Final cross-reference round, covering the remaining files where wire
bytes have a user- or installer-facing counterpart:
v1/messages.py
New Cross-references block: SETUP ZONES + SETUP TEMPERATURES for the
fields the parsers' raw bytes ultimately come from, and APPENDIX C
for what each synthesized index means on hardware (unit 257+ =
expansion-enclosure outputs, 393+ = panel flags).
models.ZoneStatus
Status-byte bit-layout doc now also points at the Owner's Manual
CONTROL chapter's "View Zone Status" keypad screen -- same Secure /
Not Ready / Trouble / Tamper labels.
models.UnitStatus
State-byte semantics doc references the Owner's Manual CONTROL
chapter for the user-side actions (All On/All Off/Scene/Bright/Dim)
that drive units into each of these states.
mock_panel.py
Notes that the mock's plausible-but-arbitrary RequestProperties /
RequestStatus responses correspond on real hardware to what an
installer typed into INSTALLER SETUP. Production fixtures should
pre-seed MockPanel state to match a known SETUP configuration.
uv.lock
Catches up the project's own entry to omni-pca 2026.5.11 (was
pinned to 2026.5.10 from the previous lock generation).
No code changes; 387 tests still pass.
Follow-up to 0d6465d, sprinkling pca-re/docs/manuals/ citations into
three more files that map to user-visible or installer-visible panel
behavior:
models.HvacMode / FanMode / HoldMode
Docstrings now explain which values correspond to keypad menu picks
vs. programmatic-only states, and point at the Owner's Manual
*Scene Commands* chapter where each menu is laid out.
pca_file.py
Module docstring adds Cross-references to the Installation Manual's
*INSTALLER SETUP* chapter (SETUP CONTROL/ZONES/AREAS/MISC/EXPANSION)
-- those are the keypad screens that produce the very SetupData /
Names / Programs blocks the parser walks. Also points at APPENDIX C
(zone and unit mapping) for where the _CAP_OMNI_PRO_II numbers come
from on the panel side.
v1/connection.py
Module docstring adds cross-references to the docs-site pages that
explain (a) the non-public handshake quirks the v1 connection relies
on for crypto and (b) why subsequent RequestUnitStatus calls need
the long-form BE u16 payload (Appendix C zone/unit mapping again).
No code changes, doc-only; 387 tests still pass.
Sprinkle pca-re/docs/manuals/ citations into the four files that map
hardest to user-visible panel behavior, so a reader chasing "why is
this byte 0x03 here" lands on the right manual chapter directly from
the source.
models.SecurityMode
Per-value comments summarising what each arming mode means at the
keypad (entry/exit delays, which zones it arms, when to use it).
Points at the Owner's Manual SECURITY_SYSTEM_OPERATION chapter where
these semantics are spelled out for end users.
models.ZoneType
Class docstring now points at the Installation Manual SETUP ZONES
table where each numeric byte value is named -- the byte values and
short names we chose match that table one-for-one, so a reader can
cross-walk the v1 ZoneStatus byte to "PERIMETER" / "AWAY INT" / etc.
by row.
events.py
Module docstring adds Cross-references to APPENDIX A (Contact ID
reporting format) and APPENDIX B (digital communicator code sheet)
in the Installation Manual -- the central-station codes a panel
transmits for each AlarmKind correspond directly to those tables.
commands.py
Module docstring points at the Owner's Manual CONTROL, Scene Commands,
and SECURITY SYSTEM OPERATION chapters so the reader can tie each
enuUnitCommand byte to the user-facing keypad path that triggers it.
No code changes; all 387 tests still pass.
First PyPI release of the v1 wire path. Wheel published from local
source 2026-05-11 with omni_pca/v1/ subpackage included.
What's in 2026.5.11 vs 2026.5.10 (already on PyPI):
* New omni_pca.v1 subpackage -- OmniConnectionV1, OmniClientV1,
OmniClientV1Adapter -- for panels that listen on UDP only and
speak the legacy OmniLink (not OmniLink2) wire dialect.
* HA integration wires the adapter into the coordinator when
Transport=UDP is selected at config-flow time; v2/TCP path is
unchanged.
* Streaming UploadNames discovery (bare opcode + lock-step
Acknowledge until EOD/NAK).
* Long-form RequestUnitStatus for unit indices > 255 (sprinklers,
named flags, expansion-enclosure outputs).
* Chunked status polls -- firmware 2.12 NAKs at ~63 records per
request, so we batch in groups of 40.
* OmniConnection.close() now sends ClientSessionTerminated so the
panel frees our session slot immediately on disconnect.
Verified end-to-end against a firmware 2.12 OmniPro II panel at
192.168.1.9: discovery (16 zones, 44 units, 16 buttons, 8 codes,
2 thermostats, 8 messages) + status polling + execute_command
round-trip all working under HA, side-by-side with the existing
TCP mock-panel path in the dev stack.
README: new "Two wire dialects" section explaining when to pick
TCP/OmniClient vs UDP/OmniClientV1.
manifest.json: requirements bump to omni-pca==2026.5.11.
After HA finishes its first-run wizard the /api/onboarding endpoint
returns 404 plain-text instead of a JSON step list. The previous
screenshot run blew up trying to json-parse "404: Not Found".
Both call sites (_onboard and _complete_onboarding) now check the
status code first and treat anything non-200 as "already complete --
skip and go to the login path".
Adds the homeassistant service to the external caddy network with
labels for juliet.warehack.ing so caddy-docker-proxy issues a public
cert and proxies traffic to port 8123. Uses the same streaming-
friendly transport tuning the docs-site service uses, because HA's
frontend keeps long-lived WebSockets open for lovelace state pushes
and config flows -- without stream_timeout: 24h etc., caddy closes
the socket every ~15s and the UI churns reconnects.
Keeps the 8123 host-port mapping intact for direct localhost dev
access; public traffic flows over the caddy bridge.
dev/ha-config/configuration.yaml (not tracked here -- root-owned in
the HA container) was updated separately to add:
http:
use_x_forwarded_for: true
trusted_proxies:
- 10.10.16.0/20 # caddy bridge subnet
Without that block HA rejects the OAuth redirect_uri at login because
the auth check sees the internal docker IP instead of the public host.
Replace the brittle bind-mount-over-site-packages trick with a proper
``pip install --no-deps /opt/omni-pca-src`` in the HA container's
entrypoint. This gives HA a real ``omni_pca-2026.5.10.dist-info`` so
the manifest's requirement check passes, plus the v1 subpackage that's
not in the published wheel yet (omni-pca==2026.5.10 isn't on PyPI).
Before: ``--force-recreate`` broke the dev stack because the bind mount
overlaid the package contents but left no dist-info, and HA's uv-based
installer can't fetch omni-pca from PyPI.
After: container recreate just works. ``docker compose restart
homeassistant`` re-installs from the latest local source on every
start, so HA + library are always in sync with the working tree.
Header comments updated to mention the real-panel (UDP/v1) config-flow
fields alongside the existing mock-panel ones.
OmniClientV1Adapter (src/omni_pca/v1/adapter.py)
V2-shape facade over OmniClientV1. Exposes the OmniClient surface the
HA coordinator was written against — get_system_information,
list_*_names, get_object_properties (synthesized from streamed names),
get_extended_status (chunked, routed to v1 typed status opcodes),
get_object_status(AREA, ...) (derived from SystemStatus.area_alarms),
events() (EventStream on v1 SystemEvents opcode 35), plus all the
write-method shims.
Chunks unit/zone/thermostat/aux polls per-type because firmware 2.12
NAKs Request*Status with >~62 records in one shot (verified live).
Falls back to "Area 1".."Area 8" when the UploadNames stream returns
zero areas — common on panels where the installer didn't name them.
custom_components/omni_pca/coordinator.py
_ensure_connected picks OmniClientV1Adapter for transport=udp. New
_walk_properties_v1 replaces the v2 RequestProperties walk with a
name-stream + synthesized-Properties pass.
custom_components/omni_pca/config_flow.py
_probe routes to OmniClientV1Adapter for transport=udp instead of
trying to drive v2 OmniClient over UDP (which silently dropped after
handshake, per the earlier diagnosis).
src/omni_pca/events.py
parse_events / _ensure_system_events / EventStream now take an
expected_opcode arg (default v2 SystemEvents=55, v1 callers pass 35).
Word format is byte-identical between v1 and v2, so the typed-event
decoder is unchanged.
src/omni_pca/v1/client.py
_range_status supports the long-form RequestUnitStatus (BE u16
start/end) so panels with unit indices > 255 (sprinklers, flags) work.
Verified end-to-end against firmware 2.12 panel at 192.168.1.9:
config entries:
state=loaded Omni Pro II (host.docker.internal) (mock)
state=loaded Omni Pro II (192.168.1.9) (real, v1+UDP)
real-panel entities created in HA: 96 (30 binary_sensor, 26 light,
15 switch, 13 button, 9 sensor, 3 climate)
cross-check: light.omni_pro_ii_front_porch_2 = on (matches live
probe: unit #2 'FRONT PORCH' state=0x01 brightness=100)
dev/probe_v1_coordinator.py
Coordinator-shaped end-to-end smoke test against the real panel
without HA — drives the full discovery + poll cycle through the
adapter. Useful for regression-checking the v1 wire path.
dev/add_real_panel.py
Programmatically adds the real-panel config entry to the dev HA
stack via the REST config-flow endpoints. Idempotent.
Some Omni network modules are configured for UDP, in which case PC Access
falls back to the v1 wire protocol (OmniLinkMessage outer = 0x10, inner
StartChar 0x5A, typed Request*Status opcodes) instead of v2's TCP path
(OmniLink2Message + StartChar 0x21 + parameterised RequestProperties).
This adds a parallel implementation rather than overloading the v2 path.
omni_pca/v1/
connection.py UDP-only OmniConnectionV1; reuses crypto + handshake,
routes post-handshake messages through OmniLinkMessage
(0x10) wrapping v1 inner format. Adds iter_streaming
for the lock-step UploadNames/Acknowledge/EOD pattern.
messages.py Block parsers for the typed v1 status replies (zone,
unit, thermostat, aux), v1 SystemStatus, and NameData
(handles both one-byte and two-byte NameNumber forms).
client.py OmniClientV1: read API (get_system_information,
get_*_status), discovery (iter_names + list_*_names),
write API (execute_command, execute_security_command,
turn_unit_*, set_unit_level, bypass/restore_zone,
execute_button, set_thermostat_*). acknowledge_alerts
is a no-op (v1 has no equivalent opcode).
Discovery uses bare UploadNames; panel streams every defined name across
all types in a fixed order with per-record Acknowledge. Verified against
firmware 2.12 — pulled 16 zones, 44 units, 16 buttons, 8 codes,
2 thermostats, 8 messages in one stream.
src/omni_pca/message.py
Fix flipped START_CHAR_V1_* constants. enuOmniLinkMessageFormat says
Addressable=0x41 and NonAddressable=0x5A; our names had them swapped.
Wire bytes were unchanged, so existing tests kept passing — but
encode_v1() with no serial_address now correctly emits 0x5A, which
is what UDP needs.
tests/
test_v1_messages.py 22 cases; payloads are real wire captures
from a firmware-2.12 panel via probe_v1_recon.
test_v1_client_commands.py 20 cases; payload-packing for the Command
and ExecuteSecurityCommand opcodes,
including BE u16 parameter2 and the
digit-by-digit security code form.
dev/
probe_v1.py Phase-1 smoke: handshake + RequestSystemInformation.
probe_v1_recon.py Raw opcode dump for protocol reconnaissance.
probe_v1_stream.py Streaming UploadNames flow exploration.
probe_v1_client.py Full read-path smoke test via OmniClientV1.
probe_v1_write.py Live no-op execute_command round-trip.
.gitignore: ignore dev/.omni_key (probe scripts read controller key from
this file as one fallback option).
Discovery on firmware 2.12: Request*ExtendedStatus opcodes (63/65/69)
NAK on this firmware — only the basic Request*Status opcodes are
implemented, so OmniClientV1 uses those (3 bytes/unit, 7 bytes/tstat,
4 bytes/aux records). HA still gets enough signal for polling; full
properties discovery uses streaming UploadNames instead.
Test totals: 387 passed, 1 skipped (existing fixture skip).
Found via live testing against the user's Omni Pro II (firmware 2.12)
on UDP. Without this, the panel holds the session slot (it's
single-client by design) and rejects new sessions from us with
ControllerCannotStartNewSession (packet type 0x07) until its idle
timeout fires (60s+ in our testing).
src/omni_pca/connection.py:
close() now sends Packet(type=ClientSessionTerminated) before
tearing down the socket, but only if we got past CONNECTING (no
point sending termination if we never had a session). For TCP we
drain the writer briefly so the byte hits the wire before FIN.
For UDP the sendto is fire-and-forget. Wrapped in try/except so
close() stays idempotent.
Live-validation findings (real Omni Pro II, firmware 2.12):
✓ UDP handshake works end-to-end. The four-packet exchange
(NewSession ack / SecureSession ack) round-trips cleanly,
confirming the two non-public protocol quirks (session key
XOR mix + per-block whitening) are correctly implemented.
✗ Post-handshake encrypted messages get no reply from this
firmware (tried v1 OmniLinkMessage and v2 OmniLink2Message;
both arrive at the panel — verified via tcpdump — and the
panel sends nothing back). Suspected: firmware 2.12 either
requires an explicit Login first or has a different opcode
set than PC Access 3.17 documents. Needs more RE work.
The handshake-quirks validation is the headline win — we've now
proven that part of the protocol against real hardware, which no
public Omni-Link client has done. Post-handshake message dispatch
is the next investigation.
357 tests still pass.
custom_components/omni_pca/const.py:
+ CONF_TRANSPORT, TRANSPORT_TCP, TRANSPORT_UDP, DEFAULT_TRANSPORT='tcp'
custom_components/omni_pca/config_flow.py:
+ 'transport' field in _USER_SCHEMA with vol.In([tcp, udp]),
default tcp (so existing flows are unchanged)
+ transport stored in entry.data on create
+ reauth carries the existing transport over from entry.data
+ _probe() takes transport=, propagates to OmniClient
custom_components/omni_pca/coordinator.py:
+ transport= constructor arg, defaults to 'tcp'
+ _ensure_connected passes transport= through to OmniClient
custom_components/omni_pca/__init__.py:
+ reads transport from entry.data (default tcp), passes to coordinator
Backward-compat: existing config entries without a transport key fall
through to 'tcp', identical to current behavior. New entries get the
choice at the config-flow form. The reauth step preserves the existing
transport so users don't have to re-pick it.
357 tests pass; ruff clean across src/ tests/ custom_components/.
HA integration tests don't need updating because they don't pass
transport= explicitly (default tcp matches the mock's default).
The C# decompile shows enuOmniLinkConnectionType has both Network_TCP=4
and Network_UDP=3 (clsOmniLinkConnection.cs uses udpSend/tcpSend
parallel paths), and clsHAC carries an enuPreferredNetworkProtocol
{TCP, UDP} per-installation byte. User reports their panel is
configured for UDP. The TCP-only assumption was too narrow.
Wire format is identical: same Packet/Message framing, same handshake,
same per-block whitening, same opcodes, same port. Only differences:
* UDP is connectionless; each datagram = one Packet (no stream framing)
* UDP needs explicit retry-on-timeout for reliability
src/omni_pca/connection.py:
- New constructor args: transport: Literal['tcp','udp']='tcp',
udp_retry_count: int = 3
- connect()/close() branch on transport — TCP keeps the existing
asyncio.open_connection + StreamReader/Writer + reader_task path;
UDP uses asyncio.get_running_loop().create_datagram_endpoint with
remote_addr= so transport.sendto(data) works without per-datagram
addrs. The reader_task is TCP-only.
- _write_packet branches between writer.write and udp_transport.sendto
- request() loops up to (1 + udp_retry_count) attempts on UDP, retrying
on RequestTimeoutError; TCP gets a single attempt (existing behavior)
- New _OmniDatagramProtocol that decodes each datagram into a Packet
and delegates to the shared _dispatch (which already knows how to
route handshake / solicited / unsolicited)
src/omni_pca/mock_panel.py:
- serve(transport='tcp'|'udp') public arg; defaults preserve existing
TCP behavior. Internally splits into _serve_tcp / _serve_udp.
- New _MockServerDatagramProtocol that mirrors _handle_client for UDP.
Tracks one active client by addr (single-session, matches Omni's
single-client constraint). Reuses the panel's existing _dispatch_v2,
_reply_*, _build_* helpers — the dispatch logic is unchanged, only
the transport framing differs.
- New _schedule_udp_push for synthesized SystemEvents (seq=0) push
to the active client's addr after state mutations.
src/omni_pca/client.py:
- OmniClient gains transport= and udp_retry_count= kwargs that pass
through to OmniConnection. Default is 'tcp' so existing callers
are unaffected.
tests/test_e2e_udp.py — 6 e2e tests:
- handshake roundtrip
- get_system_information
- arm area with right code
- arm with wrong code -> CommandFailedError
- turn unit on -> push UnitStateChanged event
- wrong ControllerKey -> HandshakeError
All run under 0.2s. Combined with the existing TCP suite: 357 tests
pass (was 351), ruff clean across src/ tests/.
The HA integration's config_flow still defaults to TCP; users on UDP
panels can manually set transport= via the OmniClient init path. A
follow-up commit will add transport to the HA config flow as a
dropdown option.
- Move install instructions from PyPI-only to Gitea-release-first
(pip from git+https or direct wheel URL); note PyPI as 'pending'
- Add Project home + Documentation links at top
- Fix quick-start API name (get_system_info -> get_system_information,
matching the actual library)
- Replace HA quick-start with the manual-clone path that works today
(HACS support pending PyPI publish)
- Cross-link tutorials (first-script, dev-stack) and how-tos
(install-in-home-assistant) from hai-omni-pro-ii.warehack.ing
- Add Tests section showing how to run the suite
- Add License + JOURNEY link in acknowledgements
Project moved to a self-hosted Gitea at git.supported.systems under the
warehack.ing org. Updated:
pyproject.toml project.urls.Repository
custom_components/omni_pca/manifest.json documentation, issue_tracker
custom_components/omni_pca/README.md every link
CHANGELOG.md release tag URL
Tests still 351 + 1 skip. No code changed.
docs/JOURNEY.md — replaced the placeholder 'What's next' section with
seven new chronological entries covering everything that happened after
the panel-search comedy:
- HA rebuild Phase A: poll-vs-push decision, pure-function helpers
extraction, 61 unit tests with no HA imports
- HA Phase B: the six new entity platforms, the Omni state-byte
overload, security-mode-to-alarm-state mapping, the scene-platform
skip decision
- HA Phase C: services + diagnostics + repairs flow
- 'wait, did we mock enough?' — catching the missing Thermostat
(6) and Button (3) RequestProperties handlers BEFORE the HA
harness ever touched the mock
- HA test harness rough patches: requires-python conflict, pytest_socket
fight, the CONF_ENTRY_ID-doesn't-exist-in-HA find, teardown hang
fixed by converting configured_panel into a generator
- Docker dev stack: mounting only src/ to dodge the read-only-venv
problem with uv
- Automated onboarding + screenshots: the auth_code OAuth dance, the
template-endpoint device-id trick, playwright auto-injection of
hassTokens, the discovery-during-onboarding nice surprise
Plus appended five new entries to 'Things worth remembering':
- Pure functions are the cheapest thing in test suites
- Mocking the entire protocol counterpart catches whole categories
- pytest_socket + real network can coexist
- The 'build without a real device' loop is unreasonably effective
- (existing entries kept verbatim)
Final length: ~6800 words, 27 dated sections plus the lessons list.
CHANGELOG.md — new file. Single 2026.5.10 entry under Keep-a-Changelog-
ish format, broken into seven sections matching the project layers:
Protocol layer (RE findings), Library, Home Assistant integration,
Tests, Developer tooling, Documentation, Known gaps. Cites the source
line numbers for the two non-public protocol quirks. Lists every
public module + every entity platform. Linked to git tag template at
the bottom (release not pushed yet).
Tests still 351 + 1 skip. No code changed.
dev/screenshot.py — end-to-end automated demo:
* onboards HA via /api/onboarding (user creation + auth_code flow)
* subsequent runs log in via /auth/login_flow with saved credentials
* adds the omni_pca config entry via /api/config/config_entries/flow
* uses HA's template REST endpoint to discover the panel device_id
* launches a headless chromium via playwright with prefetched auth tokens
* captures 6 deep-linked screenshots:
01-overview.png — Lovelace
02-integrations-list.png — HAI/Leviton sitting next to HA's built-ins
03-omni-pca-config.png — '1 device · 38 entities', custom integration
04-panel-device.png — Omni Pro II device page with full controls
05-entities-omni.png — config_entry filtered entities table
06-developer-states.png — alarm_control_panel.omni_pro_ii_main with
raw_mode_name=OFF, code_arm_required=true,
etc. proving real entity state from mock
dev/docker-compose.yml — mock-panel command rewritten:
* Mounts only src/ and run_mock_panel.py (read-only) instead of the full
project so uv doesn't try to recreate the host's .venv on a RO mount
* Installs cryptography via uv pip install --system
* PYTHONPATH set to /tmp/mock/src so omni_pca imports work without a
package install
dev/artifacts/screenshots/2026-05-10/ — six PNGs from the run.
.gitignore — adds dist/ for build artifacts.
Confirmed end-to-end: HA discovered the integration via mDNS hint
(showed up in the onboarding wizard's compatible-devices step), the
config flow connected to the mock over host.docker.internal:14369,
materialized 38 entities across 8 platforms (alarm_control_panel,
binary_sensor, button, climate, event, light, sensor, switch), and
displayed everything in the device + entity registries with friendly
names and attributes intact. The integration name hash is 38 entities
because the mock seeds 5 zones (binary + bypass) + 4 units + 2 areas +
2 thermostats + 3 buttons + 3 system-level binary sensors + 2 system
sensors + 6 thermostat sensors + 1 event entity = 38 (matches HA UI).
Pytest harness (in-process HA + MockPanel)
==========================================
pyproject.toml — bumps requires-python to 3.14.2 to align with HA 2026.5.x
which is what pytest-homeassistant-custom-component pins. Dev group 'ha'
pulls the harness; .python-version updated to 3.14.
src/omni_pca/mock_panel.py — Thermostat (6) and Button (3) RequestProperties
handlers added (previous commit). Without these the HA coordinator's
discovery walk produced empty thermostat/button dicts.
custom_components/omni_pca/services.py — fix CONF_ENTRY_ID import: HA
exports it as ATTR_CONFIG_ENTRY_ID, not CONF_ENTRY_ID. Aliased on import.
tests/conftest.py — re-enables sockets globally (the HA harness installs
pytest_socket which otherwise blocks our network e2e tests).
tests/ha_integration/ — new directory with full HA boot harness:
conftest.py:
- autouse enable_custom_integrations so HA loads our component
- autouse expected_lingering_tasks=True (background event listener)
- autouse _short_scan_interval (1s instead of 30s for fast tests)
- panel fixture: MockPanel on a random localhost port for each test
- configured_panel fixture: builds a MockConfigEntry, runs setup,
yields, then unloads on teardown so the coordinator's reader task
and OmniClient socket close cleanly (otherwise verify_cleanup hangs)
test_setup.py — 12 tests:
- integration loads + system_info populated
- alarm_control_panel/light/switch/climate/button/event/binary_sensor
entities materialise per platform
- unload_entry tears down cleanly
- turning a light on via HA service updates the mock state
- arming via HA service with the right code transitions the area
- arming with wrong code keeps the area disarmed and surfaces error
Total: 351 passed, 1 skipped (PCA fixture). Ruff clean across src/ tests/
custom_components/. The 12 HA integration tests run in <1s end-to-end —
they boot HA in-process, drive the config flow, exercise services, and
verify state mutations on the mock side.
Docker dev stack (manual smoke / screenshots)
=============================================
dev/docker-compose.yml — HA 2026.5 container + MockPanel sidecar.
dev/run_mock_panel.py — long-running mock with a populated state
(5 zones, 4 units, 2 areas, 2 thermostats, 3 buttons, codes 1234/5678).
dev/Makefile — make dev-up / dev-logs / dev-down / dev-mock / dev-reset.
dev/README.md — onboarding walkthrough (host=host.docker.internal,
port=14369, controller_key=000102030405060708090a0b0c0d0e0f).
.gitignore — adds ha-config/ so the persisted HA state from the dev
stack doesn't get committed.
The HA coordinator walks ObjectType.THERMOSTAT (6) and ObjectType.BUTTON
(3) via raw RequestProperties to discover them — the high-level
get_object_properties() path only knows zones/units/areas in v1.0. The
mock was returning Nak for both, which made HA discover zero thermostats
and zero buttons no matter how MockState was seeded.
src/omni_pca/mock_panel.py:
- New MockButtonState dataclass (just a name)
- MockState gains buttons: dict[int, MockButtonState] (with the same
bare-string -> dataclass __post_init__ promotion as the others)
- _OBJ_BUTTON = 3, _BUTTON_NAME_LEN = 12, _THERMOSTAT_NAME_LEN = 12
constants
- thermostat_name_bytes() / button_name_bytes() helpers
- _build_thermostat_properties() emits the 23-byte Properties body
matching ThermostatProperties.parse offsets (object number BE u16,
communicating flag, current temp, heat/cool setpoints, system/fan/
hold modes, thermostat type, 12-byte NUL-padded name)
- _build_button_properties() emits the 15-byte body (object number BE
u16 + 12-byte name)
- _reply_properties / _object_store dispatch both new types
tests/test_e2e_client_mock.py — two new e2e tests drive raw
RequestProperties walks for thermostats and buttons against a seeded
mock and assert ThermostatProperties / ButtonProperties parse cleanly,
mirroring what the HA coordinator's _walk_properties() does.
333 tests pass (was 331); ruff clean. Mock surface now matches every
opcode the HA coordinator and entity platforms actually call.
custom_components/omni_pca/services.yaml — declares 7 services with
config_entry selectors so HA's UI gives users a panel picker:
bypass_zone, restore_zone, execute_program, show_message,
clear_message, acknowledge_alerts, send_command (raw escape hatch)
custom_components/omni_pca/services.py — async handlers wired via
async_setup_services on entry setup; idempotent across multiple entries.
Each handler validates entry_id, looks up the right coordinator, calls
the matching OmniClient method. CommandFailedError wrapped to
HomeAssistantError; unknown Command codes raise ServiceValidationError.
async_unload_services removes them when the last entry unloads.
custom_components/omni_pca/diagnostics.py — async_get_config_entry_
diagnostics dumps a redacted snapshot for bug reports: panel model +
firmware, discovered/live counts per object type, sha256-hashed zone/
unit/area names (so uniqueness is visible without leaking PII), last
event class, controller key REDACTED via async_redact_data.
custom_components/omni_pca/__init__.py — wires async_setup_services on
entry setup and async_unload_services on the last entry unload.
custom_components/omni_pca/README.md — full entity table, service list,
example automation, troubleshooting section, link to JOURNEY.md.
Top-level README — entity rundown updated to reflect the full v1.0
surface (was: 'binary_sensor for zones').
331 tests still pass; ruff clean across src/ tests/ custom_components/.
hacs.json already in place from initial scaffold.
custom_components/omni_pca/ — six new platform modules wrapping the
v1.0 client surface. Every command method catches CommandFailedError
and re-raises HomeAssistantError so panel rejections (bad code, etc.)
become user-friendly HA errors instead of silent failures.
alarm_control_panel.py — OmniAreaAlarmPanel per discovered area.
Supports ARM_HOME (Day) / ARM_NIGHT / ARM_AWAY / ARM_VACATION /
ARM_CUSTOM_BYPASS (Day-Instant). State derives from area_status via
pure helpers.security_mode_to_alarm_state which handles arming-in-
progress, entry/exit timers, and active-alarm overrides.
light.py — OmniUnitLight per discovered unit (every unit; non-dimmable
units silently ignore brightness, no harm done). Brightness conversion
via helpers.omni_state_to_ha_brightness / ha_brightness_to_omni_percent
(Omni state byte: 0=off, 1=on, 100..200=brightness percent).
switch.py — OmniZoneBypassSwitch per binary zone. CONFIG entity_category;
pairs with the existing diagnostic 'zone bypassed' binary_sensor.
climate.py — OmniThermostatClimate per discovered thermostat.
Supports OFF / HEAT / COOL / HEAT_COOL hvac_modes; auto / on / diffuse
fan_modes; none / hold / vacation preset_modes. Single-setpoint and
range setpoint via TARGET_TEMPERATURE_RANGE. Fahrenheit native (Omni
panels are F-native; HA handles unit conversion downstream).
sensor.py — analog zones (temperature/humidity/power) + per-thermostat
diagnostic temp/humidity/outdoor sensors + OmniSystemModelSensor
+ OmniLastEventSensor (event_class + parsed event fields as attrs).
button.py — OmniPanelButton per discovered button macro. Programs not
yet exposed because the library lacks RequestProperties for Programs.
event.py — single OmniPanelEvent per panel relaying typed SystemEvents
via _trigger_event. event_types: zone_state_changed, unit_state_changed,
arming_changed, alarm_activated/cleared, ac_lost/restored,
battery_low/restored, user_macro_button, phone_line_dead/restored.
Automations key off platform: event + event_type filter.
helpers.py — extended with security_mode_to_alarm_state,
ARM_SERVICE_TO_SECURITY_MODE, omni_state_to_ha_brightness +
ha_brightness_to_omni_percent, omni/ha_{hvac,fan,hold} round-trips,
fahrenheit_to_omni_raw / celsius_to_omni_raw, analog_zone_device_class,
EVENT_TYPES tuple, event_type_for(class_name).
__init__.py — PLATFORMS extended to all 8 entity types.
scene.py intentionally NOT created — Omni 'scenes' are user-defined
button macros, already covered by the button platform. Documented in
README; revisit if/when the library gains scene-discovery opcodes.
tests/test_ha_helpers.py: +67 unit tests covering every new helper.
331 tests pass (was 264). Ruff clean across src/ tests/ custom_components/.
custom_components/omni_pca/coordinator.py — full rewrite:
- Long-lived OmniClient for entry lifetime
- One-shot discovery: system info + zone/unit/area/thermostat/button names
via list_*_names + per-index get_object_properties
- Periodic poll (30s default): get_extended_status for zones/units/thermostats,
get_object_status for areas, skip empty discoveries
- Background _run_event_listener task consuming client.events(), patches
state in-place and async_set_updated_data on push:
ZoneStateChanged -> patch zone_status raw byte
UnitStateChanged -> patch unit_status state, preserve brightness
ArmingChanged -> patch area_status mode + last_user
AlarmActivated/Cleared -> trigger refresh
AcLost/Restored, BatteryLow/Restored -> recorded for sensors
- InvalidEncryptionKeyError/HandshakeError -> ConfigEntryAuthFailed (HA reauth)
- OmniConnectionError/RequestTimeoutError -> UpdateFailed + drop client
- Event task cancelled in async_shutdown
custom_components/omni_pca/binary_sensor.py — full rewrite:
- OmniZoneBinarySensor per discovered zone (device class from zone type:
smoke/water/freeze use latched-alarm bit; doors/motion use current condition)
- OmniZoneBypassedBinarySensor per zone (DIAGNOSTIC, PROBLEM)
- OmniSystemAcBinarySensor (POWER, prefers AcLost/AcRestored push)
- OmniSystemBatteryBinarySensor (BATTERY)
- OmniSystemTroubleBinarySensor (PROBLEM)
custom_components/omni_pca/helpers.py — pure functions extracted for testing:
- device_class_for_zone_type, is_binary_zone_type, use_latched_alarm_for_zone,
prettify_name. 61 unit tests in tests/test_ha_helpers.py.
docs/JOURNEY.md — 4383-word raw chronological retrospective of the whole
arc from binary archive to working library. 18 dated sections including
the 2191-byte magic-number header validation moment, the two non-public
protocol quirks, the offline-panel comedy. Source material for future
writeups (intentionally raw, not polished).
264 tests pass (was 203, +61 helper tests). Ruff clean across all dirs.
src/omni_pca/client.py — wire OmniClient.events() that returns an async
iterator over typed SystemEvent objects (built on events.EventStream).
src/omni_pca/mock_panel.py — substantial expansion:
- Per-object state dataclasses (MockUnitState, MockAreaState, MockZoneState,
MockThermostatState) plus user_codes table for security validation
- Backward-compat: existing callers passing {idx: 'NAME'} strings still work
via __post_init__ string-promotion to the matching Mock*State instance
- New opcode handlers:
Command (20) -> Ack with state mutation, dispatches
UNIT_ON/OFF/LEVEL, BYPASS/RESTORE_ZONE,
SET_THERMOSTAT_HEAT/COOL/SYS/FAN/HOLD
ExecuteSecurityCommand (74) -> Ack on valid code (mode applied);
Nak on invalid code
RequestStatus (34) -> Status (35) for Zone/Unit/Area/Thermostat
hard-coded record sizes per
clsOL2MsgStatus.cs:13-27
RequestExtendedStatus (58) -> ExtendedStatus (59) with object_length
prefix, richer fields per object type
AcknowledgeAlerts (60) -> Ack
- Synthesized SystemEvents (55) push on state change with seq=0; events round-
trip cleanly through events.parse_events() (validated by tests, not just
asserted in code)
tests/test_e2e_client_mock.py — +9 e2e tests covering arm/disarm with code
validation, unit on/off/level, zone bypass/restore, thermostat setpoint,
push events for arming and unit changes, acknowledge_alerts.
203 passed (was 194), 2 skipped (HA harness + .pca fixture). Ruff clean.
Library v1.0 surface complete: read-only, command, status, extended status,
events. Next: rebuild the HA custom_component on top of this.
custom_components/omni_pca/ — drop-in HA integration:
- manifest.json (HA 2026.x, iot_class=local_push, requires omni-pca lib)
- config_flow.py — host/port/controller_key with auth + reauth steps,
parse_controller_key() extracted as pure testable function
- coordinator.py — OmniDataUpdateCoordinator with long-lived OmniClient,
unsolicited push wiring, ConfigEntryAuthFailed on bad key, reconnect on err
- binary_sensor.py — one entity per named zone, zone_type -> device_class map
(OPENING/MOTION/SMOKE/etc), is_on derived from ZoneProperties.status
- const.py, strings.json, translations/en.json, README.md
- hacs.json at root for HACS distribution
tests: 97 pass + 2 skip (HA harness not installed; importorskip in
test_ha_imports.py). 12 cases for parse_controller_key validation.
Ruff clean across src/ tests/ custom_components/. Status of HA component
itself NOT validated against a running HA — needs that next.