Compare commits

..

No commits in common. "main" and "v2026.5.10" have entirely different histories.

107 changed files with 178 additions and 22889 deletions

View File

@ -1,26 +0,0 @@
name: Validate
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 4 * * 1" # weekly Monday 04:00 UTC
workflow_dispatch:
jobs:
hacs:
name: HACS validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hacs/action@main
with:
category: integration
hassfest:
name: Hassfest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: home-assistant/actions/hassfest@master

6
.gitignore vendored
View File

@ -41,9 +41,3 @@ panel_key*
.wine-pca/
ha-config/
dist/
dev/.omni_key
# Frontend build artifacts. node_modules is local; the bundled panel.js
# is committed (alongside its source) so end-users don't need Node
# installed to use the integration.
custom_components/omni_pca/frontend/node_modules/

View File

@ -2,44 +2,6 @@
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.16] — 2026-05-16
Program viewer side panel + writeback API + docs link fix.
### Home Assistant integration
- Lit/TypeScript side panel for the program viewer (Phase C): filterable list, slide-in detail panel, structured-English token rendering, REF-token click-to-filter, live-state badges (SECURE / NOT READY / ON 60% / Away / 72°F) sourced from the coordinator, "Fire now" button calling `omni_pca/programs/fire` over the websocket.
- Program writeback: `DownloadProgram` wire path, HA write API, Clear / Clone UI in the side panel.
- esbuild bundle committed at `custom_components/omni_pca/www/panel.js` (~34 KB minified) so end-users don't need Node.
- `manifest.json`: `documentation` URL points at <https://hai-omni-pro-ii.warehack.ing/> (was the GitHub repo); matches the canonical docs site already referenced from `pyproject.toml`.
## [2026.5.14] — 2026-05-14
HACS publishing release — brand assets and validation tooling.
### Home Assistant integration
- `brand/icon.png` (256×256) + `brand/icon@2x.png` (512×512) shipped inline at `custom_components/omni_pca/brand/` for the HA 2026.3 brands-proxy API.
- WebSocket commands + side-panel registration for an in-HA custom panel surfacing decoded programs.
- `program_renderer`: structured-English token streams for the HA UI to render conditional logic.
- `program_engine`: real AND/OR condition evaluator (StateEvaluator decodes records against MockState; replaces the always-passes-AND/always-fails-OR stub).
- `program_engine`: EVENT programs + event taxonomy (Phase 4), clausal chains WHEN/AT/EVERY + AND/OR/THEN (Phase 5).
- `__init__.py`: `CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)` to satisfy hassfest.
- `manifest.json`: keys sorted (domain, name, then alphabetical), HTML/markdown removed from i18n strings.
- Canonical URLs switched to `github.com/rsp2k/omni-pca` (was Gitea-only).
### Library
- `pca_file.py`: progressive `SetupData` decoding — zone types, area assignments, entry/exit delays, temperature format, code PINs, installer/PCAccess codes, perimeter chime, audible exit delay, DST, house code format, time clocks, latitude/longitude/timezone, account remarks extended, 9 per-family description tables, zone options, thermostat type + areas, time_adj / alarm_reset_time / arming_confirmation / two_way_audio scalars.
- `iter_programs()` for both v1 (UDP) and v2 (TCP) wire dialects.
- `mock_panel`: v1 `UploadPrograms` streaming + program-echo tests; `MockState.from_pca()` builds state from a real `.pca`.
- `programs`: multi-record decoder properties (firmware ≥3.0 records), structured-OP AND decoder properties, AND-record u16 fields documented as big-endian on disk.
### CI / packaging
- `.github/workflows/validate.yml`: HACS action + hassfest on push / PR / weekly.
- `pyproject.toml`: full `[project.urls]` with Repository / Issues / Changelog / Documentation.
## [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.
@ -120,6 +82,4 @@ First release. Working library + Home Assistant custom component, validated end-
- **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.16]: https://github.com/rsp2k/omni-pca/releases/tag/v2026.5.16
[2026.5.14]: https://github.com/rsp2k/omni-pca/releases/tag/v2026.5.14
[2026.5.10]: https://github.com/rsp2k/omni-pca/releases/tag/v2026.5.10
[2026.5.10]: https://git.supported.systems/warehack.ing/omni-pca/releases/tag/v2026.5.10

View File

@ -4,9 +4,6 @@ Async Python client for HAI/Leviton Omni-Link II home automation panels — Omni
Includes a Home Assistant custom component (`custom_components/omni_pca/`).
**Project home:** <https://github.com/rsp2k/omni-pca>
**Documentation:** <https://hai-omni-pro-ii.warehack.ing/>
## Status
**Alpha.** Built from a full reverse-engineering of HAI's PC Access 3.17 (the Windows installer/programmer app). The protocol layer captures two non-public quirks that public Omni-Link clients miss:
@ -14,21 +11,14 @@ Includes a Home Assistant custom component (`custom_components/omni_pca/`).
1. **Session key is not the ControllerKey.** Last 5 bytes are XORed with a controller-supplied SessionID nonce.
2. **Per-block XOR pre-whitening before AES.** First two bytes of every 16-byte block are XORed with the packet's sequence number.
The full byte-level protocol spec lives at <https://hai-omni-pro-ii.warehack.ing/reference/protocol/>.
## Install
```bash
pip install omni-pca
# Or with uv
uv add omni-pca
```
For Home Assistant users, install the integration through HACS — see the [HA install how-to](https://hai-omni-pro-ii.warehack.ing/how-to/install-in-home-assistant/).
See [`docs/PROTOCOL.md`](docs/PROTOCOL.md) for the full byte-level spec.
## Quick start (library)
```bash
uv add omni-pca
```
```python
import asyncio
from omni_pca import OmniClient
@ -39,90 +29,44 @@ async def main():
port=4369,
controller_key=bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09"),
) as panel:
info = await panel.get_system_information()
info = await panel.get_system_info()
print(info.model_name, info.firmware_version)
asyncio.run(main())
```
For the panel walkthrough — connect, list zones, react to push events — see the [tutorial](https://hai-omni-pro-ii.warehack.ing/tutorials/first-script/).
## Two wire dialects — TCP/v2 vs UDP/v1
The Omni network module is configurable at the panel keypad to listen on **TCP, UDP, or both**. Each transport speaks a different wire dialect — `OmniClient` above handles the TCP path (OmniLink2, the modern wire format used by PC Access ≥ 3); panels configured UDP-only fall back to the legacy v1 protocol with typed `RequestZoneStatus` / `RequestUnitStatus` opcodes, no `RequestProperties`, and streaming name downloads. For those, use [`OmniClientV1`](https://hai-omni-pro-ii.warehack.ing/reference/library-api/#v1-udp-omniclientv1) from the `omni_pca.v1` subpackage:
```python
from omni_pca.v1 import OmniClientV1
async with OmniClientV1(
host="192.168.1.9",
controller_key=bytes.fromhex("..."),
) as panel:
info = await panel.get_system_information() # same dataclass as v2
names = await panel.list_all_names() # streaming UploadNames
zones = await panel.get_zone_status(1, 16) # typed status by range
await panel.execute_security_command(area=1, mode=SecurityMode.AWAY, code=1234)
```
The HA integration picks the right client automatically based on the **Transport** dropdown in the config flow (TCP vs UDP). See [zone & unit numbering](https://hai-omni-pro-ii.warehack.ing/explanation/zone-unit-numbering/) for why v1 panels need the long-form `RequestUnitStatus` for unit indices > 255.
## Quick start (Home Assistant)
```bash
# Manual install — works on every HA flavour
cd /path/to/your/homeassistant/config/
mkdir -p custom_components
cd custom_components
git clone https://github.com/rsp2k/omni-pca tmp-omni
cp -r tmp-omni/custom_components/omni_pca .
rm -rf tmp-omni
```
Restart HA, then add the integration via **Settings → Devices & Services**. You'll need:
Copy `custom_components/omni_pca/` into your HA `config/custom_components/`, restart HA, then add the integration via Settings → Devices & Services. You'll need:
- Panel IP / hostname
- TCP port (default 4369)
- ControllerKey as 32 hex chars
Get the ControllerKey from your `.pca` file using the bundled CLI:
Get the ControllerKey from your `.pca` file using the included parser:
```bash
omni-pca decode-pca '/path/to/Your.pca' --field controller_key
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 full entity + service catalog, or the [HA install how-to](https://hai-omni-pro-ii.warehack.ing/how-to/install-in-home-assistant/) for the step-by-step.
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
The library ships a stateful `MockPanel` that emulates the controller side of the protocol over real TCP. Useful for offline development, integration tests, and demos:
For testing, the library ships a minimal Omni controller emulator:
```python
from omni_pca.mock_panel import MockPanel
async with MockPanel(controller_key=...).serve(port=14369):
# Connect a real OmniClient to localhost:14369 — full handshake + AES
# connect a real OmniClient to localhost:14369 — works end-to-end
...
```
The local dev stack (`dev/docker-compose.yml`) packages a real Home Assistant container and the mock panel side-by-side so you can click through the integration without touching real hardware. See [the dev-stack tutorial](https://hai-omni-pro-ii.warehack.ing/tutorials/dev-stack/).
## Tests
```bash
uv sync --group ha
uv run pytest -q
```
351 tests across the protocol primitives, the mock panel, the OmniClient ↔ MockPanel end-to-end roundtrip, and an in-process Home Assistant harness driving the integration via the real config flow + service calls.
## Versioning
Date-based ([CalVer](https://calver.org/)): `YYYY.M.D`. Bumped on backwards-incompatible changes. See [`CHANGELOG.md`](CHANGELOG.md).
## License
MIT. See [`LICENSE`](LICENSE).
Date-based ([CalVer](https://calver.org/)): `YYYY.M.D`. Bumped on backwards-incompatible changes.
## Acknowledgments
This client is independent and not affiliated with Leviton or HAI. Protocol details derived from clean-room analysis of the publicly-distributed PC Access installer. The reverse-engineering arc is documented at <https://hai-omni-pro-ii.warehack.ing/journey/>.
This client is independent and not affiliated with Leviton or HAI. Protocol details derived from clean-room analysis of the publicly-distributed PC Access installer.

View File

@ -6,18 +6,16 @@ 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
### HACS
### HACS (recommended once published)
1. HACS → Integrations → search **HAI / Leviton Omni Panel**.
2. Install, then restart Home Assistant.
(If not yet in the HACS default catalog: HACS → Integrations → custom
repository → add `https://github.com/rsp2k/omni-pca`, category **Integration**.)
1. HACS → Integrations → custom repository → add
`https://git.supported.systems/warehack.ing/omni-pca`, category **Integration**.
2. Install **HAI / Leviton Omni Panel**, then restart Home Assistant.
### Manual
@ -123,6 +121,6 @@ hashed) — useful for bug reports.
- **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://github.com/rsp2k/omni-pca) for protocol /
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://github.com/rsp2k/omni-pca/blob/main/docs/JOURNEY.md).
[`docs/JOURNEY.md`](https://git.supported.systems/warehack.ing/omni-pca/blob/main/docs/JOURNEY.md).

View File

@ -13,25 +13,10 @@ from typing import TYPE_CHECKING
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_CONTROLLER_KEY,
CONF_PCA_KEY,
CONF_PCA_PATH,
CONF_TRANSPORT,
DEFAULT_TRANSPORT,
DOMAIN,
LOGGER,
)
from .const import CONF_CONTROLLER_KEY, DOMAIN, LOGGER
from .coordinator import OmniDataUpdateCoordinator
from .services import async_setup_services, async_unload_services
from .websocket import (
async_register_side_panel,
async_register_websocket_commands,
)
_PANEL_REGISTERED_KEY = "_panel_registered"
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
@ -48,8 +33,6 @@ PLATFORMS: list[Platform] = [
Platform.SWITCH,
]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""No YAML support; everything is config-flow driven."""
@ -67,18 +50,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
LOGGER.error("stored controller key for %s is corrupt: %s", entry.title, err)
return False
transport: str = entry.data.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
pca_path: str = entry.data.get(CONF_PCA_PATH, "") or ""
pca_key: int = entry.data.get(CONF_PCA_KEY, 0)
coordinator = OmniDataUpdateCoordinator(
hass,
entry,
host=host,
port=port,
controller_key=controller_key,
transport=transport,
pca_path=pca_path or None,
pca_key=pca_key,
)
try:
@ -92,20 +69,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await async_setup_services(hass)
# Websocket commands are global (not per-entry) but the registration
# helper is idempotent, so calling it for each entry is harmless.
async_register_websocket_commands(hass)
# Panel registration must happen exactly once — guard with a flag in
# hass.data. The flag survives entry reloads; only a full HA restart
# clears it (matching when HA itself would need to re-register).
if not hass.data[DOMAIN].get(_PANEL_REGISTERED_KEY):
try:
await async_register_side_panel(hass)
except Exception:
LOGGER.warning(
"omni_pca: side panel registration failed", exc_info=True,
)
hass.data[DOMAIN][_PANEL_REGISTERED_KEY] = True
return True

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -20,16 +20,10 @@ from omni_pca.connection import (
from .const import (
CONF_CONTROLLER_KEY,
CONF_PCA_KEY,
CONF_PCA_PATH,
CONF_TRANSPORT,
CONTROLLER_KEY_HEX_LEN,
DEFAULT_PORT,
DEFAULT_TRANSPORT,
DOMAIN,
LOGGER,
TRANSPORT_TCP,
TRANSPORT_UDP,
)
@ -66,21 +60,6 @@ _USER_SCHEMA = vol.Schema(
vol.Coerce(int), vol.Range(min=1, max=65535)
),
vol.Required(CONF_CONTROLLER_KEY): str,
# Most modern firmware uses TCP; some installers configure
# Network_UDP. PC Access stores the choice as
# enuPreferredNetworkProtocol in the .pca config.
vol.Required(CONF_TRANSPORT, default=DEFAULT_TRANSPORT): vol.In(
[TRANSPORT_TCP, TRANSPORT_UDP]
),
# Optional: load panel programs from a saved .pca file on the HA
# filesystem (e.g. /config/pca/My_House.pca) instead of streaming
# them from the panel on every restart. Useful when wire
# enumeration is slow or unreliable. CONF_PCA_KEY is the
# per-install key from PCA01.CFG (a uint32, 0 for plain-text).
vol.Optional(CONF_PCA_PATH, default=""): str,
vol.Optional(CONF_PCA_KEY, default=0): vol.All(
vol.Coerce(int), vol.Range(min=0, max=0xFFFFFFFF)
),
}
)
@ -100,9 +79,6 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
host: str = user_input[CONF_HOST].strip()
port: int = user_input[CONF_PORT]
transport: str = user_input.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
pca_path: str = (user_input.get(CONF_PCA_PATH) or "").strip()
pca_key: int = user_input.get(CONF_PCA_KEY, 0)
unique_id = f"{host}:{port}"
await self.async_set_unique_id(unique_id)
@ -114,24 +90,18 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
LOGGER.debug("controller key rejected: %s", err)
errors[CONF_CONTROLLER_KEY] = "invalid_key"
else:
if pca_path and (pca_err := await self._validate_pca(pca_path, pca_key)):
errors[CONF_PCA_PATH] = pca_err
if not errors:
title, error = await self._probe(host, port, key, transport)
if error is not None:
errors["base"] = error
else:
return self.async_create_entry(
title=title or f"Omni Panel ({host})",
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_CONTROLLER_KEY: key.hex(),
CONF_TRANSPORT: transport,
CONF_PCA_PATH: pca_path,
CONF_PCA_KEY: pca_key,
},
)
title, error = await self._probe(host, port, key)
if error is not None:
errors["base"] = error
else:
return self.async_create_entry(
title=title or f"Omni Panel ({host})",
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_CONTROLLER_KEY: key.hex(),
},
)
return self.async_show_form(
step_id="user",
@ -139,33 +109,6 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def _validate_pca(self, path: str, key: int) -> str | None:
"""Validate the user-supplied .pca path is readable and decrypts.
Returns ``None`` on success or an error code string on failure
(matches the {code: message} keys in strings.json).
"""
from pathlib import Path
from omni_pca.pca_file import parse_pca_file
p = Path(path)
if not p.is_file():
return "pca_not_found"
try:
data = await self.hass.async_add_executor_job(p.read_bytes)
acct = await self.hass.async_add_executor_job(
lambda: parse_pca_file(data, key=key)
)
except Exception as err:
LOGGER.debug("pca file rejected: %s", err)
return "pca_decode_failed"
# Sanity: programs block decoded cleanly. Empty is allowed
# (legitimate brand-new install with no programs).
if not isinstance(acct.programs, tuple):
return "pca_decode_failed"
return None
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
@ -178,9 +121,6 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
assert self._reauth_entry_data is not None
host: str = self._reauth_entry_data[CONF_HOST]
port: int = self._reauth_entry_data[CONF_PORT]
transport: str = self._reauth_entry_data.get(
CONF_TRANSPORT, DEFAULT_TRANSPORT
)
errors: dict[str, str] = {}
if user_input is not None:
@ -189,7 +129,7 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
except InvalidControllerKey:
errors[CONF_CONTROLLER_KEY] = "invalid_key"
else:
_, error = await self._probe(host, port, key, transport)
_, error = await self._probe(host, port, key)
if error is not None:
errors["base"] = error
else:
@ -207,46 +147,12 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
# ---- helpers ---------------------------------------------------------
async def _probe(
self,
host: str,
port: int,
key: bytes,
transport: str = DEFAULT_TRANSPORT,
self, host: str, port: int, key: bytes
) -> tuple[str | None, str | None]:
"""Try to connect once. Returns (title, error_code).
TCP uses :class:`OmniClient` (v2 wire protocol). UDP uses the v1
adapter UDP-listening panels speak the legacy wire protocol,
not OmniLink2 see :mod:`omni_pca.v1.adapter` for the bridge.
"""
"""Try to connect once. Returns (title, error_code)."""
try:
if transport == TRANSPORT_UDP:
from omni_pca.v1 import (
HandshakeError as V1HandshakeError,
)
from omni_pca.v1 import (
InvalidEncryptionKeyError as V1InvalidEncryptionKeyError,
)
from omni_pca.v1 import OmniClientV1Adapter
from omni_pca.v1.connection import (
ConnectionError as V1ConnectionError,
)
try:
async with OmniClientV1Adapter(
host, port=port, controller_key=key,
) as client:
info = await client.get_system_information()
except (V1HandshakeError, V1InvalidEncryptionKeyError):
return None, "invalid_auth"
except (V1ConnectionError, OSError, TimeoutError) as err:
LOGGER.debug("v1 probe failed: %s", err)
return None, "cannot_connect"
else:
async with OmniClient(
host, port=port, controller_key=key, transport=transport, # type: ignore[arg-type]
) as client:
info = await client.get_system_information()
async with OmniClient(host, port=port, controller_key=key) as client:
info = await client.get_system_information()
except (HandshakeError, InvalidEncryptionKeyError):
return None, "invalid_auth"
except (OmniConnectionError, OSError, TimeoutError) as err:

View File

@ -12,18 +12,6 @@ DEFAULT_PORT: Final = 4369
DEFAULT_TIMEOUT: Final = 5.0
CONF_CONTROLLER_KEY: Final = "controller_key"
CONF_TRANSPORT: Final = "transport"
# Optional: when set, load panel programs from a .pca file at this path
# instead of enumerating them over the wire on every entry refresh. The
# .pca file is decrypted with CONF_PCA_KEY (the per-install key from
# PCA01.CFG, or 0 for a plain-text dump). Both must be set together.
CONF_PCA_PATH: Final = "pca_path"
CONF_PCA_KEY: Final = "pca_key"
TRANSPORT_TCP: Final = "tcp"
TRANSPORT_UDP: Final = "udp"
DEFAULT_TRANSPORT: Final = TRANSPORT_TCP
MANUFACTURER: Final = "HAI / Leviton"

View File

@ -62,6 +62,7 @@ from omni_pca.models import (
AreaStatus,
ButtonProperties,
ObjectType,
ProgramProperties,
SystemInformation,
SystemStatus,
ThermostatProperties,
@ -72,7 +73,6 @@ from omni_pca.models import (
ZoneStatus,
)
from omni_pca.opcodes import OmniLink2MessageType
from omni_pca.programs import Program
from .const import (
DOMAIN,
@ -108,7 +108,7 @@ class OmniData:
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, Program] = 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)
@ -137,9 +137,6 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
host: str,
port: int,
controller_key: bytes,
transport: str = "tcp",
pca_path: str | None = None,
pca_key: int = 0,
) -> None:
super().__init__(
hass,
@ -151,9 +148,6 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
self._host = host
self._port = port
self._controller_key = controller_key
self._transport = transport
self._pca_path = pca_path
self._pca_key = pca_key
self._client: OmniClient | None = None
self._discovery_done = False
self._discovered: OmniData | None = None
@ -238,25 +232,11 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
async def _ensure_connected(self) -> OmniClient:
if self._client is not None:
return self._client
if self._transport == "udp":
# Panels listening UDP-only speak the v1 wire protocol, not
# v2. The adapter exposes the OmniClient API surface this
# coordinator was written against, but underneath it drives
# an OmniConnectionV1 + the typed v1 status/command opcodes.
from omni_pca.v1 import OmniClientV1Adapter
client: OmniClient = OmniClientV1Adapter( # type: ignore[assignment]
self._host,
port=self._port,
controller_key=self._controller_key,
)
else:
client = OmniClient(
self._host,
port=self._port,
controller_key=self._controller_key,
transport=self._transport, # type: ignore[arg-type]
)
client = OmniClient(
self._host,
port=self._port,
controller_key=self._controller_key,
)
# Drive __aenter__ manually so the client survives across update
# cycles; we close it explicitly on shutdown / failure.
await client.__aenter__()
@ -386,70 +366,15 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
async def _discover_programs(
self, client: OmniClient
) -> dict[int, Program]:
"""Enumerate defined panel programs.
Two sources, in order of preference:
1. ``CONF_PCA_PATH`` is configured parse the .pca file and
extract the programs block. Avoids streaming 1500 records on
every entry refresh and works against an offline snapshot.
2. Otherwise enumerate over the wire:
* v2 (TCP): ``client.iter_programs()`` drives UploadProgram
with request_reason=1 ("next defined after slot").
* v1 (UDP): adapter forwards to OmniClientV1.iter_programs(),
a bare UploadPrograms stream ack-walked to EOD.
Both paths yield :class:`omni_pca.programs.Program` and skip
empty slots. Errors are logged and swallowed programs are
non-critical discovery, so a partial list beats blocking setup.
"""
if self._pca_path:
return await self._discover_programs_from_pca()
out: dict[int, Program] = {}
try:
async for prog in client.iter_programs():
if prog.slot is not None:
out[prog.slot] = prog
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug(
"program enumeration interrupted (kept %d)", len(out), exc_info=True
)
return out
async def _discover_programs_from_pca(self) -> dict[int, Program]:
"""Parse the configured .pca file and pull out its programs block.
Runs the disk I/O on the executor since :mod:`pca_file` does
sync reads. Any failure (missing file, bad key, malformed block)
is logged and downgraded to an empty dict the rest of
discovery still works.
"""
from pathlib import Path
from omni_pca.pca_file import parse_pca_file
try:
data = await self.hass.async_add_executor_job(
Path(self._pca_path).read_bytes
)
acct = await self.hass.async_add_executor_job(
lambda: parse_pca_file(data, key=self._pca_key)
)
except Exception:
LOGGER.warning(
"failed to load programs from %s — falling back to empty list",
self._pca_path, exc_info=True,
)
return {}
out: dict[int, Program] = {}
for prog in acct.programs:
if prog.slot is None or prog.is_empty():
continue
out[prog.slot] = prog
return out
) -> 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,
@ -464,15 +389,9 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
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.
On UDP/v1 panels there is no ``RequestProperties`` opcode at all,
so we fall back to the v1 adapter's name-stream-based discovery
(each object's ``Properties`` is synthesized from its name).
"""
if parser is None or OBJECT_TYPE_TO_PROPERTIES.get(int(object_type)) is None:
return {}
if self._transport == "udp":
return await self._walk_properties_v1(client, object_type)
out: dict[int, object] = {}
cursor = 0
conn = client.connection
@ -521,42 +440,6 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
break
return out
async def _walk_properties_v1(
self, client: OmniClient, object_type: ObjectType
) -> dict[int, object]:
"""V1 fallback for :meth:`_walk_properties`.
v1 has no RequestProperties opcode names come from streaming
UploadNames and the rest of the Properties fields can't be
recovered from the wire. We delegate to the adapter's
``get_object_properties`` (which synthesizes a minimal record
from the cached name list) and skip anything it returns ``None``
for.
"""
# Pick the right per-type name lister. The adapter caches the
# UploadNames stream output so these are nearly free after the
# first call this discovery pass.
if object_type == ObjectType.THERMOSTAT:
names = await client.list_thermostat_names() # type: ignore[attr-defined]
elif object_type == ObjectType.BUTTON:
names = await client.list_button_names() # type: ignore[attr-defined]
else:
# Programs / Messages / etc — nothing to walk.
return {}
out: dict[int, object] = {}
for idx in sorted(names):
try:
props = await client.get_object_properties(object_type, idx)
except Exception:
LOGGER.debug(
"v1 properties synth failed for %s #%d",
object_type.name, idx, exc_info=True,
)
continue
if props is not None:
out[idx] = props
return out
@staticmethod
async def _best_effort(coro_fn, *, default):
"""Call ``coro_fn()`` and swallow non-transport errors, returning ``default``.

View File

@ -1,41 +0,0 @@
# Omni Programs side panel — frontend
Lit/TypeScript source for the HA side panel registered by
`websocket.py:async_register_side_panel`. The build output
(`../www/panel.js`) is committed so end-users don't need Node installed.
## Edit / rebuild
```bash
cd custom_components/omni_pca/frontend
npm install # one-time
npm run build # one-shot — drops a fresh ../www/panel.js
npm run watch # rebuild on change (use during HA dev)
```
The build script (`build.mjs`) bundles the entry point + Lit + all
imports into a single ESM file at `../www/panel.js`. Source maps are
inlined in `--watch` mode and stripped in production builds. Output is
~34 KB minified.
## Layout
| File | Purpose |
|---|---|
| `src/omni-panel-programs.ts` | The custom-element entry point. Defines `<omni-panel-programs>` (matching the panel_custom registration). |
| `src/token-renderer.ts` | Token stream → Lit `TemplateResult`. Each TokenKind gets distinctive styling; REF tokens become buttons that dispatch a click. |
| `src/types.ts` | TS interfaces mirroring the Phase-B websocket wire shapes. Short keys (`k`/`t`/`ek`/`ei`/`s`) match `websocket.py:_tokens_to_json`. |
## Wire contract
The panel calls three websocket commands (all defined in
`../websocket.py`):
* `omni_pca/programs/list` — paginated, filterable summaries.
* `omni_pca/programs/get` — full structured-English detail for one slot.
* `omni_pca/programs/fire` — sends `Command.EXECUTE_PROGRAM` over the wire.
The frontend doesn't subscribe to push events; live-state badges
refresh on a low-frequency poll (`REFRESH_MS = 5000`). That's a
deliberate scope choice — switching to per-entity event subscription
is a follow-up if the polling overhead becomes visible on huge installs.

View File

@ -1,44 +0,0 @@
// Bundle the omni_pca side panel into a single ESM file the HA static
// path serves at /api/omni_pca/panel.js.
//
// Usage:
// node build.mjs # one-shot production build
// node build.mjs --watch # rebuild on source change
//
// Output is intentionally placed at ../www/panel.js so the Python side
// (websocket.py:async_register_side_panel) finds it without extra
// configuration. The frontend dir + the Python integration sit in the
// same custom_components/omni_pca/ tree so end-users just install the
// integration; no separate HACS package needed.
import { build, context } from "esbuild";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const watch = process.argv.includes("--watch");
const opts = {
entryPoints: [resolve(__dirname, "src/omni-panel-programs.ts")],
bundle: true,
format: "esm",
target: "es2022",
minify: !watch,
sourcemap: watch ? "inline" : false,
outfile: resolve(__dirname, "../www/panel.js"),
// Lit ships its own ESM build; bundle it inline so the panel is a
// single self-contained file (matches how HACS-distributed cards work).
loader: { ".ts": "ts" },
banner: {
js: "// omni_pca side panel — generated by frontend/build.mjs. Edit src/, not this file.",
},
};
if (watch) {
const ctx = await context(opts);
await ctx.watch();
console.log("watching for changes…");
} else {
await build(opts);
console.log("built ->", opts.outfile);
}

View File

@ -1,551 +0,0 @@
{
"name": "omni-pca-panel",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omni-pca-panel",
"version": "1.0.0",
"dependencies": {
"lit": "^3.2.0"
},
"devDependencies": {
"esbuild": "^0.24.0",
"typescript": "^5.5.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
"integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz",
"integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/lit": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
"integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-element": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz",
"integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.5.0",
"@lit/reactive-element": "^2.1.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-html": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz",
"integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@ -1,18 +0,0 @@
{
"name": "omni-pca-panel",
"version": "1.0.0",
"description": "HA side panel for browsing HAI Omni Panel programs.",
"private": true,
"type": "module",
"scripts": {
"build": "node build.mjs",
"watch": "node build.mjs --watch"
},
"devDependencies": {
"esbuild": "^0.24.0",
"typescript": "^5.5.0"
},
"dependencies": {
"lit": "^3.2.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,52 +0,0 @@
// Token-stream → DOM. Each TokenKind gets distinctive styling so the
// structured-English programs read cleanly even at a glance.
//
// REF tokens are rendered as <button> nodes so they can dispatch a
// "ref-click" event for the parent component to act on (filter the
// list, jump to that entity's HA page, etc.).
import { html, TemplateResult } from "lit";
import { Token } from "./types.js";
export function renderTokens(
tokens: Token[],
onRefClick?: (kind: string, id: number) => void,
): TemplateResult {
return html`${tokens.map((t) => renderToken(t, onRefClick))}`;
}
function renderToken(
t: Token,
onRefClick?: (kind: string, id: number) => void,
): TemplateResult {
switch (t.k) {
case "newline":
return html`<br />`;
case "indent":
// Convert leading spaces to a CSS class so the panel can switch
// indent styling (e.g. left border) without re-rendering tokens.
return html`<span class="indent">${t.t}</span>`;
case "keyword":
return html`<span class="keyword">${t.t}</span>`;
case "operator":
return html`<span class="operator">${t.t}</span>`;
case "value":
return html`<span class="value">${t.t}</span>`;
case "ref": {
const handler = onRefClick && t.ek && typeof t.ei === "number"
? () => onRefClick(t.ek!, t.ei!)
: undefined;
return html`<button
type="button"
class="ref ref-${t.ek}"
title=${t.ek ?? ""}
@click=${handler}
>
<span class="ref-name">${t.t}</span>
${t.s ? html`<span class="ref-state">${t.s}</span>` : ""}
</button>`;
}
default:
return html`<span>${t.t}</span>`;
}
}

View File

@ -1,737 +0,0 @@
// TS mirrors of the Phase-B websocket wire shapes. Short field names
// match websocket.py's _tokens_to_json — keep these in sync if the
// Python side changes.
export interface Token {
/** "keyword" / "operator" / "ref" / "value" / "text" / "indent" / "newline" */
k: string;
/** Display text for this token. Empty for newline. */
t: string;
/** Object kind for REF tokens (zone / unit / area / thermostat / button / message / code / timeclock). */
ek?: string;
/** 1-based slot for REF tokens. */
ei?: number;
/** Live-state badge for REF tokens (e.g. "SECURE", "ON 60%"). */
s?: string;
}
export interface ProgramRow {
/** 1-based slot number. For chains, the head slot. */
slot: number;
/** "compact" or "chain". */
kind: string;
/** TIMED / EVENT / YEARLY / WHEN / AT / EVERY / REMARK / FREE. */
trigger_type: string;
/** One-line summary token stream. */
summary: Token[];
/** Flat ["unit:7", "zone:5", ...] for filter chips. */
references: string[];
condition_count: number;
action_count: number;
}
export interface ProgramListResponse {
programs: ProgramRow[];
total: number;
filtered_total: number;
offset: number;
limit: number;
}
export interface ProgramDetail {
slot: number;
kind: string;
trigger_type: string;
/** Full structured-English token stream. */
tokens: Token[];
references: string[];
/** For chain detail: every slot the chain spans. */
chain_slots?: number[];
/** Raw Program field values; included for compact-form programs so
* the editor can seed its form from real data rather than defaults. */
fields?: ProgramFields;
/** For chain detail: per-member role + raw fields. Drives the
* chain editor's row-per-slot rendering. */
chain_members?: Array<{
slot: number;
role: "head" | "condition" | "action";
fields: ProgramFields;
}>;
}
export interface ProgramListRequest {
type: "omni_pca/programs/list";
entry_id: string;
trigger_types?: string[];
references_entity?: string;
search?: string;
limit?: number;
offset?: number;
}
export interface ProgramGetRequest {
type: "omni_pca/programs/get";
entry_id: string;
slot: number;
}
export interface ProgramFireRequest {
type: "omni_pca/programs/fire";
entry_id: string;
slot: number;
}
// Raw Program dict — mirrors the dataclass on the Python side. Sent
// over the wire by ``omni_pca/programs/write``; the websocket validates
// each field's range and constructs the typed dataclass server-side.
export interface ProgramFields {
prog_type: number;
cond?: number;
cond2?: number;
cmd?: number;
par?: number;
pr2?: number;
month?: number;
day?: number;
days?: number;
hour?: number;
minute?: number;
remark_id?: number | null;
}
export interface ProgramWriteRequest {
type: "omni_pca/programs/write";
entry_id: string;
slot: number;
program: ProgramFields;
}
export interface NamedObject {
index: number;
name: string;
}
export interface ObjectListResponse {
zones: NamedObject[];
units: NamedObject[];
areas: NamedObject[];
thermostats: NamedObject[];
buttons: NamedObject[];
}
// Command enum values we let the user pick from the editor. Mirrors the
// most useful subset of omni_pca.commands.Command. The second element
// is what object kind (if any) the command's pr2 parameter references —
// drives the object picker's filter.
export interface CommandOption {
value: number;
label: string;
ref_kind: "unit" | "zone" | "area" | "button" | null;
}
export const COMMAND_OPTIONS: CommandOption[] = [
{ value: 0, label: "Turn OFF unit", ref_kind: "unit" },
{ value: 1, label: "Turn ON unit", ref_kind: "unit" },
{ value: 2, label: "All OFF", ref_kind: null },
{ value: 3, label: "All ON", ref_kind: null },
{ value: 4, label: "Bypass zone", ref_kind: "zone" },
{ value: 5, label: "Restore zone", ref_kind: "zone" },
{ value: 7, label: "Execute button", ref_kind: "button" },
{ value: 9, label: "Set unit level %", ref_kind: "unit" },
{ value: 48, label: "Disarm area", ref_kind: "area" },
{ value: 49, label: "Arm area Day", ref_kind: "area" },
{ value: 50, label: "Arm area Night", ref_kind: "area" },
{ value: 51, label: "Arm area Away", ref_kind: "area" },
{ value: 52, label: "Arm area Vacation", ref_kind: "area" },
];
export function commandOptionFor(value: number): CommandOption | undefined {
return COMMAND_OPTIONS.find((c) => c.value === value);
}
// Days bitmask bits (matches omni_pca.programs.Days). Bit 0 is unused.
export const DAY_BITS: ReadonlyArray<{ bit: number; label: string }> = [
{ bit: 0x02, label: "Mon" },
{ bit: 0x04, label: "Tue" },
{ bit: 0x08, label: "Wed" },
{ bit: 0x10, label: "Thu" },
{ bit: 0x20, label: "Fri" },
{ bit: 0x40, label: "Sat" },
{ bit: 0x80, label: "Sun" },
];
// Program type constants (matches omni_pca.programs.ProgramType).
export const PROGRAM_TYPE_TIMED = 1;
export const PROGRAM_TYPE_EVENT = 2;
export const PROGRAM_TYPE_YEARLY = 3;
export const PROGRAM_TYPE_REMARK = 4;
// --------------------------------------------------------------------------
// Event-ID encode/decode for the EVENT-program editor.
//
// Mirrors the Python helpers in omni_pca.program_engine — the 16-bit
// event_id uses different bit patterns per category. Each "category"
// in the UI maps to a different chunk of the ID space.
// --------------------------------------------------------------------------
export type EventCategory =
| "button" // USER_MACRO_BUTTON (evt & 0xFF00) == 0x0000
| "zone" // ZONE_STATE_CHANGE (evt & 0xFC00) == 0x0400
| "unit" // UNIT_STATE_CHANGE (evt & 0xFC00) == 0x0800
| "fixed" // hard-coded IDs (phone / AC power)
| "raw"; // anything else — show numeric
export interface DecodedEvent {
category: EventCategory;
/** For "button": 1..255 */
button?: number;
/** For "zone": 1..256, plus state 0=secure / 1=not-ready / 2=trouble / 3=tamper */
zone?: number;
zoneState?: number;
/** For "unit": 1..511 plus on bool */
unit?: number;
unitOn?: boolean;
/** For "fixed": the literal event ID. */
fixedId?: number;
/** For "raw": the literal event ID we couldn't classify. */
raw?: number;
}
// Hand-rolled fixed IDs and labels (matches Python EVENT_* constants).
export const FIXED_EVENTS: ReadonlyArray<{ id: number; label: string }> = [
{ id: 768, label: "Phone line dead" },
{ id: 769, label: "Phone ringing" },
{ id: 770, label: "Phone off hook" },
{ id: 771, label: "Phone on hook" },
{ id: 772, label: "AC power lost" },
{ id: 773, label: "AC power restored" },
];
const ZONE_STATE_LABELS = ["secure", "not ready", "trouble", "tamper"];
export function decodeEventId(eventId: number): DecodedEvent {
// FIXED first — the bit patterns below would otherwise collapse
// 768..773 into the "zone state change" category since their top
// bits look the same.
if (FIXED_EVENTS.some((f) => f.id === eventId)) {
return { category: "fixed", fixedId: eventId };
}
if ((eventId & 0xFF00) === 0x0000) {
return { category: "button", button: eventId & 0xFF };
}
if ((eventId & 0xFC00) === 0x0400) {
const zs = eventId & 0x03FF;
return {
category: "zone",
zone: Math.floor(zs / 4) + 1,
zoneState: zs % 4,
};
}
if ((eventId & 0xFC00) === 0x0800) {
const us = eventId & 0x03FF;
return {
category: "unit",
unit: Math.floor(us / 2) + 1,
unitOn: (us & 1) === 1,
};
}
return { category: "raw", raw: eventId };
}
export function encodeEventId(ev: DecodedEvent): number {
switch (ev.category) {
case "button":
return (ev.button ?? 1) & 0xFF;
case "zone": {
const zone = (ev.zone ?? 1) - 1;
const state = (ev.zoneState ?? 0) & 0x03;
return 0x0400 | ((zone * 4 + state) & 0x03FF);
}
case "unit": {
const unit = (ev.unit ?? 1) - 1;
const on = ev.unitOn ? 1 : 0;
return 0x0800 | ((unit * 2 + on) & 0x03FF);
}
case "fixed":
return ev.fixedId ?? 768;
case "raw":
default:
return ev.raw ?? 0;
}
}
export function eventIdFromFields(fields: ProgramFields): number {
return ((fields.month ?? 0) << 8) | (fields.day ?? 0);
}
export function packEventIdIntoFields(
fields: ProgramFields, eventId: number,
): ProgramFields {
return {
...fields,
month: (eventId >> 8) & 0xFF,
day: eventId & 0xFF,
};
}
export function zoneStateLabel(state: number): string {
return ZONE_STATE_LABELS[state] ?? `state ${state}`;
}
// Month abbreviations for the YEARLY editor.
export const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
// --------------------------------------------------------------------------
// Compact-form AND-IF condition encode/decode for the inline-conditions
// editor (TIMED/EVENT/YEARLY cond + cond2 fields).
//
// Mirrors clsText.GetConditionalText (clsText.cs:2224-2274) and the
// Python _emit_traditional_cond in program_renderer.py. Bit layout:
//
// family = (cond >> 8) & 0xFC
// selector bit = (cond & 0x0200) — meaning depends on family
//
// family 0x00 OTHER — cond & 0x0F = enuMiscConditional (NONE=0,
// NEVER=1, LIGHT=2, DARK=3, ...)
// family 0x04 ZONE — low 8 bits = zone index; selector bit
// 0=secure, 1=not ready
// family 0x08 CTRL — low 9 bits = unit index; selector bit
// 0=OFF, 1=ON
// family 0x0C TIME — low 8 bits = time-clock index; selector bit
// 0=disabled, 1=enabled
// family >= 0x10 SEC — (cond >> 8) & 0x0F = area, (cond >> 12) & 0x07 = mode
//
// cond == 0 means "no condition" (NONE).
// --------------------------------------------------------------------------
export type CondFamily =
| "none" // cond = 0 — no inline condition
| "misc" // OTHER family (NEVER, LIGHT, DARK, PHONE_*, AC_POWER_*, …)
| "zone" // ZONE family — zone + secure/not-ready
| "unit" // CTRL family — unit + on/off
| "time" // TIME family — time-clock + enabled/disabled
| "sec"; // SEC family — area + security mode
export interface DecodedCondition {
family: CondFamily;
/** misc-conditional index (0..15) — used when family == "misc". */
misc?: number;
/** Zone / unit / time-clock / area index — used by the named families. */
index?: number;
/** Selector bit: zone "not ready", unit "on", time-clock "enabled". */
active?: boolean;
/** SEC family security mode (0..7). */
mode?: number;
}
// MiscConditional enum (matches omni_pca.programs.MiscConditional).
// Each entry: { value, label }. NONE renders as "always" and NEVER as
// "never" — both common authoring patterns.
export const MISC_CONDITIONALS: ReadonlyArray<{ value: number; label: string }> = [
{ value: 0, label: "always" },
{ value: 1, label: "never" },
{ value: 2, label: "it is light outside" },
{ value: 3, label: "it is dark outside" },
{ value: 4, label: "phone line is dead" },
{ value: 5, label: "phone is ringing" },
{ value: 6, label: "phone is off hook" },
{ value: 7, label: "phone is on hook" },
{ value: 8, label: "AC power is off" },
{ value: 9, label: "AC power is on" },
{ value: 10, label: "battery is low" },
{ value: 11, label: "battery is OK" },
{ value: 12, label: "energy cost is low" },
{ value: 13, label: "energy cost is mid" },
{ value: 14, label: "energy cost is high" },
{ value: 15, label: "energy cost is critical" },
];
// Security modes for the SEC family (matches enuSecurityMode order).
export const SECURITY_MODE_NAMES: ReadonlyArray<{ value: number; label: string }> = [
{ value: 0, label: "Off (disarmed)" },
{ value: 1, label: "Day" },
{ value: 2, label: "Night" },
{ value: 3, label: "Away" },
{ value: 4, label: "Vacation" },
{ value: 5, label: "Day Instant" },
{ value: 6, label: "Night Delayed" },
];
export function decodeCondition(cond: number): DecodedCondition {
if (cond === 0) return { family: "none" };
const family = (cond >> 8) & 0xFC;
const active = (cond & 0x0200) !== 0;
if (family === 0x00) {
return { family: "misc", misc: cond & 0x0F };
}
if (family === 0x04) {
return { family: "zone", index: cond & 0xFF, active };
}
if (family === 0x08) {
return { family: "unit", index: cond & 0x01FF, active };
}
if (family === 0x0C) {
return { family: "time", index: cond & 0xFF, active };
}
// SEC family (family >= 0x10): area in high nibble of upper byte,
// mode in top nibble.
return {
family: "sec",
index: (cond >> 8) & 0x0F,
mode: (cond >> 12) & 0x07,
};
}
export function encodeCondition(c: DecodedCondition): number {
switch (c.family) {
case "none":
return 0;
case "misc":
return (c.misc ?? 0) & 0x0F; // family 0x00, low nibble = misc
case "zone": {
const idx = (c.index ?? 0) & 0xFF;
return 0x0400 | (c.active ? 0x0200 : 0) | idx;
}
case "unit": {
const idx = (c.index ?? 0) & 0x01FF;
return 0x0800 | (c.active ? 0x0200 : 0) | idx;
}
case "time": {
const idx = (c.index ?? 0) & 0xFF;
return 0x0C00 | (c.active ? 0x0200 : 0) | idx;
}
case "sec": {
const area = (c.index ?? 1) & 0x0F;
const mode = (c.mode ?? 0) & 0x07;
return (mode << 12) | (area << 8);
}
}
}
// --------------------------------------------------------------------------
// Clausal chain (multi-record) editor types
// --------------------------------------------------------------------------
/** ProgramType values for the chain head/body/tail records. */
export const PROGRAM_TYPE_WHEN = 5;
export const PROGRAM_TYPE_AT = 6;
export const PROGRAM_TYPE_EVERY = 7;
export const PROGRAM_TYPE_AND = 8;
export const PROGRAM_TYPE_OR = 9;
export const PROGRAM_TYPE_THEN = 10;
/** Roles assigned by the backend's chain_members payload. */
export type ChainMemberRole = "head" | "condition" | "action";
export interface ChainMember {
slot: number;
role: ChainMemberRole;
fields: ProgramFields;
}
/** Decoded view of a Traditional AND/OR record's condition.
*
* AND records use the SAME family encoding as compact-form cond, but
* the bytes land in different ProgramFields slots:
*
* family = fields.cond & 0xFF (disk byte 1)
* instance = (fields.cond2 >> 8) & 0xFF (disk byte 3)
*
* The selector bit (`0x0200`) doesn't apply to AND records the same
* way instead the family byte's bit 1 (0x02) carries the
* secure/not-ready or off/on selector. For example:
* 0x04 = ZONE secure 0x06 = ZONE not-ready
* 0x08 = CTRL off 0x0A = CTRL on
* 0x0C = TIME disabled 0x0E = TIME enabled
*/
export function decodeAndCondition(fields: ProgramFields): DecodedCondition {
const family = (fields.cond ?? 0) & 0xFF;
const instance = ((fields.cond2 ?? 0) >> 8) & 0xFF;
const familyMajor = family & 0xFC;
const selector = (family & 0x02) !== 0;
if (family === 0 && instance === 0) return { family: "none" };
if (familyMajor === 0x00) return { family: "misc", misc: family & 0x0F };
if (familyMajor === 0x04) return { family: "zone", index: instance, active: selector };
if (familyMajor === 0x08) return { family: "unit", index: instance, active: selector };
if (familyMajor === 0x0C) return { family: "time", index: instance, active: selector };
// SEC: high nibble of family = mode, low nibble = area.
return {
family: "sec",
index: family & 0x0F,
mode: (family >> 4) & 0x07,
};
}
/** Re-encode a DecodedCondition into the cond/cond2 fields of an
* AND/OR record. Returns a partial ProgramFields with cond + cond2
* set; the caller should merge with the rest of the record (cmd/par/
* etc. stay zero for Traditional AND records).
*/
export function encodeAndCondition(c: DecodedCondition): {
cond: number; cond2: number;
} {
switch (c.family) {
case "none":
return { cond: 0, cond2: 0 };
case "misc":
return { cond: (c.misc ?? 0) & 0x0F, cond2: 0 };
case "zone": {
const family = 0x04 | (c.active ? 0x02 : 0);
return { cond: family, cond2: ((c.index ?? 0) & 0xFF) << 8 };
}
case "unit": {
const family = 0x08 | (c.active ? 0x02 : 0);
return { cond: family, cond2: ((c.index ?? 0) & 0xFF) << 8 };
}
case "time": {
const family = 0x0C | (c.active ? 0x02 : 0);
return { cond: family, cond2: ((c.index ?? 0) & 0xFF) << 8 };
}
case "sec": {
const area = (c.index ?? 1) & 0x0F;
const mode = (c.mode ?? 0) & 0x07;
const family = (mode << 4) | area;
return { cond: family, cond2: 0 };
}
}
}
/** True if the AND/OR record's op byte indicates a Structured-OP
* comparison (TEMP > 70 etc.) rather than the Traditional bit-packed
* condition. Structured records use entirely different field
* semantics; the editor in this pass renders them read-only.
*
* OP byte lives at fields.cond >> 8 (disk byte 2). 0 = Traditional;
* 1..9 = Structured (CondOP enum).
*/
export function isStructuredAnd(fields: ProgramFields): boolean {
return (((fields.cond ?? 0) >> 8) & 0xFF) !== 0;
}
/** Build a fresh empty AND record (Traditional, NEVER condition). */
export function emptyAndRecord(): ProgramFields {
return {
prog_type: PROGRAM_TYPE_AND,
cond: 0x01, // family OTHER (0x00) + misc NEVER (0x01)
cond2: 0, cmd: 0, par: 0, pr2: 0,
month: 0, day: 0, days: 0, hour: 0, minute: 0,
};
}
/** Build a fresh empty OR record. Same shape as AND with a different
* prog_type semantically starts a new group in the conditions list.
*/
export function emptyOrRecord(): ProgramFields {
return { ...emptyAndRecord(), prog_type: PROGRAM_TYPE_OR };
}
/** Build a fresh empty THEN action record (Turn OFF unit 1). */
export function emptyThenRecord(firstUnit: number = 1): ProgramFields {
return {
prog_type: PROGRAM_TYPE_THEN,
cmd: 0, // UNIT_OFF
par: 0,
pr2: firstUnit,
cond: 0, cond2: 0,
month: 0, day: 0, days: 0, hour: 0, minute: 0,
};
}
// --------------------------------------------------------------------------
// Structured-OP AND record editing.
//
// When ``and_op`` (= ``(cond >> 8) & 0xFF``) is non-zero, the record
// encodes ``Arg1 OP Arg2`` where Arg1 and Arg2 are typed references
// (Zone, Unit, Thermostat, Area, TimeDate, Constant) plus per-type
// field selectors. This is fundamentally a different shape from the
// Traditional encoding handled by decodeAndCondition above.
//
// Wire layout (from programs.py decoders + clsProgram.cs):
//
// cond high byte (>>8) = and_op (CondOP)
// cond low byte (& FF) = and_arg1_argtype (CondArgType)
// cond2 (whole u16) = and_arg1_ix (object index or 0)
// cmd = and_arg1_field (per-type field selector)
// par = and_arg2_argtype (CondArgType — usually Constant)
// pr2 = and_arg2_ix (constant value OR second object idx)
// month = and_arg2_field (per-type field selector for arg2)
// day, days = and_compconst (BE u16 — extra constant, rarely used)
//
// Editor cuts:
// * Arg1 and Arg2 both restricted to Constant / Zone / Unit /
// Thermostat / Area / TimeDate. Anything else (Aux / Audio /
// System / etc.) stays read-only.
// * Non-zero CompConst stays read-only (rarely used; preserved on
// save).
// --------------------------------------------------------------------------
// CondOP enum (matches omni_pca.programs.CondOP). 0=Traditional is
// excluded from the editor — picking it would switch to Traditional
// editing semantics.
export const COND_OPS: ReadonlyArray<{ value: number; label: string }> = [
{ value: 1, label: "==" },
{ value: 2, label: "!=" },
{ value: 3, label: "<" },
{ value: 4, label: ">" },
{ value: 5, label: "is odd" },
{ value: 6, label: "is even" },
{ value: 7, label: "is multiple of" },
{ value: 8, label: "in (bitmask)" },
{ value: 9, label: "not in (bitmask)" },
];
/** True iff the operator only uses Arg1 (no Arg2). */
export function isUnaryOp(op: number): boolean {
return op === 5 || op === 6; // ODD, EVEN
}
// CondArgType enum (matches omni_pca.programs.CondArgType). Only the
// editor-supported subset; full list is in programs.py.
export const ARG_TYPES: ReadonlyArray<{
value: number; label: string; kind: string | null;
}> = [
{ value: 0, label: "Constant", kind: null },
{ value: 2, label: "Zone", kind: "zone" },
{ value: 3, label: "Unit", kind: "unit" },
{ value: 4, label: "Thermostat", kind: "thermostat" },
{ value: 6, label: "Area", kind: "area" },
{ value: 7, label: "Time / Date", kind: null }, // no object picker
];
export function isEditableArg1Type(argtype: number): boolean {
return [2, 3, 4, 6, 7].includes(argtype);
}
export function argTypeKind(argtype: number): string | null {
const a = ARG_TYPES.find((x) => x.value === argtype);
return a ? a.kind : null;
}
// Per-Arg1Type field menus. Numbers match omni_pca.programs enums
// (enuZoneField / enuUnitField / enuThermostatField / enuTimeDateField).
export const FIELDS_BY_TYPE: Readonly<Record<number, ReadonlyArray<{
value: number; label: string;
}>>> = {
// Zone (argtype 2) — enuZoneField
2: [
{ value: 1, label: "Loop reading" },
{ value: 2, label: "Current state" },
{ value: 3, label: "Arming state" },
{ value: 4, label: "Alarm state" },
],
// Unit (argtype 3) — enuUnitField
3: [
{ value: 1, label: "Current state" },
{ value: 2, label: "Previous state" },
{ value: 3, label: "Timer" },
{ value: 4, label: "Level" },
],
// Thermostat (argtype 4) — enuThermostatField
4: [
{ value: 1, label: "Current temperature" },
{ value: 2, label: "Heat setpoint" },
{ value: 3, label: "Cool setpoint" },
{ value: 4, label: "System mode" },
{ value: 5, label: "Fan mode" },
{ value: 6, label: "Hold mode" },
{ value: 7, label: "Freeze alarm" },
{ value: 8, label: "Comm error" },
{ value: 9, label: "Humidity" },
{ value: 10, label: "Humidify setpoint" },
{ value: 11, label: "Dehumidify setpoint" },
{ value: 12, label: "Outdoor temperature" },
{ value: 13, label: "System status" },
],
// Area (argtype 6) — single useful field
6: [
{ value: 1, label: "Security mode" },
],
// TimeDate (argtype 7) — enuTimeDateField
7: [
{ value: 2, label: "Year" },
{ value: 3, label: "Month" },
{ value: 4, label: "Day" },
{ value: 5, label: "Day of week (1=Mon..7=Sun)" },
{ value: 6, label: "Time (minutes since midnight)" },
{ value: 8, label: "Hour" },
{ value: 9, label: "Minute" },
],
};
export interface DecodedStructuredAnd {
op: number; // CondOP value (1..9)
arg1Type: number; // CondArgType
arg1Ix: number; // 1-based object index (0 for TimeDate)
arg1Field: number; // per-type field
arg2Type: number; // CondArgType (locked to Constant in editor)
arg2Ix: number; // constant value OR second object index
arg2Field: number; // per-type field (usually 0 for constants)
compConst: number; // extra constant; preserved verbatim
}
export function decodeStructuredAnd(fields: ProgramFields): DecodedStructuredAnd {
return {
op: ((fields.cond ?? 0) >> 8) & 0xFF,
arg1Type: (fields.cond ?? 0) & 0xFF,
arg1Ix: fields.cond2 ?? 0,
arg1Field: fields.cmd ?? 0,
arg2Type: fields.par ?? 0,
arg2Ix: fields.pr2 ?? 0,
arg2Field: fields.month ?? 0,
compConst: ((fields.day ?? 0) << 8) | (fields.days ?? 0),
};
}
export function encodeStructuredAnd(s: DecodedStructuredAnd): Partial<ProgramFields> {
return {
cond: ((s.op & 0xFF) << 8) | (s.arg1Type & 0xFF),
cond2: s.arg1Ix & 0xFFFF,
cmd: s.arg1Field & 0xFF,
par: s.arg2Type & 0xFF,
pr2: s.arg2Ix & 0xFFFF,
month: s.arg2Field & 0xFF,
day: (s.compConst >> 8) & 0xFF,
days: s.compConst & 0xFF,
};
}
/** True iff the structured AND record is in a shape the editor can
* fully drive. Arg1 must be one of the editable reference types;
* Arg2 must be Constant or one of the editable reference types
* (unary operators ignore Arg2 entirely). Non-zero compConst stays
* read-only preserved on save but not exposed as a form control. */
export function isEditableStructuredAnd(s: DecodedStructuredAnd): boolean {
if (!isEditableArg1Type(s.arg1Type)) return false;
if (!isUnaryOp(s.op) && s.arg2Type !== 0 && !isEditableArg1Type(s.arg2Type)) {
return false;
}
if (s.compConst !== 0) return false;
return true;
}
/** HA's hass object — minimal surface we use. */
export interface Hass {
connection: {
sendMessagePromise<T>(msg: unknown): Promise<T>;
subscribeEvents<T>(
callback: (event: T) => void,
eventType: string,
): Promise<() => Promise<void>>;
};
config?: {
entries?: Record<string, unknown>;
};
// Whole hass is much larger; we only touch what we need.
}

View File

@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"allowImportingTsExtensions": false,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts"]
}

View File

@ -1,13 +1,13 @@
{
"domain": "omni_pca",
"name": "HAI/Leviton Omni Panel",
"codeowners": ["@rsp2k"],
"config_flow": true,
"dependencies": ["http", "websocket_api"],
"documentation": "https://hai-omni-pro-ii.warehack.ing/",
"integration_type": "hub",
"version": "2026.5.10",
"iot_class": "local_push",
"issue_tracker": "https://github.com/rsp2k/omni-pca/issues",
"requirements": ["omni-pca==2026.5.16"],
"version": "2026.5.16"
"config_flow": true,
"dependencies": [],
"codeowners": ["@rsp2k"],
"requirements": ["omni-pca==2026.5.10"],
"documentation": "https://git.supported.systems/warehack.ing/omni-pca",
"issue_tracker": "https://git.supported.systems/warehack.ing/omni-pca/issues",
"integration_type": "hub"
}

View File

@ -69,7 +69,6 @@ async def async_setup_entry(
entities.append(OmniSystemModelSensor(coordinator))
entities.append(OmniLastEventSensor(coordinator))
entities.append(OmniProgramsSensor(coordinator))
async_add_entities(entities)
@ -262,55 +261,3 @@ class OmniLastEventSensor(
if hasattr(ev, key):
result[key] = getattr(ev, key)
return result
class OmniProgramsSensor(
CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity
):
"""Diagnostic sensor exposing the panel's automation programs.
State value is the count of defined programs. The ``programs``
attribute carries a list of per-program summaries a stable,
JSON-serializable view automations and template sensors can read.
"""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-programs"
self._attr_name = "Panel Programs"
self._attr_device_info = coordinator.device_info
@property
def native_value(self) -> int:
return len(self.coordinator.data.programs)
@property
def extra_state_attributes(self) -> dict[str, Any]:
from omni_pca.programs import ProgramType
summaries: list[dict[str, Any]] = []
for slot in sorted(self.coordinator.data.programs):
p = self.coordinator.data.programs[slot]
try:
type_name = ProgramType(p.prog_type).name
except ValueError:
type_name = f"UNKNOWN({p.prog_type})"
summaries.append(
{
"slot": slot,
"type": type_name,
"cmd": p.cmd,
"par": p.par,
"pr2": p.pr2,
"month": p.month,
"day": p.day,
"days": p.days,
"hour": p.hour,
"minute": p.minute,
}
)
return {"programs": summaries}

View File

@ -3,14 +3,11 @@
"step": {
"user": {
"title": "Connect to your Omni panel",
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. Run omni-pca decode-pca with the --field controller_key option on a PC Access export file to extract the key. Optionally provide a path to a saved .pca file to load panel programs from disk instead of streaming them from the controller.",
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. Use `omni-pca decode-pca <file>.pca --field controller_key` to extract the key from a PC Access export.",
"data": {
"host": "Host",
"port": "Port",
"controller_key": "Controller Key (32 hex chars)",
"transport": "Transport",
"pca_path": ".pca file path (optional)",
"pca_key": ".pca per-install key (integer; 0 if plain-text)"
"controller_key": "Controller Key (32 hex chars)"
}
},
"reauth_confirm": {
@ -25,8 +22,6 @@
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
"invalid_auth": "The Controller Key was rejected by the panel.",
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
"pca_not_found": "No file at the supplied .pca path.",
"pca_decode_failed": "Could not decode the .pca file. Check the per-install key.",
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
},
"abort": {

View File

@ -3,14 +3,11 @@
"step": {
"user": {
"title": "Connect to your Omni panel",
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. Run omni-pca decode-pca with the --field controller_key option on a PC Access export file to extract the key. Optionally provide a path to a saved .pca file to load panel programs from disk instead of streaming them from the controller.",
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. Use `omni-pca decode-pca <file>.pca --field controller_key` to extract the key from a PC Access export.",
"data": {
"host": "Host",
"port": "Port",
"controller_key": "Controller Key (32 hex chars)",
"transport": "Transport",
"pca_path": ".pca file path (optional)",
"pca_key": ".pca per-install key (integer; 0 if plain-text)"
"controller_key": "Controller Key (32 hex chars)"
}
},
"reauth_confirm": {
@ -25,8 +22,6 @@
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
"invalid_auth": "The Controller Key was rejected by the panel.",
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
"pca_not_found": "No file at the supplied .pca path.",
"pca_decode_failed": "Could not decode the .pca file. Check the per-install key.",
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
},
"abort": {

View File

@ -1,966 +0,0 @@
"""HA websocket commands for the program viewer.
The frontend talks to the integration via Home Assistant's standard
websocket API. We register three commands here, all namespaced under
``omni_pca/programs/``:
* ``omni_pca/programs/list`` paginated, filterable summary list. Each
result row carries the token stream for the one-line summary plus the
metadata the frontend needs to filter and drill in.
* ``omni_pca/programs/get`` full detail for a single slot. Returns
the structured-English token stream for the compact form or the
full clausal chain.
* ``omni_pca/programs/fire`` send ``Command.EXECUTE_PROGRAM`` over
the wire to ask the panel to run a program now. Returns success/error.
All commands take an ``entry_id`` so multi-panel installs can address
the right coordinator. The frontend's panel UI uses HA's `<ha-conn>`
WS client; this module just produces JSON-safe dicts.
"""
from __future__ import annotations
from dataclasses import asdict
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from omni_pca.commands import Command
from omni_pca.program_engine import ClausalChain, build_chains
from omni_pca.program_renderer import (
NameResolver,
ProgramRenderer,
StateResolver,
Token,
)
from omni_pca.programs import Program, ProgramType
from .const import DOMAIN
if TYPE_CHECKING:
from .coordinator import OmniDataUpdateCoordinator
# --------------------------------------------------------------------------
# Coordinator-backed resolvers
# --------------------------------------------------------------------------
class _CoordinatorNameResolver:
"""Resolve object names from coordinator-discovered topology.
The coordinator's :class:`OmniData` stores ``zones`` / ``units`` /
``areas`` / ``thermostats`` / ``buttons`` as dicts of typed
properties dataclasses (each with a ``name`` attribute). For
``message`` / ``code`` / ``timeclock`` we don't track HA-side
properties fall through to ``None`` so the renderer generates
``"Message 5"``-style labels.
"""
def __init__(self, coordinator: "OmniDataUpdateCoordinator") -> None:
self._coordinator = coordinator
def name_of(self, kind: str, index: int) -> str | None:
data = self._coordinator.data
if data is None:
return None
bucket = {
"zone": data.zones,
"unit": data.units,
"area": data.areas,
"thermostat": data.thermostats,
"button": data.buttons,
}.get(kind)
if bucket is None:
return None
props = bucket.get(index)
if props is None:
return None
return getattr(props, "name", None) or None
class _CoordinatorStateResolver:
"""Live-state overlay using the coordinator's *_status maps.
The status dicts update on every poll *and* are patched in-place
when the event listener decodes a push event, so each websocket
call sees the freshest available state without a round-trip to
the panel.
"""
_AREA_MODES: dict[int, str] = {
0: "Off", 1: "Day", 2: "Night", 3: "Away",
4: "Vacation", 5: "Day Instant", 6: "Night Delayed",
}
def __init__(self, coordinator: "OmniDataUpdateCoordinator") -> None:
self._coordinator = coordinator
def state_of(self, kind: str, index: int) -> str | None:
data = self._coordinator.data
if data is None:
return None
if kind == "zone":
status = data.zone_status.get(index)
if status is None:
return None
if status.is_bypassed:
return "BYPASSED"
return {0: "SECURE", 1: "NOT READY", 2: "TROUBLE", 3: "TAMPER"}.get(
status.current_state, f"state {status.current_state}",
)
if kind == "unit":
status = data.unit_status.get(index)
if status is None:
return None
if status.state == 0:
return "OFF"
if status.state >= 100:
return f"ON {status.state - 100}%"
return "ON"
if kind == "area":
status = data.area_status.get(index)
if status is None:
return None
return self._AREA_MODES.get(status.mode, f"mode {status.mode}")
if kind == "thermostat":
status = data.thermostat_status.get(index)
if status is None or status.temperature_raw == 0:
return None
return f"{status.temperature_raw // 2 - 40}°F"
return None
# --------------------------------------------------------------------------
# Token serialisation + reference extraction
# --------------------------------------------------------------------------
def _tokens_to_json(tokens: list[Token]) -> list[dict[str, Any]]:
"""Serialise a Token list to plain dicts the websocket layer can JSON.
``dataclasses.asdict`` would also work but produces ``None`` keys for
fields irrelevant to the token's kind; we omit those explicitly so
the wire format stays compact and the frontend sees clean shapes.
"""
out: list[dict[str, Any]] = []
for t in tokens:
d: dict[str, Any] = {"k": t.kind, "t": t.text}
if t.entity_kind is not None:
d["ek"] = t.entity_kind
if t.entity_id is not None:
d["ei"] = t.entity_id
if t.state is not None:
d["s"] = t.state
out.append(d)
return out
def _extract_references(tokens: list[Token]) -> list[str]:
"""Collect distinct ``"<kind>:<id>"`` references from a token stream.
Used to populate each list-row's ``references`` field so the
frontend can filter on "involves this entity" without re-parsing
the tokens. Returns a deduplicated, stable-ordered list.
"""
seen: dict[str, None] = {}
for t in tokens:
if t.entity_kind and t.entity_id is not None:
seen[f"{t.entity_kind}:{t.entity_id}"] = None
return list(seen.keys())
# --------------------------------------------------------------------------
# Helper: pick a coordinator + build the renderer
# --------------------------------------------------------------------------
def _coordinator_for_entry(
hass: HomeAssistant, entry_id: str,
) -> "OmniDataUpdateCoordinator | None":
return hass.data.get(DOMAIN, {}).get(entry_id)
def _build_renderer(coordinator: "OmniDataUpdateCoordinator") -> ProgramRenderer:
return ProgramRenderer(
names=_CoordinatorNameResolver(coordinator),
state=_CoordinatorStateResolver(coordinator),
)
def _classify_trigger(p: Program) -> str:
"""Stable string label for the trigger type — used in filter chips."""
try:
return ProgramType(p.prog_type).name
except ValueError:
return f"UNKNOWN_{p.prog_type}"
# --------------------------------------------------------------------------
# Websocket commands
# --------------------------------------------------------------------------
@websocket_api.websocket_command(
{
vol.Required("type"): "omni_pca/programs/list",
vol.Required("entry_id"): str,
vol.Optional("trigger_types"): [str],
vol.Optional("references_entity"): str, # e.g. "zone:5"
vol.Optional("search"): str,
vol.Optional("limit"): vol.All(int, vol.Range(min=1, max=1500)),
vol.Optional("offset"): vol.All(int, vol.Range(min=0)),
}
)
@websocket_api.async_response
async def _ws_list_programs(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Paginated list of programs with filters applied.
Returns rows containing the one-line summary tokens plus the
metadata needed for filter UI (trigger type, references). The
frontend renders the summary inline; clicking a row triggers a
follow-up ``programs/get`` for the full detail.
"""
coordinator = _coordinator_for_entry(hass, msg["entry_id"])
if coordinator is None:
connection.send_error(msg["id"], "not_found", "panel not configured")
return
renderer = _build_renderer(coordinator)
programs = coordinator.data.programs if coordinator.data else {}
# Clausal chains span multiple slots — group them so each chain
# appears once instead of one row per slot.
chains_by_head_slot = {
c.head.slot: c for c in build_chains(tuple(programs.values()))
}
consumed_chain_slots: set[int] = set()
for chain in chains_by_head_slot.values():
if chain.head.slot is not None:
consumed_chain_slots.add(chain.head.slot)
for cond in chain.conditions:
if cond.slot is not None:
consumed_chain_slots.add(cond.slot)
for action in chain.actions:
if action.slot is not None:
consumed_chain_slots.add(action.slot)
rows: list[dict[str, Any]] = []
for slot in sorted(programs):
if slot in chains_by_head_slot:
chain = chains_by_head_slot[slot]
summary = renderer.summarize_chain(chain)
rows.append({
"slot": slot,
"kind": "chain",
"trigger_type": _classify_trigger(chain.head),
"summary": _tokens_to_json(summary),
"references": _extract_references(summary),
"condition_count": len(chain.conditions),
"action_count": len(chain.actions),
})
continue
if slot in consumed_chain_slots:
continue # part of a chain we already rendered
program = programs[slot]
summary = renderer.summarize_program(program)
rows.append({
"slot": slot,
"kind": "compact",
"trigger_type": _classify_trigger(program),
"summary": _tokens_to_json(summary),
"references": _extract_references(summary),
"condition_count": (1 if program.cond else 0) + (1 if program.cond2 else 0),
"action_count": 1,
})
# Filtering happens after rendering so the filter UI can use the
# final, name-resolved text. Trade-off: O(N) per request even when
# filters narrow the result; with N ≤ 1500 this is fine in practice.
trigger_types: list[str] | None = msg.get("trigger_types")
references_entity: str | None = msg.get("references_entity")
search: str | None = msg.get("search")
if search:
search_lower = search.lower()
filtered = []
for row in rows:
if trigger_types and row["trigger_type"] not in trigger_types:
continue
if references_entity and references_entity not in row["references"]:
continue
if search:
row_text = "".join(
tok["t"] for tok in row["summary"] if tok.get("k") != "newline"
).lower()
if search_lower not in row_text:
continue
filtered.append(row)
total = len(rows)
filtered_total = len(filtered)
offset = msg.get("offset", 0)
limit = msg.get("limit", 200)
page = filtered[offset : offset + limit]
connection.send_result(msg["id"], {
"programs": page,
"total": total,
"filtered_total": filtered_total,
"offset": offset,
"limit": limit,
})
@websocket_api.websocket_command(
{
vol.Required("type"): "omni_pca/programs/get",
vol.Required("entry_id"): str,
vol.Required("slot"): vol.All(int, vol.Range(min=1, max=1500)),
}
)
@websocket_api.async_response
async def _ws_get_program(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Full structured-English detail for one slot.
If the requested slot is the head of a clausal chain we return the
rendered chain; if it's a continuation slot (an AND/OR/THEN in the
middle of a chain) we still return the chain that contains it, so
the frontend always shows the complete program even when the user
clicks an interior slot.
"""
coordinator = _coordinator_for_entry(hass, msg["entry_id"])
if coordinator is None:
connection.send_error(msg["id"], "not_found", "panel not configured")
return
renderer = _build_renderer(coordinator)
programs = coordinator.data.programs if coordinator.data else {}
target = programs.get(msg["slot"])
if target is None:
connection.send_error(msg["id"], "not_found", "no program at that slot")
return
chains = build_chains(tuple(programs.values()))
containing_chain: ClausalChain | None = None
for chain in chains:
members = (
(chain.head,) + chain.conditions + chain.actions
)
if any(m.slot == msg["slot"] for m in members):
containing_chain = chain
break
if containing_chain is not None:
tokens = renderer.render_chain(containing_chain)
connection.send_result(msg["id"], {
"slot": containing_chain.head.slot,
"kind": "chain",
"trigger_type": _classify_trigger(containing_chain.head),
"tokens": _tokens_to_json(tokens),
"references": _extract_references(tokens),
"chain_slots": [m.slot for m in members if m.slot is not None],
# Per-member raw fields + role so the editor can render
# an editable form for each line of the clausal chain.
# role is "head" / "condition" / "action".
"chain_members": [
{
"slot": m.slot,
"role": (
"head" if m is containing_chain.head
else "action" if m in containing_chain.actions
else "condition"
),
"fields": _program_to_fields(m),
}
for m in members if m.slot is not None
],
})
return
tokens = renderer.render_program(target)
connection.send_result(msg["id"], {
"slot": msg["slot"],
"kind": "compact",
"trigger_type": _classify_trigger(target),
"tokens": _tokens_to_json(tokens),
"references": _extract_references(tokens),
# Raw program fields for the editor to seed its form. The
# rendered token stream is for *display*; the form needs the
# underlying integer values to round-trip cleanly.
"fields": _program_to_fields(target),
})
def _program_to_fields(program: Program) -> dict[str, Any]:
"""Serialise a Program for the editor form. Mirrors the field
layout of :func:`_PROGRAM_FIELD_SCHEMA` so a round-trip
fetch edit save is straightforward.
"""
return {
"prog_type": program.prog_type,
"cond": program.cond,
"cond2": program.cond2,
"cmd": program.cmd,
"par": program.par,
"pr2": program.pr2,
"month": program.month,
"day": program.day,
"days": program.days,
"hour": program.hour,
"minute": program.minute,
"remark_id": program.remark_id,
}
_PROGRAM_FIELD_SCHEMA = vol.Schema(
{
vol.Required("prog_type"): vol.All(int, vol.Range(min=0, max=10)),
vol.Optional("cond", default=0): vol.All(int, vol.Range(min=0, max=0xFFFF)),
vol.Optional("cond2", default=0): vol.All(int, vol.Range(min=0, max=0xFFFF)),
vol.Optional("cmd", default=0): vol.All(int, vol.Range(min=0, max=0xFF)),
vol.Optional("par", default=0): vol.All(int, vol.Range(min=0, max=0xFF)),
vol.Optional("pr2", default=0): vol.All(int, vol.Range(min=0, max=0xFFFF)),
vol.Optional("month", default=0): vol.All(int, vol.Range(min=0, max=0xFF)),
vol.Optional("day", default=0): vol.All(int, vol.Range(min=0, max=0xFF)),
vol.Optional("days", default=0): vol.All(int, vol.Range(min=0, max=0xFF)),
vol.Optional("hour", default=0): vol.All(int, vol.Range(min=0, max=0xFF)),
vol.Optional("minute", default=0): vol.All(int, vol.Range(min=0, max=0xFF)),
vol.Optional("remark_id"): vol.Any(None, vol.All(int, vol.Range(min=0))),
},
extra=vol.PREVENT_EXTRA,
)
@websocket_api.websocket_command(
{
vol.Required("type"): "omni_pca/programs/chain/write",
vol.Required("entry_id"): str,
vol.Required("head_slot"): vol.All(int, vol.Range(min=1, max=1500)),
vol.Required("head"): dict, # WHEN / AT / EVERY program dict
vol.Required("conditions"): [dict],
vol.Required("actions"): [dict],
}
)
@websocket_api.async_response
async def _ws_chain_write(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Rewrite a clausal chain into consecutive slots.
A clausal program spans one head (WHEN/AT/EVERY) + N condition
records (AND/OR) + M action records (THEN), each in its own slot.
Editing means rewriting the whole run.
Logic:
1. Find the *existing* chain that owns ``head_slot`` (so we know
which old slots to clear when the chain shrinks).
2. The new run spans slots [head_slot .. head_slot + new_len - 1].
If new_len > old_len, the additional slots must currently be
FREE refuse otherwise so we never trample an adjacent
program.
3. Write each new record via ``download_program``. The new run's
records are emitted in slot order; THEN actions land last.
4. Clear any old chain slots beyond the new run's end (shrinking
case) so leftover continuation records don't get mis-associated
with the now-shorter chain.
"""
coordinator = _coordinator_for_entry(hass, msg["entry_id"])
if coordinator is None:
connection.send_error(msg["id"], "not_found", "panel not configured")
return
try:
client = coordinator.client
except RuntimeError as err:
connection.send_error(msg["id"], "not_connected", str(err))
return
from omni_pca.programs import Program # local — avoid cycle
# Validate every member dict against the per-record schema (used
# individually so each member can have its own defaults).
try:
head_fields = _PROGRAM_FIELD_SCHEMA(msg["head"])
condition_fields = [_PROGRAM_FIELD_SCHEMA(c) for c in msg["conditions"]]
action_fields = [_PROGRAM_FIELD_SCHEMA(a) for a in msg["actions"]]
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid", f"bad chain member: {err}")
return
if not action_fields:
connection.send_error(
msg["id"], "invalid", "chain must have at least one THEN action",
)
return
head_slot = msg["head_slot"]
new_len = 1 + len(condition_fields) + len(action_fields)
# Find the existing chain (if any) so we know which old slots are
# currently part of this program. Without an existing chain we still
# allow writing — that's the "create chain at this empty slot" case.
from omni_pca.program_engine import build_chains
programs = coordinator.data.programs if coordinator.data else {}
existing = next(
(c for c in build_chains(tuple(programs.values()))
if c.head.slot == head_slot),
None,
)
existing_slots: set[int] = set()
if existing is not None:
for m in (existing.head, *existing.conditions, *existing.actions):
if m.slot is not None:
existing_slots.add(m.slot)
new_slot_range = range(head_slot, head_slot + new_len)
if new_slot_range.stop > 1501:
connection.send_error(
msg["id"], "invalid",
f"chain of {new_len} records starting at slot {head_slot} "
f"would extend past slot 1500",
)
return
# Anti-trample check for any expansion slots that aren't already
# part of this chain.
for s in new_slot_range:
if s in existing_slots:
continue
if s in programs and not programs[s].is_empty():
connection.send_error(
msg["id"], "invalid",
f"target slot {s} is occupied by another program "
f"(slot {s}); free it first",
)
return
# Build the typed records.
head = Program(slot=head_slot, **head_fields)
new_records: list[tuple[int, Program]] = [(head_slot, head)]
for i, cf in enumerate(condition_fields):
slot = head_slot + 1 + i
new_records.append((slot, Program(slot=slot, **cf)))
actions_base = head_slot + 1 + len(condition_fields)
for i, af in enumerate(action_fields):
slot = actions_base + i
new_records.append((slot, Program(slot=slot, **af)))
# Write them in order.
try:
for slot, prog in new_records:
await client.download_program(slot, prog)
except NotImplementedError as err:
connection.send_error(msg["id"], "not_supported", str(err))
return
except Exception as err:
connection.send_error(msg["id"], "write_failed", str(err))
return
# Clear any old chain slot that's not in the new range (shrinking
# case). Order matters: clears come *after* writes so a transient
# observer never sees a half-rewritten chain.
to_clear = existing_slots - set(new_slot_range)
for slot in sorted(to_clear):
try:
await client.clear_program(slot)
except Exception:
# Don't fail the whole write for a clear-failure; log and continue.
_log.warning("failed to clear shrunk-away slot %s", slot)
# Update coordinator state. Same shape as single-slot write: drop
# cleared slots, set written slots.
if coordinator.data is not None:
for slot, prog in new_records:
coordinator.data.programs[slot] = prog
for slot in to_clear:
coordinator.data.programs.pop(slot, None)
connection.send_result(msg["id"], {
"head_slot": head_slot,
"written_slots": list(new_slot_range),
"cleared_slots": sorted(to_clear),
})
@websocket_api.websocket_command(
{
vol.Required("type"): "omni_pca/objects/list",
vol.Required("entry_id"): str,
}
)
@websocket_api.async_response
async def _ws_list_objects(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return discovered objects so the frontend editor can populate
object pickers (zone / unit / area / thermostat / button).
Returns a flat dict mapping each kind to a list of
``{index, name}`` entries in slot order. Cached client-side after
the first call the topology doesn't change unless the user
reloads the integration.
"""
coordinator = _coordinator_for_entry(hass, msg["entry_id"])
if coordinator is None:
connection.send_error(msg["id"], "not_found", "panel not configured")
return
data = coordinator.data
if data is None:
connection.send_result(msg["id"], {})
return
def _flatten(bucket) -> list[dict[str, Any]]:
return [
{"index": idx, "name": getattr(obj, "name", "") or f"slot {idx}"}
for idx, obj in sorted(bucket.items())
]
connection.send_result(msg["id"], {
"zones": _flatten(data.zones),
"units": _flatten(data.units),
"areas": _flatten(data.areas),
"thermostats": _flatten(data.thermostats),
"buttons": _flatten(data.buttons),
})
@websocket_api.websocket_command(
{
vol.Required("type"): "omni_pca/programs/write",
vol.Required("entry_id"): str,
vol.Required("slot"): vol.All(int, vol.Range(min=1, max=1500)),
vol.Required("program"): dict,
}
)
@websocket_api.async_response
async def _ws_write_program(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Write an arbitrary Program record to ``slot``.
The ``program`` payload is a JSON-friendly dict mirroring the
:class:`omni_pca.programs.Program` dataclass every field passed
by name. Default 0 for fields the caller omits (matches the
dataclass defaults). ``remark_id`` is optional / None.
Frontend's edit form posts the whole struct on save; the slot is
re-stamped to ``msg["slot"]`` in case the caller forgot. Saves
update ``coordinator.data.programs[slot]`` immediately so the
next list call shows the edit before the next poll catches up.
"""
coordinator = _coordinator_for_entry(hass, msg["entry_id"])
if coordinator is None:
connection.send_error(msg["id"], "not_found", "panel not configured")
return
try:
validated = _PROGRAM_FIELD_SCHEMA(msg["program"])
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid", f"bad program payload: {err}")
return
try:
client = coordinator.client
except RuntimeError as err:
connection.send_error(msg["id"], "not_connected", str(err))
return
from omni_pca.programs import Program # local — avoid cycle
program = Program(slot=msg["slot"], **validated)
try:
await client.download_program(msg["slot"], program)
except NotImplementedError as err:
connection.send_error(msg["id"], "not_supported", str(err))
return
except Exception as err:
connection.send_error(msg["id"], "write_failed", str(err))
return
if coordinator.data is not None:
coordinator.data.programs[msg["slot"]] = program
connection.send_result(
msg["id"], {"slot": msg["slot"], "written": True},
)
@websocket_api.websocket_command(
{
vol.Required("type"): "omni_pca/programs/clear",
vol.Required("entry_id"): str,
vol.Required("slot"): vol.All(int, vol.Range(min=1, max=1500)),
}
)
@websocket_api.async_response
async def _ws_clear_program(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Erase a program slot by writing an all-zero 14-byte body.
Equivalent to "delete this program". v1 panels report
``not_supported`` because their wire protocol only allows bulk
rewrites (which would clear everything).
"""
coordinator = _coordinator_for_entry(hass, msg["entry_id"])
if coordinator is None:
connection.send_error(msg["id"], "not_found", "panel not configured")
return
try:
client = coordinator.client
except RuntimeError as err:
connection.send_error(msg["id"], "not_connected", str(err))
return
try:
await client.clear_program(msg["slot"])
except NotImplementedError as err:
connection.send_error(msg["id"], "not_supported", str(err))
return
except Exception as err:
connection.send_error(msg["id"], "clear_failed", str(err))
return
# Drop the entry from the coordinator's in-memory view so subsequent
# ``list`` calls reflect the deletion before the next poll catches up.
if coordinator.data is not None:
coordinator.data.programs.pop(msg["slot"], None)
connection.send_result(msg["id"], {"slot": msg["slot"], "cleared": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "omni_pca/programs/clone",
vol.Required("entry_id"): str,
vol.Required("source_slot"): vol.All(int, vol.Range(min=1, max=1500)),
vol.Required("target_slot"): vol.All(int, vol.Range(min=1, max=1500)),
}
)
@websocket_api.async_response
async def _ws_clone_program(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Copy ``source_slot``'s program into ``target_slot``.
Useful for "I want a slightly different version of this program"
user clones into an empty slot, then (eventually, when the editor
UI lands) tweaks the fields and saves.
Refuses to clone when source and target are the same slot or when
the source slot is empty / not defined.
"""
coordinator = _coordinator_for_entry(hass, msg["entry_id"])
if coordinator is None:
connection.send_error(msg["id"], "not_found", "panel not configured")
return
src = msg["source_slot"]
dst = msg["target_slot"]
if src == dst:
connection.send_error(
msg["id"], "invalid", "source and target slots must differ",
)
return
programs = coordinator.data.programs if coordinator.data else {}
source_program = programs.get(src)
if source_program is None or source_program.is_empty():
connection.send_error(
msg["id"], "not_found", f"no program at source slot {src}",
)
return
try:
client = coordinator.client
except RuntimeError as err:
connection.send_error(msg["id"], "not_connected", str(err))
return
# The Program dataclass carries the slot field; re-stamp it for the
# destination so the on-the-wire bytes are correctly addressed.
from omni_pca.programs import Program # local — avoid cycle
cloned = Program(
slot=dst,
prog_type=source_program.prog_type,
cond=source_program.cond,
cond2=source_program.cond2,
cmd=source_program.cmd,
par=source_program.par,
pr2=source_program.pr2,
month=source_program.month,
day=source_program.day,
days=source_program.days,
hour=source_program.hour,
minute=source_program.minute,
remark_id=source_program.remark_id,
)
try:
await client.download_program(dst, cloned)
except NotImplementedError as err:
connection.send_error(msg["id"], "not_supported", str(err))
return
except Exception as err:
connection.send_error(msg["id"], "clone_failed", str(err))
return
if coordinator.data is not None:
coordinator.data.programs[dst] = cloned
connection.send_result(
msg["id"], {"source_slot": src, "target_slot": dst, "cloned": True},
)
@websocket_api.websocket_command(
{
vol.Required("type"): "omni_pca/programs/fire",
vol.Required("entry_id"): str,
vol.Required("slot"): vol.All(int, vol.Range(min=1, max=1500)),
}
)
@websocket_api.async_response
async def _ws_fire_program(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Ask the panel to execute a program right now.
Sends ``Command(EXECUTE_PROGRAM, parameter2=slot)`` via the
coordinator's :class:`OmniClient`. The panel acks; any state
changes the program triggers come back as ordinary push events.
"""
coordinator = _coordinator_for_entry(hass, msg["entry_id"])
if coordinator is None:
connection.send_error(msg["id"], "not_found", "panel not configured")
return
try:
client = coordinator.client
except RuntimeError as err:
connection.send_error(msg["id"], "not_connected", str(err))
return
try:
await client.execute_command(Command.EXECUTE_PROGRAM, parameter2=msg["slot"])
except Exception as err:
connection.send_error(msg["id"], "fire_failed", str(err))
return
connection.send_result(msg["id"], {"slot": msg["slot"], "fired": True})
# --------------------------------------------------------------------------
# Setup
# --------------------------------------------------------------------------
@callback
def async_register_websocket_commands(hass: HomeAssistant) -> None:
"""Idempotently register the program-viewer websocket commands.
Called from the integration's ``async_setup_entry``; safe to call
once per HA boot. HA's ``websocket_api.async_register_command`` is
itself idempotent so a stray double-call from reload paths is fine.
"""
websocket_api.async_register_command(hass, _ws_list_programs)
websocket_api.async_register_command(hass, _ws_get_program)
websocket_api.async_register_command(hass, _ws_fire_program)
websocket_api.async_register_command(hass, _ws_clear_program)
websocket_api.async_register_command(hass, _ws_clone_program)
websocket_api.async_register_command(hass, _ws_write_program)
websocket_api.async_register_command(hass, _ws_chain_write)
websocket_api.async_register_command(hass, _ws_list_objects)
# --------------------------------------------------------------------------
# Side-panel registration
# --------------------------------------------------------------------------
# Where the integration serves the bundled panel JS from. Phase C builds
# the actual ESM bundle and drops it at ``custom_components/omni_pca/
# www/panel.js`` — we register a static path so HA serves it at
# ``/api/omni_pca/panel.js``.
_PANEL_FRONTEND_URL: str = "omni-panel-programs"
_PANEL_WEBCOMPONENT: str = "omni-panel-programs"
_PANEL_JS_PATH: str = "/api/omni_pca/panel.js"
async def async_register_side_panel(hass: HomeAssistant) -> None:
"""Register the sidebar entry that hosts the program viewer.
The bundled panel JS is served from the integration's ``www/``
directory via a registered static path. Until Phase C ships the
bundle, the panel registration still appears (HA shows a generic
loader) so the wiring can be exercised end-to-end.
"""
from pathlib import Path
from homeassistant.components.frontend import (
async_remove_panel,
)
from homeassistant.components.panel_custom import async_register_panel
# Serve <integration>/www/panel.js at /api/omni_pca/panel.js.
www_dir = Path(__file__).parent / "www"
www_dir.mkdir(exist_ok=True)
panel_js = www_dir / "panel.js"
if not panel_js.exists():
# Stub so the static path resolves even before Phase C builds the
# real bundle. The stub renders a "panel coming soon" message so
# users on dev installs see something useful rather than 404.
panel_js.write_text(_STUB_PANEL_JS)
await hass.http.async_register_static_paths(
[_StaticPathConfig(_PANEL_JS_PATH, str(panel_js), False)]
)
# async_remove_panel before re-register so reload doesn't duplicate.
try:
async_remove_panel(hass, _PANEL_FRONTEND_URL)
except Exception:
pass
await async_register_panel(
hass,
frontend_url_path=_PANEL_FRONTEND_URL,
webcomponent_name=_PANEL_WEBCOMPONENT,
sidebar_title="Omni Programs",
sidebar_icon="mdi:script-text-outline",
module_url=_PANEL_JS_PATH,
embed_iframe=False,
require_admin=False,
)
_STUB_PANEL_JS: str = """\
// omni-pca side panel stub until Phase C frontend lands.
class OmniPanelPrograms extends HTMLElement {
set hass(hass) {
if (!this._rendered) {
this.innerHTML = `
<style>
:host, .root { display: block; padding: 24px; font-family: sans-serif; }
h1 { font-size: 1.25rem; margin: 0 0 8px; }
p { color: #666; margin: 0; }
</style>
<div class="root">
<h1>Omni Programs</h1>
<p>Frontend bundle not yet installed.
Phase C of the program viewer will populate this panel.</p>
</div>`;
this._rendered = true;
}
}
}
customElements.define('omni-panel-programs', OmniPanelPrograms);
"""
# Late import: HA's StaticPathConfig moved in 2024.7. The integration is
# pinned to a current HA release so this import works, but importing at
# module top would force the dep on tests that don't need the panel.
from homeassistant.components.http import StaticPathConfig as _StaticPathConfig # noqa: E402

File diff suppressed because one or more lines are too long

View File

@ -44,75 +44,6 @@ make dev-down # stop the stack
make dev-reset # wipe HA config and start fresh
```
## Load real `.pca` data into the mock
By default the mock serves a small synthetic state (five zones, four
units, …). Point `OMNI_PCA_FIXTURE` at a real `.pca` file to make the
mock indistinguishable on the wire from the source panel:
```bash
# dev/.env (gitignored)
OMNI_PCA_FIXTURE=/fixtures/path/to/Account.pca
```
The host directory `/home/kdm/home-auto/HAI` is mounted at `/fixtures`
inside the mock-panel container (see `docker-compose.yml`); adjust the
mount if your `.pca` lives elsewhere.
The decryption key is auto-derived from a sibling `PCA01.CFG` if one
exists (this is how PC Access exports usually ship). To override:
```bash
OMNI_PCA_FIXTURE_KEY=0xC1A280B2 # or --pca-key on the command line
```
`MockState.from_pca` populates zones, units, areas, thermostats,
buttons, programs, model byte, and firmware version from the file —
everything the HA integration reads at discovery time.
## Time-series & dashboards
`docker compose up -d` also brings up **InfluxDB v2** (port 8086) and
**Grafana** (port 3000). Open Grafana at <http://localhost:3000>
(login: `admin` / `$GRAFANA_PASSWORD` from `.env`) — the **Omni Pro II
— Panel Overview** dashboard loads automatically, pre-provisioned from
[`../grafana/`](../grafana/), the shipping bundle.
To wire HA → InfluxDB, append this block to `ha-config/configuration.yaml`
(the directory is gitignored because it contains HA auth/state; the
block lives in `../grafana/ha-snippet.yaml` for production users):
```yaml
influxdb:
api_version: 2
host: influxdb
port: 8086
ssl: false
verify_ssl: false
token: dev-token-omnipca-9472-fixed-for-dev-stack
organization: omni-pca
bucket: ha
precision: s
tags_attributes: [event_type, event_class]
include:
domains: [alarm_control_panel, binary_sensor, climate, event, light, sensor, switch]
entity_globs: ["*omni*"]
```
Restart HA (`docker compose restart homeassistant`) after editing.
Within 30 seconds, panels start populating with live data.
The dashboard JSON in `../grafana/provisioning/dashboards/` is the
source of truth; edits in the Grafana UI don't persist (provisioned
dashboards are read-only). Iterate by editing the JSON and running
`docker compose restart grafana` — the provisioner picks up changes
within ~30s.
To exercise dashboard panels against the mock, trigger HA actions
(arm an area, toggle a light): the mock pushes the resulting
`SystemEvent` back to HA, which ships it to InfluxDB, which Grafana
queries. Each step takes <1s.
## Notes
- The HA container mounts `../custom_components/omni_pca/` read-only, so

View File

@ -1,150 +0,0 @@
#!/usr/bin/env python3
"""Add a *second* omni_pca config entry pointing at the real panel.
The dev stack already has one entry pointing at the mock panel
(``host.docker.internal:14369``). This script adds another entry for
the real panel at ``192.168.1.9:4369`` using ``transport=udp`` and the
controller key from the bundled .pca fixture.
Run inside the project venv:
cd /home/kdm/home-auto/omni-pca
uv run python dev/add_real_panel.py
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
import httpx
sys.path.insert(0, str(Path(__file__).parent))
from probe_v1 import _load_key # type: ignore # noqa: E402
DEFAULT_HA_URL = "http://localhost:8123"
PANEL_HOST = "192.168.1.9"
PANEL_PORT = 4369
DEFAULT_USERNAME = "demo"
DEFAULT_PASSWORD = "demo-password-1234"
async def _get_token(ha_url: str) -> str:
"""Re-use the cached access token; otherwise log in via /auth/login_flow."""
token_file = (
Path(__file__).parent / "ha-config" / ".storage" / ".demo_access_token"
)
if token_file.exists():
return token_file.read_text().strip()
async with httpx.AsyncClient(base_url=ha_url, timeout=15.0) as client:
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()
token = r.json()["access_token"]
# Cache for next run.
try:
token_file.write_text(token)
except Exception:
pass
return token
async def amain(args: argparse.Namespace) -> int:
key_bytes = _load_key(None)
key_hex = key_bytes.hex()
print(f"[add-real-panel] target HA: {args.ha_url}")
print(f"[add-real-panel] panel: {PANEL_HOST}:{PANEL_PORT} (UDP)")
print(f"[add-real-panel] key: ...{key_hex[-4:]} (16 bytes)\n")
token = await _get_token(args.ha_url)
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(base_url=args.ha_url, timeout=30.0) as client:
# ---- check if an entry already exists for this host ----
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":
continue
data = entry.get("data", {})
if data.get("host") == PANEL_HOST and data.get("port") == PANEL_PORT:
print(f" already configured: {entry['title']} ({entry['entry_id']})")
return 0
# ---- start the config flow ----
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" flow opened: {flow_id} (step={flow.get('step_id')})")
# ---- submit the form for the real panel ----
r = await client.post(
f"/api/config/config_entries/flow/{flow_id}",
headers=headers,
json={
"host": PANEL_HOST,
"port": PANEL_PORT,
"controller_key": key_hex,
"transport": "udp",
},
timeout=60.0, # the probe round-trip can take a few seconds
)
r.raise_for_status()
result = r.json()
if result.get("type") == "create_entry":
print(f" ✓ entry created: {result.get('title')}")
print(f" entry_id: {result.get('result')}")
elif result.get("type") == "form":
print(f" form re-shown — errors: {result.get('errors')}")
return 1
else:
print(f" unexpected outcome: {result}")
return 1
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--ha-url", default=DEFAULT_HA_URL)
args = parser.parse_args()
return asyncio.run(amain(args))
if __name__ == "__main__":
sys.exit(main())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -6,27 +6,15 @@
# make dev-logs # tail HA logs
# make dev-down # stop and clean
#
# On every container start the HA service pip-installs the local
# `omni-pca` library from ../ into site-packages (the version pinned in
# the integration manifest isn't on PyPI yet, and we want our latest
# v1/ subpackage available either way). Source changes in src/omni_pca
# require a ``docker compose restart homeassistant`` to take effect.
#
# 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 one of:
# Mock panel (TCP):
# host host.docker.internal
# port 14369
# transport TCP
# controller_key 000102030405060708090a0b0c0d0e0f
# Real panel (UDP, v1 wire protocol):
# host <panel IP, e.g. 192.168.1.9>
# port 4369
# transport UDP
# controller_key <32 hex chars from the panel's .pca file>
# 3. Use:
# host host.docker.internal
# port 14369
# controller_key 000102030405060708090a0b0c0d0e0f
# (matches scripts/run_mock_panel.py defaults)
services:
mock-panel:
@ -35,22 +23,14 @@ services:
volumes:
- ../src:/tmp/mock/src:ro
- ./run_mock_panel.py:/tmp/mock/run_mock_panel.py:ro
# Mount the captured .pca fixtures read-only so the mock can
# optionally seed its state from a real export. Set
# OMNI_PCA_FIXTURE in dev/.env (or pass on the command line) to
# activate; left unset, the mock uses the hard-coded sample.
- /home/kdm/home-auto/HAI:/fixtures:ro
environment:
PYTHONPATH: /tmp/mock/src
OMNI_PCA_FIXTURE: ${OMNI_PCA_FIXTURE:-}
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"
networks:
- default
homeassistant:
image: ghcr.io/home-assistant/home-assistant:2026.5
@ -60,116 +40,9 @@ services:
volumes:
- ./ha-config:/config
- ../custom_components/omni_pca:/config/custom_components/omni_pca:ro
# Make the whole library project (pyproject + src/ + dist/) available
# so the entrypoint override below can pip-install from local source
# before /init starts. This gives HA real dist-info for
# ``omni-pca==2026.5.10`` (which isn't on PyPI yet) and ensures the
# v1 subpackage is present.
- ../:/opt/omni-pca-src:ro
# Keep 8123 mapped on localhost for direct access during development;
# public traffic comes in via caddy-docker-proxy on the `caddy` net.
ports:
- "8123:8123"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- TZ=America/Boise
networks:
- default
- caddy
labels:
caddy: juliet.warehack.ing
caddy.reverse_proxy: "{{upstreams 8123}}"
# HA uses WebSockets for the frontend (lovelace state updates,
# config flow, etc.) so we need the streaming-friendly settings
# from CLAUDE.md, otherwise caddy closes the socket every ~15s.
caddy.reverse_proxy.flush_interval: "-1"
caddy.reverse_proxy.transport: http
caddy.reverse_proxy.transport.read_timeout: "0"
caddy.reverse_proxy.transport.write_timeout: "0"
caddy.reverse_proxy.transport.keepalive: 5m
caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
caddy.reverse_proxy.stream_timeout: 24h
caddy.reverse_proxy.stream_close_delay: 5s
# HA's image entrypoint is /init (s6-overlay). We pre-install our
# local library against site-packages so HA's manifest-requirement
# check finds it, then exec /init normally.
entrypoint:
- sh
- -c
- |
set -e
pip install --quiet --no-deps --upgrade /opt/omni-pca-src
exec /init
# InfluxDB v2 + Grafana stack — kept inline rather than `extends:`-ing
# ../grafana/docker-compose.yml so this file stays self-contained and
# the named volumes get scoped to this compose project. The bundle
# compose stays the canonical ship-to-users version; we share its
# provisioning files via the volume mount on the grafana service.
influxdb:
image: influxdb:2.7-alpine
container_name: omni-pca-dev-influxdb
restart: unless-stopped
environment:
DOCKER_INFLUXDB_INIT_MODE: setup
DOCKER_INFLUXDB_INIT_USERNAME: ${INFLUX_USERNAME:-admin}
DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUX_PASSWORD}
DOCKER_INFLUXDB_INIT_ORG: omni-pca
DOCKER_INFLUXDB_INIT_BUCKET: ha
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUX_TOKEN}
DOCKER_INFLUXDB_INIT_RETENTION: 30d
volumes:
- influxdb-data:/var/lib/influxdb2
- influxdb-config:/etc/influxdb2
ports:
- "8086:8086"
networks:
- default
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8086/health"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
grafana:
image: grafana/grafana:11.4.0
container_name: omni-pca-dev-grafana
restart: unless-stopped
depends_on:
influxdb:
condition: service_healthy
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
GF_AUTH_ANONYMOUS_ENABLED: "false"
GF_USERS_ALLOW_SIGN_UP: "false"
GF_LOG_LEVEL: warn
INFLUX_URL: http://influxdb:8086
INFLUX_TOKEN: ${INFLUX_TOKEN}
volumes:
- grafana-data:/var/lib/grafana
- ../grafana/provisioning:/etc/grafana/provisioning:ro
ports:
- "3000:3000"
networks:
- default
- caddy
labels:
caddy: grafana-omni.juliet.warehack.ing
caddy.reverse_proxy: "{{upstreams 3000}}"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/health || exit 1"]
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
volumes:
influxdb-data:
influxdb-config:
grafana-data:
networks:
caddy:
external: true

View File

@ -1,151 +0,0 @@
#!/usr/bin/env python3
"""Phase-1 smoke test: v1-over-UDP handshake + RequestSystemInformation.
Run inside the project venv:
cd /home/kdm/home-auto/omni-pca
uv run python dev/probe_v1.py [--host 192.168.1.9] [--port 4369]
Requires the panel's controller key. Picks it up from (in order):
1. ``--key 32hex`` on the command line
2. ``OMNI_KEY`` env var
3. ``dev/.omni_key`` file (gitignored)
4. The bundled ``.pca`` plain fixture (developer-only fallback)
Success criteria: panel returns a v1 SystemInformation message (opcode 18)
within the timeout. Failure modes we want to distinguish:
* UDP socket fails to open routing / firewall
* Handshake step 2 timeout wrong port, wrong panel
* Handshake step 4 termination wrong controller key
* SystemInformation timeout v1 path isn't doing what we think
* SystemInformation reply v1-over-UDP is real, proceed to Phase 2
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import sys
from pathlib import Path
from omni_pca.v1.connection import (
HandshakeError,
InvalidEncryptionKeyError,
OmniConnectionV1,
RequestTimeoutError,
)
from omni_pca.opcodes import OmniLinkMessageType
def _load_key(arg_key: str | None) -> bytes:
if arg_key:
return bytes.fromhex(arg_key)
env = os.environ.get("OMNI_KEY")
if env:
return bytes.fromhex(env)
keyfile = Path(__file__).parent / ".omni_key"
if keyfile.exists():
return bytes.fromhex(keyfile.read_text().strip())
fixture = Path("/home/kdm/home-auto/HAI/pca-re/extracted/Our_House.pca.plain")
if fixture.exists():
from omni_pca.pca_file import (
PcaReader,
_CAP_OMNI_PRO_II,
_parse_header,
_walk_to_connection,
)
r = PcaReader(fixture.read_bytes())
_parse_header(r)
_walk_to_connection(r, _CAP_OMNI_PRO_II)
r.string8_fixed(120) # network_address
r.string8_fixed(5) # port
return bytes.fromhex(r.string8_fixed(32).ljust(32, "0")[:32])
raise SystemExit("no controller key: pass --key, set OMNI_KEY, or create dev/.omni_key")
def _decode_system_information(payload: bytes) -> dict[str, object]:
"""Parse the v1 SystemInformation payload (mirrors clsOLMsgSystemInformation)."""
if len(payload) < 29:
raise ValueError(f"SystemInformation payload too short: {len(payload)} bytes")
return {
"opcode": payload[0],
"model": payload[1],
"fw_major": payload[2],
"fw_minor": payload[3],
"fw_revision": int.from_bytes(payload[4:5], "big", signed=True),
"local_phone": payload[5:29].rstrip(b"\x00").decode("ascii", errors="replace"),
}
async def amain(args: argparse.Namespace) -> int:
key = _load_key(args.key)
print(f"[probe] target {args.host}:{args.port} key=...{key[-2:].hex()} (16 B)")
try:
async with OmniConnectionV1(
host=args.host,
port=args.port,
controller_key=key,
timeout=args.timeout,
retry_count=args.retries,
) as conn:
print(f"[probe] handshake OK state={conn.state.name} "
f"session_key=...{conn.session_key[-2:].hex() if conn.session_key else 'n/a'}")
print("[probe] sending v1 RequestSystemInformation (opcode 17)")
reply = await conn.request(OmniLinkMessageType.RequestSystemInformation)
print(f"[probe] reply: start_char={reply.start_char:#04x} "
f"opcode={reply.opcode} payload={reply.data.hex()}")
if reply.opcode != int(OmniLinkMessageType.SystemInformation):
print(f"[probe] WARNING: expected opcode 18 (SystemInformation), "
f"got {reply.opcode}")
return 2
info = _decode_system_information(reply.data)
print(f"[probe] ✓ v1-over-UDP works")
print(f" model = {info['model']}")
print(f" firmware = {info['fw_major']}.{info['fw_minor']}.{info['fw_revision']}")
print(f" phone = {info['local_phone']!r}")
except InvalidEncryptionKeyError as exc:
print(f"[probe] handshake terminated: wrong controller key? ({exc})")
return 1
except HandshakeError as exc:
print(f"[probe] handshake failed: {exc}")
return 1
except RequestTimeoutError as exc:
print(f"[probe] no reply to RequestSystemInformation: {exc}")
print(" → handshake worked but v1 path isn't responding. "
"Check tcpdump for what's on the wire.")
return 2
except OSError as exc:
print(f"[probe] socket error: {exc}")
return 1
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="192.168.1.9")
parser.add_argument("--port", type=int, default=4369)
parser.add_argument("--key", help="32 hex chars; overrides env/.omni_key")
parser.add_argument("--timeout", type=float, default=5.0)
parser.add_argument("--retries", type=int, default=2)
parser.add_argument("--debug", action="store_true",
help="enable DEBUG logging (TX/RX packet dump)")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return asyncio.run(amain(args))
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,122 +0,0 @@
#!/usr/bin/env python3
"""Phase-2a smoke test: drive OmniClientV1 against the real panel.
Hits the read-only methods we care about for HA polling. Compares parsed
values against the recon dump so we catch off-by-one byte errors fast.
Run:
cd /home/kdm/home-auto/omni-pca
uv run python dev/probe_v1_client.py
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from probe_v1 import _load_key # type: ignore # noqa: E402
from omni_pca.v1 import OmniClientV1, OmniNakError
async def amain(args: argparse.Namespace) -> int:
key = _load_key(args.key)
print(f"[client probe] target {args.host}:{args.port}\n")
async with OmniClientV1(
host=args.host, port=args.port, controller_key=key, timeout=4.0,
) as c:
info = await c.get_system_information()
print(f"system: model={info.model_name} fw={info.firmware_version} "
f"phone={info.local_phone!r}")
print("\n--- discovery (streaming UploadNames) ---")
all_names = await c.list_all_names()
for type_byte in sorted(all_names):
try:
from omni_pca.v1 import NameType
label = NameType(type_byte).name
except ValueError:
label = f"type{type_byte}"
print(f" {label} ({len(all_names[type_byte])} entries)")
for num in sorted(all_names[type_byte]):
print(f" #{num}: {all_names[type_byte][num]!r}")
try:
sysstatus = await c.get_system_status()
print(f"status: time={sysstatus.panel_time} "
f"battery=0x{sysstatus.battery_reading:02x} "
f"sunrise={sysstatus.sunrise_hour:02d}:{sysstatus.sunrise_minute:02d} "
f"sunset={sysstatus.sunset_hour:02d}:{sysstatus.sunset_minute:02d} "
f"area_modes={[m for m, _ in sysstatus.area_alarms]}")
except Exception as exc:
print(f"system status failed: {type(exc).__name__}: {exc}")
print("\n--- zones 1..16 ---")
zones = await c.get_zone_status(1, 16)
for idx in sorted(zones):
z = zones[idx]
flags = []
if z.is_open: flags.append("open")
if z.is_in_alarm: flags.append("alarm")
if z.is_bypassed: flags.append("bypass")
if z.is_trouble: flags.append("trouble")
tag = ",".join(flags) or "secure"
print(f" zone {idx:2d}: status=0x{z.raw_status:02x} loop=0x{z.loop:02x} ({tag})")
print("\n--- units 1..16 ---")
units = await c.get_unit_status(1, 16)
for idx in sorted(units):
u = units[idx]
br = u.brightness
br_s = f"{br}%" if br is not None else "n/a"
print(f" unit {idx:2d}: state=0x{u.state:02x} ({br_s}) "
f"time_remaining={u.time_remaining_secs}s")
print("\n--- thermostats 1..4 ---")
try:
tstats = await c.get_thermostat_status(1, 4)
for idx in sorted(tstats):
t = tstats[idx]
print(f" tstat {idx}: status=0x{t.status:02x} "
f"temp_F={t.temperature_f:.1f} "
f"heat={t.heat_setpoint_f:.0f} cool={t.cool_setpoint_f:.0f} "
f"mode=0x{t.system_mode:02x} fan=0x{t.fan_mode:02x} "
f"hold=0x{t.hold_mode:02x}")
except OmniNakError as exc:
print(f" no thermostats configured: {exc}")
print("\n--- aux 1..8 ---")
try:
auxes = await c.get_aux_status(1, 8)
for idx in sorted(auxes):
a = auxes[idx]
print(f" aux {idx}: output=0x{a.output:02x} value=0x{a.value_raw:02x} "
f"low=0x{a.low_raw:02x} high=0x{a.high_raw:02x}")
except OmniNakError as exc:
print(f" no aux sensors: {exc}")
print("\n[client probe] ✓ disconnected cleanly")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="192.168.1.9")
parser.add_argument("--port", type=int, default=4369)
parser.add_argument("--key", help="32 hex chars; overrides env/.omni_key")
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.WARNING,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return asyncio.run(amain(args))
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,129 +0,0 @@
#!/usr/bin/env python3
"""Phase-3 smoke test: drive OmniClientV1Adapter through the same
sequence the HA coordinator runs in async_config_entry_first_refresh.
Doesn't pull in HA; just executes the discovery + initial poll pattern
against the real panel and prints what an OmniData snapshot would look
like. If this works, the actual HA coordinator should work too.
Run:
cd /home/kdm/home-auto/omni-pca
uv run python dev/probe_v1_coordinator.py
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from probe_v1 import _load_key # type: ignore # noqa: E402
from omni_pca.models import ObjectType
from omni_pca.v1 import OmniClientV1Adapter
async def amain(args: argparse.Namespace) -> int:
key = _load_key(args.key)
print(f"[coord probe] target {args.host}:{args.port}\n")
async with OmniClientV1Adapter(
host=args.host, port=args.port, controller_key=key, timeout=10.0
) as c:
# ---- 1. SystemInformation ----
info = await c.get_system_information()
print(f"=== SystemInformation ===\n"
f" model={info.model_name} fw={info.firmware_version}\n")
# ---- 2. Discovery: per-type names + synthesized properties ----
print("=== Discovery (UploadNames stream + synth Properties) ===")
zone_names = await c.list_zone_names()
unit_names = await c.list_unit_names()
area_names = await c.list_area_names()
tstat_names = await c.list_thermostat_names()
button_names = await c.list_button_names()
print(f" zones: {len(zone_names)}")
print(f" units: {len(unit_names)}")
print(f" areas: {len(area_names)} (fallback if 0 streamed)")
print(f" thermostats: {len(tstat_names)}")
print(f" buttons: {len(button_names)}")
print()
# Sanity-check that get_object_properties returns a real dataclass
# for one zone, one unit, one area, one thermostat, one button.
for type_byte, name_dict, label in [
(ObjectType.ZONE, zone_names, "Zone"),
(ObjectType.UNIT, unit_names, "Unit"),
(ObjectType.AREA, area_names, "Area"),
(ObjectType.THERMOSTAT, tstat_names, "Thermostat"),
(ObjectType.BUTTON, button_names, "Button"),
]:
if not name_dict:
print(f" {label}: no entries, skipping property synth")
continue
idx = min(name_dict)
props = await c.get_object_properties(type_byte, idx)
print(f" {label} #{idx}: {props}")
print()
# ---- 3. Polling: bulk status for each type, plus area derivation ----
print("=== Polling (bulk status) ===")
if zone_names:
zone_end = max(zone_names)
zones = await c.get_extended_status(ObjectType.ZONE, 1, zone_end)
open_zones = [z for z in zones if getattr(z, "is_open", False)]
print(f" ZoneStatus[1..{zone_end}]: {len(zones)} records, "
f"{len(open_zones)} currently open")
if unit_names:
unit_end = max(unit_names)
units = await c.get_extended_status(ObjectType.UNIT, 1, unit_end)
on_units = [u for u in units if getattr(u, "is_on", False)]
print(f" UnitStatus[1..{unit_end}]: {len(units)} records, "
f"{len(on_units)} currently on")
if tstat_names:
tstat_end = max(tstat_names)
tstats = await c.get_extended_status(
ObjectType.THERMOSTAT, 1, tstat_end
)
print(f" ThermostatStatus[1..{tstat_end}]: {len(tstats)} records")
# Areas: derived from SystemStatus
if area_names:
area_end = max(area_names)
areas = await c.get_object_status(ObjectType.AREA, 1, area_end)
modes = [a.mode for a in areas]
print(f" AreaStatus[1..{area_end}]: {len(areas)} records, "
f"modes={modes}")
print()
# ---- 4. SystemStatus ----
status = await c.get_system_status()
print(f"=== SystemStatus ===\n"
f" panel_time={status.panel_time} "
f"battery=0x{status.battery_reading:02x}\n"
f" sunrise={status.sunrise_hour:02d}:{status.sunrise_minute:02d} "
f"sunset={status.sunset_hour:02d}:{status.sunset_minute:02d}\n")
print("[coord probe] ✓ full discovery + poll cycle worked over v1+UDP")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="192.168.1.9")
parser.add_argument("--port", type=int, default=4369)
parser.add_argument("--key", help="32 hex chars; overrides env/.omni_key")
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.WARNING,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return asyncio.run(amain(args))
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,149 +0,0 @@
#!/usr/bin/env python3
"""Phase-2 reconnaissance: fetch v1 status replies from the real panel.
Doesn't parse — just dumps the raw payload bytes for each known v1 opcode
so we can match them against the C# message classes before writing
parsers. Builds the picture of what your panel actually has configured.
Run:
cd /home/kdm/home-auto/omni-pca
uv run python dev/probe_v1_recon.py [--debug]
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from pathlib import Path
# Reuse the key loader from probe_v1.
sys.path.insert(0, str(Path(__file__).parent))
from probe_v1 import _load_key # type: ignore # noqa: E402
from omni_pca.opcodes import OmniLinkMessageType
from omni_pca.v1.connection import OmniConnectionV1, RequestTimeoutError
async def _request_or_warn(
conn: OmniConnectionV1,
label: str,
opcode: OmniLinkMessageType,
payload: bytes = b"",
expected_opcode: int | None = None,
) -> None:
print(f"--- {label} (req opcode {int(opcode)}, payload {payload.hex() or '<empty>'}) ---")
try:
reply = await conn.request(opcode, payload, timeout=4.0)
except RequestTimeoutError as exc:
print(f" TIMEOUT: {exc}")
return
except Exception as exc:
print(f" ERROR: {type(exc).__name__}: {exc}")
return
print(f" reply opcode = {reply.opcode}")
print(f" payload ({len(reply.payload)} B) = {reply.payload.hex()}")
if expected_opcode is not None and reply.opcode != expected_opcode:
print(f" NOTE: expected opcode {expected_opcode}, got {reply.opcode}")
async def amain(args: argparse.Namespace) -> int:
key = _load_key(args.key)
print(f"[recon] target {args.host}:{args.port}\n")
async with OmniConnectionV1(
host=args.host,
port=args.port,
controller_key=key,
timeout=4.0,
retry_count=1,
) as conn:
print(f"handshake OK state={conn.state.name}\n")
# --- panel-wide ---
await _request_or_warn(
conn, "SystemInformation", OmniLinkMessageType.RequestSystemInformation,
expected_opcode=int(OmniLinkMessageType.SystemInformation),
)
await _request_or_warn(
conn, "SystemStatus", OmniLinkMessageType.RequestSystemStatus,
expected_opcode=int(OmniLinkMessageType.SystemStatus),
)
await _request_or_warn(
conn, "StatusSummary", OmniLinkMessageType.RequestStatusSummary,
expected_opcode=int(OmniLinkMessageType.StatusSummary),
)
# --- bulk status, small ranges so we can read the bytes ---
await _request_or_warn(
conn, "ZoneStatus[1..8]", OmniLinkMessageType.RequestZoneStatus,
payload=bytes([1, 8]),
expected_opcode=int(OmniLinkMessageType.ZoneStatus),
)
await _request_or_warn(
conn, "ZoneExtendedStatus[1..8]", OmniLinkMessageType.RequestZoneExtendedStatus,
payload=bytes([1, 8]),
expected_opcode=int(OmniLinkMessageType.ZoneExtendedStatus),
)
await _request_or_warn(
conn, "UnitStatus[1..8]", OmniLinkMessageType.RequestUnitStatus,
payload=bytes([1, 8]),
expected_opcode=int(OmniLinkMessageType.UnitStatus),
)
await _request_or_warn(
conn, "UnitExtendedStatus[1..8]", OmniLinkMessageType.RequestUnitExtendedStatus,
payload=bytes([1, 8]),
expected_opcode=int(OmniLinkMessageType.UnitExtendedStatus),
)
await _request_or_warn(
conn, "ThermostatStatus[1..4]", OmniLinkMessageType.RequestThermostatStatus,
payload=bytes([1, 4]),
expected_opcode=int(OmniLinkMessageType.ThermostatStatus),
)
await _request_or_warn(
conn, "ThermostatExtendedStatus[1..4]", OmniLinkMessageType.RequestThermostatExtendedStatus,
payload=bytes([1, 4]),
expected_opcode=int(OmniLinkMessageType.ThermostatExtendedStatus),
)
await _request_or_warn(
conn, "AuxiliaryStatus[1..8]", OmniLinkMessageType.RequestAuxiliaryStatus,
payload=bytes([1, 8]),
expected_opcode=int(OmniLinkMessageType.AuxiliaryStatus),
)
# --- discovery: UploadNames is the READ request; DownloadNames is the
# WRITE direction (panel <- client). Reply payload is NameData with the
# next defined object's number + name.
# Per clsOL2MsgUploadNames: [type, num_hi, num_lo, relative_direction].
# type: 1=Zone 2=Unit 3=Button 4=Code 5=Thermostat 6=Area 7=Message
# relative_direction: +1=next after num, -1=prev before num, 0=exact
for type_byte, type_name in [(1, "Zone"), (2, "Unit"), (5, "Thermostat"), (6, "Area")]:
await _request_or_warn(
conn,
f"UploadNames[type={type_name}, after=0, dir=+1]",
OmniLinkMessageType.UploadNames,
payload=bytes([type_byte, 0, 0, 1]),
expected_opcode=int(OmniLinkMessageType.NameData),
)
print("\n--- recon complete, session closed cleanly ---")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="192.168.1.9")
parser.add_argument("--port", type=int, default=4369)
parser.add_argument("--key", help="32 hex chars; overrides env/.omni_key")
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.WARNING,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return asyncio.run(amain(args))
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,118 +0,0 @@
#!/usr/bin/env python3
"""Probe the v1 UploadNames streaming flow.
Sends UploadNames (no payload), then a series of Acknowledge messages,
dumping each reply until we get an EOD or 30 records (whichever comes
first). Confirms the lock-step pattern PC Access uses for bulk reads.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from probe_v1 import _load_key # type: ignore # noqa: E402
from omni_pca.opcodes import OmniLinkMessageType
from omni_pca.v1 import OmniConnectionV1
_NAME_TYPE_LABELS = {
1: "Zone", 2: "Unit", 3: "Button", 4: "Code",
5: "Thermostat", 6: "Area", 7: "Message",
}
def _decode_namedata(payload: bytes) -> str:
"""Best-effort decode of a NameData payload for display."""
if len(payload) < 3:
return f"<short payload: {payload.hex()}>"
name_type = payload[0]
# Heuristic: zones/messages are 15-char names, others 12. With one-byte
# NameNumber, payload length = 1 (type) + 1 (num) + L (name) + 1 (term).
# With two-byte NameNumber: 1 + 2 + L + 1.
L_15 = 15 + 3 # one-byte form, 15-char name
L_12 = 12 + 3 # one-byte form, 12-char name
if len(payload) == L_15 or len(payload) == L_15 + 1:
# 15-char name (Zone or Message), one-byte num.
num = payload[1]
name = payload[2:2 + 15].rstrip(b"\x00").decode("utf-8", errors="replace")
elif len(payload) == L_12 or len(payload) == L_12 + 1:
# 12-char name, one-byte num.
num = payload[1]
name = payload[2:2 + 12].rstrip(b"\x00").decode("utf-8", errors="replace")
else:
# Two-byte num form (NameNumber > 255): payload[1..2] = BE u16, then name.
num = (payload[1] << 8) | payload[2]
name = payload[3:].rstrip(b"\x00").decode("utf-8", errors="replace")
label = _NAME_TYPE_LABELS.get(name_type, f"type{name_type}")
return f"{label} #{num}: {name!r}"
async def amain(args: argparse.Namespace) -> int:
key = _load_key(args.key)
print(f"[stream probe] target {args.host}:{args.port}\n")
async with OmniConnectionV1(
host=args.host, port=args.port, controller_key=key, timeout=4.0
) as conn:
from omni_pca.message import Message, START_CHAR_V1_UNADDRESSED
# Step 1: bare UploadNames.
upload = Message(
start_char=START_CHAR_V1_UNADDRESSED,
data=bytes([int(OmniLinkMessageType.UploadNames)]),
)
seq, fut = conn._send_encrypted(upload)
reply = conn._decode_inner(await fut)
print(f"reply 1 (seq={seq}): opcode={reply.opcode} {_decode_namedata(reply.payload) if reply.opcode == int(OmniLinkMessageType.NameData) else f'(payload={reply.payload.hex()!r})'}")
if reply.opcode != int(OmniLinkMessageType.NameData):
print("panel didn't reply with NameData — streaming flow may not apply here")
return 0
# Step 2..N: Acknowledge → next NameData (or EOD).
for i in range(2, args.max + 1):
ack = Message(
start_char=START_CHAR_V1_UNADDRESSED,
data=bytes([int(OmniLinkMessageType.Ack)]),
)
seq, fut = conn._send_encrypted(ack)
reply = conn._decode_inner(await fut)
if reply.opcode == int(OmniLinkMessageType.EOD):
print(f"reply {i} (seq={seq}): EOD — end of stream after {i-1} records")
return 0
if reply.opcode == int(OmniLinkMessageType.NameData):
print(f"reply {i} (seq={seq}): {_decode_namedata(reply.payload)}")
else:
print(f"reply {i} (seq={seq}): unexpected opcode {reply.opcode}, "
f"payload={reply.payload.hex()}")
return 1
print(f"\nstopped after {args.max} replies (no EOD seen)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="192.168.1.9")
parser.add_argument("--port", type=int, default=4369)
parser.add_argument("--key", help="32 hex chars; overrides env/.omni_key")
parser.add_argument("--max", type=int, default=20, help="stop after N replies")
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.WARNING,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return asyncio.run(amain(args))
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,93 +0,0 @@
#!/usr/bin/env python3
"""Phase-2c live write smoke test: round-trip a no-op unit command.
Reads the current state of one unit, sends a command that should yield
the same observable result, then re-reads to confirm. Proves that
:meth:`OmniClientV1.execute_command` actually flows through the v1
Command opcode against the real panel without changing anything visible.
Run:
cd /home/kdm/home-auto/omni-pca
uv run python dev/probe_v1_write.py [--index N]
Default target is unit #4 ('STAIRS' per current panel config).
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from probe_v1 import _load_key # type: ignore # noqa: E402
from omni_pca.v1 import OmniClientV1
async def amain(args: argparse.Namespace) -> int:
key = _load_key(args.key)
print(f"[write probe] target {args.host}:{args.port} unit #{args.index}\n")
async with OmniClientV1(
host=args.host, port=args.port, controller_key=key, timeout=4.0
) as c:
before = (await c.get_unit_status(args.index, args.index))[args.index]
print(f"BEFORE: state=0x{before.state:02x} "
f"brightness={before.brightness!r} "
f"time_remaining={before.time_remaining_secs}s")
# Pick the safest no-op command for the unit's current state:
# - state == 0 → send UNIT_OFF (already off, panel acks)
# - state == 1 → send UNIT_ON (already on, panel acks)
# - 100 <= state <= 200 → set_unit_level(percent) at the current level
# - otherwise (scene/dim/etc.) → fall back to UNIT_ON which is harmless
if before.state == 0:
print("ACTION: turn_unit_off (already off, expecting Ack)")
await c.turn_unit_off(args.index)
elif before.state == 1:
print("ACTION: turn_unit_on (already on, expecting Ack)")
await c.turn_unit_on(args.index)
elif 100 <= before.state <= 200:
level = before.state - 100
print(f"ACTION: set_unit_level({level}%) (already at this level)")
await c.set_unit_level(args.index, level)
else:
print(f"ACTION: turn_unit_on (state=0x{before.state:02x} is exotic; safe ack expected)")
await c.turn_unit_on(args.index)
# Give the panel ~250ms to settle if it does pulse anything.
await asyncio.sleep(0.25)
after = (await c.get_unit_status(args.index, args.index))[args.index]
print(f"AFTER: state=0x{after.state:02x} "
f"brightness={after.brightness!r} "
f"time_remaining={after.time_remaining_secs}s")
if after.state == before.state:
print("\n✓ panel acked the Command, state unchanged — wire path verified")
else:
print(f"\n⚠ state changed (0x{before.state:02x} → 0x{after.state:02x}). "
"Probably harmless but worth investigating.")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="192.168.1.9")
parser.add_argument("--port", type=int, default=4369)
parser.add_argument("--key", help="32 hex chars; overrides env/.omni_key")
parser.add_argument("--index", type=int, default=4)
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.WARNING,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return asyncio.run(amain(args))
if __name__ == "__main__":
sys.exit(main())

View File

@ -10,10 +10,8 @@ from __future__ import annotations
import argparse
import asyncio
import logging
import os
import signal
import sys
from pathlib import Path
from omni_pca.mock_panel import (
MockAreaState,
@ -24,55 +22,10 @@ from omni_pca.mock_panel import (
MockUnitState,
MockZoneState,
)
from omni_pca.commands import Command
from omni_pca.pca_file import KEY_EXPORT, parse_pca01_cfg
from omni_pca.programs import Days, Program, ProgramType
DEFAULT_KEY_HEX = "000102030405060708090a0b0c0d0e0f"
def _seed_programs() -> dict[int, bytes]:
"""A handful of programs covering compact-form + clausal-chain shapes.
Slot 200..202 is a chain with a structured-AND condition whose Arg2
is itself a Thermostat reference exercises the Arg2-as-object
editor controls.
"""
programs: dict[int, Program] = {
12: Program(
slot=12, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=1,
hour=6, minute=0, days=int(Days.MONDAY | Days.FRIDAY),
),
42: Program(
slot=42, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_OFF), pr2=2,
hour=22, minute=30, days=int(Days.SUNDAY),
),
# Chain: WHEN zone 1 not-ready, AND IF Thermostat 1.Temp >
# Thermostat 2.Temp, THEN turn ON unit 3. The AND record is a
# structured-OP comparison with Arg2 as a Thermostat reference.
200: Program(
slot=200, prog_type=int(ProgramType.WHEN),
month=0x04, day=0x01,
),
201: Program(
slot=201, prog_type=int(ProgramType.AND),
cond=(4 << 8) | 4, # op=GT (4), arg1Type=Thermostat (4)
cond2=1, # arg1Ix=1
cmd=1, # arg1Field=current temp
par=4, # arg2Type=Thermostat (4)
pr2=2, # arg2Ix=2
month=1, # arg2Field=current temp
),
202: Program(
slot=202, prog_type=int(ProgramType.THEN),
cmd=int(Command.UNIT_ON), pr2=3,
),
}
return {slot: p.encode_wire_bytes() for slot, p in programs.items()}
def _populated_state() -> MockState:
"""A small but representative set of objects so HA shows real entities."""
return MockState(
@ -103,50 +56,11 @@ def _populated_state() -> MockState:
3: MockButtonState(name="GOODNIGHT"),
},
user_codes={1: 1234, 2: 5678},
programs=_seed_programs(),
)
def _key_for_pca(path: Path, override: int | None) -> int:
"""Pick the decryption key for a .pca file.
Priority:
1. Explicit override (CLI / env var).
2. Per-installation key from a sibling ``PCA01.CFG`` (most common
PC Access ships each export with a matching config file).
3. ``KEY_EXPORT`` as a last resort for vanilla exports.
"""
if override is not None:
return override
cfg_path = path.parent / "PCA01.CFG"
if cfg_path.is_file():
cfg = parse_pca01_cfg(cfg_path.read_bytes())
logging.info("derived pca_key from %s: 0x%08X", cfg_path.name, cfg.pca_key)
return cfg.pca_key
logging.info("no sibling PCA01.CFG; falling back to KEY_EXPORT")
return KEY_EXPORT
def _state_from_pca(path: Path, key: int) -> MockState:
"""Seed a MockState from a real .pca file."""
state = MockState.from_pca(str(path), key=key)
logging.info(
"loaded %s: %d zones, %d units, %d areas, %d thermostats, %d programs",
path.name,
len(state.zones), len(state.units), len(state.areas),
len(state.thermostats), len(state.programs),
)
return state
async def _serve(
host: str, port: int, key: bytes, pca: Path | None, pca_key: int | None,
) -> None:
if pca is not None:
state = _state_from_pca(pca, _key_for_pca(pca, pca_key))
else:
state = _populated_state()
panel = MockPanel(controller_key=key, state=state)
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())
@ -172,23 +86,6 @@ def main() -> int:
default=DEFAULT_KEY_HEX,
help="32 hex chars; default is the docker-compose value",
)
parser.add_argument(
"--pca",
default=os.environ.get("OMNI_PCA_FIXTURE"),
help="Path to a .pca file. When supplied, the mock seeds its "
"state from this file instead of the hard-coded sample. "
"Can also be set via OMNI_PCA_FIXTURE.",
)
parser.add_argument(
"--pca-key",
type=lambda s: int(s, 0),
default=(
int(os.environ["OMNI_PCA_FIXTURE_KEY"], 0)
if os.environ.get("OMNI_PCA_FIXTURE_KEY") else None
),
help="32-bit decryption key for --pca. Default: auto-derive from "
"a sibling PCA01.CFG, or fall back to KEY_EXPORT.",
)
args = parser.parse_args()
logging.basicConfig(
@ -207,14 +104,7 @@ def main() -> int:
file=sys.stderr)
return 2
pca_path: Path | None = None
if args.pca:
pca_path = Path(args.pca)
if not pca_path.is_file():
print(f"--pca path not found: {pca_path}", file=sys.stderr)
return 2
asyncio.run(_serve(args.host, args.port, key, pca_path, args.pca_key))
asyncio.run(_serve(args.host, args.port, key))
return 0

View File

@ -32,17 +32,7 @@ async def _complete_onboarding(
) -> None:
"""POST every remaining onboarding step in turn so HA stops greeting us."""
r = await client.get("/api/onboarding")
if r.status_code != 200:
# Endpoint disappears once onboarding is fully complete — nothing
# to do, the user is already past the welcome wizard.
print(" onboarding endpoint 404 — already complete")
return
try:
steps = r.json()
except Exception:
print(" onboarding endpoint returned non-JSON — assuming complete")
return
pending = [s["step"] for s in steps if not s.get("done")]
pending = [s["step"] for s in r.json() if not s.get("done")]
print(f" pending onboarding: {pending}")
if "core_config" in pending:
@ -83,15 +73,7 @@ async def _onboard(ha_url: str) -> str:
"""
async with httpx.AsyncClient(base_url=ha_url, timeout=30.0) as client:
r = await client.get("/api/onboarding")
# Once onboarding is fully complete the endpoint 404s with a
# plain-text body instead of a JSON step list — skip straight to
# the subsequent-run login path in that case.
steps: list[dict] = []
if r.status_code == 200:
try:
steps = r.json()
except Exception:
steps = []
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"):
@ -217,8 +199,7 @@ async def _take_screenshots(ha_url: str, token: str, outdir: Path) -> list[Path]
viewport={"width": 1440, "height": 900},
device_scale_factor=2,
)
# Inject auth so we skip the login screen + force HA's dark theme
# so screenshots match the docs site's default theme.
# Inject auth so we skip the login screen.
await context.add_init_script(
f"""window.localStorage.setItem('hassTokens', JSON.stringify({{
access_token: '{token}',
@ -230,15 +211,6 @@ async def _take_screenshots(ha_url: str, token: str, outdir: Path) -> list[Path]
refresh_token: 'placeholder',
}}));
window.localStorage.setItem('selectedLanguage', '"en"');
// Force dark theme HA reads selectedTheme from localStorage
// before the user-settings panel loads. The empty 'theme' object
// tells HA "use the default dark theme, not a custom one".
window.localStorage.setItem('selectedTheme', JSON.stringify({{
theme: 'default',
dark: true,
primaryColor: null,
accentColor: null,
}}));
"""
)
page = await context.new_page()
@ -298,72 +270,6 @@ async def _take_screenshots(ha_url: str, token: str, outdir: Path) -> list[Path]
await shot("06-developer-states.png",
"/developer-tools/state", wait_secs=4.0)
# The side panel registered by panel_custom (websocket.py:
# async_register_side_panel). If pointed at a real panel the
# program list is whatever the homeowner has authored; against
# the mock it's whatever ``run_mock_panel.py`` seeded. We
# deliberately do NOT write programs from here because the
# screenshot script may be aimed at real hardware.
await shot("08-side-panel-programs.png",
"/omni-panel-programs", wait_secs=6.0)
# Helper: locate the omni-panel-programs element regardless of
# what shadow-DOM path HA's panel host wraps it in. Recursive
# walk because partial-panel-resolver / hui-view / etc. can
# vary between HA versions.
find_panel_js = """
(() => {
function find(root, depth=0) {
if (!root || depth > 15) return null;
if (root.tagName === 'OMNI-PANEL-PROGRAMS') return root;
for (const k of Array.from(root.children || [])) {
const r = find(k, depth+1);
if (r) return r;
}
if (root.shadowRoot) {
const r = find(root.shadowRoot, depth+1);
if (r) return r;
}
return null;
}
return find(document.body);
})()
"""
# Click into the first program row to capture the detail panel.
try:
await page.evaluate(f"""() => {{
const panel = {find_panel_js};
if (!panel) {{ console.warn('omni panel not found'); return; }}
const row = panel.shadowRoot.querySelector('.row');
if (row) row.click();
}}""")
await page.wait_for_timeout(800)
except Exception as e:
print(f" click-into-row warning: {e}")
# Re-shoot WITHOUT a navigate (page.goto would reset selection).
await page.screenshot(path=str(outdir / "09-side-panel-detail.png"),
full_page=False)
shots.append(outdir / "09-side-panel-detail.png")
print(f" → 09-side-panel-detail.png (in-place)")
# Click "Edit" to capture the editor mode.
try:
await page.evaluate(f"""() => {{
const panel = {find_panel_js};
if (!panel) {{ console.warn('omni panel not found'); return; }}
const buttons = panel.shadowRoot.querySelectorAll('.detail button');
for (const b of buttons) {{
if (b.textContent.trim() === 'Edit') {{ b.click(); break; }}
}}
}}""")
await page.wait_for_timeout(800)
except Exception as e:
print(f" click-edit warning: {e}")
await page.screenshot(path=str(outdir / "10-side-panel-editor.png"),
full_page=False)
shots.append(outdir / "10-side-panel-editor.png")
print(f" → 10-side-panel-editor.png (in-place)")
await browser.close()
return shots

View File

@ -1,150 +0,0 @@
#!/usr/bin/env python3
"""Focused screenshot of the structured-AND Arg2-as-object editor.
Drives an already-onboarded HA at localhost:8123, opens the side panel,
clicks into the chain at slot 200, hits Edit, and snaps the form.
"""
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
HA_URL = "http://localhost:8123"
USERNAME = "demo"
PASSWORD = "demo-password-1234"
async def _login_token() -> str:
async with httpx.AsyncClient(base_url=HA_URL, timeout=30) as c:
r = await c.post(
"/auth/login_flow",
json={
"client_id": HA_URL,
"handler": ["homeassistant", None],
"redirect_uri": HA_URL,
},
)
flow_id = r.json()["flow_id"]
r = await c.post(
f"/auth/login_flow/{flow_id}",
json={
"username": USERNAME,
"password": PASSWORD,
"client_id": HA_URL,
},
)
code = r.json()["result"]
r = await c.post(
"/auth/token",
data={
"client_id": HA_URL,
"grant_type": "authorization_code",
"code": code,
},
)
return r.json()["access_token"]
FIND_PANEL = """
(() => {
function find(root, depth=0) {
if (!root || depth > 15) return null;
if (root.tagName === 'OMNI-PANEL-PROGRAMS') return root;
for (const k of Array.from(root.children || [])) {
const r = find(k, depth+1);
if (r) return r;
}
if (root.shadowRoot) {
const r = find(root.shadowRoot, depth+1);
if (r) return r;
}
return null;
}
return find(document.body);
})()
"""
async def amain(outdir: Path) -> None:
token = await _login_token()
outdir.mkdir(parents=True, exist_ok=True)
async with async_playwright() as p:
browser = await p.chromium.launch()
context = await browser.new_context(viewport={"width": 1400, "height": 900})
await context.add_init_script(f"""
window.localStorage.setItem(
'hassTokens',
JSON.stringify({{
access_token: '{token}',
token_type: 'Bearer',
refresh_token: '',
expires: Date.now() + 3600000,
hassUrl: '{HA_URL}',
clientId: '{HA_URL}',
}})
);
window.localStorage.setItem('selectedTheme', JSON.stringify({{dark: false}}));
""")
page = await context.new_page()
page.on("console", lambda m: print(f" [browser] {m.type}: {m.text}"))
await page.goto(f"{HA_URL}/omni-panel-programs", wait_until="domcontentloaded")
await page.wait_for_timeout(6000)
# Click the chain row (slot 200).
ok = await page.evaluate(f"""() => {{
const panel = {FIND_PANEL};
if (!panel) return 'no-panel';
const rows = Array.from(panel.shadowRoot.querySelectorAll('.row'));
const target = rows.find(r => r.textContent.includes('200'));
if (!target) return 'no-row-200 ' + rows.map(r => r.textContent.slice(0,40)).join(' | ');
target.click();
return 'clicked';
}}""")
print(f" row-click: {ok}")
await page.wait_for_timeout(800)
# Click Edit.
ok = await page.evaluate(f"""() => {{
const panel = {FIND_PANEL};
if (!panel) return 'no-panel';
const buttons = panel.shadowRoot.querySelectorAll('.detail button');
for (const b of buttons) {{
if (b.textContent.trim() === 'Edit') {{ b.click(); return 'clicked'; }}
}}
return 'no-edit-button';
}}""")
print(f" edit-click: {ok}")
await page.wait_for_timeout(1500)
path = outdir / "arg2-object-editor.png"
await page.screenshot(path=str(path), full_page=True)
print(f" wrote {path}")
await browser.close()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--outdir",
type=Path,
default=Path(__file__).parent / "artifacts" / "screenshots" /
datetime.now().strftime("%Y-%m-%d"),
)
args = parser.parse_args()
asyncio.run(amain(args.outdir))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""Quick screenshot of the Omni Programs side panel landing page."""
from __future__ import annotations
import asyncio
import sys
from datetime import datetime
from pathlib import Path
import httpx
from playwright.async_api import async_playwright
HA_URL = "http://localhost:8123"
USERNAME = "demo"
PASSWORD = "demo-password-1234"
async def _login_token() -> str:
async with httpx.AsyncClient(base_url=HA_URL, timeout=30) as c:
r = await c.post("/auth/login_flow", json={
"client_id": HA_URL, "handler": ["homeassistant", None],
"redirect_uri": HA_URL,
})
flow_id = r.json()["flow_id"]
r = await c.post(f"/auth/login_flow/{flow_id}", json={
"username": USERNAME, "password": PASSWORD, "client_id": HA_URL,
})
code = r.json()["result"]
r = await c.post("/auth/token", data={
"client_id": HA_URL, "grant_type": "authorization_code", "code": code,
})
return r.json()["access_token"]
async def amain(outdir: Path) -> None:
token = await _login_token()
outdir.mkdir(parents=True, exist_ok=True)
async with async_playwright() as p:
browser = await p.chromium.launch()
context = await browser.new_context(viewport={"width": 1400, "height": 900})
await context.add_init_script(f"""
window.localStorage.setItem('hassTokens', JSON.stringify({{
access_token: '{token}', token_type: 'Bearer', refresh_token: '',
expires: Date.now() + 3600000, hassUrl: '{HA_URL}', clientId: '{HA_URL}',
}}));
window.localStorage.setItem('selectedTheme', JSON.stringify({{dark: false}}));
""")
page = await context.new_page()
await page.goto(f"{HA_URL}/omni-panel-programs", wait_until="domcontentloaded")
await page.wait_for_timeout(8000)
path = outdir / "real-pca-overview.png"
await page.screenshot(path=str(path), full_page=True)
print(f" wrote {path}")
await browser.close()
if __name__ == "__main__":
outdir = Path(sys.argv[1]) if len(sys.argv) > 1 else (
Path(__file__).parent / "artifacts" / "screenshots" /
datetime.now().strftime("%Y-%m-%d")
)
asyncio.run(amain(outdir))

View File

@ -1,19 +0,0 @@
# Copy to .env and fill in. Both files in this directory load .env
# automatically via docker compose; ./env.example is committed, .env
# is gitignored.
# InfluxDB v2 admin user (created on first boot).
INFLUX_USERNAME=admin
INFLUX_PASSWORD=change-me-strong-password-here
# Admin token used by Home Assistant (writes) and Grafana (reads).
# Generate one with: openssl rand -hex 32
INFLUX_TOKEN=replace-with-a-real-token-from-openssl-rand-hex-32
# Grafana admin password (UI login as "admin"/this value).
GRAFANA_PASSWORD=change-me-too
# Public hostnames if you're putting either service behind a reverse
# proxy. Leave blank for localhost-only access.
INFLUX_PUBLIC_HOST=
GRAFANA_PUBLIC_HOST=

View File

@ -1,129 +0,0 @@
# Grafana dashboard for omni_pca
InfluxDB v2 + Grafana stack pre-provisioned to visualise an HAI/Leviton
Omni Pro II panel via the `omni_pca` Home Assistant integration.
Drop-in for any existing HA install — no integration changes required.
![Dashboard overview](../dev/artifacts/screenshots/2026-05-17/grafana-dashboard-final.png)
## What you get
One dashboard, four rows:
- **System health** — AC power, backup battery, system trouble, event count (24h).
- **Security** — area arming state timeline, recent push-event log, zone trip timeline.
- **Climate** — per-thermostat current temperatures + setpoints, HVAC mode timeline.
- **Activity** — event rate by typed event class, unit brightness heatmap.
Data flows: HA entity state → HA's `influxdb:` integration → InfluxDB
v2 bucket → Grafana Flux queries → dashboard panels.
## Quick start (~5 minutes)
```bash
cd grafana/
cp .env.example .env
# Edit .env — set strong INFLUX_PASSWORD, INFLUX_TOKEN, GRAFANA_PASSWORD.
# Generate the token with: openssl rand -hex 32
docker compose up -d
```
Wait ~30 seconds. InfluxDB does first-boot setup (creates the
`omni-pca` org, `ha` bucket, admin token); Grafana then auto-provisions
the InfluxDB datasource and the dashboard.
Then add the influxdb integration to your Home Assistant config:
```bash
# Paste the contents of ha-snippet.yaml into your configuration.yaml.
# Add `influxdb_token: <your INFLUX_TOKEN from .env>` to your secrets.yaml.
# Restart HA.
```
Within ~30 seconds you should see real-time data populating the
dashboard at <http://localhost:3000> (login: `admin` / your
`GRAFANA_PASSWORD`).
## Networking notes
The default `ha-snippet.yaml` assumes HA and InfluxDB sit on the same
docker network and HA can reach `influxdb:8086` by container name.
Three common variants:
| HA layout | `host:` value |
|---|---|
| Same compose stack as this bundle | `influxdb` |
| HA on the host, InfluxDB in docker | `host.docker.internal` or your LAN IP |
| Different machine entirely | the InfluxDB host's IP / FQDN |
If you put either service behind a reverse proxy with TLS, set `ssl:
true` in the HA snippet and supply the public hostname.
## Iterating on the dashboard
The dashboard JSON at `provisioning/dashboards/omni-pro-ii.json` is
loaded read-only by the provisioner. To change it:
1. Edit the JSON directly, then `docker compose restart grafana`
(provisioner picks up changes within ~30s).
2. Or use the Grafana UI to experiment, then **Dashboard settings →
JSON Model → Save to file** and overwrite the file in this repo.
Provisioned dashboards can't be saved from the UI by design — this is
intentional, so the file on disk stays the source of truth.
## Extending coverage
The bundle is scoped to the `omni_pca` entity surface via the
`entity_globs: ["*omni*"]` filter in `ha-snippet.yaml`. Drop that
filter (or add a second `include:` block) if you want to graph other
HA entities alongside omni data — Grafana's datasource is general
InfluxDB v2, nothing in the dashboard JSON hard-codes omni-specific
field names beyond what you'd want to scope to anyway.
A few panel ideas not yet shipped:
- Alarm activation drill-down — filter the event log to
`event_type == "alarm_activated"` and show the `alarm_type`
(Burglary / Fire / Auxiliary / …) distribution.
- Zone trip rate histogram — `binary_sensor` zone changes per zone
per hour, useful for spotting flaky sensors.
- Comm health — track integration coordinator state via the panel
device's "Comm error" attribute.
## Files in this bundle
| File | Purpose |
|---|---|
| `docker-compose.yml` | InfluxDB v2 + Grafana services |
| `.env.example` | Required environment template |
| `ha-snippet.yaml` | HA configuration.yaml additions |
| `provisioning/datasources/influxdb.yml` | Auto-wires the datasource |
| `provisioning/dashboards/dashboards.yml` | Provisioner config |
| `provisioning/dashboards/omni-pro-ii.json` | The dashboard JSON |
## Troubleshooting
**"No data" in panels.** Most panels need either continuous state
updates (climate, security) or push events (event-driven panels).
Verify HA is shipping data:
```bash
docker exec -it omni-pca-influxdb influx query \
'from(bucket:"ha") |> range(start:-5m) |> limit(n:5)' \
--token "$INFLUX_TOKEN" --org omni-pca
```
If this returns rows, the pipeline is healthy and panels will fill in
as the panel does interesting things. If it's empty, check HA logs for
`[homeassistant.components.influxdb]` errors.
**Dashboard didn't auto-load.** Check `docker logs omni-pca-grafana
2>&1 | grep -i provision` — provisioner errors show up there.
**Stat panels show duplicate values.** Your HA has multiple entities
matching the regex (e.g. `omni_pro_ii_ac_power` AND
`omni_pro_ii_ac_power_2` from prior integration reloads). Clean up the
duplicates in HA's entity registry, or tighten the filter in the
dashboard JSON.

View File

@ -1,69 +0,0 @@
# Self-contained InfluxDB v2 + Grafana stack for the omni_pca
# integration. Pre-provisioned with the InfluxDB datasource and the
# "Omni Pro II — Panel Overview" dashboard.
#
# Usage:
# cp .env.example .env && edit the secrets && docker compose up -d
# open http://localhost:3000 (admin / $GRAFANA_PASSWORD)
#
# Then paste the contents of ha-snippet.yaml into your HA
# configuration.yaml (and add `influxdb_token: $INFLUX_TOKEN` to
# secrets.yaml). Restart HA. Within 30s the dashboard's panels start
# filling in.
services:
influxdb:
image: influxdb:2.7-alpine
container_name: omni-pca-influxdb
restart: unless-stopped
environment:
DOCKER_INFLUXDB_INIT_MODE: setup
DOCKER_INFLUXDB_INIT_USERNAME: ${INFLUX_USERNAME:-admin}
DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUX_PASSWORD}
DOCKER_INFLUXDB_INIT_ORG: omni-pca
DOCKER_INFLUXDB_INIT_BUCKET: ha
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUX_TOKEN}
DOCKER_INFLUXDB_INIT_RETENTION: 30d
volumes:
- influxdb-data:/var/lib/influxdb2
- influxdb-config:/etc/influxdb2
ports:
- "8086:8086"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8086/health"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
grafana:
image: grafana/grafana:11.4.0
container_name: omni-pca-grafana
restart: unless-stopped
depends_on:
influxdb:
condition: service_healthy
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
GF_AUTH_ANONYMOUS_ENABLED: "false"
GF_USERS_ALLOW_SIGN_UP: "false"
GF_LOG_LEVEL: warn
# Consumed by ./provisioning/datasources/influxdb.yml
INFLUX_URL: http://influxdb:8086
INFLUX_TOKEN: ${INFLUX_TOKEN}
volumes:
- grafana-data:/var/lib/grafana
- ./provisioning:/etc/grafana/provisioning:ro
ports:
- "3000:3000"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/health || exit 1"]
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
volumes:
influxdb-data:
influxdb-config:
grafana-data:

View File

@ -1,51 +0,0 @@
# Paste this block into your Home Assistant configuration.yaml.
#
# Prerequisites:
# 1. The grafana stack from this directory is running:
# cd grafana/ && cp .env.example .env && docker compose up -d
# 2. Your HA instance can reach the influxdb container on port 8086.
# Common patterns:
# - HA and InfluxDB on the same compose stack: use host=influxdb
# - HA and InfluxDB on different hosts: use host=<your-influx-ip>
# - HA on the host network, InfluxDB in docker: use
# host=host.docker.internal or the host's LAN IP
# 3. Add `influxdb_token: <your INFLUX_TOKEN from .env>` to your
# secrets.yaml. Restart HA after editing both files.
#
# What this ships:
# - All state changes from omni_pca entities (alarm_control_panel,
# binary_sensor, climate, event, light, sensor, switch).
# - Event entity attributes carried as fields, including the typed
# event_class and event_data payload — so Flux queries can filter
# by alarm_type, zone_index, etc.
#
# Adjust the entity_globs filter if you also want non-omni entities in
# the dashboard, or tighten it further to scope by area / device.
influxdb:
api_version: 2
host: influxdb # change to match your network layout
port: 8086
ssl: false
verify_ssl: false
token: !secret influxdb_token
organization: omni-pca
bucket: ha
precision: s
# Tag the typed event kind so Flux queries can filter by it cheaply.
tags_attributes:
- event_type
- event_class
include:
domains:
- alarm_control_panel
- binary_sensor
- climate
- event
- light
- sensor
- switch
entity_globs:
- "*omni*" # scope to omni_pca entities only

View File

@ -1,19 +0,0 @@
# Tells Grafana to scan /etc/grafana/provisioning/dashboards for
# *.json dashboard files at boot. Picks up omni-pro-ii.json
# automatically. Dashboards loaded this way are read-only in the UI;
# the source of truth is the JSON in this directory.
apiVersion: 1
providers:
- name: omni-pca
orgId: 1
folder: ''
folderUid: ''
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: false
options:
path: /etc/grafana/provisioning/dashboards
foldersFromFilesStructure: false

View File

@ -1,682 +0,0 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Live view of an HAI/Leviton Omni Pro II panel surfaced by the omni_pca Home Assistant integration. System health, security activity, climate trends, and the typed push-event stream — all sourced from InfluxDB writes shipped by HA's influxdb integration.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 0},
"id": 100,
"panels": [],
"title": "System health",
"type": "row"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [
{"options": {"0": {"color": "red", "index": 0, "text": "LOST"}}, "type": "value"},
{"options": {"1": {"color": "green", "index": 1, "text": "OK"}}, "type": "value"}
],
"thresholds": {"mode": "absolute", "steps": [{"color": "red"}, {"color": "green", "value": 1}]},
"unit": "none"
}
},
"gridPos": {"h": 5, "w": 6, "x": 0, "y": 1},
"id": 101,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: -24h)\n |> filter(fn: (r) => r.domain == \"binary_sensor\")\n |> filter(fn: (r) => r.entity_id =~ /ac_power/ and not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> last()\n |> group()",
"refId": "A"
}
],
"title": "AC power",
"type": "stat"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [
{"options": {"0": {"color": "green", "index": 0, "text": "OK"}}, "type": "value"},
{"options": {"1": {"color": "red", "index": 1, "text": "LOW"}}, "type": "value"}
],
"thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "red", "value": 1}]}
}
},
"gridPos": {"h": 5, "w": 6, "x": 6, "y": 1},
"id": 102,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: -24h)\n |> filter(fn: (r) => r.domain == \"binary_sensor\")\n |> filter(fn: (r) => r.entity_id =~ /battery/ and not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> last()\n |> group()",
"refId": "A"
}
],
"title": "Backup battery",
"type": "stat"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [
{"options": {"0": {"color": "green", "index": 0, "text": "Clear"}}, "type": "value"},
{"options": {"1": {"color": "red", "index": 1, "text": "Trouble"}}, "type": "value"}
],
"thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "red", "value": 1}]}
}
},
"gridPos": {"h": 5, "w": 6, "x": 12, "y": 1},
"id": 103,
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: -24h)\n |> filter(fn: (r) => r.domain == \"binary_sensor\")\n |> filter(fn: (r) => r.entity_id =~ /trouble/ and not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> last()\n |> group()",
"refId": "A"
}
],
"title": "System trouble",
"type": "stat"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"description": "Count of panel push events in the last 24 hours. Empty until the panel pushes its first event (the mock fires events when HA actions trigger panel state changes; a real panel pushes continuously).",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [{"color": "blue"}, {"color": "green", "value": 1}]},
"unit": "short"
}
},
"gridPos": {"h": 5, "w": 6, "x": 18, "y": 1},
"id": 104,
"options": {
"colorMode": "background",
"graphMode": "area",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: -24h)\n |> filter(fn: (r) => r.domain == \"event\")\n |> filter(fn: (r) => r._field == \"state\")\n |> group()\n |> count()",
"refId": "A"
}
],
"title": "Events (24h)",
"type": "stat"
},
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 6},
"id": 200,
"panels": [],
"title": "Security",
"type": "row"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"description": "Arming state per area. Disarmed = green, day = teal, night = blue, away = orange, vacation = magenta, triggered = red, arming/pending = yellow.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {"fillOpacity": 80, "lineWidth": 0},
"mappings": [
{"options": {"disarmed": {"color": "#43aa8b", "text": "disarmed"}}, "type": "value"},
{"options": {"armed_home": {"color": "#577590", "text": "armed home"}}, "type": "value"},
{"options": {"armed_night": {"color": "#277da1", "text": "armed night"}}, "type": "value"},
{"options": {"armed_away": {"color": "#f8961e", "text": "armed away"}}, "type": "value"},
{"options": {"armed_vacation": {"color": "#a663cc", "text": "armed vacation"}}, "type": "value"},
{"options": {"armed_custom_bypass": {"color": "#90be6d", "text": "armed custom"}}, "type": "value"},
{"options": {"arming": {"color": "#f9c74f", "text": "arming"}}, "type": "value"},
{"options": {"pending": {"color": "#f9c74f", "text": "pending"}}, "type": "value"},
{"options": {"triggered": {"color": "#d62828", "text": "TRIGGERED"}}, "type": "value"}
],
"thresholds": {"mode": "absolute", "steps": [{"color": "#6c757d"}]}
}
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 7},
"id": 201,
"options": {
"legend": {"displayMode": "list", "placement": "bottom", "showLegend": true},
"mergeValues": true,
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {"mode": "single"}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"alarm_control_panel\")\n |> filter(fn: (r) => r._field == \"state\")\n |> filter(fn: (r) => not (r.entity_id =~ /_[0-9]+$/))\n |> keep(columns: [\"_time\", \"_value\", \"entity_id\"])\n |> group(columns: [\"entity_id\"])",
"refId": "A"
}
],
"title": "Area arming state",
"type": "state-timeline"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"description": "Push events the panel sent in the selected window. Columns: time, typed event_type, object index (zone / unit / area / user), and new_state for state-changed events.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {
"align": "auto",
"cellOptions": {"type": "auto"},
"inspect": false
},
"thresholds": {"mode": "absolute", "steps": [{"color": "transparent"}]}
},
"overrides": [
{
"matcher": {"id": "byName", "options": "event_type"},
"properties": [
{"id": "custom.cellOptions", "value": {"type": "color-background", "mode": "basic"}},
{"id": "mappings", "value": [
{"options": {"alarm_activated": {"color": "#d62828", "text": "alarm_activated"}}, "type": "value"},
{"options": {"alarm_cleared": {"color": "#43aa8b", "text": "alarm_cleared"}}, "type": "value"},
{"options": {"ac_lost": {"color": "#d62828", "text": "ac_lost"}}, "type": "value"},
{"options": {"ac_restored": {"color": "#43aa8b", "text": "ac_restored"}}, "type": "value"},
{"options": {"battery_low": {"color": "#f8961e", "text": "battery_low"}}, "type": "value"},
{"options": {"battery_restored": {"color": "#43aa8b", "text": "battery_restored"}}, "type": "value"},
{"options": {"zone_state_changed": {"color": "#577590", "text": "zone_state_changed"}}, "type": "value"},
{"options": {"unit_state_changed": {"color": "#90be6d", "text": "unit_state_changed"}}, "type": "value"},
{"options": {"arming_changed": {"color": "#f9c74f", "text": "arming_changed"}}, "type": "value"},
{"options": {"user_macro_button": {"color": "#277da1", "text": "user_macro_button"}}, "type": "value"},
{"options": {"phone_line_dead": {"color": "#f8961e", "text": "phone_line_dead"}}, "type": "value"},
{"options": {"phone_line_restored": {"color": "#43aa8b", "text": "phone_line_restored"}}, "type": "value"}
]}
]
},
{
"matcher": {"id": "byName", "options": "_time"},
"properties": [
{"id": "custom.width", "value": 175},
{"id": "displayName", "value": "time"}
]
}
]
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 7},
"id": 202,
"options": {
"cellHeight": "sm",
"footer": {"countRows": false, "fields": "", "reducer": ["sum"], "show": false},
"showHeader": true,
"sortBy": [{"desc": true, "displayName": "time"}]
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"event\")\n |> filter(fn: (r) => r._field == \"new_state\" or r._field == \"unit_index\" or r._field == \"zone_index\" or r._field == \"area_index\" or r._field == \"user_index\" or r._field == \"alarm_type\" or r._field == \"button_index\")\n |> pivot(rowKey: [\"_time\", \"event_type\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> drop(columns: [\"_start\", \"_stop\", \"_measurement\", \"domain\", \"entity_id\", \"event_class\"])\n |> group()\n |> sort(columns: [\"_time\"], desc: true)\n |> limit(n: 50)",
"refId": "A"
}
],
"title": "Recent panel events",
"type": "table"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"description": "Zone open/closed timeline. Painted segments = zone is_on (open / tripped).",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {"fillOpacity": 70, "lineWidth": 0},
"mappings": [
{"options": {"0": {"color": "green", "index": 0, "text": "secure"}}, "type": "value"},
{"options": {"1": {"color": "orange", "index": 1, "text": "open"}}, "type": "value"}
],
"thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "orange", "value": 1}]}
}
},
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 15},
"id": 203,
"options": {
"legend": {"displayMode": "list", "placement": "bottom", "showLegend": false},
"mergeValues": true,
"rowHeight": 0.9,
"showValue": "never",
"tooltip": {"mode": "single"}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"binary_sensor\")\n |> filter(fn: (r) => not (r.entity_id =~ /ac_power|battery|trouble|bypass|_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> keep(columns: [\"_time\", \"_value\", \"entity_id\"])\n |> group(columns: [\"entity_id\"])",
"refId": "A"
}
],
"title": "Zone trip timeline",
"type": "state-timeline"
},
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 23},
"id": 300,
"panels": [],
"title": "Climate",
"type": "row"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"description": "Current temperature per thermostat. Mock fixture values are raw panel format; a real panel reports °F.",
"fieldConfig": {
"defaults": {
"color": {"mode": "fixed", "fixedColor": "#f1faee"},
"custom": {
"axisPlacement": "auto",
"drawStyle": "line",
"fillOpacity": 8,
"gradientMode": "opacity",
"lineInterpolation": "stepBefore",
"lineWidth": 2,
"pointSize": 4,
"showPoints": "auto",
"spanNulls": true
},
"unit": "celsius"
},
"overrides": [
{
"matcher": {"id": "byFrameRefID", "options": "A"},
"properties": [{"id": "color", "value": {"mode": "palette-classic-by-name"}}]
}
]
},
"gridPos": {"h": 9, "w": 16, "x": 0, "y": 24},
"id": 301,
"options": {
"legend": {"calcs": ["mean", "lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"climate\")\n |> filter(fn: (r) => not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"current_temperature\")\n |> keep(columns: [\"_time\", \"_value\", \"entity_id\"])\n |> group(columns: [\"entity_id\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Thermostat temperatures",
"type": "timeseries"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"description": "HVAC system mode per thermostat over the selected window. Off = grey, Heat = orange, Cool = blue, Auto = green, Dry = teal, Fan only = yellow.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {"fillOpacity": 80, "lineWidth": 0},
"mappings": [
{"options": {"off": {"color": "#adb5bd", "text": "off"}}, "type": "value"},
{"options": {"heat": {"color": "#f3722c", "text": "heat"}}, "type": "value"},
{"options": {"cool": {"color": "#277da1", "text": "cool"}}, "type": "value"},
{"options": {"heat_cool":{"color": "#43aa8b", "text": "auto"}}, "type": "value"},
{"options": {"auto": {"color": "#43aa8b", "text": "auto"}}, "type": "value"},
{"options": {"dry": {"color": "#577590", "text": "dry"}}, "type": "value"},
{"options": {"fan_only": {"color": "#f9c74f", "text": "fan only"}}, "type": "value"}
],
"thresholds": {"mode": "absolute", "steps": [{"color": "#adb5bd"}]}
}
},
"gridPos": {"h": 9, "w": 8, "x": 16, "y": 24},
"id": 302,
"options": {
"legend": {"displayMode": "list", "placement": "bottom", "showLegend": true},
"mergeValues": true,
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {"mode": "single"}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"climate\")\n |> filter(fn: (r) => r._field == \"state\")\n |> filter(fn: (r) => not (r.entity_id =~ /_[0-9]+$/))\n |> keep(columns: [\"_time\", \"_value\", \"entity_id\"])\n |> group(columns: [\"entity_id\"])",
"refId": "A"
}
],
"title": "HVAC mode",
"type": "state-timeline"
},
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 33},
"id": 400,
"panels": [],
"title": "Activity",
"type": "row"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"description": "Panel event rate, bucketed by event_type. Tracks zone state changes, button presses, alarm activation, AC/battery events, etc. Each event_type has its own color matching the events table.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"drawStyle": "bars",
"fillOpacity": 80,
"lineWidth": 0,
"showPoints": "never",
"stacking": {"mode": "normal"}
},
"unit": "short"
},
"overrides": [
{"matcher": {"id": "byName", "options": "alarm_activated"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#d62828"}}]},
{"matcher": {"id": "byName", "options": "alarm_cleared"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
{"matcher": {"id": "byName", "options": "ac_lost"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#d62828"}}]},
{"matcher": {"id": "byName", "options": "ac_restored"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
{"matcher": {"id": "byName", "options": "battery_low"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f8961e"}}]},
{"matcher": {"id": "byName", "options": "battery_restored"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
{"matcher": {"id": "byName", "options": "zone_state_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#577590"}}]},
{"matcher": {"id": "byName", "options": "unit_state_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#90be6d"}}]},
{"matcher": {"id": "byName", "options": "arming_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f9c74f"}}]},
{"matcher": {"id": "byName", "options": "user_macro_button"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#277da1"}}]},
{"matcher": {"id": "byName", "options": "phone_line_dead"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f8961e"}}]},
{"matcher": {"id": "byName", "options": "phone_line_restored"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]}
]
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 34},
"id": 401,
"options": {
"legend": {"displayMode": "table", "placement": "right", "showLegend": true, "calcs": ["sum"]},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"event\")\n |> filter(fn: (r) => r._field == \"state\")\n |> group(columns: [\"event_type\"])\n |> aggregateWindow(every: v.windowPeriod, fn: count, createEmpty: true)",
"refId": "A"
}
],
"title": "Event rate by type",
"type": "timeseries"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"description": "Top 15 most-toggled units in the selected window — bar length = number of state changes. Reveals which lights/relays get used most.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "#f9c74f", "value": null},
{"color": "#f8961e", "value": 5},
{"color": "#f3722c", "value": 15},
{"color": "#d62828", "value": 30}
]
},
"min": 0,
"unit": "short"
}
},
"gridPos": {"h": 10, "w": 12, "x": 12, "y": 34},
"id": 402,
"options": {
"displayMode": "gradient",
"valueMode": "color",
"showUnfilled": true,
"orientation": "horizontal",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "/^_value$/", "values": true},
"minVizHeight": 10,
"minVizWidth": 0,
"namePlacement": "left"
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"light\")\n |> filter(fn: (r) => not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> toFloat()\n |> keep(columns: [\"_value\", \"entity_id\"])\n |> group(columns: [\"entity_id\"])\n |> count()\n |> group()\n |> sort(columns: [\"_value\"], desc: true)\n |> limit(n: 15)",
"refId": "A"
}
],
"title": "Top toggled units (24h)",
"type": "bargauge"
},
{
"collapsed": false,
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 44},
"id": 500,
"panels": [],
"title": "Insights",
"type": "row"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"description": "Zones currently bypassed. Bypass = the panel ignores this zone for arming/alarm purposes. Empty when nothing is bypassed; rows accrue when a switch is flipped.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {
"align": "auto",
"cellOptions": {"type": "auto"},
"inspect": false
},
"thresholds": {"mode": "absolute", "steps": [{"color": "transparent"}]}
},
"overrides": [
{
"matcher": {"id": "byName", "options": "entity_id"},
"properties": [
{"id": "displayName", "value": "zone bypass switch"},
{"id": "custom.cellOptions", "value": {"type": "color-text", "wrapText": false}},
{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f9c74f"}}
]
},
{
"matcher": {"id": "byName", "options": "_time"},
"properties": [
{"id": "displayName", "value": "since"},
{"id": "custom.width", "value": 175}
]
},
{
"matcher": {"id": "byName", "options": "_value"},
"properties": [{"id": "custom.hidden", "value": true}]
}
]
},
"gridPos": {"h": 8, "w": 8, "x": 0, "y": 45},
"id": 501,
"options": {
"cellHeight": "sm",
"footer": {"countRows": true, "fields": "", "reducer": ["sum"], "show": true},
"showHeader": true,
"sortBy": [{"desc": true, "displayName": "since"}]
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: -24h)\n |> filter(fn: (r) => r.domain == \"switch\")\n |> filter(fn: (r) => not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> last()\n |> filter(fn: (r) => r._value > 0.0)\n |> keep(columns: [\"_time\", \"_value\", \"entity_id\"])\n |> group()\n |> sort(columns: [\"_time\"], desc: true)",
"refId": "A"
}
],
"title": "Active zone bypasses",
"type": "table"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"description": "User-macro button press events from the panel. Each row = one press; button_index identifies which scene/macro fired.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {
"align": "auto",
"cellOptions": {"type": "auto"},
"inspect": false
},
"thresholds": {"mode": "absolute", "steps": [{"color": "transparent"}]}
},
"overrides": [
{
"matcher": {"id": "byName", "options": "button_index"},
"properties": [
{"id": "displayName", "value": "button #"},
{"id": "custom.cellOptions", "value": {"type": "color-background", "mode": "basic"}},
{"id": "color", "value": {"mode": "fixed", "fixedColor": "#277da1"}}
]
},
{
"matcher": {"id": "byName", "options": "_time"},
"properties": [
{"id": "displayName", "value": "time"},
{"id": "custom.width", "value": 175}
]
}
]
},
"gridPos": {"h": 8, "w": 8, "x": 8, "y": 45},
"id": 502,
"options": {
"cellHeight": "sm",
"footer": {"countRows": true, "fields": "", "reducer": ["sum"], "show": true},
"showHeader": true,
"sortBy": [{"desc": true, "displayName": "time"}]
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"event\" and r.event_type == \"user_macro_button\")\n |> filter(fn: (r) => r._field == \"button_index\")\n |> keep(columns: [\"_time\", \"_value\"])\n |> group()\n |> sort(columns: [\"_time\"], desc: true)\n |> limit(n: 25)\n |> rename(columns: {_value: \"button_index\"})",
"refId": "A"
}
],
"title": "Button press log",
"type": "table"
},
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"description": "Distribution of panel push events by typed kind across the selected window. Matches the colors used in the event rate and events table panels.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"hideFrom": {"legend": false, "tooltip": false, "viz": false}
},
"mappings": []
},
"overrides": [
{"matcher": {"id": "byName", "options": "alarm_activated"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#d62828"}}]},
{"matcher": {"id": "byName", "options": "alarm_cleared"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
{"matcher": {"id": "byName", "options": "ac_lost"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#d62828"}}]},
{"matcher": {"id": "byName", "options": "ac_restored"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
{"matcher": {"id": "byName", "options": "battery_low"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f8961e"}}]},
{"matcher": {"id": "byName", "options": "battery_restored"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
{"matcher": {"id": "byName", "options": "zone_state_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#577590"}}]},
{"matcher": {"id": "byName", "options": "unit_state_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#90be6d"}}]},
{"matcher": {"id": "byName", "options": "arming_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f9c74f"}}]},
{"matcher": {"id": "byName", "options": "user_macro_button"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#277da1"}}]}
]
},
"gridPos": {"h": 8, "w": 8, "x": 16, "y": 45},
"id": 503,
"options": {
"displayLabels": ["percent", "name"],
"legend": {"displayMode": "table", "placement": "right", "showLegend": true, "values": ["value"]},
"pieType": "donut",
"reduceOptions": {"calcs": ["sum"], "fields": "", "values": false},
"tooltip": {"mode": "single", "sort": "none"}
},
"pluginVersion": "11.4.0",
"targets": [
{
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"event\" and r._field == \"state\")\n |> keep(columns: [\"_time\", \"_value\", \"event_type\"])\n |> group(columns: [\"event_type\"])\n |> count(column: \"_value\")\n |> map(fn: (r) => ({_time: now(), _value: r._value, event_type: r.event_type}))\n |> pivot(rowKey: [\"_time\"], columnKey: [\"event_type\"], valueColumn: \"_value\")",
"refId": "A"
}
],
"title": "Event distribution",
"type": "piechart"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["omni-pca", "hai", "omni-pro-ii", "home-assistant"],
"templating": {
"list": [
{
"current": {"selected": true, "text": "All", "value": "$__all"},
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
"definition": "from(bucket: \"ha\") |> range(start: -7d) |> filter(fn: (r) => r.domain == \"event\" and r._field == \"state\") |> keep(columns: [\"event_type\"]) |> group() |> distinct(column: \"event_type\")",
"hide": 0,
"includeAll": true,
"label": "Event type",
"multi": true,
"name": "event_type",
"options": [],
"query": "from(bucket: \"ha\") |> range(start: -7d) |> filter(fn: (r) => r.domain == \"event\" and r._field == \"state\") |> keep(columns: [\"event_type\"]) |> group() |> distinct(column: \"event_type\")",
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
}
]
},
"time": {"from": "now-24h", "to": "now"},
"timepicker": {
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h"],
"time_options": ["1h", "6h", "24h", "2d", "7d", "30d"]
},
"timezone": "browser",
"title": "Omni Pro II — Panel Overview",
"uid": "omni-pro-ii-overview",
"version": 1,
"weekStart": ""
}

View File

@ -1,21 +0,0 @@
# Auto-wires the InfluxDB v2 datasource at Grafana boot. Picks up
# INFLUX_URL and INFLUX_TOKEN from the grafana container's environment
# (set in docker-compose.yml from .env). No manual datasource config
# needed.
apiVersion: 1
datasources:
- name: InfluxDB
type: influxdb
access: proxy
url: ${INFLUX_URL}
isDefault: true
editable: false
jsonData:
version: Flux
organization: omni-pca
defaultBucket: ha
tlsSkipVerify: true
secureJsonData:
token: ${INFLUX_TOKEN}

View File

@ -1,6 +1,6 @@
[project]
name = "omni-pca"
version = "2026.5.16"
version = "2026.5.10"
description = "Async Python client for HAI/Leviton Omni-Link II home automation panels (Omni Pro II, Omni IIe, Omni LTe, Lumina)."
readme = "README.md"
license = { text = "MIT" }
@ -24,20 +24,12 @@ dependencies = [
[project.optional-dependencies]
cli = ["rich>=13.9.0", "typer>=0.15.0"]
# astral provides sunrise/sunset computation for the program engine's
# AT_SUNRISE / AT_SUNSET TIMED-program sentinels. Pure Python, MIT.
# Pinned to 2.2 for compatibility with Home Assistant (which pins it
# to exactly that version) so the `ha` group still resolves.
engine = ["astral>=2.2,<3"]
[project.scripts]
omni-pca = "omni_pca.__main__:main"
[project.urls]
Repository = "https://github.com/rsp2k/omni-pca"
Issues = "https://github.com/rsp2k/omni-pca/issues"
Changelog = "https://github.com/rsp2k/omni-pca/blob/main/CHANGELOG.md"
Documentation = "https://hai-omni-pro-ii.warehack.ing/"
Repository = "https://git.supported.systems/warehack.ing/omni-pca"
[build-system]
requires = ["uv_build>=0.11.8,<0.12.0"]

View File

@ -2,34 +2,9 @@
from importlib.metadata import PackageNotFoundError, version
from .programs import (
Condition,
ConditionFamily,
Days,
MiscConditional,
Program,
ProgramCond,
ProgramType,
TimeKind,
decode_program_table,
iter_defined,
)
try:
__version__ = version("omni-pca")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
__all__ = [
"Condition",
"ConditionFamily",
"Days",
"MiscConditional",
"Program",
"ProgramCond",
"ProgramType",
"TimeKind",
"__version__",
"decode_program_table",
"iter_defined",
]
__all__ = ["__version__"]

View File

@ -20,7 +20,7 @@ import struct
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from enum import IntEnum
from types import TracebackType
from typing import TYPE_CHECKING, Literal, Self
from typing import TYPE_CHECKING, Self
from .commands import Command, CommandFailedError, SecurityCommandResponse
@ -120,20 +120,12 @@ class OmniClient:
*,
controller_key: bytes,
timeout: float = 5.0,
transport: Literal["tcp", "udp"] = "tcp",
udp_retry_count: int = 3,
) -> None:
"""``transport='udp'`` if your panel is configured for the
``Network_UDP`` connection type (some firmware versions and the
default for many installs). ``udp_retry_count`` is ignored on TCP.
"""
self._conn = OmniConnection(
host=host,
port=port,
controller_key=controller_key,
timeout=timeout,
transport=transport,
udp_retry_count=udp_retry_count,
)
self._subscriber_task: asyncio.Task[None] | None = None
@ -607,92 +599,6 @@ class OmniClient:
"""
await self.execute_command(Command.CLEAR_MESSAGE, parameter2=index)
# ---- program enumeration --------------------------------------------
async def iter_programs(self) -> AsyncIterator["Program"]:
"""Stream every defined program from the panel.
v2 has no bulk "send all programs" opcode; instead the panel
exposes an iterator semantic via ``UploadProgram`` with
``request_reason=1`` ("next defined after this slot"). We seed
with slot 0 and follow each reply's ``ProgramNumber`` back into
the next request until the panel sends EOD.
Mirrors the C# ReadConfig loop at ``clsHAC.OL2ReadConfigProcessProgramData``
(clsHAC.cs:5323-5332) and the seed call at clsHAC.cs:4985.
Yields decoded :class:`omni_pca.programs.Program` instances, one
per defined slot in ascending slot order. Empty slots are
skipped by the panel the iterator only sees defined programs.
"""
from .programs import Program # local import: avoids cycle in __init__
slot = 0
while True:
payload = bytes([(slot >> 8) & 0xFF, slot & 0xFF, 1])
reply = await self._conn.request(
OmniLink2MessageType.UploadProgram, payload
)
if reply.opcode == int(OmniLink2MessageType.EOD):
return
if reply.opcode != int(OmniLink2MessageType.ProgramData):
raise OmniConnectionError(
f"unexpected opcode {reply.opcode} during UploadProgram iteration "
f"(expected {int(OmniLink2MessageType.ProgramData)})"
)
if len(reply.payload) < 2 + 14:
raise OmniConnectionError(
f"ProgramData payload too short ({len(reply.payload)} bytes)"
)
slot = (reply.payload[0] << 8) | reply.payload[1]
yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot)
async def download_program(self, slot: int, program: "Program") -> None:
"""Write ``program`` into the panel at the given 1-based ``slot``.
Wire opcode: 8 (DownloadProgram) per clsOLMsg2DownloadProgram
(clsHAC.cs:1133-1140). Payload is the same 2-byte BE slot
number + 14-byte wire body the UploadProgram reply uses, so
``Program.encode_wire_bytes`` produces the right thing.
The panel responds with ``Ack`` on success; we raise
:class:`CommandFailedError` on ``Nak`` and
:class:`OmniConnectionError` for any other opcode.
Writing an all-zero body clears the slot (treats the slot as
``ProgramType.FREE``) matches the panel's behaviour for an
empty record.
"""
if not 1 <= slot <= 1500:
raise ValueError(f"program slot {slot} out of range 1..1500")
body = program.encode_wire_bytes()
if len(body) != 14:
raise ValueError(
f"encoded program body must be 14 bytes, got {len(body)}"
)
payload = bytes([(slot >> 8) & 0xFF, slot & 0xFF]) + body
reply = await self._conn.request(
OmniLink2MessageType.DownloadProgram, payload
)
if reply.opcode == int(OmniLink2MessageType.Nak):
raise CommandFailedError(
f"panel NAK'd DownloadProgram for slot {slot}"
)
if reply.opcode != int(OmniLink2MessageType.Ack):
raise OmniConnectionError(
f"unexpected opcode {reply.opcode} after DownloadProgram "
f"(expected {int(OmniLink2MessageType.Ack)})"
)
async def clear_program(self, slot: int) -> None:
"""Convenience: clear a program slot by writing an all-zero body.
On the panel this marks the slot as :class:`ProgramType.FREE`,
same as ``DownloadProgram(slot, all-zero)``.
"""
from .programs import Program, ProgramType
empty = Program(slot=slot, prog_type=int(ProgramType.FREE))
await self.download_program(slot, empty)
# ---- helpers (status) -----------------------------------------------
async def _fetch_status_range(
@ -777,22 +683,10 @@ class OmniClient:
)
async def list_area_names(self) -> dict[int, str]:
"""Return area names, falling back to "Area N" when none are named.
Most installs assign no user-visible name to areas single-area
homes don't bother, and even multi-area installs commonly leave
area names blank. HA needs *something* to label each area entity,
so we synthesize "Area 1".."Area 8" (the Omni Pro II cap) when
the Properties walk returns no names. Mirrors the v1 adapter's
list_area_names fallback in omni_pca.v1.adapter.
"""
named = await self._walk_named_objects(
return await self._walk_named_objects(
ObjectType.AREA,
lambda r: (r.index, r.name) if isinstance(r, AreaProperties) else None,
)
if named:
return named
return {i: f"Area {i}" for i in range(1, 9)}
async def subscribe(
self, callback: Callable[[Message], Awaitable[None]]

View File

@ -39,19 +39,6 @@ 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.
Cross-references (HAI OmniPro II Owner's Manual):
Chapter "CONTROL" (pca-re/docs/owner_manual/05_CONTROL/) documents
the user-facing keypad keys that map to these commands
e.g. UNIT_ON/OFF + UNIT_LEVEL are what a homeowner triggers via
the "Control → 1 (Unit)" menu, SHOW_MESSAGE_WITH_BEEP is
invoked from "Control → Message → Show".
Chapter "Scene Commands" (06_Scene_Commands/) covers
COMPOSE_SCENE and the per-room scene-recall path.
Chapter "SECURITY SYSTEM OPERATION" (03_SECURITY_SYSTEM_OPERATION/)
documents what each SecurityMode byte (0-6) means at the user
level the arming menu, entry/exit-delay semantics, and which
zones each mode arms.
"""
from __future__ import annotations

View File

@ -26,7 +26,6 @@ import logging
from collections.abc import AsyncIterator
from enum import IntEnum
from types import TracebackType
from typing import Literal
from .crypto import (
BLOCK_SIZE,
@ -105,30 +104,18 @@ class OmniConnection:
port: int = _DEFAULT_PORT,
controller_key: bytes = b"",
timeout: float = 5.0,
transport: Literal["tcp", "udp"] = "tcp",
udp_retry_count: int = 3,
) -> None:
if len(controller_key) != 16:
raise ValueError(
f"controller_key must be 16 bytes, got {len(controller_key)}"
)
if transport not in ("tcp", "udp"):
raise ValueError(f"transport must be 'tcp' or 'udp', got {transport!r}")
self._host = host
self._port = port
self._controller_key = bytes(controller_key)
self._default_timeout = timeout
self._transport_kind: Literal["tcp", "udp"] = transport
self._udp_retry_count = max(0, udp_retry_count)
# TCP transport state
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
# UDP transport state (asyncio.DatagramTransport + our protocol)
self._udp_transport: asyncio.DatagramTransport | None = None
self._udp_protocol: _OmniDatagramProtocol | None = None
self._state = ConnectionState.DISCONNECTED
self._session_id: bytes | None = None
@ -180,41 +167,22 @@ class OmniConnection:
await self.close()
async def connect(self) -> None:
"""Open the socket and run the 4-step secure-session handshake.
Transport is set by the ``transport=`` constructor arg. TCP opens
a stream socket; UDP opens a datagram endpoint. Either way, the
handshake (and everything else) speaks the same Packet/Message
format and crypto.
"""
"""Open the TCP socket and run the 4-step secure-session handshake."""
if self._state is not ConnectionState.DISCONNECTED:
raise ConnectionError(f"already connecting/connected (state={self._state})")
self._state = ConnectionState.CONNECTING
try:
if self._transport_kind == "tcp":
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self._host, self._port),
timeout=self._default_timeout,
)
self._reader_task = asyncio.create_task(
self._read_loop(), name=f"omni-conn-reader-{self._host}"
)
else:
# UDP: connectionless. We "connect" the datagram socket to
# the panel so we can reject stray datagrams from elsewhere
# and use plain `transport.sendto(data)`.
loop = asyncio.get_running_loop()
self._udp_transport, self._udp_protocol = (
await loop.create_datagram_endpoint(
lambda: _OmniDatagramProtocol(self),
remote_addr=(self._host, self._port),
)
)
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self._host, self._port),
timeout=self._default_timeout,
)
except (TimeoutError, OSError) as exc:
self._state = ConnectionState.DISCONNECTED
raise ConnectionError(
f"failed to open {self._transport_kind.upper()} socket: {exc}"
) from exc
raise ConnectionError(f"failed to open TCP socket: {exc}") from exc
self._reader_task = asyncio.create_task(
self._read_loop(), name=f"omni-conn-reader-{self._host}"
)
try:
await self._do_handshake()
@ -227,36 +195,8 @@ class OmniConnection:
if self._closed:
return
self._closed = True
previous_state = self._state
self._state = ConnectionState.DISCONNECTED
# Politely tell the controller we're done — Omni is single-client,
# and on UDP it has no other way to know we've gone (TCP gets a
# FIN; UDP just sees datagrams stop). Without this, the panel
# holds the session slot until its idle timeout and rejects new
# connections from us with ControllerCannotStartNewSession.
if previous_state in (
ConnectionState.NEW_SESSION,
ConnectionState.SECURE,
ConnectionState.ONLINE,
):
try:
term_seq = self._claim_seq()
term = Packet(
seq=term_seq,
type=PacketType.ClientSessionTerminated,
data=b"",
)
self._write_packet(term)
# Best-effort flush so the byte hits the wire before we
# tear down the socket. UDP is fire-and-forget; TCP needs
# a tick for the writer to drain.
if self._writer is not None:
with contextlib.suppress(Exception):
await self._writer.drain()
except Exception as exc: # noqa: BLE001 - close() must be idempotent
_log.debug("close: failed to send ClientSessionTerminated: %s", exc)
# Cancel anyone still waiting for a reply.
for fut in self._pending.values():
if not fut.done():
@ -272,12 +212,6 @@ class OmniConnection:
self._writer = None
self._reader = None
if self._udp_transport is not None:
with contextlib.suppress(OSError):
self._udp_transport.close()
self._udp_transport = None
self._udp_protocol = None
if self._reader_task is not None and not self._reader_task.done():
self._reader_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
@ -304,39 +238,17 @@ class OmniConnection:
f"cannot send request, connection state={self._state.name}"
)
message = encode_v2(opcode, payload)
per_attempt_timeout = timeout if timeout is not None else self._default_timeout
# UDP needs explicit retries since datagram delivery is best-effort.
# TCP gets reliable delivery for free; we still keep retry_count for
# API uniformity but it defaults to 0 effectively.
max_attempts = (
1 + self._udp_retry_count if self._transport_kind == "udp" else 1
)
last_exc: Exception | None = None
for attempt in range(1, max_attempts + 1):
seq, fut = self._send_encrypted(message)
try:
reply_packet = await asyncio.wait_for(fut, per_attempt_timeout)
except TimeoutError as exc:
last_exc = exc
self._pending.pop(seq, None)
if attempt < max_attempts:
_log.debug(
"udp retry %d/%d on opcode=%d seq=%d",
attempt,
max_attempts,
int(opcode),
seq,
)
continue
raise RequestTimeoutError(
f"no reply for opcode={int(opcode)} "
f"after {max_attempts} attempt(s)"
) from last_exc
return self._decode_inner(reply_packet)
# Loop exit without return only via re-raised timeout above.
raise RequestTimeoutError(
f"request loop exited without reply for opcode={int(opcode)}"
)
seq, fut = self._send_encrypted(message)
try:
reply_packet = await asyncio.wait_for(
fut, timeout if timeout is not None else self._default_timeout
)
except TimeoutError as exc:
self._pending.pop(seq, None)
raise RequestTimeoutError(
f"no reply for opcode={int(opcode)} seq={seq}"
) from exc
return self._decode_inner(reply_packet)
def unsolicited(self) -> AsyncIterator[Message]:
"""Async iterator over unsolicited inbound messages (seq=0)."""
@ -468,23 +380,17 @@ class OmniConnection:
return seq, fut
def _write_packet(self, pkt: Packet, *, encrypted: bool = False) -> None:
if self._writer is None:
raise ConnectionError("transport not open")
wire = pkt.encode()
_log.debug(
"TX[%s] seq=%d type=%s len=%d encrypted=%s",
self._transport_kind,
"TX seq=%d type=%s len=%d encrypted=%s",
pkt.seq,
pkt.type.name,
len(pkt.data),
encrypted,
)
if self._transport_kind == "tcp":
if self._writer is None:
raise ConnectionError("transport not open")
self._writer.write(wire)
else:
if self._udp_transport is None:
raise ConnectionError("transport not open")
self._udp_transport.sendto(wire)
self._writer.write(wire)
def _decode_inner(self, pkt: Packet) -> Message:
"""Decrypt + parse the inner ``Message`` from an OmniLink2Message packet."""
@ -690,46 +596,3 @@ class OmniConnection:
return
if not fut.done():
fut.set_result(pkt)
# --------------------------------------------------------------------------
# UDP transport
# --------------------------------------------------------------------------
class _OmniDatagramProtocol(asyncio.DatagramProtocol):
"""asyncio.DatagramProtocol bound to a single OmniConnection.
Each datagram received on a UDP Omni socket *is* one complete Packet
(no stream framing that is the whole reason UDP is useful here).
We just decode it and hand it to the connection's dispatcher.
"""
def __init__(self, conn: OmniConnection) -> None:
self._conn = conn
def connection_made(self, transport: asyncio.BaseTransport) -> None:
# transport is a DatagramTransport in this codepath.
pass
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
# Each datagram is a complete Packet — no stream framing.
# The TCP _dispatch already handles handshake routing, solicited
# replies, and unsolicited push, so we just delegate.
try:
pkt = Packet.decode(data)
except Exception as exc:
_log.warning("dropping malformed UDP datagram: %s", exc)
return
try:
self._conn._dispatch(pkt)
except Exception:
_log.exception("UDP dispatch crashed for seq=%d", pkt.seq)
def error_received(self, exc: Exception) -> None:
_log.warning("UDP socket error: %s", exc)
def connection_lost(self, exc: Exception | None) -> None:
if exc is not None:
_log.warning("UDP transport lost: %s", exc)

View File

@ -29,16 +29,6 @@ References (decompiled C# source):
bit-mask classifier we mirror below
clsText.cs:1693-1911 (GetEventText)
per-category sub-field extraction
Cross-references (HAI OmniPro II Installation Manual):
APPENDIX A CONTACT ID REPORTING FORMAT (p68): the Contact ID
event codes the panel transmits to a central monitoring station
for each :class:`AlarmKind`. The class names below mirror those
codes one-for-one. (pca-re/docs/manuals/installation_manual/
10_APPENDIX_A_CONTACT_ID_REPORTING_FORMAT/)
APPENDIX B DIGITAL COMMUNICATOR CODE SHEET (p69-73): the 4/2 and
3/1 reporting-format code tables. Useful when correlating a
SystemEvents word with what a central station would see. (12_)
"""
from __future__ import annotations
@ -137,22 +127,17 @@ class UpbLinkAction(IntEnum):
# --------------------------------------------------------------------------
def _ensure_system_events(
message: Message,
expected_opcode: int = int(OmniLink2MessageType.SystemEvents),
) -> bytes:
"""Validate that ``message`` is a SystemEvents reply, return payload bytes.
def _ensure_system_events(message: Message) -> bytes:
"""Validate that ``message`` is a v2 SystemEvents reply, return its
payload bytes (everything after the opcode).
The v1 and v2 SystemEvents inner-message bodies are byte-identical
(clsOLMsgSystemEvents.cs vs clsOL2MsgSystemEvents.cs both yield
``[opcode][word1_hi][word1_lo][word2_hi][word2_lo]``); only the
opcode byte differs (35 vs 55). Pass ``expected_opcode`` to dispatch
the v1 path from :class:`omni_pca.v1.adapter.OmniClientV1Adapter`.
Reference: clsOLMsgSystemEvents.cs (entire file) the message body
is just ``[opcode][word1_hi][word1_lo][word2_hi][word2_lo]``.
"""
if message.opcode != expected_opcode:
if message.opcode != int(OmniLink2MessageType.SystemEvents):
raise ValueError(
f"not a SystemEvents message: opcode {message.opcode} "
f"(expected {expected_opcode})"
"not a SystemEvents message: opcode "
f"{message.opcode} (expected {int(OmniLink2MessageType.SystemEvents)})"
)
payload = message.payload
if len(payload) % 2 != 0:
@ -715,23 +700,18 @@ def _classify(word: int) -> SystemEvent:
# --------------------------------------------------------------------------
def parse_events(
message: Message,
expected_opcode: int = int(OmniLink2MessageType.SystemEvents),
) -> list[SystemEvent]:
"""Decode a ``SystemEvents`` message into typed events.
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.
``expected_opcode`` defaults to v2 (55); pass v1's value (35) when
decoding from a ``v1.OmniConnectionV1`` push stream.
Reference: clsOLMsgSystemEvents.cs / clsOL2MsgSystemEvents.cs.
Reference: clsOLMsgSystemEvents.cs:10-18 (SystemEventsCount + per-
word accessor).
"""
payload = _ensure_system_events(message, expected_opcode)
payload = _ensure_system_events(message)
return [_classify(w) for w in _iter_event_words(payload)]
@ -810,7 +790,6 @@ class EventStream:
"""
source: object # OmniConnection or duck-typed equivalent
expected_opcode: int = int(OmniLink2MessageType.SystemEvents)
_buffer: list[SystemEvent] = field(default_factory=list)
def __post_init__(self) -> None:
@ -838,10 +817,10 @@ class EventStream:
raise
except asyncio.CancelledError:
raise
if msg.opcode != self.expected_opcode:
if msg.opcode != int(OmniLink2MessageType.SystemEvents):
# Non-event message (Status, Ack, …) — silently ignore.
continue
self._buffer = parse_events(msg, self.expected_opcode)
self._buffer = parse_events(msg)
return self._buffer.pop(0)

View File

@ -3,7 +3,7 @@
Wire layout (non-addressable):
``[start_char][length][...data...][crc_lo][crc_hi]``
For v1 addressable messages (StartChar=0x41) a single SerialAddress byte
For v1 addressable messages (StartChar=0x5A) a single SerialAddress byte
is interleaved between start_char and length.
CRC is CRC-16/MODBUS (poly 0xA001, init 0, reflected) computed over the
@ -13,8 +13,6 @@ on the wire (CRC1 = low byte, CRC2 = high byte).
References:
clsOmniLinkMessage.cs (lines 9, 164-186, 273-289) frame + CRC
clsOmniLink2Message.cs (lines 17-23) v2 StartChar = 0x21
enuOmniLinkMessageFormat.cs Addressable=0x41, NonAddressable=0x5A,
OmniLink2=0x21
clsOL2MsgLogin.cs / clsOLMsgLogin.cs example payloads
"""
@ -25,8 +23,8 @@ from dataclasses import dataclass, field
from .opcodes import OmniLink2MessageType, OmniLinkMessageType
START_CHAR_V2 = 0x21
START_CHAR_V1_ADDRESSABLE = 0x41
START_CHAR_V1_UNADDRESSED = 0x5A
START_CHAR_V1_UNADDRESSED = 0x41
START_CHAR_V1_ADDRESSABLE = 0x5A
_CRC_POLY_REFLECTED = 0xA001

File diff suppressed because it is too large Load Diff

View File

@ -490,25 +490,18 @@ class ObjectType(IntEnum):
class SecurityMode(IntEnum):
"""Area security mode (enuSecurityMode.cs).
The first 7 values are what the user actually picks at the keypad
when arming. Values 9..14 are the "arming in progress" variants the
panel reports while a delayed-arm timer is running.
Reference: HAI OmniPro II Owner's Manual, *Security System
Operation* chapter (pca-re/docs/owner_manual/
03_SECURITY_SYSTEM_OPERATION/) the user-facing semantics of each
mode (entry/exit delays, which zones are armed, when to use which)
come from there.
Values 9..14 are the "arming in progress" variants the panel reports
while a delayed-arm timer is running.
"""
OFF = 0 # disarmed; resets fire / emergency alarms, silences sirens
DAY = 1 # perimeter armed, interior motion NOT armed, entry delay
NIGHT = 2 # perimeter + non-sleeping-area motion armed, NO entry delay
AWAY = 3 # everything armed, both exit + entry delays
VACATION = 4 # same as AWAY but for multi-day absences
DAY_INSTANT = 5 # DAY with no entry delay (instant alarm on perimeter)
NIGHT_DELAYED = 6 # NIGHT with entry delay on entry-exit zones
ANY_CHANGE = 7 # programming-condition wildcard, NOT a real arming state
OFF = 0
DAY = 1
NIGHT = 2
AWAY = 3
VACATION = 4
DAY_INSTANT = 5
NIGHT_DELAYED = 6
ANY_CHANGE = 7
ARMING_DAY = 9
ARMING_NIGHT = 10
ARMING_AWAY = 11
@ -518,13 +511,7 @@ class SecurityMode(IntEnum):
class HvacMode(IntEnum):
"""Thermostat system mode (enuThermostatMode.cs).
Values 0-3 match the keypad's "Thermostat → MODE" menu one-for-one
(Owner's Manual *Scene Commands → Thermostat Control* chapter,
pca-re/docs/owner_manual/06_Scene_Commands/). ``EMERGENCY_HEAT`` (4)
is heat-pump-only and not exposed in the standard keypad menu.
"""
"""Thermostat system mode (enuThermostatMode.cs)."""
OFF = 0
HEAT = 1
@ -534,12 +521,7 @@ class HvacMode(IntEnum):
class FanMode(IntEnum):
"""Thermostat fan mode (enuThermostatFanMode.cs).
Values 0-1 match the keypad's "Thermostat → FAN" menu (Owner's
Manual *Scene Commands*, 06_Scene_Commands/). ``CYCLE`` (2) is
programmable-only and not surfaced at the keypad.
"""
"""Thermostat fan mode (enuThermostatFanMode.cs)."""
AUTO = 0
ON = 1
@ -549,12 +531,8 @@ class FanMode(IntEnum):
class HoldMode(IntEnum):
"""Thermostat hold mode (enuThermostatHoldMode.cs).
``OFF`` / ``HOLD`` are the two states surfaced at the keypad's
"Thermostat → HOLD" menu (Owner's Manual *Scene Commands*,
06_Scene_Commands/). ``VACATION`` (2) is a programmable mode the
panel uses while a Vacation security mode is active. Value 255
(``OLD_ON``) is a legacy "Hold" sentinel from older firmware that
some panels still emit; treat it as equivalent to ``HOLD``.
Value 255 (``OLD_ON``) is a legacy "Hold" sentinel from older firmware
that some panels still emit; treat it as equivalent to ``HOLD``.
"""
OFF = 0
@ -582,13 +560,6 @@ class ZoneType(IntEnum):
the temperature/humidity sensors and a handful of utility types. Any
raw byte value still round-trips through ``ZoneStatus.zone_type``
it just won't have a named enum member.
Reference: HAI OmniPro II Installation Manual, *Installer Setup
SETUP ZONES ZONE TYPES* table (pca-re/docs/manuals/
installation_manual/04_INSTALLER_SETUP/INSTALLER_SETUP.md, p38-39).
The byte values and short names here match the installer-setup
keypad selections one-for-one (e.g. ``PERIMETER = 1`` is the same
"PERIMETER" the installer scrolls to when setting Z1..Z176 types).
"""
ENTRY_EXIT = 0
@ -686,9 +657,7 @@ class ZoneStatus:
bytes[2] status byte (current+latched+arming, see below)
bytes[3] analog loop reading (0-255)
Status byte bit layout (clsZone.cs:385, clsText.cs:3110, and the
"View Zone Status" keypad screen in the Owner's Manual *CONTROL*
chapter, pca-re/docs/owner_manual/05_CONTROL/):
Status byte bit layout (clsZone.cs:385, clsText.cs:3110):
bits 0-1 (mask 0x03): current condition
0=Secure, 1=NotReady, 2=Trouble, 3=Tamper
bits 2-3 (mask 0x0C): latched alarm status
@ -783,11 +752,7 @@ class UnitStatus:
bytes[3..4] remaining time in seconds (BE u16, 0 = indefinite)
bytes[5..6] optional ZigBee instantaneous power (W, BE u16)
State byte semantics (clsUnit.cs:405-533; user-visible meaning in
the Owner's Manual *CONTROL → Light/Appliance Control* chapter,
pca-re/docs/owner_manual/05_CONTROL/, which documents the keypad
"All On" / "All Off" / "Scene" / "Bright/Dim" actions that put a
unit into each of these states):
State byte semantics (clsUnit.cs:405-533):
0 Off
1 On
2..13 Scene A..L (state - 63 'A'..'L' as ASCII char)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,884 +0,0 @@
"""Structured-English rendering of HAI Omni panel programs.
The decoded :class:`omni_pca.programs.Program` records produced by
``pca_file`` and the wire upload paths carry every byte but no narrative.
This module turns them into readable sentences modelled on PC Access's
program editor:
WHEN Front Door is opened
AND IF Living Room Motion is secure
AND IF after sunset
OR IF Bedtime Mode is active
THEN Turn ON Hallway Light
AND Show Message "WELCOME HOME"
Output is a sequence of :class:`Token` records rather than a flat string
so that consumers (CLI, HA frontend, anything else) can:
* Identify object references (zones / units / areas / thermostats /
buttons / messages) render each as a clickable link to the entity
page, badge them with live state, etc.
* Style keywords (`WHEN`, `AND IF`, `THEN`) separately from object
names and values.
* Recover plain text trivially via ``"".join(t.text for t in tokens)``.
A :class:`ProgramRenderer` is constructed with a :class:`NameResolver`
and an optional :class:`StateResolver` for the live-state overlay. The
two resolvers are protocols (any object with the right methods works);
the convenience :class:`AccountNameResolver` adapts a :class:`PcaAccount`
and :class:`MockStateResolver` adapts a :class:`MockState` together
those cover the two common consumers (offline ``.pca`` snapshot vs.
running mock panel) without forcing either into a base class.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable, Protocol, runtime_checkable
from .commands import Command
from .programs import (
CondArgType,
CondOP,
Days,
MiscConditional,
Program,
ProgramCond,
ProgramType,
TimeKind,
)
from .program_engine import (
ClausalChain,
EVENT_AC_POWER_OFF,
EVENT_AC_POWER_ON,
EVENT_PHONE_DEAD,
EVENT_PHONE_OFF_HOOK,
EVENT_PHONE_ON_HOOK,
EVENT_PHONE_RINGING,
)
# --------------------------------------------------------------------------
# Token stream
# --------------------------------------------------------------------------
class TokenKind:
"""String constants for :attr:`Token.kind`. Defined as a class of
str constants so consumers can do ``if t.kind == TokenKind.REF``."""
KEYWORD: str = "keyword" # WHEN, AND IF, THEN, OR IF, etc.
OPERATOR: str = "operator" # is, ==, >, after, before, …
REF: str = "ref" # an object reference (zone / unit / …)
VALUE: str = "value" # a literal value (time, number, mode name)
TEXT: str = "text" # plain prose connectors
INDENT: str = "indent" # leading whitespace for the next line
NEWLINE: str = "newline" # end-of-line
@dataclass(frozen=True, slots=True)
class Token:
"""One unit of structured-English output.
``text`` is what the consumer prints. The other fields are
metadata; only ``REF`` tokens use them all.
For ``REF`` tokens:
* ``entity_kind`` is one of ``"zone"`` / ``"unit"`` / ``"area"``
/ ``"thermostat"`` / ``"button"`` / ``"message"``
/ ``"code"`` / ``"timeclock"``
* ``entity_id`` is the 1-based slot the reference resolves to
* ``state`` is the live-state overlay string when a state
resolver was provided (e.g. ``"SECURE"``, ``"ON 60%"``,
``"Off"``); ``None`` when no overlay is available
For non-REF tokens, ``entity_kind`` / ``entity_id`` / ``state`` are ``None``.
"""
kind: str
text: str
entity_kind: str | None = None
entity_id: int | None = None
state: str | None = None
def tokens_to_string(tokens: Iterable[Token]) -> str:
"""Render a token stream to plain text. Useful for logs / dumps."""
pieces: list[str] = []
for t in tokens:
if t.kind == TokenKind.NEWLINE:
pieces.append("\n")
elif t.kind == TokenKind.INDENT:
pieces.append(t.text)
else:
pieces.append(t.text)
if t.kind == TokenKind.REF and t.state is not None:
pieces.append(f" [{t.state}]")
return "".join(pieces)
# --------------------------------------------------------------------------
# Resolver protocols + default implementations
# --------------------------------------------------------------------------
@runtime_checkable
class NameResolver(Protocol):
"""Translate a (kind, 1-based-index) reference into a human name.
Returns the name string when known, or ``None`` when the slot is
undefined / the kind isn't supported. The renderer falls back to
a generated label (``"Zone 5"``, ``"Unit 7"``) when the resolver
returns ``None``.
"""
def name_of(self, kind: str, index: int) -> str | None: ...
@runtime_checkable
class StateResolver(Protocol):
"""Translate a (kind, 1-based-index) reference into a live-state
overlay string. Returns ``None`` when no overlay applies the
renderer omits the bracketed annotation in that case.
"""
def state_of(self, kind: str, index: int) -> str | None: ...
class AccountNameResolver:
"""Resolves names from a :class:`omni_pca.pca_file.PcaAccount`.
Works as both a static-snapshot view (offline ``.pca`` inspection)
and as a fallback for the HA path when only header data is loaded.
"""
def __init__(self, account) -> None:
self._account = account
def name_of(self, kind: str, index: int) -> str | None:
table = {
"zone": getattr(self._account, "zone_names", {}),
"unit": getattr(self._account, "unit_names", {}),
"area": getattr(self._account, "area_names", {}),
"thermostat": getattr(self._account, "thermostat_names", {}),
"button": getattr(self._account, "button_names", {}),
"message": getattr(self._account, "message_names", {}),
"code": getattr(self._account, "code_names", {}),
}.get(kind, {})
return table.get(index)
class MockStateResolver:
"""Resolves both names and live state from a :class:`MockState`.
Implements both :class:`NameResolver` and :class:`StateResolver`
so the same object covers both roles when rendering against a
running mock panel.
"""
def __init__(self, state) -> None:
self._state = state
def name_of(self, kind: str, index: int) -> str | None:
getter = {
"zone": getattr(self._state, "zones", {}).get,
"unit": getattr(self._state, "units", {}).get,
"area": getattr(self._state, "areas", {}).get,
"thermostat": getattr(self._state, "thermostats", {}).get,
"button": getattr(self._state, "buttons", {}).get,
}.get(kind)
if getter is None:
return None
obj = getter(index)
return getattr(obj, "name", None) if obj else None
def state_of(self, kind: str, index: int) -> str | None:
if kind == "zone":
z = self._state.zones.get(index)
if z is None:
return None
if z.is_bypassed:
return "BYPASSED"
return "NOT READY" if z.current_state != 0 else "SECURE"
if kind == "unit":
u = self._state.units.get(index)
if u is None:
return None
if u.state == 0:
return "OFF"
if u.state >= 100:
return f"ON {u.state - 100}%"
return "ON"
if kind == "area":
a = self._state.areas.get(index)
if a is None:
return None
return _SECURITY_MODE_NAMES.get(a.mode, f"mode {a.mode}")
if kind == "thermostat":
t = self._state.thermostats.get(index)
if t is None or t.temperature_raw == 0:
return None
# Linear scale on Omni: temp_raw / 2 - 40 = °F.
return f"{t.temperature_raw // 2 - 40}°F"
return None
_SECURITY_MODE_NAMES: dict[int, str] = {
0: "Off",
1: "Day",
2: "Night",
3: "Away",
4: "Vacation",
5: "Day Instant",
6: "Night Delayed",
}
# --------------------------------------------------------------------------
# Helpers — friendly names for fixed enums
# --------------------------------------------------------------------------
_DAY_BIT_LABELS: tuple[tuple[int, str], ...] = (
(int(Days.MONDAY), "Mon"),
(int(Days.TUESDAY), "Tue"),
(int(Days.WEDNESDAY), "Wed"),
(int(Days.THURSDAY), "Thu"),
(int(Days.FRIDAY), "Fri"),
(int(Days.SATURDAY), "Sat"),
(int(Days.SUNDAY), "Sun"),
)
_ALL_DAYS_MASK: int = sum(b for b, _ in _DAY_BIT_LABELS)
_WEEKDAYS_MASK: int = int(
Days.MONDAY | Days.TUESDAY | Days.WEDNESDAY | Days.THURSDAY | Days.FRIDAY
)
_WEEKEND_MASK: int = int(Days.SATURDAY | Days.SUNDAY)
def format_days(mask: int) -> str:
"""Render a Days bitmask as a friendly schedule string.
Common patterns get short names; anything else is the abbreviated
weekday list (``"Mon, Wed, Fri"``).
"""
if mask == 0:
return "never"
if mask & _ALL_DAYS_MASK == _ALL_DAYS_MASK:
return "every day"
if mask & _ALL_DAYS_MASK == _WEEKDAYS_MASK:
return "weekdays"
if mask & _ALL_DAYS_MASK == _WEEKEND_MASK:
return "weekends"
parts = [label for bit, label in _DAY_BIT_LABELS if mask & bit]
return ", ".join(parts) if parts else "(no days)"
# Command → ("verb", expects_pr2_object_kind) lookup. ``None`` for the
# second element means "no object reference" — the command's parameters
# are the action's payload alone.
_COMMAND_VERBS: dict[int, tuple[str, str | None]] = {
int(Command.UNIT_OFF): ("Turn OFF", "unit"),
int(Command.UNIT_ON): ("Turn ON", "unit"),
int(Command.ALL_OFF): ("Turn ALL OFF", None),
int(Command.ALL_ON): ("Turn ALL ON", None),
int(Command.BYPASS_ZONE): ("Bypass", "zone"),
int(Command.RESTORE_ZONE): ("Restore", "zone"),
int(Command.RESTORE_ALL_ZONES): ("Restore all zones", None),
int(Command.EXECUTE_BUTTON): ("Execute button", "button"),
int(Command.UNIT_LEVEL): ("Set level", "unit"),
int(Command.UNIT_RAMP): ("Ramp", "unit"),
int(Command.DIM_STEP): ("Dim", "unit"),
int(Command.BRIGHT_STEP): ("Brighten", "unit"),
int(Command.SECURITY_OFF): ("Disarm", "area"),
int(Command.SECURITY_DAY): ("Arm Day", "area"),
int(Command.SECURITY_NIGHT): ("Arm Night", "area"),
int(Command.SECURITY_AWAY): ("Arm Away", "area"),
int(Command.SECURITY_VACATION): ("Arm Vacation", "area"),
int(Command.SECURITY_DAY_INSTANT): ("Arm Day Instant", "area"),
int(Command.SECURITY_NIGHT_DELAYED): ("Arm Night Delayed", "area"),
}
# --------------------------------------------------------------------------
# The renderer
# --------------------------------------------------------------------------
@dataclass
class ProgramRenderer:
"""Render :class:`Program` records and clausal chains as token streams.
Parameters
----------
names:
Object-name resolver (zones, units, areas, thermostats, buttons,
messages, codes). Pass an :class:`AccountNameResolver` for
offline ``.pca`` snapshots or a :class:`MockStateResolver` for
the mock-panel case.
state:
Optional live-state resolver. When provided, every ``REF`` token
carries a ``state`` annotation that consumers can render as a
badge (``"Front Door [SECURE]"`` etc.).
"""
names: NameResolver
state: StateResolver | None = None
# ---- public API ------------------------------------------------------
def render_program(self, p: Program) -> list[Token]:
"""Render a single compact-form program (TIMED / EVENT / YEARLY).
Returns the multi-line full form. For a one-line summary, see
:meth:`summarize_program`.
"""
out: list[Token] = []
try:
kind = ProgramType(p.prog_type)
except ValueError:
out.append(Token(TokenKind.TEXT, f"Unknown program type {p.prog_type}"))
return out
if kind == ProgramType.TIMED:
self._emit_timed_header(p, out)
elif kind == ProgramType.EVENT:
self._emit_event_header(p, out)
elif kind == ProgramType.YEARLY:
self._emit_yearly_header(p, out)
elif kind == ProgramType.REMARK:
self._emit_remark(p, out)
return out
elif kind == ProgramType.FREE:
out.append(Token(TokenKind.TEXT, "(empty slot)"))
return out
else:
# Multi-record record on its own — caller should use
# render_chain instead. Be helpful rather than silent.
out.append(Token(
TokenKind.TEXT,
f"(multi-record {kind.name} — render with render_chain)",
))
return out
# Compact-form programs can carry up to two inline AND conditions
# in their cond / cond2 fields. Skip when both are zero.
for slot_idx, field_val in (("cond", p.cond), ("cond2", p.cond2)):
if field_val == 0:
continue
out.append(Token(TokenKind.NEWLINE, ""))
out.append(Token(TokenKind.INDENT, " "))
out.append(Token(TokenKind.KEYWORD, "AND IF"))
out.append(Token(TokenKind.TEXT, " "))
self._emit_traditional_cond(field_val, out)
out.append(Token(TokenKind.NEWLINE, ""))
out.append(Token(TokenKind.KEYWORD, "THEN"))
out.append(Token(TokenKind.TEXT, " "))
self._emit_action(p, out)
return out
def render_chain(self, chain: ClausalChain) -> list[Token]:
"""Render a multi-record clausal chain (WHEN/AT/EVERY + body).
Output mirrors PC Access's structured-English: trigger on the
first line, conditions indented two spaces with ``AND IF`` /
``OR IF`` keywords, actions on their own lines under ``THEN`` /
``AND``.
"""
out: list[Token] = []
head = chain.head
head_kind = head.prog_type
if head_kind == int(ProgramType.WHEN):
self._emit_when_header(head, out)
elif head_kind == int(ProgramType.AT):
self._emit_at_header(head, out)
elif head_kind == int(ProgramType.EVERY):
self._emit_every_header(head, out)
else:
out.append(Token(TokenKind.TEXT, f"(chain head type {head_kind}?)"))
# Conditions: AND IF / OR IF, indented.
for cond in chain.conditions:
out.append(Token(TokenKind.NEWLINE, ""))
out.append(Token(TokenKind.INDENT, " "))
keyword = "OR IF" if cond.prog_type == int(ProgramType.OR) else "AND IF"
out.append(Token(TokenKind.KEYWORD, keyword))
out.append(Token(TokenKind.TEXT, " "))
self._emit_and_record(cond, out)
# Actions: first one prefixed THEN, rest AND.
for i, action in enumerate(chain.actions):
out.append(Token(TokenKind.NEWLINE, ""))
out.append(Token(TokenKind.KEYWORD, "THEN" if i == 0 else "AND"))
out.append(Token(TokenKind.TEXT, " "))
self._emit_action(action, out)
return out
def summarize_program(self, p: Program) -> list[Token]:
"""One-line summary suitable for the list view.
Format: ``<trigger summary> <action summary>``. Conditions
on compact-form programs are elided with ``(+N conds)``.
"""
out: list[Token] = []
try:
kind = ProgramType(p.prog_type)
except ValueError:
out.append(Token(TokenKind.TEXT, f"?type {p.prog_type}"))
return out
if kind == ProgramType.TIMED:
self._emit_timed_summary(p, out)
elif kind == ProgramType.EVENT:
self._emit_event_summary(p, out)
elif kind == ProgramType.YEARLY:
self._emit_yearly_summary(p, out)
elif kind == ProgramType.REMARK:
self._emit_remark(p, out)
return out
elif kind == ProgramType.FREE:
out.append(Token(TokenKind.TEXT, "(empty)"))
return out
else:
out.append(Token(TokenKind.TEXT, kind.name))
return out
# Inline condition count.
cond_count = (1 if p.cond else 0) + (1 if p.cond2 else 0)
if cond_count:
out.append(Token(TokenKind.TEXT, f" (+{cond_count} cond)"))
out.append(Token(TokenKind.TEXT, ""))
self._emit_action(p, out)
return out
def summarize_chain(self, chain: ClausalChain) -> list[Token]:
"""One-line summary of a clausal chain for the list view."""
out: list[Token] = []
head = chain.head
if head.prog_type == int(ProgramType.WHEN):
out.append(Token(TokenKind.KEYWORD, "WHEN"))
out.append(Token(TokenKind.TEXT, " "))
self._emit_event(head.event_id, out)
elif head.prog_type == int(ProgramType.AT):
out.append(Token(TokenKind.KEYWORD, "AT"))
out.append(Token(TokenKind.TEXT, " "))
out.append(Token(TokenKind.VALUE, head.format_time()))
out.append(Token(TokenKind.TEXT, " "))
out.append(Token(TokenKind.VALUE, format_days(head.days)))
elif head.prog_type == int(ProgramType.EVERY):
out.append(Token(TokenKind.KEYWORD, "EVERY"))
out.append(Token(TokenKind.TEXT, " "))
out.append(Token(TokenKind.VALUE, _format_interval(head.every_interval)))
if chain.conditions:
out.append(Token(
TokenKind.TEXT,
f" (+{len(chain.conditions)} cond)",
))
out.append(Token(TokenKind.TEXT, ""))
# Show the first action only on summary; "+N more" if there are more.
if chain.actions:
self._emit_action(chain.actions[0], out)
if len(chain.actions) > 1:
out.append(Token(
TokenKind.TEXT,
f" (+{len(chain.actions) - 1} more)",
))
return out
# ---- emit helpers — triggers / headers -------------------------------
def _emit_timed_header(self, p: Program, out: list[Token]) -> None:
out.append(Token(TokenKind.KEYWORD, "AT"))
out.append(Token(TokenKind.TEXT, " "))
out.append(Token(TokenKind.VALUE, p.format_time()))
out.append(Token(TokenKind.TEXT, " "))
out.append(Token(TokenKind.VALUE, format_days(p.days)))
def _emit_event_header(self, p: Program, out: list[Token]) -> None:
out.append(Token(TokenKind.KEYWORD, "WHEN"))
out.append(Token(TokenKind.TEXT, " "))
self._emit_event(p.event_id, out)
def _emit_yearly_header(self, p: Program, out: list[Token]) -> None:
out.append(Token(TokenKind.KEYWORD, "ON"))
out.append(Token(TokenKind.TEXT, " "))
out.append(Token(
TokenKind.VALUE, f"{p.month:d}/{p.day:d} at {p.hour:02d}:{p.minute:02d}",
))
def _emit_remark(self, p: Program, out: list[Token]) -> None:
rid = p.remark_id if p.remark_id is not None else 0
out.append(Token(TokenKind.KEYWORD, "REMARK"))
out.append(Token(TokenKind.TEXT, f" #{rid}"))
def _emit_when_header(self, p: Program, out: list[Token]) -> None:
out.append(Token(TokenKind.KEYWORD, "WHEN"))
out.append(Token(TokenKind.TEXT, " "))
self._emit_event(p.event_id, out)
def _emit_at_header(self, p: Program, out: list[Token]) -> None:
out.append(Token(TokenKind.KEYWORD, "AT"))
out.append(Token(TokenKind.TEXT, " "))
out.append(Token(TokenKind.VALUE, p.format_time()))
out.append(Token(TokenKind.TEXT, " "))
out.append(Token(TokenKind.VALUE, format_days(p.days)))
def _emit_every_header(self, p: Program, out: list[Token]) -> None:
out.append(Token(TokenKind.KEYWORD, "EVERY"))
out.append(Token(TokenKind.TEXT, " "))
out.append(Token(TokenKind.VALUE, _format_interval(p.every_interval)))
def _emit_timed_summary(self, p: Program, out: list[Token]) -> None:
out.append(Token(TokenKind.VALUE, p.format_time()))
out.append(Token(TokenKind.TEXT, " "))
out.append(Token(TokenKind.VALUE, format_days(p.days)))
def _emit_event_summary(self, p: Program, out: list[Token]) -> None:
out.append(Token(TokenKind.KEYWORD, "WHEN"))
out.append(Token(TokenKind.TEXT, " "))
self._emit_event(p.event_id, out)
def _emit_yearly_summary(self, p: Program, out: list[Token]) -> None:
out.append(Token(
TokenKind.VALUE,
f"{p.month:d}/{p.day:d} @ {p.hour:02d}:{p.minute:02d}",
))
def _emit_event(self, event_id: int, out: list[Token]) -> None:
"""Render an event-ID as natural language.
Mirrors clsText.GetEventCategory (clsText.cs:1585-...) for the
common categories. Unknown event IDs render as ``"event 0xNNNN"``.
"""
if event_id == EVENT_PHONE_DEAD:
out.append(Token(TokenKind.TEXT, "phone line is dead"))
return
if event_id == EVENT_PHONE_RINGING:
out.append(Token(TokenKind.TEXT, "phone is ringing"))
return
if event_id == EVENT_PHONE_OFF_HOOK:
out.append(Token(TokenKind.TEXT, "phone is off hook"))
return
if event_id == EVENT_PHONE_ON_HOOK:
out.append(Token(TokenKind.TEXT, "phone is on hook"))
return
if event_id == EVENT_AC_POWER_OFF:
out.append(Token(TokenKind.TEXT, "AC power lost"))
return
if event_id == EVENT_AC_POWER_ON:
out.append(Token(TokenKind.TEXT, "AC power restored"))
return
# USER_MACRO_BUTTON (high byte == 0)
if (event_id & 0xFF00) == 0x0000:
button = event_id & 0xFF
self._emit_ref("button", button, out)
out.append(Token(TokenKind.TEXT, " is pressed"))
return
# ZONE_STATE_CHANGE (& 0xFC00 == 0x0400)
if (event_id & 0xFC00) == 0x0400:
zone_state = event_id & 0x03FF
zone = (zone_state // 4) + 1
state = zone_state % 4
self._emit_ref("zone", zone, out)
state_label = {
0: "becomes secure",
1: "becomes not ready",
2: "reports trouble",
3: "reports tamper",
}.get(state, f"changes to state {state}")
out.append(Token(TokenKind.TEXT, " " + state_label))
return
# UNIT_STATE_CHANGE (& 0xFC00 == 0x0800)
if (event_id & 0xFC00) == 0x0800:
unit_state = event_id & 0x03FF
unit = (unit_state // 2) + 1
on = unit_state & 1
self._emit_ref("unit", unit, out)
out.append(Token(TokenKind.TEXT, " turns " + ("ON" if on else "OFF")))
return
out.append(Token(TokenKind.TEXT, f"event 0x{event_id:04x}"))
# ---- emit helpers — conditions ---------------------------------------
def _emit_traditional_cond(self, cond: int, out: list[Token]) -> None:
"""Render a compact-form ``cond`` u16 (TIMED/EVENT/YEARLY inline
AND condition).
These use a different bit-layout from AND-record cond fields
see clsText.GetConditionalText (clsText.cs:2224-2274).
"""
family = (cond >> 8) & 0xFC
if family == 0:
misc = cond & 0x0F
self._emit_misc_conditional(misc, out)
return
if family == ProgramCond.ZONE:
zone = cond & 0xFF
not_ready = bool(cond & 0x0200)
self._emit_ref("zone", zone, out)
out.append(Token(TokenKind.TEXT, " is "))
out.append(Token(TokenKind.OPERATOR, "not ready" if not_ready else "secure"))
return
if family == ProgramCond.CTRL:
unit = cond & 0x01FF
on = bool(cond & 0x0200)
self._emit_ref("unit", unit, out)
out.append(Token(TokenKind.TEXT, " is "))
out.append(Token(TokenKind.OPERATOR, "ON" if on else "OFF"))
return
if family == ProgramCond.TIME:
tc = cond & 0xFF
enabled = bool(cond & 0x0200)
out.append(Token(TokenKind.TEXT, "Time clock "))
out.append(Token(TokenKind.VALUE, str(tc)))
out.append(Token(TokenKind.TEXT, " is "))
out.append(Token(TokenKind.OPERATOR,
"enabled" if enabled else "disabled"))
return
# SEC default: high nibble = mode, bits 8-11 = area.
area = (cond >> 8) & 0x0F
mode = (cond >> 12) & 0x07
if area == 0:
area = 1
self._emit_ref("area", area, out)
out.append(Token(TokenKind.TEXT, " is "))
out.append(Token(
TokenKind.VALUE,
_SECURITY_MODE_NAMES.get(mode, f"mode {mode}"),
))
def _emit_and_record(self, c: Program, out: list[Token]) -> None:
"""Render an AND/OR Program record (Traditional or Structured)."""
if c.and_op == CondOP.ARG1_TRADITIONAL:
self._emit_traditional_and(c, out)
else:
self._emit_structured_and(c, out)
def _emit_traditional_and(self, c: Program, out: list[Token]) -> None:
"""AND/OR record carrying a Traditional condition.
Encoding via clsConditionLine.Cond (clsConditionLine.cs:17-33):
``and_family`` is the family+selector byte; ``and_instance`` is
the object index (1-based).
"""
family = c.and_family
instance = c.and_instance
family_major = family & 0xFC
secondary = bool(family & 0x02)
if family_major == 0:
self._emit_misc_conditional(family & 0x0F, out)
return
if family_major == ProgramCond.ZONE:
self._emit_ref("zone", instance, out)
out.append(Token(TokenKind.TEXT, " is "))
out.append(Token(
TokenKind.OPERATOR, "not ready" if secondary else "secure",
))
return
if family_major == ProgramCond.CTRL:
self._emit_ref("unit", instance, out)
out.append(Token(TokenKind.TEXT, " is "))
out.append(Token(
TokenKind.OPERATOR, "ON" if secondary else "OFF",
))
return
if family_major == ProgramCond.TIME:
out.append(Token(TokenKind.TEXT, "Time clock "))
out.append(Token(TokenKind.VALUE, str(instance)))
out.append(Token(TokenKind.TEXT, " is "))
out.append(Token(
TokenKind.OPERATOR,
"enabled" if secondary else "disabled",
))
return
# SEC: high nibble = mode, low = area
area = family & 0x0F
mode = (family >> 4) & 0x07
if area == 0:
area = 1
self._emit_ref("area", area, out)
out.append(Token(TokenKind.TEXT, " is "))
out.append(Token(
TokenKind.VALUE,
_SECURITY_MODE_NAMES.get(mode, f"mode {mode}"),
))
def _emit_misc_conditional(self, misc_code: int, out: list[Token]) -> None:
try:
cat = MiscConditional(misc_code)
except ValueError:
out.append(Token(TokenKind.TEXT, f"misc condition {misc_code}"))
return
labels = {
MiscConditional.NONE: "always",
MiscConditional.NEVER: "never",
MiscConditional.LIGHT: "it is light outside",
MiscConditional.DARK: "it is dark outside",
MiscConditional.PHONE_DEAD: "phone line is dead",
MiscConditional.PHONE_RINGING: "phone is ringing",
MiscConditional.PHONE_OFF_HOOK: "phone is off hook",
MiscConditional.PHONE_ON_HOOK: "phone is on hook",
MiscConditional.AC_POWER_OFF: "AC power is off",
MiscConditional.AC_POWER_ON: "AC power is on",
MiscConditional.BATTERY_LOW: "battery is low",
MiscConditional.BATTERY_OK: "battery is OK",
MiscConditional.ENERGY_COST_LOW: "energy cost is low",
MiscConditional.ENERGY_COST_MID: "energy cost is mid",
MiscConditional.ENERGY_COST_HIGH: "energy cost is high",
MiscConditional.ENERGY_COST_CRITICAL: "energy cost is critical",
}
out.append(Token(TokenKind.TEXT, labels.get(cat, cat.name)))
def _emit_structured_and(self, c: Program, out: list[Token]) -> None:
"""Render an ``Arg1 OP Arg2`` AND/OR record.
For each arg side we render either an object reference + field,
or a literal value. The operator goes in between.
"""
self._emit_structured_arg(
c.and_arg1_argtype, c.and_arg1_ix, c.and_arg1_field, out,
)
out.append(Token(TokenKind.TEXT, " "))
out.append(Token(TokenKind.OPERATOR, _OP_SYMBOLS.get(c.and_op, "?")))
out.append(Token(TokenKind.TEXT, " "))
self._emit_structured_arg(
c.and_arg2_argtype, c.and_arg2_ix, c.and_arg2_field, out,
)
def _emit_structured_arg(
self, argtype: int, ix: int, field_id: int, out: list[Token],
) -> None:
if argtype == CondArgType.CONSTANT:
out.append(Token(TokenKind.VALUE, str(ix)))
return
kind = _ARGTYPE_KIND.get(argtype)
if kind is None:
out.append(Token(TokenKind.TEXT, f"argtype{argtype}#{ix}"))
return
if kind == "timedate":
field_label = _TIMEDATE_FIELD_LABELS.get(field_id, f"field{field_id}")
out.append(Token(TokenKind.TEXT, field_label))
return
# Object reference with field suffix (when known).
self._emit_ref(kind, ix, out)
field_label = _FIELD_LABELS.get((kind, field_id))
if field_label:
out.append(Token(TokenKind.TEXT, "."))
out.append(Token(TokenKind.TEXT, field_label))
# ---- emit helpers — actions ------------------------------------------
def _emit_action(self, p: Program, out: list[Token]) -> None:
"""Render the cmd / par / pr2 triple as a friendly verb.
For unrecognised commands we fall back to the raw enum name,
which keeps the rendering useful even for less-common
Command values we haven't mapped yet.
"""
cmd_byte = p.cmd
try:
cmd = Command(cmd_byte)
except ValueError:
out.append(Token(TokenKind.TEXT, f"command {cmd_byte}"))
return
verb_entry = _COMMAND_VERBS.get(cmd_byte)
verb, ref_kind = verb_entry if verb_entry else (cmd.name.replace("_", " "), None)
out.append(Token(TokenKind.KEYWORD, verb))
if ref_kind is not None:
out.append(Token(TokenKind.TEXT, " "))
self._emit_ref(ref_kind, p.pr2, out)
if cmd == Command.UNIT_LEVEL:
out.append(Token(TokenKind.TEXT, " to "))
out.append(Token(TokenKind.VALUE, f"{p.par}%"))
# ---- emit helpers — refs ---------------------------------------------
def _emit_ref(self, kind: str, index: int, out: list[Token]) -> None:
"""Emit a typed object reference token with name + live state."""
name = self.names.name_of(kind, index)
if not name:
name = f"{kind.capitalize()} {index}"
state = None
if self.state is not None:
state = self.state.state_of(kind, index)
out.append(Token(
TokenKind.REF, name,
entity_kind=kind, entity_id=index, state=state,
))
# --------------------------------------------------------------------------
# Tables — kept at module scope so they're not re-allocated per render
# --------------------------------------------------------------------------
_OP_SYMBOLS: dict[int, str] = {
int(CondOP.ARG1_EQ_ARG2): "==",
int(CondOP.ARG1_NE_ARG2): "!=",
int(CondOP.ARG1_LT_ARG2): "<",
int(CondOP.ARG1_GT_ARG2): ">",
int(CondOP.ARG1_ODD): "is odd",
int(CondOP.ARG1_EVEN): "is even",
int(CondOP.ARG1_MULTIPLE_ARG2): "is multiple of",
int(CondOP.ARG1_IN_ARG2): "in",
int(CondOP.ARG1_NOT_IN_ARG2): "not in",
}
_ARGTYPE_KIND: dict[int, str] = {
int(CondArgType.ZONE): "zone",
int(CondArgType.UNIT): "unit",
int(CondArgType.THERMOSTAT): "thermostat",
int(CondArgType.AREA): "area",
int(CondArgType.TIME_DATE): "timedate",
}
_FIELD_LABELS: dict[tuple[str, int], str] = {
# enuZoneField
("zone", 1): "LoopReading",
("zone", 2): "CurrentState",
("zone", 3): "ArmingState",
("zone", 4): "AlarmState",
# enuUnitField
("unit", 1): "CurrentState",
("unit", 2): "PreviousState",
("unit", 3): "Timer",
("unit", 4): "Level",
# enuThermostatField
("thermostat", 1): "Temperature",
("thermostat", 2): "HeatSetpoint",
("thermostat", 3): "CoolSetpoint",
("thermostat", 4): "SystemMode",
("thermostat", 5): "FanMode",
("thermostat", 6): "HoldMode",
("thermostat", 7): "FreezeAlarm",
("thermostat", 8): "CommError",
("thermostat", 9): "Humidity",
("thermostat", 10): "HumidifySetpoint",
("thermostat", 11): "DehumidifySetpoint",
("thermostat", 12): "OutdoorTemperature",
("thermostat", 13): "SystemStatus",
}
_TIMEDATE_FIELD_LABELS: dict[int, str] = {
1: "Date",
2: "Year",
3: "Month",
4: "Day",
5: "DayOfWeek",
6: "Time",
7: "DST_Flag",
8: "Hour",
9: "Minute",
10: "SunriseSunset",
}
def _format_interval(seconds: int) -> str:
"""Render an EVERY-program interval. Treats the raw value as
seconds matches the live-fixture observation that 5 SECONDS UI
selection stores as 5. Higher values fall through to natural
``"30 min"`` / ``"2 hr"`` shortenings for readability."""
if seconds <= 0:
return "(disabled)"
if seconds < 60:
return f"{seconds} sec"
if seconds < 3600:
return f"{seconds // 60} min"
return f"{seconds // 3600} hr"

File diff suppressed because it is too large Load Diff

View File

@ -1,52 +0,0 @@
"""V1 (legacy) Omni-Link protocol over UDP.
The v2 path in :mod:`omni_pca` (TCP, OmniLink2Message, StartChar 0x21,
parameterised RequestProperties / RequestExtendedStatus) is what most
modern firmware speaks. This subpackage exists because some panels are
configured at the network module to listen on **UDP only**, in which case
PC Access falls back to the v1 wire protocol (typed RequestZoneStatus,
RequestUnitStatus, etc., StartChar 0x5A, OmniLinkMessage outer = 0x10).
Reference: clsOmniLinkConnection.cs:353-360 (ConnectionProtocol() returns
V1 for Modem/UDP/Serial, V2 only for TCP).
"""
from __future__ import annotations
from .adapter import OmniClientV1Adapter
from .client import OmniClientV1, OmniNakError, OmniProtocolError
from .connection import (
HandshakeError,
InvalidEncryptionKeyError,
OmniConnectionV1,
RequestTimeoutError,
)
from .messages import (
NameRecord,
NameType,
parse_v1_aux_status,
parse_v1_namedata,
parse_v1_system_status,
parse_v1_thermostat_status,
parse_v1_unit_status,
parse_v1_zone_status,
)
__all__ = [
"HandshakeError",
"InvalidEncryptionKeyError",
"NameRecord",
"NameType",
"OmniClientV1",
"OmniClientV1Adapter",
"OmniConnectionV1",
"OmniNakError",
"OmniProtocolError",
"RequestTimeoutError",
"parse_v1_aux_status",
"parse_v1_namedata",
"parse_v1_system_status",
"parse_v1_thermostat_status",
"parse_v1_unit_status",
"parse_v1_zone_status",
]

View File

@ -1,441 +0,0 @@
"""V2-shape adapter over :class:`OmniClientV1`.
The Home Assistant coordinator was written against :class:`omni_pca.client.OmniClient`
(the v2 API). When the user configures ``transport=udp`` we need a client
that *looks* like ``OmniClient`` but speaks v1-over-UDP underneath.
This adapter exposes only the methods the coordinator and entity
platforms actually call. Where v1 lacks a v2 opcode (Properties for
zones/units/areas, AcknowledgeAlerts), we synthesize a sensible
fallback rather than raise HA users shouldn't have to care that their
panel is on a different wire protocol.
What the adapter does:
* **Discovery (``list_*_names``)**: delegates to ``OmniClientV1`` (which
drives the streaming ``UploadNames`` flow once per call).
* **Properties (``get_object_properties``)**: synthesizes a minimal
``*Properties`` dataclass from the name alone. v1 has no Properties
opcode, so we can't fetch zone_type / unit_type / area_alarms / etc.
Defaults are zero entity platforms read mostly the name + the live
``*Status`` snapshot, so this works for the common case.
* **Bulk status (``get_extended_status``)**: routes Zone/Unit/Thermostat/
AuxSensor through the v1 typed ``get_*_status`` calls and returns the
resulting dataclass list (same shape v2 produces).
* **Area status (``get_object_status(AREA, )``)**: derives ``AreaStatus``
records from the per-area mode bytes in v1 ``SystemStatus`` v1 has
no per-area status opcode and the modes are the only thing the panel
reports on UDP.
* **Events (``events()``)**: returns an :class:`EventStream` filtered on
v1's SystemEvents opcode (35) instead of v2's (55). Word format is
identical, so the existing typed-event decoder works unchanged.
* **Writes**: pass-through to the underlying ``OmniClientV1`` methods,
whose Command / ExecuteSecurityCommand payloads are byte-identical
to v2 only the opcode differs.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Self
from ..commands import Command
from ..events import EventStream, SystemEvent
from ..models import (
AreaProperties,
AreaStatus,
AuxSensorStatus,
ButtonProperties,
ObjectType,
SecurityMode,
SystemInformation,
SystemStatus,
ThermostatProperties,
ThermostatStatus,
UnitProperties,
UnitStatus,
ZoneProperties,
ZoneStatus,
)
from ..opcodes import OmniLinkMessageType
from .client import OmniClientV1
from .connection import OmniConnectionV1
# Type used by coordinator for object_type arg (the IntEnum in
# omni_pca.client is just a re-export of models.ObjectType).
_ObjectType = ObjectType
_DEFAULT_PORT = 4369
class OmniClientV1Adapter:
"""V2-shaped facade over :class:`OmniClientV1`.
Construct with the same kwargs as :class:`OmniClient`; the
coordinator does not need to know which one it has.
"""
def __init__(
self,
host: str,
port: int = _DEFAULT_PORT,
controller_key: bytes = b"",
timeout: float = 5.0,
retry_count: int = 3,
**_ignored,
) -> None:
# `transport=` and similar kwargs are accepted-and-ignored so the
# coordinator's construction call stays identical across v1/v2.
self._client = OmniClientV1(
host=host,
port=port,
controller_key=controller_key,
timeout=timeout,
retry_count=retry_count,
)
# ---- lifecycle ------------------------------------------------------
async def __aenter__(self) -> Self:
await self._client.__aenter__()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
await self._client.__aexit__(exc_type, exc, tb)
@property
def connection(self) -> OmniConnectionV1:
"""Underlying :class:`OmniConnectionV1` — used by the coordinator's
low-level walks. v1's connection has the same ``unsolicited()`` /
``request()`` surface as v2's, just a different wire dialect.
"""
return self._client.connection
# ---- panel-wide reads ----------------------------------------------
async def get_system_information(self) -> SystemInformation:
return await self._client.get_system_information()
async def get_system_status(self) -> SystemStatus:
return await self._client.get_system_status()
# ---- discovery (cached once per coordinator setup) -----------------
#
# The coordinator calls list_*_names() once per object type. Each
# call drives a fresh UploadNames stream, which on this panel takes
# ~250ms per ~100 names. We cache the full bucketed dict on first
# call so the four list_*_names() calls + several synthesize-
# properties calls all share one network roundtrip.
async def _ensure_names(self) -> dict[int, dict[int, str]]:
cached = getattr(self, "_name_cache", None)
if cached is None:
cached = await self._client.list_all_names()
self._name_cache = cached
return cached
def _invalidate_names(self) -> None:
"""Force the next discovery call to re-stream UploadNames."""
self._name_cache = None # type: ignore[assignment]
async def list_zone_names(self) -> dict[int, str]:
return (await self._ensure_names()).get(1, {}) # NameType.ZONE
async def list_unit_names(self) -> dict[int, str]:
return (await self._ensure_names()).get(2, {}) # NameType.UNIT
async def list_area_names(self) -> dict[int, str]:
"""Return area names, falling back to "Area N" when stream is empty.
Most v1 panels don't expose user-assigned area names — the slots
exist (8 for Omni Pro II) but the .pca file leaves them zero-
filled. HA needs *something* to label each area entity, so we
synthesize "Area 1".."Area 8" as a fixed-size fallback. The 8
is the Omni Pro II cap; we cap here even when ``SystemStatus``
reports more mode bytes because the long-form SystemStatus
payload mixes in EE-expansion telemetry past byte 22.
"""
named = (await self._ensure_names()).get(5, {}) # NameType.AREA
if named:
return named
return {i: f"Area {i}" for i in range(1, 9)}
async def list_thermostat_names(self) -> dict[int, str]:
return (await self._ensure_names()).get(6, {}) # NameType.THERMOSTAT
async def list_button_names(self) -> dict[int, str]:
return (await self._ensure_names()).get(3, {}) # NameType.BUTTON
async def list_code_names(self) -> dict[int, str]:
return (await self._ensure_names()).get(4, {}) # NameType.CODE
async def list_message_names(self) -> dict[int, str]:
return (await self._ensure_names()).get(7, {}) # NameType.MESSAGE
# ---- programs ------------------------------------------------------
def iter_programs(self):
"""Forward to OmniClientV1.iter_programs (streaming UploadPrograms).
Same async-iterator shape as :meth:`OmniClient.iter_programs` so the
coordinator does not need a transport branch.
"""
return self._client.iter_programs()
async def download_program(self, slot: int, program) -> None:
"""v1 forwarder — raises NotImplementedError. See client.py."""
await self._client.download_program(slot, program)
async def clear_program(self, slot: int) -> None:
await self._client.clear_program(slot)
# ---- properties synthesis ------------------------------------------
async def get_object_properties(
self, object_type: ObjectType, index: int
) -> ZoneProperties | UnitProperties | AreaProperties | ThermostatProperties | None:
"""Synthesize a Properties dataclass from the name alone.
v1 has no ``RequestProperties`` opcode; the rich fields v2 carries
(zone_type, unit areas bitfield, exit/entry delays, ) simply
aren't reachable on UDP. We return a minimal dataclass with just
``index`` + ``name`` populated and everything else defaulted to
0/False so entity setup doesn't need a transport branch.
Returns ``None`` if the object isn't defined (no name and not in
the default area-fallback range), which mirrors v2's behavior
when ``RequestProperties`` walks past the last defined object.
"""
names = await self._ensure_names()
if object_type == ObjectType.ZONE:
name = names.get(1, {}).get(index)
if not name:
return None
return ZoneProperties(
index=index, name=name, zone_type=0, area=1,
options=0, status=0, loop=0,
)
if object_type == ObjectType.UNIT:
name = names.get(2, {}).get(index)
if not name:
return None
return UnitProperties(
index=index, name=name, unit_type=0,
status=0, time=0, areas=0,
)
if object_type == ObjectType.AREA:
# Use the same fallback logic as list_area_names so HA always
# gets at least the 8 default-area entries.
label = (await self.list_area_names()).get(index)
if label is None:
return None
return AreaProperties(
index=index, name=label, mode=0, alarms=0,
enabled=True, entry_delay=0, exit_delay=0,
)
if object_type == ObjectType.THERMOSTAT:
name = names.get(6, {}).get(index)
if not name:
return None
return ThermostatProperties(
index=index, name=name, thermostat_type=0,
communicating=True,
)
if object_type == ObjectType.BUTTON:
name = names.get(3, {}).get(index)
if not name:
return None
return ButtonProperties(index=index, name=name)
return None
# ---- bulk status ---------------------------------------------------
# Per-type max records per chunk. Empirically firmware 2.12 caps unit
# responses around 62 records regardless of the MessageLength byte
# limit; other types follow similar conservative caps. We chunk well
# under those thresholds to leave headroom for any per-firmware
# variance and the AES zero-padding the wire frames add.
_CHUNK_SIZES: dict[int, int] = {
ObjectType.ZONE: 80, # 2 B/rec, panel caps high enough
ObjectType.UNIT: 40, # firmware 2.12 NAKs at 63+ records
ObjectType.THERMOSTAT: 30,
ObjectType.AUXILIARY: 60,
}
async def get_extended_status(
self,
object_type: ObjectType,
start: int,
end: int | None = None,
) -> list:
"""Route v2 ``get_extended_status`` to the matching v1 typed call.
v1 panels (Omni Pro II) can have 511 units across a sparse
address space. We chunk wide ranges into per-type-sized batches
and concatenate the records same effect for the caller, only
the wire transcript is different.
"""
last = end if end is not None else start
if object_type == ObjectType.ZONE:
fetch = self._client.get_zone_status
elif object_type == ObjectType.UNIT:
fetch = self._client.get_unit_status
elif object_type == ObjectType.THERMOSTAT:
fetch = self._client.get_thermostat_status
elif object_type == ObjectType.AUXILIARY:
fetch = self._client.get_aux_status
else:
raise ValueError(
f"v1 has no bulk extended-status opcode for {object_type.name}"
)
chunk = self._CHUNK_SIZES.get(int(object_type), 40)
out: dict[int, object] = {}
cur = start
while cur <= last:
chunk_end = min(cur + chunk - 1, last)
records = await fetch(cur, chunk_end)
out.update(records)
cur = chunk_end + 1
return [out[i] for i in sorted(out)]
async def get_object_status(
self,
object_type: ObjectType,
start: int,
end: int | None = None,
) -> list:
"""Synthesize AreaStatus from SystemStatus's per-area mode bytes.
v1 has no per-area status opcode but the SystemStatus payload
carries one ``Mode`` byte per area (single-area panels see one
byte at offset 15, multi-area panels see N consecutive bytes).
We promote each into an :class:`AreaStatus` with just ``index``
and ``mode`` populated; entry/exit timers and alarms are zero
because the protocol doesn't expose them at this level.
For non-area object types we fall back to extended-status, which
on v1 maps to the basic typed-status opcodes (which is what the
v2 coordinator actually wants anyway since v2's basic and
extended status are interchangeable in shape).
"""
if object_type != ObjectType.AREA:
return await self.get_extended_status(object_type, start, end)
last = end if end is not None else start
status = await self._client.get_system_status()
# First N bytes of area_alarms are valid area modes; the rest are
# EE-expansion data on long SystemStatus payloads (firmware 2.12
# length=39 form). We can't reliably tell where modes stop, so
# match against the list_area_names() count from the same
# SystemStatus.
area_count = max(1, min(8, len(status.area_alarms)))
out: list[AreaStatus] = []
for idx in range(start, min(last, area_count) + 1):
mode_pair = (
status.area_alarms[idx - 1] if idx - 1 < len(status.area_alarms)
else (0, 0)
)
out.append(
AreaStatus(
index=idx,
mode=mode_pair[0],
last_user=0,
entry_timer_secs=0,
exit_timer_secs=0,
alarms=mode_pair[1],
)
)
return out
# ---- events --------------------------------------------------------
def events(self) -> AsyncIterator[SystemEvent]:
"""v1-aware EventStream — filters on v1 SystemEvents opcode (35)."""
return EventStream(
self._client.connection,
expected_opcode=int(OmniLinkMessageType.SystemEvents),
).__aiter__()
async def subscribe(
self, callback: Callable[[object], Awaitable[None]]
) -> None:
"""Not used by the coordinator (which prefers ``events()``); kept
for API parity with :class:`OmniClient`. Raises ``NotImplementedError``
to flag accidental use when we need it, copy the v2 implementation.
"""
raise NotImplementedError(
"OmniClientV1Adapter.subscribe is not implemented; "
"use events() instead"
)
# ---- writes (pure pass-through) ------------------------------------
async def execute_command(
self, command: Command, parameter1: int = 0, parameter2: int = 0
) -> None:
await self._client.execute_command(command, parameter1, parameter2)
async def execute_security_command(
self, area: int, mode: SecurityMode, code: int
) -> None:
await self._client.execute_security_command(area, mode, code)
async def acknowledge_alerts(self) -> None:
await self._client.acknowledge_alerts()
async def turn_unit_on(self, index: int) -> None:
await self._client.turn_unit_on(index)
async def turn_unit_off(self, index: int) -> None:
await self._client.turn_unit_off(index)
async def set_unit_level(self, index: int, percent: int) -> None:
await self._client.set_unit_level(index, percent)
async def bypass_zone(self, index: int, code: int = 0) -> None:
await self._client.bypass_zone(index, code)
async def restore_zone(self, index: int, code: int = 0) -> None:
await self._client.restore_zone(index, code)
async def execute_button(self, index: int) -> None:
await self._client.execute_button(index)
async def execute_program(self, index: int) -> None:
"""Run a panel program by index.
v1 ``enuUnitCommand.Execute`` (raw byte not aliased in our enum)
and v2 both use a generic Command. The Command enum's
``EXECUTE_PROGRAM`` value works on both because the on-the-wire
Command body is byte-identical.
"""
await self.execute_command(Command.EXECUTE_PROGRAM, parameter2=index)
async def show_message(self, index: int, beep: bool = True) -> None:
await self.execute_command(
Command.SHOW_MESSAGE_WITH_BEEP if beep else Command.SHOW_MESSAGE_NO_BEEP,
parameter2=index,
)
async def clear_message(self, index: int) -> None:
await self.execute_command(Command.CLEAR_MESSAGE, parameter2=index)
async def set_thermostat_system_mode(self, index: int, mode_value: int) -> None:
await self._client.set_thermostat_system_mode(index, mode_value)
async def set_thermostat_fan_mode(self, index: int, mode_value: int) -> None:
await self._client.set_thermostat_fan_mode(index, mode_value)
async def set_thermostat_hold_mode(self, index: int, mode_value: int) -> None:
await self._client.set_thermostat_hold_mode(index, mode_value)
async def set_thermostat_heat_setpoint_raw(
self, index: int, raw_temp: int
) -> None:
await self._client.set_thermostat_heat_setpoint_raw(index, raw_temp)
async def set_thermostat_cool_setpoint_raw(
self, index: int, raw_temp: int
) -> None:
await self._client.set_thermostat_cool_setpoint_raw(index, raw_temp)

View File

@ -1,518 +0,0 @@
"""High-level read-only client for v1-over-UDP Omni-Link panels.
Mirrors the v2 :class:`omni_pca.client.OmniClient` API where the v1 wire
protocol can satisfy the same call. Methods that require v2-only opcodes
(e.g. ``RequestProperties``, ``AcknowledgeAlerts``) are intentionally
absent until Phase 2b/2c add their v1 equivalents (streaming
``UploadNames``, no-op or alternate dispatch).
API parity goals (this module):
get_system_information() same dataclass as v2
get_system_status() same dataclass as v2
get_zone_status(start, end) -> dict uses v1 ZoneStatus
get_unit_status(start, end) -> dict uses v1 UnitStatus
get_thermostat_status(start, end) -> dict uses v1 ThermostatStatus
get_aux_status(start, end) -> dict uses v1 AuxiliaryStatus
"""
from __future__ import annotations
import struct
from collections.abc import AsyncIterator, Callable
from typing import Self
from ..commands import Command, CommandFailedError, SecurityCommandResponse
from ..models import (
AuxSensorStatus,
SecurityMode,
SystemInformation,
SystemStatus,
ThermostatStatus,
UnitStatus,
ZoneStatus,
)
from ..opcodes import OmniLinkMessageType
from .connection import OmniConnectionV1
from .messages import (
NameRecord,
NameType,
parse_v1_aux_status,
parse_v1_namedata,
parse_v1_system_status,
parse_v1_thermostat_status,
parse_v1_unit_status,
parse_v1_zone_status,
)
_DEFAULT_PORT = 4369
class OmniClientV1:
"""Read-only v1-over-UDP Omni-Link client.
.. code-block:: python
async with OmniClientV1("192.168.1.9", controller_key=key) as c:
info = await c.get_system_information()
zones = await c.get_zone_status(1, 16)
"""
def __init__(
self,
host: str,
port: int = _DEFAULT_PORT,
controller_key: bytes = b"",
timeout: float = 5.0,
retry_count: int = 3,
) -> None:
self._conn = OmniConnectionV1(
host=host,
port=port,
controller_key=controller_key,
timeout=timeout,
retry_count=retry_count,
)
@property
def connection(self) -> OmniConnectionV1:
return self._conn
async def __aenter__(self) -> Self:
await self._conn.connect()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
await self._conn.close()
# ---- panel-wide ----------------------------------------------------
async def get_system_information(self) -> SystemInformation:
"""Fetch model + firmware + dialer phone number.
Wire format identical to v2 (verified per
clsOLMsgSystemInformation.cs vs clsOL2MsgSystemInformation.cs);
we reuse the existing dataclass parser unchanged.
"""
reply = await self._conn.request(
OmniLinkMessageType.RequestSystemInformation
)
self._expect(reply.opcode, OmniLinkMessageType.SystemInformation)
return SystemInformation.parse(reply.payload)
async def get_system_status(self) -> SystemStatus:
"""Fetch panel time, sunrise/sunset, battery reading, area modes."""
reply = await self._conn.request(
OmniLinkMessageType.RequestSystemStatus
)
self._expect(reply.opcode, OmniLinkMessageType.SystemStatus)
return parse_v1_system_status(reply.payload)
# ---- bulk per-object status ----------------------------------------
async def get_zone_status(
self, start: int, end: int
) -> dict[int, ZoneStatus]:
return await self._range_status(
OmniLinkMessageType.RequestZoneStatus,
OmniLinkMessageType.ZoneStatus,
start,
end,
parse_v1_zone_status,
)
async def get_unit_status(
self, start: int, end: int
) -> dict[int, UnitStatus]:
return await self._range_status(
OmniLinkMessageType.RequestUnitStatus,
OmniLinkMessageType.UnitStatus,
start,
end,
parse_v1_unit_status,
)
async def get_thermostat_status(
self, start: int, end: int
) -> dict[int, ThermostatStatus]:
return await self._range_status(
OmniLinkMessageType.RequestThermostatStatus,
OmniLinkMessageType.ThermostatStatus,
start,
end,
parse_v1_thermostat_status,
)
async def get_aux_status(
self, start: int, end: int
) -> dict[int, AuxSensorStatus]:
return await self._range_status(
OmniLinkMessageType.RequestAuxiliaryStatus,
OmniLinkMessageType.AuxiliaryStatus,
start,
end,
parse_v1_aux_status,
)
# ---- discovery (streaming UploadNames) ------------------------------
async def iter_names(self) -> AsyncIterator[NameRecord]:
"""Stream every defined name from the panel.
v1 has no per-type name request a bare ``UploadNames`` triggers
the panel to dump *all* defined names of *all* types in a fixed
order (Zone, Unit, Button, Code, Area, Thermostat, Message, ),
each as a separate ``NameData`` reply that the client must
``Acknowledge`` to advance. This iterator handles the lock-step
protocol and yields each record as it arrives.
Reference: clsHAC.cs:4418 (sends bare UploadNames),
OL1ReadConfigHandleResponse (loops over NameData/EOD).
"""
async for reply in self._conn.iter_streaming(
OmniLinkMessageType.UploadNames
):
if reply.opcode != int(OmniLinkMessageType.NameData):
# Defensive — iter_streaming normally only yields
# non-EOD/NAK replies, so this is a wire-format fault.
raise OmniProtocolError(
f"unexpected opcode {reply.opcode} during UploadNames stream "
f"(expected {int(OmniLinkMessageType.NameData)})"
)
yield parse_v1_namedata(reply.payload)
async def list_all_names(self) -> dict[int, dict[int, str]]:
"""Bucket every defined name by ``NameType``.
Returns ``{name_type: {object_number: name}}``. Useful when HA
needs all four (zones+units+areas+thermostats) in one pass
cheaper than four separate streams since the panel only supports
one streaming session at a time anyway.
"""
out: dict[int, dict[int, str]] = {}
async for rec in self.iter_names():
out.setdefault(rec.name_type, {})[rec.number] = rec.name
return out
async def list_zone_names(self) -> dict[int, str]:
return (await self.list_all_names()).get(int(NameType.ZONE), {})
async def list_unit_names(self) -> dict[int, str]:
return (await self.list_all_names()).get(int(NameType.UNIT), {})
async def list_area_names(self) -> dict[int, str]:
return (await self.list_all_names()).get(int(NameType.AREA), {})
async def list_thermostat_names(self) -> dict[int, str]:
return (await self.list_all_names()).get(int(NameType.THERMOSTAT), {})
async def list_button_names(self) -> dict[int, str]:
return (await self.list_all_names()).get(int(NameType.BUTTON), {})
# ---- programs (streaming UploadPrograms) -----------------------------
async def iter_programs(self) -> AsyncIterator["Program"]:
"""Stream every defined program from the panel.
v1 has no per-slot request a bare ``UploadPrograms`` triggers
the panel to dump every defined program in ascending slot order,
each as a separate ``ProgramData`` reply that we must
``Acknowledge`` to advance.
Reference: clsHAC.cs:4403 (bare UploadPrograms send), 4642-4651
(per-reply ack-walk), 4538-4540 (dispatch).
Yields decoded :class:`omni_pca.programs.Program` instances.
Empty slots are not transmitted the iterator only sees defined
programs.
"""
from ..programs import Program
async for reply in self._conn.iter_streaming(
OmniLinkMessageType.UploadPrograms
):
if reply.opcode != int(OmniLinkMessageType.ProgramData):
raise OmniProtocolError(
f"unexpected opcode {reply.opcode} during UploadPrograms stream "
f"(expected {int(OmniLinkMessageType.ProgramData)})"
)
if len(reply.payload) < 2 + 14:
raise OmniProtocolError(
f"ProgramData payload too short ({len(reply.payload)} bytes)"
)
slot = (reply.payload[0] << 8) | reply.payload[1]
yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot)
async def download_program(self, slot: int, program) -> None:
"""v1 does not expose a single-slot DownloadProgram opcode.
On v1 the only way to change programs is the bulk
``DownloadPrograms`` flow (clsHAC.cs:171, clsOLMsgDownloadPrograms),
which clears the panel's entire program table and re-streams
every record. That's destructive for HA's "edit one program"
use case, so we surface a structured error instead of silently
falling back. Use a v2-capable panel for editing.
"""
raise NotImplementedError(
"v1 panels don't support single-slot program writes; "
"the DownloadPrograms flow clears all programs before "
"rewriting. Use a TCP-mode (v2) connection for editing."
)
async def clear_program(self, slot: int) -> None:
raise NotImplementedError(
"v1 panels don't support single-slot program clears; "
"see download_program for details."
)
# ---- write methods (Command + ExecuteSecurityCommand) ----------------
#
# The Command and ExecuteSecurityCommand payloads are byte-identical
# between v1 and v2 — only the outer opcode differs (15 vs 20 for
# Command, 102 vs 74 for ExecuteSecurityCommand). So these methods are
# near-duplicates of OmniClient's, just routed through the v1 opcodes.
# Reference: clsOLMsgCommand.cs, clsOLMsgExecuteSecurityCommand.cs.
async def execute_command(
self,
command: Command,
parameter1: int = 0,
parameter2: int = 0,
) -> None:
"""Send a generic Command (v1 opcode 15).
Wire payload (4 bytes, identical to v2 form):
[0] command byte (enuUnitCommand 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)
Panel acks with v1 Ack (opcode 5) on success, Nak (6) on failure.
"""
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(OmniLinkMessageType.Command, payload)
if reply.opcode == int(OmniLinkMessageType.Nak):
raise CommandFailedError(
f"panel NAK'd Command {command.name} "
f"(p1={parameter1}, p2={parameter2})"
)
if reply.opcode != int(OmniLinkMessageType.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,
) -> None:
"""Arm or disarm a security area (v1 opcode 102).
Wire payload (6 bytes, identical to v2 form clsOLMsgExecuteSecurityCommand.cs):
[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)
Panel responds with:
* ``ExecuteSecurityCommandResponse`` (103) carrying a status byte
(0 = success, see :class:`SecurityCommandResponse` for others), or
* ``Ack`` (5) on success without structured response, or
* ``Nak`` (6) on flat-out refusal.
Raises:
ValueError: ``area`` not 1..255 or ``code`` not 0..9999.
CommandFailedError: panel Nak'd OR response status was non-zero;
``failure_code`` carries the raw status byte when present.
"""
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}")
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(
OmniLinkMessageType.ExecuteSecurityCommand, payload
)
if reply.opcode == int(OmniLinkMessageType.Nak):
raise CommandFailedError(
f"panel NAK'd ExecuteSecurityCommand "
f"(area={area}, mode={mode.name})"
)
if reply.opcode == int(OmniLinkMessageType.ExecuteSecurityCommandResponse):
if not reply.payload:
raise CommandFailedError(
"ExecuteSecurityCommandResponse with empty payload"
)
status = reply.payload[0]
if status != int(SecurityCommandResponse.SUCCESS):
try:
label = SecurityCommandResponse(status).name
except ValueError:
label = f"unknown({status})"
raise CommandFailedError(
f"ExecuteSecurityCommand failed: {label}",
failure_code=status,
)
return
if reply.opcode == int(OmniLinkMessageType.Ack):
return
raise CommandFailedError(
f"unexpected reply to ExecuteSecurityCommand: opcode={reply.opcode}"
)
async def acknowledge_alerts(self) -> None:
"""V1 has no AcknowledgeAlerts opcode — silently no-op.
v2 introduced :attr:`OmniLink2MessageType.AcknowledgeAlerts` (60)
as a dedicated panel-wide ack; v1 panels expect alerts to be
cleared by per-area arming or by user action at the keypad. To
keep the v1v2 method shape parallel, this method is a no-op so
HA service callers don't need a per-transport branch.
"""
return
# ---- thin command wrappers (one-liner conveniences) ------------------
async def turn_unit_on(self, index: int) -> None:
await self.execute_command(Command.UNIT_ON, parameter2=index)
async def turn_unit_off(self, index: int) -> None:
await self.execute_command(Command.UNIT_OFF, parameter2=index)
async def set_unit_level(self, index: int, percent: int) -> None:
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:
await self.execute_command(
Command.BYPASS_ZONE, parameter1=code, parameter2=index
)
async def restore_zone(self, index: int, code: int = 0) -> None:
await self.execute_command(
Command.RESTORE_ZONE, parameter1=code, parameter2=index
)
async def execute_button(self, index: int) -> None:
await self.execute_command(Command.EXECUTE_BUTTON, parameter2=index)
async def set_thermostat_system_mode(self, index: int, mode_value: int) -> None:
if not 0 <= mode_value <= 0xFF:
raise ValueError(f"mode value must fit in a byte: {mode_value}")
await self.execute_command(
Command.SET_THERMOSTAT_SYSTEM_MODE,
parameter1=mode_value,
parameter2=index,
)
async def set_thermostat_fan_mode(self, index: int, mode_value: int) -> None:
await self.execute_command(
Command.SET_THERMOSTAT_FAN_MODE,
parameter1=mode_value,
parameter2=index,
)
async def set_thermostat_hold_mode(self, index: int, mode_value: int) -> None:
await self.execute_command(
Command.SET_THERMOSTAT_HOLD_MODE,
parameter1=mode_value,
parameter2=index,
)
async def set_thermostat_heat_setpoint_raw(
self, index: int, raw_temp: int
) -> None:
"""Set the heat setpoint by raw byte value (Omni temperature scale).
Use the same :func:`omni_temp_to_celsius` family of helpers from
:mod:`omni_pca.models` to convert from °C/°F if needed.
"""
if not 0 <= raw_temp <= 0xFF:
raise ValueError(f"raw_temp must fit in a byte: {raw_temp}")
await self.execute_command(
Command.SET_THERMOSTAT_HEAT_SETPOINT,
parameter1=raw_temp,
parameter2=index,
)
async def set_thermostat_cool_setpoint_raw(
self, index: int, raw_temp: int
) -> None:
if not 0 <= raw_temp <= 0xFF:
raise ValueError(f"raw_temp must fit in a byte: {raw_temp}")
await self.execute_command(
Command.SET_THERMOSTAT_COOL_SETPOINT,
parameter1=raw_temp,
parameter2=index,
)
# ---- helpers --------------------------------------------------------
async def _range_status[T](
self,
request_op: OmniLinkMessageType,
reply_op: OmniLinkMessageType,
start: int,
end: int,
parser: Callable[[bytes, int], list[T]],
) -> dict[int, T]:
if not 1 <= start <= end <= 0xFFFF:
raise ValueError(
f"invalid range: start={start}, end={end} "
f"(must be 1..65535 with start<=end)"
)
# v1 has two payload forms (clsOLMsgRequestUnitStatus.cs:18-31):
# short (3-byte msg with 1-byte start+end) when both ≤ 255, long
# (5-byte msg with BE u16 start+end) otherwise. The panel picks
# the right reply format based on what it received.
if start <= 0xFF and end <= 0xFF:
payload = bytes([start, end])
else:
payload = bytes(
[(start >> 8) & 0xFF, start & 0xFF,
(end >> 8) & 0xFF, end & 0xFF]
)
reply = await self._conn.request(request_op, payload)
self._expect(reply.opcode, reply_op)
records = parser(reply.payload, start)
return {r.index: r for r in records} # type: ignore[attr-defined]
@staticmethod
def _expect(actual: int, expected: OmniLinkMessageType) -> None:
if actual == int(OmniLinkMessageType.Nak):
raise OmniNakError(
f"panel NAK'd request expecting opcode {int(expected)} "
f"({expected.name})"
)
if actual != int(expected):
raise OmniProtocolError(
f"unexpected reply opcode {actual}, want {int(expected)} "
f"({expected.name})"
)
class OmniNakError(RuntimeError):
"""Panel returned the v1 Nak opcode (6) instead of the expected reply.
Thrown when a feature the panel doesn't support is requested — e.g.
``RequestZoneExtendedStatus`` on firmware 2.12 NAKs because only the
non-extended ``RequestZoneStatus`` is supported.
"""
class OmniProtocolError(RuntimeError):
"""Panel returned a reply opcode neither matching nor a NAK."""

View File

@ -1,530 +0,0 @@
"""Async UDP connection to an Omni-Link controller speaking the v1 wire protocol.
Differs from :class:`omni_pca.connection.OmniConnection` in three ways:
1. **Transport**: UDP only. Each datagram carries exactly one outer Packet.
2. **Outer packet type for messages**: ``OmniLinkMessage`` (0x10), not
``OmniLink2Message`` (0x20). The 4-step handshake packets are identical.
3. **Inner message format**: v1 ``Message`` with ``StartChar = 0x5A``
(NonAddressable) carrying a v1 opcode, not the v2 ``StartChar = 0x21``
carrying a v2 opcode.
The handshake itself (ClientRequestNewSession ControllerAckNewSession
ClientRequestSecureSession ControllerAckSecureSession) and the AES-128
session key derivation are protocol-agnostic and we reuse the same crypto
primitives.
Reference: clsOmniLinkConnection.cs (UDP path):
udpConnect lines 1239-1295 open + queue ClientRequestNewSession
udpListen lines 1298-1399 receive loop, dispatches replies
udpHandleRequestNewSession lines 1401-1459 step 2 step 3
udpHandleRequestSecureSession lines 1461-1487 step 4 OnlineSecure
udpSend lines 1514-1560 outer PacketType = OmniLinkMessage (16)
EncryptPacket lines 372-401 same crypto as TCP
Cross-references:
*Two non-public quirks* Owner's-Manual-style writeup of the
session-key XOR mix and per-block sequence whitening that this
handshake relies on: https://hai-omni-pro-ii.warehack.ing/explanation/quirks/
*Zone & unit numbering* explains why subsequent ``RequestUnitStatus``
calls need the long-form (BE u16) payload for unit indices > 255:
https://hai-omni-pro-ii.warehack.ing/explanation/zone-unit-numbering/
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import AsyncIterator
from enum import IntEnum
from types import TracebackType
from ..crypto import (
BLOCK_SIZE,
decrypt_message_payload,
derive_session_key,
encrypt_message_payload,
)
from ..message import (
START_CHAR_V1_UNADDRESSED,
Message,
MessageCrcError,
)
from ..opcodes import OmniLinkMessageType, PacketType
from ..packet import Packet
_log = logging.getLogger(__name__)
_DEFAULT_PORT = 4369
_SESSION_ID_LEN = 5
_PROTO_VERSION = (0x00, 0x01)
_MAX_SEQ = 0xFFFF
class ConnectionState(IntEnum):
DISCONNECTED = 0
CONNECTING = 1
NEW_SESSION = 2
SECURE = 3
ONLINE = 4
class ConnectionError(OSError): # noqa: A001 - intentional shadow at module scope
pass
class HandshakeError(ConnectionError):
pass
class InvalidEncryptionKeyError(HandshakeError):
"""Controller answered ``ControllerSessionTerminated`` during handshake."""
class ProtocolError(ValueError):
pass
class RequestTimeoutError(TimeoutError):
pass
class OmniConnectionV1:
"""UDP + v1-wire-format connection to an Omni-Link controller."""
def __init__(
self,
host: str,
port: int = _DEFAULT_PORT,
controller_key: bytes = b"",
timeout: float = 5.0,
retry_count: int = 3,
) -> None:
if len(controller_key) != 16:
raise ValueError(
f"controller_key must be 16 bytes, got {len(controller_key)}"
)
self._host = host
self._port = port
self._controller_key = bytes(controller_key)
self._default_timeout = timeout
self._retry_count = max(0, retry_count)
self._udp_transport: asyncio.DatagramTransport | None = None
self._udp_protocol: _OmniDatagramProtocol | None = None
self._state = ConnectionState.DISCONNECTED
self._session_id: bytes | None = None
self._session_key: bytes | None = None
# First wire packet uses seq=1; wraparound skips 0 (reserved for
# unsolicited inbound). See clsOmniLinkConnection.cs:1251 (UDP
# init pktSequence=1, then udpSend pre-increments).
self._next_seq: int = 1
self._pending: dict[int, asyncio.Future[Packet]] = {}
self._unsolicited_queue: asyncio.Queue[Message] = asyncio.Queue()
self._handshake_event: asyncio.Event = asyncio.Event()
self._handshake_packet: Packet | None = None
self._handshake_error: Exception | None = None
self._closed = False
@property
def state(self) -> ConnectionState:
return self._state
@property
def session_key(self) -> bytes | None:
return self._session_key
async def __aenter__(self) -> OmniConnectionV1:
await self.connect()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
await self.close()
async def connect(self) -> None:
if self._state is not ConnectionState.DISCONNECTED:
raise ConnectionError(
f"already connecting/connected (state={self._state})"
)
self._state = ConnectionState.CONNECTING
try:
loop = asyncio.get_running_loop()
self._udp_transport, self._udp_protocol = (
await loop.create_datagram_endpoint(
lambda: _OmniDatagramProtocol(self),
remote_addr=(self._host, self._port),
)
)
except (TimeoutError, OSError) as exc:
self._state = ConnectionState.DISCONNECTED
raise ConnectionError(f"failed to open UDP socket: {exc}") from exc
try:
await self._do_handshake()
except BaseException:
await self.close()
raise
async def close(self) -> None:
"""Tear down. Politely terminate the panel session first.
Without ClientSessionTerminated the panel keeps our slot allocated
until its idle timeout and rejects subsequent connect attempts
with ControllerCannotStartNewSession (0x07).
"""
if self._closed:
return
self._closed = True
previous_state = self._state
self._state = ConnectionState.DISCONNECTED
if previous_state in (
ConnectionState.NEW_SESSION,
ConnectionState.SECURE,
ConnectionState.ONLINE,
):
try:
term = Packet(
seq=self._claim_seq(),
type=PacketType.ClientSessionTerminated,
data=b"",
)
self._write_packet(term)
except Exception as exc: # noqa: BLE001 - close() must be idempotent
_log.debug("close: failed to send ClientSessionTerminated: %s", exc)
for fut in self._pending.values():
if not fut.done():
fut.set_exception(ConnectionError("connection closed"))
self._pending.clear()
if self._udp_transport is not None:
with contextlib.suppress(OSError):
self._udp_transport.close()
self._udp_transport = None
self._udp_protocol = None
# ---- public request API ---------------------------------------------
async def request(
self,
opcode: OmniLinkMessageType | int,
payload: bytes = b"",
timeout: float | None = None,
) -> Message:
"""Send a v1 request, await the matching reply, return the inner Message."""
if self._state is not ConnectionState.ONLINE:
raise ConnectionError(
f"cannot send request, connection state={self._state.name}"
)
message = Message(
start_char=START_CHAR_V1_UNADDRESSED,
data=bytes([int(opcode)]) + payload,
)
per_attempt_timeout = timeout if timeout is not None else self._default_timeout
max_attempts = 1 + self._retry_count
last_exc: Exception | None = None
for attempt in range(1, max_attempts + 1):
seq, fut = self._send_encrypted(message)
try:
reply_packet = await asyncio.wait_for(fut, per_attempt_timeout)
except TimeoutError as exc:
last_exc = exc
self._pending.pop(seq, None)
if attempt < max_attempts:
_log.debug(
"udp v1 retry %d/%d on opcode=%d seq=%d",
attempt, max_attempts, int(opcode), seq,
)
continue
raise RequestTimeoutError(
f"no v1 reply for opcode={int(opcode)} "
f"after {max_attempts} attempt(s)"
) from last_exc
return self._decode_inner(reply_packet)
raise RequestTimeoutError(
f"request loop exited without reply for opcode={int(opcode)}"
)
async def iter_streaming(
self,
initial_op: OmniLinkMessageType | int,
*,
ack_op: OmniLinkMessageType | int = OmniLinkMessageType.Ack,
end_op: OmniLinkMessageType | int = OmniLinkMessageType.EOD,
nak_op: OmniLinkMessageType | int = OmniLinkMessageType.Nak,
timeout: float | None = None,
) -> AsyncIterator[Message]:
"""Drive a v1 lock-step streaming download (UploadNames / UploadSetup / etc).
Sends ``initial_op`` (no payload), yields each ``ack_op``-elicited
reply, and stops when the panel sends ``end_op``. ``nak_op`` is
treated as an immediate end-of-stream no exception (some
firmwares use NAK to signal "no records to upload").
Unlike :meth:`request` we don't retry on timeout — losing a
reply mid-stream desynchronises the conversation, so the right
answer is to surface the timeout and let the caller restart.
"""
if self._state is not ConnectionState.ONLINE:
raise ConnectionError(
f"cannot stream, connection state={self._state.name}"
)
per_reply_timeout = timeout if timeout is not None else self._default_timeout
# Step 1: send the initial bare-opcode request, wait for first reply.
first_msg = Message(
start_char=START_CHAR_V1_UNADDRESSED,
data=bytes([int(initial_op)]),
)
seq, fut = self._send_encrypted(first_msg)
try:
reply_pkt = await asyncio.wait_for(fut, per_reply_timeout)
except TimeoutError as exc:
self._pending.pop(seq, None)
raise RequestTimeoutError(
f"no first reply to streaming opcode={int(initial_op)}"
) from exc
reply = self._decode_inner(reply_pkt)
# Step 2..N: ack-and-receive until end_op or nak_op.
while True:
if reply.opcode == int(end_op) or reply.opcode == int(nak_op):
return
yield reply
ack_msg = Message(
start_char=START_CHAR_V1_UNADDRESSED,
data=bytes([int(ack_op)]),
)
seq, fut = self._send_encrypted(ack_msg)
try:
reply_pkt = await asyncio.wait_for(fut, per_reply_timeout)
except TimeoutError as exc:
self._pending.pop(seq, None)
raise RequestTimeoutError(
f"no reply after streaming Ack (seq={seq})"
) from exc
reply = self._decode_inner(reply_pkt)
def unsolicited(self) -> AsyncIterator[Message]:
queue = self._unsolicited_queue
async def _gen() -> AsyncIterator[Message]:
while True:
yield await queue.get()
return _gen()
# ---- handshake -------------------------------------------------------
async def _do_handshake(self) -> None:
# Step 1: empty ClientRequestNewSession.
self._state = ConnectionState.NEW_SESSION
step1 = Packet(
seq=self._claim_seq(),
type=PacketType.ClientRequestNewSession,
data=b"",
)
self._write_packet(step1)
# Step 2: ControllerAckNewSession (carries protocol version + SessionID).
ack1 = await self._await_handshake_packet()
if ack1.type is PacketType.ControllerCannotStartNewSession:
raise HandshakeError("controller cannot start new session (busy?)")
if ack1.type is not PacketType.ControllerAckNewSession:
raise HandshakeError(f"unexpected step-2 packet type {ack1.type.name}")
if len(ack1.data) < 7:
raise HandshakeError(
f"ControllerAckNewSession payload too short: {len(ack1.data)} bytes"
)
if (ack1.data[0], ack1.data[1]) != _PROTO_VERSION:
raise HandshakeError(
f"unsupported protocol version {ack1.data[0]:#04x}{ack1.data[1]:02x}"
)
self._session_id = bytes(ack1.data[2 : 2 + _SESSION_ID_LEN])
self._session_key = derive_session_key(self._controller_key, self._session_id)
# Step 3: encrypted ClientRequestSecureSession echoing SessionID.
self._state = ConnectionState.SECURE
step3_seq = self._claim_seq()
step3_ct = encrypt_message_payload(
self._session_id, step3_seq, self._session_key
)
step3 = Packet(
seq=step3_seq,
type=PacketType.ClientRequestSecureSession,
data=step3_ct,
)
self._write_packet(step3)
# Step 4: ControllerAckSecureSession (or termination).
ack2 = await self._await_handshake_packet()
if ack2.type is PacketType.ControllerSessionTerminated:
raise InvalidEncryptionKeyError(
"controller terminated session during handshake (wrong ControllerKey?)"
)
if ack2.type is not PacketType.ControllerAckSecureSession:
raise HandshakeError(
f"unexpected step-4 packet type {ack2.type.name}"
)
self._state = ConnectionState.ONLINE
async def _await_handshake_packet(self) -> Packet:
try:
await asyncio.wait_for(
self._handshake_event.wait(), self._default_timeout
)
except TimeoutError as exc:
raise HandshakeError(
"timeout waiting for controller handshake reply"
) from exc
if self._handshake_error is not None:
err = self._handshake_error
self._handshake_error = None
raise err
pkt = self._handshake_packet
self._handshake_packet = None
self._handshake_event.clear()
if pkt is None:
raise HandshakeError("handshake event fired with no packet")
return pkt
# ---- send / receive helpers -----------------------------------------
def _claim_seq(self) -> int:
seq = self._next_seq
nxt = seq + 1
if nxt > _MAX_SEQ or nxt == 0:
nxt = 1
self._next_seq = nxt
return seq
def _send_encrypted(
self, inner: Message
) -> tuple[int, asyncio.Future[Packet]]:
if self._session_key is None:
raise ConnectionError("no session key (handshake not complete)")
seq = self._claim_seq()
plaintext = inner.encode()
ciphertext = encrypt_message_payload(plaintext, seq, self._session_key)
# KEY DIFFERENCE FROM V2: outer type is OmniLinkMessage (0x10),
# not OmniLink2Message (0x20). See clsOmniLinkConnection.cs:1536.
pkt = Packet(seq=seq, type=PacketType.OmniLinkMessage, data=ciphertext)
loop = asyncio.get_running_loop()
fut: asyncio.Future[Packet] = loop.create_future()
self._pending[seq] = fut
self._write_packet(pkt)
return seq, fut
def _write_packet(self, pkt: Packet) -> None:
if self._udp_transport is None:
raise ConnectionError("transport not open")
wire = pkt.encode()
_log.debug(
"TX seq=%d type=%s len=%d", pkt.seq, pkt.type.name, len(pkt.data)
)
self._udp_transport.sendto(wire)
def _decode_inner(self, pkt: Packet) -> Message:
if self._session_key is None:
raise ConnectionError("no session key")
if not pkt.data:
raise ProtocolError("empty packet data")
plaintext = decrypt_message_payload(pkt.data, pkt.seq, self._session_key)
try:
return Message.decode(plaintext)
except MessageCrcError as exc:
raise ProtocolError(f"inner v1 message CRC mismatch: {exc}") from exc
# ---- inbound dispatch (called from the datagram protocol) -----------
def _dispatch(self, pkt: Packet) -> None:
if pkt.data is None:
pkt = Packet(seq=pkt.seq, type=pkt.type, data=b"")
if self._state in (ConnectionState.NEW_SESSION, ConnectionState.SECURE):
handshake_types = {
PacketType.ControllerAckNewSession,
PacketType.ControllerAckSecureSession,
PacketType.ControllerSessionTerminated,
PacketType.ControllerCannotStartNewSession,
}
if pkt.type in handshake_types:
self._handshake_packet = pkt
self._handshake_event.set()
return
if pkt.seq == 0:
if pkt.type is PacketType.OmniLinkMessage:
try:
msg = self._decode_inner(pkt)
except (ProtocolError, ConnectionError) as exc:
_log.warning(
"dropping malformed unsolicited v1 packet: %s", exc
)
return
try:
self._unsolicited_queue.put_nowait(msg)
except asyncio.QueueFull: # pragma: no cover - unbounded queue
_log.warning("unsolicited queue full; dropping message")
return
fut = self._pending.pop(pkt.seq, None)
if fut is None:
_log.debug(
"no waiter for seq=%d type=%s; dropping",
pkt.seq, pkt.type.name,
)
return
if pkt.type is PacketType.ControllerSessionTerminated:
fut.set_exception(ConnectionError("controller terminated session"))
return
if not fut.done():
fut.set_result(pkt)
class _OmniDatagramProtocol(asyncio.DatagramProtocol):
"""asyncio.DatagramProtocol bound to a single OmniConnectionV1.
Each datagram is one complete Packet. We decode it and hand it to the
connection's dispatcher; the dispatcher already knows how to sort
handshake / solicited / unsolicited paths.
"""
def __init__(self, conn: OmniConnectionV1) -> None:
self._conn = conn
def connection_made(self, transport: asyncio.BaseTransport) -> None:
pass
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
try:
pkt = Packet.decode(data)
except Exception as exc:
_log.warning("dropping malformed UDP datagram: %s", exc)
return
try:
self._conn._dispatch(pkt)
except Exception:
_log.exception("UDP v1 dispatch crashed for seq=%d", pkt.seq)
def error_received(self, exc: Exception) -> None:
_log.warning("UDP v1 socket error: %s", exc)
def connection_lost(self, exc: Exception | None) -> None:
if exc is not None:
_log.warning("UDP v1 transport lost: %s", exc)

View File

@ -1,322 +0,0 @@
"""V1 status-reply and name parsers.
The v1 wire protocol's typed status messages (ZoneStatus, UnitStatus,
ThermostatStatus, AuxiliaryStatus) carry one record per object in the
range the client requested but, unlike v2's ExtendedStatus, the records
do **not** include the object number. The starting index is implicit
from the request payload, and each record is at a fixed offset.
This module supplies "block" parsers that take both the reply payload
and the starting index, and produce a list of the existing top-level
dataclasses (:class:`omni_pca.models.ZoneStatus` etc) so HA entity code
doesn't need a v1-specific schema. The :func:`parse_v1_namedata` helper
decodes the bulk-name-download replies streamed by ``UploadNames``.
Per-record byte counts (verified against firmware 2.12 over UDP):
ZoneStatus 2 bytes per zone (status, analog_loop)
UnitStatus 3 bytes per unit (status, time_hi, time_lo)
ThermostatStatus 7 bytes per tstat (status, current_t, heat_sp,
cool_sp, sys_mode, fan_mode,
hold_mode)
AuxiliaryStatus 4 bytes per aux (relay, current, low_sp,
high_sp)
Cross-references (HAI OmniPro II Installation Manual):
*INSTALLER SETUP SETUP ZONES* (pca-re/docs/manuals/
installation_manual/04_INSTALLER_SETUP/) the zone-type and
zone-options bits that determine what each ``ZoneStatus.raw_status``
byte's high nibble means come from this chapter.
*INSTALLER SETUP SETUP TEMPERATURES* same chapter, thermostat
enable/disable + thermostat type that drives whether
``parse_v1_thermostat_status`` records are populated at all.
*APPENDIX C ZONE AND UNIT MAPPING* (12_) what each record's
synthesized index *means* on the hardware side (e.g. unit 257+
= expansion-enclosure outputs, 393+ = panel flags).
References:
clsOLMsgZoneStatus.cs / clsOLMsgRequestZoneStatus.cs
clsOLMsgUnitStatus.cs / clsOLMsgRequestUnitStatus.cs
clsOLMsgThermostatStatus.cs / clsOLMsgRequestThermostatStatus.cs
clsOLMsgAuxiliaryStatus.cs / clsOLMsgRequestAuxiliaryStatus.cs
clsOLMsgSystemStatus.cs v1 byte 14 = battery, then per-area Mode
clsOLMsgNameData.cs bulk name download record format
enuNameType.cs Zone=1 Unit=2 Button=3 Code=4 Area=5
Tstat=6 Message=7 UserSetting=8
AccessControlReader=9
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import IntEnum
from ..models import (
AuxSensorStatus,
SystemStatus,
ThermostatStatus,
UnitStatus,
ZoneStatus,
)
_ZONE_RECORD_BYTES = 2
_UNIT_RECORD_BYTES = 3
_THERMOSTAT_RECORD_BYTES = 7
_AUX_RECORD_BYTES = 4
def parse_v1_zone_status(payload: bytes, start_index: int) -> list[ZoneStatus]:
"""Parse a v1 ZoneStatus reply payload into per-zone dataclasses.
``payload`` is the inner Message ``payload`` (data minus opcode byte);
its length must be a multiple of ``_ZONE_RECORD_BYTES``.
"""
if len(payload) % _ZONE_RECORD_BYTES != 0:
raise ValueError(
f"v1 ZoneStatus payload length {len(payload)} not a multiple of "
f"{_ZONE_RECORD_BYTES}"
)
out: list[ZoneStatus] = []
for i, off in enumerate(range(0, len(payload), _ZONE_RECORD_BYTES)):
out.append(
ZoneStatus(
index=start_index + i,
raw_status=payload[off],
loop=payload[off + 1],
)
)
return out
def parse_v1_unit_status(payload: bytes, start_index: int) -> list[UnitStatus]:
"""Parse a v1 UnitStatus reply payload into per-unit dataclasses."""
if len(payload) % _UNIT_RECORD_BYTES != 0:
raise ValueError(
f"v1 UnitStatus payload length {len(payload)} not a multiple of "
f"{_UNIT_RECORD_BYTES}"
)
out: list[UnitStatus] = []
for i, off in enumerate(range(0, len(payload), _UNIT_RECORD_BYTES)):
out.append(
UnitStatus(
index=start_index + i,
state=payload[off],
time_remaining_secs=(payload[off + 1] << 8) | payload[off + 2],
)
)
return out
def parse_v1_thermostat_status(
payload: bytes, start_index: int
) -> list[ThermostatStatus]:
"""Parse a v1 ThermostatStatus reply payload into per-tstat dataclasses.
The v1 record only carries 7 fields; the v2 dataclass has 4 more
(humidity, humidify_setpoint, dehumidify_setpoint, outdoor_temp,
horc_status). We zero-fill those HA's climate platform doesn't
require them and an explicit 0 is more honest than a fake value.
"""
if len(payload) % _THERMOSTAT_RECORD_BYTES != 0:
raise ValueError(
f"v1 ThermostatStatus payload length {len(payload)} not a multiple "
f"of {_THERMOSTAT_RECORD_BYTES}"
)
out: list[ThermostatStatus] = []
for i, off in enumerate(range(0, len(payload), _THERMOSTAT_RECORD_BYTES)):
out.append(
ThermostatStatus(
index=start_index + i,
status=payload[off],
temperature_raw=payload[off + 1],
heat_setpoint_raw=payload[off + 2],
cool_setpoint_raw=payload[off + 3],
system_mode=payload[off + 4],
fan_mode=payload[off + 5],
hold_mode=payload[off + 6],
humidity_raw=0,
humidify_setpoint_raw=0,
dehumidify_setpoint_raw=0,
outdoor_temperature_raw=0,
horc_status=0,
)
)
return out
def parse_v1_aux_status(payload: bytes, start_index: int) -> list[AuxSensorStatus]:
"""Parse a v1 AuxiliaryStatus reply payload into per-aux dataclasses."""
if len(payload) % _AUX_RECORD_BYTES != 0:
raise ValueError(
f"v1 AuxiliaryStatus payload length {len(payload)} not a multiple "
f"of {_AUX_RECORD_BYTES}"
)
out: list[AuxSensorStatus] = []
for i, off in enumerate(range(0, len(payload), _AUX_RECORD_BYTES)):
out.append(
AuxSensorStatus(
index=start_index + i,
output=payload[off],
value_raw=payload[off + 1],
low_raw=payload[off + 2],
high_raw=payload[off + 3],
)
)
return out
def parse_v1_system_status(payload: bytes) -> SystemStatus:
"""Parse a v1 SystemStatus reply.
Bytes 0..13 are byte-identical to v2 (time/date + sunrise/sunset +
battery). After byte 13 v1 carries per-area Mode bytes (1 byte each)
while v2 carries 2-byte alarm-flag pairs. We translate to the v2
dataclass's ``area_alarms`` shape by promoting each v1 mode byte to
a ``(mode, 0)`` tuple that way HA code that already consumes
:class:`SystemStatus` keeps working without a v1-specific branch.
"""
if len(payload) < 14:
raise ValueError(
f"v1 SystemStatus payload too short: {len(payload)} bytes"
)
time_valid = payload[0] != 0
year = payload[1]
month = payload[2]
day = payload[3]
# day_of_week = payload[4]
hour = payload[5]
minute = payload[6]
second = payload[7]
# daylight = payload[8]
sunrise_h = payload[9]
sunrise_m = payload[10]
sunset_h = payload[11]
sunset_m = payload[12]
battery = payload[13]
panel_time: datetime | None = None
if time_valid:
try:
panel_time = datetime(
year=2000 + year,
month=month,
day=day,
hour=hour,
minute=minute,
second=second,
)
except ValueError:
panel_time = None
# Promote each v1 per-area mode byte to a (mode, 0) pair so the v2
# area_alarms tuple shape carries the same information without a
# second dataclass.
mode_bytes = payload[14:]
area_alarms = tuple((b, 0) for b in mode_bytes)
return SystemStatus(
time_valid=time_valid,
panel_time=panel_time,
sunrise_hour=sunrise_h,
sunrise_minute=sunrise_m,
sunset_hour=sunset_h,
sunset_minute=sunset_m,
battery_reading=battery,
area_alarms=area_alarms,
)
# ---- NameData --------------------------------------------------------------
class NameType(IntEnum):
"""Categories of named objects panels can stream over UploadNames.
Reference: enuNameType.cs.
"""
ZONE = 1
UNIT = 2
BUTTON = 3
CODE = 4
AREA = 5
THERMOSTAT = 6
MESSAGE = 7
USER_SETTING = 8
ACCESS_CONTROL_READER = 9
# Per-type max name length (clsCapOMNI_PRO_II.cs lines 55-71).
# Other Omni models share these numbers — the few exceptions are
# documented but not relevant for the panels we know speak v1+UDP.
_NAME_TYPE_LENGTH: dict[int, int] = {
NameType.ZONE: 15,
NameType.UNIT: 12,
NameType.BUTTON: 12,
NameType.CODE: 12,
NameType.AREA: 12,
NameType.THERMOSTAT: 12,
NameType.MESSAGE: 15,
NameType.USER_SETTING: 15,
NameType.ACCESS_CONTROL_READER: 15,
}
@dataclass(frozen=True, slots=True)
class NameRecord:
"""One name record from a v1 ``NameData`` reply (opcode 11)."""
name_type: int
number: int
name: str
@property
def name_type_label(self) -> str:
try:
return NameType(self.name_type).name
except ValueError:
return f"Unknown({self.name_type})"
def parse_v1_namedata(payload: bytes) -> NameRecord:
"""Decode a v1 ``NameData`` payload (opcode 11) into a :class:`NameRecord`.
Wire layout (per clsOLMsgNameData.cs, MessageLength is the
full Data byte count including the opcode):
* One-byte form (NameNumber 255), MessageLength = 4 + NameTypeLen:
``[opcode][type][num][name×L][\\0]`` one trailing reserved byte.
* Two-byte form (NameNumber > 255), MessageLength = 5 + NameTypeLen:
``[opcode][type][num_hi][num_lo][name×L][\\0]``.
``payload`` here is the *inner* :attr:`Message.payload` (data minus
the leading opcode), so the lengths to compare against are L+3 and
L+4 respectively.
"""
if len(payload) < 3:
raise ValueError(f"NameData payload too short: {len(payload)} bytes")
name_type = payload[0]
name_len = _NAME_TYPE_LENGTH.get(name_type)
if name_len is not None:
# Disambiguate by payload length against the expected forms.
one_byte_len = name_len + 3 # type + num + name + 1 trailing
two_byte_len = name_len + 4 # type + num_hi + num_lo + name + 1 trailing
if len(payload) >= two_byte_len:
number = (payload[1] << 8) | payload[2]
name_bytes = payload[3 : 3 + name_len]
elif len(payload) >= one_byte_len:
number = payload[1]
name_bytes = payload[2 : 2 + name_len]
else:
# Short payload — best-effort one-byte decode of whatever is left.
number = payload[1]
name_bytes = payload[2:]
else:
# Unknown type — can't tell the form. Assume one-byte and consume
# the rest; HA filters by known type anyway.
number = payload[1]
name_bytes = payload[2:]
name = name_bytes.split(b"\x00", 1)[0].decode("utf-8", errors="replace")
return NameRecord(name_type=name_type, number=number, name=name)

View File

@ -64,26 +64,6 @@ def _short_scan_interval(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.fixture
def populated_state() -> MockState:
"""A lightly-populated mock state covering every entity platform."""
from omni_pca.programs import Days, Program, ProgramType
programs = {
slot: prog.encode_wire_bytes()
for slot, prog in {
12: Program(
slot=12, prog_type=int(ProgramType.TIMED),
cmd=3, hour=6, minute=0,
days=int(Days.MONDAY | Days.FRIDAY),
),
42: Program(
slot=42, prog_type=int(ProgramType.TIMED),
cmd=4, hour=22, minute=30,
days=int(Days.SUNDAY),
),
99: Program(
slot=99, prog_type=int(ProgramType.EVENT),
cmd=5, month=5, day=12,
),
}.items()
}
return MockState(
zones={
1: MockZoneState(name="FRONT_DOOR"),
@ -104,7 +84,6 @@ def populated_state() -> MockState:
1: MockButtonState(name="GOOD_MORNING"),
},
user_codes={1: 1234},
programs=programs,
)

View File

@ -1,207 +0,0 @@
"""HA-side integration: optional .pca file source for panel programs.
When ``CONF_PCA_PATH`` is set in the entry data, the coordinator should
parse the .pca file at that path (with ``CONF_PCA_KEY`` as the per-install
key) and use those programs *instead* of streaming them over the wire.
The wire-based discovery for everything else (zones, units, etc.) is
unaffected.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
import pytest
from custom_components.omni_pca.const import (
CONF_CONTROLLER_KEY,
CONF_PCA_KEY,
CONF_PCA_PATH,
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 .conftest import CONTROLLER_KEY_HEX
LIVE_FIXTURE_PLAIN = Path(
"/home/kdm/home-auto/HAI/pca-re/extracted/Our_House.pca.plain"
)
def _materialize_encrypted_fixture(tmp_path: Path) -> tuple[Path, int]:
"""Re-encrypt the plain fixture so parse_pca_file can decrypt it.
parse_pca_file always runs the XOR keystream. The plain dump bypasses
that, so we re-apply the keystream with KEY_EXPORT and write the
result to tmp_path. Returns (file_path, key) the coordinator should use.
"""
from omni_pca.pca_file import KEY_EXPORT, decrypt_pca_bytes
plain = LIVE_FIXTURE_PLAIN.read_bytes()
# XOR is symmetric — "decrypt" of plain bytes with the export key
# produces a valid encrypted .pca that parse_pca_file can read back.
encrypted = decrypt_pca_bytes(plain, KEY_EXPORT)
fixture = tmp_path / "Test_House.pca"
fixture.write_bytes(encrypted)
return fixture, KEY_EXPORT
@pytest.fixture
async def configured_with_pca(
hass: HomeAssistant, panel: tuple[Any, str, int], tmp_path: Path
) -> AsyncIterator[ConfigEntry]:
"""Config entry pointing at a .pca file fixture for programs."""
if not LIVE_FIXTURE_PLAIN.is_file():
pytest.skip(f"live .pca fixture missing: {LIVE_FIXTURE_PLAIN}")
fixture_path, pca_key = _materialize_encrypted_fixture(tmp_path)
_, host, port = panel
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_CONTROLLER_KEY: CONTROLLER_KEY_HEX,
CONF_PCA_PATH: str(fixture_path),
CONF_PCA_KEY: pca_key,
},
title=f"Mock Omni @ {host}:{port} (with .pca)",
unique_id=f"{host}:{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()
async def test_pca_source_overrides_wire_programs(
hass: HomeAssistant, configured_with_pca: ConfigEntry
) -> None:
"""The fixture .pca has 330 defined programs (Phase 1 recon). The mock
panel only seeded 3 in conftest. When pca_path is set, the .pca count
wins proving the coordinator routed through _discover_programs_from_pca,
not iter_programs."""
coordinator = hass.data[DOMAIN][configured_with_pca.entry_id]
assert len(coordinator.data.programs) == 330
# Sanity: the diagnostic sensor reflects the .pca count, not the mock seed.
sensors = [
s for s in hass.states.async_all("sensor")
if "panel_programs" in s.entity_id
]
assert len(sensors) == 1
assert int(sensors[0].state) == 330
async def test_mockpanel_from_pca_drives_full_ha_discovery(
hass: HomeAssistant, tmp_path: Path
) -> None:
"""End-to-end: build a MockPanel state straight from the live .pca,
then point HA at that mock with no other configuration. The
integration should discover *every* named zone / unit / button /
thermostat from the .pca via the normal wire path no .pca config
needed, because the mock is now serving real data.
"""
if not LIVE_FIXTURE_PLAIN.is_file():
pytest.skip(f"live .pca fixture missing: {LIVE_FIXTURE_PLAIN}")
from custom_components.omni_pca.const import CONF_CONTROLLER_KEY
from omni_pca.mock_panel import MockPanel, MockState
from omni_pca.pca_file import KEY_EXPORT, decrypt_pca_bytes
encrypted = decrypt_pca_bytes(LIVE_FIXTURE_PLAIN.read_bytes(), KEY_EXPORT)
state = MockState.from_pca(encrypted, key=KEY_EXPORT)
# Sanity — the from_pca seeding matches the live fixture's names.
assert len(state.zones) == 16
assert len(state.units) == 44
panel = MockPanel(
controller_key=bytes(range(16)), # matches CONTROLLER_KEY_HEX
state=state,
)
async with panel.serve(host="127.0.0.1") as (host, port):
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_CONTROLLER_KEY: CONTROLLER_KEY_HEX,
},
title=f"Mock Omni @ {host}:{port} (from .pca)",
unique_id=f"{host}:{port}",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
try:
coordinator = hass.data[DOMAIN][entry.entry_id]
# All 16 zones surfaced through normal wire discovery.
assert len(coordinator.data.zones) == 16
# Units, buttons, thermostats too.
assert len(coordinator.data.units) == 44
assert len(coordinator.data.buttons) == 16
assert len(coordinator.data.thermostats) == 2
# And the programs sensor reflects 330 from wire iter_programs.
assert len(coordinator.data.programs) == 330
# Zone types flowed from SetupData → mock → wire Properties
# reply → HA's ZoneProperties parser.
zone_types_by_slot = {
idx: z.zone_type for idx, z in coordinator.data.zones.items()
}
assert zone_types_by_slot[1] == 0x00 # GARAGE ENTRY → EntryExit
assert zone_types_by_slot[3] == 0x01 # BACK DOOR → Perimeter
assert zone_types_by_slot[7] == 0x03 # LIVINGROOM MOT → AwayInt
assert zone_types_by_slot[11] == 0x55 # OUTSIDE TEMP → outdoor temp
# Per-zone area assignments — single-area install, every
# zone surfaces as area=1 through the wire Properties reply.
for idx, z in coordinator.data.zones.items():
assert z.area == 1, f"zone {idx} expected area=1 got {z.area}"
# Areas: the live fixture has no user-assigned area names
# but the v2 client's list_area_names now falls back to
# "Area 1".."Area 8". HA's _discover_areas then enumerates
# each, walks the Properties reply, and lands the configured
# entry/exit delays from SetupData.
assert 1 in coordinator.data.areas
assert coordinator.data.areas[1].entry_delay == 60
assert coordinator.data.areas[1].exit_delay == 90
finally:
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_pca_path_validation_rejects_missing_file(
hass: HomeAssistant, tmp_path: Path
) -> None:
"""The config-flow validator returns ``pca_not_found`` for an absent
file. We exercise the helper directly to avoid spinning a full mock
panel just for the validation branch."""
from custom_components.omni_pca.config_flow import OmniConfigFlow
flow = OmniConfigFlow()
flow.hass = hass
err = await flow._validate_pca(str(tmp_path / "does-not-exist.pca"), 0)
assert err == "pca_not_found"
async def test_pca_path_validation_rejects_garbage(
hass: HomeAssistant, tmp_path: Path
) -> None:
"""A file that doesn't decode as a .pca returns ``pca_decode_failed``."""
from custom_components.omni_pca.config_flow import OmniConfigFlow
garbage = tmp_path / "garbage.pca"
garbage.write_bytes(b"not a real pca file" * 1000)
flow = OmniConfigFlow()
flow.hass = hass
err = await flow._validate_pca(str(garbage), 0)
assert err == "pca_decode_failed"

View File

@ -1,673 +0,0 @@
"""HA websocket commands for the program viewer.
Tests run against the live HA test harness so they exercise:
* websocket command registration during integration setup
* filter / pagination / search of the program list
* detail rendering for compact-form + clausal-chain slots
* fire-program over the wire to the mock panel
Each test uses the already-seeded ``configured_panel`` fixture from
conftest.py plus the test-harness-provided ``hass_ws_client``.
"""
from __future__ import annotations
import pytest
from custom_components.omni_pca.const import DOMAIN
from homeassistant.core import HomeAssistant
from omni_pca.commands import Command
from omni_pca.programs import Days, Program, ProgramType
@pytest.fixture
def seeded_programs() -> dict[int, Program]:
"""A small set of programs covering the main shapes the viewer renders.
Three compact-form TIMED programs and one clausal chain enough
to exercise summary rendering, the chain group-and-render path, and
the filter/search dimensions.
"""
return {
12: Program(
slot=12, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=1, # unit 1 in fixture
hour=6, minute=0, days=int(Days.MONDAY | Days.FRIDAY),
),
42: Program(
slot=42, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=2, # unit 2
hour=22, minute=30, days=int(Days.SUNDAY),
),
99: Program(
slot=99, prog_type=int(ProgramType.EVENT),
cmd=int(Command.UNIT_ON), pr2=1,
# WHEN zone 1 changes to NOT_READY (event_id = 0x0401)
month=0x04, day=0x01,
),
# A clausal chain spanning slots 200..203: WHEN zone 1 not-ready
# AND IF unit 1 ON THEN turn ON unit 2 AND turn OFF unit 1.
200: Program(
slot=200, prog_type=int(ProgramType.WHEN),
# event_id = 0x0401 (zone 1 not-ready) packed in month/day
month=0x04, day=0x01,
),
201: Program(
slot=201, prog_type=int(ProgramType.AND),
# Traditional AND: family byte 0x0A = CTRL+ON, instance 1.
# and_family = cond & 0xFF, and_instance = (cond2>>8) & 0xFF.
cond=0x000A, cond2=0x0100,
),
202: Program(
slot=202, prog_type=int(ProgramType.THEN),
cmd=int(Command.UNIT_ON), pr2=2,
),
203: Program(
slot=203, prog_type=int(ProgramType.THEN),
cmd=int(Command.UNIT_OFF), pr2=1,
),
}
@pytest.fixture
def populated_state(seeded_programs):
"""Override the conftest fixture to inject our test programs."""
from omni_pca.mock_panel import (
MockAreaState,
MockButtonState,
MockState,
MockThermostatState,
MockUnitState,
MockZoneState,
)
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},
programs={
slot: p.encode_wire_bytes() for slot, p in seeded_programs.items()
},
)
async def test_ws_list_programs_returns_summaries(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""The list command returns rendered summary tokens for every
program the coordinator discovered."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/list",
"entry_id": configured_panel.entry_id,
})
response = await client.receive_json()
assert response["success"] is True
result = response["result"]
# 3 compact-form programs (12, 42, 99) + 1 clausal chain (head at
# slot 200, spanning 200..203). The chain renders as a single row.
assert result["total"] == 4
assert result["filtered_total"] == 4
rows_by_slot = {row["slot"]: row for row in result["programs"]}
assert rows_by_slot.keys() == {12, 42, 99, 200}
assert rows_by_slot[200]["kind"] == "chain"
assert rows_by_slot[12]["kind"] == "compact"
async def test_ws_list_programs_filter_by_trigger_type(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""``trigger_types=["TIMED"]`` filters out the EVENT-typed row."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/list",
"entry_id": configured_panel.entry_id,
"trigger_types": ["TIMED"],
})
response = await client.receive_json()
assert response["success"] is True
result = response["result"]
assert result["filtered_total"] == 2 # only the two TIMED rows
assert {row["slot"] for row in result["programs"]} == {12, 42}
async def test_ws_list_programs_filter_by_referenced_entity(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""``references_entity="unit:2"`` returns only programs that mention unit 2."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/list",
"entry_id": configured_panel.entry_id,
"references_entity": "unit:2",
})
response = await client.receive_json()
result = response["result"]
# Slot 42 ("Turn ON KITCHEN_OVERHEAD" = unit 2) plus the seeded chain
# at slot 200 (action: Turn ON unit 2) both reference unit:2.
assert result["filtered_total"] == 2
slots = {r["slot"] for r in result["programs"]}
assert slots == {42, 200}
async def test_ws_list_programs_search_substring(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Search is a case-insensitive substring match on the rendered text."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/list",
"entry_id": configured_panel.entry_id,
"search": "kitchen",
})
response = await client.receive_json()
result = response["result"]
# Slot 42 ("Turn ON KITCHEN_OVERHEAD" — truncated to 12 chars on
# wire = "KITCHEN_OVER") matches. The chain at slot 200 also has
# an action against unit 2 which renders with the same truncated
# name, so it matches too.
assert result["filtered_total"] == 2
slots = {r["slot"] for r in result["programs"]}
assert slots == {42, 200}
async def test_ws_list_programs_pagination(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/list",
"entry_id": configured_panel.entry_id,
"limit": 2,
"offset": 1,
})
response = await client.receive_json()
result = response["result"]
# 4 list rows total: 3 compact + 1 chain head.
assert result["filtered_total"] == 4
assert len(result["programs"]) == 2
assert [row["slot"] for row in result["programs"]] == [42, 99]
async def test_ws_get_program_returns_full_token_stream(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Detail of a single slot returns the full structured-English tokens."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/get",
"entry_id": configured_panel.entry_id,
"slot": 42,
})
response = await client.receive_json()
assert response["success"] is True
result = response["result"]
assert result["slot"] == 42
assert result["kind"] == "compact"
assert result["trigger_type"] == "TIMED"
text = "".join(
tok["t"] for tok in result["tokens"] if tok.get("k") != "newline"
)
assert "Turn ON" in text
# Unit names cap at 12 bytes on Omni Pro II (lenUnitName), so the
# 16-char "KITCHEN_OVERHEAD" lands on the wire as "KITCHEN_OVER".
assert "KITCHEN_OVER" in text
async def test_ws_get_program_returns_raw_fields_for_editor(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""The detail response includes a 'fields' dict carrying raw Program
integer values, so the editor can seed forms from actual data rather
than defaults. Round-trip: get fields write back should preserve
every byte (idempotent under no-op edits)."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/get",
"entry_id": configured_panel.entry_id,
"slot": 42,
})
response = await client.receive_json()
assert response["success"] is True
fields = response["result"]["fields"]
# Slot 42 is the seeded TIMED 22:30 Sunday → Turn ON unit 2 program.
assert fields["prog_type"] == 1
assert fields["hour"] == 22
assert fields["minute"] == 30
assert fields["days"] == int(Days.SUNDAY)
assert fields["cmd"] == int(Command.UNIT_ON)
assert fields["pr2"] == 2
# Round-trip: write those same fields back; nothing should change.
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
before = coordinator.data.programs[42]
await client.send_json_auto_id({
"type": "omni_pca/programs/write",
"entry_id": configured_panel.entry_id,
"slot": 42,
"program": fields,
})
write_response = await client.receive_json()
assert write_response["success"] is True
after = coordinator.data.programs[42]
assert before.encode_wire_bytes() == after.encode_wire_bytes()
async def test_ws_get_program_missing_slot_returns_error(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/get",
"entry_id": configured_panel.entry_id,
"slot": 500, # not seeded
})
response = await client.receive_json()
assert response["success"] is False
assert response["error"]["code"] == "not_found"
async def test_ws_list_programs_unknown_entry_id(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Bad entry_id returns a structured ``not_found`` error, not a crash."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/list",
"entry_id": "does-not-exist",
})
response = await client.receive_json()
assert response["success"] is False
assert response["error"]["code"] == "not_found"
async def test_ws_fire_program_executes_command(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Fire sends Command.EXECUTE_PROGRAM over the wire — the mock acks."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/fire",
"entry_id": configured_panel.entry_id,
"slot": 42,
})
response = await client.receive_json()
assert response["success"] is True
assert response["result"] == {"slot": 42, "fired": True}
async def test_ws_clear_program_writes_zero_body(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Clear erases a slot end-to-end: ws command → DownloadProgram on
the wire mock state loses the slot coordinator drops it from
its in-memory map."""
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
assert 42 in coordinator.data.programs
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/clear",
"entry_id": configured_panel.entry_id,
"slot": 42,
})
response = await client.receive_json()
assert response["success"] is True
assert response["result"] == {"slot": 42, "cleared": True}
# The coordinator's view drops the slot immediately so a follow-up
# list reflects the deletion without waiting for the next poll.
assert 42 not in coordinator.data.programs
async def test_ws_clone_program_copies_to_empty_slot(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Cloning slot 12 to slot 500 lands a copy at the target with the
right fields and leaves the source untouched."""
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
assert 12 in coordinator.data.programs
assert 500 not in coordinator.data.programs
source = coordinator.data.programs[12]
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/clone",
"entry_id": configured_panel.entry_id,
"source_slot": 12,
"target_slot": 500,
})
response = await client.receive_json()
assert response["success"] is True
assert response["result"] == {
"source_slot": 12, "target_slot": 500, "cloned": True,
}
# New program landed at the target with re-stamped slot.
cloned = coordinator.data.programs[500]
assert cloned.slot == 500
assert cloned.prog_type == source.prog_type
assert cloned.cmd == source.cmd
assert cloned.pr2 == source.pr2
# Source remains.
assert 12 in coordinator.data.programs
async def test_ws_clone_program_rejects_same_slot(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/clone",
"entry_id": configured_panel.entry_id,
"source_slot": 12,
"target_slot": 12,
})
response = await client.receive_json()
assert response["success"] is False
assert response["error"]["code"] == "invalid"
async def test_ws_clone_program_rejects_missing_source(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Cloning from a slot that has no program is a structured error."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/clone",
"entry_id": configured_panel.entry_id,
"source_slot": 999, # not seeded
"target_slot": 100,
})
response = await client.receive_json()
assert response["success"] is False
assert response["error"]["code"] == "not_found"
async def test_ws_write_program_creates_new_slot(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Writing a Program dict to an empty slot lands a new program."""
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
assert 700 not in coordinator.data.programs
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/write",
"entry_id": configured_panel.entry_id,
"slot": 700,
"program": {
"prog_type": 1, # TIMED
"cmd": int(Command.UNIT_ON),
"pr2": 2,
"hour": 7, "minute": 30,
"days": int(Days.SATURDAY | Days.SUNDAY),
},
})
response = await client.receive_json()
assert response["success"] is True
assert response["result"] == {"slot": 700, "written": True}
new_program = coordinator.data.programs[700]
assert new_program.slot == 700
assert new_program.cmd == int(Command.UNIT_ON)
assert new_program.pr2 == 2
assert new_program.hour == 7 and new_program.minute == 30
async def test_ws_write_program_overwrites_existing_slot(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Writing to a slot that has a program replaces the existing one."""
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
# Slot 12 is seeded (TIMED hour=6 minute=0). Rewrite it.
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/write",
"entry_id": configured_panel.entry_id,
"slot": 12,
"program": {
"prog_type": 1,
"cmd": int(Command.UNIT_OFF),
"pr2": 99,
"hour": 23, "minute": 45, "days": int(Days.MONDAY),
},
})
response = await client.receive_json()
assert response["success"] is True
updated = coordinator.data.programs[12]
assert updated.cmd == int(Command.UNIT_OFF)
assert updated.pr2 == 99
assert updated.hour == 23 and updated.minute == 45
async def test_ws_write_program_validates_payload(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Bad program dict (out-of-range field) returns structured error."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/write",
"entry_id": configured_panel.entry_id,
"slot": 12,
"program": {
"prog_type": 99, # invalid (max 10)
"cmd": 1, "pr2": 1, "hour": 6, "minute": 0,
},
})
response = await client.receive_json()
assert response["success"] is False
assert response["error"]["code"] == "invalid"
async def test_ws_list_objects_returns_named_buckets(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""objects/list returns zones/units/areas/thermostats/buttons in
slot-sorted order with their HA-discovered names."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/objects/list",
"entry_id": configured_panel.entry_id,
})
response = await client.receive_json()
assert response["success"] is True
result = response["result"]
assert {"zones", "units", "areas", "thermostats", "buttons"} <= result.keys()
# Fixture has units at indexes 1, 2 (LIVING_LAMP, KITCHEN_OVERHEAD-truncated).
units = result["units"]
assert len(units) == 2
assert units[0]["index"] == 1
assert units[0]["name"] == "LIVING_LAMP"
# And zones come back with their fixture names too.
zones_by_idx = {z["index"]: z["name"] for z in result["zones"]}
assert zones_by_idx[1] == "FRONT_DOOR"
async def test_ws_get_chain_returns_member_fields(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Chain detail response includes a chain_members array with each
member's role + raw fields, so the editor can render an editable
row per slot."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/get",
"entry_id": configured_panel.entry_id,
"slot": 200, # head of the seeded chain
})
response = await client.receive_json()
assert response["success"] is True
result = response["result"]
assert result["kind"] == "chain"
members = result["chain_members"]
roles = [m["role"] for m in members]
assert roles == ["head", "condition", "action", "action"]
# Head carries the event_id (zone 1 NOT_READY = 0x0401).
head_fields = members[0]["fields"]
assert head_fields["prog_type"] == int(ProgramType.WHEN)
assert head_fields["month"] == 0x04
assert head_fields["day"] == 0x01
# Condition is a Traditional AND record with family CTRL+ON, unit 1.
cond_fields = members[1]["fields"]
assert cond_fields["prog_type"] == int(ProgramType.AND)
assert cond_fields["cond"] == 0x000A
assert cond_fields["cond2"] == 0x0100
async def test_ws_chain_write_replaces_in_place(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Same-length rewrite leaves the chain footprint unchanged but
updates every member's bytes."""
client = await hass_ws_client(hass)
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
# Existing chain: slots 200..203.
assert {200, 201, 202, 203} <= coordinator.data.programs.keys()
await client.send_json_auto_id({
"type": "omni_pca/programs/chain/write",
"entry_id": configured_panel.entry_id,
"head_slot": 200,
"head": {
"prog_type": int(ProgramType.WHEN),
"month": 0x04, "day": 0x02, # zone 1 trouble (id 0x0402)
},
"conditions": [
# AND IF unit 2 ON (family 0x0A, instance 2)
{"prog_type": int(ProgramType.AND),
"cond": 0x000A, "cond2": 0x0200},
],
"actions": [
{"prog_type": int(ProgramType.THEN),
"cmd": int(Command.UNIT_OFF), "pr2": 2},
{"prog_type": int(ProgramType.THEN),
"cmd": int(Command.UNIT_ON), "pr2": 1},
],
})
response = await client.receive_json()
assert response["success"] is True
assert response["result"]["written_slots"] == [200, 201, 202, 203]
assert response["result"]["cleared_slots"] == []
# Coordinator state reflects the new bytes.
assert coordinator.data.programs[200].day == 0x02
assert coordinator.data.programs[201].cond2 == 0x0200
assert coordinator.data.programs[202].cmd == int(Command.UNIT_OFF)
async def test_ws_chain_write_shrinks_and_clears(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Shorter rewrite clears the trailing old chain slots."""
client = await hass_ws_client(hass)
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
await client.send_json_auto_id({
"type": "omni_pca/programs/chain/write",
"entry_id": configured_panel.entry_id,
"head_slot": 200,
"head": {
"prog_type": int(ProgramType.WHEN),
"month": 0x04, "day": 0x01,
},
# No conditions, one action — chain shrinks from 4 slots to 2.
"conditions": [],
"actions": [
{"prog_type": int(ProgramType.THEN),
"cmd": int(Command.UNIT_ON), "pr2": 1},
],
})
response = await client.receive_json()
assert response["success"] is True
assert response["result"]["written_slots"] == [200, 201]
assert sorted(response["result"]["cleared_slots"]) == [202, 203]
# Cleared slots are gone from the coordinator's view.
assert 202 not in coordinator.data.programs
assert 203 not in coordinator.data.programs
async def test_ws_chain_write_refuses_to_trample(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Expanding a chain into a slot that already holds another program
is refused protects against accidental data loss."""
client = await hass_ws_client(hass)
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
# Seed a sentinel program at slot 204 (right after the chain) so an
# expand attempt collides.
coordinator.data.programs[204] = Program(
slot=204, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=1,
hour=12, minute=0, days=int(Days.MONDAY),
)
await client.send_json_auto_id({
"type": "omni_pca/programs/chain/write",
"entry_id": configured_panel.entry_id,
"head_slot": 200,
"head": {"prog_type": int(ProgramType.WHEN),
"month": 0x04, "day": 0x01},
"conditions": [
{"prog_type": int(ProgramType.AND),
"cond": 0x000A, "cond2": 0x0100},
# Adding a second condition pushes the chain from 4 to 5
# slots → slot 204 collision.
{"prog_type": int(ProgramType.AND),
"cond": 0x000A, "cond2": 0x0200},
],
"actions": [
{"prog_type": int(ProgramType.THEN),
"cmd": int(Command.UNIT_ON), "pr2": 2},
{"prog_type": int(ProgramType.THEN),
"cmd": int(Command.UNIT_OFF), "pr2": 1},
],
})
response = await client.receive_json()
assert response["success"] is False
assert response["error"]["code"] == "invalid"
# The sentinel program is untouched.
assert coordinator.data.programs[204].cmd == int(Command.UNIT_ON)
async def test_ws_chain_write_rejects_zero_actions(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""A chain with no THEN actions is meaningless — refuse it."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/chain/write",
"entry_id": configured_panel.entry_id,
"head_slot": 200,
"head": {"prog_type": int(ProgramType.WHEN),
"month": 0x04, "day": 0x01},
"conditions": [],
"actions": [],
})
response = await client.receive_json()
assert response["success"] is False
assert response["error"]["code"] == "invalid"
async def test_ws_list_programs_live_state_overlay_zone(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Summary tokens carry live-state badges on REF tokens.
The EVENT program at slot 99 references zone 1; the coordinator's
``zone_status[1]`` carries SECURE / NOT READY etc. and we expect
that label to flow through to the token's ``s`` field.
"""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/list",
"entry_id": configured_panel.entry_id,
})
response = await client.receive_json()
rows_by_slot = {row["slot"]: row for row in response["result"]["programs"]}
event_row = rows_by_slot[99]
refs = [tok for tok in event_row["summary"] if tok.get("k") == "ref"]
# At least one REF should be the zone-1 reference with a state label.
zone_refs = [r for r in refs if r.get("ek") == "zone" and r.get("ei") == 1]
assert zone_refs, f"expected zone:1 ref in {refs!r}"
assert "s" in zone_refs[0] # state badge populated

View File

@ -78,35 +78,6 @@ async def test_button_entity_for_panel_button(
assert len(states) == 1
async def test_programs_sensor_reflects_seeded_panel(
hass: HomeAssistant, configured_panel
) -> None:
"""The diagnostic Panel Programs sensor enumerates discovered programs.
Mock seed has 3 programs at slots 12 / 42 / 99 (see
:func:`populated_state`); after discovery the coordinator stashes them
on ``OmniData.programs`` and the sensor surfaces a count + per-slot
summary attribute.
"""
programs_states = [
s for s in hass.states.async_all("sensor")
if s.entity_id.endswith("_panel_programs") or "panel_programs" in s.entity_id
]
assert len(programs_states) == 1, (
f"expected one panel_programs sensor, got {[s.entity_id for s in programs_states]}"
)
s = programs_states[0]
assert int(s.state) == 3
summaries = s.attributes["programs"]
assert [p["slot"] for p in summaries] == [12, 42, 99]
# Spot-check the per-record fields landed in summary form.
by_slot = {p["slot"]: p for p in summaries}
assert by_slot[12]["type"] == "TIMED"
assert by_slot[12]["hour"] == 6 and by_slot[12]["minute"] == 0
assert by_slot[99]["type"] == "EVENT"
assert by_slot[99]["month"] == 5 and by_slot[99]["day"] == 12
async def test_event_entity_per_panel(
hass: HomeAssistant, configured_panel
) -> None:

View File

@ -1,666 +0,0 @@
"""End-to-end wire round-trip: client → MockPanel → program decoded.
Seeds the MockPanel with known :class:`Program` records, exercises
both wire dialects, and asserts the decoded result equals what was
seeded.
* v2 (TCP, request/response per slot): drives ``UploadProgram`` once
per slot. Proves the per-program framing (2-byte BE ProgramNumber +
14-byte body wrapped in a ``ProgramData`` reply).
* v1 (UDP, streaming): drives bare ``UploadPrograms``, ack-walks the
streamed ``ProgramData`` replies to ``EOD``. Proves the streaming
lock-step matches the panel's behaviour described in
``clsHAC.OL1ReadConfig`` (clsHAC.cs:4403, 4538-4540, 4642-4651).
"""
from __future__ import annotations
import struct
import pytest
from omni_pca.connection import OmniConnection
from omni_pca.mock_panel import MockPanel, MockState
from omni_pca.opcodes import OmniLink2MessageType, OmniLinkMessageType
from omni_pca.programs import Days, Program, ProgramType
from omni_pca.v1 import OmniClientV1
CONTROLLER_KEY = bytes.fromhex("000102030405060708090a0b0c0d0e0f")
def _seeded() -> Program:
"""A TIMED program with non-trivial fields in every slot.
Picks values that would fail if any byte got swapped or zeroed.
"""
return Program(
slot=42,
prog_type=int(ProgramType.TIMED),
cond=0x8D09,
cond2=0x9B09,
cmd=0x44,
par=3,
pr2=0x0100,
month=8,
day=12,
days=int(Days.MONDAY | Days.TUESDAY | Days.WEDNESDAY | Days.THURSDAY | Days.FRIDAY),
hour=7,
minute=15,
)
@pytest.mark.asyncio
async def test_v2_upload_program_round_trips_through_mock_panel() -> None:
seeded = _seeded()
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={42: seeded.encode_wire_bytes()}),
)
async with (
panel.serve(transport="tcp") as (host, port),
OmniConnection(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as conn,
):
# UploadProgram request body: [number_hi, number_lo, request_reason]
payload = struct.pack(">HB", 42, 0)
reply = await conn.request(OmniLink2MessageType.UploadProgram, payload)
assert reply.opcode == int(OmniLink2MessageType.ProgramData)
# Reply payload: [number_hi, number_lo] + 14-byte body
assert len(reply.payload) == 2 + 14
echoed_number = (reply.payload[0] << 8) | reply.payload[1]
assert echoed_number == 42
decoded = Program.from_wire_bytes(reply.payload[2:], slot=42)
# Compare field-by-field — slot was passed through unchanged.
assert decoded.prog_type == seeded.prog_type
assert decoded.cond == seeded.cond
assert decoded.cond2 == seeded.cond2
assert decoded.cmd == seeded.cmd
assert decoded.par == seeded.par
assert decoded.pr2 == seeded.pr2
assert decoded.month == seeded.month
assert decoded.day == seeded.day
assert decoded.days == seeded.days
assert decoded.hour == seeded.hour
assert decoded.minute == seeded.minute
@pytest.mark.asyncio
async def test_v2_upload_program_empty_slot_returns_zero_body() -> None:
"""An unseeded slot should respond with 14 zero bytes (matches real panel)."""
panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState())
async with (
panel.serve(transport="tcp") as (host, port),
OmniConnection(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as conn,
):
payload = struct.pack(">HB", 99, 0)
reply = await conn.request(OmniLink2MessageType.UploadProgram, payload)
assert reply.opcode == int(OmniLink2MessageType.ProgramData)
assert reply.payload == bytes([0, 99]) + b"\x00" * 14
decoded = Program.from_wire_bytes(reply.payload[2:], slot=99)
assert decoded.is_empty()
@pytest.mark.asyncio
async def test_v2_upload_program_event_type_no_swap_on_wire() -> None:
"""EVENT-typed programs must NOT swap Mon/Day on the wire (clsOLMsgProgramData
doesn't apply the file-layout swap)."""
seeded = Program(
slot=7,
prog_type=int(ProgramType.EVENT),
cond=0x0C04,
cmd=int(OmniLink2MessageType.Ack), # arbitrary; just non-zero
month=5, # in WIRE layout: byte 9 = month, byte 10 = day
day=12,
)
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={7: seeded.encode_wire_bytes()}),
)
async with (
panel.serve(transport="tcp") as (host, port),
OmniConnection(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as conn,
):
payload = struct.pack(">HB", 7, 0)
reply = await conn.request(OmniLink2MessageType.UploadProgram, payload)
body = reply.payload[2:]
# Byte 9 should be 5 (month), byte 10 should be 12 (day) -- the
# exact wire-layout encoding of an EVENT program with month=5,
# day=12. If the mock swapped (treating it as file layout), we'd
# see byte 9 = 12 and byte 10 = 5.
assert body[9] == 5
assert body[10] == 12
# And the decoded values match what we seeded.
decoded = Program.from_wire_bytes(body, slot=7)
assert decoded.month == 5
assert decoded.day == 12
# ---- v1 streaming -------------------------------------------------------
def _decode_v1_programdata(payload: bytes) -> tuple[int, Program]:
"""Strip the BE ProgramNumber prefix from a v1 ``ProgramData`` payload,
decode the 14-byte body. Mirrors the v2 helper inline above."""
assert len(payload) >= 2 + 14
slot = (payload[0] << 8) | payload[1]
return slot, Program.from_wire_bytes(payload[2 : 2 + 14], slot=slot)
@pytest.mark.asyncio
async def test_v1_upload_programs_streams_all_seeded_slots() -> None:
"""The v1 ``UploadPrograms`` opcode is bare; the panel streams one
``ProgramData`` reply per defined slot, each followed by a client Ack,
terminated by ``EOD``. Order is by ascending slot index which is
what we feed back from ``sorted(state.programs)``."""
seeded = {
12: Program(slot=12, prog_type=int(ProgramType.TIMED), cmd=3, hour=6, minute=0,
days=int(Days.MONDAY | Days.FRIDAY)),
42: Program(slot=42, prog_type=int(ProgramType.TIMED), cond=0x8D09, cond2=0x9B09,
cmd=0x44, par=3, pr2=0x0100, month=8, day=12,
days=int(Days.MONDAY), hour=7, minute=15),
99: Program(slot=99, prog_type=int(ProgramType.EVENT), cmd=5, month=5, day=12),
}
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={s: p.encode_wire_bytes() for s, p in seeded.items()}),
)
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
received: dict[int, Program] = {}
async for reply in c.connection.iter_streaming(
OmniLinkMessageType.UploadPrograms
):
assert reply.opcode == int(OmniLinkMessageType.ProgramData)
slot, prog = _decode_v1_programdata(reply.payload)
received[slot] = prog
assert set(received) == set(seeded)
for slot, want in seeded.items():
got = received[slot]
# Field-by-field — same checks as the v2 test, plus a slot equality.
assert got.slot == slot
assert got.prog_type == want.prog_type
assert got.cond == want.cond
assert got.cond2 == want.cond2
assert got.cmd == want.cmd
assert got.par == want.par
assert got.pr2 == want.pr2
assert got.month == want.month
assert got.day == want.day
assert got.days == want.days
assert got.hour == want.hour
assert got.minute == want.minute
@pytest.mark.asyncio
async def test_v1_upload_programs_empty_state_yields_immediate_eod() -> None:
"""No programs defined → the streaming iterator terminates without
yielding anything (the panel jumps straight to EOD)."""
panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
replies = [
r async for r in c.connection.iter_streaming(
OmniLinkMessageType.UploadPrograms
)
]
assert replies == []
# ---- v2 iter_programs (reason=1 "next defined" iteration) ---------------
@pytest.mark.asyncio
async def test_v2_upload_program_reason1_returns_next_defined_slot() -> None:
"""``request_reason=1`` should return the lowest defined slot strictly
greater than the requested number the C# panel uses this to iterate
(clsHAC.cs:5331)."""
seeded = {
5: Program(slot=5, prog_type=int(ProgramType.TIMED), cmd=3),
12: Program(slot=12, prog_type=int(ProgramType.TIMED), cmd=3),
99: Program(slot=99, prog_type=int(ProgramType.EVENT), cmd=5),
}
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={s: p.encode_wire_bytes() for s, p in seeded.items()}),
)
async with (
panel.serve(transport="tcp") as (host, port),
OmniConnection(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as conn,
):
# Seed slot 0 with reason=1 → first defined slot (5).
reply = await conn.request(
OmniLink2MessageType.UploadProgram, struct.pack(">HB", 0, 1)
)
assert reply.opcode == int(OmniLink2MessageType.ProgramData)
assert (reply.payload[0] << 8) | reply.payload[1] == 5
# From slot 5 with reason=1 → slot 12.
reply = await conn.request(
OmniLink2MessageType.UploadProgram, struct.pack(">HB", 5, 1)
)
assert (reply.payload[0] << 8) | reply.payload[1] == 12
# From slot 12 with reason=1 → slot 99.
reply = await conn.request(
OmniLink2MessageType.UploadProgram, struct.pack(">HB", 12, 1)
)
assert (reply.payload[0] << 8) | reply.payload[1] == 99
# From slot 99 with reason=1 → EOD (no more).
reply = await conn.request(
OmniLink2MessageType.UploadProgram, struct.pack(">HB", 99, 1)
)
assert reply.opcode == int(OmniLink2MessageType.EOD)
@pytest.mark.asyncio
async def test_v2_client_iter_programs_enumerates_all_seeded() -> None:
"""High-level OmniClient.iter_programs() drives the reason=1 iteration
and yields decoded Program records in slot-ascending order."""
from omni_pca.client import OmniClient
seeded = {
12: Program(slot=12, prog_type=int(ProgramType.TIMED), cmd=3, hour=6, minute=0,
days=int(Days.MONDAY | Days.FRIDAY)),
42: Program(slot=42, prog_type=int(ProgramType.TIMED), cond=0x8D09, cond2=0x9B09,
cmd=0x44, par=3, pr2=0x0100, month=8, day=12,
days=int(Days.MONDAY), hour=7, minute=15),
99: Program(slot=99, prog_type=int(ProgramType.EVENT), cmd=5, month=5, day=12),
}
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={s: p.encode_wire_bytes() for s, p in seeded.items()}),
)
async with panel.serve(transport="tcp") as (host, port):
async with OmniClient(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
received = [p async for p in c.iter_programs()]
assert [p.slot for p in received] == [12, 42, 99]
for got, want in zip(received, seeded.values()):
assert got.prog_type == want.prog_type
assert got.cmd == want.cmd
assert got.hour == want.hour
assert got.minute == want.minute
@pytest.mark.asyncio
async def test_v2_client_iter_programs_empty_state_yields_nothing() -> None:
from omni_pca.client import OmniClient
panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState())
async with panel.serve(transport="tcp") as (host, port):
async with OmniClient(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
received = [p async for p in c.iter_programs()]
assert received == []
# ---- v1 client iter_programs (high-level wrapper over iter_streaming) ----
@pytest.mark.asyncio
async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
"""End-to-end: build MockState from the live .pca, drive iter_programs
over v2 wire, decode every yielded Program. This exercises the full
file mock wire decoder pipeline with real on-disk data.
The fixture is the same plain-text dump tests/test_pca_file.py uses;
we re-encrypt with KEY_EXPORT on the fly so parse_pca_file accepts it.
"""
from pathlib import Path
from omni_pca.client import OmniClient
from omni_pca.mock_panel import MockState
from omni_pca.pca_file import KEY_EXPORT, decrypt_pca_bytes
plain = Path("/home/kdm/home-auto/HAI/pca-re/extracted/Our_House.pca.plain")
if not plain.is_file():
pytest.skip(f"live fixture missing: {plain}")
encrypted = decrypt_pca_bytes(plain.read_bytes(), KEY_EXPORT)
state = MockState.from_pca(encrypted, key=KEY_EXPORT)
# SystemInfo fields were populated from the .pca header.
assert state.model_byte == 16 # OMNI_PRO_II
assert state.firmware_major == 2
# Programs: 330 defined per Phase 1 recon.
assert len(state.programs) == 330
# Names: per the live fixture's reconnaissance dump.
assert len(state.zones) == 16
assert len(state.units) == 44
assert len(state.buttons) == 16
assert len(state.thermostats) == 2
# Areas: this fixture has no user-assigned names but
# NumAreasUsed=1, so MockState.from_pca synthesizes a single
# unnamed area 1 with the .pca's entry/exit delays.
assert len(state.areas) == 1
assert state.areas[1].name == ""
assert state.areas[1].entry_delay == 60 # configured in PC Access
assert state.areas[1].exit_delay == 90
assert state.areas[1].enabled is True
# Sanity-check the raw PcaAccount scalars too.
from omni_pca.pca_file import parse_pca_file
acct = parse_pca_file(encrypted, key=KEY_EXPORT)
assert acct.temp_format == 1 # 1 = Fahrenheit
assert acct.num_areas_used == 1
assert acct.area_entry_delays[1] == 60
assert acct.area_exit_delays[1] == 90
# Area-1 boolean flags (real homeowner-configured values):
# EntryChime OFF (no keypad chime on entry)
# QuickArm ON (arming without a code)
# AutoBypass OFF
# AllOnForAlarm ON
# TroubleBeep OFF
# PerimeterChime OFF (homeowner disabled)
# AudibleExitDelay ON
assert acct.area_entry_chime[1] is False
assert acct.area_quick_arm[1] is True
assert acct.area_auto_bypass[1] is False
assert acct.area_all_on_for_alarm[1] is True
assert acct.area_trouble_beep[1] is False
assert acct.area_perimeter_chime[1] is False
assert acct.area_audible_exit_delay[1] is True
# And the values flowed through MockState.
assert state.areas[1].quick_arm is True
assert state.areas[1].entry_chime is False
assert state.areas[1].perimeter_chime is False
# DST configuration — US default (Mar/2nd Sun, Nov/1st Sun).
assert acct.dst_start_month == 3
assert acct.dst_start_week == 2
assert acct.dst_end_month == 11
assert acct.dst_end_week == 1
# Unit type derivation — X10 sub-types resolved via HouseCodeFormat.
# HouseCode 1 in this fixture is HLC (5), so units 1..16 split into
# HLCRoom (Number-1 ≡ 0 mod 8) and HLCLoad. HouseCodes 2..16 are
# Extended (1), so units 17..256 are enuOL2UnitType.Extended (2).
assert acct.unit_types[1] == 5 # ROOM ONE → HLCRoom
assert acct.unit_types[2] == 6 # FRONT PORCH → HLCLoad
assert acct.unit_types[9] == 5 # next room-slot → HLCRoom
assert acct.unit_types[17] == 2 # HouseCode 2 Extended → Extended
assert acct.unit_types[257] == 13 # ExpEnc → Output
assert acct.unit_types[385] == 13 # VoltOut → Output
assert acct.unit_types[393] == 12 # FlagOut → Flag
# Unit type/areas threaded into MockUnitState — first 16 units are
# under HouseCode 1 (HLC).
assert state.units[1].unit_type == 5 # ROOM ONE → HLCRoom
# Area was 0xff (panel default = "all") → normalized to 0x01 in mock.
assert state.units[1].areas == 0x01
# HouseCodes.EnableExtCode raw bytes.
assert acct.house_code_formats[1] == 5 # HLC
assert all(v == 1 for v in (
acct.house_code_formats[i] for i in range(2, 17)
)) # all Extended
# TimeClock 1: outdoor-lights schedule On 22:30 → Off 06:00 daily.
tc1_on, tc1_off = acct.time_clocks[0], acct.time_clocks[1]
assert (tc1_on.hour, tc1_on.minute) == (22, 30)
assert tc1_on.days == 0xFE # every day (bits 1..7)
assert (tc1_off.hour, tc1_off.minute) == (6, 0)
# Installer / PCAccess codes (PII; both repr=False).
assert 0 < acct.installer_code <= 0xFFFF
assert 0 < acct.pc_access_code <= 0xFFFF
assert acct.enable_pc_access is True
r = repr(acct)
assert "installer_code" not in r
assert "pc_access_code" not in r
# Geographic configuration — northern-US install on Pacific time.
assert 25 <= acct.latitude <= 49 # continental US lat range
assert 67 <= acct.longitude <= 125 # continental US long range
assert acct.time_zone in (5, 6, 7, 8, 9, 10) # US zones EST..AKST
# Telephony / dialer scalars + the panel's own number (PII).
assert acct.telephone_access is True
assert acct.rings_before_answer == 8
assert acct.my_phone_number != "" # a real number is set
assert "my_phone_number" not in repr(acct) # but never in repr
assert acct.callback_number == "-" # blank-number sentinel
# Misc panel scalars.
assert acct.house_code == 1 # base X10 house code A
assert acct.num_thermostats == 64 # OMNI_PRO_II thermostat cap
assert acct.flash_light_num == 2 # X10 unit flashed on alarm
assert acct.verify_fire_alarms is True
assert acct.enable_console_emg is True
assert acct.high_security is False
# DCM dialer block — not configured for monitoring in this fixture
# ("-" blank phone numbers) but the per-zone alarm-code table and
# emergency codes are still populated.
assert acct.dcm.phone_number_1 == "-"
assert "phone_number_1" not in repr(acct.dcm) # PII repr=False
assert len(acct.dcm.zone_alarm_codes) == 176
assert len(acct.dcm.emergency_codes) == 8
assert all(0 <= c <= 255 for c in acct.dcm.emergency_codes)
# Codes: PINs decode as BE u16. PII fields not in repr().
assert acct.code_authority[1] == 1 # COMPUTER → User
assert acct.code_authority[4] == 2 # Debra → Manager
assert acct.code_authority[5] == 3 # Cage → Installer
assert 0 <= acct.code_pins[1] <= 0xFFFF
assert "code_pins" not in repr(acct)
assert state.zones[1].name == "GARAGE ENTRY"
assert state.units[1].name == "ROOM ONE"
assert state.thermostats[1].name == "DOWNSTAIRS"
# Zone types from SetupData — door zones are EntryExit (0) or
# Perimeter (1), motion sensors are AwayInt (3), the OUTSIDE TEMP
# zone is Extended_Range_OutdoorTemp (0x55).
assert state.zones[1].zone_type == 0x00 # GARAGE ENTRY → EntryExit
assert state.zones[2].zone_type == 0x00 # FRONT DOOR → EntryExit
assert state.zones[3].zone_type == 0x01 # BACK DOOR → Perimeter
assert state.zones[7].zone_type == 0x03 # LIVINGROOM MOT → AwayInt
assert state.zones[11].zone_type == 0x55 # OUTSIDE TEMP → outdoor temp
# Zone area assignments from SetupData — single-area install, all
# zones in area 1.
for slot, zone in state.zones.items():
assert zone.area == 1, f"slot {slot} expected area=1 got {zone.area}"
# ZoneOptions — every zone carries the panel-default 4 in this fixture.
for slot, zone in state.zones.items():
assert zone.options == 4, f"slot {slot} expected options=4 got {zone.options}"
assert all(v == 4 for v in acct.zone_options.values())
assert len(acct.zone_options) == 176
# Thermostat type + area from SetupData. The two named thermostats
# (DOWNSTAIRS, UPSTAIRS) are type 1; areas were 0xFF (all) →
# normalised to area 1 only in MockState.
assert acct.thermostat_types[1] == 1
assert acct.thermostat_types[2] == 1
assert len(acct.thermostat_types) == 64
assert state.thermostats[1].thermostat_type == 1
assert state.thermostats[1].areas == 0x01
# Four scalars sandwiched around the thermostat arrays.
assert acct.time_adj == 30 # panel default
assert 1 <= acct.alarm_reset_time <= 30 # in valid standard range
assert acct.arming_confirmation is False
assert acct.two_way_audio is False
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with panel.serve(transport="tcp") as (host, port):
async with OmniClient(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
decoded = [p async for p in c.iter_programs()]
# Every defined slot streamed back, in ascending slot order.
assert len(decoded) == 330
assert [p.slot for p in decoded] == sorted(state.programs)
# Spot check: every decoded record has a known ProgramType.
for p in decoded:
assert p.prog_type in {1, 2, 3} # TIMED / EVENT / YEARLY from this fixture
# ---- DownloadProgram writeback ------------------------------------------
@pytest.mark.asyncio
async def test_v2_download_program_writes_slot() -> None:
"""Writing a Program via DownloadProgram lands it in MockState; a
subsequent UploadProgram returns the same bytes proving the
full read-then-write-then-read loop works against the mock."""
from omni_pca.client import OmniClient
from omni_pca.commands import Command
target = Program(
slot=42, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=7,
hour=22, minute=30,
days=int(Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY),
)
panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState())
async with panel.serve(transport="tcp") as (host, port):
async with OmniClient(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0,
) as c:
# Slot 42 starts empty.
assert 42 not in panel.state.programs
await c.download_program(42, target)
# Now the mock's state should carry the wire bytes.
assert 42 in panel.state.programs
assert panel.state.programs[42] == target.encode_wire_bytes()
# And a read-back via iter_programs should yield the same program.
programs = [p async for p in c.iter_programs()]
assert len(programs) == 1
p = programs[0]
assert p.slot == 42
assert p.prog_type == int(ProgramType.TIMED)
assert p.cmd == int(Command.UNIT_ON)
assert p.pr2 == 7
assert p.hour == 22 and p.minute == 30
assert p.days == int(Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY)
@pytest.mark.asyncio
async def test_v2_download_program_overwrites_existing_slot() -> None:
"""Writing to a slot that already has a program replaces it."""
from omni_pca.client import OmniClient
from omni_pca.commands import Command
original = Program(
slot=10, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_OFF), pr2=1,
hour=6, minute=0, days=int(Days.MONDAY),
)
replacement = Program(
slot=10, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=99,
hour=22, minute=0, days=int(Days.SUNDAY),
)
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={10: original.encode_wire_bytes()}),
)
async with panel.serve(transport="tcp") as (host, port):
async with OmniClient(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0,
) as c:
await c.download_program(10, replacement)
assert panel.state.programs[10] == replacement.encode_wire_bytes()
@pytest.mark.asyncio
async def test_v2_clear_program_removes_slot() -> None:
"""``clear_program`` writes an all-zero body, which the mock treats
as deletion subsequent reads see the slot as undefined."""
from omni_pca.client import OmniClient
from omni_pca.commands import Command
seed = Program(
slot=5, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=1,
hour=6, minute=0, days=int(Days.MONDAY),
)
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={5: seed.encode_wire_bytes()}),
)
async with panel.serve(transport="tcp") as (host, port):
async with OmniClient(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0,
) as c:
await c.clear_program(5)
assert 5 not in panel.state.programs
@pytest.mark.asyncio
async def test_v2_download_program_rejects_out_of_range_slot() -> None:
"""Client-side range check catches bad slot before sending."""
from omni_pca.client import OmniClient
p = Program(slot=1, prog_type=int(ProgramType.TIMED))
panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState())
async with panel.serve(transport="tcp") as (host, port):
async with OmniClient(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0,
) as c:
with pytest.raises(ValueError, match="out of range"):
await c.download_program(0, p)
with pytest.raises(ValueError, match="out of range"):
await c.download_program(1501, p)
@pytest.mark.asyncio
async def test_v1_download_program_raises_not_implemented() -> None:
"""v1 has no single-slot write; the client raises a structured
NotImplementedError so HA can surface the limitation."""
from omni_pca.v1 import OmniClientV1
p = Program(slot=1, prog_type=int(ProgramType.TIMED))
panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0,
) as c:
with pytest.raises(NotImplementedError, match="v1 panels"):
await c.download_program(1, p)
@pytest.mark.asyncio
async def test_v1_client_iter_programs_enumerates_all_seeded() -> None:
seeded = {
12: Program(slot=12, prog_type=int(ProgramType.TIMED), cmd=3, hour=6, minute=0,
days=int(Days.MONDAY | Days.FRIDAY)),
42: Program(slot=42, prog_type=int(ProgramType.TIMED), cond=0x8D09, cond2=0x9B09,
cmd=0x44, par=3, pr2=0x0100, month=8, day=12,
days=int(Days.MONDAY), hour=7, minute=15),
99: Program(slot=99, prog_type=int(ProgramType.EVENT), cmd=5, month=5, day=12),
}
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={s: p.encode_wire_bytes() for s, p in seeded.items()}),
)
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
received = [p async for p in c.iter_programs()]
assert [p.slot for p in received] == [12, 42, 99]
for got, want in zip(received, seeded.values()):
assert got.prog_type == want.prog_type
assert got.cmd == want.cmd
assert got.cond == want.cond
assert got.cond2 == want.cond2
assert got.hour == want.hour
assert got.minute == want.minute

View File

@ -1,152 +0,0 @@
"""End-to-end: OmniClient ↔ MockPanel over UDP.
Mirrors test_e2e_client_mock.py but with ``transport='udp'`` on both
sides. The protocol/encryption/handshake bytes are identical to TCP;
this proves only the transport layer change is sound.
"""
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 ConnectionState, HandshakeError, OmniConnection
from omni_pca.events import UnitStateChanged
from omni_pca.mock_panel import (
MockAreaState,
MockButtonState,
MockPanel,
MockState,
MockThermostatState,
MockUnitState,
MockZoneState,
)
from omni_pca.models import (
AreaStatus,
SecurityMode,
)
from omni_pca.opcodes import OmniLink2MessageType
CONTROLLER_KEY = bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09")
def _populated_state() -> MockState:
return MockState(
zones={1: MockZoneState(name="FRONT_DOOR")},
units={1: MockUnitState(name="LIVING_LAMP")},
areas={1: MockAreaState(name="MAIN")},
thermostats={1: MockThermostatState(name="LIVING")},
buttons={1: MockButtonState(name="GOOD_MORNING")},
user_codes={1: 1234},
)
async def test_udp_handshake_roundtrip() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with (
panel.serve(transport="udp") as (host, port),
OmniConnection(
host=host,
port=port,
controller_key=CONTROLLER_KEY,
transport="udp",
timeout=2.0,
) as conn,
):
assert conn.state is ConnectionState.ONLINE
assert panel.session_count == 1
async def test_udp_get_system_information() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with (
panel.serve(transport="udp") as (host, port),
OmniConnection(
host=host,
port=port,
controller_key=CONTROLLER_KEY,
transport="udp",
timeout=2.0,
) as conn,
):
reply = await conn.request(OmniLink2MessageType.RequestSystemInformation)
assert reply.opcode == int(OmniLink2MessageType.SystemInformation)
# First payload byte is the model byte.
assert reply.payload[0] == 16 # OMNI_PRO_II
async def test_udp_arm_area_with_correct_code() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with (
panel.serve(transport="udp") as (host, port),
OmniClient(
host=host,
port=port,
controller_key=CONTROLLER_KEY,
transport="udp",
timeout=2.0,
) as client,
):
await client.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=1234,
)
statuses = await client.get_object_status(ObjectType.AREA, 1)
assert len(statuses) == 1
area = statuses[0]
assert isinstance(area, AreaStatus)
assert area.mode == int(SecurityMode.AWAY)
async def test_udp_arm_with_wrong_code_raises() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
with pytest.raises(CommandFailedError):
async with OmniClient(
host=host,
port=port,
controller_key=CONTROLLER_KEY,
transport="udp",
timeout=2.0,
) as client:
await client.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=9999,
)
async def test_udp_unit_on_pushes_state_changed_event() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with (
panel.serve(transport="udp") as (host, port),
OmniClient(
host=host,
port=port,
controller_key=CONTROLLER_KEY,
transport="udp",
timeout=2.0,
) as client,
):
events = client.events()
await client.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_udp_wrong_key_fails_handshake() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY)
wrong_key = secrets.token_bytes(16)
async with panel.serve(transport="udp") as (host, port):
with pytest.raises(HandshakeError):
async with OmniConnection(
host=host,
port=port,
controller_key=wrong_key,
transport="udp",
timeout=2.0,
):
pass

View File

@ -1,252 +0,0 @@
"""End-to-end: OmniClientV1 ↔ MockPanel speaking the v1 wire dialect.
Exercises the MockPanel's new ``_dispatch_v1`` path over UDP (which
is what ``OmniClientV1`` opens see :class:`omni_pca.v1.connection.
OmniConnectionV1`). The packets travel ``127.0.0.1`` so there is no
real packet-loss risk; we still set a 2 s per-reply timeout to fail
fast if the dispatcher hangs.
"""
from __future__ import annotations
import pytest
from omni_pca.commands import CommandFailedError
from omni_pca.mock_panel import (
MockAreaState,
MockButtonState,
MockPanel,
MockState,
MockThermostatState,
MockUnitState,
MockZoneState,
)
from omni_pca.models import SecurityMode
from omni_pca.v1 import NameType, OmniClientV1
CONTROLLER_KEY = bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09")
def _populated_state() -> MockState:
return MockState(
zones={
1: MockZoneState(name="FRONT DOOR"),
2: MockZoneState(name="BACK DOOR"),
3: MockZoneState(name="LIVING MOT", current_state=1, loop=0xFD),
},
units={
1: MockUnitState(name="FRONT PORCH", state=1), # on
2: MockUnitState(name="LIVING LAMP", state=0x96), # 50% brightness
},
areas={1: MockAreaState(name="MAIN", mode=int(SecurityMode.OFF))},
thermostats={
1: MockThermostatState(
name="DOWNSTAIRS",
temperature_raw=170, heat_setpoint_raw=140,
cool_setpoint_raw=200, system_mode=1, fan_mode=0, hold_mode=0,
),
},
buttons={1: MockButtonState(name="GOOD MORNING")},
user_codes={1: 1234},
)
# ---- handshake + read API ------------------------------------------------
@pytest.mark.asyncio
async def test_v1_handshake_and_system_information() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
info = await c.get_system_information()
assert info.model_name == "Omni Pro II"
assert info.firmware_version == "2.12r1"
@pytest.mark.asyncio
async def test_v1_get_system_status_reports_areas() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
status = await c.get_system_status()
# Mock emits 8 area mode bytes (Omni Pro II cap).
assert len(status.area_alarms) == 8
# Each tuple is (mode, 0); area 1 was OFF (0).
assert status.area_alarms[0] == (0, 0)
@pytest.mark.asyncio
async def test_v1_zone_status_short_form() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
zones = await c.get_zone_status(1, 8)
assert len(zones) == 8
assert zones[1].is_secure
# Zone 3 has current_state=1 (NotReady -> open).
assert zones[3].is_open
assert zones[3].loop == 0xFD
@pytest.mark.asyncio
async def test_v1_unit_status_short_form() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
units = await c.get_unit_status(1, 4)
assert units[1].is_on
assert units[2].brightness == 50 # state=0x96 == 150 -> 50%
assert not units[3].is_on # undefined slot, defaults
assert not units[4].is_on
@pytest.mark.asyncio
async def test_v1_unit_status_long_form() -> None:
"""Force the BE-u16 wire form by including indices > 255."""
state = _populated_state()
state.units[300] = MockUnitState(name="SPRINKLER-Z3", state=1)
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
units = await c.get_unit_status(298, 302)
assert len(units) == 5
assert units[300].is_on
@pytest.mark.asyncio
async def test_v1_thermostat_status() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
tstats = await c.get_thermostat_status(1, 1)
t = tstats[1]
assert t.temperature_raw == 170
assert t.heat_setpoint_raw == 140
assert t.cool_setpoint_raw == 200
assert t.system_mode == 1
assert t.fan_mode == 0
assert t.hold_mode == 0
# ---- UploadNames streaming ----------------------------------------------
@pytest.mark.asyncio
async def test_v1_upload_names_streams_all_objects() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
all_names = await c.list_all_names()
# Expected: Zones 1-3, Units 1-2, Button 1, Area 1, Thermostat 1.
assert set(all_names.keys()) == {
int(NameType.ZONE),
int(NameType.UNIT),
int(NameType.BUTTON),
int(NameType.AREA),
int(NameType.THERMOSTAT),
}
assert all_names[int(NameType.ZONE)] == {
1: "FRONT DOOR", 2: "BACK DOOR", 3: "LIVING MOT",
}
assert all_names[int(NameType.UNIT)] == {
1: "FRONT PORCH", 2: "LIVING LAMP",
}
assert all_names[int(NameType.BUTTON)] == {1: "GOOD MORNING"}
assert all_names[int(NameType.AREA)] == {1: "MAIN"}
assert all_names[int(NameType.THERMOSTAT)] == {1: "DOWNSTAIRS"}
@pytest.mark.asyncio
async def test_v1_upload_names_empty_panel_returns_no_records() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY)
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
all_names = await c.list_all_names()
assert all_names == {}
@pytest.mark.asyncio
async def test_v1_upload_names_two_byte_form_for_high_indices() -> None:
state = _populated_state()
state.units[300] = MockUnitState(name="Z-LANDSCAPE") # > 255
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
all_names = await c.list_all_names()
assert all_names[int(NameType.UNIT)][300] == "Z-LANDSCAPE"
# ---- write methods ------------------------------------------------------
@pytest.mark.asyncio
async def test_v1_turn_unit_on_mutates_mock_state() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
assert panel.state.units[2].state == 0x96 # 50%
await c.set_unit_level(2, 75)
assert panel.state.units[2].state == 100 + 75 # 175 = 75%
@pytest.mark.asyncio
async def test_v1_bypass_and_restore_zone() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
await c.bypass_zone(1, code=1)
assert panel.state.zones[1].is_bypassed
await c.restore_zone(1, code=1)
assert not panel.state.zones[1].is_bypassed
@pytest.mark.asyncio
async def test_v1_execute_security_command_arm_away() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
await c.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=1234
)
assert panel.state.areas[1].mode == int(SecurityMode.AWAY)
@pytest.mark.asyncio
async def test_v1_execute_security_command_wrong_code() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as c:
with pytest.raises(CommandFailedError):
await c.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=9999
)
# State unchanged after failed command.
assert panel.state.areas[1].mode == int(SecurityMode.OFF)

Some files were not shown because too many files have changed in this diff Show More