Compare commits
No commits in common. "main" and "v2026.5.10" have entirely different histories.
main
...
v2026.5.10
26
.github/workflows/validate.yml
vendored
@ -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
@ -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/
|
||||
|
||||
42
CHANGELOG.md
@ -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
|
||||
|
||||
84
README.md
@ -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.
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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``.
|
||||
|
||||
@ -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.
|
||||
@ -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);
|
||||
}
|
||||
551
custom_components/omni_pca/frontend/package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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>`;
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@ -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
|
||||
|
||||
151
dev/probe_v1.py
@ -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())
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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())
|
||||
@ -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))
|
||||
@ -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=
|
||||
@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@ -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:
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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": ""
|
||||
}
|
||||
@ -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}
|
||||
@ -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"]
|
||||
|
||||
@ -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__"]
|
||||
|
||||
@ -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]]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
@ -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",
|
||||
]
|
||||
@ -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)
|
||||
@ -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 v1↔v2 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."""
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||