Compare commits
65 Commits
v2026.5.10
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ba8c2043e | |||
| 9726ee36bb | |||
| 8a0fb1e4fe | |||
| 486258a034 | |||
| 9ca4da98e8 | |||
| 5870e2f7ee | |||
| 6f92671cf2 | |||
| 4781f4d276 | |||
| 14d16a5a4c | |||
| e6308c5624 | |||
| 73f05188dd | |||
| 9cdb312baf | |||
| f38777e219 | |||
| 821a402d32 | |||
| ce87ebcb13 | |||
| 0026c5b00a | |||
| 172aa2974a | |||
| 56d288db37 | |||
| d4c4e530f6 | |||
| 16655da34c | |||
| 116591be90 | |||
| cc32081caf | |||
| 269d0e897d | |||
| d6205cd330 | |||
| 2cc28b0e50 | |||
| 8250df0206 | |||
| c7eb92122b | |||
| e61e37a3fc | |||
| 362580bccc | |||
| 7b789f8cfb | |||
| b8745e17de | |||
| 7683557bbb | |||
| 994608a4f6 | |||
| 501686795b | |||
| 8141599b4e | |||
| 70bf9caf58 | |||
| 7db9616a34 | |||
| 390f3a9dc0 | |||
| e57fbc41e3 | |||
| b412dc0f37 | |||
| 4ad20c9350 | |||
| 933d326dd3 | |||
| 290ba5a78d | |||
| e560d98f87 | |||
| 23f56e701b | |||
| 4be4101f37 | |||
| 61ae95997c | |||
| ef7d53c468 | |||
| eb1a632ef2 | |||
| 00f0028053 | |||
| d4c04b3044 | |||
| 0e3835d4ff | |||
| dd53b2a89a | |||
| 24eecceff9 | |||
| 0d6465dad0 | |||
| 259c46e558 | |||
| abf96601e8 | |||
| df628aa56f | |||
| 09e2d83b49 | |||
| 30b482a8cb | |||
| 92c8b695b4 | |||
| d91561a6d2 | |||
| 81725b4dbf | |||
| 7f82dbbbfa | |||
| 5f6404a7e0 |
26
.github/workflows/validate.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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,3 +41,9 @@ panel_key*
|
|||||||
.wine-pca/
|
.wine-pca/
|
||||||
ha-config/
|
ha-config/
|
||||||
dist/
|
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,6 +2,44 @@
|
|||||||
|
|
||||||
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.
|
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
|
## [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.
|
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.
|
||||||
@ -82,4 +120,6 @@ 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.
|
- **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.
|
- **HACS submission**: pending live-panel validation.
|
||||||
|
|
||||||
[2026.5.10]: https://git.supported.systems/warehack.ing/omni-pca/releases/tag/v2026.5.10
|
[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
|
||||||
|
|||||||
78
README.md
@ -4,6 +4,9 @@ Async Python client for HAI/Leviton Omni-Link II home automation panels — Omni
|
|||||||
|
|
||||||
Includes a Home Assistant custom component (`custom_components/omni_pca/`).
|
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
|
## 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:
|
**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:
|
||||||
@ -11,14 +14,21 @@ 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.
|
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.
|
2. **Per-block XOR pre-whitening before AES.** First two bytes of every 16-byte block are XORed with the packet's sequence number.
|
||||||
|
|
||||||
See [`docs/PROTOCOL.md`](docs/PROTOCOL.md) for the full byte-level spec.
|
The full byte-level protocol spec lives at <https://hai-omni-pro-ii.warehack.ing/reference/protocol/>.
|
||||||
|
|
||||||
## Quick start (library)
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
pip install omni-pca
|
||||||
|
|
||||||
|
# Or with uv
|
||||||
uv add omni-pca
|
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/).
|
||||||
|
|
||||||
|
## Quick start (library)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import asyncio
|
import asyncio
|
||||||
from omni_pca import OmniClient
|
from omni_pca import OmniClient
|
||||||
@ -29,44 +39,90 @@ async def main():
|
|||||||
port=4369,
|
port=4369,
|
||||||
controller_key=bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09"),
|
controller_key=bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09"),
|
||||||
) as panel:
|
) as panel:
|
||||||
info = await panel.get_system_info()
|
info = await panel.get_system_information()
|
||||||
print(info.model_name, info.firmware_version)
|
print(info.model_name, info.firmware_version)
|
||||||
|
|
||||||
asyncio.run(main())
|
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)
|
## Quick start (Home Assistant)
|
||||||
|
|
||||||
Copy `custom_components/omni_pca/` into your HA `config/custom_components/`, restart HA, then add the integration via Settings → Devices & Services. You'll need:
|
```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:
|
||||||
|
|
||||||
- Panel IP / hostname
|
- Panel IP / hostname
|
||||||
- TCP port (default 4369)
|
- TCP port (default 4369)
|
||||||
- ControllerKey as 32 hex chars
|
- ControllerKey as 32 hex chars
|
||||||
|
|
||||||
Get the ControllerKey from your `.pca` file using the included parser:
|
Get the ControllerKey from your `.pca` file using the bundled CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from omni-pca omni-pca decode-pca path/to/Your.pca --field controller_key
|
omni-pca decode-pca '/path/to/Your.pca' --field controller_key
|
||||||
```
|
```
|
||||||
|
|
||||||
The integration creates one HA device per panel plus typed entities for every named object on the controller: `alarm_control_panel` for areas, `light` for units, `binary_sensor`/`switch` for zones (state + bypass), `climate` for thermostats, `sensor` for analog zones and panel telemetry, `button` for panel macros, and `event` for the typed push-notification stream. See [`custom_components/omni_pca/README.md`](custom_components/omni_pca/README.md) for the entity table and service list.
|
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.
|
||||||
|
|
||||||
## Without a panel — mock controller
|
## Without a panel — mock controller
|
||||||
|
|
||||||
For testing, the library ships a minimal Omni controller emulator:
|
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:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from omni_pca.mock_panel import MockPanel
|
from omni_pca.mock_panel import MockPanel
|
||||||
|
|
||||||
async with MockPanel(controller_key=...).serve(port=14369):
|
async with MockPanel(controller_key=...).serve(port=14369):
|
||||||
# connect a real OmniClient to localhost:14369 — works end-to-end
|
# Connect a real OmniClient to localhost:14369 — full handshake + AES
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Versioning
|
||||||
|
|
||||||
Date-based ([CalVer](https://calver.org/)): `YYYY.M.D`. Bumped on backwards-incompatible changes.
|
Date-based ([CalVer](https://calver.org/)): `YYYY.M.D`. Bumped on backwards-incompatible changes. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [`LICENSE`](LICENSE).
|
||||||
|
|
||||||
## Acknowledgments
|
## 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.
|
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/>.
|
||||||
|
|||||||
@ -6,16 +6,18 @@ opens an encrypted session straight to the panel and listens for unsolicited
|
|||||||
push messages.
|
push messages.
|
||||||
|
|
||||||
This integration is the HA-facing wrapper around the
|
This integration is the HA-facing wrapper around the
|
||||||
[`omni-pca`](https://git.supported.systems/warehack.ing/omni-pca) Python library; the library
|
[`omni-pca`](https://github.com/rsp2k/omni-pca) Python library; the library
|
||||||
handles the wire protocol, this component surfaces it as HA entities.
|
handles the wire protocol, this component surfaces it as HA entities.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
### HACS (recommended once published)
|
### HACS
|
||||||
|
|
||||||
1. HACS → Integrations → custom repository → add
|
1. HACS → Integrations → search **HAI / Leviton Omni Panel**.
|
||||||
`https://git.supported.systems/warehack.ing/omni-pca`, category **Integration**.
|
2. Install, then restart Home Assistant.
|
||||||
2. Install **HAI / Leviton Omni Panel**, 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**.)
|
||||||
|
|
||||||
### Manual
|
### Manual
|
||||||
|
|
||||||
@ -121,6 +123,6 @@ hashed) — useful for bug reports.
|
|||||||
- **No entities for X**: only objects with a name configured on the panel
|
- **No entities for X**: only objects with a name configured on the panel
|
||||||
are discovered. PC Access's "Names" page is where they live.
|
are discovered. PC Access's "Names" page is where they live.
|
||||||
|
|
||||||
See the [parent README](https://git.supported.systems/warehack.ing/omni-pca) for protocol /
|
See the [parent README](https://github.com/rsp2k/omni-pca) for protocol /
|
||||||
library details. Detailed reverse-engineering notes are in
|
library details. Detailed reverse-engineering notes are in
|
||||||
[`docs/JOURNEY.md`](https://git.supported.systems/warehack.ing/omni-pca/blob/main/docs/JOURNEY.md).
|
[`docs/JOURNEY.md`](https://github.com/rsp2k/omni-pca/blob/main/docs/JOURNEY.md).
|
||||||
|
|||||||
@ -13,10 +13,25 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
from .const import CONF_CONTROLLER_KEY, DOMAIN, LOGGER
|
from .const import (
|
||||||
|
CONF_CONTROLLER_KEY,
|
||||||
|
CONF_PCA_KEY,
|
||||||
|
CONF_PCA_PATH,
|
||||||
|
CONF_TRANSPORT,
|
||||||
|
DEFAULT_TRANSPORT,
|
||||||
|
DOMAIN,
|
||||||
|
LOGGER,
|
||||||
|
)
|
||||||
from .coordinator import OmniDataUpdateCoordinator
|
from .coordinator import OmniDataUpdateCoordinator
|
||||||
from .services import async_setup_services, async_unload_services
|
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:
|
if TYPE_CHECKING:
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@ -33,6 +48,8 @@ PLATFORMS: list[Platform] = [
|
|||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
"""No YAML support; everything is config-flow driven."""
|
"""No YAML support; everything is config-flow driven."""
|
||||||
@ -50,12 +67,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
LOGGER.error("stored controller key for %s is corrupt: %s", entry.title, err)
|
LOGGER.error("stored controller key for %s is corrupt: %s", entry.title, err)
|
||||||
return False
|
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(
|
coordinator = OmniDataUpdateCoordinator(
|
||||||
hass,
|
hass,
|
||||||
entry,
|
entry,
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
controller_key=controller_key,
|
controller_key=controller_key,
|
||||||
|
transport=transport,
|
||||||
|
pca_path=pca_path or None,
|
||||||
|
pca_key=pca_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -69,6 +92,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
await async_setup_services(hass)
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
BIN
custom_components/omni_pca/brand/icon.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
custom_components/omni_pca/brand/icon@2x.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
@ -20,10 +20,16 @@ from omni_pca.connection import (
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_CONTROLLER_KEY,
|
CONF_CONTROLLER_KEY,
|
||||||
|
CONF_PCA_KEY,
|
||||||
|
CONF_PCA_PATH,
|
||||||
|
CONF_TRANSPORT,
|
||||||
CONTROLLER_KEY_HEX_LEN,
|
CONTROLLER_KEY_HEX_LEN,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
|
DEFAULT_TRANSPORT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
|
TRANSPORT_TCP,
|
||||||
|
TRANSPORT_UDP,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -60,6 +66,21 @@ _USER_SCHEMA = vol.Schema(
|
|||||||
vol.Coerce(int), vol.Range(min=1, max=65535)
|
vol.Coerce(int), vol.Range(min=1, max=65535)
|
||||||
),
|
),
|
||||||
vol.Required(CONF_CONTROLLER_KEY): str,
|
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)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -79,6 +100,9 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
host: str = user_input[CONF_HOST].strip()
|
host: str = user_input[CONF_HOST].strip()
|
||||||
port: int = user_input[CONF_PORT]
|
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}"
|
unique_id = f"{host}:{port}"
|
||||||
|
|
||||||
await self.async_set_unique_id(unique_id)
|
await self.async_set_unique_id(unique_id)
|
||||||
@ -90,18 +114,24 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
LOGGER.debug("controller key rejected: %s", err)
|
LOGGER.debug("controller key rejected: %s", err)
|
||||||
errors[CONF_CONTROLLER_KEY] = "invalid_key"
|
errors[CONF_CONTROLLER_KEY] = "invalid_key"
|
||||||
else:
|
else:
|
||||||
title, error = await self._probe(host, port, key)
|
if pca_path and (pca_err := await self._validate_pca(pca_path, pca_key)):
|
||||||
if error is not None:
|
errors[CONF_PCA_PATH] = pca_err
|
||||||
errors["base"] = error
|
if not errors:
|
||||||
else:
|
title, error = await self._probe(host, port, key, transport)
|
||||||
return self.async_create_entry(
|
if error is not None:
|
||||||
title=title or f"Omni Panel ({host})",
|
errors["base"] = error
|
||||||
data={
|
else:
|
||||||
CONF_HOST: host,
|
return self.async_create_entry(
|
||||||
CONF_PORT: port,
|
title=title or f"Omni Panel ({host})",
|
||||||
CONF_CONTROLLER_KEY: key.hex(),
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
@ -109,6 +139,33 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors,
|
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(
|
async def async_step_reauth(
|
||||||
self, entry_data: Mapping[str, Any]
|
self, entry_data: Mapping[str, Any]
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@ -121,6 +178,9 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
assert self._reauth_entry_data is not None
|
assert self._reauth_entry_data is not None
|
||||||
host: str = self._reauth_entry_data[CONF_HOST]
|
host: str = self._reauth_entry_data[CONF_HOST]
|
||||||
port: int = self._reauth_entry_data[CONF_PORT]
|
port: int = self._reauth_entry_data[CONF_PORT]
|
||||||
|
transport: str = self._reauth_entry_data.get(
|
||||||
|
CONF_TRANSPORT, DEFAULT_TRANSPORT
|
||||||
|
)
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
@ -129,7 +189,7 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
except InvalidControllerKey:
|
except InvalidControllerKey:
|
||||||
errors[CONF_CONTROLLER_KEY] = "invalid_key"
|
errors[CONF_CONTROLLER_KEY] = "invalid_key"
|
||||||
else:
|
else:
|
||||||
_, error = await self._probe(host, port, key)
|
_, error = await self._probe(host, port, key, transport)
|
||||||
if error is not None:
|
if error is not None:
|
||||||
errors["base"] = error
|
errors["base"] = error
|
||||||
else:
|
else:
|
||||||
@ -147,12 +207,46 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
# ---- helpers ---------------------------------------------------------
|
# ---- helpers ---------------------------------------------------------
|
||||||
|
|
||||||
async def _probe(
|
async def _probe(
|
||||||
self, host: str, port: int, key: bytes
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
key: bytes,
|
||||||
|
transport: str = DEFAULT_TRANSPORT,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""Try to connect once. Returns (title, error_code)."""
|
"""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:
|
try:
|
||||||
async with OmniClient(host, port=port, controller_key=key) as client:
|
if transport == TRANSPORT_UDP:
|
||||||
info = await client.get_system_information()
|
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()
|
||||||
except (HandshakeError, InvalidEncryptionKeyError):
|
except (HandshakeError, InvalidEncryptionKeyError):
|
||||||
return None, "invalid_auth"
|
return None, "invalid_auth"
|
||||||
except (OmniConnectionError, OSError, TimeoutError) as err:
|
except (OmniConnectionError, OSError, TimeoutError) as err:
|
||||||
|
|||||||
@ -12,6 +12,18 @@ DEFAULT_PORT: Final = 4369
|
|||||||
DEFAULT_TIMEOUT: Final = 5.0
|
DEFAULT_TIMEOUT: Final = 5.0
|
||||||
|
|
||||||
CONF_CONTROLLER_KEY: Final = "controller_key"
|
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"
|
MANUFACTURER: Final = "HAI / Leviton"
|
||||||
|
|
||||||
|
|||||||
@ -62,7 +62,6 @@ from omni_pca.models import (
|
|||||||
AreaStatus,
|
AreaStatus,
|
||||||
ButtonProperties,
|
ButtonProperties,
|
||||||
ObjectType,
|
ObjectType,
|
||||||
ProgramProperties,
|
|
||||||
SystemInformation,
|
SystemInformation,
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
ThermostatProperties,
|
ThermostatProperties,
|
||||||
@ -73,6 +72,7 @@ from omni_pca.models import (
|
|||||||
ZoneStatus,
|
ZoneStatus,
|
||||||
)
|
)
|
||||||
from omni_pca.opcodes import OmniLink2MessageType
|
from omni_pca.opcodes import OmniLink2MessageType
|
||||||
|
from omni_pca.programs import Program
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -108,7 +108,7 @@ class OmniData:
|
|||||||
areas: dict[int, AreaProperties] = field(default_factory=dict)
|
areas: dict[int, AreaProperties] = field(default_factory=dict)
|
||||||
thermostats: dict[int, ThermostatProperties] = field(default_factory=dict)
|
thermostats: dict[int, ThermostatProperties] = field(default_factory=dict)
|
||||||
buttons: dict[int, ButtonProperties] = field(default_factory=dict)
|
buttons: dict[int, ButtonProperties] = field(default_factory=dict)
|
||||||
programs: dict[int, ProgramProperties] = field(default_factory=dict)
|
programs: dict[int, Program] = field(default_factory=dict)
|
||||||
|
|
||||||
zone_status: dict[int, ZoneStatus] = field(default_factory=dict)
|
zone_status: dict[int, ZoneStatus] = field(default_factory=dict)
|
||||||
unit_status: dict[int, UnitStatus] = field(default_factory=dict)
|
unit_status: dict[int, UnitStatus] = field(default_factory=dict)
|
||||||
@ -137,6 +137,9 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
controller_key: bytes,
|
controller_key: bytes,
|
||||||
|
transport: str = "tcp",
|
||||||
|
pca_path: str | None = None,
|
||||||
|
pca_key: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
@ -148,6 +151,9 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
self._host = host
|
self._host = host
|
||||||
self._port = port
|
self._port = port
|
||||||
self._controller_key = controller_key
|
self._controller_key = controller_key
|
||||||
|
self._transport = transport
|
||||||
|
self._pca_path = pca_path
|
||||||
|
self._pca_key = pca_key
|
||||||
self._client: OmniClient | None = None
|
self._client: OmniClient | None = None
|
||||||
self._discovery_done = False
|
self._discovery_done = False
|
||||||
self._discovered: OmniData | None = None
|
self._discovered: OmniData | None = None
|
||||||
@ -232,11 +238,25 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
async def _ensure_connected(self) -> OmniClient:
|
async def _ensure_connected(self) -> OmniClient:
|
||||||
if self._client is not None:
|
if self._client is not None:
|
||||||
return self._client
|
return self._client
|
||||||
client = OmniClient(
|
if self._transport == "udp":
|
||||||
self._host,
|
# Panels listening UDP-only speak the v1 wire protocol, not
|
||||||
port=self._port,
|
# v2. The adapter exposes the OmniClient API surface this
|
||||||
controller_key=self._controller_key,
|
# 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]
|
||||||
|
)
|
||||||
# Drive __aenter__ manually so the client survives across update
|
# Drive __aenter__ manually so the client survives across update
|
||||||
# cycles; we close it explicitly on shutdown / failure.
|
# cycles; we close it explicitly on shutdown / failure.
|
||||||
await client.__aenter__()
|
await client.__aenter__()
|
||||||
@ -366,15 +386,70 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
|
|
||||||
async def _discover_programs(
|
async def _discover_programs(
|
||||||
self, client: OmniClient
|
self, client: OmniClient
|
||||||
) -> dict[int, ProgramProperties]:
|
) -> dict[int, Program]:
|
||||||
# Programs aren't reachable via the Properties opcode (the C# side
|
"""Enumerate defined panel programs.
|
||||||
# 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
|
Two sources, in order of preference:
|
||||||
# discovery the moment the library exposes it. AMBIGUITY: the spec
|
|
||||||
# asks for "named programs" — there's no on-the-wire path for that
|
1. ``CONF_PCA_PATH`` is configured → parse the .pca file and
|
||||||
# in v1.0 of omni_pca, so an empty mapping is the honest answer.
|
extract the programs block. Avoids streaming 1500 records on
|
||||||
_ = client, ProgramProperties
|
every entry refresh and works against an offline snapshot.
|
||||||
return {}
|
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
|
||||||
|
|
||||||
async def _walk_properties(
|
async def _walk_properties(
|
||||||
self,
|
self,
|
||||||
@ -389,9 +464,15 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
client's internal parser table only covers zones/units/areas in
|
client's internal parser table only covers zones/units/areas in
|
||||||
v1.0). We drive ``RequestProperties`` directly on the connection
|
v1.0). We drive ``RequestProperties`` directly on the connection
|
||||||
so we don't have to monkey-patch the library.
|
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:
|
if parser is None or OBJECT_TYPE_TO_PROPERTIES.get(int(object_type)) is None:
|
||||||
return {}
|
return {}
|
||||||
|
if self._transport == "udp":
|
||||||
|
return await self._walk_properties_v1(client, object_type)
|
||||||
out: dict[int, object] = {}
|
out: dict[int, object] = {}
|
||||||
cursor = 0
|
cursor = 0
|
||||||
conn = client.connection
|
conn = client.connection
|
||||||
@ -440,6 +521,42 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
break
|
break
|
||||||
return out
|
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
|
@staticmethod
|
||||||
async def _best_effort(coro_fn, *, default):
|
async def _best_effort(coro_fn, *, default):
|
||||||
"""Call ``coro_fn()`` and swallow non-transport errors, returning ``default``.
|
"""Call ``coro_fn()`` and swallow non-transport errors, returning ``default``.
|
||||||
|
|||||||
41
custom_components/omni_pca/frontend/README.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# 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.
|
||||||
44
custom_components/omni_pca/frontend/build.mjs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
// 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
Normal file
@ -0,0 +1,551 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
custom_components/omni_pca/frontend/package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
2700
custom_components/omni_pca/frontend/src/omni-panel-programs.ts
Normal file
52
custom_components/omni_pca/frontend/src/token-renderer.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// 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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
737
custom_components/omni_pca/frontend/src/types.ts
Normal file
@ -0,0 +1,737 @@
|
|||||||
|
// 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.
|
||||||
|
}
|
||||||
20
custom_components/omni_pca/frontend/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"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",
|
"domain": "omni_pca",
|
||||||
"name": "HAI/Leviton Omni Panel",
|
"name": "HAI/Leviton Omni Panel",
|
||||||
"version": "2026.5.10",
|
|
||||||
"iot_class": "local_push",
|
|
||||||
"config_flow": true,
|
|
||||||
"dependencies": [],
|
|
||||||
"codeowners": ["@rsp2k"],
|
"codeowners": ["@rsp2k"],
|
||||||
"requirements": ["omni-pca==2026.5.10"],
|
"config_flow": true,
|
||||||
"documentation": "https://git.supported.systems/warehack.ing/omni-pca",
|
"dependencies": ["http", "websocket_api"],
|
||||||
"issue_tracker": "https://git.supported.systems/warehack.ing/omni-pca/issues",
|
"documentation": "https://hai-omni-pro-ii.warehack.ing/",
|
||||||
"integration_type": "hub"
|
"integration_type": "hub",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"issue_tracker": "https://github.com/rsp2k/omni-pca/issues",
|
||||||
|
"requirements": ["omni-pca==2026.5.16"],
|
||||||
|
"version": "2026.5.16"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,6 +69,7 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
entities.append(OmniSystemModelSensor(coordinator))
|
entities.append(OmniSystemModelSensor(coordinator))
|
||||||
entities.append(OmniLastEventSensor(coordinator))
|
entities.append(OmniLastEventSensor(coordinator))
|
||||||
|
entities.append(OmniProgramsSensor(coordinator))
|
||||||
|
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
@ -261,3 +262,55 @@ class OmniLastEventSensor(
|
|||||||
if hasattr(ev, key):
|
if hasattr(ev, key):
|
||||||
result[key] = getattr(ev, key)
|
result[key] = getattr(ev, key)
|
||||||
return result
|
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,11 +3,14 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Connect to your Omni panel",
|
"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. Use `omni-pca decode-pca <file>.pca --field controller_key` to extract the key from a PC Access export.",
|
"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.",
|
||||||
"data": {
|
"data": {
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"controller_key": "Controller Key (32 hex chars)"
|
"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)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
@ -22,6 +25,8 @@
|
|||||||
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
|
"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_auth": "The Controller Key was rejected by the panel.",
|
||||||
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
|
"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."
|
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
|
|||||||
@ -3,11 +3,14 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Connect to your Omni panel",
|
"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. Use `omni-pca decode-pca <file>.pca --field controller_key` to extract the key from a PC Access export.",
|
"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.",
|
||||||
"data": {
|
"data": {
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"controller_key": "Controller Key (32 hex chars)"
|
"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)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
@ -22,6 +25,8 @@
|
|||||||
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
|
"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_auth": "The Controller Key was rejected by the panel.",
|
||||||
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
|
"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."
|
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
|
|||||||
966
custom_components/omni_pca/websocket.py
Normal file
@ -0,0 +1,966 @@
|
|||||||
|
"""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
|
||||||
1418
custom_components/omni_pca/www/panel.js
Normal file
@ -44,6 +44,75 @@ make dev-down # stop the stack
|
|||||||
make dev-reset # wipe HA config and start fresh
|
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
|
## Notes
|
||||||
|
|
||||||
- The HA container mounts `../custom_components/omni_pca/` read-only, so
|
- The HA container mounts `../custom_components/omni_pca/` read-only, so
|
||||||
|
|||||||
150
dev/add_real_panel.py
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
#!/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: 107 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 190 KiB |
BIN
dev/artifacts/screenshots/2026-05-11/01-overview.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
dev/artifacts/screenshots/2026-05-11/02-integrations-list.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
dev/artifacts/screenshots/2026-05-11/03-omni-pca-config.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
dev/artifacts/screenshots/2026-05-11/04-panel-device.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
dev/artifacts/screenshots/2026-05-11/05-entities-omni.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
dev/artifacts/screenshots/2026-05-11/06-developer-states.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/01-overview.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/02-integrations-list.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/03-omni-pca-config.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/04-panel-device.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/05-entities-omni.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/06-developer-states.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/07-side-panel-empty.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/08-side-panel-programs.png
Normal file
|
After Width: | Height: | Size: 295 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/09-side-panel-detail.png
Normal file
|
After Width: | Height: | Size: 325 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/10-side-panel-editor.png
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/11-chain-editor.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/12-structured-and.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
dev/artifacts/screenshots/2026-05-17/arg2-object-editor.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
dev/artifacts/screenshots/2026-05-17/grafana-iter-final.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 281 KiB |
BIN
dev/artifacts/screenshots/2026-05-17/real-pca-overview.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
dev/brand/icon.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
dev/brand/icon@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@ -6,15 +6,27 @@
|
|||||||
# make dev-logs # tail HA logs
|
# make dev-logs # tail HA logs
|
||||||
# make dev-down # stop and clean
|
# 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:
|
# Once running, open http://localhost:8123 and:
|
||||||
# 1. Onboard with any name / location.
|
# 1. Onboard with any name / location.
|
||||||
# 2. Settings -> Devices & Services -> Add Integration ->
|
# 2. Settings -> Devices & Services -> Add Integration ->
|
||||||
# "HAI/Leviton Omni Panel".
|
# "HAI/Leviton Omni Panel".
|
||||||
# 3. Use:
|
# 3. Use one of:
|
||||||
# host host.docker.internal
|
# Mock panel (TCP):
|
||||||
# port 14369
|
# host host.docker.internal
|
||||||
# controller_key 000102030405060708090a0b0c0d0e0f
|
# port 14369
|
||||||
# (matches scripts/run_mock_panel.py defaults)
|
# 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>
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mock-panel:
|
mock-panel:
|
||||||
@ -23,14 +35,22 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ../src:/tmp/mock/src:ro
|
- ../src:/tmp/mock/src:ro
|
||||||
- ./run_mock_panel.py:/tmp/mock/run_mock_panel.py: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:
|
environment:
|
||||||
PYTHONPATH: /tmp/mock/src
|
PYTHONPATH: /tmp/mock/src
|
||||||
|
OMNI_PCA_FIXTURE: ${OMNI_PCA_FIXTURE:-}
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- "uv pip install --system --quiet cryptography && python /tmp/mock/run_mock_panel.py --host 0.0.0.0 --port 14369"
|
- "uv pip install --system --quiet cryptography && python /tmp/mock/run_mock_panel.py --host 0.0.0.0 --port 14369"
|
||||||
ports:
|
ports:
|
||||||
- "14369:14369"
|
- "14369:14369"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
homeassistant:
|
homeassistant:
|
||||||
image: ghcr.io/home-assistant/home-assistant:2026.5
|
image: ghcr.io/home-assistant/home-assistant:2026.5
|
||||||
@ -40,9 +60,116 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./ha-config:/config
|
- ./ha-config:/config
|
||||||
- ../custom_components/omni_pca:/config/custom_components/omni_pca:ro
|
- ../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:
|
ports:
|
||||||
- "8123:8123"
|
- "8123:8123"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
- TZ=America/Boise
|
- 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
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
#!/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())
|
||||||
122
dev/probe_v1_client.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
#!/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())
|
||||||
129
dev/probe_v1_coordinator.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
#!/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())
|
||||||
149
dev/probe_v1_recon.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
#!/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())
|
||||||
118
dev/probe_v1_stream.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
#!/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())
|
||||||
93
dev/probe_v1_write.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
#!/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,8 +10,10 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from omni_pca.mock_panel import (
|
from omni_pca.mock_panel import (
|
||||||
MockAreaState,
|
MockAreaState,
|
||||||
@ -22,10 +24,55 @@ from omni_pca.mock_panel import (
|
|||||||
MockUnitState,
|
MockUnitState,
|
||||||
MockZoneState,
|
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"
|
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:
|
def _populated_state() -> MockState:
|
||||||
"""A small but representative set of objects so HA shows real entities."""
|
"""A small but representative set of objects so HA shows real entities."""
|
||||||
return MockState(
|
return MockState(
|
||||||
@ -56,11 +103,50 @@ def _populated_state() -> MockState:
|
|||||||
3: MockButtonState(name="GOODNIGHT"),
|
3: MockButtonState(name="GOODNIGHT"),
|
||||||
},
|
},
|
||||||
user_codes={1: 1234, 2: 5678},
|
user_codes={1: 1234, 2: 5678},
|
||||||
|
programs=_seed_programs(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _serve(host: str, port: int, key: bytes) -> None:
|
def _key_for_pca(path: Path, override: int | None) -> int:
|
||||||
panel = MockPanel(controller_key=key, state=_populated_state())
|
"""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 with panel.serve(host=host, port=port) as (bound_host, bound_port):
|
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("MockPanel listening on %s:%d", bound_host, bound_port)
|
||||||
logging.info("Use this controller key in HA: %s", key.hex())
|
logging.info("Use this controller key in HA: %s", key.hex())
|
||||||
@ -86,6 +172,23 @@ def main() -> int:
|
|||||||
default=DEFAULT_KEY_HEX,
|
default=DEFAULT_KEY_HEX,
|
||||||
help="32 hex chars; default is the docker-compose value",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -104,7 +207,14 @@ def main() -> int:
|
|||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
asyncio.run(_serve(args.host, args.port, key))
|
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))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,17 @@ async def _complete_onboarding(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""POST every remaining onboarding step in turn so HA stops greeting us."""
|
"""POST every remaining onboarding step in turn so HA stops greeting us."""
|
||||||
r = await client.get("/api/onboarding")
|
r = await client.get("/api/onboarding")
|
||||||
pending = [s["step"] for s in r.json() if not s.get("done")]
|
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")]
|
||||||
print(f" pending onboarding: {pending}")
|
print(f" pending onboarding: {pending}")
|
||||||
|
|
||||||
if "core_config" in pending:
|
if "core_config" in pending:
|
||||||
@ -73,7 +83,15 @@ async def _onboard(ha_url: str) -> str:
|
|||||||
"""
|
"""
|
||||||
async with httpx.AsyncClient(base_url=ha_url, timeout=30.0) as client:
|
async with httpx.AsyncClient(base_url=ha_url, timeout=30.0) as client:
|
||||||
r = await client.get("/api/onboarding")
|
r = await client.get("/api/onboarding")
|
||||||
steps = r.json()
|
# 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 = []
|
||||||
user_step = next((s for s in steps if s["step"] == "user"), None)
|
user_step = next((s for s in steps if s["step"] == "user"), None)
|
||||||
|
|
||||||
if user_step and not user_step.get("done"):
|
if user_step and not user_step.get("done"):
|
||||||
@ -199,7 +217,8 @@ async def _take_screenshots(ha_url: str, token: str, outdir: Path) -> list[Path]
|
|||||||
viewport={"width": 1440, "height": 900},
|
viewport={"width": 1440, "height": 900},
|
||||||
device_scale_factor=2,
|
device_scale_factor=2,
|
||||||
)
|
)
|
||||||
# Inject auth so we skip the login screen.
|
# Inject auth so we skip the login screen + force HA's dark theme
|
||||||
|
# so screenshots match the docs site's default theme.
|
||||||
await context.add_init_script(
|
await context.add_init_script(
|
||||||
f"""window.localStorage.setItem('hassTokens', JSON.stringify({{
|
f"""window.localStorage.setItem('hassTokens', JSON.stringify({{
|
||||||
access_token: '{token}',
|
access_token: '{token}',
|
||||||
@ -211,6 +230,15 @@ async def _take_screenshots(ha_url: str, token: str, outdir: Path) -> list[Path]
|
|||||||
refresh_token: 'placeholder',
|
refresh_token: 'placeholder',
|
||||||
}}));
|
}}));
|
||||||
window.localStorage.setItem('selectedLanguage', '"en"');
|
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()
|
page = await context.new_page()
|
||||||
@ -270,6 +298,72 @@ async def _take_screenshots(ha_url: str, token: str, outdir: Path) -> list[Path]
|
|||||||
await shot("06-developer-states.png",
|
await shot("06-developer-states.png",
|
||||||
"/developer-tools/state", wait_secs=4.0)
|
"/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()
|
await browser.close()
|
||||||
return shots
|
return shots
|
||||||
|
|
||||||
|
|||||||
150
dev/screenshot_arg2_object.py
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
#!/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())
|
||||||
63
dev/screenshot_overview.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
#!/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))
|
||||||
19
grafana/.env.example
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# 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=
|
||||||
129
grafana/README.md
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
# 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.
|
||||||
69
grafana/docker-compose.yml
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# 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:
|
||||||
51
grafana/ha-snippet.yaml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# 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
|
||||||
19
grafana/provisioning/dashboards/dashboards.yml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# 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
|
||||||
682
grafana/provisioning/dashboards/omni-pro-ii.json
Normal file
@ -0,0 +1,682 @@
|
|||||||
|
{
|
||||||
|
"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": ""
|
||||||
|
}
|
||||||
21
grafana/provisioning/datasources/influxdb.yml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# 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]
|
[project]
|
||||||
name = "omni-pca"
|
name = "omni-pca"
|
||||||
version = "2026.5.10"
|
version = "2026.5.16"
|
||||||
description = "Async Python client for HAI/Leviton Omni-Link II home automation panels (Omni Pro II, Omni IIe, Omni LTe, Lumina)."
|
description = "Async Python client for HAI/Leviton Omni-Link II home automation panels (Omni Pro II, Omni IIe, Omni LTe, Lumina)."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
@ -24,12 +24,20 @@ dependencies = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
cli = ["rich>=13.9.0", "typer>=0.15.0"]
|
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]
|
[project.scripts]
|
||||||
omni-pca = "omni_pca.__main__:main"
|
omni-pca = "omni_pca.__main__:main"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Repository = "https://git.supported.systems/warehack.ing/omni-pca"
|
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/"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.11.8,<0.12.0"]
|
requires = ["uv_build>=0.11.8,<0.12.0"]
|
||||||
|
|||||||
@ -2,9 +2,34 @@
|
|||||||
|
|
||||||
from importlib.metadata import PackageNotFoundError, version
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
|
||||||
|
from .programs import (
|
||||||
|
Condition,
|
||||||
|
ConditionFamily,
|
||||||
|
Days,
|
||||||
|
MiscConditional,
|
||||||
|
Program,
|
||||||
|
ProgramCond,
|
||||||
|
ProgramType,
|
||||||
|
TimeKind,
|
||||||
|
decode_program_table,
|
||||||
|
iter_defined,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
__version__ = version("omni-pca")
|
__version__ = version("omni-pca")
|
||||||
except PackageNotFoundError:
|
except PackageNotFoundError:
|
||||||
__version__ = "0.0.0+unknown"
|
__version__ = "0.0.0+unknown"
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = [
|
||||||
|
"Condition",
|
||||||
|
"ConditionFamily",
|
||||||
|
"Days",
|
||||||
|
"MiscConditional",
|
||||||
|
"Program",
|
||||||
|
"ProgramCond",
|
||||||
|
"ProgramType",
|
||||||
|
"TimeKind",
|
||||||
|
"__version__",
|
||||||
|
"decode_program_table",
|
||||||
|
"iter_defined",
|
||||||
|
]
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import struct
|
|||||||
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import TYPE_CHECKING, Self
|
from typing import TYPE_CHECKING, Literal, Self
|
||||||
|
|
||||||
from .commands import Command, CommandFailedError, SecurityCommandResponse
|
from .commands import Command, CommandFailedError, SecurityCommandResponse
|
||||||
|
|
||||||
@ -120,12 +120,20 @@ class OmniClient:
|
|||||||
*,
|
*,
|
||||||
controller_key: bytes,
|
controller_key: bytes,
|
||||||
timeout: float = 5.0,
|
timeout: float = 5.0,
|
||||||
|
transport: Literal["tcp", "udp"] = "tcp",
|
||||||
|
udp_retry_count: int = 3,
|
||||||
) -> None:
|
) -> 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(
|
self._conn = OmniConnection(
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
controller_key=controller_key,
|
controller_key=controller_key,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
transport=transport,
|
||||||
|
udp_retry_count=udp_retry_count,
|
||||||
)
|
)
|
||||||
self._subscriber_task: asyncio.Task[None] | None = None
|
self._subscriber_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
@ -599,6 +607,92 @@ class OmniClient:
|
|||||||
"""
|
"""
|
||||||
await self.execute_command(Command.CLEAR_MESSAGE, parameter2=index)
|
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) -----------------------------------------------
|
# ---- helpers (status) -----------------------------------------------
|
||||||
|
|
||||||
async def _fetch_status_range(
|
async def _fetch_status_range(
|
||||||
@ -683,10 +777,22 @@ class OmniClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def list_area_names(self) -> dict[int, str]:
|
async def list_area_names(self) -> dict[int, str]:
|
||||||
return await self._walk_named_objects(
|
"""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(
|
||||||
ObjectType.AREA,
|
ObjectType.AREA,
|
||||||
lambda r: (r.index, r.name) if isinstance(r, AreaProperties) else None,
|
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(
|
async def subscribe(
|
||||||
self, callback: Callable[[Message], Awaitable[None]]
|
self, callback: Callable[[Message], Awaitable[None]]
|
||||||
|
|||||||
@ -39,6 +39,19 @@ The reply (ExecuteSecurityCommandResponse, opcode 75) carries a single
|
|||||||
status byte at ``payload[0]`` whose values are listed in
|
status byte at ``payload[0]`` whose values are listed in
|
||||||
``enuSecurityCommnadResponse.cs`` — :class:`SecurityCommandResponse`
|
``enuSecurityCommnadResponse.cs`` — :class:`SecurityCommandResponse`
|
||||||
mirrors that enum.
|
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
|
from __future__ import annotations
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import logging
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from .crypto import (
|
from .crypto import (
|
||||||
BLOCK_SIZE,
|
BLOCK_SIZE,
|
||||||
@ -104,18 +105,30 @@ class OmniConnection:
|
|||||||
port: int = _DEFAULT_PORT,
|
port: int = _DEFAULT_PORT,
|
||||||
controller_key: bytes = b"",
|
controller_key: bytes = b"",
|
||||||
timeout: float = 5.0,
|
timeout: float = 5.0,
|
||||||
|
transport: Literal["tcp", "udp"] = "tcp",
|
||||||
|
udp_retry_count: int = 3,
|
||||||
) -> None:
|
) -> None:
|
||||||
if len(controller_key) != 16:
|
if len(controller_key) != 16:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"controller_key must be 16 bytes, got {len(controller_key)}"
|
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._host = host
|
||||||
self._port = port
|
self._port = port
|
||||||
self._controller_key = bytes(controller_key)
|
self._controller_key = bytes(controller_key)
|
||||||
self._default_timeout = timeout
|
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._reader: asyncio.StreamReader | None = None
|
||||||
self._writer: asyncio.StreamWriter | 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._state = ConnectionState.DISCONNECTED
|
||||||
|
|
||||||
self._session_id: bytes | None = None
|
self._session_id: bytes | None = None
|
||||||
@ -167,22 +180,41 @@ class OmniConnection:
|
|||||||
await self.close()
|
await self.close()
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
"""Open the TCP socket and run the 4-step secure-session handshake."""
|
"""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.
|
||||||
|
"""
|
||||||
if self._state is not ConnectionState.DISCONNECTED:
|
if self._state is not ConnectionState.DISCONNECTED:
|
||||||
raise ConnectionError(f"already connecting/connected (state={self._state})")
|
raise ConnectionError(f"already connecting/connected (state={self._state})")
|
||||||
self._state = ConnectionState.CONNECTING
|
self._state = ConnectionState.CONNECTING
|
||||||
try:
|
try:
|
||||||
self._reader, self._writer = await asyncio.wait_for(
|
if self._transport_kind == "tcp":
|
||||||
asyncio.open_connection(self._host, self._port),
|
self._reader, self._writer = await asyncio.wait_for(
|
||||||
timeout=self._default_timeout,
|
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),
|
||||||
|
)
|
||||||
|
)
|
||||||
except (TimeoutError, OSError) as exc:
|
except (TimeoutError, OSError) as exc:
|
||||||
self._state = ConnectionState.DISCONNECTED
|
self._state = ConnectionState.DISCONNECTED
|
||||||
raise ConnectionError(f"failed to open TCP socket: {exc}") from exc
|
raise ConnectionError(
|
||||||
|
f"failed to open {self._transport_kind.upper()} socket: {exc}"
|
||||||
self._reader_task = asyncio.create_task(
|
) from exc
|
||||||
self._read_loop(), name=f"omni-conn-reader-{self._host}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._do_handshake()
|
await self._do_handshake()
|
||||||
@ -195,8 +227,36 @@ class OmniConnection:
|
|||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
|
previous_state = self._state
|
||||||
self._state = ConnectionState.DISCONNECTED
|
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.
|
# Cancel anyone still waiting for a reply.
|
||||||
for fut in self._pending.values():
|
for fut in self._pending.values():
|
||||||
if not fut.done():
|
if not fut.done():
|
||||||
@ -212,6 +272,12 @@ class OmniConnection:
|
|||||||
self._writer = None
|
self._writer = None
|
||||||
self._reader = 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():
|
if self._reader_task is not None and not self._reader_task.done():
|
||||||
self._reader_task.cancel()
|
self._reader_task.cancel()
|
||||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||||
@ -238,17 +304,39 @@ class OmniConnection:
|
|||||||
f"cannot send request, connection state={self._state.name}"
|
f"cannot send request, connection state={self._state.name}"
|
||||||
)
|
)
|
||||||
message = encode_v2(opcode, payload)
|
message = encode_v2(opcode, payload)
|
||||||
seq, fut = self._send_encrypted(message)
|
per_attempt_timeout = timeout if timeout is not None else self._default_timeout
|
||||||
try:
|
# UDP needs explicit retries since datagram delivery is best-effort.
|
||||||
reply_packet = await asyncio.wait_for(
|
# TCP gets reliable delivery for free; we still keep retry_count for
|
||||||
fut, timeout if timeout is not None else self._default_timeout
|
# API uniformity but it defaults to 0 effectively.
|
||||||
)
|
max_attempts = (
|
||||||
except TimeoutError as exc:
|
1 + self._udp_retry_count if self._transport_kind == "udp" else 1
|
||||||
self._pending.pop(seq, None)
|
)
|
||||||
raise RequestTimeoutError(
|
last_exc: Exception | None = None
|
||||||
f"no reply for opcode={int(opcode)} seq={seq}"
|
for attempt in range(1, max_attempts + 1):
|
||||||
) from exc
|
seq, fut = self._send_encrypted(message)
|
||||||
return self._decode_inner(reply_packet)
|
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)}"
|
||||||
|
)
|
||||||
|
|
||||||
def unsolicited(self) -> AsyncIterator[Message]:
|
def unsolicited(self) -> AsyncIterator[Message]:
|
||||||
"""Async iterator over unsolicited inbound messages (seq=0)."""
|
"""Async iterator over unsolicited inbound messages (seq=0)."""
|
||||||
@ -380,17 +468,23 @@ class OmniConnection:
|
|||||||
return seq, fut
|
return seq, fut
|
||||||
|
|
||||||
def _write_packet(self, pkt: Packet, *, encrypted: bool = False) -> None:
|
def _write_packet(self, pkt: Packet, *, encrypted: bool = False) -> None:
|
||||||
if self._writer is None:
|
|
||||||
raise ConnectionError("transport not open")
|
|
||||||
wire = pkt.encode()
|
wire = pkt.encode()
|
||||||
_log.debug(
|
_log.debug(
|
||||||
"TX seq=%d type=%s len=%d encrypted=%s",
|
"TX[%s] seq=%d type=%s len=%d encrypted=%s",
|
||||||
|
self._transport_kind,
|
||||||
pkt.seq,
|
pkt.seq,
|
||||||
pkt.type.name,
|
pkt.type.name,
|
||||||
len(pkt.data),
|
len(pkt.data),
|
||||||
encrypted,
|
encrypted,
|
||||||
)
|
)
|
||||||
self._writer.write(wire)
|
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)
|
||||||
|
|
||||||
def _decode_inner(self, pkt: Packet) -> Message:
|
def _decode_inner(self, pkt: Packet) -> Message:
|
||||||
"""Decrypt + parse the inner ``Message`` from an OmniLink2Message packet."""
|
"""Decrypt + parse the inner ``Message`` from an OmniLink2Message packet."""
|
||||||
@ -596,3 +690,46 @@ class OmniConnection:
|
|||||||
return
|
return
|
||||||
if not fut.done():
|
if not fut.done():
|
||||||
fut.set_result(pkt)
|
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,6 +29,16 @@ References (decompiled C# source):
|
|||||||
— bit-mask classifier we mirror below
|
— bit-mask classifier we mirror below
|
||||||
clsText.cs:1693-1911 (GetEventText)
|
clsText.cs:1693-1911 (GetEventText)
|
||||||
— per-category sub-field extraction
|
— 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
|
from __future__ import annotations
|
||||||
@ -127,17 +137,22 @@ class UpbLinkAction(IntEnum):
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _ensure_system_events(message: Message) -> bytes:
|
def _ensure_system_events(
|
||||||
"""Validate that ``message`` is a v2 SystemEvents reply, return its
|
message: Message,
|
||||||
payload bytes (everything after the opcode).
|
expected_opcode: int = int(OmniLink2MessageType.SystemEvents),
|
||||||
|
) -> bytes:
|
||||||
|
"""Validate that ``message`` is a SystemEvents reply, return payload bytes.
|
||||||
|
|
||||||
Reference: clsOLMsgSystemEvents.cs (entire file) — the message body
|
The v1 and v2 SystemEvents inner-message bodies are byte-identical
|
||||||
is just ``[opcode][word1_hi][word1_lo][word2_hi][word2_lo]…``.
|
(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`.
|
||||||
"""
|
"""
|
||||||
if message.opcode != int(OmniLink2MessageType.SystemEvents):
|
if message.opcode != expected_opcode:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"not a SystemEvents message: opcode "
|
f"not a SystemEvents message: opcode {message.opcode} "
|
||||||
f"{message.opcode} (expected {int(OmniLink2MessageType.SystemEvents)})"
|
f"(expected {expected_opcode})"
|
||||||
)
|
)
|
||||||
payload = message.payload
|
payload = message.payload
|
||||||
if len(payload) % 2 != 0:
|
if len(payload) % 2 != 0:
|
||||||
@ -700,18 +715,23 @@ def _classify(word: int) -> SystemEvent:
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def parse_events(message: Message) -> list[SystemEvent]:
|
def parse_events(
|
||||||
"""Decode a v2 ``SystemEvents`` (opcode 55) message into typed events.
|
message: Message,
|
||||||
|
expected_opcode: int = int(OmniLink2MessageType.SystemEvents),
|
||||||
|
) -> list[SystemEvent]:
|
||||||
|
"""Decode a ``SystemEvents`` message into typed events.
|
||||||
|
|
||||||
The panel batches multiple state changes into a single message, so
|
The panel batches multiple state changes into a single message, so
|
||||||
the return type is always a list — even for messages that carry just
|
the return type is always a list — even for messages that carry just
|
||||||
one event. Empty SystemEvents messages return an empty list rather
|
one event. Empty SystemEvents messages return an empty list rather
|
||||||
than raising.
|
than raising.
|
||||||
|
|
||||||
Reference: clsOLMsgSystemEvents.cs:10-18 (SystemEventsCount + per-
|
``expected_opcode`` defaults to v2 (55); pass v1's value (35) when
|
||||||
word accessor).
|
decoding from a ``v1.OmniConnectionV1`` push stream.
|
||||||
|
|
||||||
|
Reference: clsOLMsgSystemEvents.cs / clsOL2MsgSystemEvents.cs.
|
||||||
"""
|
"""
|
||||||
payload = _ensure_system_events(message)
|
payload = _ensure_system_events(message, expected_opcode)
|
||||||
return [_classify(w) for w in _iter_event_words(payload)]
|
return [_classify(w) for w in _iter_event_words(payload)]
|
||||||
|
|
||||||
|
|
||||||
@ -790,6 +810,7 @@ class EventStream:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
source: object # OmniConnection or duck-typed equivalent
|
source: object # OmniConnection or duck-typed equivalent
|
||||||
|
expected_opcode: int = int(OmniLink2MessageType.SystemEvents)
|
||||||
_buffer: list[SystemEvent] = field(default_factory=list)
|
_buffer: list[SystemEvent] = field(default_factory=list)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
@ -817,10 +838,10 @@ class EventStream:
|
|||||||
raise
|
raise
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
if msg.opcode != int(OmniLink2MessageType.SystemEvents):
|
if msg.opcode != self.expected_opcode:
|
||||||
# Non-event message (Status, Ack, …) — silently ignore.
|
# Non-event message (Status, Ack, …) — silently ignore.
|
||||||
continue
|
continue
|
||||||
self._buffer = parse_events(msg)
|
self._buffer = parse_events(msg, self.expected_opcode)
|
||||||
return self._buffer.pop(0)
|
return self._buffer.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
Wire layout (non-addressable):
|
Wire layout (non-addressable):
|
||||||
``[start_char][length][...data...][crc_lo][crc_hi]``
|
``[start_char][length][...data...][crc_lo][crc_hi]``
|
||||||
|
|
||||||
For v1 addressable messages (StartChar=0x5A) a single SerialAddress byte
|
For v1 addressable messages (StartChar=0x41) a single SerialAddress byte
|
||||||
is interleaved between start_char and length.
|
is interleaved between start_char and length.
|
||||||
|
|
||||||
CRC is CRC-16/MODBUS (poly 0xA001, init 0, reflected) computed over the
|
CRC is CRC-16/MODBUS (poly 0xA001, init 0, reflected) computed over the
|
||||||
@ -13,6 +13,8 @@ on the wire (CRC1 = low byte, CRC2 = high byte).
|
|||||||
References:
|
References:
|
||||||
clsOmniLinkMessage.cs (lines 9, 164-186, 273-289) — frame + CRC
|
clsOmniLinkMessage.cs (lines 9, 164-186, 273-289) — frame + CRC
|
||||||
clsOmniLink2Message.cs (lines 17-23) — v2 StartChar = 0x21
|
clsOmniLink2Message.cs (lines 17-23) — v2 StartChar = 0x21
|
||||||
|
enuOmniLinkMessageFormat.cs — Addressable=0x41, NonAddressable=0x5A,
|
||||||
|
OmniLink2=0x21
|
||||||
clsOL2MsgLogin.cs / clsOLMsgLogin.cs — example payloads
|
clsOL2MsgLogin.cs / clsOLMsgLogin.cs — example payloads
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -23,8 +25,8 @@ from dataclasses import dataclass, field
|
|||||||
from .opcodes import OmniLink2MessageType, OmniLinkMessageType
|
from .opcodes import OmniLink2MessageType, OmniLinkMessageType
|
||||||
|
|
||||||
START_CHAR_V2 = 0x21
|
START_CHAR_V2 = 0x21
|
||||||
START_CHAR_V1_UNADDRESSED = 0x41
|
START_CHAR_V1_ADDRESSABLE = 0x41
|
||||||
START_CHAR_V1_ADDRESSABLE = 0x5A
|
START_CHAR_V1_UNADDRESSED = 0x5A
|
||||||
|
|
||||||
_CRC_POLY_REFLECTED = 0xA001
|
_CRC_POLY_REFLECTED = 0xA001
|
||||||
|
|
||||||
|
|||||||
@ -490,18 +490,25 @@ class ObjectType(IntEnum):
|
|||||||
class SecurityMode(IntEnum):
|
class SecurityMode(IntEnum):
|
||||||
"""Area security mode (enuSecurityMode.cs).
|
"""Area security mode (enuSecurityMode.cs).
|
||||||
|
|
||||||
Values 9..14 are the "arming in progress" variants the panel reports
|
The first 7 values are what the user actually picks at the keypad
|
||||||
while a delayed-arm timer is running.
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
OFF = 0
|
OFF = 0 # disarmed; resets fire / emergency alarms, silences sirens
|
||||||
DAY = 1
|
DAY = 1 # perimeter armed, interior motion NOT armed, entry delay
|
||||||
NIGHT = 2
|
NIGHT = 2 # perimeter + non-sleeping-area motion armed, NO entry delay
|
||||||
AWAY = 3
|
AWAY = 3 # everything armed, both exit + entry delays
|
||||||
VACATION = 4
|
VACATION = 4 # same as AWAY but for multi-day absences
|
||||||
DAY_INSTANT = 5
|
DAY_INSTANT = 5 # DAY with no entry delay (instant alarm on perimeter)
|
||||||
NIGHT_DELAYED = 6
|
NIGHT_DELAYED = 6 # NIGHT with entry delay on entry-exit zones
|
||||||
ANY_CHANGE = 7
|
ANY_CHANGE = 7 # programming-condition wildcard, NOT a real arming state
|
||||||
ARMING_DAY = 9
|
ARMING_DAY = 9
|
||||||
ARMING_NIGHT = 10
|
ARMING_NIGHT = 10
|
||||||
ARMING_AWAY = 11
|
ARMING_AWAY = 11
|
||||||
@ -511,7 +518,13 @@ class SecurityMode(IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
class HvacMode(IntEnum):
|
class HvacMode(IntEnum):
|
||||||
"""Thermostat system mode (enuThermostatMode.cs)."""
|
"""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.
|
||||||
|
"""
|
||||||
|
|
||||||
OFF = 0
|
OFF = 0
|
||||||
HEAT = 1
|
HEAT = 1
|
||||||
@ -521,7 +534,12 @@ class HvacMode(IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
class FanMode(IntEnum):
|
class FanMode(IntEnum):
|
||||||
"""Thermostat fan mode (enuThermostatFanMode.cs)."""
|
"""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.
|
||||||
|
"""
|
||||||
|
|
||||||
AUTO = 0
|
AUTO = 0
|
||||||
ON = 1
|
ON = 1
|
||||||
@ -531,8 +549,12 @@ class FanMode(IntEnum):
|
|||||||
class HoldMode(IntEnum):
|
class HoldMode(IntEnum):
|
||||||
"""Thermostat hold mode (enuThermostatHoldMode.cs).
|
"""Thermostat hold mode (enuThermostatHoldMode.cs).
|
||||||
|
|
||||||
Value 255 (``OLD_ON``) is a legacy "Hold" sentinel from older firmware
|
``OFF`` / ``HOLD`` are the two states surfaced at the keypad's
|
||||||
that some panels still emit; treat it as equivalent to ``HOLD``.
|
"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``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
OFF = 0
|
OFF = 0
|
||||||
@ -560,6 +582,13 @@ class ZoneType(IntEnum):
|
|||||||
the temperature/humidity sensors and a handful of utility types. Any
|
the temperature/humidity sensors and a handful of utility types. Any
|
||||||
raw byte value still round-trips through ``ZoneStatus.zone_type`` —
|
raw byte value still round-trips through ``ZoneStatus.zone_type`` —
|
||||||
it just won't have a named enum member.
|
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
|
ENTRY_EXIT = 0
|
||||||
@ -657,7 +686,9 @@ class ZoneStatus:
|
|||||||
bytes[2] status byte (current+latched+arming, see below)
|
bytes[2] status byte (current+latched+arming, see below)
|
||||||
bytes[3] analog loop reading (0-255)
|
bytes[3] analog loop reading (0-255)
|
||||||
|
|
||||||
Status byte bit layout (clsZone.cs:385, clsText.cs:3110):
|
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/):
|
||||||
bits 0-1 (mask 0x03): current condition
|
bits 0-1 (mask 0x03): current condition
|
||||||
0=Secure, 1=NotReady, 2=Trouble, 3=Tamper
|
0=Secure, 1=NotReady, 2=Trouble, 3=Tamper
|
||||||
bits 2-3 (mask 0x0C): latched alarm status
|
bits 2-3 (mask 0x0C): latched alarm status
|
||||||
@ -752,7 +783,11 @@ class UnitStatus:
|
|||||||
bytes[3..4] remaining time in seconds (BE u16, 0 = indefinite)
|
bytes[3..4] remaining time in seconds (BE u16, 0 = indefinite)
|
||||||
bytes[5..6] optional ZigBee instantaneous power (W, BE u16)
|
bytes[5..6] optional ZigBee instantaneous power (W, BE u16)
|
||||||
|
|
||||||
State byte semantics (clsUnit.cs:405-533):
|
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):
|
||||||
0 Off
|
0 Off
|
||||||
1 On
|
1 On
|
||||||
2..13 Scene A..L (state - 63 → 'A'..'L' as ASCII char)
|
2..13 Scene A..L (state - 63 → 'A'..'L' as ASCII char)
|
||||||
|
|||||||
1366
src/omni_pca/program_engine.py
Normal file
884
src/omni_pca/program_renderer.py
Normal file
@ -0,0 +1,884 @@
|
|||||||
|
"""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"
|
||||||
1016
src/omni_pca/programs.py
Normal file
52
src/omni_pca/v1/__init__.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"""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",
|
||||||
|
]
|
||||||
441
src/omni_pca/v1/adapter.py
Normal file
@ -0,0 +1,441 @@
|
|||||||
|
"""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)
|
||||||
518
src/omni_pca/v1/client.py
Normal file
@ -0,0 +1,518 @@
|
|||||||
|
"""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."""
|
||||||
530
src/omni_pca/v1/connection.py
Normal file
@ -0,0 +1,530 @@
|
|||||||
|
"""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)
|
||||||
322
src/omni_pca/v1/messages.py
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
"""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,6 +64,26 @@ def _short_scan_interval(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def populated_state() -> MockState:
|
def populated_state() -> MockState:
|
||||||
"""A lightly-populated mock state covering every entity platform."""
|
"""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(
|
return MockState(
|
||||||
zones={
|
zones={
|
||||||
1: MockZoneState(name="FRONT_DOOR"),
|
1: MockZoneState(name="FRONT_DOOR"),
|
||||||
@ -84,6 +104,7 @@ def populated_state() -> MockState:
|
|||||||
1: MockButtonState(name="GOOD_MORNING"),
|
1: MockButtonState(name="GOOD_MORNING"),
|
||||||
},
|
},
|
||||||
user_codes={1: 1234},
|
user_codes={1: 1234},
|
||||||
|
programs=programs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
207
tests/ha_integration/test_pca_source.py
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
"""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"
|
||||||
673
tests/ha_integration/test_program_websocket.py
Normal file
@ -0,0 +1,673 @@
|
|||||||
|
"""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,6 +78,35 @@ async def test_button_entity_for_panel_button(
|
|||||||
assert len(states) == 1
|
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(
|
async def test_event_entity_per_panel(
|
||||||
hass: HomeAssistant, configured_panel
|
hass: HomeAssistant, configured_panel
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
666
tests/test_e2e_program_echo.py
Normal file
@ -0,0 +1,666 @@
|
|||||||
|
"""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
|
||||||
152
tests/test_e2e_udp.py
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
"""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
|
||||||
252
tests/test_e2e_v1_mock.py
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
"""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)
|
||||||