Compare commits

...

10 Commits

Author SHA1 Message Date
04b6a44403 URLs: github.com/rsp2k/omni-pca -> git.supported.systems/warehack.ing/omni-pca
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.
2026-05-10 17:47:04 -06:00
7b4052624c Docs: extend JOURNEY through the HA + harness + demo arc; add CHANGELOG
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.
2026-05-10 16:29:41 -06:00
f6a09592f1 Live demo: HA in docker discovers + drives mock panel, screenshots captured
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).
2026-05-10 16:17:33 -06:00
df8b6128ea HA test harness + docker dev stack — both proven green
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.
2026-05-10 15:37:48 -06:00
93b7e1f604 Mock: add Thermostat + Button RequestProperties handlers
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.
2026-05-10 15:09:31 -06:00
83d85a9885 HA Phase C: services + diagnostics + README polish
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.
2026-05-10 15:01:47 -06:00
57b8aa4b04 HA Phase B: alarm + light + switch + climate + sensor + button + event
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/.
2026-05-10 14:59:18 -06:00
e8ed7d1b89 HA Phase A: rebuild coordinator + binary_sensor on v1.0 client + JOURNEY.md
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.
2026-05-10 14:48:50 -06:00
c26db62959 Library v1.0 phase C: stateful mock + e2e for the new surface
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.
2026-05-10 14:28:35 -06:00
68cf44a585 Library v1.0 phase B: command opcodes + typed system events
src/omni_pca/commands.py — Command IntEnum (64 values, sourced from
enuUnitCommand.cs which is the canonical 'enuCommand' despite the misleading
name) + SecurityCommandResponse + CommandFailedError exception. Notable
discovery: enuUnitCommand.UserSetting (104) is actually EXECUTE_PROGRAM;
renamed for clarity, C# alias documented inline.

src/omni_pca/client.py — 18 new methods on OmniClient:
  Core: execute_command, execute_security_command, acknowledge_alerts,
        get_object_status, get_extended_status
  Wrappers: turn_unit_on/off, set_unit_level, bypass_zone, restore_zone,
        set_thermostat_{system,fan,hold}_mode,
        set_thermostat_{heat,cool}_setpoint_raw,
        execute_button, execute_program, show_message, clear_message
  All command methods raise CommandFailedError on Nak.

src/omni_pca/events.py — typed SystemEvents (opcode 55) decoder.
- EventType IntEnum (28 dispatch tags)
- 26 SystemEvent subclasses + UnknownEvent catch-all
  Includes: ZoneStateChanged, UnitStateChanged, ArmingChanged,
  AlarmActivated/Cleared, AcLost/Restored, BatteryLow/Restored,
  PhoneLine{Off,On,Dead,Restored}, UserMacroButton, ProLinkMessage,
  CentraLiteSwitch, X10CodeReceived, AllOnOff, DcmTrouble/Ok,
  EnergyCostChanged, CameraTrigger, AccessReaderEvent, UpbLinkEvent
- SystemEvents packets carry MULTIPLE events; public API is
  parse_events(message) -> list[SystemEvent], plus SystemEvent.parse()
- EventStream helper that flattens batches across messages
- Wiring of OmniClient.events() left for next pass

55 new tests across both files. 194 pass, 2 pre-existing skips. Ruff clean.
2026-05-10 14:17:12 -06:00
52 changed files with 11035 additions and 443 deletions

2
.gitignore vendored
View File

@ -39,3 +39,5 @@ panel_key*
# Wine artifacts (if used for testing)
.wine-pca/
ha-config/
dist/

View File

@ -1 +1 @@
3.12
3.14

85
CHANGELOG.md Normal file
View File

@ -0,0 +1,85 @@
# Changelog
All notable changes to this project. Date-based versioning ([CalVer](https://calver.org/), `YYYY.M.D`); each release date corresponds to a backwards-incompatible boundary.
## [2026.5.10] — 2026-05-10
First release. Working library + Home Assistant custom component, validated end-to-end against an in-process mock panel and a real HA instance running in Docker. Not yet validated against a live panel because the user's panel's network module is currently off.
### Protocol layer (the reverse engineering)
- Decompiled HAI's PC Access 3.17 (.NET) with ilspycmd; identified two namespaces — `HAI_Shared` (protocol/crypto/domain) and `PCAccess3` (UI). Decompilation lives in `pca-re/decompiled/`.
- Reverse-engineered the `.pca` and `PCA01.CFG` file format — Borland-Pascal LCG keystream XORed byte-by-byte. Two hardcoded keys:
- `KEY_PC01 = 0x14326573` for `PCA01.CFG`
- `KEY_EXPORT = 0x17569237` for import/export `.pca`
Per-installation `.pca` files use a third key derived from the panel's installer code; that key is stored in plaintext inside `PCA01.CFG` after first-stage decryption.
- Documented the Omni-Link II wire protocol byte-for-byte (`pca-re/notes/handshake.md`), including **two non-public quirks** absent from `jomnilinkII`, `pyomnilink`, and every public Omni-Link writeup we found:
1. **Session key = `ControllerKey[0:11] || (ControllerKey[11:16] XOR SessionID[0:5])`** — not just the panel's ControllerKey directly. Source: `clsOmniLinkConnection.cs:1886-1892`.
2. **Per-block XOR pre-whitening before AES** — first two bytes of every 16-byte block are XORed with the packet's 16-bit sequence number, same mask all blocks. Source: `clsOmniLinkConnection.cs:396-401`.
- Located a latent bug in PC Access itself: a `LargeVocabulary` skip-path uses a buffer sized for the non-LargeVocabulary case. Harmless on every shipping panel (the count check always satisfies the constraint) but documented in `pca-re/notes/body_parser.md`.
### Library — `omni_pca`
- `crypto.py` — AES-128-ECB with PaddingMode.Zeros semantics, `derive_session_key()`, per-block XOR pre-whitening, `encrypt_message_payload()`/`decrypt_message_payload()`. All citations to C# source line numbers.
- `opcodes.py` — Three IntEnums byte-exact to the C# decompilation: `PacketType` (12 values), `OmniLinkMessageType` (104 v1 opcodes), `OmniLink2MessageType` (83 v2 opcodes). Plus `ConnectionType`, `ProtocolVersion`.
- `packet.py` / `message.py` — Outer `Packet` (4-byte header + payload) and inner `Message` framing. CRC-16/MODBUS (poly `0xA001`).
- `pca_file.py` — Borland LCG XOR cipher, `PcaReader` with `u8/u16/u32/string8/string8_fixed/string16/string16_fixed`, `parse_pca01_cfg()`, `parse_pca_file()`. Account-info fields default `repr=False` to avoid accidental PII leakage in logs.
- `connection.py``OmniConnection`: async TCP, full secure-session handshake (4 packets), monotonic per-direction sequence numbers with `0xFFFF → 1` wraparound (skips 0), TCP framing that decrypts the first 16-byte block to learn the inner message length, reader task dispatching solicited replies to Futures and unsolicited messages to a queue, automatic reconnect on `OmniConnectionError`, custom exceptions (`HandshakeError`, `InvalidEncryptionKeyError`, `ProtocolError`, `RequestTimeoutError`).
- `models.py` — 21 typed frozen-slots dataclasses for every Omni object: `SystemInformation`, `SystemStatus`, `ZoneProperties/Status`, `UnitProperties/Status`, `AreaProperties/Status`, `ThermostatProperties/Status`, `ButtonProperties`, `ProgramProperties`, `CodeProperties`, `MessageProperties`, `AuxSensorStatus`, `AudioZoneProperties/Status`, `AudioSourceProperties/Status`, `UserSettingProperties/Status`. Plus `SecurityMode`, `HvacMode`, `FanMode`, `HoldMode`, `ZoneType`, `ObjectType` enums and temperature converters (Omni's linear `°F = round(raw * 9/10) - 40`).
- `commands.py``Command` IntEnum (64 values, sourced from `enuUnitCommand.cs` which is the canonical command enum despite the misleading name), `SecurityCommandResponse`, `CommandFailedError`.
- `client.py` — High-level `OmniClient` with 18 methods: `get_system_information`, `get_system_status`, `get_object_properties`, `list_*_names`, `execute_security_command`, `execute_command`, `get_object_status`, `get_extended_status`, `acknowledge_alerts`, typed wrappers (`turn_unit_on/off`, `set_unit_level`, `bypass_zone/restore_zone`, `set_thermostat_{system,fan,hold}_mode`, `set_thermostat_{heat,cool}_setpoint_raw`, `execute_button`, `execute_program`, `show_message`, `clear_message`), `events()` async iterator over typed `SystemEvent` objects.
- `events.py``SystemEvent` hierarchy. 26 typed subclasses (`ZoneStateChanged`, `UnitStateChanged`, `ArmingChanged`, `AlarmActivated/Cleared`, `AcLost/Restored`, `BatteryLow/Restored`, `UserMacroButton`, `PhoneLineDead/Restored`, …) + `UnknownEvent` catch-all. SystemEvents (opcode 55) packets carry multiple events; `parse_events()` returns a list. `EventStream` flattens batches across messages.
- `mock_panel.py` — Stateful async TCP server emulating an Omni Pro II controller. Handles handshake, `RequestSystemInformation/Status`, `RequestProperties` for Zone/Unit/Area/Thermostat/Button, `RequestStatus`/`RequestExtendedStatus`, `Command`, `ExecuteSecurityCommand`, `AcknowledgeAlerts`. State changes push synthesized `SystemEvents` packets back to the client.
- `__main__.py` — CLI: `omni-pca decode-pca <file> [--field controller_key|host|port] [--include-pii]`, `omni-pca mock-panel`, `omni-pca version`. PII opt-in.
### Home Assistant integration — `custom_components/omni_pca/`
- `coordinator.py``OmniDataUpdateCoordinator` with long-lived `OmniClient`, one-time discovery pass at first refresh (enumerates zones, units, areas, thermostats, buttons), periodic 30s poll for live state, background event-listener task consuming `client.events()` and patching state in-place on each push. `ConfigEntryAuthFailed` on `InvalidEncryptionKeyError` triggers HA's reauth flow.
- Eight platforms wrapping the library client:
- `alarm_control_panel` — one per area, supports Day/Night/Away/Vacation/DayInstant arm modes with code validation
- `binary_sensor` — one per binary zone (state + bypass diagnostic) plus 3 system-level (AC, battery, trouble)
- `button` — one per panel button macro
- `climate` — one per thermostat (OFF/HEAT/COOL/HEAT_COOL + fan + preset modes)
- `event` — one per panel, relays 12 typed event types to HA automations
- `light` — one per unit (dimmable; non-dimmable relays silently ignore brightness)
- `sensor` — analog zones (temperature/humidity/power), per-thermostat diagnostic temp/humidity/outdoor sensors, panel model+firmware sensor, last-event sensor
- `switch` — per-zone bypass control (config entity_category)
- `config_flow.py` — User + reauth steps. Host/port/controller_key with hex validation. Probes the panel via `OmniClient.get_system_information()` before creating the entry; surfaces auth/cannot_connect errors with HA-friendly strings.
- `services.yaml` + `services.py` — 7 services (`bypass_zone`, `restore_zone`, `execute_program`, `show_message`, `clear_message`, `acknowledge_alerts`, `send_command`). Idempotent registration; each takes a `config_entry` selector so users pick the panel.
- `diagnostics.py` — Snapshot dump with controller key redacted and zone/unit/area names sha256-hashed.
- `helpers.py` — Pure functions for everything HA-shape-dependent: zone-type→device-class, brightness conversion, HVAC mode round-trip, temperature inverse, alarm state translation, event-type strings. No `homeassistant.*` imports; 61 unit tests covering it.
- `manifest.json``iot_class: local_push`, `version: 2026.5.10`, `config_flow: true`, requires `omni-pca==2026.5.10`.
- `hacs.json` at project root for HACS distribution.
### Tests
- **351 passing, 1 skipped.** Ruff clean across `src/`, `tests/`, `custom_components/`.
- 17 e2e tests connecting `OmniClient` to `MockPanel` over real TCP, proving the full handshake + encryption + framing stack roundtrips.
- 12 HA-side integration tests using `pytest-homeassistant-custom-component` — boot HA in-process, drive the config flow, exercise services, verify state mutations. Full HA-side suite runs in <1 second.
- 61 unit tests on `custom_components/omni_pca/helpers.py` running without HA installed.
- Unit tests for every library module (crypto KAT vectors, CRC-16, packet/message ser-de, .pca decrypt, command payloads, event parsing).
### Developer tooling
- `dev/docker-compose.yml` + `dev/Makefile` — One-command HA + MockPanel stack for manual smoke testing and screenshot capture.
- `dev/run_mock_panel.py` — Long-running mock seeded with 5 zones, 4 units, 2 areas, 2 thermostats, 3 buttons, 2 user codes.
- `dev/screenshot.py` — End-to-end automated demo: onboards HA via REST, adds the integration via config-flow API, drives headless chromium via playwright to capture six deep-linked PNGs (overview, integrations list, integration detail, device page, entities table, developer states).
### Documentation
- `docs/JOURNEY.md` — 6,000+ word raw chronological narrative from "pile of binaries" through "351 tests green, screenshots captured". Source material for future writeups.
- `pca-re/notes/findings.md` — RE technical findings (cipher, file format, protocol overview).
- `pca-re/notes/handshake.md` — Byte-level handshake spec with C# source line citations.
- `pca-re/notes/body_parser.md` — .pca body schema + the LargeVocabulary latent bug.
- Top-level `README.md` — Library + HA quick start.
- `custom_components/omni_pca/README.md` — Entity table, services list, automation example, troubleshooting.
- `dev/README.md` — Docker dev stack walkthrough.
### Known gaps
- **Live panel validation**: blocked on the user's panel's Ethernet module being enabled. Mock panel proves the stack roundtrips; the live lap is one TCP connect away once the panel is reachable.
- **Programs discovery**: the library's v1.0 has no `RequestProperties` path for Program objects; the HA coordinator returns an empty programs dict. Programs can still be executed by index via the `omni_pca.execute_program` service.
- **PyPI publish**: `omni-pca` not yet on PyPI; HA `manifest.json` requirements line will only resolve once it is. For now users either install the wheel manually or pip-install from a Git URL.
- **HACS submission**: pending live-panel validation.
[2026.5.10]: https://git.supported.systems/warehack.ing/omni-pca/releases/tag/v2026.5.10

View File

@ -49,6 +49,8 @@ Get the ControllerKey from your `.pca` file using the included parser:
uvx --from omni-pca omni-pca decode-pca path/to/Your.pca --field controller_key
```
The integration creates one HA device per panel plus typed entities for every named object on the controller: `alarm_control_panel` for areas, `light` for units, `binary_sensor`/`switch` for zones (state + bypass), `climate` for thermostats, `sensor` for analog zones and panel telemetry, `button` for panel macros, and `event` for the typed push-notification stream. See [`custom_components/omni_pca/README.md`](custom_components/omni_pca/README.md) for the entity table and service list.
## Without a panel — mock controller
For testing, the library ships a minimal Omni controller emulator:

View File

@ -6,7 +6,7 @@ opens an encrypted session straight to the panel and listens for unsolicited
push messages.
This integration is the HA-facing wrapper around the
[`omni-pca`](https://github.com/rsp2k/omni-pca) Python library; the library
[`omni-pca`](https://git.supported.systems/warehack.ing/omni-pca) Python library; the library
handles the wire protocol, this component surfaces it as HA entities.
## Install
@ -14,7 +14,7 @@ handles the wire protocol, this component surfaces it as HA entities.
### HACS (recommended once published)
1. HACS → Integrations → custom repository → add
`https://github.com/rsp2k/omni-pca`, category **Integration**.
`https://git.supported.systems/warehack.ing/omni-pca`, category **Integration**.
2. Install **HAI / Leviton Omni Panel**, then restart Home Assistant.
### Manual
@ -30,8 +30,7 @@ Copy the `custom_components/omni_pca/` directory into your HA
- **Host** — IP or hostname of the panel (e.g. `192.168.1.50`)
- **Port** — defaults to `4369` (HAI's reserved port)
- **Controller Key** — 32 hex characters, the panel's NVRAM key
3. Save. The panel's model and firmware appear as a single device, with one
`binary_sensor` per defined zone.
3. Save. The panel appears as a single device with entities per object.
### Where do I get the Controller Key?
@ -45,23 +44,83 @@ uvx omni-pca decode-pca '/path/to/My House.pca' --field controller_key
Otherwise, find it in PC Access under the panel's **Setup → Misc → Network**
page (HAI labels it "Encryption Key 1").
## What you get
## Entities created
- One **device** per panel — model + firmware reported in the UI.
- One **`binary_sensor`** per defined zone, named from the panel's own
zone-name field. `OPENING` device class for door/window contacts,
`MOTION` for interior PIRs, `SMOKE` for fire zones, etc., chosen by zone
type when the panel reports one.
- **Push updates**: zone state changes propagate within a single round-trip
thanks to unsolicited-message subscription. The 30-second poll is just a
safety net.
One device per panel, plus per-object entities below.
## Roadmap
| Platform | Entity | Per |
|---|---|---|
| `alarm_control_panel` | Area arm/disarm with code | discovered area |
| `binary_sensor` | Zone open/tripped | binary zone |
| `binary_sensor` | Zone bypassed (diagnostic) | binary zone |
| `binary_sensor` | AC power, backup battery, system trouble | panel |
| `button` | Panel button macro | discovered button |
| `climate` | Thermostat (heat/cool/auto, fan, hold) | discovered thermostat |
| `event` | Typed push event relay | panel |
| `light` | Unit on/off + brightness | discovered unit |
| `sensor` | Analog zone (temp/humidity/power) | analog zone |
| `sensor` | Thermostat current temp / humidity / outdoor temp | thermostat |
| `sensor` | Panel model + firmware, last event class | panel |
| `switch` | Zone bypass toggle | binary zone |
- Areas → `alarm_control_panel` entities
- Units → `light` / `switch` entities
- Thermostats → `climate`
- Aux sensors → `sensor`
State propagates via the panel's unsolicited push messages: zone changes,
arming changes, AC/battery troubles, etc. all arrive within one TCP round-
trip. A 30-second background poll backstops anything that didn't push.
See the [parent README](https://github.com/rsp2k/omni-pca) for protocol /
library details.
## Services
| Service | Purpose |
|---|---|
| `omni_pca.bypass_zone` | Bypass a zone by 1-based index |
| `omni_pca.restore_zone` | Restore a previously-bypassed zone |
| `omni_pca.execute_program` | Run a stored program by index |
| `omni_pca.show_message` | Display a stored message on consoles |
| `omni_pca.clear_message` | Clear a displayed message |
| `omni_pca.acknowledge_alerts` | Clear all outstanding troubles/alerts |
| `omni_pca.send_command` | Power-user escape hatch (raw Command opcode) |
Every service takes an `entry_id` so it picks the right panel when you have
multiple configured.
## Automation example
React to any alarm activation in real time:
```yaml
automation:
- alias: Notify on alarm
trigger:
- platform: event
event_type: state_changed
event_data:
entity_id: event.panel_events
condition: >
{{ trigger.event.data.new_state.attributes.event_type ==
"alarm_activated" }}
action:
- service: notify.mobile_app
data:
title: ALARM
message: >
Area {{ trigger.event.data.new_state.attributes.area_index }}
```
## Diagnostics
Settings → Devices & Services → *HAI/Leviton Omni Panel* → ⋮ → **Download
diagnostics** dumps a redacted snapshot (controller key removed, zone names
hashed) — useful for bug reports.
## Troubleshooting
- **Won't connect**: confirm port 4369 is open on the panel. The Omni Pro
II's network module ships *off* by default; enable it under Setup → Misc
→ Network on a console.
- **Authentication failed**: re-check the Controller Key. The integration
triggers HA's reauth flow when the panel rejects the key.
- **No entities for X**: only objects with a name configured on the panel
are discovered. PC Access's "Names" page is where they live.
See the [parent README](https://git.supported.systems/warehack.ing/omni-pca) for protocol /
library details. Detailed reverse-engineering notes are in
[`docs/JOURNEY.md`](https://git.supported.systems/warehack.ing/omni-pca/blob/main/docs/JOURNEY.md).

View File

@ -1,4 +1,11 @@
"""HAI/Leviton Omni Panel integration for Home Assistant."""
"""HAI/Leviton Omni Panel integration for Home Assistant.
Forwards every config entry to the full set of platforms wrapping the
omni-pca library: alarm_control_panel (areas), binary_sensor (zones +
system flags), button (panel button macros), climate (thermostats),
event (typed push events), light (units), sensor (analog zones,
thermostat readings, panel telemetry), switch (zone bypass).
"""
from __future__ import annotations
@ -9,12 +16,22 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_CONTROLLER_KEY, DOMAIN, LOGGER
from .coordinator import OmniDataUpdateCoordinator
from .services import async_setup_services, async_unload_services
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
PLATFORMS: list[Platform] = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.EVENT,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
@ -44,19 +61,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
# Re-raise so HA retries with backoff; clean up any half-open client.
# Re-raise so HA retries with backoff; clean up any half-open client
# *and* the background event task spawned by the first refresh.
await coordinator.async_shutdown()
raise
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await async_setup_services(hass)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
"""Unload a config entry.
``coordinator.async_shutdown()`` cancels the long-lived event-listener
task and closes the ``OmniClient`` socket, so HA's reload doesn't
leak a background coroutine or a half-open TCP connection.
"""
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.async_shutdown()
await async_unload_services(hass)
return unloaded

View File

@ -0,0 +1,156 @@
"""Alarm control panel platform — one entity per discovered Omni area.
State translation lives in :func:`helpers.security_mode_to_alarm_state`
so it stays unit-testable without Home Assistant. Arm / disarm calls
go through :meth:`OmniClient.execute_security_command` which validates
the user code against the panel; a wrong code surfaces as
:class:`HomeAssistantError`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
CodeFormat,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from omni_pca.commands import CommandFailedError
from omni_pca.models import SecurityMode
from .const import DOMAIN
from .coordinator import OmniDataUpdateCoordinator
from .helpers import (
ARM_SERVICE_TO_SECURITY_MODE,
prettify_name,
security_mode_to_alarm_state,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [
OmniAreaAlarmPanel(coordinator, index)
for index in sorted(coordinator.data.areas)
]
async_add_entities(entities)
_ALARM_STATE_STR_TO_ENUM: dict[str, AlarmControlPanelState] = {
"disarmed": AlarmControlPanelState.DISARMED,
"armed_home": AlarmControlPanelState.ARMED_HOME,
"armed_away": AlarmControlPanelState.ARMED_AWAY,
"armed_night": AlarmControlPanelState.ARMED_NIGHT,
"armed_vacation": AlarmControlPanelState.ARMED_VACATION,
"armed_custom_bypass": AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
"arming": AlarmControlPanelState.ARMING,
"pending": AlarmControlPanelState.PENDING,
"triggered": AlarmControlPanelState.TRIGGERED,
}
class OmniAreaAlarmPanel(
CoordinatorEntity[OmniDataUpdateCoordinator], AlarmControlPanelEntity
):
"""One discovered area as a HA alarm_control_panel."""
_attr_has_entity_name = True
_attr_code_arm_required = True
_attr_code_format = CodeFormat.NUMBER
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.ARM_NIGHT
| AlarmControlPanelEntityFeature.ARM_VACATION
| AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
)
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator)
self._index = index
self._attr_unique_id = f"{coordinator.unique_id}-area-{index}"
props = coordinator.data.areas[index]
self._attr_name = prettify_name(props.name) or f"Area {index}"
self._attr_device_info = coordinator.device_info
@property
def available(self) -> bool:
return (
super().available
and self.coordinator.data is not None
and self._index in self.coordinator.data.areas
)
@property
def alarm_state(self) -> AlarmControlPanelState | None:
status = self.coordinator.data.area_status.get(self._index)
if status is None:
return None
state_str = security_mode_to_alarm_state(
mode=status.mode,
alarm_active=status.alarm_active,
entry_timer=status.entry_timer_secs,
exit_timer=status.exit_timer_secs,
)
return _ALARM_STATE_STR_TO_ENUM.get(state_str)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
status = self.coordinator.data.area_status.get(self._index)
if status is None:
return None
return {
"area_index": self._index,
"raw_mode": status.mode,
"raw_mode_name": status.mode_name,
"entry_timer_secs": status.entry_timer_secs,
"exit_timer_secs": status.exit_timer_secs,
"last_user": status.last_user,
"alarms": status.alarms,
}
async def _send(self, mode_name: str, code: str | None) -> None:
if code is None or not code.isdigit():
raise HomeAssistantError("A numeric user code is required")
mode_value = ARM_SERVICE_TO_SECURITY_MODE[mode_name]
try:
await self.coordinator.client.execute_security_command(
area=self._index, mode=SecurityMode(mode_value), code=int(code)
)
except CommandFailedError as err:
raise HomeAssistantError(f"Panel rejected command: {err}") from err
await self.coordinator.async_request_refresh()
async def async_alarm_disarm(self, code: str | None = None) -> None:
await self._send("disarm", code)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
await self._send("arm_home", code)
async def async_alarm_arm_away(self, code: str | None = None) -> None:
await self._send("arm_away", code)
async def async_alarm_arm_night(self, code: str | None = None) -> None:
await self._send("arm_night", code)
async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
await self._send("arm_vacation", code)
async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None:
await self._send("arm_custom_bypass", code)

View File

@ -1,17 +1,55 @@
"""Binary sensor platform: one entity per Omni zone."""
"""Binary sensor platform for the omni_pca integration.
Per-zone entities
-----------------
* :class:`OmniZoneBinarySensor` one per discovered zone. ``is_on``
derives from :class:`~omni_pca.models.ZoneStatus`. The HA device class
is picked from the zone-type byte by
:func:`~custom_components.omni_pca.helpers.device_class_for_zone_type`.
* :class:`OmniZoneBypassedBinarySensor` one per discovered zone.
Diagnostic entity (``problem`` device-class) that turns on when the
zone is currently bypassed by the user or auto-bypassed by the panel.
Panel-level entities
--------------------
* :class:`OmniSystemAcBinarySensor` ``power``-class. ``is_on`` = AC OK.
Tracks both the periodic SystemStatus poll and any pushed
:class:`~omni_pca.events.AcLost` / :class:`~omni_pca.events.AcRestored`
events so HA reacts immediately on a power-blip.
* :class:`OmniSystemBatteryBinarySensor` ``battery``-class. ``is_on``
when the backup battery reading drops below the panel's threshold
(or a :class:`~omni_pca.events.BatteryLow` event came in since the
last :class:`~omni_pca.events.BatteryRestored`).
* :class:`OmniSystemTroubleBinarySensor` ``problem``-class. ``is_on``
when SystemStatus reports any troubles.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from omni_pca.events import (
AcLost,
AcRestored,
BatteryLow,
BatteryRestored,
)
from .const import DOMAIN
from .coordinator import OmniDataUpdateCoordinator
from .helpers import (
device_class_for_zone_type,
is_binary_zone_type,
prettify_name,
use_latched_alarm_for_zone,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
@ -19,61 +57,49 @@ if TYPE_CHECKING:
from homeassistant.helpers.entity_platform import AddEntitiesCallback
# Best-effort mapping from Omni zone-type byte (enuZoneType) to HA device
# class. Anything not listed falls back to OPENING — a sane default for
# perimeter contacts, which dominate residential installs. We pick this
# explicitly rather than guessing motion vs. door from the name.
#
# Reference: HAI_Shared/enuZoneType.cs (subset).
_ZONE_TYPE_TO_DEVICE_CLASS: dict[int, BinarySensorDeviceClass] = {
0: BinarySensorDeviceClass.OPENING, # Perimeter
1: BinarySensorDeviceClass.OPENING, # PerimeterEntryExit
2: BinarySensorDeviceClass.MOTION, # Interior (typically PIR)
3: BinarySensorDeviceClass.MOTION, # InteriorAuto
4: BinarySensorDeviceClass.SAFETY, # Tamper
5: BinarySensorDeviceClass.SMOKE, # Fire
6: BinarySensorDeviceClass.SAFETY, # PoliceEmergency
7: BinarySensorDeviceClass.SAFETY, # Duress
8: BinarySensorDeviceClass.SOUND, # Auxiliary
32: BinarySensorDeviceClass.SMOKE, # Auxiliary fire
33: BinarySensorDeviceClass.GAS,
34: BinarySensorDeviceClass.MOISTURE,
80: BinarySensorDeviceClass.MOTION, # AwayInterior
81: BinarySensorDeviceClass.MOTION, # NightInterior
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create one binary_sensor per zone the panel reported."""
"""Create one binary_sensor per discovered zone, plus system-level entities."""
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [
OmniZoneBinarySensor(coordinator, index)
for index in sorted(coordinator.data.zones)
]
entities: list[BinarySensorEntity] = []
for index in sorted(coordinator.data.zones):
props = coordinator.data.zones[index]
if not is_binary_zone_type(props.zone_type):
# Analog zones (temperature, humidity) aren't binary sensors;
# Phase B will surface them on the sensor platform.
continue
entities.append(OmniZoneBinarySensor(coordinator, index))
entities.append(OmniZoneBypassedBinarySensor(coordinator, index))
entities.append(OmniSystemAcBinarySensor(coordinator))
entities.append(OmniSystemBatteryBinarySensor(coordinator))
entities.append(OmniSystemTroubleBinarySensor(coordinator))
async_add_entities(entities)
class OmniZoneBinarySensor(
# --------------------------------------------------------------------------
# Zone entities
# --------------------------------------------------------------------------
class _OmniZoneBaseEntity(
CoordinatorEntity[OmniDataUpdateCoordinator], BinarySensorEntity
):
"""A single zone exposed as a binary_sensor."""
"""Shared boilerplate for the two per-zone entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: OmniDataUpdateCoordinator, index: int) -> None:
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator)
self._index = index
self._attr_unique_id = f"{coordinator.unique_id}-zone-{index}"
self._attr_device_info = coordinator.device_info
zone = coordinator.data.zones[index]
self._attr_name = _prettify(zone.name)
self._attr_device_class = _ZONE_TYPE_TO_DEVICE_CLASS.get(
zone.zone_type, BinarySensorDeviceClass.OPENING
)
@property
def available(self) -> bool:
@ -83,33 +109,202 @@ class OmniZoneBinarySensor(
and self._index in self.coordinator.data.zones
)
@property
def _zone_props(self): # type: ignore[no-untyped-def]
return self.coordinator.data.zones.get(self._index)
@property
def _zone_status(self): # type: ignore[no-untyped-def]
return self.coordinator.data.zone_status.get(self._index)
class OmniZoneBinarySensor(_OmniZoneBaseEntity):
"""A single zone exposed as the primary binary_sensor.
Live ``is_on`` derives from the matching :class:`ZoneStatus`:
* For motion / smoke / water / freeze / panic / tamper zones we use
the *latched* tripped bit so a brief pulse stays visible until the
user clears the alarm
(see :func:`~custom_components.omni_pca.helpers.use_latched_alarm_for_zone`).
* For door / window / opening zones we use the *current condition*
bit so HA tracks the door truthfully.
"""
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator, index)
self._attr_unique_id = f"{coordinator.unique_id}-zone-{index}"
props = coordinator.data.zones[index]
self._attr_name = prettify_name(props.name) or f"Zone {index}"
self._attr_device_class = BinarySensorDeviceClass(
device_class_for_zone_type(props.zone_type)
)
@property
def is_on(self) -> bool | None:
status = self._zone_status
props = self._zone_props
if status is None or props is None:
return None
# Pick the right bit based on zone type — latched-alarm zones
# (smoke, water, panic, …) stay "on" until cleared even after a
# one-shot trip, while contact / motion zones track the live
# current condition bit.
if use_latched_alarm_for_zone(props.zone_type):
return status.is_in_alarm
return status.is_open
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
status = self._zone_status
props = self._zone_props
if status is None or props is None:
return None
return {
"zone_index": self._index,
"zone_type": props.zone_type,
"area": props.area,
"is_open": status.is_open,
"is_bypassed": status.is_bypassed,
"is_in_alarm": status.is_in_alarm,
"is_trouble": status.is_trouble,
"loop_reading": status.loop,
"raw_status": status.raw_status,
}
class OmniZoneBypassedBinarySensor(_OmniZoneBaseEntity):
"""Diagnostic entity that turns on when a zone is bypassed.
Surfacing bypass as its own entity (rather than just an attribute on
the primary sensor) lets automations key on it directly e.g.
"remind me at 10pm if any zone is still bypassed".
"""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_device_class = BinarySensorDeviceClass.PROBLEM
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator, index)
self._attr_unique_id = f"{coordinator.unique_id}-zone-{index}-bypassed"
props = coordinator.data.zones[index]
base = prettify_name(props.name) or f"Zone {index}"
self._attr_name = f"{base} Bypassed"
@property
def is_on(self) -> bool | None:
status = self._zone_status
if status is None:
return None
return status.is_bypassed
# --------------------------------------------------------------------------
# System-level entities
# --------------------------------------------------------------------------
class _OmniSystemBaseEntity(
CoordinatorEntity[OmniDataUpdateCoordinator], BinarySensorEntity
):
"""Shared boilerplate for hub-scoped system binary sensors."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
class OmniSystemAcBinarySensor(_OmniSystemBaseEntity):
"""``power`` device class — on when mains AC is present.
Uses the most recent :class:`AcLost` / :class:`AcRestored` push event
as the authoritative signal, falling back to the SystemStatus battery
heuristic when no event has been seen yet (panel never lost AC).
"""
_attr_device_class = BinarySensorDeviceClass.POWER
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-system-ac"
self._attr_name = "AC Power"
@property
def is_on(self) -> bool | None:
data = self.coordinator.data
if data is None:
return None
zone = data.zones.get(self._index)
if zone is None:
return None
return zone.is_open
last = data.last_event
if isinstance(last, AcLost):
return False
if isinstance(last, AcRestored):
return True
if data.system_status is not None:
return data.system_status.ac_ok
return None
class OmniSystemBatteryBinarySensor(_OmniSystemBaseEntity):
"""``battery`` device class — on when the backup battery is LOW."""
_attr_device_class = BinarySensorDeviceClass.BATTERY
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-system-battery"
self._attr_name = "Backup Battery"
@property
def extra_state_attributes(self) -> dict[str, int] | None:
def is_on(self) -> bool | None:
data = self.coordinator.data
if data is None:
return None
zone = data.zones.get(self._index)
if zone is None:
last = data.last_event
if isinstance(last, BatteryLow):
return True
if isinstance(last, BatteryRestored):
return False
if data.system_status is not None:
return not data.system_status.battery_ok
return None
@property
def extra_state_attributes(self) -> dict[str, int] | None:
if self.coordinator.data is None or self.coordinator.data.system_status is None:
return None
return {
"zone_index": zone.index,
"zone_type": zone.zone_type,
"area": zone.area,
"raw_status": zone.status,
"loop_reading": zone.loop,
"battery_reading": self.coordinator.data.system_status.battery_reading,
}
def _prettify(name: str) -> str:
"""Convert ``FRONT_DOOR`` → ``Front Door`` for HA-friendly display."""
return name.replace("_", " ").strip().title()
class OmniSystemTroubleBinarySensor(_OmniSystemBaseEntity):
"""``problem`` device class — on when SystemStatus reports any troubles."""
_attr_device_class = BinarySensorDeviceClass.PROBLEM
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-system-trouble"
self._attr_name = "System Trouble"
@property
def is_on(self) -> bool | None:
data = self.coordinator.data
if data is None or data.system_status is None:
return None
return bool(data.system_status.troubles)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
if self.coordinator.data is None or self.coordinator.data.system_status is None:
return None
return {
"troubles": list(self.coordinator.data.system_status.troubles),
}

View File

@ -0,0 +1,62 @@
"""Button platform — one HA button per discovered Omni button macro.
Programs aren't currently discoverable (the library doesn't yet have a
RequestProperties path for the Program object type), so program-execute
support lives in the services platform instead (Phase C).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components.button import ButtonEntity
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from omni_pca.commands import CommandFailedError
from .const import DOMAIN
from .coordinator import OmniDataUpdateCoordinator
from .helpers import prettify_name
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [
OmniPanelButton(coordinator, index)
for index in sorted(coordinator.data.buttons)
]
async_add_entities(entities)
class OmniPanelButton(
CoordinatorEntity[OmniDataUpdateCoordinator], ButtonEntity
):
"""Push-button entity that fires an Omni button macro."""
_attr_has_entity_name = True
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator)
self._index = index
self._attr_unique_id = f"{coordinator.unique_id}-button-{index}"
props = coordinator.data.buttons[index]
self._attr_name = prettify_name(props.name) or f"Button {index}"
self._attr_device_info = coordinator.device_info
async def async_press(self) -> None:
try:
await self.coordinator.client.execute_button(self._index)
except CommandFailedError as err:
raise HomeAssistantError(f"Panel rejected button: {err}") from err

View File

@ -0,0 +1,271 @@
"""Climate platform — one HA climate entity per discovered thermostat.
Omni stores temperatures in a linear byte (raw = round((°F + 40) * 10/9)).
HA stays in Fahrenheit because the panel is native there; users with HA
configured for metric will see automatic display conversion downstream.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE as ATTR_TEMP
from homeassistant.const import UnitOfTemperature
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from omni_pca.commands import CommandFailedError
from omni_pca.models import (
FanMode as OmniFanMode,
)
from omni_pca.models import (
HoldMode as OmniHoldMode,
)
from omni_pca.models import (
HvacMode as OmniHvacMode,
)
from .const import DOMAIN
from .coordinator import OmniDataUpdateCoordinator
from .helpers import (
fahrenheit_to_omni_raw,
ha_fan_to_omni,
ha_hold_to_omni,
ha_hvac_to_omni,
omni_fan_to_ha,
omni_hold_to_ha,
omni_hvac_to_ha,
prettify_name,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
_HVAC_STR_TO_ENUM: dict[str, HVACMode] = {
"off": HVACMode.OFF,
"heat": HVACMode.HEAT,
"cool": HVACMode.COOL,
"heat_cool": HVACMode.HEAT_COOL,
}
PRESET_NONE = "none"
PRESET_HOLD = "hold"
PRESET_VACATION = "vacation"
FAN_AUTO = "auto"
FAN_ON = "on"
FAN_DIFFUSE = "diffuse"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [
OmniThermostatClimate(coordinator, index)
for index in sorted(coordinator.data.thermostats)
]
async_add_entities(entities)
class OmniThermostatClimate(
CoordinatorEntity[OmniDataUpdateCoordinator], ClimateEntity
):
"""One discovered thermostat as a HA climate entity."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
_attr_target_temperature_step = 1.0
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
_attr_hvac_modes: ClassVar[list[HVACMode]] = [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.HEAT_COOL,
]
_attr_fan_modes: ClassVar[list[str]] = [FAN_AUTO, FAN_ON, FAN_DIFFUSE]
_attr_preset_modes: ClassVar[list[str]] = [PRESET_NONE, PRESET_HOLD, PRESET_VACATION]
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator)
self._index = index
self._attr_unique_id = f"{coordinator.unique_id}-thermostat-{index}"
props = coordinator.data.thermostats[index]
self._attr_name = prettify_name(props.name) or f"Thermostat {index}"
self._attr_device_info = coordinator.device_info
@property
def available(self) -> bool:
return (
super().available
and self.coordinator.data is not None
and self._index in self.coordinator.data.thermostats
)
@property
def _status(self): # type: ignore[no-untyped-def]
return self.coordinator.data.thermostat_status.get(self._index)
@property
def current_temperature(self) -> float | None:
s = self._status
if s is None or s.temperature_raw == 0:
return None
return s.temperature_f
@property
def current_humidity(self) -> int | None:
s = self._status
if s is None or s.humidity_raw == 0:
return None
return int(s.humidity_raw)
@property
def hvac_mode(self) -> HVACMode | None:
s = self._status
if s is None:
return None
return _HVAC_STR_TO_ENUM.get(omni_hvac_to_ha(s.system_mode))
@property
def target_temperature(self) -> float | None:
s = self._status
if s is None:
return None
if s.system_mode == int(OmniHvacMode.HEAT):
return s.heat_setpoint_f
if s.system_mode == int(OmniHvacMode.COOL):
return s.cool_setpoint_f
return None
@property
def target_temperature_high(self) -> float | None:
s = self._status
if s is None or s.system_mode != int(OmniHvacMode.AUTO):
return None
return s.cool_setpoint_f
@property
def target_temperature_low(self) -> float | None:
s = self._status
if s is None or s.system_mode != int(OmniHvacMode.AUTO):
return None
return s.heat_setpoint_f
@property
def fan_mode(self) -> str | None:
s = self._status
if s is None:
return None
return omni_fan_to_ha(s.fan_mode)
@property
def preset_mode(self) -> str | None:
s = self._status
if s is None:
return None
return omni_hold_to_ha(s.hold_mode)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
s = self._status
if s is None:
return None
return {
"thermostat_index": self._index,
"outdoor_temperature_f": (
s.outdoor_temperature_raw and round(s.outdoor_temperature_f, 1)
),
"humidify_setpoint": s.humidify_setpoint_raw,
"dehumidify_setpoint": s.dehumidify_setpoint_raw,
}
# ---- setters ---------------------------------------------------------
async def _set(self, coro_factory) -> None: # type: ignore[no-untyped-def]
try:
await coro_factory()
except CommandFailedError as err:
raise HomeAssistantError(f"Panel rejected command: {err}") from err
await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
omni_mode = OmniHvacMode(ha_hvac_to_omni(str(hvac_mode)))
await self._set(
lambda: self.coordinator.client.set_thermostat_system_mode(
self._index, omni_mode
)
)
async def async_set_fan_mode(self, fan_mode: str) -> None:
omni_mode = OmniFanMode(ha_fan_to_omni(fan_mode))
await self._set(
lambda: self.coordinator.client.set_thermostat_fan_mode(
self._index, omni_mode
)
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
omni_mode = OmniHoldMode(ha_hold_to_omni(preset_mode))
await self._set(
lambda: self.coordinator.client.set_thermostat_hold_mode(
self._index, omni_mode
)
)
async def async_set_temperature(self, **kwargs: Any) -> None:
if ATTR_HVAC_MODE in kwargs:
await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE])
s = self._status
if s is None:
raise HomeAssistantError("Thermostat not yet polled")
if ATTR_TARGET_TEMP_LOW in kwargs:
await self._set(
lambda: self.coordinator.client.set_thermostat_heat_setpoint_raw(
self._index, fahrenheit_to_omni_raw(kwargs[ATTR_TARGET_TEMP_LOW])
)
)
if ATTR_TARGET_TEMP_HIGH in kwargs:
await self._set(
lambda: self.coordinator.client.set_thermostat_cool_setpoint_raw(
self._index, fahrenheit_to_omni_raw(kwargs[ATTR_TARGET_TEMP_HIGH])
)
)
if ATTR_TEMP in kwargs:
target_raw = fahrenheit_to_omni_raw(kwargs[ATTR_TEMP])
# Single setpoint — choose heat or cool based on current mode.
if s.system_mode == int(OmniHvacMode.HEAT):
await self._set(
lambda: self.coordinator.client.set_thermostat_heat_setpoint_raw(
self._index, target_raw
)
)
elif s.system_mode == int(OmniHvacMode.COOL):
await self._set(
lambda: self.coordinator.client.set_thermostat_cool_setpoint_raw(
self._index, target_raw
)
)

View File

@ -20,6 +20,15 @@ MANUFACTURER: Final = "HAI / Leviton"
# panel goes quiet.
SCAN_INTERVAL: Final = timedelta(seconds=30)
# Background event-listener task name, surfaced to ``asyncio.all_tasks()``
# for diagnostics.
EVENT_TASK_NAME: Final = "omni_pca-event-listener"
# Upper bound for the discovery walk. The protocol caps object indices at
# uint16, but Omni panels never approach that — most installs have <100
# zones / units / areas, so we stop early when discovery returns EOD.
MAX_OBJECT_INDEX: Final = 0xFFFF
# Length, in characters, of a hex-encoded 16-byte controller key.
CONTROLLER_KEY_HEX_LEN: Final = 32

View File

@ -1,23 +1,41 @@
"""DataUpdateCoordinator that owns the long-lived OmniClient connection.
The coordinator caches *static* panel topology (system info, zone names,
unit names, area names) on first refresh and only re-queries dynamic state
on subsequent updates. Unsolicited messages from the panel are also routed
through here so binary sensors flip immediately without waiting for the
next 30s poll.
Lifecycle
---------
1. ``async_config_entry_first_refresh`` connects, runs a one-time
*discovery* pass that enumerates every named zone / unit / area /
thermostat / button / program on the panel, and seeds ``self.data``
with a populated :class:`OmniData`.
2. ``_async_update_data`` is then called every :data:`SCAN_INTERVAL` to
re-poll *live state only* (extended status for zones / units /
thermostats, basic status for areas).
3. A background task (:meth:`_run_event_listener`) consumes
:meth:`OmniClient.events` for the lifetime of the entry; whenever a
typed :class:`SystemEvent` arrives, the relevant slice of state is
patched in-place and ``async_set_updated_data`` fires so HA pushes
updates to subscribed entities without waiting for the next poll.
The library's :class:`OmniClient` is the *only* thing that talks to the
wire. We keep one client per coordinator and close it on shutdown; on a
recoverable :class:`OmniConnectionError` we drop and recreate it on the
next refresh, preserving the existing :class:`OmniData` so entities don't
flicker to "unavailable" between attempts.
"""
from __future__ import annotations
import asyncio
import contextlib
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from omni_pca.client import ObjectType, OmniClient
from omni_pca.client import ObjectType as ClientObjectType
from omni_pca.client import OmniClient
from omni_pca.connection import (
ConnectionError as OmniConnectionError,
)
@ -26,49 +44,88 @@ from omni_pca.connection import (
InvalidEncryptionKeyError,
RequestTimeoutError,
)
from omni_pca.models import SystemInformation, SystemStatus, ZoneProperties
from omni_pca.events import (
AcLost,
AcRestored,
AlarmActivated,
AlarmCleared,
ArmingChanged,
BatteryLow,
BatteryRestored,
SystemEvent,
UnitStateChanged,
ZoneStateChanged,
)
from omni_pca.models import (
OBJECT_TYPE_TO_PROPERTIES,
AreaProperties,
AreaStatus,
ButtonProperties,
ObjectType,
ProgramProperties,
SystemInformation,
SystemStatus,
ThermostatProperties,
ThermostatStatus,
UnitProperties,
UnitStatus,
ZoneProperties,
ZoneStatus,
)
from omni_pca.opcodes import OmniLink2MessageType
from .const import DOMAIN, LOGGER, MANUFACTURER, SCAN_INTERVAL
from .const import (
DOMAIN,
EVENT_TASK_NAME,
LOGGER,
MANUFACTURER,
MAX_OBJECT_INDEX,
SCAN_INTERVAL,
)
if TYPE_CHECKING:
from omni_pca.message import Message
@dataclass(slots=True)
class OmniZoneState:
"""Per-zone state combining static name with dynamic status."""
index: int
name: str
zone_type: int
area: int
status: int # raw zone status byte from the panel
loop: int
@property
def is_open(self) -> bool:
"""True when the zone is tripped / not-ready / open.
The Omni-Link II ``ZoneStatus`` byte packs current condition in the
low nibble. 0 = secure (closed). Any non-zero current condition is
treated as "not secure" for binary-sensor purposes.
"""
return (self.status & 0x03) != 0
# --------------------------------------------------------------------------
# Public data shape exposed to entities
# --------------------------------------------------------------------------
@dataclass(slots=True)
class OmniData:
"""Top-level coordinator data exposed to entities."""
"""Snapshot of everything a coordinator's entities can read.
system_information: SystemInformation
system_status: SystemStatus | None
zones: dict[int, OmniZoneState]
unit_names: dict[int, str] = field(default_factory=dict)
area_names: dict[int, str] = field(default_factory=dict)
Discovery dictionaries (``zones``, ``units``, ``areas``,
``thermostats``, ``buttons``, ``programs``) are populated once on
first refresh and never re-walked they describe panel topology,
which only changes when the installer reprograms the controller and
the user reloads the integration.
Live ``*_status`` dictionaries are re-populated on every poll *and*
patched in-place from the event listener.
"""
system_info: SystemInformation
zones: dict[int, ZoneProperties] = field(default_factory=dict)
units: dict[int, UnitProperties] = field(default_factory=dict)
areas: dict[int, AreaProperties] = field(default_factory=dict)
thermostats: dict[int, ThermostatProperties] = field(default_factory=dict)
buttons: dict[int, ButtonProperties] = field(default_factory=dict)
programs: dict[int, ProgramProperties] = field(default_factory=dict)
zone_status: dict[int, ZoneStatus] = field(default_factory=dict)
unit_status: dict[int, UnitStatus] = field(default_factory=dict)
area_status: dict[int, AreaStatus] = field(default_factory=dict)
thermostat_status: dict[int, ThermostatStatus] = field(default_factory=dict)
system_status: SystemStatus | None = None
last_event: SystemEvent | None = None
# --------------------------------------------------------------------------
# Coordinator
# --------------------------------------------------------------------------
class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
"""Coordinator that owns one OmniClient and one panel device."""
"""Coordinator that owns one :class:`OmniClient` and one panel device."""
config_entry: ConfigEntry
@ -92,11 +149,9 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
self._port = port
self._controller_key = controller_key
self._client: OmniClient | None = None
self._static_loaded = False
self._zone_names: dict[int, str] = {}
self._unit_names: dict[int, str] = {}
self._area_names: dict[int, str] = {}
self._system_information: SystemInformation | None = None
self._discovery_done = False
self._discovered: OmniData | None = None
self._event_task: asyncio.Task[None] | None = None
# ---- public surface --------------------------------------------------
@ -105,13 +160,20 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
"""Stable identifier for this panel (host:port)."""
return f"{self._host}:{self._port}"
@property
def client(self) -> OmniClient:
"""The live OmniClient. Raises if the coordinator hasn't connected yet."""
if self._client is None:
raise RuntimeError("OmniClient is not connected")
return self._client
@property
def device_info(self) -> DeviceInfo:
"""DeviceInfo for the single hub device this coordinator represents."""
info = self._system_information
info = self._discovered.system_info if self._discovered is not None else None
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name="Omni Pro II",
name=info.model_name if info is not None else "Omni Panel",
manufacturer=MANUFACTURER,
model=info.model_name if info is not None else None,
sw_version=info.firmware_version if info is not None else None,
@ -119,14 +181,9 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
)
async def async_shutdown(self) -> None:
"""Tear down the client connection on unload."""
if self._client is not None:
client = self._client
self._client = None
try:
await client.__aexit__(None, None, None)
except Exception:
LOGGER.debug("error closing OmniClient", exc_info=True)
"""Tear down the event task and the client connection on unload."""
await self._cancel_event_task()
await self._drop_client()
await super().async_shutdown()
# ---- DataUpdateCoordinator hook -------------------------------------
@ -134,29 +191,43 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
async def _async_update_data(self) -> OmniData:
try:
client = await self._ensure_connected()
if not self._static_loaded:
await self._load_static(client)
if not self._discovery_done:
self._discovered = await self._run_discovery(client)
self._discovery_done = True
self._start_event_task()
assert self._discovered is not None
base = self._discovered
zone_status = await self._poll_zone_status(client, base.zones)
unit_status = await self._poll_unit_status(client, base.units)
area_status = await self._poll_area_status(client, base.areas)
thermostat_status = await self._poll_thermostat_status(
client, base.thermostats
)
system_status = await self._safe_system_status(client)
zones = await self._snapshot_zones(client)
except (InvalidEncryptionKeyError, HandshakeError) as err:
# Surface as auth failure so HA triggers the reauth flow.
from homeassistant.exceptions import ConfigEntryAuthFailed
await self._drop_client()
raise ConfigEntryAuthFailed(str(err)) from err
except (OmniConnectionError, RequestTimeoutError, OSError) as err:
await self._drop_client()
raise UpdateFailed(f"panel unreachable: {err}") from err
assert self._system_information is not None # set by _load_static
return OmniData(
system_information=self._system_information,
# Preserve any last_event already captured by the event task; the
# poll path doesn't see push events so it must not overwrite it.
last_event = self.data.last_event if self.data is not None else None
return replace(
self._discovered,
zone_status=zone_status,
unit_status=unit_status,
area_status=area_status,
thermostat_status=thermostat_status,
system_status=system_status,
zones=zones,
unit_names=dict(self._unit_names),
area_names=dict(self._area_names),
last_event=last_event,
)
# ---- internals -------------------------------------------------------
# ---- connection management ------------------------------------------
async def _ensure_connected(self) -> OmniClient:
if self._client is not None:
@ -166,14 +237,9 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
port=self._port,
controller_key=self._controller_key,
)
# Manually drive __aenter__ so we can keep the connection open
# across update cycles instead of using `async with`.
# Drive __aenter__ manually so the client survives across update
# cycles; we close it explicitly on shutdown / failure.
await client.__aenter__()
try:
await client.subscribe(self._handle_unsolicited)
except Exception:
await client.__aexit__(None, None, None)
raise
self._client = client
return client
@ -184,83 +250,451 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
self._client = None
try:
await client.__aexit__(None, None, None)
except Exception:
LOGGER.debug("error during reconnect cleanup", exc_info=True)
except Exception: # pragma: no cover - best-effort cleanup
LOGGER.debug("error during client cleanup", exc_info=True)
async def _load_static(self, client: OmniClient) -> None:
self._system_information = await client.get_system_information()
self._zone_names = await client.list_zone_names()
# Unit / area names are best-effort; some panels may not have any.
try:
self._unit_names = await client.list_unit_names()
except Exception:
LOGGER.debug("list_unit_names failed; continuing", exc_info=True)
self._unit_names = {}
try:
self._area_names = await client.list_area_names()
except Exception:
LOGGER.debug("list_area_names failed; continuing", exc_info=True)
self._area_names = {}
self._static_loaded = True
LOGGER.debug(
"loaded static topology: %d zones, %d units, %d areas",
len(self._zone_names),
len(self._unit_names),
len(self._area_names),
# ---- discovery -------------------------------------------------------
async def _run_discovery(self, client: OmniClient) -> OmniData:
"""Walk every object type once and stash the static topology."""
system_info = await client.get_system_information()
zones = await self._discover_zones(client)
units = await self._discover_units(client)
areas = await self._discover_areas(client)
thermostats = await self._discover_thermostats(client)
buttons = await self._discover_buttons(client)
programs = await self._discover_programs(client)
LOGGER.info(
"omni_pca discovery: %d zones, %d units, %d areas, "
"%d thermostats, %d buttons, %d programs",
len(zones),
len(units),
len(areas),
len(thermostats),
len(buttons),
len(programs),
)
return OmniData(
system_info=system_info,
zones=zones,
units=units,
areas=areas,
thermostats=thermostats,
buttons=buttons,
programs=programs,
)
async def _safe_system_status(self, client: OmniClient) -> SystemStatus | None:
async def _discover_zones(
self, client: OmniClient
) -> dict[int, ZoneProperties]:
names = await self._best_effort(client.list_zone_names, default={})
out: dict[int, ZoneProperties] = {}
for index in sorted(names):
try:
props = await client.get_object_properties(
ClientObjectType.ZONE, index
)
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug("zone %d properties fetch failed", index, exc_info=True)
continue
if isinstance(props, ZoneProperties):
out[index] = props
return out
async def _discover_units(
self, client: OmniClient
) -> dict[int, UnitProperties]:
names = await self._best_effort(client.list_unit_names, default={})
out: dict[int, UnitProperties] = {}
for index in sorted(names):
try:
props = await client.get_object_properties(
ClientObjectType.UNIT, index
)
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug("unit %d properties fetch failed", index, exc_info=True)
continue
if isinstance(props, UnitProperties):
out[index] = props
return out
async def _discover_areas(
self, client: OmniClient
) -> dict[int, AreaProperties]:
names = await self._best_effort(client.list_area_names, default={})
out: dict[int, AreaProperties] = {}
for index in sorted(names):
try:
props = await client.get_object_properties(
ClientObjectType.AREA, index
)
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug("area %d properties fetch failed", index, exc_info=True)
continue
if isinstance(props, AreaProperties):
out[index] = props
return out
async def _discover_thermostats(
self, client: OmniClient
) -> dict[int, ThermostatProperties]:
"""Walk thermostat properties via the low-level connection.
The high-level :meth:`OmniClient.get_object_properties` only knows
zone/unit/area parsers in v1.0 of the library; thermostats are in
:data:`OBJECT_TYPE_TO_PROPERTIES` on the model side, so we drive
the wire ourselves and parse with the model's class.
"""
return await self._walk_properties(
client, ObjectType.THERMOSTAT, ThermostatProperties
)
async def _discover_buttons(
self, client: OmniClient
) -> dict[int, ButtonProperties]:
return await self._walk_properties(
client, ObjectType.BUTTON, ButtonProperties
)
async def _discover_programs(
self, client: OmniClient
) -> dict[int, ProgramProperties]:
# Programs aren't reachable via the Properties opcode (the C# side
# uses a separate request/reply pair), so we just return an empty
# dict. We keep the field on OmniData so Phase B can plug in real
# discovery the moment the library exposes it. AMBIGUITY: the spec
# asks for "named programs" — there's no on-the-wire path for that
# in v1.0 of omni_pca, so an empty mapping is the honest answer.
_ = client, ProgramProperties
return {}
async def _walk_properties(
self,
client: OmniClient,
object_type: ObjectType,
parser: type,
) -> dict[int, object]:
"""Walk every defined object of ``object_type`` and parse with ``parser``.
Mirrors the strategy used by ``OmniClient._walk_named_objects`` but
works for any model in :data:`OBJECT_TYPE_TO_PROPERTIES` (the
client's internal parser table only covers zones/units/areas in
v1.0). We drive ``RequestProperties`` directly on the connection
so we don't have to monkey-patch the library.
"""
if parser is None or OBJECT_TYPE_TO_PROPERTIES.get(int(object_type)) is None:
return {}
out: dict[int, object] = {}
cursor = 0
conn = client.connection
# Manual request/reply loop with relative_direction=1 (=next).
for _ in range(MAX_OBJECT_INDEX):
payload = bytes(
[
int(object_type),
(cursor >> 8) & 0xFF,
cursor & 0xFF,
1, # relative_direction = next
0, 0, 0, # filter1..3
]
)
try:
reply = await conn.request(
OmniLink2MessageType.RequestProperties, payload
)
except RequestTimeoutError:
break
if reply.opcode == int(OmniLink2MessageType.EOD):
break
if reply.opcode != int(OmniLink2MessageType.Properties):
break
try:
obj = parser.parse(reply.payload)
except Exception:
LOGGER.debug(
"parse failed for %s past index %d",
object_type.name,
cursor,
exc_info=True,
)
break
# Object name being empty is OK for buttons/programs but the
# spec says "named only" — we still keep the entry as a
# candidate; entity setup filters by truthiness.
index_attr = getattr(obj, "index", None)
name_attr = getattr(obj, "name", "")
if index_attr is None:
break
if name_attr:
out[index_attr] = obj
cursor = index_attr
if cursor >= MAX_OBJECT_INDEX:
break
return out
@staticmethod
async def _best_effort(coro_fn, *, default):
"""Call ``coro_fn()`` and swallow non-transport errors, returning ``default``.
We let :class:`OmniConnectionError` / :class:`RequestTimeoutError`
propagate so the coordinator can drop the client and reconnect;
anything else (a parse failure on a particular reply, NAK on a
feature the panel doesn't support) is downgraded to a debug log.
"""
try:
return await coro_fn()
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug("best-effort %s failed", coro_fn.__name__, exc_info=True)
return default
# ---- live polling ----------------------------------------------------
async def _poll_zone_status(
self, client: OmniClient, zones: dict[int, ZoneProperties]
) -> dict[int, ZoneStatus]:
if not zones:
return {}
end = max(zones)
try:
records = await client.get_extended_status(ObjectType.ZONE, 1, end)
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug("zone extended_status poll failed", exc_info=True)
return self.data.zone_status if self.data is not None else {}
return {
r.index: r
for r in records
if isinstance(r, ZoneStatus) and r.index in zones
}
async def _poll_unit_status(
self, client: OmniClient, units: dict[int, UnitProperties]
) -> dict[int, UnitStatus]:
if not units:
return {}
end = max(units)
try:
records = await client.get_extended_status(ObjectType.UNIT, 1, end)
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug("unit extended_status poll failed", exc_info=True)
return self.data.unit_status if self.data is not None else {}
return {
r.index: r
for r in records
if isinstance(r, UnitStatus) and r.index in units
}
async def _poll_area_status(
self, client: OmniClient, areas: dict[int, AreaProperties]
) -> dict[int, AreaStatus]:
if not areas:
return {}
end = max(areas)
try:
records = await client.get_object_status(ObjectType.AREA, 1, end)
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug("area status poll failed", exc_info=True)
return self.data.area_status if self.data is not None else {}
return {
r.index: r
for r in records
if isinstance(r, AreaStatus) and r.index in areas
}
async def _poll_thermostat_status(
self, client: OmniClient, thermostats: dict[int, ThermostatProperties]
) -> dict[int, ThermostatStatus]:
if not thermostats:
return {}
end = max(thermostats)
try:
records = await client.get_extended_status(
ObjectType.THERMOSTAT, 1, end
)
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug("thermostat extended_status poll failed", exc_info=True)
return (
self.data.thermostat_status if self.data is not None else {}
)
return {
r.index: r
for r in records
if isinstance(r, ThermostatStatus) and r.index in thermostats
}
async def _safe_system_status(
self, client: OmniClient
) -> SystemStatus | None:
try:
return await client.get_system_status()
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug("get_system_status failed; continuing", exc_info=True)
LOGGER.debug("get_system_status failed", exc_info=True)
return None
async def _snapshot_zones(self, client: OmniClient) -> dict[int, OmniZoneState]:
zones: dict[int, OmniZoneState] = {}
for index, name in self._zone_names.items():
try:
props = await client.get_object_properties(ObjectType.ZONE, index)
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug("zone %d snapshot failed; skipping", index, exc_info=True)
continue
if not isinstance(props, ZoneProperties):
continue
zones[index] = OmniZoneState(
index=index,
name=name,
zone_type=props.zone_type,
area=props.area,
status=props.status,
loop=props.loop,
)
return zones
# ---- event listener --------------------------------------------------
async def _handle_unsolicited(self, msg: Message) -> None:
"""Push-driven update path.
def _start_event_task(self) -> None:
if self._event_task is not None and not self._event_task.done():
return
self._event_task = self.config_entry.async_create_background_task(
self.hass,
self._run_event_listener(),
EVENT_TASK_NAME,
)
We don't try to be clever about parsing every unsolicited opcode
here. The simplest correct behavior is to nudge HA to refetch on
any panel-initiated message; entities will see fresh zone state
within one round-trip.
async def _cancel_event_task(self) -> None:
if self._event_task is None:
return
task = self._event_task
self._event_task = None
if not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
async def _run_event_listener(self) -> None:
"""Background loop: consume typed events and push state to entities.
Re-establishes the iterator on each connection cycle. If the
client gets dropped (transport error during a poll), we exit; the
next ``_async_update_data`` will reconnect and respawn this task.
"""
LOGGER.debug("unsolicited opcode %#04x payload=%s", msg.opcode, msg.payload.hex())
# Schedule a refresh on the event loop without awaiting from the
# subscriber callback (which lives in the connection's read loop).
self.hass.async_create_task(self._refresh_after_push())
async def _refresh_after_push(self) -> None:
if self.data is None or self._client is None:
client = self._client
if client is None:
return
try:
zones = await self._snapshot_zones(self._client)
except (OmniConnectionError, RequestTimeoutError):
await self.async_request_refresh()
async for event in client.events():
self._apply_event(event)
except asyncio.CancelledError:
raise
except (OmniConnectionError, RequestTimeoutError, OSError):
LOGGER.debug("event listener exited on transport error", exc_info=True)
except Exception: # pragma: no cover - defensive
LOGGER.exception("event listener crashed")
def _apply_event(self, event: SystemEvent) -> None:
"""Patch ``self.data`` in place for the relevant event subclass."""
data = self.data
if data is None:
return
# Mutate a copy so listeners see a brand-new object identity.
new_data = replace(self.data, zones=zones)
self.async_set_updated_data(new_data)
new_data = self._patched_for_event(data, event)
if new_data is not None:
self.async_set_updated_data(new_data)
def _patched_for_event(
self, data: OmniData, event: SystemEvent
) -> OmniData | None:
"""Return a new OmniData reflecting ``event``, or ``None`` to skip.
Pure-ish (mutates only the dict members of the returned snapshot).
Split out so it stays unit-testable without HA.
"""
if isinstance(event, ZoneStateChanged):
existing = data.zone_status.get(event.zone_index)
if existing is None:
# We saw a zone the discovery missed — synthesize a record
# so entities at least see the open/closed flip.
new_status = ZoneStatus(
index=event.zone_index,
raw_status=0x01 if event.is_open else 0x00,
loop=0,
)
else:
# Toggle low-2-bit current condition; preserve the rest.
base = existing.raw_status & ~0x03
new_raw = base | (0x01 if event.is_open else 0x00)
new_status = ZoneStatus(
index=existing.index,
raw_status=new_raw,
loop=existing.loop,
)
patched = dict(data.zone_status)
patched[event.zone_index] = new_status
return replace(data, zone_status=patched, last_event=event)
if isinstance(event, UnitStateChanged):
existing = data.unit_status.get(event.unit_index)
new_state = 1 if event.is_on else 0
if existing is None:
new_status = UnitStatus(
index=event.unit_index,
state=new_state,
time_remaining_secs=0,
)
else:
# Preserve a brightness level if we have one — the event
# only carries on/off.
if existing.state >= 100 and event.is_on:
new_status = existing
else:
new_status = UnitStatus(
index=existing.index,
state=new_state,
time_remaining_secs=existing.time_remaining_secs,
)
patched = dict(data.unit_status)
patched[event.unit_index] = new_status
return replace(data, unit_status=patched, last_event=event)
if isinstance(event, ArmingChanged):
existing = data.area_status.get(event.area_index)
if existing is None:
if event.area_index == 0:
# System-wide arming change with no specific area —
# let the next poll resync.
return replace(data, last_event=event)
new_status = AreaStatus(
index=event.area_index,
mode=event.new_mode,
last_user=event.user_index,
entry_timer_secs=0,
exit_timer_secs=0,
alarms=0,
)
else:
new_status = AreaStatus(
index=existing.index,
mode=event.new_mode,
last_user=event.user_index,
entry_timer_secs=existing.entry_timer_secs,
exit_timer_secs=existing.exit_timer_secs,
alarms=existing.alarms,
)
patched = dict(data.area_status)
patched[new_status.index] = new_status
return replace(data, area_status=patched, last_event=event)
if isinstance(event, AlarmActivated | AlarmCleared):
# Force a poll so AreaStatus.alarms picks up the current bits.
self.hass.async_create_task(self.async_request_refresh())
return replace(data, last_event=event)
if isinstance(event, AcLost | AcRestored | BatteryLow | BatteryRestored):
# Just stash the event; the system_* binary sensors derive
# their state from `last_event` alone.
return replace(data, last_event=event)
# Other event families are interesting but don't move any
# currently-modeled state — record them for diagnostics so
# subscribers can still react via the last_event attribute.
return replace(data, last_event=event)

View File

@ -0,0 +1,85 @@
"""Diagnostics dump for an Omni panel config entry.
Captures a redacted snapshot of the coordinator's data so the user can
attach it to a bug report. Sensitive fields (controller key, PII in
device names) are stripped or hashed.
"""
from __future__ import annotations
import hashlib
from typing import TYPE_CHECKING, Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST, CONF_PORT
from .const import CONF_CONTROLLER_KEY, DOMAIN
from .coordinator import OmniDataUpdateCoordinator
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
REDACTED_KEYS = {CONF_CONTROLLER_KEY, "controller_key", "password", "code"}
def _hash_name(name: str) -> str:
"""Hash a panel-defined name so we can confirm uniqueness without leaking it."""
return "n_" + hashlib.sha256(name.encode("utf-8", errors="ignore")).hexdigest()[:12]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
data = coordinator.data
return {
"entry": {
"title": entry.title,
"data": async_redact_data(dict(entry.data), REDACTED_KEYS),
CONF_HOST: entry.data.get(CONF_HOST),
CONF_PORT: entry.data.get(CONF_PORT),
},
"panel": (
{
"model_byte": data.system_info.model_byte,
"model_name": data.system_info.model_name,
"firmware_version": data.system_info.firmware_version,
}
if data and data.system_info
else None
),
"discovered_counts": {
"zones": len(data.zones) if data else 0,
"units": len(data.units) if data else 0,
"areas": len(data.areas) if data else 0,
"thermostats": len(data.thermostats) if data else 0,
"buttons": len(data.buttons) if data else 0,
"programs": len(data.programs) if data else 0,
},
"live_status_counts": {
"zone_status": len(data.zone_status) if data else 0,
"unit_status": len(data.unit_status) if data else 0,
"area_status": len(data.area_status) if data else 0,
"thermostat_status": len(data.thermostat_status) if data else 0,
},
"name_hashes": (
{
"zones": {idx: _hash_name(props.name) for idx, props in data.zones.items()},
"units": {idx: _hash_name(props.name) for idx, props in data.units.items()},
"areas": {idx: _hash_name(props.name) for idx, props in data.areas.items()},
}
if data
else {}
),
"last_event_class": (
type(data.last_event).__name__ if data and data.last_event else None
),
"last_update_success": coordinator.last_update_success,
"update_interval_seconds": (
coordinator.update_interval.total_seconds()
if coordinator.update_interval
else None
),
}

View File

@ -0,0 +1,68 @@
"""Event platform — surfaces the panel's typed push events as a single
``EventEntity`` per panel. The event_type attribute carries the kind of
event; event_data carries the parsed details. Trigger automations on
``platform: event`` filtering by event_type or event_data.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from homeassistant.components.event import EventEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import OmniDataUpdateCoordinator
from .helpers import EVENT_TYPES, event_type_for
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([OmniPanelEvent(coordinator)])
class OmniPanelEvent(
CoordinatorEntity[OmniDataUpdateCoordinator], EventEntity
):
"""One event entity per panel; relays every push event the coordinator sees."""
_attr_has_entity_name = True
_attr_event_types: ClassVar[list[str]] = list(EVENT_TYPES)
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-events"
self._attr_name = "Panel Events"
self._attr_device_info = coordinator.device_info
self._last_event_id: int | None = None
def _handle_coordinator_update(self) -> None:
ev = self.coordinator.data.last_event
if ev is None:
return
# Only fire when the event reference actually changed; the
# coordinator may push other state without a new event arriving.
ev_id = id(ev)
if ev_id == self._last_event_id:
return
self._last_event_id = ev_id
event_data: dict[str, Any] = {"event_class": type(ev).__name__}
for key in (
"zone_index", "unit_index", "area_index", "user_index",
"new_state", "new_mode", "alarm_type",
):
if hasattr(ev, key):
event_data[key] = getattr(ev, key)
self._trigger_event(event_type_for(type(ev).__name__), event_data)
self.async_write_ha_state()

View File

@ -0,0 +1,412 @@
"""Pure helper functions for the omni_pca integration.
Anything in this module is deliberately decoupled from Home Assistant and
the live OmniClient so it can be unit-tested without either dependency.
The HA-side code (binary_sensor, etc.) imports these and converts the
returned strings to ``BinarySensorDeviceClass`` enum members.
"""
from __future__ import annotations
from typing import Final
# String values that correspond 1:1 to HA's BinarySensorDeviceClass enum
# members. We return strings here (instead of importing the enum) so this
# module stays importable without Home Assistant in the venv.
DEVICE_CLASS_OPENING: Final = "opening"
DEVICE_CLASS_DOOR: Final = "door"
DEVICE_CLASS_WINDOW: Final = "window"
DEVICE_CLASS_MOTION: Final = "motion"
DEVICE_CLASS_SMOKE: Final = "smoke"
DEVICE_CLASS_GAS: Final = "gas"
DEVICE_CLASS_MOISTURE: Final = "moisture"
DEVICE_CLASS_TAMPER: Final = "tamper"
DEVICE_CLASS_SAFETY: Final = "safety"
DEVICE_CLASS_PROBLEM: Final = "problem"
DEVICE_CLASS_SOUND: Final = "sound"
DEVICE_CLASS_HEAT: Final = "heat"
DEVICE_CLASS_COLD: Final = "cold"
# Maps the Omni ``enuZoneType`` byte (see ``omni_pca.models.ZoneType``) to
# a HA ``BinarySensorDeviceClass`` string. The mapping is a judgement
# call — Omni's zone-type taxonomy is finer-grained than HA's binary
# sensor classes, so we collapse a few buckets:
#
# * Perimeter / entry-exit / latching variants → opening
# (most installs use these for door/window contacts)
# * Interior / night / away interior → motion (PIRs)
# * Fire family (FIRE/FIRE_EMERGENCY/FIRE_TAMPER) → smoke
# * Water / freeze → moisture / cold
# * Gas → gas
# * Tamper / latching tamper → tamper
# * Panic / police / silent duress / aux-emerg → safety
# * Temperature / humidity / aux → not a binary sensor
# (callers should skip — see ``is_binary_zone_type``)
#
# The default for any unmapped value is "opening", which matches the
# dominant residential install (perimeter contact).
_ZONE_TYPE_TO_DEVICE_CLASS: dict[int, str] = {
# Burglary / contact zones
0: DEVICE_CLASS_OPENING, # ENTRY_EXIT
1: DEVICE_CLASS_OPENING, # PERIMETER
4: DEVICE_CLASS_OPENING, # DOUBLE_ENTRY_DELAY
5: DEVICE_CLASS_OPENING, # QUAD_ENTRY_DELAY
6: DEVICE_CLASS_OPENING, # LATCHING_PERIMETER
67: DEVICE_CLASS_OPENING, # EXIT_TERMINATOR
# Motion zones
2: DEVICE_CLASS_MOTION, # NIGHT_INTERIOR
3: DEVICE_CLASS_MOTION, # AWAY_INTERIOR
7: DEVICE_CLASS_MOTION, # LATCHING_NIGHT_INTERIOR
8: DEVICE_CLASS_MOTION, # LATCHING_AWAY_INTERIOR
# Panic / duress / police family
16: DEVICE_CLASS_SAFETY, # PANIC
17: DEVICE_CLASS_SAFETY, # POLICE_EMERGENCY
18: DEVICE_CLASS_SAFETY, # SILENT_DURESS
48: DEVICE_CLASS_SAFETY, # AUX_EMERGENCY
# Tamper
19: DEVICE_CLASS_TAMPER, # TAMPER
20: DEVICE_CLASS_TAMPER, # LATCHING_TAMPER
56: DEVICE_CLASS_TAMPER, # FIRE_TAMPER (treat as tamper, not smoke)
# Fire family
32: DEVICE_CLASS_SMOKE, # FIRE
33: DEVICE_CLASS_SMOKE, # FIRE_EMERGENCY
# Other safety / environmental
34: DEVICE_CLASS_GAS, # GAS
49: DEVICE_CLASS_PROBLEM, # TROUBLE
54: DEVICE_CLASS_COLD, # FREEZE
55: DEVICE_CLASS_MOISTURE, # WATER
# Sound / aux
64: DEVICE_CLASS_SOUND, # AUXILIARY (loose mapping; use sound)
65: DEVICE_CLASS_OPENING, # KEYSWITCH
66: DEVICE_CLASS_OPENING, # SHUNT_LOCK
}
# Zone-type bytes that don't map to a binary sensor at all — they're
# numeric readings (temperature, humidity, energy) and should be exposed
# via the sensor platform in Phase B instead. We skip these in
# binary_sensor setup.
_ANALOG_ZONE_TYPES: frozenset[int] = frozenset({
80, # ENERGY_SAVER
81, # OUTDOOR_TEMP
82, # TEMPERATURE
83, # TEMP_ALARM
84, # HUMIDITY
})
def device_class_for_zone_type(zone_type: int) -> str:
"""Return the HA ``BinarySensorDeviceClass`` value for an Omni zone type.
Defaults to ``"opening"`` the most common contact-sensor case for
any zone-type byte we don't have an explicit mapping for. Callers
should check :func:`is_binary_zone_type` first to decide whether the
zone makes sense as a binary sensor at all.
"""
return _ZONE_TYPE_TO_DEVICE_CLASS.get(zone_type, DEVICE_CLASS_OPENING)
def is_binary_zone_type(zone_type: int) -> bool:
"""True iff this zone type belongs on the binary_sensor platform.
Analog/numeric zone types (temperature, humidity, energy savers) are
sensor-platform candidates, not binary sensors, so we filter them out
here so the coordinator's discovery doesn't have to know.
"""
return zone_type not in _ANALOG_ZONE_TYPES
# Zone types whose live ``is_on`` semantics should be derived from the
# *latched* alarm bit (alarm tripped) rather than the current condition
# bit (open/closed). Smoke/fire/gas/water/freeze/panic are latching by
# nature — a smoke detector that flashed for one second still wants to
# read "on" until the user clears the alarm.
_LATCHED_ALARM_ZONE_TYPES: frozenset[int] = frozenset({
16, 17, 18, # panic family
19, 20, 56, # tamper family
32, 33, # fire family
34, # gas
48, # aux emergency
54, 55, # freeze, water
})
def use_latched_alarm_for_zone(zone_type: int) -> bool:
"""True if this zone's ``is_on`` should track the latched alarm bit.
For door/window/motion zones we use the *current condition* bit (live
open/closed). For latching alarm zones (smoke, water, panic, ) we
instead use the latched-tripped bit so a brief sensor blip stays
visible to the user until the alarm is cleared.
"""
return zone_type in _LATCHED_ALARM_ZONE_TYPES
def prettify_name(name: str) -> str:
"""Convert the panel's ``FRONT_DOOR`` style name into ``Front Door``.
Returns an empty string unchanged so callers can use truthiness to
detect "no name configured on this index".
"""
return name.replace("_", " ").strip().title()
# --------------------------------------------------------------------------
# Alarm panel state translation
# --------------------------------------------------------------------------
# String values matching HA's AlarmControlPanelState enum so this module
# stays importable without Home Assistant in the venv.
ALARM_STATE_DISARMED: Final = "disarmed"
ALARM_STATE_ARMED_HOME: Final = "armed_home"
ALARM_STATE_ARMED_AWAY: Final = "armed_away"
ALARM_STATE_ARMED_NIGHT: Final = "armed_night"
ALARM_STATE_ARMED_VACATION: Final = "armed_vacation"
ALARM_STATE_ARMED_CUSTOM_BYPASS: Final = "armed_custom_bypass"
ALARM_STATE_ARMING: Final = "arming"
ALARM_STATE_PENDING: Final = "pending"
ALARM_STATE_TRIGGERED: Final = "triggered"
# Maps SecurityMode (steady-state values) to HA alarm states. Arming-in-
# progress modes (9..14) get mapped via _ARMING_MODE_TO_FINAL — an arming
# area is always reported as ARMING regardless of the destination mode.
_SECURITY_MODE_TO_ALARM_STATE: dict[int, str] = {
0: ALARM_STATE_DISARMED,
1: ALARM_STATE_ARMED_HOME, # DAY
2: ALARM_STATE_ARMED_NIGHT, # NIGHT
3: ALARM_STATE_ARMED_AWAY, # AWAY
4: ALARM_STATE_ARMED_VACATION, # VACATION
5: ALARM_STATE_ARMED_CUSTOM_BYPASS, # DAY_INSTANT
6: ALARM_STATE_ARMED_NIGHT, # NIGHT_DELAYED
}
_ARMING_MODES: frozenset[int] = frozenset({9, 10, 11, 12, 13, 14})
def security_mode_to_alarm_state(
mode: int,
alarm_active: bool = False,
entry_timer: int = 0,
exit_timer: int = 0,
) -> str:
"""Map an Omni SecurityMode to a HA alarm_control_panel state string.
Priority order:
1. ``alarm_active`` triggered
2. ``entry_timer > 0`` pending
3. arming-in-progress modes or ``exit_timer > 0`` arming
4. steady-state mapping
"""
if alarm_active:
return ALARM_STATE_TRIGGERED
if entry_timer > 0:
return ALARM_STATE_PENDING
if mode in _ARMING_MODES or exit_timer > 0:
return ALARM_STATE_ARMING
return _SECURITY_MODE_TO_ALARM_STATE.get(mode, ALARM_STATE_DISARMED)
# Inverse for the four standard arm services HA exposes. Returned ints are
# the SecurityMode values to send via execute_security_command.
ARM_SERVICE_TO_SECURITY_MODE: dict[str, int] = {
"arm_home": 1, # DAY
"arm_away": 3, # AWAY
"arm_night": 2, # NIGHT
"arm_vacation": 4, # VACATION
"arm_custom_bypass": 5, # DAY_INSTANT
"disarm": 0, # OFF
}
# --------------------------------------------------------------------------
# Light brightness conversion (Omni 0..100 ↔ HA 0..255)
# --------------------------------------------------------------------------
def omni_state_to_ha_brightness(state: int) -> int | None:
"""Decode a UnitStatus.state byte into HA brightness (1..255) or None.
Returns None when the unit is off (state == 0). For state == 1 (plain
"on", non-dimmable) returns 255. For state in 100..200 returns
``round((state - 100) * 255 / 100)`` clamped to 1..255.
"""
if state == 0:
return None
if state == 1:
return 255
if 100 <= state <= 200:
percent = state - 100
return max(1, min(255, round(percent * 255 / 100)))
# Scene levels (2..13) and ramping codes (17..25): treat as on, full.
return 255
def ha_brightness_to_omni_percent(brightness: int) -> int:
"""Convert HA's 1..255 brightness to Omni's 0..100 percent.
Brightness 0 is invalid here (use turn_off); 1 maps to 1%, 255 to 100%.
"""
if brightness <= 0:
return 0
if brightness >= 255:
return 100
return max(1, min(100, round(brightness * 100 / 255)))
# --------------------------------------------------------------------------
# HVAC mode translation
# --------------------------------------------------------------------------
HVAC_MODE_OFF: Final = "off"
HVAC_MODE_HEAT: Final = "heat"
HVAC_MODE_COOL: Final = "cool"
HVAC_MODE_HEAT_COOL: Final = "heat_cool"
HVAC_MODE_AUX_HEAT: Final = "heat" # HA collapses emergency-heat into heat + preset
_OMNI_HVAC_TO_HA: dict[int, str] = {
0: HVAC_MODE_OFF,
1: HVAC_MODE_HEAT,
2: HVAC_MODE_COOL,
3: HVAC_MODE_HEAT_COOL,
4: HVAC_MODE_HEAT, # EMERGENCY_HEAT — HA treats as heat + a preset
}
_HA_HVAC_TO_OMNI: dict[str, int] = {
HVAC_MODE_OFF: 0,
HVAC_MODE_HEAT: 1,
HVAC_MODE_COOL: 2,
HVAC_MODE_HEAT_COOL: 3,
}
def omni_hvac_to_ha(mode: int) -> str:
return _OMNI_HVAC_TO_HA.get(mode, HVAC_MODE_OFF)
def ha_hvac_to_omni(mode: str) -> int:
return _HA_HVAC_TO_OMNI.get(mode, 0)
_OMNI_FAN_TO_HA: dict[int, str] = {0: "auto", 1: "on", 2: "diffuse"}
_HA_FAN_TO_OMNI: dict[str, int] = {"auto": 0, "on": 1, "diffuse": 2, "cycle": 2}
def omni_fan_to_ha(mode: int) -> str:
return _OMNI_FAN_TO_HA.get(mode, "auto")
def ha_fan_to_omni(mode: str) -> int:
return _HA_FAN_TO_OMNI.get(mode, 0)
_OMNI_HOLD_TO_HA: dict[int, str] = {0: "none", 1: "hold", 2: "vacation", 0xFF: "hold"}
_HA_HOLD_TO_OMNI: dict[str, int] = {"none": 0, "hold": 1, "vacation": 2}
def omni_hold_to_ha(mode: int) -> str:
return _OMNI_HOLD_TO_HA.get(mode, "none")
def ha_hold_to_omni(mode: str) -> int:
return _HA_HOLD_TO_OMNI.get(mode, 0)
# --------------------------------------------------------------------------
# Temperature: HA °F → Omni raw byte
# --------------------------------------------------------------------------
#
# Omni encodes temperature linearly. Per clsText.cs (DecodeTempRaw):
# °F = round(raw * 9 / 10) - 40
# °C = raw / 2 - 40
# Inverse:
# raw = round((°F + 40) * 10 / 9)
def fahrenheit_to_omni_raw(f: float) -> int:
"""Inverse of omni_temp_to_fahrenheit. Clamps to the valid 0..255 byte."""
raw = round((f + 40) * 10 / 9)
return max(0, min(255, raw))
def celsius_to_omni_raw(c: float) -> int:
"""Inverse of omni_temp_to_celsius. Clamps to the valid 0..255 byte."""
raw = round((c + 40) * 2)
return max(0, min(255, raw))
# --------------------------------------------------------------------------
# Analog zone → sensor device class
# --------------------------------------------------------------------------
SENSOR_DEVICE_CLASS_TEMPERATURE: Final = "temperature"
SENSOR_DEVICE_CLASS_HUMIDITY: Final = "humidity"
SENSOR_DEVICE_CLASS_POWER: Final = "power"
_ANALOG_ZONE_TYPE_TO_DEVICE_CLASS: dict[int, str] = {
80: SENSOR_DEVICE_CLASS_POWER, # ENERGY_SAVER
81: SENSOR_DEVICE_CLASS_TEMPERATURE, # OUTDOOR_TEMP
82: SENSOR_DEVICE_CLASS_TEMPERATURE, # TEMPERATURE
83: SENSOR_DEVICE_CLASS_TEMPERATURE, # TEMP_ALARM
84: SENSOR_DEVICE_CLASS_HUMIDITY, # HUMIDITY
}
def analog_zone_device_class(zone_type: int) -> str | None:
"""Return the HA SensorDeviceClass string for an analog zone, or None."""
return _ANALOG_ZONE_TYPE_TO_DEVICE_CLASS.get(zone_type)
# --------------------------------------------------------------------------
# Event surfacing
# --------------------------------------------------------------------------
# Snake_case event-type strings exposed by the EventEntity.
EVENT_TYPE_ZONE_STATE_CHANGED: Final = "zone_state_changed"
EVENT_TYPE_UNIT_STATE_CHANGED: Final = "unit_state_changed"
EVENT_TYPE_ARMING_CHANGED: Final = "arming_changed"
EVENT_TYPE_ALARM_ACTIVATED: Final = "alarm_activated"
EVENT_TYPE_ALARM_CLEARED: Final = "alarm_cleared"
EVENT_TYPE_AC_LOST: Final = "ac_lost"
EVENT_TYPE_AC_RESTORED: Final = "ac_restored"
EVENT_TYPE_BATTERY_LOW: Final = "battery_low"
EVENT_TYPE_BATTERY_RESTORED: Final = "battery_restored"
EVENT_TYPE_USER_MACRO_BUTTON: Final = "user_macro_button"
EVENT_TYPE_PHONE_LINE_DEAD: Final = "phone_line_dead"
EVENT_TYPE_PHONE_LINE_RESTORED: Final = "phone_line_restored"
EVENT_TYPE_UNKNOWN: Final = "unknown"
EVENT_TYPES: tuple[str, ...] = (
EVENT_TYPE_ZONE_STATE_CHANGED,
EVENT_TYPE_UNIT_STATE_CHANGED,
EVENT_TYPE_ARMING_CHANGED,
EVENT_TYPE_ALARM_ACTIVATED,
EVENT_TYPE_ALARM_CLEARED,
EVENT_TYPE_AC_LOST,
EVENT_TYPE_AC_RESTORED,
EVENT_TYPE_BATTERY_LOW,
EVENT_TYPE_BATTERY_RESTORED,
EVENT_TYPE_USER_MACRO_BUTTON,
EVENT_TYPE_PHONE_LINE_DEAD,
EVENT_TYPE_PHONE_LINE_RESTORED,
EVENT_TYPE_UNKNOWN,
)
def event_type_for(class_name: str) -> str:
"""Map a SystemEvent subclass name to its snake_case event type."""
mapping = {
"ZoneStateChanged": EVENT_TYPE_ZONE_STATE_CHANGED,
"UnitStateChanged": EVENT_TYPE_UNIT_STATE_CHANGED,
"ArmingChanged": EVENT_TYPE_ARMING_CHANGED,
"AlarmActivated": EVENT_TYPE_ALARM_ACTIVATED,
"AlarmCleared": EVENT_TYPE_ALARM_CLEARED,
"AcLost": EVENT_TYPE_AC_LOST,
"AcRestored": EVENT_TYPE_AC_RESTORED,
"BatteryLow": EVENT_TYPE_BATTERY_LOW,
"BatteryRestored": EVENT_TYPE_BATTERY_RESTORED,
"UserMacroButton": EVENT_TYPE_USER_MACRO_BUTTON,
"PhoneLineDead": EVENT_TYPE_PHONE_LINE_DEAD,
"PhoneLineRestored": EVENT_TYPE_PHONE_LINE_RESTORED,
}
return mapping.get(class_name, EVENT_TYPE_UNKNOWN)

View File

@ -0,0 +1,115 @@
"""Light platform — one HA light entity per discovered Omni unit.
We expose every unit as a dimmable light. On non-dimmable units the
panel silently ignores the brightness component and just toggles, so
the worst case is a relay that ignores the slider no harm done.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ColorMode,
LightEntity,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from omni_pca.commands import CommandFailedError
from .const import DOMAIN
from .coordinator import OmniDataUpdateCoordinator
from .helpers import (
ha_brightness_to_omni_percent,
omni_state_to_ha_brightness,
prettify_name,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [
OmniUnitLight(coordinator, index)
for index in sorted(coordinator.data.units)
]
async_add_entities(entities)
class OmniUnitLight(CoordinatorEntity[OmniDataUpdateCoordinator], LightEntity):
"""One discovered unit as a HA light."""
_attr_has_entity_name = True
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes: ClassVar[set[ColorMode]] = {ColorMode.BRIGHTNESS}
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator)
self._index = index
self._attr_unique_id = f"{coordinator.unique_id}-unit-{index}"
props = coordinator.data.units[index]
self._attr_name = prettify_name(props.name) or f"Unit {index}"
self._attr_device_info = coordinator.device_info
@property
def available(self) -> bool:
return (
super().available
and self.coordinator.data is not None
and self._index in self.coordinator.data.units
)
@property
def is_on(self) -> bool | None:
status = self.coordinator.data.unit_status.get(self._index)
if status is None:
return None
return status.is_on
@property
def brightness(self) -> int | None:
status = self.coordinator.data.unit_status.get(self._index)
if status is None:
return None
return omni_state_to_ha_brightness(status.state)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
status = self.coordinator.data.unit_status.get(self._index)
if status is None:
return None
return {
"unit_index": self._index,
"raw_state": status.state,
"time_remaining_secs": status.time_remaining_secs,
}
async def async_turn_on(self, **kwargs: Any) -> None:
try:
if ATTR_BRIGHTNESS in kwargs:
percent = ha_brightness_to_omni_percent(int(kwargs[ATTR_BRIGHTNESS]))
await self.coordinator.client.set_unit_level(self._index, percent)
else:
await self.coordinator.client.turn_unit_on(self._index)
except CommandFailedError as err:
raise HomeAssistantError(f"Panel rejected command: {err}") from err
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
try:
await self.coordinator.client.turn_unit_off(self._index)
except CommandFailedError as err:
raise HomeAssistantError(f"Panel rejected command: {err}") from err
await self.coordinator.async_request_refresh()

View File

@ -7,7 +7,7 @@
"dependencies": [],
"codeowners": ["@rsp2k"],
"requirements": ["omni-pca==2026.5.10"],
"documentation": "https://github.com/rsp2k/omni-pca",
"issue_tracker": "https://github.com/rsp2k/omni-pca/issues",
"documentation": "https://git.supported.systems/warehack.ing/omni-pca",
"issue_tracker": "https://git.supported.systems/warehack.ing/omni-pca/issues",
"integration_type": "hub"
}

View File

@ -0,0 +1,263 @@
"""Sensor platform — analog zones, thermostat readings, panel telemetry.
We deliberately re-expose thermostat current_temperature / humidity as
diagnostic sensors (in addition to the climate entity) so users can
plot history. The climate entity remains the canonical control surface.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import OmniDataUpdateCoordinator
from .helpers import (
SENSOR_DEVICE_CLASS_HUMIDITY,
SENSOR_DEVICE_CLASS_TEMPERATURE,
analog_zone_device_class,
is_binary_zone_type,
prettify_name,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
_DEVICE_CLASS_STR_TO_ENUM: dict[str, SensorDeviceClass] = {
SENSOR_DEVICE_CLASS_TEMPERATURE: SensorDeviceClass.TEMPERATURE,
SENSOR_DEVICE_CLASS_HUMIDITY: SensorDeviceClass.HUMIDITY,
"power": SensorDeviceClass.POWER,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[SensorEntity] = []
# Analog zones (temperature / humidity / energy)
for index in sorted(coordinator.data.zones):
props = coordinator.data.zones[index]
if is_binary_zone_type(props.zone_type):
continue
device_class_str = analog_zone_device_class(props.zone_type)
if device_class_str is None:
continue
entities.append(
OmniAnalogZoneSensor(coordinator, index, device_class_str)
)
# Per-thermostat diagnostic sensors
for index in sorted(coordinator.data.thermostats):
entities.append(OmniThermostatTempSensor(coordinator, index))
entities.append(OmniThermostatHumiditySensor(coordinator, index))
entities.append(OmniThermostatOutdoorTempSensor(coordinator, index))
entities.append(OmniSystemModelSensor(coordinator))
entities.append(OmniLastEventSensor(coordinator))
async_add_entities(entities)
# --------------------------------------------------------------------------
# Analog zones
# --------------------------------------------------------------------------
class OmniAnalogZoneSensor(
CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity
):
_attr_has_entity_name = True
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(
self,
coordinator: OmniDataUpdateCoordinator,
index: int,
device_class_str: str,
) -> None:
super().__init__(coordinator)
self._index = index
self._attr_unique_id = f"{coordinator.unique_id}-zone-{index}-analog"
props = coordinator.data.zones[index]
self._attr_name = prettify_name(props.name) or f"Zone {index}"
self._attr_device_info = coordinator.device_info
self._attr_device_class = _DEVICE_CLASS_STR_TO_ENUM.get(device_class_str)
if device_class_str == SENSOR_DEVICE_CLASS_TEMPERATURE:
self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT
elif device_class_str == SENSOR_DEVICE_CLASS_HUMIDITY:
self._attr_native_unit_of_measurement = PERCENTAGE
@property
def native_value(self) -> float | int | None:
status = self.coordinator.data.zone_status.get(self._index)
if status is None:
return None
# Reuse the linear temp formula for temperature zones; humidity
# zones report the loop byte as the percentage directly.
if self._attr_device_class == SensorDeviceClass.TEMPERATURE:
return round(status.loop * 9 / 10) - 40
return status.loop
# --------------------------------------------------------------------------
# Thermostat diagnostic sensors
# --------------------------------------------------------------------------
class _ThermostatBase(CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity):
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator)
self._index = index
self._attr_device_info = coordinator.device_info
@property
def _status(self): # type: ignore[no-untyped-def]
return self.coordinator.data.thermostat_status.get(self._index)
class OmniThermostatTempSensor(_ThermostatBase):
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator, index)
self._attr_unique_id = f"{coordinator.unique_id}-thermostat-{index}-temp"
props = coordinator.data.thermostats[index]
base = prettify_name(props.name) or f"Thermostat {index}"
self._attr_name = f"{base} Temperature"
@property
def native_value(self) -> float | None:
s = self._status
if s is None or s.temperature_raw == 0:
return None
return round(s.temperature_f, 1)
class OmniThermostatHumiditySensor(_ThermostatBase):
_attr_device_class = SensorDeviceClass.HUMIDITY
_attr_native_unit_of_measurement = PERCENTAGE
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator, index)
self._attr_unique_id = f"{coordinator.unique_id}-thermostat-{index}-humidity"
props = coordinator.data.thermostats[index]
base = prettify_name(props.name) or f"Thermostat {index}"
self._attr_name = f"{base} Humidity"
@property
def native_value(self) -> int | None:
s = self._status
if s is None or s.humidity_raw == 0:
return None
return int(s.humidity_raw)
class OmniThermostatOutdoorTempSensor(_ThermostatBase):
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator, index)
self._attr_unique_id = f"{coordinator.unique_id}-thermostat-{index}-outdoor"
props = coordinator.data.thermostats[index]
base = prettify_name(props.name) or f"Thermostat {index}"
self._attr_name = f"{base} Outdoor Temperature"
@property
def native_value(self) -> float | None:
s = self._status
if s is None or s.outdoor_temperature_raw == 0:
return None
return round(s.outdoor_temperature_f, 1)
# --------------------------------------------------------------------------
# Panel telemetry
# --------------------------------------------------------------------------
class OmniSystemModelSensor(
CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity
):
"""Static text sensor: model + firmware. Helps confirm the integration
talked to the panel without needing to dig into Devices & Services."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-system-model"
self._attr_name = "Panel Model"
self._attr_device_info = coordinator.device_info
@property
def native_value(self) -> str | None:
info = self.coordinator.data.system_info
if info is None:
return None
return f"{info.model_name} {info.firmware_version}"
class OmniLastEventSensor(
CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity
):
"""Diagnostic text sensor showing the most recent push event class name."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-last-event"
self._attr_name = "Last Panel Event"
self._attr_device_info = coordinator.device_info
@property
def native_value(self) -> str | None:
ev = self.coordinator.data.last_event
if ev is None:
return None
return type(ev).__name__
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
ev = self.coordinator.data.last_event
if ev is None:
return None
result: dict[str, Any] = {"event_class": type(ev).__name__}
for key in (
"zone_index", "unit_index", "area_index", "user_index",
"new_state", "new_mode", "alarm_type",
):
if hasattr(ev, key):
result[key] = getattr(ev, key)
return result

View File

@ -0,0 +1,185 @@
"""Service handlers for the omni_pca integration.
Services give the user a write-surface for things the entity layer
doesn't naturally expose: program execution (no Properties opcode for
Programs in v1.0), arbitrary panel messages, raw commands for power
users, and panel-wide alert acknowledgement.
All services route through the per-entry coordinator's ``OmniClient``;
each accepts an ``entry_id`` field so HA can pick the right panel when
multiple are configured.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.const import ATTR_CONFIG_ENTRY_ID as CONF_ENTRY_ID
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from omni_pca.commands import Command, CommandFailedError
from .const import DOMAIN, LOGGER
from .coordinator import OmniDataUpdateCoordinator
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant, ServiceCall
SERVICE_BYPASS_ZONE = "bypass_zone"
SERVICE_RESTORE_ZONE = "restore_zone"
SERVICE_EXECUTE_PROGRAM = "execute_program"
SERVICE_SHOW_MESSAGE = "show_message"
SERVICE_CLEAR_MESSAGE = "clear_message"
SERVICE_ACKNOWLEDGE_ALERTS = "acknowledge_alerts"
SERVICE_SEND_COMMAND = "send_command"
ATTR_ZONE_INDEX = "zone_index"
ATTR_PROGRAM_INDEX = "program_index"
ATTR_MESSAGE_INDEX = "message_index"
ATTR_COMMAND = "command"
ATTR_PARAM_1 = "parameter1"
ATTR_PARAM_2 = "parameter2"
_BASE_SCHEMA = vol.Schema({vol.Required(CONF_ENTRY_ID): cv.string})
def _zone_schema() -> vol.Schema:
return _BASE_SCHEMA.extend(
{vol.Required(ATTR_ZONE_INDEX): vol.All(int, vol.Range(min=1, max=0xFFFF))}
)
def _program_schema() -> vol.Schema:
return _BASE_SCHEMA.extend(
{vol.Required(ATTR_PROGRAM_INDEX): vol.All(int, vol.Range(min=1, max=0xFFFF))}
)
def _message_schema() -> vol.Schema:
return _BASE_SCHEMA.extend(
{vol.Required(ATTR_MESSAGE_INDEX): vol.All(int, vol.Range(min=1, max=0xFFFF))}
)
def _command_schema() -> vol.Schema:
return _BASE_SCHEMA.extend(
{
vol.Required(ATTR_COMMAND): vol.All(int, vol.Range(min=0, max=255)),
vol.Optional(ATTR_PARAM_1, default=0): vol.All(
int, vol.Range(min=0, max=255)
),
vol.Optional(ATTR_PARAM_2, default=0): vol.All(
int, vol.Range(min=0, max=0xFFFF)
),
}
)
def _coordinator_for(
hass: HomeAssistant, call: ServiceCall
) -> OmniDataUpdateCoordinator:
entry_id = call.data[CONF_ENTRY_ID]
coordinators = hass.data.get(DOMAIN, {})
if entry_id not in coordinators:
raise ServiceValidationError(
f"No Omni panel configured with entry_id {entry_id!r}"
)
return coordinators[entry_id]
async def _wrap(coro_factory) -> None: # type: ignore[no-untyped-def]
try:
await coro_factory()
except CommandFailedError as err:
raise HomeAssistantError(f"Panel rejected command: {err}") from err
async def async_setup_services(hass: HomeAssistant) -> None:
"""Register all services for the integration. Idempotent."""
if hass.services.has_service(DOMAIN, SERVICE_BYPASS_ZONE):
return # already registered (multiple entries reuse the same services)
async def _bypass_zone(call: ServiceCall) -> None:
coord = _coordinator_for(hass, call)
idx = int(call.data[ATTR_ZONE_INDEX])
await _wrap(lambda: coord.client.bypass_zone(idx))
async def _restore_zone(call: ServiceCall) -> None:
coord = _coordinator_for(hass, call)
idx = int(call.data[ATTR_ZONE_INDEX])
await _wrap(lambda: coord.client.restore_zone(idx))
async def _execute_program(call: ServiceCall) -> None:
coord = _coordinator_for(hass, call)
idx = int(call.data[ATTR_PROGRAM_INDEX])
await _wrap(lambda: coord.client.execute_program(idx))
async def _show_message(call: ServiceCall) -> None:
coord = _coordinator_for(hass, call)
idx = int(call.data[ATTR_MESSAGE_INDEX])
await _wrap(lambda: coord.client.show_message(idx))
async def _clear_message(call: ServiceCall) -> None:
coord = _coordinator_for(hass, call)
idx = int(call.data[ATTR_MESSAGE_INDEX])
await _wrap(lambda: coord.client.clear_message(idx))
async def _acknowledge_alerts(call: ServiceCall) -> None:
coord = _coordinator_for(hass, call)
await _wrap(lambda: coord.client.acknowledge_alerts())
async def _send_command(call: ServiceCall) -> None:
coord = _coordinator_for(hass, call)
cmd_byte = int(call.data[ATTR_COMMAND])
try:
cmd = Command(cmd_byte)
except ValueError as err:
raise ServiceValidationError(
f"Unknown Command code {cmd_byte}; see omni_pca.commands.Command"
) from err
p1 = int(call.data[ATTR_PARAM_1])
p2 = int(call.data[ATTR_PARAM_2])
LOGGER.debug("send_command %s p1=%d p2=%d", cmd.name, p1, p2)
await _wrap(lambda: coord.client.execute_command(cmd, p1, p2))
hass.services.async_register(
DOMAIN, SERVICE_BYPASS_ZONE, _bypass_zone, schema=_zone_schema()
)
hass.services.async_register(
DOMAIN, SERVICE_RESTORE_ZONE, _restore_zone, schema=_zone_schema()
)
hass.services.async_register(
DOMAIN, SERVICE_EXECUTE_PROGRAM, _execute_program, schema=_program_schema()
)
hass.services.async_register(
DOMAIN, SERVICE_SHOW_MESSAGE, _show_message, schema=_message_schema()
)
hass.services.async_register(
DOMAIN, SERVICE_CLEAR_MESSAGE, _clear_message, schema=_message_schema()
)
hass.services.async_register(
DOMAIN, SERVICE_ACKNOWLEDGE_ALERTS, _acknowledge_alerts, schema=_BASE_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_SEND_COMMAND, _send_command, schema=_command_schema()
)
async def async_unload_services(hass: HomeAssistant) -> None:
"""Tear down services if no entries remain."""
if hass.data.get(DOMAIN):
return # other entries still active
for svc in (
SERVICE_BYPASS_ZONE,
SERVICE_RESTORE_ZONE,
SERVICE_EXECUTE_PROGRAM,
SERVICE_SHOW_MESSAGE,
SERVICE_CLEAR_MESSAGE,
SERVICE_ACKNOWLEDGE_ALERTS,
SERVICE_SEND_COMMAND,
):
hass.services.async_remove(DOMAIN, svc)

View File

@ -0,0 +1,159 @@
bypass_zone:
name: Bypass zone
description: Bypass a single zone (panel ignores it until restored).
fields:
entry_id:
name: Panel
description: Config entry ID of the Omni panel.
required: true
selector:
config_entry:
integration: omni_pca
zone_index:
name: Zone index
description: 1-based zone number on the panel.
required: true
selector:
number:
min: 1
max: 176
mode: box
restore_zone:
name: Restore zone
description: Restore a previously-bypassed zone.
fields:
entry_id:
name: Panel
description: Config entry ID of the Omni panel.
required: true
selector:
config_entry:
integration: omni_pca
zone_index:
name: Zone index
description: 1-based zone number on the panel.
required: true
selector:
number:
min: 1
max: 176
mode: box
execute_program:
name: Execute program
description: Run a stored program on the panel by its 1-based index.
fields:
entry_id:
name: Panel
description: Config entry ID of the Omni panel.
required: true
selector:
config_entry:
integration: omni_pca
program_index:
name: Program index
description: 1-based program number on the panel.
required: true
selector:
number:
min: 1
max: 1024
mode: box
show_message:
name: Show panel message
description: Display a stored message on panel consoles by message index.
fields:
entry_id:
name: Panel
description: Config entry ID of the Omni panel.
required: true
selector:
config_entry:
integration: omni_pca
message_index:
name: Message index
description: 1-based stored message number.
required: true
selector:
number:
min: 1
max: 128
mode: box
clear_message:
name: Clear panel message
description: Clear the currently-displayed message on panel consoles.
fields:
entry_id:
name: Panel
description: Config entry ID of the Omni panel.
required: true
selector:
config_entry:
integration: omni_pca
message_index:
name: Message index
description: 1-based stored message number to clear.
required: true
selector:
number:
min: 1
max: 128
mode: box
acknowledge_alerts:
name: Acknowledge alerts
description: Acknowledge all outstanding alerts and trouble conditions on the panel.
fields:
entry_id:
name: Panel
description: Config entry ID of the Omni panel.
required: true
selector:
config_entry:
integration: omni_pca
send_command:
name: Send raw command
description: >
Send a raw Omni Command (opcode 20). Power-user escape hatch — see
omni_pca.commands.Command for the full enumeration.
fields:
entry_id:
name: Panel
description: Config entry ID of the Omni panel.
required: true
selector:
config_entry:
integration: omni_pca
command:
name: Command code
description: Numeric Command enum value (0-255).
required: true
selector:
number:
min: 0
max: 255
mode: box
parameter1:
name: Parameter 1
description: First command parameter (single byte 0-255).
required: false
default: 0
selector:
number:
min: 0
max: 255
mode: box
parameter2:
name: Parameter 2
description: Second command parameter (BE uint16, 0-65535).
required: false
default: 0
selector:
number:
min: 0
max: 65535
mode: box

View File

@ -0,0 +1,89 @@
"""Switch platform — per-zone bypass control.
Lights are exposed via the ``light`` platform. The switch platform is
reserved for *configuration* toggles like zone bypass, where the user
wants a write surface that pairs with the diagnostic ``zone bypassed``
binary sensor for read.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from omni_pca.commands import CommandFailedError
from .const import DOMAIN
from .coordinator import OmniDataUpdateCoordinator
from .helpers import is_binary_zone_type, prettify_name
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[SwitchEntity] = []
for index in sorted(coordinator.data.zones):
props = coordinator.data.zones[index]
if not is_binary_zone_type(props.zone_type):
continue
entities.append(OmniZoneBypassSwitch(coordinator, index))
async_add_entities(entities)
class OmniZoneBypassSwitch(CoordinatorEntity[OmniDataUpdateCoordinator], SwitchEntity):
"""Toggle that bypasses or restores a single zone."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator)
self._index = index
self._attr_unique_id = f"{coordinator.unique_id}-zone-{index}-bypass"
props = coordinator.data.zones[index]
base = prettify_name(props.name) or f"Zone {index}"
self._attr_name = f"{base} Bypass"
self._attr_device_info = coordinator.device_info
@property
def available(self) -> bool:
return (
super().available
and self.coordinator.data is not None
and self._index in self.coordinator.data.zones
)
@property
def is_on(self) -> bool | None:
status = self.coordinator.data.zone_status.get(self._index)
if status is None:
return None
return status.is_bypassed
async def async_turn_on(self, **kwargs: Any) -> None:
try:
await self.coordinator.client.bypass_zone(self._index)
except CommandFailedError as err:
raise HomeAssistantError(f"Panel rejected bypass: {err}") from err
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
try:
await self.coordinator.client.restore_zone(self._index)
except CommandFailedError as err:
raise HomeAssistantError(f"Panel rejected restore: {err}") from err
await self.coordinator.async_request_refresh()

26
dev/Makefile Normal file
View File

@ -0,0 +1,26 @@
.PHONY: dev-up dev-down dev-logs dev-mock dev-reset
# Boot HA + MockPanel stack
dev-up:
docker compose -f docker-compose.yml up -d
@echo
@echo "HA → http://localhost:8123 (first-run onboarding required)"
@echo "Mock panel → host.docker.internal:14369"
@echo "Controller key → 000102030405060708090a0b0c0d0e0f"
dev-down:
docker compose -f docker-compose.yml down
dev-logs:
docker compose -f docker-compose.yml logs -f
# Run only the mock on the host (no docker), useful when you want to
# point a host-side OmniClient at it.
dev-mock:
cd .. && uv run python dev/run_mock_panel.py --host 127.0.0.1 --port 14369
# Wipe the HA config dir (clears onboarding + entities).
dev-reset:
docker compose -f docker-compose.yml down
rm -rf ha-config
mkdir -p ha-config

54
dev/README.md Normal file
View File

@ -0,0 +1,54 @@
# Dev stack
Local Home Assistant + MockPanel for clicking around the integration without a
real Omni controller. Useful for screenshots, manual smoke tests, and seeing
what the entity layout looks like.
## Quick start
```bash
cd dev/
make dev-up # docker compose up -d
# wait ~30s for HA to boot
open http://localhost:8123
```
First time: HA onboarding wizard (any name / location works). Then:
1. **Settings → Devices & Services → Add Integration**
2. Search for **HAI/Leviton Omni Panel**
3. Fill in:
- host: `host.docker.internal`
- port: `14369`
- controller key: `000102030405060708090a0b0c0d0e0f`
4. Submit. Within a few seconds you should see the Omni Pro II device with
~25 entities (binary sensors, lights, alarm panel, climate, sensors,
buttons, switches, the events entity).
## What the mock simulates
Five named zones, four units, two areas, two thermostats, three button
macros. User codes `1234` (master, code index 1) and `5678` (code index 2).
Arming the alarm with code `1234` will succeed and the
`alarm_control_panel` entity transitions through ARMING → ARMED_AWAY in
real time via the panel's push-event simulation. Wrong code → HA error
toast, panel stays disarmed.
## Other targets
```bash
make dev-logs # tail HA + mock logs
make dev-mock # run only the mock on the host (no docker)
make dev-down # stop the stack
make dev-reset # wipe HA config and start fresh
```
## Notes
- The HA container mounts `../custom_components/omni_pca/` read-only, so
edits to the integration need a restart (`docker compose restart
homeassistant`) to take effect.
- The mock panel binds `0.0.0.0:14369` inside the container. If you
prefer to talk to it from the host directly (e.g. with `omni-pca`
CLI), use `make dev-mock` to run it natively.

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

48
dev/docker-compose.yml Normal file
View File

@ -0,0 +1,48 @@
# Local dev stack: real Home Assistant talking to a MockPanel running on
# the host. Lets you click around the UI and grab screenshots without a
# physical Omni controller.
#
# make dev-up # start
# make dev-logs # tail HA logs
# make dev-down # stop and clean
#
# Once running, open http://localhost:8123 and:
# 1. Onboard with any name / location.
# 2. Settings -> Devices & Services -> Add Integration ->
# "HAI/Leviton Omni Panel".
# 3. Use:
# host host.docker.internal
# port 14369
# controller_key 000102030405060708090a0b0c0d0e0f
# (matches scripts/run_mock_panel.py defaults)
services:
mock-panel:
image: ghcr.io/astral-sh/uv:python3.14-bookworm-slim
working_dir: /tmp/mock
volumes:
- ../src:/tmp/mock/src:ro
- ./run_mock_panel.py:/tmp/mock/run_mock_panel.py:ro
environment:
PYTHONPATH: /tmp/mock/src
command:
- sh
- -c
- "uv pip install --system --quiet cryptography && python /tmp/mock/run_mock_panel.py --host 0.0.0.0 --port 14369"
ports:
- "14369:14369"
homeassistant:
image: ghcr.io/home-assistant/home-assistant:2026.5
container_name: omni-pca-dev-ha
depends_on:
- mock-panel
volumes:
- ./ha-config:/config
- ../custom_components/omni_pca:/config/custom_components/omni_pca:ro
ports:
- "8123:8123"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- TZ=America/Boise

112
dev/run_mock_panel.py Normal file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Launch a long-running MockPanel suitable for the docker-compose dev stack.
Reuses the mock fixture from the test suite so the behaviour matches what
the HA integration tests prove out. Defaults match dev/docker-compose.yml.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import signal
import sys
from omni_pca.mock_panel import (
MockAreaState,
MockButtonState,
MockPanel,
MockState,
MockThermostatState,
MockUnitState,
MockZoneState,
)
DEFAULT_KEY_HEX = "000102030405060708090a0b0c0d0e0f"
def _populated_state() -> MockState:
"""A small but representative set of objects so HA shows real entities."""
return MockState(
zones={
1: MockZoneState(name="FRONT_DOOR"),
2: MockZoneState(name="GARAGE_ENTRY"),
3: MockZoneState(name="BACK_DOOR"),
10: MockZoneState(name="LIVING_MOTION"),
11: MockZoneState(name="HALL_MOTION"),
},
units={
1: MockUnitState(name="LIVING_LAMP"),
2: MockUnitState(name="KITCHEN_OVERHEAD"),
3: MockUnitState(name="FRONT_PORCH"),
4: MockUnitState(name="BEDROOM_FAN"),
},
areas={
1: MockAreaState(name="MAIN"),
2: MockAreaState(name="GUEST"),
},
thermostats={
1: MockThermostatState(name="LIVING_ROOM"),
2: MockThermostatState(name="MASTER_BEDROOM"),
},
buttons={
1: MockButtonState(name="GOOD_MORNING"),
2: MockButtonState(name="MOVIE_MODE"),
3: MockButtonState(name="GOODNIGHT"),
},
user_codes={1: 1234, 2: 5678},
)
async def _serve(host: str, port: int, key: bytes) -> None:
panel = MockPanel(controller_key=key, state=_populated_state())
async with panel.serve(host=host, port=port) as (bound_host, bound_port):
logging.info("MockPanel listening on %s:%d", bound_host, bound_port)
logging.info("Use this controller key in HA: %s", key.hex())
stop = asyncio.Event()
def _on_signal() -> None:
logging.info("shutdown signal received")
stop.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _on_signal)
await stop.wait()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=14369)
parser.add_argument(
"--controller-key",
default=DEFAULT_KEY_HEX,
help="32 hex chars; default is the docker-compose value",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
try:
key = bytes.fromhex(args.controller_key)
except ValueError:
print(f"controller-key must be 32 hex chars: {args.controller_key!r}",
file=sys.stderr)
return 2
if len(key) != 16:
print(f"controller-key must decode to exactly 16 bytes (got {len(key)})",
file=sys.stderr)
return 2
asyncio.run(_serve(args.host, args.port, key))
return 0
if __name__ == "__main__":
raise SystemExit(main())

318
dev/screenshot.py Normal file
View File

@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""End-to-end demo: onboard HA, add the omni_pca integration against the
mock panel, drive playwright through the resulting UI to capture screenshots.
Run inside the project venv:
uv run --with playwright --with httpx --with websockets \
python dev/screenshot.py [--outdir DIR] [--ha-url URL]
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from datetime import datetime
from pathlib import Path
import httpx
from playwright.async_api import async_playwright
DEFAULT_HA_URL = "http://localhost:8123"
DEFAULT_USERNAME = "demo"
DEFAULT_PASSWORD = "demo-password-1234"
PANEL_HOST = "host.docker.internal"
PANEL_PORT = 14369
CONTROLLER_KEY = "000102030405060708090a0b0c0d0e0f"
async def _complete_onboarding(
client: httpx.AsyncClient, headers: dict[str, str], ha_url: str
) -> None:
"""POST every remaining onboarding step in turn so HA stops greeting us."""
r = await client.get("/api/onboarding")
pending = [s["step"] for s in r.json() if not s.get("done")]
print(f" pending onboarding: {pending}")
if "core_config" in pending:
try:
r = await client.post("/api/onboarding/core_config", headers=headers)
print(f" core_config -> {r.status_code}")
except Exception as e:
print(f" core_config error: {e}")
if "analytics" in pending:
try:
r = await client.post(
"/api/onboarding/analytics",
headers=headers,
json={},
)
print(f" analytics -> {r.status_code}")
except Exception as e:
print(f" analytics error: {e}")
if "integration" in pending:
try:
r = await client.post(
"/api/onboarding/integration",
headers=headers,
json={"client_id": ha_url, "redirect_uri": ha_url},
)
print(f" integration -> {r.status_code}")
except Exception as e:
print(f" integration error: {e}")
async def _onboard(ha_url: str) -> str:
"""Run HA's onboarding REST flow if needed. Returns access token.
HA uses the authorization_code OAuth flow. On first-run, POSTing to
/api/onboarding/users returns an auth_code that we exchange for tokens.
On subsequent runs, we use a long-lived access token created during
the first run (persisted in ha-config/.storage/auth).
"""
async with httpx.AsyncClient(base_url=ha_url, timeout=30.0) as client:
r = await client.get("/api/onboarding")
steps = r.json()
user_step = next((s for s in steps if s["step"] == "user"), None)
if user_step and not user_step.get("done"):
# First-run path: create user, get auth_code, exchange.
r = await client.post(
"/api/onboarding/users",
json={
"client_id": ha_url,
"name": "Demo User",
"username": DEFAULT_USERNAME,
"password": DEFAULT_PASSWORD,
"language": "en",
},
)
r.raise_for_status()
auth_code = r.json()["auth_code"]
print(" ✓ user created")
r = await client.post(
"/auth/token",
data={
"client_id": ha_url,
"grant_type": "authorization_code",
"code": auth_code,
},
)
r.raise_for_status()
access_token = r.json()["access_token"]
# Complete the remaining onboarding steps so we land on the
# dashboard rather than the discovery wizard.
headers = {"Authorization": f"Bearer {access_token}"}
await _complete_onboarding(client, headers, ha_url)
return access_token
# Subsequent-run path: log in via /auth/login_flow.
token_file = (
Path(__file__).parent / "ha-config" / ".storage" / ".demo_access_token"
)
if token_file.exists():
print(" re-using cached demo access token")
return token_file.read_text().strip()
print(" logging in via /auth/login_flow")
r = await client.post(
"/auth/login_flow",
json={
"client_id": ha_url,
"handler": ["homeassistant", None],
"redirect_uri": ha_url,
},
)
r.raise_for_status()
flow_id = r.json()["flow_id"]
r = await client.post(
f"/auth/login_flow/{flow_id}",
json={
"client_id": ha_url,
"username": DEFAULT_USERNAME,
"password": DEFAULT_PASSWORD,
},
)
r.raise_for_status()
auth_code = r.json()["result"]
r = await client.post(
"/auth/token",
data={
"client_id": ha_url,
"grant_type": "authorization_code",
"code": auth_code,
},
)
r.raise_for_status()
return r.json()["access_token"]
async def _add_integration(ha_url: str, token: str) -> None:
"""Add the omni_pca config entry via the REST config-flow endpoints."""
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(base_url=ha_url, timeout=30.0) as client:
r = await client.get("/api/config/config_entries/entry", headers=headers)
r.raise_for_status()
for entry in r.json():
if entry.get("domain") == "omni_pca":
print(f" integration already configured: {entry['title']}")
return
r = await client.post(
"/api/config/config_entries/flow",
headers=headers,
json={"handler": "omni_pca", "show_advanced_options": False},
)
r.raise_for_status()
flow = r.json()
flow_id = flow["flow_id"]
print(f" config flow opened: {flow_id} (step={flow.get('step_id')})")
r = await client.post(
f"/api/config/config_entries/flow/{flow_id}",
headers=headers,
json={
"host": PANEL_HOST,
"port": PANEL_PORT,
"controller_key": CONTROLLER_KEY,
},
)
r.raise_for_status()
result = r.json()
if result.get("type") == "create_entry":
print(f" ✓ entry created: {result.get('title')}")
else:
raise RuntimeError(f"unexpected flow outcome: {result}")
async def _take_screenshots(ha_url: str, token: str, outdir: Path) -> list[Path]:
"""Drive playwright through a few interesting pages."""
outdir.mkdir(parents=True, exist_ok=True)
shots: list[Path] = []
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
viewport={"width": 1440, "height": 900},
device_scale_factor=2,
)
# Inject auth so we skip the login screen.
await context.add_init_script(
f"""window.localStorage.setItem('hassTokens', JSON.stringify({{
access_token: '{token}',
token_type: 'Bearer',
expires_in: 1800,
hassUrl: '{ha_url}',
clientId: '{ha_url}',
expires: Date.now() + 1800000,
refresh_token: 'placeholder',
}}));
window.localStorage.setItem('selectedLanguage', '"en"');
"""
)
page = await context.new_page()
async def shot(filename: str, url: str, *, wait_for: str | None = None,
wait_secs: float = 4.0) -> None:
print(f"{filename} ({url})")
try:
await page.goto(f"{ha_url}{url}", wait_until="networkidle",
timeout=30000)
except Exception as e:
print(f" nav warning: {e}")
if wait_for:
try:
await page.locator(wait_for).first.wait_for(timeout=10000)
except Exception:
pass
await page.wait_for_timeout(int(wait_secs * 1000))
path = outdir / filename
await page.screenshot(path=str(path), full_page=False)
shots.append(path)
# Make sure onboarding is fully complete before we screenshot anything.
async with httpx.AsyncClient(base_url=ha_url, timeout=15.0) as client:
await _complete_onboarding(
client, {"Authorization": f"Bearer {token}"}, ha_url
)
# Look up the panel device id so we can deep-link to its page.
device_id: str | None = None
async with httpx.AsyncClient(base_url=ha_url, timeout=15.0) as client:
r = await client.post(
"/api/template",
headers={"Authorization": f"Bearer {token}"},
json={"template": "{{ device_id('sensor.omni_pro_ii_panel_model') }}"},
)
if r.status_code == 200:
device_id = (r.text or "").strip().strip('"')
if device_id and device_id != "None":
print(f" panel device_id: {device_id}")
else:
device_id = None
await shot("01-overview.png", "/lovelace/0", wait_secs=5.0)
await shot("02-integrations-list.png",
"/config/integrations/dashboard", wait_secs=4.0)
await shot("03-omni-pca-config.png",
"/config/integrations/integration/omni_pca", wait_secs=4.0)
if device_id:
await shot("04-panel-device.png",
f"/config/devices/device/{device_id}", wait_secs=4.0)
await shot(
"05-entities-omni.png",
'/config/entities?config_entry=' + 'omni_pca',
wait_secs=4.0,
)
await shot("06-developer-states.png",
"/developer-tools/state", wait_secs=4.0)
await browser.close()
return shots
async def amain(args: argparse.Namespace) -> int:
print("[1/3] HA onboarding...")
token = await _onboard(args.ha_url)
# Cache token so subsequent runs against an already-onboarded HA can reuse.
token_file = (
Path(__file__).parent / "ha-config" / ".storage" / ".demo_access_token"
)
try:
token_file.parent.mkdir(parents=True, exist_ok=True)
token_file.write_text(token)
except Exception:
pass
print("[2/3] adding omni_pca integration...")
await _add_integration(args.ha_url, token)
# Give HA a moment to discover all entities.
await asyncio.sleep(8)
print("[3/3] capturing screenshots...")
shots = await _take_screenshots(args.ha_url, token, args.outdir)
print(f"\n✓ wrote {len(shots)} screenshots to {args.outdir}")
for p in shots:
print(f" {p}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--ha-url", default=DEFAULT_HA_URL)
parser.add_argument(
"--outdir",
type=Path,
default=Path(__file__).parent
/ "artifacts"
/ "screenshots"
/ datetime.now().strftime("%Y-%m-%d"),
)
args = parser.parse_args()
return asyncio.run(amain(args))
if __name__ == "__main__":
sys.exit(main())

951
docs/JOURNEY.md Normal file
View File

@ -0,0 +1,951 @@
# JOURNEY
Raw chronological notes from a few days reverse-engineering HAI's PC Access
3.17, then writing a Python library and a Home Assistant integration to
talk to the panel directly. Dated. Append-only-ish.
---
## 2026-05-10 morning — the pile of binaries
Started with a directory called `PC Access/` that had clearly been zipped
up off a Mac and handed around. The giveaway was `._*` files next to every
real file:
```
-rw------- 1 kdm kdm 120 Aug 15 2016 ._Newtonsoft.Json.dll
-rw------- 1 kdm kdm 484352 Aug 15 2016 Newtonsoft.Json.dll
```
That's AppleDouble cruft: macOS extended attributes shimmed into companion
files when an HFS+ volume gets archived to a non-Apple filesystem. 120 bytes
of resource fork garbage per real file. Useless. Touched everything from
the PC Access install date (Mar 2018) all the way back to a 2006 firmware
updater. Whoever extracted this had been carrying it across Macs for years.
What we actually had:
| File | Size | What it is |
|------|-----:|-----|
| `PCA3U_EN.exe` | 5.4 MB | The PC Access GUI, a .NET assembly (v3.17.0.843, 2018-01-02) |
| `PCA1106W.exe` | 3.3 MB | Older native C++ version from 2008 |
| `f_update.exe` | 437 KB | Native firmware updater (2006) |
| `OT7FileUploaderLib.dll` | 16 KB | OmniTouch 7 firmware uploader |
| `Our House.pca` | 144 KB | A panel config file. High entropy. Not ours. |
| `PCA01.CFG` | 318 B | App settings. Also encrypted. |
| `Serial Number.txt` | 20 B | A 20-char license key |
`Our House.pca` was the interesting one. Entropy 7.994 bits per byte —
either compressed, encrypted, or both. No magic bytes. No structure
visible in the first 256 bytes. It also had someone else's account name
embedded in the metadata: this panel had been bought used and shipped
with the previous owner's config still on it. Held that thought.
`file PCA3U_EN.exe` came back with `Mono/.Net assembly`. That was the
single biggest piece of luck in the whole project: a .NET assembly means
ilspycmd will give us back readable C# in seconds. Beats staring at IDA
listings of Borland C++ runtime stubs all afternoon, which is what
`PCA1106W.exe` would have made us do.
## 2026-05-10 — decompile and skim
Ran ilspycmd 10.0.1.8346 over `PCA3U_EN.exe`. 898 typedefs. They cleanly
split into two namespaces:
- `HAI_Shared` — the domain model, the wire protocol, the crypto, all of
it reusable across HAI's product line (Omni, Lumina, HMS).
- `PCAccess3` — just UI. Forms, controls, window positions.
That's the prize: `HAI_Shared` is essentially a free protocol
implementation library, written by people who actually know how the panel
works, sitting there in C# waiting to be read.
First skim of `HAI_Shared`:
- `clsOmniLinkPacket` — outer transport packet. 4-byte header
(`[seq_hi][seq_lo][type][reserved=0]`) + payload. Sequence number is
big-endian. There are 12 packet types: NewSession, AckNewSession,
RequestSecureSession, AckSecureSession, two flavors of
SessionTerminated, the `OmniLinkMessage` (encrypted, v1) and
`OmniLink2Message` (encrypted, v2) wrappers, plus their unencrypted
twins.
- `clsOmniLinkMessage` — inner application message.
`[StartChar][MessageLength][...payload, payload[0]=opcode...][CRC_lo][CRC_hi]`.
CRC is CRC-16/MODBUS with poly `0xA001`. Standard.
- `clsAES` — the panel's symmetric crypto. AES-128, ECB,
`PaddingMode.Zeros`, key reused as IV (which is fine in ECB but a code
smell that hints at someone copy-pasting from a textbook).
- `enuOmniLink2MessageType` — 83 v2 opcodes. Login, Logout,
RequestSystemInformation, RequestExtendedStatus, Command, ZigBee
pass-through, firmware upload, etc.
- `clsCapOMNI_PRO_II`, `clsCapLUMINA`, `clsCapHMS950e`, … — per-model
capability classes carrying constants like `numZones=176`,
`numUnits=511`. Real domain model, not a config file.
Wrote those down in `findings.md` and pushed on.
## 2026-05-10 — the cipher that wasn't AES
Then we hit the file format. The `.pca` and `.CFG` blobs *look* like
AES-CBC ciphertext. They aren't. From `clsPcaCryptFileStream`:
```csharp
private byte oldRandom(byte max) {
RandomSeed = RandomSeed * 134775813 + 1;
return (byte)((RandomSeed >> 16) % max);
}
// per byte: ciphertext = plaintext ^ oldRandom(255) // mod 255, not 256
```
That multiplier — `134775813` = `0x08088405` — is the Borland Delphi /
Turbo Pascal `Random()` LCG. So someone wrote this thing in Delphi
originally, ported it to C#, and kept the exact same PRNG so existing
.pca files would still decrypt. The mod-255 (not 256) stays in too,
which means the keystream byte is in `[0..254]`, never `0xFF`. It
doesn't lose information — it just shifts the output distribution.
Quirky but not broken.
Two hardcoded 32-bit keys live in `clsPcaCfg`:
```csharp
private readonly uint keyPC01 = 338847091u; // 0x142A3D33 — for PCA01.CFG
public readonly uint keyExport = 391549495u; // for exported .pca files
```
And a third path: `SetSecurityStamp(string S)` derives a per-installation
key from a stamp string:
```csharp
uint num = 305419896u; // 0x12345678 — developer Easter egg as init value
foreach (char c in S)
num = ((num ^ c) << 7) ^ c;
Key = num;
```
`0x12345678` as an init constant is the giveaway: someone was bored at
the keyboard the day they wrote this. It's the kind of thing you grep
for. (The actual hash function, `((k ^ c) << 7) ^ c`, is fine — not
cryptographic, but fine for "let me derive a per-install key from a
serial number.")
## 2026-05-10 — the wrong-key-looks-right problem
Wrote a Python decryptor in maybe an hour: a generator that yields
keystream bytes, an XOR over the file. Easy.
Then we hit a subtle thing. The first script auto-tried the two known
keys and picked the one whose plaintext "looked more printable". It
picked `keyExport`, ran the parser, and got nonsense — but a *plausible*
kind of nonsense: short non-empty strings, non-zero counter values,
generally the texture of real binary data.
Turns out **printable-character ratio is a terrible heuristic for binary
file plaintext.** Random noise is, on average, slightly more "printable"
than a real binary file padded with zeros and length-prefixed strings —
because random noise has a uniform distribution and a real file has long
runs of `0x00` (which falls outside the 32127 printable range).
Replaced it with something concrete and stupid:
```python
def score(pt):
n = pt[0]
if not (1 <= n <= 64): return 0
tag = pt[1:1+n]
if all(32 <= b < 127 for b in tag):
return 100 + n
return 0
```
The first byte is a String8 length, and the next `n` bytes should be the
ASCII version tag like `CFG05` or `PCA03`. If it parses cleanly, the key
is right; if not, it isn't. Robust because it's not statistical.
`PCA01.CFG` decrypted with `keyPC01`. First bytes:
```
00000000 05 43 46 47 30 35 17 41 ... .CFG05.A
```
`CFG05`. Format version 5. Walked the rest of the schema (modem strings,
port number, key field, password) and pulled out the prize:
```
pca_key = 0xC1A280B2 (3,248,652,466)
password = "PASSWORD" # factory default, never changed
```
So the per-installation `.pca` key was sitting inside `PCA01.CFG` the
whole time, encrypted with a hardcoded key that's right there in the
binary. The `keyExport` path is only for files that were exported for
sharing, which is *not* what `Our House.pca` was — it was the live
in-place config.
Decrypted `Our House.pca` with `0xC1A280B2`. First bytes:
```
00000000 05 50 43 41 30 33 ... .PCA03
```
`PCA03`. File format v3. Right key.
## 2026-05-10 — the 2191-byte header parses byte-perfect
Read `clsHAC.ReadFileHeader` to figure out the layout:
```
String8 version_tag "PCA03"
String8(30) AccountName
String16(120) AccountAddress
String8(20) AccountPhone
String8(4) AccountCode
String16(2000) AccountRemarks
byte Model
byte MajorVersion
byte MinorVersion
sbyte Revision
```
One thing about `ReadString8(out S, byte L)`: it always consumes
`1 + L` bytes regardless of the declared string length. So the strings
are fixed-width slots with a length prefix, not variable-length.
Total header size: 2191 bytes.
Then we found the validation block at `clsHAC.cs:7943`:
```csharp
if (num == 2191) { /* header read OK */ }
```
If your byte counter doesn't equal 2191 after parsing the header, you
got it wrong. It did. That was the moment we knew the parser was
correct: not by inspection of the output, but by hitting an exact magic
number that the original code was checking against.
Decoded header:
- Model byte = `0x10` = `enuModel.OMNI_PRO_II`
- Firmware: 2.12 r1
- AccountName / Address / Phone — the previous owner's PII
- 8 user codes, all still factory default `12345678`
That last one stung. The panel had probably been sitting on someone's
wall for a decade with `12345678` as the master code. (Not our panel,
yet — but our panel was about to inherit it.) Plaintext stays in
`extracted/Our_House.pca.plain` and that path stays in `.gitignore`.
All future notes redact PII.
## 2026-05-10 — walking the body
Header was 2191 bytes; the file is 144 KB. Plenty more to parse before
we'd hit the network connection block where the AES key for live-panel
talk is stored.
The body layout (from `clsHAC.ReadFromFile`):
```
ByteArray SetupData.data (3840 bytes for OMNI_PRO_II)
bool slRequireCodeForSecurity
bool slPasswordOnRestore
UInt16 (discarded)
UInt16 EventLog.Count
UInt32 (discarded)
ZoneNames, UnitNames, ButtonNames, CodeNames, ThermostatNames,
AreaNames, MessageNames
ZoneVoices, UnitVoices, ButtonVoices, CodeVoices, ThermostatVoices,
AreaVoices, MessageVoices
Programs
EventLog
# v >= 2:
if Ethernet feature:
String8(120) Connection.NetworkAddress
String8(5) port-string
String8(32) ControllerKey-as-hex <- 32 hex chars = 16-byte AES key
...
```
The Names blocks were straightforward: each is `max_slots * (1 + name_len)`
bytes. For Zones that's `176 * 16 = 2816` bytes. Adds up cleanly.
Then we hit the Voices blocks and the parser desynced.
## 2026-05-10 — the latent bug in PC Access itself
Each "Voice" block lets the panel speak the name of an object. Six
phrases per object (`numVoicePhrases = 6`). The C# reads them like this:
```csharp
byte[] B = new byte[CAP.numVoicePhrases]; // 6 bytes
for (int i = 1; i <= GetFileMaxX(); i++) {
num = (i > Count)
? num + FS.ReadByteArray(out B, B.Length) // skip path: 6 bytes
: num + _Items[i-1].Voice.Read(FS); // structured path
}
```
The "structured path" calls `clsVoiceWordArray.Read`, which branches on
whether the panel has the `LargeVocabulary` feature:
- LargeVocabulary present → 6 phrases × **2 bytes** (UInt16) = **12 bytes**
- LargeVocabulary absent → 6 phrases × 1 byte = 6 bytes
OMNI_PRO_II *has* LargeVocabulary. So the structured path reads 12 bytes
per slot. But the **skip path** in the loop above always reads 6 bytes,
no matter what. There's no `if (LargeVocabulary) B = new byte[12];`.
If `Count == GetFileMaxX()` (every slot is filled), this never matters —
the skip path is never taken. For every block on our panel except one,
that's true. But Units has `Count = 511` and `GetFileMaxX = 512`, so
exactly one slot takes the skip path, reads 6 bytes when it should have
read 12, and the next 6 bytes — which are actually the start of the
*next* block — get treated as the tail of the current slot. The parser
walks 6 bytes off the rails and never recovers.
The C# code in the wild gets away with this because `Count >= Max` for
basically all real panels in deployment. But it's a real bug — it would
bite if a model ever shipped with LargeVocabulary AND had Buttons or
Messages with `Count < Max`. We patched our parser; the original is
still wrong.
Found it by hex-dumping the file, locating the panel IP address
(`192.168.1.9`) at byte offset `0xe2d8`, and back-solving the diff
between where we expected to land and where the IP actually was. The
gap was exactly 6684 bytes, which is `(512-1)*6` worth of voice slots
read at half the right size. Math checked out. Off by N.
## 2026-05-10 — the prize
After the Voices, the body has Programs (1500 × 14 B), EventLog (250 ×
9 B), and then — for a v3 file with the Ethernet feature — the
Connection block:
```
String8(120) Connection.NetworkAddress
String8(5) port-string
String8(32) ControllerKey-as-hex
```
For our panel:
- IP: `192.168.1.9`
- Port: `4369`
- ControllerKey: 16 bytes of AES-128 key, extracted at file offset
`0xe2d8`
Total bytes to that point: `2191 + 3840 + 10 + 15407 + 13374 + 21000 + 2250 = 58072 = 0xe2d8`.
Exactly the offset where the IP appears in the hex dump. Done.
That key plus the right handshake = direct talk to the panel.
## 2026-05-10 — the two non-public quirks
Now we needed to read `clsOmniLinkConnection.cs`. It's 2109 lines of
state machine for the secure-session handshake, the keepalive timer, the
TCP framing, and the encryption. We expected a textbook AES session: send
client-hello, get server-hello, derive key from PIN somehow, encrypt
everything from then on.
What we found instead were two surprises that no public Omni-Link
write-up we'd seen mentions. Both of them look like quirks. Both of them
will reject your client with `ControllerSessionTerminated` if you skip
them.
### Quirk 1 — the session key is not the ControllerKey
You'd expect the AES session key to be the ControllerKey verbatim. It
isn't. From `clsOmniLinkConnection.cs:1886-1892`:
```csharp
SessionKey = new byte[16];
ControllerKey.CopyTo(SessionKey, 0);
for (int j = 0; j < 5; j++)
{
SessionKey[11 + j] = (byte)(ControllerKey[11 + j] ^ SessionID[j]);
}
AES = new clsAES(SessionKey);
```
The first 11 bytes of the session key are the ControllerKey verbatim.
The last 5 bytes are the ControllerKey XORed with a 5-byte `SessionID`
nonce that the controller sent in `ControllerAckNewSession`. That's
the entire key derivation. No PBKDF2, no HKDF, no PIN, no salt. Just
five bytes of XOR.
The same five-byte block appears twice in the source — once for UDP
(line 1423) and once for TCP (line 1886). Identical.
The implication for someone writing a client is: if you encrypt your
`ClientRequestSecureSession` with the raw ControllerKey, the panel
decrypts it to garbage and disconnects you. You have to wait for the
nonce, mix it in, *then* encrypt.
### Quirk 2 — per-block XOR pre-whitening before AES
This one is the real headline. Before AES-encrypting any payload block,
the first two bytes of every 16-byte block get XORed with the packet's
sequence number. Same XOR mask, every block of the packet. From
`clsOmniLinkConnection.cs:396-401`:
```csharp
for (num = 0; num < PKT.Data.Length; num += 16)
{
PKT.Data[num] = (byte)(PKT.Data[num] ^ ((PKT.SequenceNumber & 0xFF00) >> 8));
PKT.Data[num + 1] = (byte)(PKT.Data[num + 1] ^ (PKT.SequenceNumber & 0xFF));
}
PKT.Data = AES.Encrypt(PKT.Data);
```
And then the inverse on receive (`:413-417`):
```csharp
PKT.Data = AES.Decrypt(PKT.Data);
for (int i = 0; i < PKT.Data.Length; i += 16)
{
PKT.Data[i] = (byte)(PKT.Data[i] ^ ((PKT.SequenceNumber & 0xFF00) >> 8));
PKT.Data[i + 1] = (byte)(PKT.Data[i + 1] ^ (PKT.SequenceNumber & 0xFF));
}
```
So the on-the-wire encryption is "AES-128-ECB of (payload XOR-prewhitened
with the seq number, two bytes per block)". A naive Omni-Link client that
just AES-ECB-encrypts the raw payload will produce ciphertext the panel
won't accept.
It feels weak — an attacker with a known-plaintext for one block can
recover the seq XOR mask trivially, and from there the whitening is
unprotected. But it's the protocol. The panel won't talk to you without
it.
We think the original intent might have been something like nonce-mixing
(use the seq as a per-packet salt to defeat ECB block-repetition
attacks), and the implementation got cargo-culted from one block to all
blocks of the packet. Doesn't matter. Implement it. Move on.
A bonus surprise: **there is no separate `Login` step on TCP.** The C#
defines `clsOL2MsgLogin` (v2 Login, opcode 42) but never instantiates
it on the TCP path. Possessing the right ControllerKey *is* the
authentication. The login opcode appears to be a serial-only artifact
from before the Ethernet module existed. The v1 serial path *does*
construct `clsOLMsgLogin` with the user's PIN; the v2 TCP path goes
straight from `ControllerAckSecureSession` to `RequestSystemInformation`.
We documented all of this in `notes/handshake.md` while it was fresh.
## 2026-05-10 around noon — first commit
```
9a02418 Initial scaffold + protocol primitives
```
uv project, ruff, pytest, mypy strict, MIT, README, gitignore explicitly
protecting any `.pca` or panel keys. Date-versioned (CalVer): `2026.5.10`.
The library lives in `src/omni_pca/`:
- `crypto.py` — AES-128-ECB plus the per-block XOR seq pre-whitening and
the `SessionKey = CK[0:11] || (CK[11:16] XOR SessionID)` derivation
- `opcodes.py` — all 12 packet types, all 104 v1 opcodes, all 83 v2
opcodes, all transcribed by hand from the decompiled enums
- `packet.py` — outer `Packet` with `encode()`/`decode()`
- `message.py` — inner `Message` with CRC-16/MODBUS
- `pca_file.py` — Borland LCG cipher, `PcaReader`, parsers for both
`.pca` and `.CFG`
49 tests passed, ruff clean. The protocol unit tests use canned bytes
extracted from the C# source; they don't need a panel to run.
## 2026-05-10 1pm — mock panel as ground truth
Second commit:
```
1901d6e Async client + mock panel + e2e roundtrip
```
The async client (`OmniConnection`, `OmniClient`) runs the four-step
secure-session handshake, frames TCP correctly (read first 16-byte block,
decrypt, learn `MessageLength`, read the rest), keeps a per-direction
monotonic sequence number that wraps `0xFFFF → 1` (skipping 0 because the
controller uses 0 for unsolicited packets), and dispatches solicited
replies to a Future while shoving unsolicited packets into a queue.
That's all well and good, but how do we test it without a panel? The
panel was at `192.168.1.9` last we knew, and we had no idea if its
network module was even on. Building a real Omni controller emulator
in Python turned out to be the right answer.
`mock_panel.py` is a TCP server that:
- accepts `ClientRequestNewSession`, generates a 5-byte SessionID,
sends back `ControllerAckNewSession` with the version bytes `00 01`
prepended
- derives the same SessionKey the client did (using the same XOR-mix)
- decrypts the `ClientRequestSecureSession`, validates that the 5-byte
echo matches the SessionID it just sent, sends back the symmetric
`ControllerAckSecureSession` (re-encrypting the same SessionID)
- handles `RequestSystemInformation`, `RequestSystemStatus`,
`RequestProperties` (Zone/Unit/Area, both absolute index and rel=1
iteration with EOD termination), and Naks anything else
It's a thin emulator but it's a *complete* protocol counterpart. Six
end-to-end tests connect a real `OmniClient` over a real TCP socket to
a real `MockPanel` and exchange real frames. They prove the handshake,
the AES, the XOR whitening, and the sequence numbering all agree —
because if any one of them is wrong, decryption produces garbage and
the connection drops.
That ground-truth check was load-bearing. It meant we could iterate on
the client all afternoon without worrying that some bug in our
encryption was being masked by a bug in our framing.
## 2026-05-10 ~1:10pm — the HA scaffold
Third commit:
```
2e43936 HA custom_component scaffold (binary_sensor for zones)
```
Drop-in Home Assistant integration at `custom_components/omni_pca/`:
manifest, config_flow with auth + reauth, coordinator with reconnect
logic, binary_sensor for each named zone with `device_class` derived
from `zone_type` (OPENING, MOTION, SMOKE, etc.). 12 unit tests for
`parse_controller_key()` because that's the one piece of pure logic
worth pinning down hard.
Status of the HA component itself wasn't validated against a running
Home Assistant — that comes next. But the HACS manifest is there, so
once we trust it we can drop it in.
## 2026-05-10 2pm — fleshing out the model surface
Fourth commit:
```
08974e2 Models: 16 status/properties dataclasses + enums + temp converters
```
The Omni protocol has a wide object surface — Zones, Units, Areas,
Thermostats, Buttons, Programs, Codes, Messages, Aux Sensors, Audio
Zones, Audio Sources, User Settings — and each has both a "properties"
record (configured, mostly static) and a "status" record (live state).
Wrote frozen-slots dataclasses for all of them, with `.parse(payload)`
classmethods that decode the byte layouts straight from the C# field
definitions. Added IntEnums for the dispatch tags (`ObjectType`,
`SecurityMode`, `HvacMode`, `FanMode`, `HoldMode`, `ThermostatKind`,
`ZoneType`, `UserSettingKind`).
One small surprise from `clsText.cs`: the temperature encoding the
panel uses is *linear*, not the non-linear thermistor scale we'd
guessed it might be. `C = raw / 2 - 40`. Easy.
42 new tests. 139 total.
## 2026-05-10 ~2:15pm — commands and events
Fifth commit:
```
68cf44a Library v1.0 phase B: command opcodes + typed system events
```
`commands.py` — the `Command` IntEnum, sourced from `enuUnitCommand.cs`
which is the canonical "all commands" enum despite the misleading name
(it covers HVAC, security, scene, button, message commands too — not
just units). One naming weirdness: `enuUnitCommand.UserSetting` (104) is
actually EXECUTE_PROGRAM. Renamed for clarity in our enum and left the
original C# alias documented inline so anyone cross-referencing won't
get confused.
`OmniClient` got 18 new methods: `execute_command`,
`execute_security_command`, `acknowledge_alerts`, `get_object_status`,
`get_extended_status`, plus convenience wrappers (`turn_unit_on`,
`set_unit_level`, `bypass_zone`, `set_thermostat_heat_setpoint_raw`,
…). All the command methods raise `CommandFailedError` on Nak.
`events.py` — the `SystemEvents` (opcode 55) decoder. The panel pushes
batches of these unsolicited; each batch contains multiple events of
different types (zone state changes, unit state changes, arming
changes, alarm activated, AC lost, battery low, phone line dead, X10
codes received, …). 28 dispatch tags, 26 typed event subclasses, an
`UnknownEvent` catch-all for opcode values we don't know yet, and an
`EventStream` helper that flattens batches across messages.
55 new tests. 194 total.
## 2026-05-10 ~2:30pm — stateful mock and the full v1.0 surface
Sixth commit:
```
c26db62 Library v1.0 phase C: stateful mock + e2e for the new surface
```
The mock got real state. `MockUnitState`, `MockAreaState`, `MockZoneState`,
`MockThermostatState`, plus a `user_codes` table for security validation.
All the new opcodes wired through:
- `Command` (20) → Ack with state mutation, dispatching UNIT_ON, UNIT_OFF,
UNIT_LEVEL, BYPASS_ZONE, RESTORE_ZONE, SET_THERMOSTAT_HEAT, etc.
- `ExecuteSecurityCommand` (74) → Ack on a valid code, Nak on invalid
- `RequestStatus` (34) → `Status` (35) for the four object kinds with
hard-coded record sizes per `clsOL2MsgStatus.cs:13-27`
- `RequestExtendedStatus` (58) → `ExtendedStatus` (59) with the
`object_length` prefix and the richer per-type fields
- `AcknowledgeAlerts` (60) → Ack
- And synthesized `SystemEvents` (55) pushed with `seq=0` whenever state
changes, so the e2e tests can subscribe to events through the real
client API and watch them roundtrip cleanly through `events.parse_events()`
9 new e2e tests — arm/disarm with code validation, unit on/off/level,
zone bypass/restore, thermostat setpoint, push events for arming and
unit changes, acknowledge_alerts. 203 total passing, 2 skipped (the
HA harness and a `.pca` fixture we don't ship).
The library has the v1.0 surface: read, command, status, extended status,
events. All exercised by an in-process emulator that speaks the same
protocol as the real panel.
## 2026-05-10 afternoon — trying to find the real panel
Now the part that didn't go well.
The `.pca` file said the panel lived at `192.168.1.9:4369`. Tried to
connect: nothing. TCP SYN, no SYN-ACK. Pinged: silent. nmap'd the
subnet to make sure we were on the right network:
- `192.168.1.7`, `.8`, `.11` — open ports including SSH with banner
`SSH-2.0-dropbear_2018.76`. Three OmniTouch 7 touchscreens. They're
the wall-mounted controllers; they live on the same LAN as the panel,
speak Omni-Link II to the panel themselves, and run a stripped Linux
with dropbear for the firmware updater. Confirmed by the SSH banner
date (2018) lining up with the OmniTouch 7 firmware era.
- `.6` — likely the panel itself, but no open ports, no response.
- `.9` — also dark. The 2018 IP either changed or the network module
was disabled at some point.
So the panel is sitting there, doing its job (the touchscreens clearly
work — they're on the network), but its Ethernet/Omni-Link II module is
either turned off in the panel's setup menu or the network bridge
hardware is bad. We have the ControllerKey, we have the right port, we
have a fully-tested client and a mock panel that proves the client
works end-to-end — but we can't prove it against the real thing yet.
We have, in other words, built the world's most thoroughly-tested
unused integration. There is something quietly funny about that.
The fix is physical: walk over to the panel, find the menu that
enables the Ethernet module, save, reboot. Then the live validation
becomes a five-minute test. Until then, the mock is the best we have,
and the mock is a faithful enough emulator that we trust it.
## 2026-05-10 evening — HA rebuild Phase A
The first HA scaffold (a placeholder `binary_sensor` for zones, written
before the library was complete) needed to come down and get rebuilt on
the v1.0 surface. The interesting design choice: how should the
coordinator pull state?
Option A: re-poll everything every N seconds.
Option B: rely on the panel's unsolicited push messages and only poll
as a backstop.
We picked B. The Omni panel is genuinely chatty — when a zone trips,
when an area arms, when AC fails, when a unit toggles, the panel pushes
a `SystemEvents` packet within a few hundred ms. Our `OmniConnection`
already decodes those into typed `SystemEvent` objects via an async
iterator (`client.events()`). The coordinator now runs a long-lived
background task consuming that iterator and patches the relevant slice
of state in-place, then calls `async_set_updated_data()` so HA reacts
immediately. The 30-second poll is a safety net for state we missed.
The piece that took longer than expected was extracting pure functions
from the entity-class soup so we could unit-test without HA installed
in the venv. We ended up with `helpers.py`: zone-type → device-class
mapping, latched-vs-current-condition logic per zone family, name
prettifier (`FRONT_DOOR``Front Door`). 61 unit tests for `helpers.py`
alone, all running without importing `homeassistant.*`. Sounds excessive
until you remember that pure-function tests are the only ones that run
in <100ms; you don't want to wait 15 seconds for HA to boot just to
verify that zone-type 32 (FIRE) maps to `BinarySensorDeviceClass.SMOKE`.
## 2026-05-10 evening — HA Phase B (the entity build-out)
Six platforms in one pass: `alarm_control_panel` (per area, with code
validation), `light` (per unit, dimmable), `switch` (per zone for
bypass control), `climate` (per thermostat, full HVAC modes),
`sensor` (analog zones + thermostat readings + panel telemetry),
`button` (per panel macro), `event` (one per panel relaying typed
push events as HA event_types).
The mapping work was repetitive but mostly mechanical. The interesting
bits:
- The Omni unit "state" byte is overloaded: 0=off, 1=on (relay),
100..200=brightness percent (state - 100), plus weird ranges for
scene levels (2..13) and ramping codes (17..25). Encoded as a pair
of pure helpers (`omni_state_to_ha_brightness` /
`ha_brightness_to_omni_percent`) so the conversion is unit-tested.
- Omni's `SecurityMode` enum has *both* steady-state values (Off=0,
Day=1, Away=3, …) *and* arming-in-progress values (ArmingDay=9,
ArmingAway=11, …). The HA `AlarmControlPanelState` mapping needs
to bucket the 9..14 range into HA's `arming` state regardless of
destination. Plus alarm_active overrides everything to `triggered`,
and entry-timer running means `pending`, exit-timer means `arming`.
All of this lives in one pure `security_mode_to_alarm_state()`
function so it's unit-testable end to end.
- The HA `event` platform is newer than I'd realised. It exposes
push events as a single entity per integration with `event_types`
and `event_data`. Automations key on `platform: event` filtering
by `event_type`. We surface 12 event-type strings:
`zone_state_changed`, `unit_state_changed`, `arming_changed`,
`alarm_activated`, `alarm_cleared`, `ac_lost`, `ac_restored`,
`battery_low`, `battery_restored`, `user_macro_button`,
`phone_line_dead`, `phone_line_restored`, plus an `unknown`
catch-all for the 14 less common SystemEvent subclasses.
Skipped the `scene` platform entirely. Omni "scenes" are actually
just user-named button macros — the underlying call is the same
`execute_button` that the `button` platform already exposes. Adding
a parallel scene wrapper would just double-count entities. Documented
the choice in the integration README.
## 2026-05-10 evening — HA Phase C (services + diagnostics)
Seven services, all routed through a `services.py` module that's
idempotently registered on first config-entry setup and unloaded on
the last config-entry teardown:
```
omni_pca.bypass_zone
omni_pca.restore_zone
omni_pca.execute_program
omni_pca.show_message
omni_pca.clear_message
omni_pca.acknowledge_alerts
omni_pca.send_command (raw escape hatch)
```
Each takes an `entry_id` field with HA's `config_entry` selector so
the UI gives users a panel picker. `services.yaml` declares the
schema; `services.py` enforces it via `voluptuous`.
Diagnostics endpoint dumps a redacted snapshot for bug reports:
`controller_key` redacted via `async_redact_data`; zone/unit/area
names hashed with sha256 so structure is visible without leaking
PII; counts per object type; last event class; last update success
timestamp. Useful one day, useless until then, but it's three lines
and HA users expect it.
## 2026-05-10 evening — "wait, did we mock the panel enough?"
The thinking-out-loud moment that caught a real bug. The HA test
harness was about to be set up; before doing that, the question was:
does the mock actually answer every opcode the HA coordinator calls?
Mapped HA-side calls to mock-side handlers. Most matched. But the
HA coordinator walks `RequestProperties` for object types Thermostat
(6) and Button (3), and the mock's `_reply_properties` only knew
about Zone/Unit/Area. Both would have returned `Nak`, the coordinator
would have moved on, and HA would have discovered zero thermostats
and zero buttons no matter how `MockState` was seeded.
Added the two handlers (each ~30 lines: build the per-object
Properties body matching the wire format documented in
`models.ThermostatProperties.parse` / `models.ButtonProperties.parse`),
plus two e2e tests that drive the walk with `OmniClient` and assert
the parses come out clean. Caught it before HA ever touched the mock.
This is the kind of bug that *would* have shown up the first time
you tried the integration: zero climate entities, zero button
entities, no error message because the panel just said "no, I have
no thermostats here". You'd spend an hour staring at it. Mock-the-
whole-protocol pays for itself the first time it catches one of
these.
## 2026-05-10 evening — HA test harness, the rough patches
`pytest-homeassistant-custom-component` is the standard HA dev test
harness. It pins to a specific HA version (we got `2026.5.1` paired
with HA `2026.5.x`) and provides fixtures to spin up HA in-process
per test. Sounds simple. Three rough patches:
1. **`requires-python` conflict.** Our library targets `>=3.12`. HA
`2026.5+` requires `>=3.14.2`. uv resolves dependency groups
against the project's `requires-python` and refused to install
the test harness because it couldn't find a Python version
satisfying both. Bumped the project to `>=3.14.2` — fine for HA
users (HA already needs 3.14), library users on older Python
pin to a previous omni-pca version.
2. **`pytest_socket` blocks our e2e tests.** The HA harness installs
`pytest_socket` globally to keep HA unit tests hermetic. That
broke our existing 17 e2e tests that legitimately need to talk
to a localhost MockPanel over a real TCP socket. Fix: a top-
level `tests/conftest.py` autouse fixture requesting the
harness's `socket_enabled` fixture, which re-enables sockets by
default. HA-side tests can opt back into the strict policy if
they want.
3. **`CONF_ENTRY_ID` doesn't exist in HA.** Our `services.py` was
importing `CONF_ENTRY_ID` from `homeassistant.const`. The harness
import-test caught it: HA exports the constant as
`ATTR_CONFIG_ENTRY_ID`, not `CONF_ENTRY_ID`. Without the harness,
this would have crashed on first install in a real HA. Worth the
harness already.
Then teardown started hanging. Each test passed (5-15 seconds for HA
boot + entity discovery + assertions) but the harness's
`verify_cleanup` timed out waiting for the coordinator's background
event-listener task to finish. The coordinator's `async_shutdown()`
cancels it cleanly — but the harness was tearing the test down without
calling unload first. Fix: convert the `configured_panel` fixture into
a generator and call `hass.config_entries.async_unload()` in the
teardown branch. With that, all 12 HA-side tests run in 0.74 seconds
total (each one boots HA, runs config flow, asserts, unloads).
Final score: 351 tests pass, 1 skipped (the gitignored `.pca`
fixture), ruff clean across `src/ tests/ custom_components/`.
## 2026-05-10 late evening — docker dev stack
Wanted a one-command setup so the integration could be browsed
manually and screenshotted for the README. `docker-compose.yml` with
two services: real HA `2026.5` from upstream + a sidecar running
the mock panel.
The interesting wrinkle: the mock panel container needs to import
`omni_pca`. Mounting the project read-only and running `uv` inside
the container failed because uv tried to recreate the host's
`.venv` and the mount was read-only. Fix: mount only `src/` and
`run_mock_panel.py`, set `PYTHONPATH=/tmp/mock/src`, install just
`cryptography` via `uv pip install --system`, run the script
directly. No package install, no venv, just a Python interpreter
with the right import path.
## 2026-05-10 late evening — automated HA onboarding + screenshots
`dev/screenshot.py` does the entire flow:
1. POST `/api/onboarding/users` to create the demo user (returns
`auth_code`)
2. POST `/auth/token` with `grant_type=authorization_code` to get
the access token (HA doesn't support password grant)
3. On subsequent runs: log in via `/auth/login_flow` (cleaner than
re-using a saved token; the token expires in 30 minutes anyway)
4. POST `/api/config/config_entries/flow` to start the omni_pca
config flow, then post the user-input dict to complete it
5. Cache the panel's device_id by calling HA's template endpoint
(`{{ device_id('sensor.omni_pro_ii_panel_model') }}`) — which is
a delightfully clean way to ask HA "what's the device id for this
entity?"
6. Launch headless chromium via the `playwright` Python package,
inject `localStorage.hassTokens` so it skips the login screen,
navigate to six deep-linked pages and screenshot each
The whole script is ~250 lines and produces six PNGs. The
`04-panel-device.png` is the headline shot: HA's device page for
"Omni Pro II / by HAI / Leviton / Firmware: 2.12r1" with all the
Controls (lights, buttons, areas, thermostats), Activity panel,
Diagnostics download. Every entity from the mock visible in real HA
UI in the right shape.
A nice side-effect: HA's onboarding wizard has a "We found compatible
devices!" step that scans the network for known integrations. Our
manifest got picked up — "HAI/Leviton Omni Panel" appeared in that
list during onboarding even though we hadn't done anything explicit
to register it for discovery. The integration name and `iot_class`
in `manifest.json` was enough.
## What's left for future sessions
The panel's network module is still off. When it comes back online,
the moment of truth is one TCP connect to `192.168.1.6:4369` (or
wherever it lives now) and one `RequestSystemInformation`. If the
reply is `Omni Pro II / 2.12 r1` the entire stack — file decryption,
key extraction, key derivation, XOR pre-whitening, AES, framing,
sequencing — was right end to end. The mock says yes. We'll find out.
Other backlog items:
- `Programs` discovery (no `RequestProperties` opcode for Programs;
current implementation returns an empty dict — needs a real
protocol path or a separate `RequestProgramData` style call)
- HACS submission once we've validated against the live panel
- Maybe publish `omni-pca` to PyPI so the HA `manifest.json`
requirements line works without a wheel install
---
## Things worth remembering
**The "wrong key looks plausible" problem is real and recurring.**
Statistical heuristics (entropy, printable ratio, frequency analysis)
are great for telling random noise from English; they're terrible for
telling random noise from binary file plaintext. When a file format
has a known header magic, parse-the-magic beats every heuristic.
**Magic numbers in source code are gifts.** `0x12345678` as an init
value, `134775813` as an LCG multiplier, `2191` as a header length —
each one is a hard checkpoint that tells you, on first try, whether
the next four hours are going to be productive or not.
**A complete protocol counterpart is worth more than ten times its
LOC in confidence.** The mock panel was maybe 400 lines of code and
it eliminated an entire category of "is the client wrong or am I
holding it wrong" questions. Every test that connects a real client
to it through real TCP is a test that the entire stack — handshake,
encryption, framing, sequencing — agrees with itself.
**Quirk #2 (the per-block XOR pre-whitening) is the kind of thing
nobody finds without doing the work.** It's not in `jomnilinkII`,
not in `pyomnilink`, not in the public Omni-Link II writeups we
checked. The decompiled C# was unambiguous and twice-redundant
(once for encrypt, once for decrypt). Without those exact six lines
of source, an OSS client that did everything else right would still
get `ControllerSessionTerminated` on the first encrypted message,
with no useful diagnostic.
**The latent LargeVocabulary bug in PC Access is harmless but
symptomatic.** It's a copy-paste mistake — the skip path uses a
buffer sized for the no-LargeVocabulary case while the structured
path uses the LargeVocabulary size. Every panel in deployment
satisfies `Count >= Max` for the affected blocks, so the bug never
fires. But it would, on a model that doesn't, and PC Access would
silently mis-parse its own config file. The kind of bug that lives
in shipping code for a decade because nobody runs the unhappy path.
**Pure functions are the cheapest thing in test suites.** The HA
custom_component grew six entity platforms before it had any HA
test harness installed. Every translation between Omni's wire
encoding and HA's UI encoding lives in `helpers.py` as a pure
function with no HA imports. 61 unit tests for those alone, all
running in <100ms. When the harness arrived, the only thing left
to test was the wiring itself — and the wiring tests run in 0.74
seconds for the entire 12-test HA-side suite because the pure
parts already had coverage.
**Mocking the entire protocol counterpart, not just the surface,
catches whole categories of bugs.** When the mock and the client
were both being grown, a "did we mock enough?" check caught two
missing `RequestProperties` handlers (Thermostat and Button). HA
would have discovered zero of either type silently. With the
real-world panel offline, mock-the-protocol is the only way to
trust the stack — but even with the panel available, it's the
only way to trust changes without rebooting hardware between every
edit.
**`pytest_socket` and "real network in tests" can coexist.** HA's
test harness disables sockets globally to keep core unit tests
hermetic. Our integration tests need real TCP to talk to the in-
process MockPanel. The fix is one autouse fixture that requests
the harness's `socket_enabled` fixture; takes ten seconds, lets
both worlds work without modification.
**The "build the integration without a real device" loop is
unreasonably effective.** With the docker dev stack, the full
flow is `make dev-up`, click through HA onboarding (or run
`screenshot.py` to do it via REST), see your entities. Make a
code change, `docker compose restart homeassistant`, refresh the
browser, see the change. Repeat. The panel itself becomes optional
for ~95% of the development. The other 5% is the live-validation
lap when the panel comes back online.

View File

@ -5,7 +5,7 @@ description = "Async Python client for HAI/Leviton Omni-Link II home automation
readme = "README.md"
license = { text = "MIT" }
authors = [{ name = "Ryan Malloy", email = "ryan@supported.systems" }]
requires-python = ">=3.12"
requires-python = ">=3.14.2"
keywords = ["hai", "leviton", "omni", "home-automation", "omni-link", "home-assistant"]
classifiers = [
"Development Status :: 3 - Alpha",
@ -29,7 +29,7 @@ cli = ["rich>=13.9.0", "typer>=0.15.0"]
omni-pca = "omni_pca.__main__:main"
[project.urls]
Repository = "https://github.com/rsp2k/omni-pca"
Repository = "https://git.supported.systems/warehack.ing/omni-pca"
[build-system]
requires = ["uv_build>=0.11.8,<0.12.0"]
@ -43,6 +43,13 @@ dev = [
"ruff>=0.13.0",
"mypy>=1.18.0",
]
# Optional group for testing the HA custom_component end-to-end. Pulls in
# the full Home Assistant test harness; requires Python 3.14.2+. The repo's
# .python-version pins to 3.14 for development; install with:
# uv sync --group ha
ha = [
"pytest-homeassistant-custom-component>=0.13.330",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"

View File

@ -17,11 +17,15 @@ from __future__ import annotations
import asyncio
import contextlib
import struct
from collections.abc import Awaitable, Callable
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from enum import IntEnum
from types import TracebackType
from typing import Self
from typing import TYPE_CHECKING, Self
from .commands import Command, CommandFailedError, SecurityCommandResponse
if TYPE_CHECKING:
from .events import SystemEvent
from .connection import (
ConnectionError as OmniConnectionError,
)
@ -31,13 +35,23 @@ from .connection import (
)
from .message import Message
from .models import (
OBJECT_TYPE_TO_STATUS,
AreaProperties,
AreaStatus,
FanMode,
HoldMode,
HvacMode,
PropertiesReply,
SecurityMode,
StatusReply,
SystemInformation,
SystemStatus,
UnitProperties,
ZoneProperties,
)
from .models import (
ObjectType as ModelObjectType,
)
from .opcodes import OmniLink2MessageType
@ -70,6 +84,23 @@ _PROPERTIES_PARSERS: dict[ObjectType, type[PropertiesReply]] = {
}
# Per-object-type record sizes for a basic Status (opcode 35) reply, where
# (unlike ExtendedStatus) there is no per-record length byte and the size
# is hard-coded in the wire format. Source: clsOL2MsgStatus.cs:13-27.
_STATUS_RECORD_SIZES: dict[int, int] = {
1: 4, # enuObjectType.Zone — number(2) + status + loop
2: 5, # enuObjectType.Unit — number(2) + state + time(2)
5: 6, # enuObjectType.Area — number(2) + mode + alarms + entry + exit
6: 9, # enuObjectType.Thermostat — number(2) + status + 6 bytes (status..hold)
7: 3, # enuObjectType.Message — number(2) + status
8: 6, # enuObjectType.Auxillary — number(2) + output + temp + low + high
10: 6, # enuObjectType.AudioZone — number(2) + power + source + volume + mute
11: 4, # enuObjectType.Expansion — number(2) + status + battery
13: 5, # enuObjectType.UserSetting — number(2) + type + value(2)
15: 5, # enuObjectType.AccessControlLock — number(2) + status + duration(2)
}
class OmniClient:
"""High-level async Omni-Link II client.
@ -169,6 +200,475 @@ class OmniClient:
self._expect(reply, OmniLink2MessageType.Properties)
return parser.parse(reply.payload)
# ---- commands --------------------------------------------------------
async def execute_command(
self,
command: Command,
parameter1: int = 0,
parameter2: int = 0,
) -> None:
"""Send a generic Command (opcode 20).
Most state-change operations on lights, scenes, zones, thermostats,
scenes, audio zones, etc. flow through here. The panel acks with
an :attr:`OmniLink2MessageType.Ack`; specific resulting state must
be re-polled (or you can subscribe to the unsolicited push stream
to see the corresponding ExtendedStatus push the panel emits).
Wire opcode: 20 (Command).
Wire payload (4 bytes, from clsOL2MsgCommand.cs:5-57):
[0] command byte (this enum value)
[1] parameter1 (single byte; brightness, mode, code index, ...)
[2] parameter2 high byte (BE u16)
[3] parameter2 low byte (object number for nearly every command)
Reference: clsOL2MsgCommand.cs.
"""
if not 0 <= parameter1 <= 0xFF:
raise ValueError(f"parameter1 must fit in a byte: {parameter1}")
if not 0 <= parameter2 <= 0xFFFF:
raise ValueError(f"parameter2 must fit in u16: {parameter2}")
payload = struct.pack(
">BBH", int(command), parameter1 & 0xFF, parameter2 & 0xFFFF
)
reply = await self._conn.request(OmniLink2MessageType.Command, payload)
if reply.opcode == OmniLink2MessageType.Nak:
raise CommandFailedError(
f"panel NAK'd Command {command.name} "
f"(p1={parameter1}, p2={parameter2})"
)
if reply.opcode != OmniLink2MessageType.Ack:
raise CommandFailedError(
f"unexpected reply to Command {command.name}: opcode={reply.opcode}"
)
async def execute_security_command(
self,
area: int,
mode: SecurityMode,
code: int,
) -> AreaStatus | None:
"""Arm or disarm a security area.
The panel validates the code against its enabled-codes list for
that area; on failure it returns an
:attr:`OmniLink2MessageType.ExecuteSecurityCommandResponse` whose
``payload[0]`` is one of the :class:`SecurityCommandResponse`
values. On success the panel may either return an Ack or push an
ExtendedStatus update for the affected area; we surface only the
response code (success/failure) and return ``None`` for the
success path because the synchronous reply does not carry a full
:class:`AreaStatus` record. Re-poll via :meth:`get_object_status`
if you need the post-command state.
Wire opcode: 74 (ExecuteSecurityCommand).
Wire payload (6 bytes, from clsOL2MsgExecuteSecurityCommand.cs:5-90):
[0] area number (1-based)
[1] security mode byte (raw enuSecurityMode 0..7)
[2] code digit 1 (thousands)
[3] code digit 2 (hundreds)
[4] code digit 3 (tens)
[5] code digit 4 (ones)
Raises:
ValueError: ``area`` not 1..255 or ``code`` not 0..9999.
CommandFailedError: panel Nak'd the request, or the
ExecuteSecurityCommandResponse status byte is non-zero.
The structured failure code is exposed on
``CommandFailedError.failure_code``.
Reference: clsOL2MsgExecuteSecurityCommand.cs,
clsOL2MsgExecuteSecurityCommandResponse.cs.
"""
if not 1 <= area <= 0xFF:
raise ValueError(f"area out of range: {area}")
if not 0 <= code <= 9999:
raise ValueError(f"code out of range (0000-9999): {code}")
# Match the C# digit-packing exactly (clsOL2MsgExecuteSecurityCommand.cs:36-41).
d1 = (code // 1000) % 10
d2 = (code // 100) % 10
d3 = (code // 10) % 10
d4 = code % 10
payload = bytes([area & 0xFF, int(mode) & 0xFF, d1, d2, d3, d4])
reply = await self._conn.request(
OmniLink2MessageType.ExecuteSecurityCommand, payload
)
if reply.opcode == OmniLink2MessageType.Nak:
raise CommandFailedError(
f"panel NAK'd ExecuteSecurityCommand "
f"(area={area}, mode={mode.name})"
)
if reply.opcode == OmniLink2MessageType.ExecuteSecurityCommandResponse:
if not reply.payload:
raise CommandFailedError(
"ExecuteSecurityCommandResponse with empty payload"
)
status = reply.payload[0]
if status != SecurityCommandResponse.SUCCESS:
try:
label = SecurityCommandResponse(status).name
except ValueError:
label = f"unknown({status})"
raise CommandFailedError(
f"ExecuteSecurityCommand failed: {label}",
failure_code=status,
)
return None
if reply.opcode == OmniLink2MessageType.Ack:
return None
raise CommandFailedError(
f"unexpected reply to ExecuteSecurityCommand: opcode={reply.opcode}"
)
async def acknowledge_alerts(self) -> None:
"""Acknowledge all outstanding alerts/troubles on the panel.
Wire opcode: 60 (AcknowledgeAlerts). No payload, panel acks.
Reference: enuOmniLink2MessageType.AcknowledgeAlerts.
"""
reply = await self._conn.request(OmniLink2MessageType.AcknowledgeAlerts)
if reply.opcode == OmniLink2MessageType.Nak:
raise CommandFailedError("panel NAK'd AcknowledgeAlerts")
if reply.opcode != OmniLink2MessageType.Ack:
raise CommandFailedError(
f"unexpected reply to AcknowledgeAlerts: opcode={reply.opcode}"
)
async def get_object_status(
self,
object_type: ModelObjectType,
start: int,
end: int | None = None,
) -> Sequence[StatusReply]:
"""Request basic Status (opcode 34/35) for a range of objects.
``end=None`` requests just the single object at ``start``. Returns
a list of the appropriate ``*Status`` dataclass instances, parsed
from each fixed-size record in the reply.
Unlike :meth:`get_extended_status`, the basic Status reply has NO
per-record ``object_length`` byte record sizes are hard-coded
per object type (see ``clsOL2MsgStatus.cs:13-27``).
Wire opcode: 34 (RequestStatus) -> 35 (Status).
RequestStatus payload (5 bytes, clsOL2MsgRequestStatus.cs:5-41):
[0] object type (enuObjectType)
[1..2] starting number (BE u16)
[3..4] ending number (BE u16)
Status reply payload layout (clsOL2MsgStatus.cs):
[0] object type
[1..] N records of size :data:`_STATUS_RECORD_SIZES[object_type]`
Reference: clsOL2MsgRequestStatus.cs, clsOL2MsgStatus.cs.
"""
return await self._fetch_status_range(
object_type=object_type,
start=start,
end=end,
request_opcode=OmniLink2MessageType.RequestStatus,
reply_opcode=OmniLink2MessageType.Status,
header_bytes=1, # just object_type
record_sizes=_STATUS_RECORD_SIZES,
)
async def get_extended_status(
self,
object_type: ModelObjectType,
start: int,
end: int | None = None,
) -> Sequence[StatusReply]:
"""Request ExtendedStatus (opcode 58/59) for a range of objects.
For Thermostats, AuxSensors, dimmable Units, and most other types
this carries more fields (current temperature, setpoints,
brightness level, etc.) than the basic Status reply.
Unlike basic Status, the ExtendedStatus reply has an explicit
``object_length`` byte at ``payload[1]`` so the record size doesn't
have to be hard-coded we use it as-is.
Wire opcode: 58 (RequestExtendedStatus) -> 59 (ExtendedStatus).
RequestExtendedStatus payload (5 bytes, clsOL2MsgRequestExtendedStatus.cs:5-41):
[0] object type
[1..2] starting number (BE u16)
[3..4] ending number (BE u16)
ExtendedStatus reply payload layout (clsOL2MsgExtendedStatus.cs):
[0] object type
[1] object length (per-record byte count)
[2..] N records of ``object_length`` bytes
Reference: clsOL2MsgRequestExtendedStatus.cs, clsOL2MsgExtendedStatus.cs.
"""
return await self._fetch_status_range(
object_type=object_type,
start=start,
end=end,
request_opcode=OmniLink2MessageType.RequestExtendedStatus,
reply_opcode=OmniLink2MessageType.ExtendedStatus,
header_bytes=2, # object_type + object_length
record_sizes=None, # take from payload[1]
)
# ---- thin command wrappers ------------------------------------------
async def turn_unit_on(self, index: int) -> None:
"""Turn a unit (light, relay, scene) ON.
Wire opcode: 20 (Command), command byte = ``Command.UNIT_ON`` (1).
Reference: enuUnitCommand.On (line 6).
"""
await self.execute_command(Command.UNIT_ON, parameter2=index)
async def turn_unit_off(self, index: int) -> None:
"""Turn a unit OFF.
Wire opcode: 20 (Command), command byte = ``Command.UNIT_OFF`` (0).
Reference: enuUnitCommand.Off (line 5).
"""
await self.execute_command(Command.UNIT_OFF, parameter2=index)
async def set_unit_level(self, index: int, percent: int) -> None:
"""Set a dimmable unit's brightness to ``percent`` (0..100).
Wire opcode: 20 (Command), command byte = ``Command.UNIT_LEVEL`` (9),
parameter1 = percent.
Reference: enuUnitCommand.Level (line 15).
"""
if not 0 <= percent <= 100:
raise ValueError(f"percent must be 0..100: {percent}")
await self.execute_command(
Command.UNIT_LEVEL, parameter1=percent, parameter2=index
)
async def bypass_zone(self, index: int, code: int = 0) -> None:
"""Bypass a zone (1-based).
Wire opcode: 20 (Command), command byte = ``Command.BYPASS_ZONE`` (4),
parameter1 = user code index (0 = installer/no-code path),
parameter2 = zone number.
Reference: enuUnitCommand.Bypass (line 10).
"""
await self.execute_command(
Command.BYPASS_ZONE, parameter1=code, parameter2=index
)
async def restore_zone(self, index: int, code: int = 0) -> None:
"""Restore a previously-bypassed zone.
Wire opcode: 20 (Command), command byte = ``Command.RESTORE_ZONE`` (5),
parameter1 = user code index, parameter2 = zone number.
Reference: enuUnitCommand.Restore (line 11).
"""
await self.execute_command(
Command.RESTORE_ZONE, parameter1=code, parameter2=index
)
async def set_thermostat_system_mode(
self, index: int, mode: HvacMode
) -> None:
"""Change the thermostat's system mode (Off/Heat/Cool/Auto/EmHeat).
Wire opcode: 20 (Command), command byte =
``Command.SET_THERMOSTAT_SYSTEM_MODE`` (68),
parameter1 = mode value, parameter2 = thermostat number.
Reference: enuUnitCommand.Mode (line 73).
"""
await self.execute_command(
Command.SET_THERMOSTAT_SYSTEM_MODE,
parameter1=int(mode),
parameter2=index,
)
async def set_thermostat_fan_mode(
self, index: int, mode: FanMode
) -> None:
"""Change the thermostat's fan mode (Auto/On/Cycle).
Wire opcode: 20 (Command), command byte =
``Command.SET_THERMOSTAT_FAN_MODE`` (69).
Reference: enuUnitCommand.Fan (line 74).
"""
await self.execute_command(
Command.SET_THERMOSTAT_FAN_MODE,
parameter1=int(mode),
parameter2=index,
)
async def set_thermostat_hold_mode(
self, index: int, mode: HoldMode
) -> None:
"""Change the thermostat's hold mode (Off/Hold/Vacation).
Wire opcode: 20 (Command), command byte =
``Command.SET_THERMOSTAT_HOLD_MODE`` (70).
Reference: enuUnitCommand.Hold (line 75).
"""
await self.execute_command(
Command.SET_THERMOSTAT_HOLD_MODE,
parameter1=int(mode),
parameter2=index,
)
async def set_thermostat_heat_setpoint_raw(
self, index: int, raw: int
) -> None:
"""Set the heat setpoint, in Omni's raw temperature byte units.
Convert from C/F at the call site (see
:func:`omni_pca.models.omni_temp_to_celsius` /
:func:`omni_pca.models.omni_temp_to_fahrenheit` for the inverse) -
this layer is deliberately transport-shaped.
Wire opcode: 20 (Command), command byte =
``Command.SET_THERMOSTAT_HEAT_SETPOINT`` (66).
Reference: enuUnitCommand.SetLowSetPt (line 71).
"""
if not 0 <= raw <= 0xFF:
raise ValueError(f"raw setpoint must be a byte: {raw}")
await self.execute_command(
Command.SET_THERMOSTAT_HEAT_SETPOINT,
parameter1=raw,
parameter2=index,
)
async def set_thermostat_cool_setpoint_raw(
self, index: int, raw: int
) -> None:
"""Set the cool setpoint, in Omni's raw temperature byte units.
Wire opcode: 20 (Command), command byte =
``Command.SET_THERMOSTAT_COOL_SETPOINT`` (67).
Reference: enuUnitCommand.SetHighSetPt (line 72).
"""
if not 0 <= raw <= 0xFF:
raise ValueError(f"raw setpoint must be a byte: {raw}")
await self.execute_command(
Command.SET_THERMOSTAT_COOL_SETPOINT,
parameter1=raw,
parameter2=index,
)
async def execute_button(self, index: int) -> None:
"""Run the program assigned to a button.
Wire opcode: 20 (Command), command byte = ``Command.EXECUTE_BUTTON`` (7).
Reference: enuUnitCommand.Button (line 13).
"""
await self.execute_command(Command.EXECUTE_BUTTON, parameter2=index)
async def execute_program(self, index: int) -> None:
"""Run a stored program by index (1-based).
Wire opcode: 20 (Command), command byte = ``Command.EXECUTE_PROGRAM`` (104).
Note: enuUnitCommand calls this ``UserSetting`` historically we
rename for clarity since "execute program" matches the user-facing
verb in the owner manual.
Reference: enuUnitCommand.UserSetting (line 98).
"""
await self.execute_command(Command.EXECUTE_PROGRAM, parameter2=index)
async def show_message(self, index: int, beep: bool = True) -> None:
"""Display a stored message on the panel's keypad.
Wire opcode: 20 (Command), command byte = ``Command.SHOW_MESSAGE_WITH_BEEP``
(80) when ``beep=True`` or ``Command.SHOW_MESSAGE_NO_BEEP`` (86) otherwise.
Reference: enuUnitCommand.ShowMsgWBeep (line 81),
enuUnitCommand.ShowMsgNoBeep (line 87).
"""
cmd = (
Command.SHOW_MESSAGE_WITH_BEEP
if beep
else Command.SHOW_MESSAGE_NO_BEEP
)
await self.execute_command(cmd, parameter2=index)
async def clear_message(self, index: int) -> None:
"""Clear a previously-shown message.
Wire opcode: 20 (Command), command byte = ``Command.CLEAR_MESSAGE`` (82).
Reference: enuUnitCommand.ClearMsg (line 83).
"""
await self.execute_command(Command.CLEAR_MESSAGE, parameter2=index)
# ---- helpers (status) -----------------------------------------------
async def _fetch_status_range(
self,
*,
object_type: ModelObjectType,
start: int,
end: int | None,
request_opcode: OmniLink2MessageType,
reply_opcode: OmniLink2MessageType,
header_bytes: int,
record_sizes: dict[int, int] | None,
) -> Sequence[StatusReply]:
if not 0 <= start <= 0xFFFF:
raise ValueError(f"start out of range: {start}")
end_n = start if end is None else end
if not 0 <= end_n <= 0xFFFF:
raise ValueError(f"end out of range: {end_n}")
if end_n < start:
raise ValueError(f"end ({end_n}) must be >= start ({start})")
parser = OBJECT_TYPE_TO_STATUS.get(int(object_type))
if parser is None:
raise NotImplementedError(
f"no status parser for object type {object_type.name}"
)
payload = struct.pack(">BHH", int(object_type), start, end_n)
reply = await self._conn.request(request_opcode, payload)
if reply.opcode == OmniLink2MessageType.EOD:
return []
if reply.opcode == OmniLink2MessageType.Nak:
raise CommandFailedError(
f"panel NAK'd {request_opcode.name} for "
f"{object_type.name}#{start}..{end_n}"
)
self._expect(reply, reply_opcode)
body = reply.payload
if len(body) < header_bytes:
raise OmniConnectionError(
f"{reply_opcode.name} payload too short: {len(body)}"
)
if body[0] != int(object_type):
raise OmniConnectionError(
f"{reply_opcode.name} object type mismatch: "
f"sent {int(object_type)}, got {body[0]}"
)
if record_sizes is None:
# ExtendedStatus carries the per-record size at payload[1].
record_size = body[1]
records_start = 2
else:
record_size = record_sizes.get(int(object_type), 0)
if record_size == 0:
raise NotImplementedError(
f"no Status record size for {object_type.name}"
)
records_start = 1
records_buf = body[records_start:]
if record_size == 0:
return []
out: list[StatusReply] = []
for off in range(0, len(records_buf), record_size):
chunk = records_buf[off : off + record_size]
if len(chunk) < record_size:
# Trailing partial record: ignore (panel may pad).
break
out.append(parser.parse(chunk))
return out
async def list_zone_names(self) -> dict[int, str]:
"""Walk all zones, returning ``{index: name}`` for those with a name set."""
return await self._walk_named_objects(
@ -218,6 +718,27 @@ class OmniClient:
_runner(), name="omni-client-subscriber"
)
def events(self) -> AsyncIterator[SystemEvent]:
"""Async iterator over typed :class:`SystemEvent` push notifications.
Built on top of :meth:`OmniConnection.unsolicited` and
:class:`omni_pca.events.EventStream`. Filters out non-SystemEvents
unsolicited messages, parses each SystemEvents (opcode 55) message
into one or more typed events, and yields them one at a time.
Usage::
async for event in client.events():
match event:
case ZoneStateChanged() if event.is_open:
...
case ArmingChanged():
...
"""
from .events import EventStream
return EventStream(self._conn).__aiter__()
# ---- helpers ---------------------------------------------------------
@staticmethod

211
src/omni_pca/commands.py Normal file
View File

@ -0,0 +1,211 @@
"""Command (opcode 20) and ExecuteSecurityCommand (opcode 74) primitives.
This module pins down the exact byte values the panel expects in the
*first byte* of a Command (opcode 20) payload, plus the failure-mode
exception used by the typed methods on :class:`omni_pca.client.OmniClient`.
Naming note: there is no standalone ``enuCommand`` enum in HAI_Shared
the C# code uses :class:`enuUnitCommand` (file
``decompiled/project/HAI_Shared/enuUnitCommand.cs``) for *every* Command
opcode regardless of object type, even though the name suggests it's
unit-only. We mirror that single enum here under the cleaner name
:class:`Command`. Every member cites the line in ``enuUnitCommand.cs``
where its byte value is defined.
The rich Command (opcode 20) wire format (from
``clsOL2MsgCommand.cs:5-57``) is:
payload[0] = command byte (this enum)
payload[1] = parameter1 (single byte, e.g. brightness, mode value)
payload[2] = parameter2 high byte (BE u16)
payload[3] = parameter2 low byte
Parameter2 is almost always the **object number** (unit#, zone#,
thermostat#, message#, button#, scene#). Parameter1 carries whatever the
specific command needs (level, mode, set-point, etc.) see the per-
member doc-comments below for the mapping.
The ExecuteSecurityCommand (opcode 74) wire format (from
``clsOL2MsgExecuteSecurityCommand.cs:5-90``) is:
payload[0] = area number (1-based)
payload[1] = security mode byte (enuSecurityMode, raw 0-7)
payload[2] = code digit 1 (thousands place, 0-9)
payload[3] = code digit 2 (hundreds place, 0-9)
payload[4] = code digit 3 (tens place, 0-9)
payload[5] = code digit 4 (ones place, 0-9)
The reply (ExecuteSecurityCommandResponse, opcode 75) carries a single
status byte at ``payload[0]`` whose values are listed in
``enuSecurityCommnadResponse.cs`` :class:`SecurityCommandResponse`
mirrors that enum.
"""
from __future__ import annotations
from enum import IntEnum
from .connection import ProtocolError
class Command(IntEnum):
"""OMNI command codes used as ``payload[0]`` of a Command (opcode 20).
Every member's value is sourced from
``decompiled/project/HAI_Shared/enuUnitCommand.cs``; the trailing
line-number reference points to the exact definition.
"""
# ---- unit / lighting ------------------------------------------------
UNIT_OFF = 0 # enuUnitCommand.Off, line 5
UNIT_ON = 1 # enuUnitCommand.On, line 6
ALL_OFF = 2 # enuUnitCommand.AllOff, line 7 (alias: All=2 line 8)
ALL_ON = 3 # enuUnitCommand.AllOn, line 9
BYPASS_ZONE = 4 # enuUnitCommand.Bypass, line 10
RESTORE_ZONE = 5 # enuUnitCommand.Restore, line 11
RESTORE_ALL_ZONES = 6 # enuUnitCommand.RestoreAll, line 12
EXECUTE_BUTTON = 7 # enuUnitCommand.Button, line 13
ENERGY = 8 # enuUnitCommand.Energy, line 14
UNIT_LEVEL = 9 # enuUnitCommand.Level, line 15 (param1 = 0..100 %)
UNIT_DECREMENT_COUNTER = 10 # enuUnitCommand.Dec, line 16
UNIT_INCREMENT_COUNTER = 11 # enuUnitCommand.Inc, line 17
UNIT_SET_COUNTER = 12 # enuUnitCommand.Set, line 18
UNIT_RAMP = 13 # enuUnitCommand.Ramp, line 19
COMPOSE_SCENE = 14 # enuUnitCommand.Compose, line 20
UPB_STATUS_REQUEST = 15 # enuUnitCommand.UPBStatus, line 21
DIM_STEP = 16 # enuUnitCommand.Dim, line 22 (param1 = step)
DIM_1 = 17 # enuUnitCommand.Dim1, line 23
DIM_2 = 18 # enuUnitCommand.Dim2, line 24
DIM_3 = 19 # enuUnitCommand.Dim3, line 25
DIM_4 = 20 # enuUnitCommand.Dim4, line 26
DIM_5 = 21 # enuUnitCommand.Dim5, line 27
DIM_6 = 22 # enuUnitCommand.Dim6, line 28
DIM_7 = 23 # enuUnitCommand.Dim7, line 29
DIM_8 = 24 # enuUnitCommand.Dim8, line 30
DIM_9 = 25 # enuUnitCommand.Dim9, line 31
UPB_BLINK = 26 # enuUnitCommand.UPBBlink, line 32
UPB_BLINK_OFF = 27 # enuUnitCommand.UPBBlinkOff, line 33
UPB_LINK_OFF = 28 # enuUnitCommand.UPBLinkOff, line 34
UPB_LINK_ON = 29 # enuUnitCommand.UPBLinkOn, line 35
UPB_LINK_SET = 30 # enuUnitCommand.UPBLinkSet, line 36
UPB_LINK_FADE_STOP = 31 # enuUnitCommand.UPBLinkFadeStop, line 37
BRIGHT_STEP = 32 # enuUnitCommand.Bright, line 38 (param1 = step)
BRIGHT_1 = 33 # enuUnitCommand.Bright1, line 39
BRIGHT_2 = 34 # enuUnitCommand.Bright2, line 40
BRIGHT_3 = 35 # enuUnitCommand.Bright3, line 41
BRIGHT_4 = 36 # enuUnitCommand.Bright4, line 42
BRIGHT_5 = 37 # enuUnitCommand.Bright5, line 43
BRIGHT_6 = 38 # enuUnitCommand.Bright6, line 44
BRIGHT_7 = 39 # enuUnitCommand.Bright7, line 45
BRIGHT_8 = 40 # enuUnitCommand.Bright8, line 46
BRIGHT_9 = 41 # enuUnitCommand.Bright9, line 47
CENTRALITE_SCENE_OFF = 42 # enuUnitCommand.CentraLiteSceneOff, line 48
CENTRALITE_SCENE_ON = 43 # enuUnitCommand.CentraLiteSceneOn, line 49
UPB_LED_OFF = 44 # enuUnitCommand.UPBLEDOff, line 50
UPB_LED_ON = 45 # enuUnitCommand.UPBLEDOn, line 51
RADIO_RA_PHANTOM_OFF = 46 # enuUnitCommand.RadioRAPhantomOff, line 52
RADIO_RA_PHANTOM_ON = 47 # enuUnitCommand.RadioRAPhantomOn, line 53
# ---- security (alternative path; preferred path is opcode 74) ------
# When sent through a Command (opcode 20), parameter1 carries the user
# code index (1-based) and parameter2 carries the area number. The
# panel honours these only if the code is enabled for the area.
SECURITY_OFF = 48 # enuUnitCommand.SecurityOff, line 55 (alias Security=48 line 54)
SECURITY_DAY = 49 # enuUnitCommand.SecurityDay, line 56
SECURITY_NIGHT = 50 # enuUnitCommand.SecurityNight, line 57
SECURITY_AWAY = 51 # enuUnitCommand.SecurityAway, line 58
SECURITY_VACATION = 52 # enuUnitCommand.SecurityVac, line 59
SECURITY_DAY_INSTANT = 53 # enuUnitCommand.SecurityDyi, line 60
SECURITY_NIGHT_DELAYED = 54 # enuUnitCommand.SecurityNtd, line 61
SECURITY_ANY_CHANGE = 55 # enuUnitCommand.SecurityAny, line 62
SECURITY_ARMING_DAY = 57 # enuUnitCommand.SecurityArmingDay, line 63
SECURITY_ARMING_NIGHT = 58 # enuUnitCommand.SecurityArmingNight, line 64
SECURITY_ARMING_AWAY = 59 # enuUnitCommand.SecurityArmingAway, line 65
SECURITY_ARMING_VACATION = 60 # enuUnitCommand.SecurityArmingVacation, line 66
SECURITY_ARMING_DAY_INSTANT = 61 # enuUnitCommand.SecurityArmingDayInst, line 67
SECURITY_ARMING_NIGHT_DELAYED = 62 # enuUnitCommand.SecurityArmingNightDelay, line 68
# ---- energy (HMS) --------------------------------------------------
ENERGY_OFF = 64 # enuUnitCommand.Eof, line 69
ENERGY_ON = 65 # enuUnitCommand.Eon, line 70
# ---- thermostat ---------------------------------------------------
SET_THERMOSTAT_HEAT_SETPOINT = 66 # enuUnitCommand.SetLowSetPt, line 71
SET_THERMOSTAT_COOL_SETPOINT = 67 # enuUnitCommand.SetHighSetPt, line 72
SET_THERMOSTAT_SYSTEM_MODE = 68 # enuUnitCommand.Mode, line 73
SET_THERMOSTAT_FAN_MODE = 69 # enuUnitCommand.Fan, line 74
SET_THERMOSTAT_HOLD_MODE = 70 # enuUnitCommand.Hold, line 75
THERMOSTAT_INC_DEC_LO = 71 # enuUnitCommand.IncDecLo, line 76
THERMOSTAT_INC_DEC_HI = 72 # enuUnitCommand.IncDecHi, line 77
SET_THERMOSTAT_HUMIDIFY_SETPOINT = 73 # enuUnitCommand.SetHumidifySetPt, line 78
SET_THERMOSTAT_DEHUMIDIFY_SETPOINT = 74 # enuUnitCommand.SetDeHumidifySetPt, line 79
# ---- panel display messages ---------------------------------------
SHOW_MESSAGE_WITH_BEEP = 80 # enuUnitCommand.ShowMsgWBeep, line 81 (alias FirstMsgCmd=80 line 80)
LOG_MESSAGE = 81 # enuUnitCommand.LogMsg, line 82
CLEAR_MESSAGE = 82 # enuUnitCommand.ClearMsg, line 83
SAY_MESSAGE = 83 # enuUnitCommand.SayMsg, line 84
PHONE_MESSAGE = 84 # enuUnitCommand.PhoneMsg, line 85
SEND_MESSAGE = 85 # enuUnitCommand.SendMsg, line 86
SHOW_MESSAGE_NO_BEEP = 86 # enuUnitCommand.ShowMsgNoBeep, line 87
EMAIL_MESSAGE = 87 # enuUnitCommand.EMailMsg, line 88 (alias LastMsgCmd=87 line 89)
# ---- scenes / misc -----------------------------------------------
SCENE_OFF = 96 # enuUnitCommand.SceneOff, line 90
SCENE_ON = 97 # enuUnitCommand.SceneOn, line 91
SCENE_SET = 98 # enuUnitCommand.SceneSet, line 92
TOGGLE = 99 # enuUnitCommand.Toggle, line 93
SHOW_VIDEO = 100 # enuUnitCommand.ShowVideo, line 94
TIMED_LEVEL = 101 # enuUnitCommand.TimedLevel, line 95
CONSOLE_BEEP = 102 # enuUnitCommand.ConsoleBeep, line 96
BEEP = 103 # enuUnitCommand.Beep, line 97
EXECUTE_PROGRAM = 104 # enuUnitCommand.UserSetting, line 98
LOCK = 105 # enuUnitCommand.Lock, line 99
UNLOCK = 106 # enuUnitCommand.Unlock, line 100
LUTRON_HOMEWORKS_KEYPAD = 107 # enuUnitCommand.LutronHomeWorksKeypadButtonPress, line 101
CLIPSAL_C_BUS_SCENE = 108 # enuUnitCommand.Clipsal_C_Bus_Scene, line 102
RADIO_RA2_PHANTOM = 109 # enuUnitCommand.RadioRA2Phantom, line 103
STOP = 110 # enuUnitCommand.Stop, line 104
# ---- audio --------------------------------------------------------
AUDIO_ZONE = 112 # enuUnitCommand.AudioZone, line 105
AUDIO_VOLUME = 113 # enuUnitCommand.AudioVolume, line 106
AUDIO_SOURCE = 114 # enuUnitCommand.AudioSource, line 107
AUDIO_KEY_PRESS = 115 # enuUnitCommand.AudioKeyPress, line 108
class SecurityCommandResponse(IntEnum):
"""Status byte returned in an ExecuteSecurityCommandResponse (opcode 75).
Source: ``decompiled/project/HAI_Shared/enuSecurityCommnadResponse.cs``
(typo in the C# enum name preserved here for grep parity).
"""
SUCCESS = 0 # line 5
INVALID_CODE = 1 # line 6
INVALID_SECURITY_MODE = 2 # line 7
INVALID_AREA = 3 # line 8
ZONES_NOT_READY = 4 # line 9
INSTALLER_RESTORE_NEEDED = 5 # line 10
CODE_LOCKED_OUT = 6 # line 11
INVALID = 0xFF # line 12
class CommandFailedError(ProtocolError):
"""A command opcode was Nak'd by the panel, or returned a structured
failure code (e.g. the Security command response carries one of the
:class:`SecurityCommandResponse` values).
The ``failure_code`` attribute is set when the panel returned an
ExecuteSecurityCommandResponse with a non-zero status byte; it's
``None`` for plain Nak replies that carry no further detail.
"""
def __init__(
self,
message: str,
*,
failure_code: int | None = None,
) -> None:
super().__init__(message)
self.failure_code = failure_code

860
src/omni_pca/events.py Normal file
View File

@ -0,0 +1,860 @@
"""Typed system-event objects for Omni-Link II push notifications.
The panel batches state-change notifications into a single ``SystemEvents``
message (v2 opcode 55). Each batched event is a single 16-bit big-endian
word in the message payload the message envelope is just a sequence of
those words. The 16-bit value is *category-encoded*: the high bits pick
the event family (zone, unit, arming, alarm, AC, battery, ) and the
remaining bits carry per-family fields (zone index, area, alarm type,
unit number, security mode, etc.).
Pipeline:
raw bytes -> Message (opcode 55)
Message -> parse_events(message) -> list[SystemEvent]
SystemEvent -> ZoneStateChanged | UnitStateChanged | ArmingChanged |
A single SystemEvents message can carry multiple events, so the public
parse entry points always return a *list*. ``EventStream`` flattens that
list across an underlying ``OmniConnection.unsolicited()`` iterator so
consumers can iterate one typed event at a time.
References (decompiled C# source):
clsOLMsgSystemEvents.cs message envelope + per-event word read
enuOmniLink2MessageType.cs:60 SystemEvents = 55 (v2 opcode)
enuEventType.cs category enum (the values used here)
enuAlarmType.cs alarm subtype byte
enuSecurityMode.cs security mode byte (used by arming)
clsText.cs:1585-1690 (GetEventCategory)
bit-mask classifier we mirror below
clsText.cs:1693-1911 (GetEventText)
per-category sub-field extraction
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from enum import IntEnum
from typing import ClassVar
from .message import Message
from .models import SecurityMode
from .opcodes import OmniLink2MessageType
# --------------------------------------------------------------------------
# Numeric tags / enums
# --------------------------------------------------------------------------
class EventType(IntEnum):
"""Symbolic identifiers for the event subclasses we expose.
These are *not* the raw 16-bit on-the-wire codes those are densely
packed bit fields. We assign each typed-event subclass a stable small
integer so that ``SystemEvent.event_type`` is a single discriminator
and ``EVENT_REGISTRY`` can dispatch on it.
Reference: enuEventType.cs (the C# equivalent that drives clsText
.GetEventCategory). The order/values below intentionally mirror the
classification order in clsText.cs:1585-1690 so a future maintainer
reading both files side-by-side sees the same shape.
"""
USER_MACRO_BUTTON = 0 # clsText.cs:1587-1590
PRO_LINK_MESSAGE = 1 # clsText.cs:1591-1594
CENTRALITE_SWITCH = 2 # clsText.cs:1595-1598
ALARM_ACTIVATED = 3 # clsText.cs:1599-1602, 1738-1750
ALARM_CLEARED = 4 # synthesized: ALARM word with alarm_type=0
ZONE_STATE_CHANGED = 5 # clsText.cs:1603-1606, 1751-1756
UNIT_STATE_CHANGED = 6 # clsText.cs:1607-1610, 1757-1765
X10_CODE = 7 # clsText.cs:1615-1618
ALL_ON_OFF = 8 # clsText.cs:1643-1646
PHONE_LINE_DEAD = 9 # clsText.cs:1649,1853-1857
PHONE_LINE_RING = 10 # clsText.cs:1651,1858-1859
PHONE_LINE_OFF_HOOK = 11 # clsText.cs:1653,1860-1861
PHONE_LINE_ON_HOOK = 12 # clsText.cs:1655,1862-1863
AC_LOST = 13 # clsText.cs:1657,1866-1870
AC_RESTORED = 14 # clsText.cs:1659,1871-1872
BATTERY_LOW = 15 # clsText.cs:1661,1875-1879
BATTERY_RESTORED = 16 # clsText.cs:1663,1880-1881
DCM_TROUBLE = 17 # clsText.cs:1665,1884-1888
DCM_OK = 18 # clsText.cs:1667,1889-1890
ENERGY_COST_LOW = 19 # clsText.cs:1669,1893-1897
ENERGY_COST_MID = 20 # clsText.cs:1671,1898-1899
ENERGY_COST_HIGH = 21 # clsText.cs:1673,1900-1901
ENERGY_COST_CRITICAL = 22 # clsText.cs:1675,1902-1903
CAMERA = 23 # clsText.cs:1677-1683,1906-1907
ACCESS_READER = 24 # clsText.cs:1684-1688,1908-1909
UPB_LINK = 25 # clsText.cs:1635-1638,1795-1810
ARMING_CHANGED = 26 # clsText.cs:1689,2140-2217 (catch-all)
UNKNOWN = 0xFF # parser couldn't classify
class AlarmKind(IntEnum):
"""Alarm subtype byte (enuAlarmType.cs)."""
ANY = 0 # enuAlarmType.cs:5
BURGLARY = 1 # enuAlarmType.cs:6
FIRE = 2 # enuAlarmType.cs:7
GAS = 3 # enuAlarmType.cs:8
AUX = 4 # enuAlarmType.cs:9
FREEZE = 5 # enuAlarmType.cs:10
WATER = 6 # enuAlarmType.cs:11
DURESS = 7 # enuAlarmType.cs:12
TEMPERATURE = 8 # enuAlarmType.cs:13
CONFIRMED_BURGLARY = 9 # enuAlarmType.cs:14
class UpbLinkAction(IntEnum):
"""UPB link sub-action, the upper byte of a UPB-LINK event word.
The C# code maps these via ``enuButtonType`` — UPBLinkOff/On/Set/
FadeStop and the enum values are picked so they line up with the
on-the-wire upper byte (clsText.cs:1801-1808 + enuEventType.cs:14-19,
where UPB_LINK_OFF=64512, UPB_LINK_ON=64768, UPB_LINK_SET=65024,
UPB_LINK_FADE_STOP=65280 i.e. high-byte = 0xFC, 0xFD, 0xFE, 0xFF).
"""
OFF = 0xFC
ON = 0xFD
SET = 0xFE
FADE_STOP = 0xFF
# --------------------------------------------------------------------------
# Wire-format helpers
# --------------------------------------------------------------------------
def _ensure_system_events(message: Message) -> bytes:
"""Validate that ``message`` is a v2 SystemEvents reply, return its
payload bytes (everything after the opcode).
Reference: clsOLMsgSystemEvents.cs (entire file) the message body
is just ``[opcode][word1_hi][word1_lo][word2_hi][word2_lo]``.
"""
if message.opcode != int(OmniLink2MessageType.SystemEvents):
raise ValueError(
"not a SystemEvents message: opcode "
f"{message.opcode} (expected {int(OmniLink2MessageType.SystemEvents)})"
)
payload = message.payload
if len(payload) % 2 != 0:
# The C# count formula is ``(MessageLength - 1) / 2`` and silently
# truncates a trailing odd byte. We do the same — never raise.
payload = payload[: len(payload) - 1]
return payload
def _iter_event_words(payload: bytes) -> list[int]:
"""Split a SystemEvents payload into 16-bit BE words.
Reference: clsOLMsgSystemEvents.cs:15-18 (SystemEvent(index) accessor).
"""
return [(payload[i] << 8) | payload[i + 1] for i in range(0, len(payload), 2)]
# --------------------------------------------------------------------------
# Base + concrete event classes
# --------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class SystemEvent:
"""Base class for every typed system-event object.
Subclasses override ``EVENT_TYPE`` (the discriminator) and may add
extra attributes carrying decoded fields. The original 16-bit word
is always preserved as ``raw_word`` so callers can fall back to the
unstructured value for diagnostics.
"""
event_type: EventType
raw_word: int
EVENT_TYPE: ClassVar[EventType | None] = None
@classmethod
def parse(cls, message: Message) -> list[SystemEvent]:
"""Decode every event word in a v2 SystemEvents message.
Returns a list because a single message can batch multiple events
(clsOLMsgSystemEvents.SystemEventsCount() one count value, but
the protocol allows ``count`` to be > 1).
"""
return parse_events(message)
@dataclass(frozen=True, slots=True)
class UserMacroButton(SystemEvent):
"""A user macro button (1-255) was triggered.
Wire layout: ``(word & 0xFF00) == 0`` button index in low byte.
Reference: clsText.cs:1587-1590, 1697-1698.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.USER_MACRO_BUTTON
button_index: int = 0
@dataclass(frozen=True, slots=True)
class ProLinkMessage(SystemEvent):
"""A Pro-Link message (256..383) was received.
Wire layout: ``(word & 0xFF80) == 0x100`` message index in low 7 bits.
Reference: clsText.cs:1591-1594, 1699-1700.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.PRO_LINK_MESSAGE
message_index: int = 0
@dataclass(frozen=True, slots=True)
class CentraLiteSwitch(SystemEvent):
"""A CentraLite/Aegis scene-keypad button was pressed.
Wire layout: ``(word & 0xFF80) == 0x180`` switch sub-index in low 7 bits.
Reference: clsText.cs:1595-1598, 1701-1736.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.CENTRALITE_SWITCH
switch_index: int = 0
@dataclass(frozen=True, slots=True)
class AlarmActivated(SystemEvent):
"""A real alarm condition was triggered.
Wire layout: ``(word & 0xFF00) == 0x200`` (the ALARM family).
- bits 4-7 of low byte (``(word & 0xF0) >> 4``) enuAlarmType
- bits 0-3 of low byte (``word & 0xF``) area index
(0 means "system-wide alarm, no specific area")
Reference: clsText.cs:1599-1602, 1738-1750.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.ALARM_ACTIVATED
area_index: int = 0
alarm_type: int = 0 # AlarmKind value
@dataclass(frozen=True, slots=True)
class AlarmCleared(SystemEvent):
"""An alarm condition was cleared.
Synthesized the wire word is in the ALARM family but with the
alarm-type nibble equal to ``AlarmKind.ANY`` (0). The C# code does
not have a separate cleared category; it simply formats the word as
an "Any" alarm. We split it out so home-automation callers can react
to "alarm went away" without rebuilding the bitfield themselves.
Reference: clsText.cs:1738-1750 (the ``a`` variable can be 0).
"""
EVENT_TYPE: ClassVar[EventType] = EventType.ALARM_CLEARED
area_index: int = 0
@dataclass(frozen=True, slots=True)
class ZoneStateChanged(SystemEvent):
"""A security zone changed state (open/close, secure/not-ready).
Wire layout: ``(word & 0xFC00) == 0x400`` (ZONE_STATE_CHANGE family).
- low byte zone index 1..255
- bit 9 (``(word >> 8) & 0x02``) 1 = not-ready/open, 0 = secure
Reference: clsText.cs:1603-1606, 1751-1756.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.ZONE_STATE_CHANGED
zone_index: int = 0
new_state: int = 0 # 0=secure, 1=not-ready/open
@property
def is_open(self) -> bool:
return self.new_state != 0
@property
def is_secure(self) -> bool:
return self.new_state == 0
@dataclass(frozen=True, slots=True)
class UnitStateChanged(SystemEvent):
"""A controllable unit (light/output) changed state.
Wire layout: ``(word & 0xFC00) == 0x800`` (UNIT_STATE_CHANGE family).
- unit index = ``((word >> 8) & 1) * 256 + (word & 0xFF)``
(so bit 8 lifts the index above 255)
- bit 9 (``(word >> 8) & 0x02``) 1 = on, 0 = off
Reference: clsText.cs:1607-1610, 1757-1765.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.UNIT_STATE_CHANGED
unit_index: int = 0
new_state: int = 0 # 0=off, 1=on
@property
def is_on(self) -> bool:
return self.new_state != 0
@dataclass(frozen=True, slots=True)
class X10CodeReceived(SystemEvent):
"""An X-10 house/unit code was seen on the powerline.
Wire layout: ``(word & 0xFC00) == 0xC00``.
- house letter A..P = chr(65 + ((word & 0xFF) >> 4))
- unit number 1..16 = (word & 0xF) + 1
- on/off (bit 9) = ``((word >> 8) & 2) == 2`` On
- "all units" (bit 8) = ``(word & 0x100) != 0``
Reference: clsText.cs:1615-1618, 1785-1793.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.X10_CODE
house_code: str = "A"
unit_number: int = 1
is_on: bool = False
all_units: bool = False
@dataclass(frozen=True, slots=True)
class AllOnOff(SystemEvent):
"""An "All On" or "All Off" command was issued.
Wire layout: ``(word & 0xFFE0) == 0x3E0`` (=992 family).
- bit 4 (``(word & 0x10) >> 4``) 0 = all off, 1 = all on
- low 4 bits (``word & 0xF``) area (0 = system-wide)
Reference: clsText.cs:1643-1646, 1835-1851.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.ALL_ON_OFF
area_index: int = 0
on: bool = False
@dataclass(frozen=True, slots=True)
class PhoneLineDead(SystemEvent):
"""Phone line went dead (word == 768).
Reference: clsText.cs:1649, 1853-1857."""
EVENT_TYPE: ClassVar[EventType] = EventType.PHONE_LINE_DEAD
@dataclass(frozen=True, slots=True)
class PhoneLineRinging(SystemEvent):
"""Phone is ringing (word == 769).
Reference: clsText.cs:1651, 1858-1859."""
EVENT_TYPE: ClassVar[EventType] = EventType.PHONE_LINE_RING
@dataclass(frozen=True, slots=True)
class PhoneLineOffHook(SystemEvent):
"""Panel went off-hook to dial out (word == 770).
Reference: clsText.cs:1653, 1860-1861."""
EVENT_TYPE: ClassVar[EventType] = EventType.PHONE_LINE_OFF_HOOK
@dataclass(frozen=True, slots=True)
class PhoneLineOnHook(SystemEvent):
"""Panel hung up (word == 771).
Reference: clsText.cs:1655, 1862-1863."""
EVENT_TYPE: ClassVar[EventType] = EventType.PHONE_LINE_ON_HOOK
@dataclass(frozen=True, slots=True)
class AcLost(SystemEvent):
"""Mains AC was lost (word == 772).
Reference: clsText.cs:1657, 1866-1870."""
EVENT_TYPE: ClassVar[EventType] = EventType.AC_LOST
@dataclass(frozen=True, slots=True)
class AcRestored(SystemEvent):
"""Mains AC came back (word == 773).
Reference: clsText.cs:1659, 1871-1872."""
EVENT_TYPE: ClassVar[EventType] = EventType.AC_RESTORED
@dataclass(frozen=True, slots=True)
class BatteryLow(SystemEvent):
"""Backup battery is low (word == 774).
Reference: clsText.cs:1661, 1875-1879."""
EVENT_TYPE: ClassVar[EventType] = EventType.BATTERY_LOW
@dataclass(frozen=True, slots=True)
class BatteryRestored(SystemEvent):
"""Backup battery is OK again (word == 775).
Reference: clsText.cs:1663, 1880-1881."""
EVENT_TYPE: ClassVar[EventType] = EventType.BATTERY_RESTORED
@dataclass(frozen=True, slots=True)
class DcmTrouble(SystemEvent):
"""Digital communicator failure (word == 776).
Reference: clsText.cs:1665, 1884-1888."""
EVENT_TYPE: ClassVar[EventType] = EventType.DCM_TROUBLE
@dataclass(frozen=True, slots=True)
class DcmOk(SystemEvent):
"""Digital communicator OK (word == 777).
Reference: clsText.cs:1667, 1889-1890."""
EVENT_TYPE: ClassVar[EventType] = EventType.DCM_OK
@dataclass(frozen=True, slots=True)
class EnergyCostChanged(SystemEvent):
"""Real-time energy-cost band changed (words 778..781).
The discriminator on the dataclass tells you which band; ``raw_word``
keeps the original number in case a future firmware adds more.
Reference: clsText.cs:1669-1676, 1893-1903.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.ENERGY_COST_LOW # placeholder, overridden by parse
cost_level: int = 0 # 0=low, 1=mid, 2=high, 3=critical
@dataclass(frozen=True, slots=True)
class CameraTrigger(SystemEvent):
"""A camera input fired (words 782..787).
Wire layout: ``camera_index = word - 781`` 1..6.
Reference: clsText.cs:1677-1683, 1906-1907.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.CAMERA
camera_index: int = 1
@dataclass(frozen=True, slots=True)
class AccessReaderEvent(SystemEvent):
"""An access-control reader emitted an event (words 976..991).
Wire layout: ``reader_index = (word & 0xF) + 1``.
Reference: clsText.cs:1684-1688, 1908-1909.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.ACCESS_READER
reader_index: int = 1
@dataclass(frozen=True, slots=True)
class UpbLinkEvent(SystemEvent):
"""A UPB link command was sent (words 0xFC00..0xFFFF).
Wire layout: ``(word & 0xFC00) == 0xFC00``.
- upper byte enuButtonType: UPBLinkOff(0xFC), UPBLinkOn(0xFD),
UPBLinkSet(0xFE), UPBLinkFadeStop(0xFF)
- lower byte link index 1..255
Reference: clsText.cs:1635-1638, 1795-1810.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.UPB_LINK
link_index: int = 0
action: int = 0 # UpbLinkAction value (raw upper byte)
@dataclass(frozen=True, slots=True)
class ArmingChanged(SystemEvent):
"""An area's security mode changed (catch-all SECURITY_MODE_CHANGE).
Wire layout (clsText.cs:2155-2217 this is the default branch of
``GetButtonText``, which is what GetEventCategory routes to when the
event word doesn't match any other family):
- bits 12-14 (``(word >> 12) & 7``) enuSecurityMode value
- bits 8-11 (``(word >> 8) & 0xF``) area index (0 = system)
- low byte (``word & 0xFF``) user/code index that
triggered the change (0 = unknown / panel-initiated)
- bit 15 (``(word >> 8) & 0x80``) "Set" vs. "Arm" verb,
surfaced as ``is_set_command``
- bit 11 (``(word >> 8) & 0x40``)... reserved-ish; left as raw
Reference: clsText.cs:1689 (catch-all) + 2140-2217 (decoder).
"""
EVENT_TYPE: ClassVar[EventType] = EventType.ARMING_CHANGED
area_index: int = 0
new_mode: int = 0 # SecurityMode value
user_index: int = 0
is_set_command: bool = False # True for SET (panic/Lumina), False for ARM
@property
def mode_name(self) -> str:
try:
return SecurityMode(self.new_mode).name
except ValueError:
return f"Unknown({self.new_mode})"
@dataclass(frozen=True, slots=True)
class UnknownEvent(SystemEvent):
"""Catch-all so an unrecognised event word never crashes the iterator.
The event type byte was parseable but didn't match any family in
clsText.GetEventCategory's classification — likely a future-firmware
code we haven't mapped yet.
"""
EVENT_TYPE: ClassVar[EventType] = EventType.UNKNOWN
# --------------------------------------------------------------------------
# Per-word classifier (mirrors clsText.GetEventCategory)
# --------------------------------------------------------------------------
def _classify(word: int) -> SystemEvent:
"""Decode a single 16-bit event word into the appropriate subclass.
Mirrors ``clsText.GetEventCategory`` (clsText.cs:1585-1690) and the
per-family field-extraction in ``GetEventText`` (clsText.cs:1693-1911).
The classification order matters exact-match cases (PHONE_, AC_,
BATTERY_, ) are inspected before the wide SECURITY_MODE_CHANGE
catch-all, exactly as the C# does.
"""
# USER_MACRO_BUTTON: high byte == 0
if (word & 0xFF00) == 0x0000:
return UserMacroButton(
event_type=EventType.USER_MACRO_BUTTON,
raw_word=word,
button_index=word & 0xFF,
)
# PRO_LINK_MESSAGE: high 9 bits == 0x100
if (word & 0xFF80) == 0x0100:
return ProLinkMessage(
event_type=EventType.PRO_LINK_MESSAGE,
raw_word=word,
message_index=word & 0x7F,
)
# CENTRALITE_SWITCH: high 9 bits == 0x180
if (word & 0xFF80) == 0x0180:
return CentraLiteSwitch(
event_type=EventType.CENTRALITE_SWITCH,
raw_word=word,
switch_index=word & 0x7F,
)
# ALARM (activated/cleared): top byte == 0x02
if (word & 0xFF00) == 0x0200:
alarm_type = (word & 0xF0) >> 4
area = word & 0x0F
if alarm_type == int(AlarmKind.ANY):
# Per clsText.cs:1738-1750, the "ANY" subtype is what the panel
# emits when an alarm is being cleared — it formats it as
# "Any alarm cleared" string. Surface as a distinct subclass.
return AlarmCleared(
event_type=EventType.ALARM_CLEARED,
raw_word=word,
area_index=area,
)
return AlarmActivated(
event_type=EventType.ALARM_ACTIVATED,
raw_word=word,
area_index=area,
alarm_type=alarm_type,
)
# ZONE_STATE_CHANGE: top 6 bits == 0x4
if (word & 0xFC00) == 0x0400:
return ZoneStateChanged(
event_type=EventType.ZONE_STATE_CHANGED,
raw_word=word,
zone_index=word & 0xFF,
new_state=1 if ((word >> 8) & 0x02) == 0x02 else 0,
)
# UNIT_STATE_CHANGE: top 6 bits == 0x8
if (word & 0xFC00) == 0x0800:
unit_index = ((word >> 8) & 0x01) * 256 + (word & 0xFF)
return UnitStateChanged(
event_type=EventType.UNIT_STATE_CHANGED,
raw_word=word,
unit_index=unit_index,
new_state=1 if ((word >> 8) & 0x02) == 0x02 else 0,
)
# X-10 code: top 6 bits == 0xC
if (word & 0xFC00) == 0x0C00:
return X10CodeReceived(
event_type=EventType.X10_CODE,
raw_word=word,
house_code=chr(65 + ((word & 0xFF) >> 4)),
unit_number=(word & 0x0F) + 1,
is_on=((word >> 8) & 0x02) == 0x02,
all_units=(word & 0x0100) != 0,
)
# ALL_ON_OFF: top 11 bits == 992 (0x3E0) — covers 992..1023, but
# we leave 1024+ for ZONE which has already been handled above.
if (word & 0xFFE0) == 0x03E0:
return AllOnOff(
event_type=EventType.ALL_ON_OFF,
raw_word=word,
area_index=word & 0x0F,
on=(word & 0x10) != 0,
)
# Exact-match singletons (PHONE_, AC_, BATTERY_, DCM_, ENERGY, CAMERA,
# ACCESS_READER) come before the SECURITY catch-all.
if word == 768:
return PhoneLineDead(event_type=EventType.PHONE_LINE_DEAD, raw_word=word)
if word == 769:
return PhoneLineRinging(event_type=EventType.PHONE_LINE_RING, raw_word=word)
if word == 770:
return PhoneLineOffHook(event_type=EventType.PHONE_LINE_OFF_HOOK, raw_word=word)
if word == 771:
return PhoneLineOnHook(event_type=EventType.PHONE_LINE_ON_HOOK, raw_word=word)
if word == 772:
return AcLost(event_type=EventType.AC_LOST, raw_word=word)
if word == 773:
return AcRestored(event_type=EventType.AC_RESTORED, raw_word=word)
if word == 774:
return BatteryLow(event_type=EventType.BATTERY_LOW, raw_word=word)
if word == 775:
return BatteryRestored(event_type=EventType.BATTERY_RESTORED, raw_word=word)
if word == 776:
return DcmTrouble(event_type=EventType.DCM_TROUBLE, raw_word=word)
if word == 777:
return DcmOk(event_type=EventType.DCM_OK, raw_word=word)
if 778 <= word <= 781:
level = word - 778
return EnergyCostChanged(
event_type=EventType(EventType.ENERGY_COST_LOW + level),
raw_word=word,
cost_level=level,
)
if 782 <= word <= 787:
return CameraTrigger(
event_type=EventType.CAMERA,
raw_word=word,
camera_index=word - 781,
)
if 976 <= word <= 991:
return AccessReaderEvent(
event_type=EventType.ACCESS_READER,
raw_word=word,
reader_index=(word & 0x0F) + 1,
)
# UPB_LINK: top 6 bits == 0xFC (covers 0xFC00..0xFFFF).
# The C# code peels off the 0xFD00 (UPB_LINK_ON) sub-family first to
# check whether the unit is actually an HLC or Z-Wave room controller
# (clsText.cs:1619-1633). We don't have access to the panel's unit
# type cache here, so we always classify these as UpbLinkEvent — the
# caller can refine using the unit_index if they care.
if (word & 0xFC00) == 0xFC00:
upper = (word >> 8) & 0xFF
return UpbLinkEvent(
event_type=EventType.UPB_LINK,
raw_word=word,
link_index=word & 0xFF,
action=upper,
)
# SECURITY_MODE_CHANGE catch-all (clsText.cs:1689). This is the
# default branch in GetEventCategory: anything that didn't match any
# of the families above lands here. The 16-bit layout is:
# bits 12-14 → SecurityMode (0..7)
# bits 8-11 → area index (0 = system / no specific area)
# bit 15 → "Set" vs. "Arm" verb (Lumina vs. Omni semantics)
# low byte → user/code index that triggered the change
if (word >> 8) & 0xF0:
# Plausible arming change: the high nibble of the high byte is
# non-zero (carries either the Set bit or the area+mode bits).
return ArmingChanged(
event_type=EventType.ARMING_CHANGED,
raw_word=word,
area_index=(word >> 8) & 0x0F,
new_mode=(word >> 12) & 0x07,
user_index=word & 0xFF,
is_set_command=((word >> 8) & 0x80) == 0x80,
)
return UnknownEvent(event_type=EventType.UNKNOWN, raw_word=word)
# --------------------------------------------------------------------------
# Public parse entry points
# --------------------------------------------------------------------------
def parse_events(message: Message) -> list[SystemEvent]:
"""Decode a v2 ``SystemEvents`` (opcode 55) message into typed events.
The panel batches multiple state changes into a single message, so
the return type is always a list even for messages that carry just
one event. Empty SystemEvents messages return an empty list rather
than raising.
Reference: clsOLMsgSystemEvents.cs:10-18 (SystemEventsCount + per-
word accessor).
"""
payload = _ensure_system_events(message)
return [_classify(w) for w in _iter_event_words(payload)]
# --------------------------------------------------------------------------
# Registry — discriminator → subclass, useful for callers doing isinstance
# routing or generating documentation.
# --------------------------------------------------------------------------
EVENT_REGISTRY: dict[int, type[SystemEvent]] = {
int(EventType.USER_MACRO_BUTTON): UserMacroButton,
int(EventType.PRO_LINK_MESSAGE): ProLinkMessage,
int(EventType.CENTRALITE_SWITCH): CentraLiteSwitch,
int(EventType.ALARM_ACTIVATED): AlarmActivated,
int(EventType.ALARM_CLEARED): AlarmCleared,
int(EventType.ZONE_STATE_CHANGED): ZoneStateChanged,
int(EventType.UNIT_STATE_CHANGED): UnitStateChanged,
int(EventType.X10_CODE): X10CodeReceived,
int(EventType.ALL_ON_OFF): AllOnOff,
int(EventType.PHONE_LINE_DEAD): PhoneLineDead,
int(EventType.PHONE_LINE_RING): PhoneLineRinging,
int(EventType.PHONE_LINE_OFF_HOOK): PhoneLineOffHook,
int(EventType.PHONE_LINE_ON_HOOK): PhoneLineOnHook,
int(EventType.AC_LOST): AcLost,
int(EventType.AC_RESTORED): AcRestored,
int(EventType.BATTERY_LOW): BatteryLow,
int(EventType.BATTERY_RESTORED): BatteryRestored,
int(EventType.DCM_TROUBLE): DcmTrouble,
int(EventType.DCM_OK): DcmOk,
int(EventType.ENERGY_COST_LOW): EnergyCostChanged,
int(EventType.ENERGY_COST_MID): EnergyCostChanged,
int(EventType.ENERGY_COST_HIGH): EnergyCostChanged,
int(EventType.ENERGY_COST_CRITICAL): EnergyCostChanged,
int(EventType.CAMERA): CameraTrigger,
int(EventType.ACCESS_READER): AccessReaderEvent,
int(EventType.UPB_LINK): UpbLinkEvent,
int(EventType.ARMING_CHANGED): ArmingChanged,
int(EventType.UNKNOWN): UnknownEvent,
}
# --------------------------------------------------------------------------
# Helper: queue-backed iterator for tests + library consumers
# --------------------------------------------------------------------------
def _has_unsolicited(obj: object) -> bool:
"""True if ``obj`` quacks like an OmniConnection (has ``unsolicited()``).
We avoid importing OmniConnection at runtime to keep the dependency
purely a type hint, so EventStream stays usable with any object that
exposes the same async-iterator contract (real connection, mock, or
in-memory queue wrapper used in tests).
"""
return callable(getattr(obj, "unsolicited", None))
@dataclass
class EventStream:
"""Async iterator over typed ``SystemEvent`` objects.
Wraps any object with an ``unsolicited() -> AsyncIterator[Message]``
method (typically an :class:`OmniConnection`). Filters out non-
SystemEvents messages, parses each SystemEvents message into a list
of typed events, and yields them one at a time. A single inbound
message that batches three events therefore produces three iterator
steps callers don't have to know about batching.
Usage::
async for event in EventStream(conn):
match event:
case ZoneStateChanged() if event.is_open:
print(f"zone {event.zone_index} opened")
case ArmingChanged():
print(f"area {event.area_index} -> {event.mode_name}")
"""
source: object # OmniConnection or duck-typed equivalent
_buffer: list[SystemEvent] = field(default_factory=list)
def __post_init__(self) -> None:
if not _has_unsolicited(self.source):
raise TypeError(
"EventStream source must expose an unsolicited() method "
f"(got {type(self.source).__name__})"
)
def __aiter__(self) -> EventStream:
return self
async def __anext__(self) -> SystemEvent:
# Drain buffered events from the previous batched message first.
while not self._buffer:
try:
# ``unsolicited()`` returns a fresh async generator each
# call on the real connection, but tests pass us a queue
# wrapper that returns a long-lived iterator. Either way
# we want one message at a time, so we manually advance.
if not hasattr(self, "_iter") or self._iter is None: # type: ignore[has-type]
self._iter = self.source.unsolicited().__aiter__() # type: ignore[attr-defined]
msg = await self._iter.__anext__()
except StopAsyncIteration:
raise
except asyncio.CancelledError:
raise
if msg.opcode != int(OmniLink2MessageType.SystemEvents):
# Non-event message (Status, Ack, …) — silently ignore.
continue
self._buffer = parse_events(msg)
return self._buffer.pop(0)
__all__ = [
"EVENT_REGISTRY",
"AcLost",
"AcRestored",
"AccessReaderEvent",
"AlarmActivated",
"AlarmCleared",
"AlarmKind",
"AllOnOff",
"ArmingChanged",
"BatteryLow",
"BatteryRestored",
"CameraTrigger",
"CentraLiteSwitch",
"DcmOk",
"DcmTrouble",
"EnergyCostChanged",
"EventStream",
"EventType",
"PhoneLineDead",
"PhoneLineOffHook",
"PhoneLineOnHook",
"PhoneLineRinging",
"ProLinkMessage",
"SystemEvent",
"UnitStateChanged",
"UnknownEvent",
"UpbLinkAction",
"UpbLinkEvent",
"UserMacroButton",
"X10CodeReceived",
"ZoneStateChanged",
"parse_events",
]

View File

@ -11,7 +11,13 @@ Coverage today:
* Full secure-session handshake (NewSession / SecureSession ack pair)
* ``RequestSystemInformation`` (22) -> ``SystemInformation`` (23)
* ``RequestSystemStatus`` (24) -> ``SystemStatus`` (25)
* ``RequestProperties`` (32) -> ``Properties`` (33) for Zone + Unit
* ``RequestProperties`` (32) -> ``Properties`` (33) for Zone + Unit + Area
* ``Command`` (20) -> ``Ack`` (1) / ``Nak`` (2), with state mutation
* ``ExecuteSecurityCommand`` (74) -> ``Ack`` (1) (or Nak on bad code), with state
* ``RequestStatus`` (34) -> ``Status`` (35) for Zone/Unit/Area/Thermostat
* ``RequestExtendedStatus`` (58) -> ``ExtendedStatus`` (59) for the same set
* ``AcknowledgeAlerts`` (60) -> ``Ack`` (1)
* Synthesized push of ``SystemEvents`` (55, seq=0) when state mutates
* Any other v2 opcode -> ``Nak`` (2) with the request's opcode
* CRC failures on the inner message -> ``Nak``
* Graceful ``ClientSessionTerminated`` close
@ -21,6 +27,10 @@ References:
clsOmniLinkConnection.cs:1688-1921 (TCP listener / ack flow)
clsOL2MsgSystemInformation.cs / clsOL2MsgSystemStatus.cs
clsOL2MsgRequestProperties.cs / clsOL2MsgProperties.cs
clsOL2MsgCommand.cs / clsOL2MsgExecuteSecurityCommand.cs
clsOL2MsgRequestStatus.cs / clsOL2MsgStatus.cs
clsOL2MsgRequestExtendedStatus.cs / clsOL2MsgExtendedStatus.cs
clsOLMsgSystemEvents.cs
"""
from __future__ import annotations
@ -33,6 +43,7 @@ from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from .commands import Command
from .crypto import (
BLOCK_SIZE,
decrypt_message_payload,
@ -48,14 +59,38 @@ _log = logging.getLogger(__name__)
# enuObjectType (clsOmniLink2.cs / enuObjectType.cs)
_OBJ_ZONE = 1
_OBJ_UNIT = 2
_OBJ_BUTTON = 3
_OBJ_AREA = 5
_OBJ_THERMOSTAT = 6
# Inner-message size constants (model OMNI_PRO_II)
_ZONE_NAME_LEN = 15
_UNIT_NAME_LEN = 12
_AREA_NAME_LEN = 12
_BUTTON_NAME_LEN = 12
_THERMOSTAT_NAME_LEN = 12
_PHONE_LEN = 24
# Per-object-type record sizes for the basic Status (opcode 35) reply.
# Source: clsOL2MsgStatus.cs:13-27 — sizes hard-coded per object type, no
# per-record length byte.
_STATUS_RECORD_SIZES: dict[int, int] = {
_OBJ_ZONE: 4, # number(2) + status + loop
_OBJ_UNIT: 5, # number(2) + state + time(2)
_OBJ_AREA: 6, # number(2) + mode + alarms + entry + exit
_OBJ_THERMOSTAT: 9, # number(2) + status + 6 bytes
}
# Per-object-type ExtendedStatus (opcode 59) record sizes. The reply carries
# this byte at payload[1] (object_length); we use these to build the reply.
# Source: clsOL2MsgExtendedStatus.cs (per-object body offsets).
_EXTENDED_STATUS_RECORD_SIZES: dict[int, int] = {
_OBJ_ZONE: 4, # number(2) + status + loop
_OBJ_UNIT: 5, # number(2) + state + time(2) — ZigBeePower optional
_OBJ_AREA: 6, # number(2) + mode + alarms + entry + exit
_OBJ_THERMOSTAT: 14, # number(2) + status + temp + heat + cool + sys + fan + hold + humidity + h_set + dh_set + outdoor + horc
}
# Wire format for the controller-side ack of NewSession is two literal
# protocol-version bytes followed by the 5-byte SessionID.
_PROTO_HI = 0x00
@ -63,10 +98,101 @@ _PROTO_LO = 0x01
_SESSION_ID_BYTES = 5
# Small delay before pushing a synthesized SystemEvents so the request future
# resolves first. Kept tiny; tests use asyncio.wait_for with their own timeout.
_PUSH_DELAY = 0.005
# --------------------------------------------------------------------------
# Per-object state dataclasses
# --------------------------------------------------------------------------
@dataclass
class MockUnitState:
"""One programmable unit (light / output / scene)."""
name: str = ""
state: int = 0 # 0=off, 1=on, 100..200=brightness percent (raw Omni)
time_remaining: int = 0
@dataclass
class MockAreaState:
"""One programmable security area."""
name: str = ""
mode: int = 0 # SecurityMode value (Off=0, Day=1, Night=2, Away=3, ...)
last_user: int = 0
entry_timer: int = 0
exit_timer: int = 0
alarms: int = 0
@dataclass
class MockZoneState:
"""One programmable security zone."""
name: str = ""
current_state: int = 0 # 0=secure, 1=not-ready, 2=trouble, 3=tamper
latched_state: int = 0 # 0=secure, 4=tripped, 8=reset (raw bits 2-3)
arming_state: int = 0 # 0=disarmed, 16=armed, 32=bypassed, 48=auto-bypassed
is_bypassed: bool = False
loop: int = 0 # analog loop reading
@property
def status_byte(self) -> int:
"""Compose the on-the-wire status byte from the sub-fields.
Encoding mirrors clsZone.cs:385 / clsText.cs:3110:
bits 0-1 current_state (0..3)
bits 2-3 latched_state (0/4/8)
bits 4-5 arming_state (0/16/32/48)
is_bypassed forces the arming bits to BYPASSED (0x20) regardless of
the underlying arming_state value.
"""
val = (self.current_state & 0x03) | (self.latched_state & 0x0C)
if self.is_bypassed:
val |= 0x20
else:
val |= self.arming_state & 0x30
return val & 0xFF
@dataclass
class MockButtonState:
"""One programmable button macro (no live state — buttons just fire programs)."""
name: str = ""
@dataclass
class MockThermostatState:
"""One programmable thermostat. Defaults are sane Omni Pro II values."""
name: str = ""
temperature_raw: int = 168 # ~76°F on Omni linear scale
heat_setpoint_raw: int = 144 # ~62°F
cool_setpoint_raw: int = 184 # ~80°F
system_mode: int = 0 # HvacMode: 0=Off, 1=Heat, 2=Cool, 3=Auto, 4=EmHeat
fan_mode: int = 0 # FanMode: 0=Auto, 1=On, 2=Cycle
hold_mode: int = 0 # HoldMode: 0=Off, 1=Hold, 2=Vacation
humidity_raw: int = 0
humidify_setpoint_raw: int = 0
dehumidify_setpoint_raw: int = 0
outdoor_temperature_raw: int = 0
horc_status: int = 0
status: int = 1 # 1 = communicating with the panel
@dataclass
class MockState:
"""Programmable panel state. Defaults mimic an Omni Pro II out of the box."""
"""Programmable panel state. Defaults mimic an Omni Pro II out of the box.
Backward compat: callers may pass ``zones={1: "FRONT DOOR"}`` (a plain
``dict[int, str]``) and the constructor will auto-promote the strings
into the appropriate ``Mock*State`` instance.
"""
model_byte: int = 16 # OMNI_PRO_II
firmware_major: int = 2
@ -74,10 +200,19 @@ class MockState:
firmware_revision: int = 1
local_phone: str = ""
# Names by 1-based index (matches Omni's user-facing numbering).
zones: dict[int, str] = field(default_factory=dict)
units: dict[int, str] = field(default_factory=dict)
areas: dict[int, str] = field(default_factory=dict)
# Per-object state machines, by 1-based index. Values may be passed as
# plain strings (interpreted as the object's name) or as the matching
# ``Mock*State`` dataclass instance.
zones: dict[int, MockZoneState] = field(default_factory=dict)
units: dict[int, MockUnitState] = field(default_factory=dict)
areas: dict[int, MockAreaState] = field(default_factory=dict)
thermostats: dict[int, MockThermostatState] = field(default_factory=dict)
buttons: dict[int, MockButtonState] = field(default_factory=dict)
# User-code table for ExecuteSecurityCommand validation.
# Mapping is ``{code_index: 4-digit pin}``; the panel returns the
# matched code_index in the area's last_user field on success.
user_codes: dict[int, int] = field(default_factory=dict)
# SystemStatus snapshot. Defaults: time set, battery good, no alarms.
time_set: bool = True
@ -95,14 +230,59 @@ class MockState:
sunset_minute: int = 45
battery: int = 200 # 0-255 — typical "good" value
def __post_init__(self) -> None:
"""Promote bare-string values into per-type state dataclasses.
This keeps the existing ``MockState(zones={1: "FRONT DOOR"})``
call sites working unchanged, while letting new code pass full
``MockZoneState`` / ``MockUnitState`` / etc. records.
"""
self.zones = _promote_dict(self.zones, MockZoneState)
self.units = _promote_dict(self.units, MockUnitState)
self.areas = _promote_dict(self.areas, MockAreaState)
self.thermostats = _promote_dict(self.thermostats, MockThermostatState)
self.buttons = _promote_dict(self.buttons, MockButtonState)
# ---- name-bytes helpers (kept for back-compat with old callers) -----
def zone_name_bytes(self, idx: int) -> bytes:
return _name_bytes(self.zones.get(idx, ""), _ZONE_NAME_LEN)
z = self.zones.get(idx)
return _name_bytes(z.name if z else "", _ZONE_NAME_LEN)
def unit_name_bytes(self, idx: int) -> bytes:
return _name_bytes(self.units.get(idx, ""), _UNIT_NAME_LEN)
u = self.units.get(idx)
return _name_bytes(u.name if u else "", _UNIT_NAME_LEN)
def area_name_bytes(self, idx: int) -> bytes:
return _name_bytes(self.areas.get(idx, ""), _AREA_NAME_LEN)
a = self.areas.get(idx)
return _name_bytes(a.name if a else "", _AREA_NAME_LEN)
def thermostat_name_bytes(self, idx: int) -> bytes:
t = self.thermostats.get(idx)
return _name_bytes(t.name if t else "", _THERMOSTAT_NAME_LEN)
def button_name_bytes(self, idx: int) -> bytes:
b = self.buttons.get(idx)
return _name_bytes(b.name if b else "", _BUTTON_NAME_LEN)
def _promote_dict(
raw: dict[int, object],
dataclass_cls: type,
) -> dict[int, object]:
"""Walk a ``{int: str | DataclassInstance}`` dict, wrapping bare strings.
Bare strings become ``dataclass_cls(name=string)``. Anything that is
already an instance of ``dataclass_cls`` (or anything else) passes
through untouched.
"""
out: dict[int, object] = {}
for k, v in raw.items():
if isinstance(v, str):
out[k] = dataclass_cls(name=v)
else:
out[k] = v
return out
def _name_bytes(name: str, width: int) -> bytes:
@ -133,6 +313,11 @@ class MockPanel:
self._session_count = 0
self._last_request_opcode: int | None = None
self._busy = asyncio.Lock() # serialise concurrent connection attempts
# Per-connection state captured on _handle_client; used by the
# synthesized-event push helper when state mutates.
self._active_writer: asyncio.StreamWriter | None = None
self._active_session_key: bytes | None = None
self._push_tasks: set[asyncio.Task[None]] = set()
# -------- public observables (handy in tests) --------
@ -161,6 +346,12 @@ class MockPanel:
async with server:
yield bound_host, bound_port
finally:
# Cancel any in-flight push tasks so the test event loop
# tears down cleanly.
for t in list(self._push_tasks):
if not t.done():
t.cancel()
self._push_tasks.clear()
server.close()
with contextlib.suppress(Exception): # pragma: no cover
await server.wait_closed()
@ -201,6 +392,9 @@ class MockPanel:
)
if not handled:
break
# Make session info available to push helpers.
self._active_writer = writer
self._active_session_key = session_key
elif pkt_type is PacketType.ClientSessionTerminated:
_log.debug("mock panel: client requested teardown")
@ -222,6 +416,8 @@ class MockPanel:
except (asyncio.IncompleteReadError, ConnectionError):
_log.debug("mock panel: client connection ended unexpectedly")
finally:
self._active_writer = None
self._active_session_key = None
writer.close()
with contextlib.suppress(Exception): # pragma: no cover
await writer.wait_closed()
@ -335,18 +531,38 @@ class MockPanel:
_log.debug("mock panel: dispatch opcode=%s payload=%d bytes",
opcode_name, len(inner.payload))
reply = self._dispatch_v2(opcode, inner.payload)
reply, push_words = self._dispatch_v2(opcode, inner.payload)
await self._send_v2_reply(client_seq, reply, session_key, writer)
if push_words:
self._schedule_event_push(push_words, session_key, writer)
return True
def _dispatch_v2(self, opcode: int, payload: bytes) -> Message:
def _dispatch_v2(
self, opcode: int, payload: bytes
) -> tuple[Message, tuple[int, ...]]:
"""Dispatch a single decoded request and return (reply, push_event_words).
``push_event_words`` is a (possibly empty) tuple of 16-bit event
words to push as an unsolicited SystemEvents (opcode 55) frame
AFTER the synchronous reply has been written.
"""
if opcode == OmniLink2MessageType.RequestSystemInformation:
return self._reply_system_information()
return self._reply_system_information(), ()
if opcode == OmniLink2MessageType.RequestSystemStatus:
return self._reply_system_status()
return self._reply_system_status(), ()
if opcode == OmniLink2MessageType.RequestProperties:
return self._reply_properties(payload)
return _build_nak(opcode)
return self._reply_properties(payload), ()
if opcode == OmniLink2MessageType.Command:
return self._handle_command(payload)
if opcode == OmniLink2MessageType.ExecuteSecurityCommand:
return self._handle_execute_security_command(payload)
if opcode == OmniLink2MessageType.RequestStatus:
return self._reply_status(payload), ()
if opcode == OmniLink2MessageType.RequestExtendedStatus:
return self._reply_extended_status(payload), ()
if opcode == OmniLink2MessageType.AcknowledgeAlerts:
return _build_ack(), ()
return _build_nak(opcode), ()
# -------- reply builders (byte-exact per clsOL2Msg*.cs) --------
@ -422,15 +638,23 @@ class MockPanel:
return self._build_unit_properties(target)
if obj_type == _OBJ_AREA:
return self._build_area_properties(target)
if obj_type == _OBJ_THERMOSTAT:
return self._build_thermostat_properties(target)
if obj_type == _OBJ_BUTTON:
return self._build_button_properties(target)
return _build_nak(OmniLink2MessageType.RequestProperties)
def _object_store(self, obj_type: int) -> dict[int, str] | None:
def _object_store(self, obj_type: int) -> dict[int, object] | None:
if obj_type == _OBJ_ZONE:
return self.state.zones
return self.state.zones # type: ignore[return-value]
if obj_type == _OBJ_UNIT:
return self.state.units
return self.state.units # type: ignore[return-value]
if obj_type == _OBJ_AREA:
return self.state.areas
return self.state.areas # type: ignore[return-value]
if obj_type == _OBJ_THERMOSTAT:
return self.state.thermostats # type: ignore[return-value]
if obj_type == _OBJ_BUTTON:
return self.state.buttons # type: ignore[return-value]
return None
def _build_zone_properties(self, index: int) -> Message:
@ -439,14 +663,15 @@ class MockPanel:
# [4]=Status, [5]=Loop, [6]=Type, [7]=Area, [8]=Options,
# [9..23]=Name (15 bytes)
# encode_v2 prepends the opcode, so we emit body = Data[1..23].
zone = self.state.zones.get(index)
body = (
bytes(
[
_OBJ_ZONE,
(index >> 8) & 0xFF,
index & 0xFF,
0, # Status: closed/secure
0, # Loop
zone.status_byte if zone else 0,
zone.loop if zone else 0,
0, # Type: EntryExit
1, # Area: default to area 1
0, # Options
@ -461,15 +686,16 @@ class MockPanel:
# [0]=opcode, [1]=ObjectType, [2..3]=ObjectNumber,
# [4]=UnitStatus, [5..6]=UnitTime, [7]=UnitType,
# [8..19]=Name (12), [20]=reserved, [21]=UnitAreas
unit = self.state.units.get(index)
body = (
bytes(
[
_OBJ_UNIT,
(index >> 8) & 0xFF,
index & 0xFF,
0, # UnitStatus: off
0,
0, # UnitTime
unit.state if unit else 0,
(unit.time_remaining >> 8) & 0xFF if unit else 0,
unit.time_remaining & 0xFF if unit else 0,
1, # UnitType: Standard
]
)
@ -478,22 +704,75 @@ class MockPanel:
)
return encode_v2(OmniLink2MessageType.Properties, body)
def _build_thermostat_properties(self, index: int) -> Message:
# Properties.Data layout for Thermostat (Data[0]=opcode, body starts
# at Data[1]. ``payload`` here strips the opcode; payload[i]==Data[i+1]):
# payload[0] object type (Thermostat = 6)
# payload[1..2] object number (BE u16)
# payload[3] communicating flag
# payload[4] temperature raw
# payload[5] heat setpoint raw
# payload[6] cool setpoint raw
# payload[7] mode
# payload[8] fan mode
# payload[9] hold mode
# payload[10] thermostat type
# payload[11..22] 12-byte name
t = self.state.thermostats.get(index)
body = (
bytes(
[
_OBJ_THERMOSTAT,
(index >> 8) & 0xFF,
index & 0xFF,
t.status if t else 0, # communicating flag
t.temperature_raw if t else 0,
t.heat_setpoint_raw if t else 0,
t.cool_setpoint_raw if t else 0,
t.system_mode if t else 0,
t.fan_mode if t else 0,
t.hold_mode if t else 0,
1, # thermostat type: AUTO_HEAT_COOL
]
)
+ self.state.thermostat_name_bytes(index)
)
return encode_v2(OmniLink2MessageType.Properties, body)
def _build_button_properties(self, index: int) -> Message:
# Properties.Data layout for Button:
# payload[0] object type (Button = 3)
# payload[1..2] object number (BE u16)
# payload[3..14] 12-byte name (NUL-padded)
body = (
bytes(
[
_OBJ_BUTTON,
(index >> 8) & 0xFF,
index & 0xFF,
]
)
+ self.state.button_name_bytes(index)
)
return encode_v2(OmniLink2MessageType.Properties, body)
def _build_area_properties(self, index: int) -> Message:
# Properties.Data for Area:
# [0]=opcode, [1]=ObjectType, [2..3]=ObjectNumber,
# [4]=AreaMode, [5]=AreaAlarms, [6]=EntryTimer, [7]=ExitTimer,
# [8]=Enabled, [9]=ExitDelay, [10]=EntryDelay,
# [11..22]=Name (12 bytes)
area = self.state.areas.get(index)
body = (
bytes(
[
_OBJ_AREA,
(index >> 8) & 0xFF,
index & 0xFF,
0, # AreaMode: Off
0, # AreaAlarms
0, # EntryTimer
0, # ExitTimer
area.mode if area else 0,
area.alarms if area else 0,
area.entry_timer if area else 0,
area.exit_timer if area else 0,
1, # Enabled
60, # ExitDelay (s)
30, # EntryDelay (s)
@ -503,7 +782,250 @@ class MockPanel:
)
return encode_v2(OmniLink2MessageType.Properties, body)
# -------- low-level reply send --------
# -------- Status (opcode 34/35) and ExtendedStatus (opcode 58/59) --------
def _reply_status(self, payload: bytes) -> Message:
"""Build a Status (opcode 35) reply for a RequestStatus (opcode 34).
RequestStatus payload (5 bytes, clsOL2MsgRequestStatus.cs):
[0] object type
[1..2] starting number (BE u16)
[3..4] ending number (BE u16)
Status reply payload layout (clsOL2MsgStatus.cs):
[0] object type
[1..] N records of size :data:`_STATUS_RECORD_SIZES[object_type]`
"""
if len(payload) < 5:
return _build_nak(OmniLink2MessageType.RequestStatus)
obj_type = payload[0]
start = (payload[1] << 8) | payload[2]
end = (payload[3] << 8) | payload[4]
store = self._object_store(obj_type)
if store is None or obj_type not in _STATUS_RECORD_SIZES:
return _build_nak(OmniLink2MessageType.RequestStatus)
body = bytearray([obj_type])
for idx in range(start, end + 1):
obj = store.get(idx)
if obj is None:
continue
body.extend(_status_record(obj_type, idx, obj))
if len(body) == 1:
# No matching objects in range — return EOD per protocol.
return encode_v2(OmniLink2MessageType.EOD, b"")
return encode_v2(OmniLink2MessageType.Status, bytes(body))
def _reply_extended_status(self, payload: bytes) -> Message:
"""Build an ExtendedStatus (opcode 59) reply for opcode 58.
ExtendedStatus reply payload layout (clsOL2MsgExtendedStatus.cs):
[0] object type
[1] object length (per-record byte count)
[2..] N records of ``object_length`` bytes
"""
if len(payload) < 5:
return _build_nak(OmniLink2MessageType.RequestExtendedStatus)
obj_type = payload[0]
start = (payload[1] << 8) | payload[2]
end = (payload[3] << 8) | payload[4]
store = self._object_store(obj_type)
record_size = _EXTENDED_STATUS_RECORD_SIZES.get(obj_type, 0)
if store is None or record_size == 0:
return _build_nak(OmniLink2MessageType.RequestExtendedStatus)
body = bytearray([obj_type, record_size])
any_records = False
for idx in range(start, end + 1):
obj = store.get(idx)
if obj is None:
continue
body.extend(_extended_status_record(obj_type, idx, obj))
any_records = True
if not any_records:
return encode_v2(OmniLink2MessageType.EOD, b"")
return encode_v2(OmniLink2MessageType.ExtendedStatus, bytes(body))
# -------- Command (opcode 20) --------
def _handle_command(self, payload: bytes) -> tuple[Message, tuple[int, ...]]:
"""Apply a Command (opcode 20) and return (reply, push_event_words).
Command payload (4 bytes, clsOL2MsgCommand.cs after stripping opcode):
[0] command byte (enuUnitCommand)
[1] parameter1 (single byte; brightness, mode, code index, ...)
[2] parameter2 high byte (BE u16)
[3] parameter2 low byte (object number for nearly every command)
"""
if len(payload) < 4:
return _build_nak(OmniLink2MessageType.Command), ()
cmd_byte = payload[0]
param1 = payload[1]
param2 = (payload[2] << 8) | payload[3]
try:
cmd = Command(cmd_byte)
except ValueError:
_log.debug("mock panel: unknown command byte %d", cmd_byte)
return _build_nak(OmniLink2MessageType.Command), ()
push: tuple[int, ...] = ()
if cmd == Command.UNIT_OFF:
unit = self._ensure_unit(param2)
unit.state = 0
unit.time_remaining = 0
push = (_unit_state_changed_word(param2, 0),)
elif cmd == Command.UNIT_ON:
unit = self._ensure_unit(param2)
unit.state = 1
unit.time_remaining = 0
push = (_unit_state_changed_word(param2, 1),)
elif cmd == Command.UNIT_LEVEL:
# Per enuUnitCommand.Level (line 15): param1 = 0..100 percent.
# Encoded into the state byte as 100..200.
if not 0 <= param1 <= 100:
return _build_nak(OmniLink2MessageType.Command), ()
unit = self._ensure_unit(param2)
unit.state = 100 + param1
unit.time_remaining = 0
push = (_unit_state_changed_word(param2, 1 if param1 > 0 else 0),)
elif cmd == Command.BYPASS_ZONE:
zone = self._ensure_zone(param2)
zone.is_bypassed = True
push = (_zone_state_changed_word(param2, 1),)
elif cmd == Command.RESTORE_ZONE:
zone = self._ensure_zone(param2)
zone.is_bypassed = False
push = (_zone_state_changed_word(param2, 0),)
elif cmd == Command.SET_THERMOSTAT_HEAT_SETPOINT:
tstat = self._ensure_thermostat(param2)
tstat.heat_setpoint_raw = param1
elif cmd == Command.SET_THERMOSTAT_COOL_SETPOINT:
tstat = self._ensure_thermostat(param2)
tstat.cool_setpoint_raw = param1
elif cmd == Command.SET_THERMOSTAT_SYSTEM_MODE:
tstat = self._ensure_thermostat(param2)
tstat.system_mode = param1
elif cmd == Command.SET_THERMOSTAT_FAN_MODE:
tstat = self._ensure_thermostat(param2)
tstat.fan_mode = param1
elif cmd == Command.SET_THERMOSTAT_HOLD_MODE:
tstat = self._ensure_thermostat(param2)
tstat.hold_mode = param1
else:
# Acknowledge but don't model: EXECUTE_BUTTON, EXECUTE_PROGRAM,
# SHOW_MESSAGE_*, CLEAR_MESSAGE, scenes, audio, energy, ...
_log.info(
"mock panel: command %s (byte=%d, p1=%d, p2=%d) acknowledged "
"with no state effect",
cmd.name, cmd_byte, param1, param2,
)
return _build_ack(), push
# -------- ExecuteSecurityCommand (opcode 74) --------
def _handle_execute_security_command(
self, payload: bytes
) -> tuple[Message, tuple[int, ...]]:
"""Validate the user code, mutate area state, push an ArmingChanged event.
Payload (6 bytes, clsOL2MsgExecuteSecurityCommand.cs after stripping opcode):
[0] area number (1-based)
[1] security mode (raw enuSecurityMode 0..7)
[2..5] code digits (thousands, hundreds, tens, ones)
Implementation choice: on success we return a plain Ack (opcode 1)
rather than ExecuteSecurityCommandResponse (opcode 75) the Omni
firmware varies and the client treats both as success. On bad-code
we return Nak (the simplest panel behaviour); the client raises
:class:`CommandFailedError` either way.
"""
if len(payload) < 6:
return _build_nak(OmniLink2MessageType.ExecuteSecurityCommand), ()
area_idx = payload[0]
mode = payload[1]
code = (
payload[2] * 1000 + payload[3] * 100 + payload[4] * 10 + payload[5]
)
# Find a matching code in user_codes. The matched code_index is
# what the panel records as the "last user" for the area.
matched_user = None
for user_idx, pin in self.state.user_codes.items():
if pin == code:
matched_user = user_idx
break
if matched_user is None:
_log.debug("mock panel: ExecuteSecurityCommand bad code %04d", code)
return _build_nak(OmniLink2MessageType.ExecuteSecurityCommand), ()
area = self._ensure_area(area_idx)
area.mode = mode
area.last_user = matched_user
push = (_arming_changed_word(area_idx, mode, matched_user),)
return _build_ack(), push
# -------- per-object ensure helpers --------
def _ensure_unit(self, idx: int) -> MockUnitState:
unit = self.state.units.get(idx)
if unit is None:
unit = MockUnitState()
self.state.units[idx] = unit
return unit
def _ensure_zone(self, idx: int) -> MockZoneState:
zone = self.state.zones.get(idx)
if zone is None:
zone = MockZoneState()
self.state.zones[idx] = zone
return zone
def _ensure_area(self, idx: int) -> MockAreaState:
area = self.state.areas.get(idx)
if area is None:
area = MockAreaState()
self.state.areas[idx] = area
return area
def _ensure_thermostat(self, idx: int) -> MockThermostatState:
tstat = self.state.thermostats.get(idx)
if tstat is None:
tstat = MockThermostatState()
self.state.thermostats[idx] = tstat
return tstat
# -------- low-level reply send + push helpers --------
def _schedule_event_push(
self,
event_words: tuple[int, ...],
session_key: bytes,
writer: asyncio.StreamWriter,
) -> None:
"""Fire-and-forget: push a SystemEvents (opcode 55) frame after a tiny delay.
The delay lets the synchronous reply hit the client first so the
request future resolves before the unsolicited event arrives. Tests
that wait on ``client.events()`` use ``asyncio.wait_for`` with their
own timeout to fail fast if the push never arrives.
"""
async def _push() -> None:
try:
await asyncio.sleep(_PUSH_DELAY)
msg = _build_system_events_message(event_words)
# Push goes out with seq=0 so the client routes it to the
# unsolicited queue (clsOmniLinkConnection.cs:1847-1854).
await self._send_v2_reply(0, msg, session_key, writer)
except (ConnectionError, asyncio.CancelledError):
pass
except Exception: # pragma: no cover - diagnostic only
_log.exception("mock panel: failed to push synthesized event")
task = asyncio.create_task(_push(), name="mock-panel-event-push")
self._push_tasks.add(task)
task.add_done_callback(self._push_tasks.discard)
async def _send_v2_reply(
self,
@ -519,6 +1041,174 @@ class MockPanel:
await writer.drain()
# --------------------------------------------------------------------------
# Status / ExtendedStatus per-record builders
# --------------------------------------------------------------------------
def _status_record(obj_type: int, idx: int, obj: object) -> bytes:
"""Build one record of a basic Status (opcode 35) reply for ``obj_type``."""
if obj_type == _OBJ_ZONE:
z = obj # type: ignore[assignment]
assert isinstance(z, MockZoneState)
return bytes([(idx >> 8) & 0xFF, idx & 0xFF, z.status_byte, z.loop])
if obj_type == _OBJ_UNIT:
u = obj # type: ignore[assignment]
assert isinstance(u, MockUnitState)
return bytes(
[
(idx >> 8) & 0xFF,
idx & 0xFF,
u.state & 0xFF,
(u.time_remaining >> 8) & 0xFF,
u.time_remaining & 0xFF,
]
)
if obj_type == _OBJ_AREA:
a = obj # type: ignore[assignment]
assert isinstance(a, MockAreaState)
return bytes(
[
(idx >> 8) & 0xFF,
idx & 0xFF,
a.mode & 0xFF,
a.alarms & 0xFF,
a.entry_timer & 0xFF,
a.exit_timer & 0xFF,
]
)
if obj_type == _OBJ_THERMOSTAT:
t = obj # type: ignore[assignment]
assert isinstance(t, MockThermostatState)
return bytes(
[
(idx >> 8) & 0xFF,
idx & 0xFF,
t.status & 0xFF,
t.temperature_raw & 0xFF,
t.heat_setpoint_raw & 0xFF,
t.cool_setpoint_raw & 0xFF,
t.system_mode & 0xFF,
t.fan_mode & 0xFF,
t.hold_mode & 0xFF,
]
)
raise AssertionError(f"unhandled object type {obj_type}")
def _extended_status_record(obj_type: int, idx: int, obj: object) -> bytes:
"""Build one record of an ExtendedStatus (opcode 59) reply for ``obj_type``.
The basic-status records are byte-compatible with the extended-status
records for Zone, Unit, and Area (the ExtendedStatus reply just adds
the per-record length byte at payload[1]). Thermostat is the only type
where the extended record is wider it adds humidity/outdoor/horc
fields at the end.
"""
if obj_type in (_OBJ_ZONE, _OBJ_UNIT, _OBJ_AREA):
return _status_record(obj_type, idx, obj)
if obj_type == _OBJ_THERMOSTAT:
t = obj # type: ignore[assignment]
assert isinstance(t, MockThermostatState)
return bytes(
[
(idx >> 8) & 0xFF,
idx & 0xFF,
t.status & 0xFF,
t.temperature_raw & 0xFF,
t.heat_setpoint_raw & 0xFF,
t.cool_setpoint_raw & 0xFF,
t.system_mode & 0xFF,
t.fan_mode & 0xFF,
t.hold_mode & 0xFF,
t.humidity_raw & 0xFF,
t.humidify_setpoint_raw & 0xFF,
t.dehumidify_setpoint_raw & 0xFF,
t.outdoor_temperature_raw & 0xFF,
t.horc_status & 0xFF,
]
)
raise AssertionError(f"unhandled object type {obj_type}")
# --------------------------------------------------------------------------
# SystemEvents (opcode 55) — synthesized push frames
# --------------------------------------------------------------------------
def _build_system_events_message(words: tuple[int, ...]) -> Message:
"""Pack one or more 16-bit event words into a v2 SystemEvents Message.
Each word is encoded big-endian. Reference: clsOLMsgSystemEvents.cs.
"""
body = bytearray()
for w in words:
body.append((w >> 8) & 0xFF)
body.append(w & 0xFF)
return encode_v2(OmniLink2MessageType.SystemEvents, bytes(body))
def _zone_state_changed_word(zone_index: int, new_state: int) -> int:
"""Encode a ZONE_STATE_CHANGE (top 6 bits == 0x4) event word.
Layout (matches events._classify):
bits 10-15: family marker (0x0400)
bit 9 : new_state (0=secure, 1=open)
low byte : zone index 1..255
"""
word = 0x0400 | (zone_index & 0xFF)
if new_state:
word |= 0x0200
return word & 0xFFFF
def _unit_state_changed_word(unit_index: int, new_state: int) -> int:
"""Encode a UNIT_STATE_CHANGE (top 6 bits == 0x8) event word.
Layout:
bits 10-15: family marker (0x0800)
bit 9 : new_state (0=off, 1=on)
bit 8 : unit_index >= 256 high bit
low byte : unit index low 8 bits
"""
word = 0x0800 | (unit_index & 0xFF)
if unit_index >= 256:
word |= 0x0100
if new_state:
word |= 0x0200
return word & 0xFFFF
def _arming_changed_word(area_index: int, new_mode: int, user_index: int) -> int:
"""Encode a SECURITY_MODE_CHANGE catch-all event word.
Layout (mirrors events._classify catch-all branch and clsText.cs:2155-2217):
bits 12-14: SecurityMode (0..7)
bits 8-11 : area index (0 = system / no specific area)
low byte : user/code index that triggered the change (0 = panel)
NOTE: the classifier in :func:`omni_pca.events._classify` only routes
a word to ArmingChanged when ``(word >> 8) & 0xF0`` is non-zero. Our
encoding satisfies that as long as ``new_mode`` is at least 1 (the
SecurityMode high nibble of the high byte is non-zero). For Off (0)
the test seeds a non-zero mode Disarm (mode=Off) flowing through
the same path would round-trip as an UnknownEvent, which matches
real-panel behaviour where Off is pushed as a different event family.
"""
word = ((new_mode & 0x07) << 12) | ((area_index & 0x0F) << 8) | (user_index & 0xFF)
return word & 0xFFFF
# --------------------------------------------------------------------------
# Stock reply / NAK builders
# --------------------------------------------------------------------------
def _build_ack() -> Message:
"""Build a v2 Ack (opcode 1) with no payload."""
return encode_v2(OmniLink2MessageType.Ack, b"")
def _build_nak(in_reply_to_opcode: int) -> Message:
"""Build a v2 Nak. Payload is a single byte echoing the opcode being negged.

26
tests/conftest.py Normal file
View File

@ -0,0 +1,26 @@
"""Pytest configuration shared across the test suite.
The HA test harness (``pytest-homeassistant-custom-component``) installs
``pytest_socket`` globally, which disables real socket use to keep HA
unit tests hermetic. Our library has its own e2e tests that legitimately
need to talk to a localhost ``MockPanel`` over a real TCP socket, so we
re-enable sockets by default and let the HA integration tests opt back
into the strict policy via the harness fixtures.
"""
from __future__ import annotations
import pytest
@pytest.fixture(autouse=True)
def _enable_localhost_sockets(socket_enabled: pytest.FixtureRequest) -> None: # type: ignore[valid-type]
"""Re-enable sockets for every test by default.
``socket_enabled`` is the standard fixture exported by ``pytest_socket``
(and re-exported by the HA harness); requesting it via autouse undoes
the harness's default ``disable_socket()`` for tests that need real
networking. HA-side tests can override by explicitly using the
``socket_disabled`` fixture if they want hermetic behaviour.
"""
return None

View File

@ -0,0 +1,7 @@
"""HA-side integration tests.
These spin up a real Home Assistant instance in-process via
``pytest-homeassistant-custom-component``, point the omni_pca config
entry at a live ``MockPanel`` running on a localhost port, and assert
that the entity layer materializes correctly.
"""

View File

@ -0,0 +1,133 @@
"""Fixtures for the HA-side integration tests.
Each test gets:
* a fresh ``MockPanel`` listening on a random localhost port,
* a HA config entry whose ``host``/``port``/``controller_key`` point at it,
* a fully booted HA instance with the integration loaded.
The HA harness blocks real sockets by default; we re-enable them here
so the in-process client can talk to the in-process mock.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
import pytest
from custom_components.omni_pca.const import CONF_CONTROLLER_KEY, DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from omni_pca.mock_panel import (
MockAreaState,
MockButtonState,
MockPanel,
MockState,
MockThermostatState,
MockUnitState,
MockZoneState,
)
CONTROLLER_KEY = bytes(range(16))
CONTROLLER_KEY_HEX = CONTROLLER_KEY.hex()
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations: None) -> None:
"""Tell HA to load components from ``custom_components/`` for every test."""
return None
@pytest.fixture(autouse=True)
def expected_lingering_tasks() -> bool:
"""Allow the coordinator's background event-listener task to outlive the
test body the integration cancels it on entry unload, but the harness's
default ``verify_cleanup`` flags any task still alive at teardown."""
return True
@pytest.fixture(autouse=True)
def _short_scan_interval(monkeypatch: pytest.MonkeyPatch) -> None:
"""Cut the 30s polling interval down so tests don't wait on it."""
from datetime import timedelta
from custom_components.omni_pca import const, coordinator
fast = timedelta(seconds=1)
monkeypatch.setattr(const, "SCAN_INTERVAL", fast)
monkeypatch.setattr(coordinator, "SCAN_INTERVAL", fast)
@pytest.fixture
def populated_state() -> MockState:
"""A lightly-populated mock state covering every entity platform."""
return MockState(
zones={
1: MockZoneState(name="FRONT_DOOR"),
2: MockZoneState(name="GARAGE_ENTRY"),
10: MockZoneState(name="LIVING_MOTION"),
},
units={
1: MockUnitState(name="LIVING_LAMP"),
2: MockUnitState(name="KITCHEN_OVERHEAD"),
},
areas={
1: MockAreaState(name="MAIN"),
},
thermostats={
1: MockThermostatState(name="LIVING_ROOM"),
},
buttons={
1: MockButtonState(name="GOOD_MORNING"),
},
user_codes={1: 1234},
)
@pytest.fixture
async def panel(populated_state: MockState) -> AsyncIterator[tuple[MockPanel, str, int]]:
"""Spin up a MockPanel on a random localhost port for the test's lifetime."""
mock = MockPanel(controller_key=CONTROLLER_KEY, state=populated_state)
async with mock.serve(host="127.0.0.1") as (host, port):
yield mock, host, port
@pytest.fixture
def config_entry_data(panel: tuple[MockPanel, str, int]) -> dict[str, Any]:
_, host, port = panel
return {
CONF_HOST: host,
CONF_PORT: port,
CONF_CONTROLLER_KEY: CONTROLLER_KEY_HEX,
}
@pytest.fixture
async def configured_panel(
hass: HomeAssistant, config_entry_data: dict[str, Any]
) -> AsyncIterator[ConfigEntry]:
"""Add a config entry to HA, trigger setup, unload at teardown.
The unload step is important it cancels the coordinator's background
event-listener task and closes the OmniClient socket. Without it, the
HA harness's ``verify_cleanup`` hangs waiting for the lingering reader
coroutine.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data=config_entry_data,
title=f"Mock Omni at {config_entry_data[CONF_HOST]}:{config_entry_data[CONF_PORT]}",
unique_id=f"{config_entry_data[CONF_HOST]}:{config_entry_data[CONF_PORT]}",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
try:
yield entry
finally:
if entry.entry_id in hass.data.get(DOMAIN, {}):
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()

View File

@ -0,0 +1,151 @@
"""HA-side integration: integration loads, entities materialize."""
from __future__ import annotations
from custom_components.omni_pca.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
async def test_integration_loads_against_mock_panel(
hass: HomeAssistant, configured_panel
) -> None:
"""End-to-end: HA discovers our integration, completes the secure
session against the mock, populates the coordinator, and lands in
LOADED state with no errors."""
assert configured_panel.state is ConfigEntryState.LOADED
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
assert coordinator.data is not None
assert coordinator.data.system_info is not None
assert coordinator.data.system_info.model_name == "Omni Pro II"
async def test_zone_entities_created(
hass: HomeAssistant, configured_panel
) -> None:
"""Every named zone in MockState lands as a binary_sensor entity."""
states = hass.states.async_all("binary_sensor")
zone_entity_ids = [s.entity_id for s in states if "front_door" in s.entity_id.lower()
or "garage_entry" in s.entity_id.lower()
or "living_motion" in s.entity_id.lower()]
# Each zone gets a primary + bypassed entity, so at least 3 names x 2 = 6
# plus the system-level AC / battery / trouble entities.
assert len(zone_entity_ids) >= 3, (
f"expected zone entities, got {[s.entity_id for s in states]}"
)
async def test_alarm_panel_entity_created(
hass: HomeAssistant, configured_panel
) -> None:
"""One alarm_control_panel per discovered area."""
states = hass.states.async_all("alarm_control_panel")
assert len(states) == 1
assert states[0].state != STATE_UNAVAILABLE
async def test_light_entities_for_units(
hass: HomeAssistant, configured_panel
) -> None:
"""One light entity per discovered unit."""
states = hass.states.async_all("light")
assert len(states) == 2
# Both units default to off in the mock.
for s in states:
assert s.state in (STATE_OFF, STATE_UNAVAILABLE)
async def test_switch_entities_for_zone_bypass(
hass: HomeAssistant, configured_panel
) -> None:
"""One bypass switch per binary zone."""
states = hass.states.async_all("switch")
assert len(states) == 3 # one per binary zone
async def test_climate_entity_for_thermostat(
hass: HomeAssistant, configured_panel
) -> None:
states = hass.states.async_all("climate")
assert len(states) == 1
async def test_button_entity_for_panel_button(
hass: HomeAssistant, configured_panel
) -> None:
states = hass.states.async_all("button")
assert len(states) == 1
async def test_event_entity_per_panel(
hass: HomeAssistant, configured_panel
) -> None:
states = hass.states.async_all("event")
assert len(states) == 1
async def test_unload_entry(
hass: HomeAssistant, configured_panel
) -> None:
"""Unloading the config entry tears everything down cleanly."""
assert await hass.config_entries.async_unload(configured_panel.entry_id)
await hass.async_block_till_done()
assert configured_panel.state is ConfigEntryState.NOT_LOADED
# Coordinator removed from hass.data
assert configured_panel.entry_id not in hass.data.get(DOMAIN, {})
async def test_turn_unit_on_via_light_service(
hass: HomeAssistant, configured_panel, panel
) -> None:
"""Drive a HA service call; verify it reaches the mock and updates state."""
mock, _, _ = panel
light_states = hass.states.async_all("light")
assert light_states, "expected at least one light entity"
target = light_states[0].entity_id
await hass.services.async_call(
"light", "turn_on", {"entity_id": target}, blocking=True
)
await hass.async_block_till_done()
# The mock's state updated for whichever unit was first in sorted order.
on_units = [u for u in mock.state.units.values() if u.state == 1]
assert on_units, "expected the mock to record the unit as ON"
async def test_arm_panel_via_alarm_service(
hass: HomeAssistant, configured_panel, panel
) -> None:
"""Arm the panel from HA; verify the mock area transitions."""
mock, _, _ = panel
alarm_states = hass.states.async_all("alarm_control_panel")
assert alarm_states, "expected one alarm_control_panel entity"
target = alarm_states[0].entity_id
await hass.services.async_call(
"alarm_control_panel",
"alarm_arm_away",
{"entity_id": target, "code": "1234"},
blocking=True,
)
await hass.async_block_till_done()
assert mock.state.areas[1].mode == 3 # SecurityMode.AWAY
async def test_arm_panel_with_wrong_code_keeps_disarmed(
hass: HomeAssistant, configured_panel, panel
) -> None:
"""Wrong code: panel stays disarmed and HA surfaces the error."""
mock, _, _ = panel
alarm_states = hass.states.async_all("alarm_control_panel")
target = alarm_states[0].entity_id
# The service should raise; we don't assert the exception class because
# HA wraps it. We just assert the panel mode didn't change.
import contextlib
with contextlib.suppress(Exception):
await hass.services.async_call(
"alarm_control_panel",
"alarm_arm_away",
{"entity_id": target, "code": "9999"},
blocking=True,
)
assert mock.state.areas[1].mode == 0 # still disarmed

519
tests/test_commands.py Normal file
View File

@ -0,0 +1,519 @@
"""Unit tests for command opcodes and status range queries.
The tests use a captured-payload approach: we monkey-patch
``OmniClient._conn.request`` so it records the (opcode, payload) pair
that the client would emit and returns whatever canned ``Message``
the test wants. No network involved just round-trip the bytes.
Conventions:
* Each test pins the exact wire bytes the client should produce, so
that any future refactor that rearranges the payload layout is
caught immediately.
* Where a single command can be expressed as a high-level helper
(``turn_unit_on``) we still verify the underlying ``Command``
enum value and the param1/param2 byte placement, not just the
success path.
"""
from __future__ import annotations
import struct
from collections.abc import Callable
from dataclasses import dataclass
import pytest
from omni_pca.client import OmniClient
from omni_pca.commands import Command, CommandFailedError, SecurityCommandResponse
from omni_pca.message import Message, encode_v2
from omni_pca.models import (
AreaStatus,
FanMode,
HoldMode,
HvacMode,
ObjectType,
SecurityMode,
ThermostatStatus,
UnitStatus,
ZoneStatus,
)
from omni_pca.opcodes import OmniLink2MessageType
# --------------------------------------------------------------------------
# Test scaffolding: a stub OmniClient that captures requests instead of
# sending them. We bypass __init__ (which builds an OmniConnection) by
# using object.__new__ + manually setting the _conn attr to our stub.
# --------------------------------------------------------------------------
@dataclass
class _RecordedRequest:
opcode: int
payload: bytes
class _StubConn:
"""Stand-in for OmniConnection; .request() captures + returns a canned reply."""
def __init__(
self,
reply_factory: Callable[[int, bytes], Message] | None = None,
) -> None:
self.calls: list[_RecordedRequest] = []
self._reply_factory = reply_factory or self._default_ack
@staticmethod
def _default_ack(_opcode: int, _payload: bytes) -> Message:
return encode_v2(OmniLink2MessageType.Ack)
async def request(
self,
opcode: OmniLink2MessageType | int,
payload: bytes = b"",
timeout: float | None = None,
) -> Message:
del timeout # mirror the OmniConnection.request signature; unused here
op_int = int(opcode)
self.calls.append(_RecordedRequest(opcode=op_int, payload=bytes(payload)))
return self._reply_factory(op_int, bytes(payload))
def _make_client(stub: _StubConn) -> OmniClient:
"""Build an OmniClient with a stubbed connection (no socket, no handshake)."""
client = object.__new__(OmniClient)
client._conn = stub # type: ignore[attr-defined]
client._subscriber_task = None # type: ignore[attr-defined]
return client
# --------------------------------------------------------------------------
# Command enum value pins. These guard against accidental renumbering.
# --------------------------------------------------------------------------
def test_command_enum_pins_unit_values() -> None:
assert Command.UNIT_OFF == 0
assert Command.UNIT_ON == 1
assert Command.UNIT_LEVEL == 9
assert Command.BYPASS_ZONE == 4
assert Command.RESTORE_ZONE == 5
def test_command_enum_pins_thermostat_values() -> None:
# enuUnitCommand.SetLowSetPt (line 71) → 66 (heat)
assert Command.SET_THERMOSTAT_HEAT_SETPOINT == 66
# enuUnitCommand.SetHighSetPt (line 72) → 67 (cool)
assert Command.SET_THERMOSTAT_COOL_SETPOINT == 67
# enuUnitCommand.Mode/Fan/Hold (lines 73/74/75)
assert Command.SET_THERMOSTAT_SYSTEM_MODE == 68
assert Command.SET_THERMOSTAT_FAN_MODE == 69
assert Command.SET_THERMOSTAT_HOLD_MODE == 70
def test_command_enum_pins_message_and_program_values() -> None:
assert Command.SHOW_MESSAGE_WITH_BEEP == 80
assert Command.LOG_MESSAGE == 81
assert Command.CLEAR_MESSAGE == 82
assert Command.SHOW_MESSAGE_NO_BEEP == 86
assert Command.EXECUTE_BUTTON == 7
assert Command.EXECUTE_PROGRAM == 104
def test_security_command_response_enum_pins() -> None:
assert SecurityCommandResponse.SUCCESS == 0
assert SecurityCommandResponse.INVALID_CODE == 1
assert SecurityCommandResponse.INVALID_AREA == 3
assert SecurityCommandResponse.CODE_LOCKED_OUT == 6
# --------------------------------------------------------------------------
# execute_command() — generic Command opcode (20)
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_command_payload_layout() -> None:
stub = _StubConn()
client = _make_client(stub)
# Pretend we're flipping unit #257 ON (parameter2 needs both bytes).
await client.execute_command(Command.UNIT_ON, parameter1=0, parameter2=257)
assert len(stub.calls) == 1
call = stub.calls[0]
assert call.opcode == int(OmniLink2MessageType.Command)
# Command (1) + p1 (1 byte) + p2 (BE u16) = 4 bytes
assert call.payload == bytes([1, 0, 0x01, 0x01])
@pytest.mark.asyncio
async def test_execute_command_packs_param1_byte_and_param2_be_u16() -> None:
stub = _StubConn()
client = _make_client(stub)
await client.execute_command(
Command.UNIT_LEVEL, parameter1=75, parameter2=0xABCD
)
payload = stub.calls[0].payload
# [cmd=9, p1=75, p2_hi=0xAB, p2_lo=0xCD]
assert payload == bytes([9, 75, 0xAB, 0xCD])
@pytest.mark.asyncio
async def test_execute_command_validates_param_ranges() -> None:
stub = _StubConn()
client = _make_client(stub)
with pytest.raises(ValueError, match="parameter1"):
await client.execute_command(Command.UNIT_ON, parameter1=256)
with pytest.raises(ValueError, match="parameter2"):
await client.execute_command(Command.UNIT_ON, parameter2=0x10000)
# No request emitted on validation failure.
assert stub.calls == []
@pytest.mark.asyncio
async def test_execute_command_raises_on_nak() -> None:
def nak_reply(_op: int, _pl: bytes) -> Message:
return encode_v2(OmniLink2MessageType.Nak)
stub = _StubConn(reply_factory=nak_reply)
client = _make_client(stub)
with pytest.raises(CommandFailedError, match="NAK"):
await client.execute_command(Command.UNIT_ON, parameter2=1)
# --------------------------------------------------------------------------
# Convenience wrappers over execute_command
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_turn_unit_on_off_emits_correct_command_byte() -> None:
stub = _StubConn()
client = _make_client(stub)
await client.turn_unit_on(5)
await client.turn_unit_off(5)
assert stub.calls[0].payload == bytes([1, 0, 0, 5]) # UNIT_ON
assert stub.calls[1].payload == bytes([0, 0, 0, 5]) # UNIT_OFF
@pytest.mark.asyncio
async def test_set_unit_level_validates_range_and_emits_level() -> None:
stub = _StubConn()
client = _make_client(stub)
await client.set_unit_level(3, 50)
assert stub.calls[0].payload == bytes([9, 50, 0, 3])
with pytest.raises(ValueError, match=r"0\.\.100"):
await client.set_unit_level(3, 101)
@pytest.mark.asyncio
async def test_bypass_and_restore_zone_emit_correct_payload() -> None:
stub = _StubConn()
client = _make_client(stub)
await client.bypass_zone(12, code=2)
await client.restore_zone(12, code=2)
assert stub.calls[0].payload == bytes([4, 2, 0, 12]) # BYPASS_ZONE
assert stub.calls[1].payload == bytes([5, 2, 0, 12]) # RESTORE_ZONE
@pytest.mark.asyncio
async def test_set_thermostat_modes_emit_correct_payloads() -> None:
stub = _StubConn()
client = _make_client(stub)
await client.set_thermostat_system_mode(2, HvacMode.COOL)
await client.set_thermostat_fan_mode(2, FanMode.ON)
await client.set_thermostat_hold_mode(2, HoldMode.HOLD)
assert stub.calls[0].payload == bytes([68, int(HvacMode.COOL), 0, 2])
assert stub.calls[1].payload == bytes([69, int(FanMode.ON), 0, 2])
assert stub.calls[2].payload == bytes([70, int(HoldMode.HOLD), 0, 2])
@pytest.mark.asyncio
async def test_set_thermostat_setpoints_use_raw_byte() -> None:
stub = _StubConn()
client = _make_client(stub)
await client.set_thermostat_heat_setpoint_raw(1, 140) # ~70 °F
await client.set_thermostat_cool_setpoint_raw(1, 160) # ~80 °F
assert stub.calls[0].payload == bytes([66, 140, 0, 1])
assert stub.calls[1].payload == bytes([67, 160, 0, 1])
@pytest.mark.asyncio
async def test_button_program_message_helpers() -> None:
stub = _StubConn()
client = _make_client(stub)
await client.execute_button(4)
await client.execute_program(7)
await client.show_message(2, beep=True)
await client.show_message(2, beep=False)
await client.clear_message(2)
assert stub.calls[0].payload == bytes([7, 0, 0, 4]) # EXECUTE_BUTTON
assert stub.calls[1].payload == bytes([104, 0, 0, 7]) # EXECUTE_PROGRAM
assert stub.calls[2].payload == bytes([80, 0, 0, 2]) # SHOW_MSG_BEEP
assert stub.calls[3].payload == bytes([86, 0, 0, 2]) # SHOW_MSG_NOBEEP
assert stub.calls[4].payload == bytes([82, 0, 0, 2]) # CLEAR_MESSAGE
# --------------------------------------------------------------------------
# execute_security_command (opcode 74)
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_execute_security_command_payload_encoding_away_1234() -> None:
"""The C# code packs the 4-digit code as four separate digit bytes."""
stub = _StubConn(
reply_factory=lambda _op, _pl: encode_v2(
OmniLink2MessageType.ExecuteSecurityCommandResponse,
bytes([SecurityCommandResponse.SUCCESS]),
)
)
client = _make_client(stub)
result = await client.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=1234
)
assert result is None
payload = stub.calls[0].payload
# area, mode, d1, d2, d3, d4
assert payload == bytes([1, int(SecurityMode.AWAY), 1, 2, 3, 4])
assert stub.calls[0].opcode == int(
OmniLink2MessageType.ExecuteSecurityCommand
)
@pytest.mark.asyncio
async def test_execute_security_command_pads_short_codes_with_zeros() -> None:
"""Code 7 → digits 0,0,0,7 (matches the C# arithmetic)."""
stub = _StubConn(
reply_factory=lambda _op, _pl: encode_v2(OmniLink2MessageType.Ack)
)
client = _make_client(stub)
await client.execute_security_command(
area=2, mode=SecurityMode.OFF, code=7
)
assert stub.calls[0].payload == bytes([2, 0, 0, 0, 0, 7])
@pytest.mark.asyncio
async def test_execute_security_command_failure_raises_with_code() -> None:
def reply(_op: int, _pl: bytes) -> Message:
return encode_v2(
OmniLink2MessageType.ExecuteSecurityCommandResponse,
bytes([SecurityCommandResponse.INVALID_CODE]),
)
stub = _StubConn(reply_factory=reply)
client = _make_client(stub)
with pytest.raises(CommandFailedError) as ei:
await client.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=9999
)
assert ei.value.failure_code == int(SecurityCommandResponse.INVALID_CODE)
assert "INVALID_CODE" in str(ei.value)
@pytest.mark.asyncio
async def test_execute_security_command_validates_inputs() -> None:
stub = _StubConn()
client = _make_client(stub)
with pytest.raises(ValueError, match="area"):
await client.execute_security_command(
area=0, mode=SecurityMode.AWAY, code=1234
)
with pytest.raises(ValueError, match="code"):
await client.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=10000
)
assert stub.calls == []
# --------------------------------------------------------------------------
# acknowledge_alerts (opcode 60)
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_acknowledge_alerts_sends_no_payload_and_expects_ack() -> None:
stub = _StubConn()
client = _make_client(stub)
await client.acknowledge_alerts()
assert stub.calls[0].opcode == int(OmniLink2MessageType.AcknowledgeAlerts)
assert stub.calls[0].payload == b""
# --------------------------------------------------------------------------
# get_object_status (opcode 34/35) — request payload + reply parsing
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_object_status_builds_request_payload() -> None:
"""RequestStatus is [object_type, start_hi, start_lo, end_hi, end_lo]."""
captured: list[bytes] = []
def reply(_op: int, payload: bytes) -> Message:
captured.append(payload)
# Reply with one zone record: number=3, status=0x10, loop=200.
body = bytes([int(ObjectType.ZONE), 0, 3, 0x10, 200])
return encode_v2(OmniLink2MessageType.Status, body)
stub = _StubConn(reply_factory=reply)
client = _make_client(stub)
zones = await client.get_object_status(ObjectType.ZONE, 3)
assert captured[0] == struct.pack(">BHH", int(ObjectType.ZONE), 3, 3)
assert len(zones) == 1
z = zones[0]
assert isinstance(z, ZoneStatus)
assert z.index == 3
assert z.raw_status == 0x10
assert z.loop == 200
@pytest.mark.asyncio
async def test_get_object_status_parses_multiple_unit_records() -> None:
"""Unit records are 5 bytes each (clsOL2MsgStatus.cs:17)."""
def reply(_op: int, _pl: bytes) -> Message:
# object_type byte + two 5-byte unit records.
records = (
bytes([0, 1, 1, 0, 0]) # unit 1, state=1 (On)
+ bytes([0, 2, 100, 0, 0]) # unit 2, state=100 (level 0%)
)
return encode_v2(
OmniLink2MessageType.Status,
bytes([int(ObjectType.UNIT)]) + records,
)
stub = _StubConn(reply_factory=reply)
client = _make_client(stub)
units = await client.get_object_status(ObjectType.UNIT, 1, 2)
assert len(units) == 2
assert all(isinstance(u, UnitStatus) for u in units)
u1, u2 = units
assert u1.index == 1
assert u1.state == 1
assert u2.index == 2
assert u2.state == 100
@pytest.mark.asyncio
async def test_get_object_status_returns_empty_on_eod() -> None:
stub = _StubConn(
reply_factory=lambda _op, _pl: encode_v2(OmniLink2MessageType.EOD)
)
client = _make_client(stub)
out = await client.get_object_status(ObjectType.AREA, 99)
assert out == []
@pytest.mark.asyncio
async def test_get_object_status_raises_on_nak() -> None:
stub = _StubConn(
reply_factory=lambda _op, _pl: encode_v2(OmniLink2MessageType.Nak)
)
client = _make_client(stub)
with pytest.raises(CommandFailedError, match="NAK"):
await client.get_object_status(ObjectType.ZONE, 1)
# --------------------------------------------------------------------------
# get_extended_status (opcode 58/59) — header has object_length byte
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_extended_status_request_layout_matches_spec() -> None:
captured: list[bytes] = []
def reply(_op: int, payload: bytes) -> Message:
captured.append(payload)
# Reply: object_type, object_length=4, then one zone record (4 bytes).
body = bytes([int(ObjectType.ZONE), 4]) + bytes([0, 5, 0x00, 100])
return encode_v2(OmniLink2MessageType.ExtendedStatus, body)
stub = _StubConn(reply_factory=reply)
client = _make_client(stub)
zones = await client.get_extended_status(ObjectType.ZONE, 5, 5)
assert captured[0] == struct.pack(">BHH", int(ObjectType.ZONE), 5, 5)
assert stub.calls[0].opcode == int(
OmniLink2MessageType.RequestExtendedStatus
)
assert len(zones) == 1
assert zones[0].index == 5 # type: ignore[union-attr]
@pytest.mark.asyncio
async def test_get_extended_status_uses_object_length_byte_for_record_size() -> None:
"""ExtendedStatus thermostat record is 14 bytes (clsOL2MsgExtendedStatus.cs:138-235)."""
record = bytes(
[
0, 1, # number = 1
0, # status
140, # current temp raw
120, 160, # heat / cool setpoints
int(HvacMode.AUTO),
int(FanMode.AUTO),
int(HoldMode.OFF),
150, # humidity raw
120, 160, # humidify / dehumidify setpoints
130, # outdoor temp raw
1, # H or C status
]
)
assert len(record) == 14
def reply(_op: int, _pl: bytes) -> Message:
body = bytes([int(ObjectType.THERMOSTAT), 14]) + record
return encode_v2(OmniLink2MessageType.ExtendedStatus, body)
stub = _StubConn(reply_factory=reply)
client = _make_client(stub)
out = await client.get_extended_status(ObjectType.THERMOSTAT, 1)
assert len(out) == 1
t = out[0]
assert isinstance(t, ThermostatStatus)
assert t.index == 1
assert t.temperature_raw == 140
assert t.system_mode == int(HvacMode.AUTO)
@pytest.mark.asyncio
async def test_get_extended_status_returns_empty_on_eod() -> None:
stub = _StubConn(
reply_factory=lambda _op, _pl: encode_v2(OmniLink2MessageType.EOD)
)
client = _make_client(stub)
out = await client.get_extended_status(ObjectType.AREA, 1, 8)
assert out == []
@pytest.mark.asyncio
async def test_get_extended_status_area_record_parses_to_areastatus() -> None:
"""Area ExtendedStatus record is 6 bytes
(clsOL2MsgExtendedStatus.cs:75-118): number(2) + mode + alarms + entry +
exit (matches our AreaStatus.parse).
"""
def reply(_op: int, _pl: bytes) -> Message:
# area 1, mode AWAY, alarms 0, entry 0, exit 30
record = bytes([0, 1, int(SecurityMode.AWAY), 0, 0, 30])
body = bytes([int(ObjectType.AREA), 6]) + record
return encode_v2(OmniLink2MessageType.ExtendedStatus, body)
stub = _StubConn(reply_factory=reply)
client = _make_client(stub)
out = await client.get_extended_status(ObjectType.AREA, 1)
assert len(out) == 1
a = out[0]
assert isinstance(a, AreaStatus)
assert a.index == 1
assert a.mode == int(SecurityMode.AWAY)
assert a.exit_timer_secs == 30
@pytest.mark.asyncio
async def test_get_object_status_validates_range() -> None:
stub = _StubConn()
client = _make_client(stub)
with pytest.raises(ValueError, match="end"):
await client.get_object_status(ObjectType.ZONE, 5, 3)
assert stub.calls == []

View File

@ -7,14 +7,37 @@ session-key derivation, or per-block whitening disagree, the handshake fails.
from __future__ import annotations
import asyncio
import secrets
import pytest
from omni_pca.client import ObjectType, OmniClient
from omni_pca.commands import CommandFailedError
from omni_pca.connection import HandshakeError
from omni_pca.mock_panel import MockPanel, MockState
from omni_pca.models import AreaProperties, UnitProperties, ZoneProperties
from omni_pca.events import ArmingChanged, UnitStateChanged
from omni_pca.mock_panel import (
MockAreaState,
MockButtonState,
MockPanel,
MockState,
MockThermostatState,
MockUnitState,
MockZoneState,
)
from omni_pca.models import (
AreaProperties,
AreaStatus,
SecurityMode,
ThermostatStatus,
UnitProperties,
UnitStatus,
ZoneProperties,
ZoneStatus,
)
from omni_pca.models import (
ObjectType as ModelObjectType,
)
CONTROLLER_KEY = bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09")
@ -99,3 +122,232 @@ async def test_e2e_wrong_key_fails_with_handshake_error() -> None:
with pytest.raises(HandshakeError):
async with OmniClient(host=host, port=port, controller_key=wrong_key) as cli:
await cli.get_system_information()
# --------------------------------------------------------------------------
# New surface: typed commands + status + event push
# --------------------------------------------------------------------------
def _state_with_area_and_codes() -> MockState:
"""Common fixture: one area with one valid user-code mapping."""
return MockState(
areas={1: MockAreaState(name="Main")},
user_codes={1: 1234},
)
async def test_e2e_arm_area() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_state_with_area_and_codes())
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
await cli.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=1234
)
statuses = await cli.get_object_status(ModelObjectType.AREA, 1)
assert len(statuses) == 1
area = statuses[0]
assert isinstance(area, AreaStatus)
assert area.index == 1
assert area.mode == int(SecurityMode.AWAY)
assert area.mode_name == "AWAY"
async def test_e2e_arm_with_wrong_code_raises() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_state_with_area_and_codes())
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
with pytest.raises(CommandFailedError):
await cli.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=9999
)
async def test_e2e_turn_unit_on_off() -> None:
state = MockState(units={1: MockUnitState(name="Lamp")})
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
await cli.turn_unit_on(1)
statuses = await cli.get_object_status(ModelObjectType.UNIT, 1)
assert len(statuses) == 1
unit = statuses[0]
assert isinstance(unit, UnitStatus)
assert unit.state == 1
assert unit.is_on is True
await cli.turn_unit_off(1)
statuses = await cli.get_object_status(ModelObjectType.UNIT, 1)
assert statuses[0].state == 0
assert statuses[0].is_on is False
async def test_e2e_set_unit_level() -> None:
state = MockState(units={1: MockUnitState(name="Dimmer")})
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
await cli.set_unit_level(1, 60)
statuses = await cli.get_extended_status(ModelObjectType.UNIT, 1)
assert len(statuses) == 1
unit = statuses[0]
assert isinstance(unit, UnitStatus)
# state byte 100..200 encodes brightness percent (state - 100).
assert unit.state == 160
assert unit.brightness == 60
async def test_e2e_bypass_restore_zone() -> None:
state = MockState(zones={1: MockZoneState(name="Front Door")})
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
# Initially not bypassed.
statuses = await cli.get_object_status(ModelObjectType.ZONE, 1)
assert isinstance(statuses[0], ZoneStatus)
assert statuses[0].is_bypassed is False
await cli.bypass_zone(1)
statuses = await cli.get_object_status(ModelObjectType.ZONE, 1)
assert statuses[0].is_bypassed is True
await cli.restore_zone(1)
statuses = await cli.get_object_status(ModelObjectType.ZONE, 1)
assert statuses[0].is_bypassed is False
async def test_e2e_set_thermostat_heat_setpoint() -> None:
state = MockState(thermostats={1: MockThermostatState(name="Living")})
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
await cli.set_thermostat_heat_setpoint_raw(1, 150)
statuses = await cli.get_extended_status(ModelObjectType.THERMOSTAT, 1)
assert len(statuses) == 1
tstat = statuses[0]
assert isinstance(tstat, ThermostatStatus)
assert tstat.heat_setpoint_raw == 150
async def test_e2e_arm_pushes_arming_changed_event() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_state_with_area_and_codes())
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
events = cli.events()
await cli.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=1234
)
ev = await asyncio.wait_for(events.__anext__(), timeout=1.0)
assert isinstance(ev, ArmingChanged)
assert ev.area_index == 1
assert ev.new_mode == int(SecurityMode.AWAY)
assert ev.user_index == 1
async def test_e2e_unit_command_pushes_unit_state_changed_event() -> None:
state = MockState(units={1: MockUnitState(name="Lamp")})
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
events = cli.events()
await cli.turn_unit_on(1)
ev = await asyncio.wait_for(events.__anext__(), timeout=1.0)
assert isinstance(ev, UnitStateChanged)
assert ev.unit_index == 1
assert ev.is_on is True
async def test_e2e_thermostat_properties_discovery() -> None:
"""The HA coordinator walks thermostats via raw RequestProperties; ensure
the mock answers and the response parses cleanly into ThermostatProperties.
"""
from omni_pca.models import ObjectType as ObjType
from omni_pca.models import ThermostatProperties
from omni_pca.opcodes import OmniLink2MessageType
state = MockState(
thermostats={
1: MockThermostatState(name="LIVING_ROOM"),
3: MockThermostatState(name="MASTER"),
}
)
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
found: dict[int, str] = {}
cursor = 0
for _ in range(10):
payload = bytes(
[int(ObjType.THERMOSTAT), (cursor >> 8) & 0xFF, cursor & 0xFF, 1, 0, 0, 0]
)
reply = await cli.connection.request(
OmniLink2MessageType.RequestProperties, payload
)
if reply.opcode == int(OmniLink2MessageType.EOD):
break
t = ThermostatProperties.parse(reply.payload)
found[t.index] = t.name
cursor = t.index
assert found == {1: "LIVING_ROOM", 3: "MASTER"}
async def test_e2e_button_properties_discovery() -> None:
"""Same idea for button discovery (the HA coordinator drives this too)."""
from omni_pca.models import ButtonProperties
from omni_pca.models import ObjectType as ObjType
from omni_pca.opcodes import OmniLink2MessageType
state = MockState(
buttons={
1: MockButtonState(name="GOOD_MORNING"),
5: MockButtonState(name="MOVIE_MODE"),
}
)
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
found: dict[int, str] = {}
cursor = 0
for _ in range(10):
payload = bytes(
[int(ObjType.BUTTON), (cursor >> 8) & 0xFF, cursor & 0xFF, 1, 0, 0, 0]
)
reply = await cli.connection.request(
OmniLink2MessageType.RequestProperties, payload
)
if reply.opcode == int(OmniLink2MessageType.EOD):
break
b = ButtonProperties.parse(reply.payload)
found[b.index] = b.name
cursor = b.index
assert found == {1: "GOOD_MORNING", 5: "MOVIE_MODE"}
async def test_e2e_acknowledge_alerts() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
# Should complete without raising.
await cli.acknowledge_alerts()

405
tests/test_events.py Normal file
View File

@ -0,0 +1,405 @@
"""Tests for ``omni_pca.events`` — typed system-event parsing.
The test data is hand-built each helper constructs a synthetic v2
``SystemEvents`` (opcode 55) ``Message`` containing one or more 16-bit
event words encoded in the panel's wire layout. Cross-reference the
bit-mask comments below against ``clsText.GetEventCategory``
(clsText.cs:1585-1690) and ``GetEventText`` (clsText.cs:1693-1911).
"""
from __future__ import annotations
import asyncio
import pytest
from omni_pca.events import (
EVENT_REGISTRY,
AccessReaderEvent,
AcLost,
AcRestored,
AlarmActivated,
AlarmCleared,
AlarmKind,
AllOnOff,
ArmingChanged,
BatteryLow,
BatteryRestored,
CameraTrigger,
DcmOk,
DcmTrouble,
EnergyCostChanged,
EventStream,
EventType,
PhoneLineDead,
PhoneLineOffHook,
PhoneLineOnHook,
PhoneLineRinging,
SystemEvent,
UnitStateChanged,
UnknownEvent,
UpbLinkAction,
UpbLinkEvent,
UserMacroButton,
X10CodeReceived,
ZoneStateChanged,
parse_events,
)
from omni_pca.message import START_CHAR_V2, Message
from omni_pca.opcodes import OmniLink2MessageType
# --------------------------------------------------------------------------
# helpers
# --------------------------------------------------------------------------
def _make_events_message(*words: int) -> Message:
"""Build a v2 SystemEvents (opcode 55) Message containing the given
16-bit event words, encoded big-endian on the wire."""
payload = bytearray()
for w in words:
payload.append((w >> 8) & 0xFF)
payload.append(w & 0xFF)
data = bytes([int(OmniLink2MessageType.SystemEvents)]) + bytes(payload)
return Message(start_char=START_CHAR_V2, data=data)
# --------------------------------------------------------------------------
# Per-subclass parse tests — one event per message
# --------------------------------------------------------------------------
def test_parse_user_macro_button() -> None:
msg = _make_events_message(0x0042)
events = parse_events(msg)
assert len(events) == 1
ev = events[0]
assert isinstance(ev, UserMacroButton)
assert ev.button_index == 0x42
assert ev.event_type is EventType.USER_MACRO_BUTTON
assert ev.raw_word == 0x0042
def test_parse_alarm_activated_burglary_area_3() -> None:
# ALARM family = 0x0200; (alarm_type=Burglary=1) << 4 | area=3 = 0x13
word = 0x0200 | (int(AlarmKind.BURGLARY) << 4) | 0x03
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, AlarmActivated)
assert ev.alarm_type == AlarmKind.BURGLARY
assert ev.area_index == 3
def test_parse_alarm_cleared_when_alarm_type_zero() -> None:
# ALARM family with alarm_type=ANY(0): we surface as a cleared event.
word = 0x0200 | (int(AlarmKind.ANY) << 4) | 0x05
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, AlarmCleared)
assert ev.area_index == 5
def test_parse_zone_state_changed_open() -> None:
# ZONE family = 0x0400; bit 9 (0x0200) set ⇒ not-ready/open; zone 17.
word = 0x0400 | 0x0200 | 17
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, ZoneStateChanged)
assert ev.zone_index == 17
assert ev.is_open
assert not ev.is_secure
assert ev.new_state == 1
def test_parse_zone_state_changed_secure() -> None:
word = 0x0400 | 23 # bit 9 clear ⇒ secure
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, ZoneStateChanged)
assert ev.zone_index == 23
assert ev.is_secure
def test_parse_unit_state_changed_high_index_on() -> None:
# UNIT family = 0x0800; index 300 = bit 8 set + low byte 44; bit 9 = on.
# 300 = 256 + 44; bit 8 of high byte == ((1<<8))=0x100; OR bit 9 (0x200).
word = 0x0800 | 0x0200 | 0x0100 | 44 # high-byte bit 0 (=0x100) + bit 1 (=0x200)
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, UnitStateChanged)
assert ev.unit_index == 300
assert ev.is_on
assert ev.new_state == 1
def test_parse_unit_state_changed_low_index_off() -> None:
word = 0x0800 | 7 # bit 9 clear, no index extension
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, UnitStateChanged)
assert ev.unit_index == 7
assert not ev.is_on
def test_parse_x10_code_received() -> None:
# X-10 family = 0x0C00; house 'B' (=1<<4 in low byte high nibble),
# unit 5 (=4 in low nibble, +1), bit 9 ⇒ on.
word = 0x0C00 | 0x0200 | (1 << 4) | 4
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, X10CodeReceived)
assert ev.house_code == "B"
assert ev.unit_number == 5
assert ev.is_on
assert not ev.all_units
def test_parse_all_on_off_area_2_on() -> None:
# ALL_ON_OFF family = 0x03E0; area 2 in low nibble; on bit (0x10) set.
word = 0x03E0 | 0x10 | 2
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, AllOnOff)
assert ev.area_index == 2
assert ev.on
def test_parse_phone_singletons() -> None:
cases: list[tuple[int, type]] = [
(768, PhoneLineDead),
(769, PhoneLineRinging),
(770, PhoneLineOffHook),
(771, PhoneLineOnHook),
]
for word, klass in cases:
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, klass), (word, type(ev))
assert ev.raw_word == word
def test_parse_ac_battery_dcm_singletons() -> None:
[ac_off] = parse_events(_make_events_message(772))
[ac_on] = parse_events(_make_events_message(773))
[batt_low] = parse_events(_make_events_message(774))
[batt_ok] = parse_events(_make_events_message(775))
[dcm_bad] = parse_events(_make_events_message(776))
[dcm_ok] = parse_events(_make_events_message(777))
assert isinstance(ac_off, AcLost)
assert isinstance(ac_on, AcRestored)
assert isinstance(batt_low, BatteryLow)
assert isinstance(batt_ok, BatteryRestored)
assert isinstance(dcm_bad, DcmTrouble)
assert isinstance(dcm_ok, DcmOk)
def test_parse_energy_cost_levels() -> None:
for word, level in [(778, 0), (779, 1), (780, 2), (781, 3)]:
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, EnergyCostChanged)
assert ev.cost_level == level
def test_parse_camera_trigger() -> None:
[ev] = parse_events(_make_events_message(785)) # 785 - 781 = 4 → camera 4
assert isinstance(ev, CameraTrigger)
assert ev.camera_index == 4
def test_parse_access_reader_event() -> None:
# 976..991: reader_index = (word & 0xF) + 1
[ev] = parse_events(_make_events_message(978))
assert isinstance(ev, AccessReaderEvent)
assert ev.reader_index == ((978 & 0xF) + 1)
def test_parse_upb_link_actions() -> None:
# UPB_LINK family = 0xFC00; upper byte selects action.
actions = [
(UpbLinkAction.OFF, 0xFC),
(UpbLinkAction.ON, 0xFD),
(UpbLinkAction.SET, 0xFE),
(UpbLinkAction.FADE_STOP, 0xFF),
]
for action, upper in actions:
word = (upper << 8) | 12 # link index 12
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, UpbLinkEvent), (action, ev)
assert ev.link_index == 12
assert ev.action == int(action)
def test_parse_arming_changed_user_5_area_2_away() -> None:
# SECURITY_MODE_CHANGE catch-all:
# bits 12-14 = SecurityMode (3 = AWAY)
# bits 8-11 = area (2)
# low byte = user/code (5)
word = (3 << 12) | (2 << 8) | 5
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, ArmingChanged)
assert ev.area_index == 2
assert ev.new_mode == 3
assert ev.user_index == 5
assert ev.mode_name == "AWAY"
def test_parse_arming_changed_set_command_bit() -> None:
# Same as above but with the "Set" verb bit (bit 15) set.
word = (1 << 12) | (1 << 15) | (1 << 8) | 9
[ev] = parse_events(_make_events_message(word))
assert isinstance(ev, ArmingChanged)
assert ev.is_set_command
assert ev.user_index == 9
def test_unknown_event_returned_for_unmapped_word() -> None:
# Any value in the gap between the special singletons and the SECURITY
# catch-all that ALSO has zero in the high nibble of the high byte
# falls through to UnknownEvent. word=900 is in the gap (after CAMERA
# range, before ACCESS_READER) and (900 >> 8) & 0xF0 = 0 → unknown.
[ev] = parse_events(_make_events_message(900))
assert isinstance(ev, UnknownEvent)
assert ev.event_type is EventType.UNKNOWN
assert ev.raw_word == 900
# --------------------------------------------------------------------------
# Multi-event-per-packet (the panel batches into a single SystemEvents msg)
# --------------------------------------------------------------------------
def test_parse_three_events_in_one_message() -> None:
msg = _make_events_message(
0x0400 | 0x0200 | 5, # zone 5 opened
(3 << 12) | (1 << 8) | 7, # area 1 → AWAY by user 7
773, # AC restored
)
events = parse_events(msg)
assert len(events) == 3
z, a, ac = events
assert isinstance(z, ZoneStateChanged)
assert z.zone_index == 5
assert z.is_open
assert isinstance(a, ArmingChanged)
assert a.area_index == 1
assert a.new_mode == 3
assert isinstance(ac, AcRestored)
def test_empty_system_events_message_returns_empty_list() -> None:
msg = _make_events_message() # zero event words
assert parse_events(msg) == []
def test_odd_trailing_byte_is_silently_truncated() -> None:
"""The C# count is ``(MessageLength - 1) / 2`` — a trailing odd byte
is dropped, not raised. We mirror that to stay tolerant of messages
where the panel has appended a stray byte (seen on some firmwares)."""
data = bytes([int(OmniLink2MessageType.SystemEvents)]) + b"\x00\x42\x77"
msg = Message(start_char=START_CHAR_V2, data=data)
events = parse_events(msg)
assert len(events) == 1
assert isinstance(events[0], UserMacroButton)
assert events[0].button_index == 0x42
def test_parse_rejects_wrong_opcode() -> None:
# opcode 25 = SystemStatus, not SystemEvents.
msg = Message(
start_char=START_CHAR_V2,
data=bytes([int(OmniLink2MessageType.SystemStatus)]) + b"\x00",
)
with pytest.raises(ValueError, match="not a SystemEvents message"):
parse_events(msg)
def test_classmethod_parse_matches_function() -> None:
msg = _make_events_message(0x0042, 773)
via_classmethod = SystemEvent.parse(msg)
via_function = parse_events(msg)
assert [type(e) for e in via_classmethod] == [type(e) for e in via_function]
assert [e.raw_word for e in via_classmethod] == [e.raw_word for e in via_function]
# --------------------------------------------------------------------------
# Registry sanity check
# --------------------------------------------------------------------------
def test_event_registry_covers_every_eventtype_value() -> None:
"""Every non-UNKNOWN EventType value should map to a concrete class."""
for et in EventType:
assert int(et) in EVENT_REGISTRY, f"missing registry entry for {et!r}"
# --------------------------------------------------------------------------
# EventStream — async iterator over an underlying connection-like source
# --------------------------------------------------------------------------
class _FakeConnection:
"""Minimal stand-in for OmniConnection used in the EventStream tests.
Exposes the same ``unsolicited() -> AsyncIterator[Message]`` contract
backed by an in-memory queue so the test harness can drive the stream
deterministically without touching real I/O.
"""
def __init__(self) -> None:
self.queue: asyncio.Queue[Message] = asyncio.Queue()
self._closed = False
def push(self, msg: Message) -> None:
self.queue.put_nowait(msg)
def close(self) -> None:
self._closed = True
def unsolicited(self):
async def _gen():
while True:
if self._closed and self.queue.empty():
return
msg = await self.queue.get()
yield msg
return _gen()
@pytest.mark.asyncio
async def test_event_stream_yields_one_typed_event_per_step() -> None:
conn = _FakeConnection()
# Three events spread across two messages — confirms both flattening
# and cross-message iteration work.
conn.push(_make_events_message(0x0042, 773)) # 2 events
conn.push(_make_events_message(0x0400 | 0x0200 | 9)) # 1 event
conn.close()
stream = EventStream(source=conn)
seen: list[SystemEvent] = []
async for ev in stream:
seen.append(ev)
if len(seen) == 3:
break
assert isinstance(seen[0], UserMacroButton)
assert seen[0].button_index == 0x42
assert isinstance(seen[1], AcRestored)
assert isinstance(seen[2], ZoneStateChanged)
assert seen[2].zone_index == 9
assert seen[2].is_open
@pytest.mark.asyncio
async def test_event_stream_skips_non_event_messages() -> None:
"""Status replies, Acks, etc. that show up on the unsolicited
channel must be silently dropped only opcode 55 produces events."""
conn = _FakeConnection()
# Inject a SystemStatus reply (opcode 25) — should be filtered out.
other = Message(
start_char=START_CHAR_V2,
data=bytes([int(OmniLink2MessageType.SystemStatus)]) + b"\x00" * 14,
)
conn.push(other)
conn.push(_make_events_message(0x0042))
conn.close()
stream = EventStream(source=conn)
ev = await stream.__anext__()
assert isinstance(ev, UserMacroButton)
assert ev.button_index == 0x42
def test_event_stream_rejects_non_connection_source() -> None:
with pytest.raises(TypeError, match="unsolicited"):
EventStream(source=object()) # type: ignore[arg-type]

304
tests/test_ha_helpers.py Normal file
View File

@ -0,0 +1,304 @@
"""Pure-function tests for ``custom_components.omni_pca.helpers``.
These never import anything from ``homeassistant.*``, so they run in the
same venv as the rest of the library tests. The HA-bound modules
(coordinator, binary_sensor, __init__) are covered separately by
``test_ha_imports.py`` which uses ``pytest.importorskip("homeassistant")``.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
# Load the helpers module by file path so we don't have to drag in the
# rest of the package (which imports `homeassistant.*` at module scope).
_REPO_ROOT = Path(__file__).parent.parent
_HELPERS_PATH = _REPO_ROOT / "custom_components" / "omni_pca" / "helpers.py"
def _load_helpers():
spec = importlib.util.spec_from_file_location(
"_omni_pca_helpers_under_test", _HELPERS_PATH
)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
helpers = _load_helpers()
class TestDeviceClassForZoneType:
@pytest.mark.parametrize(
("zone_type", "expected"),
[
(0, "opening"), # ENTRY_EXIT
(1, "opening"), # PERIMETER
(2, "motion"), # NIGHT_INTERIOR
(3, "motion"), # AWAY_INTERIOR
(16, "safety"), # PANIC
(17, "safety"), # POLICE_EMERGENCY
(18, "safety"), # SILENT_DURESS
(19, "tamper"), # TAMPER
(20, "tamper"), # LATCHING_TAMPER
(32, "smoke"), # FIRE
(33, "smoke"), # FIRE_EMERGENCY
(34, "gas"), # GAS
(54, "cold"), # FREEZE
(55, "moisture"), # WATER
(56, "tamper"), # FIRE_TAMPER
],
)
def test_known_zone_types(self, zone_type: int, expected: str) -> None:
assert helpers.device_class_for_zone_type(zone_type) == expected
def test_unknown_zone_type_defaults_to_opening(self) -> None:
assert helpers.device_class_for_zone_type(199) == "opening"
def test_zero_is_opening(self) -> None:
assert helpers.device_class_for_zone_type(0) == "opening"
class TestIsBinaryZoneType:
@pytest.mark.parametrize("analog_type", [80, 81, 82, 83, 84])
def test_analog_types_excluded(self, analog_type: int) -> None:
assert helpers.is_binary_zone_type(analog_type) is False
@pytest.mark.parametrize(
"binary_type", [0, 1, 2, 3, 16, 19, 32, 34, 54, 55, 56, 64]
)
def test_binary_types_included(self, binary_type: int) -> None:
assert helpers.is_binary_zone_type(binary_type) is True
class TestUseLatchedAlarmForZone:
@pytest.mark.parametrize(
"latching_type",
[16, 17, 18, 19, 20, 32, 33, 34, 48, 54, 55, 56],
)
def test_latching_types(self, latching_type: int) -> None:
assert helpers.use_latched_alarm_for_zone(latching_type) is True
@pytest.mark.parametrize("contact_type", [0, 1, 2, 3, 4, 5, 6, 7, 8])
def test_contact_and_motion_types_use_current_condition(
self, contact_type: int
) -> None:
assert helpers.use_latched_alarm_for_zone(contact_type) is False
class TestPrettifyName:
@pytest.mark.parametrize(
("raw", "expected"),
[
("FRONT_DOOR", "Front Door"),
("front_door", "Front Door"),
("KITCHEN", "Kitchen"),
(" Trimmed ", "Trimmed"),
("MOTION_KIDS_ROOM", "Motion Kids Room"),
("", ""),
],
)
def test_round_trip(self, raw: str, expected: str) -> None:
assert helpers.prettify_name(raw) == expected
# ----------------------------------------------------------------------------
# Phase B helpers — pure functions used by the new entity platforms
# ----------------------------------------------------------------------------
class TestSecurityModeToAlarmState:
@pytest.mark.parametrize(
("mode", "expected"),
[
(0, "disarmed"),
(1, "armed_home"), # DAY
(2, "armed_night"), # NIGHT
(3, "armed_away"), # AWAY
(4, "armed_vacation"), # VACATION
(5, "armed_custom_bypass"), # DAY_INSTANT
(6, "armed_night"), # NIGHT_DELAYED
],
)
def test_steady_state(self, mode: int, expected: str) -> None:
assert helpers.security_mode_to_alarm_state(mode) == expected
def test_alarm_active_overrides(self) -> None:
assert helpers.security_mode_to_alarm_state(3, alarm_active=True) == "triggered"
def test_entry_timer_pending(self) -> None:
assert helpers.security_mode_to_alarm_state(3, entry_timer=15) == "pending"
def test_exit_timer_arming(self) -> None:
assert helpers.security_mode_to_alarm_state(3, exit_timer=30) == "arming"
@pytest.mark.parametrize("arming_mode", [9, 10, 11, 12, 13, 14])
def test_arming_in_progress_modes(self, arming_mode: int) -> None:
assert helpers.security_mode_to_alarm_state(arming_mode) == "arming"
def test_unknown_mode_falls_back_to_disarmed(self) -> None:
assert helpers.security_mode_to_alarm_state(99) == "disarmed"
class TestArmServiceMapping:
def test_all_services_present(self) -> None:
for svc in ("arm_home", "arm_away", "arm_night", "arm_vacation",
"arm_custom_bypass", "disarm"):
assert svc in helpers.ARM_SERVICE_TO_SECURITY_MODE
def test_round_trip_through_alarm_state(self) -> None:
# Arm with each service, decode the resulting mode back to the HA
# state, and verify the names are sensible.
for svc, expected_state in [
("disarm", "disarmed"),
("arm_home", "armed_home"),
("arm_away", "armed_away"),
("arm_night", "armed_night"),
("arm_vacation", "armed_vacation"),
]:
mode = helpers.ARM_SERVICE_TO_SECURITY_MODE[svc]
assert helpers.security_mode_to_alarm_state(mode) == expected_state
class TestBrightnessConversions:
@pytest.mark.parametrize(
("state", "expected"),
[
(0, None), # off
(1, 255), # plain on, non-dimmable
(100, 1), # 0% via Omni's overlap (level 100 = 0%, but we floor at 1)
(150, 128), # 50%
(200, 255), # 100%
],
)
def test_state_to_ha_brightness(self, state: int, expected: int | None) -> None:
result = helpers.omni_state_to_ha_brightness(state)
assert result == expected
@pytest.mark.parametrize(
("brightness", "expected_percent"),
[
(1, 1),
(128, 50),
(255, 100),
],
)
def test_ha_brightness_to_omni_percent(
self, brightness: int, expected_percent: int
) -> None:
assert helpers.ha_brightness_to_omni_percent(brightness) == expected_percent
def test_zero_brightness(self) -> None:
assert helpers.ha_brightness_to_omni_percent(0) == 0
class TestHvacFanHoldRoundTrip:
@pytest.mark.parametrize(
("omni_mode", "expected_ha"),
[(0, "off"), (1, "heat"), (2, "cool"), (3, "heat_cool"), (4, "heat")],
)
def test_hvac_mapping(self, omni_mode: int, expected_ha: str) -> None:
assert helpers.omni_hvac_to_ha(omni_mode) == expected_ha
@pytest.mark.parametrize(
("ha_mode", "expected_omni"),
[("off", 0), ("heat", 1), ("cool", 2), ("heat_cool", 3)],
)
def test_hvac_inverse(self, ha_mode: str, expected_omni: int) -> None:
assert helpers.ha_hvac_to_omni(ha_mode) == expected_omni
def test_fan_round_trip(self) -> None:
for omni in (0, 1, 2):
ha = helpers.omni_fan_to_ha(omni)
back = helpers.ha_fan_to_omni(ha)
assert back == omni
def test_hold_round_trip(self) -> None:
for omni in (0, 1, 2):
ha = helpers.omni_hold_to_ha(omni)
back = helpers.ha_hold_to_omni(ha)
assert back == omni
def test_legacy_old_on_hold_value(self) -> None:
# Old firmware sentinel 0xFF should map to the same HA preset as 1.
assert helpers.omni_hold_to_ha(0xFF) == helpers.omni_hold_to_ha(1)
class TestTemperatureInverse:
@pytest.mark.parametrize(
("fahrenheit", "expected_raw"),
[
(-40, 0), # bottom of the scale
(0, 44), # ~0°F
(32, 80), # freezing
(72, 124), # room temp
(212, 280), # boiling — above byte range, gets clamped
],
)
def test_fahrenheit_to_raw(self, fahrenheit: float, expected_raw: int) -> None:
result = helpers.fahrenheit_to_omni_raw(fahrenheit)
# We clamp to 0..255 so 212°F (would compute 280) becomes 255.
if expected_raw > 255:
assert result == 255
else:
assert result == expected_raw
def test_inverse_round_trip_at_typical_setpoints(self) -> None:
# Take a few raw values, decode to °F via the linear formula, encode
# back, and verify we get the same byte (within ±1 due to rounding).
for raw in (80, 100, 124, 144, 168, 184):
fahrenheit = round(raw * 9 / 10) - 40
back = helpers.fahrenheit_to_omni_raw(fahrenheit)
assert abs(back - raw) <= 1
class TestAnalogZoneDeviceClass:
@pytest.mark.parametrize(
("zone_type", "expected"),
[
(80, "power"),
(81, "temperature"),
(82, "temperature"),
(83, "temperature"),
(84, "humidity"),
(1, None), # binary zone — not analog
(255, None), # unknown
],
)
def test_mapping(self, zone_type: int, expected: str | None) -> None:
assert helpers.analog_zone_device_class(zone_type) == expected
class TestEventTypeFor:
@pytest.mark.parametrize(
("class_name", "expected"),
[
("ZoneStateChanged", "zone_state_changed"),
("UnitStateChanged", "unit_state_changed"),
("ArmingChanged", "arming_changed"),
("AlarmActivated", "alarm_activated"),
("AlarmCleared", "alarm_cleared"),
("AcLost", "ac_lost"),
("AcRestored", "ac_restored"),
("BatteryLow", "battery_low"),
("BatteryRestored", "battery_restored"),
("UserMacroButton", "user_macro_button"),
("PhoneLineDead", "phone_line_dead"),
("PhoneLineRestored", "phone_line_restored"),
],
)
def test_known_events(self, class_name: str, expected: str) -> None:
assert helpers.event_type_for(class_name) == expected
def test_unknown_event_class(self) -> None:
assert helpers.event_type_for("SomeRandomThing") == "unknown"
def test_event_types_tuple_includes_unknown(self) -> None:
assert "unknown" in helpers.EVENT_TYPES

2697
uv.lock generated

File diff suppressed because it is too large Load Diff