Compare commits
10 Commits
08974e2ec4
...
04b6a44403
| Author | SHA1 | Date | |
|---|---|---|---|
| 04b6a44403 | |||
| 7b4052624c | |||
| f6a09592f1 | |||
| df8b6128ea | |||
| 93b7e1f604 | |||
| 83d85a9885 | |||
| 57b8aa4b04 | |||
| e8ed7d1b89 | |||
| c26db62959 | |||
| 68cf44a585 |
2
.gitignore
vendored
@ -39,3 +39,5 @@ panel_key*
|
||||
|
||||
# Wine artifacts (if used for testing)
|
||||
.wine-pca/
|
||||
ha-config/
|
||||
dist/
|
||||
|
||||
@ -1 +1 @@
|
||||
3.12
|
||||
3.14
|
||||
|
||||
85
CHANGELOG.md
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
156
custom_components/omni_pca/alarm_control_panel.py
Normal 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)
|
||||
@ -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),
|
||||
}
|
||||
|
||||
62
custom_components/omni_pca/button.py
Normal 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
|
||||
271
custom_components/omni_pca/climate.py
Normal 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
|
||||
)
|
||||
)
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
85
custom_components/omni_pca/diagnostics.py
Normal 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
|
||||
),
|
||||
}
|
||||
68
custom_components/omni_pca/event.py
Normal 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()
|
||||
412
custom_components/omni_pca/helpers.py
Normal 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)
|
||||
115
custom_components/omni_pca/light.py
Normal 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()
|
||||
@ -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"
|
||||
}
|
||||
|
||||
263
custom_components/omni_pca/sensor.py
Normal 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
|
||||
185
custom_components/omni_pca/services.py
Normal 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)
|
||||
159
custom_components/omni_pca/services.yaml
Normal 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
|
||||
89
custom_components/omni_pca/switch.py
Normal 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
@ -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
@ -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.
|
||||
BIN
dev/artifacts/screenshots/2026-05-10/01-overview.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
dev/artifacts/screenshots/2026-05-10/02-devices.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
dev/artifacts/screenshots/2026-05-10/02-integrations-list.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
dev/artifacts/screenshots/2026-05-10/03-integrations.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
dev/artifacts/screenshots/2026-05-10/03-omni-pca-config.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
dev/artifacts/screenshots/2026-05-10/04-entities.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
dev/artifacts/screenshots/2026-05-10/04-panel-device.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
dev/artifacts/screenshots/2026-05-10/05-developer-states.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
dev/artifacts/screenshots/2026-05-10/05-entities-light.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
dev/artifacts/screenshots/2026-05-10/05-entities-omni.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
dev/artifacts/screenshots/2026-05-10/06-developer-states.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
48
dev/docker-compose.yml
Normal 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
@ -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
@ -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
@ -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 32–127 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.
|
||||
@ -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"
|
||||
|
||||
@ -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
@ -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
@ -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",
|
||||
]
|
||||
@ -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
@ -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
|
||||
7
tests/ha_integration/__init__.py
Normal 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.
|
||||
"""
|
||||
133
tests/ha_integration/conftest.py
Normal 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()
|
||||
151
tests/ha_integration/test_setup.py
Normal 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
@ -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 == []
|
||||
@ -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
@ -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
@ -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
|
||||