Compare commits
55 Commits
v2026.5.11
...
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 |
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
|
||||
5
.gitignore
vendored
@ -42,3 +42,8 @@ panel_key*
|
||||
ha-config/
|
||||
dist/
|
||||
dev/.omni_key
|
||||
|
||||
# Frontend build artifacts. node_modules is local; the bundled panel.js
|
||||
# is committed (alongside its source) so end-users don't need Node
|
||||
# installed to use the integration.
|
||||
custom_components/omni_pca/frontend/node_modules/
|
||||
|
||||
42
CHANGELOG.md
@ -2,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.
|
||||
|
||||
## [2026.5.16] — 2026-05-16
|
||||
|
||||
Program viewer side panel + writeback API + docs link fix.
|
||||
|
||||
### Home Assistant integration
|
||||
|
||||
- Lit/TypeScript side panel for the program viewer (Phase C): filterable list, slide-in detail panel, structured-English token rendering, REF-token click-to-filter, live-state badges (SECURE / NOT READY / ON 60% / Away / 72°F) sourced from the coordinator, "Fire now" button calling `omni_pca/programs/fire` over the websocket.
|
||||
- Program writeback: `DownloadProgram` wire path, HA write API, Clear / Clone UI in the side panel.
|
||||
- esbuild bundle committed at `custom_components/omni_pca/www/panel.js` (~34 KB minified) so end-users don't need Node.
|
||||
- `manifest.json`: `documentation` URL points at <https://hai-omni-pro-ii.warehack.ing/> (was the GitHub repo); matches the canonical docs site already referenced from `pyproject.toml`.
|
||||
|
||||
## [2026.5.14] — 2026-05-14
|
||||
|
||||
HACS publishing release — brand assets and validation tooling.
|
||||
|
||||
### Home Assistant integration
|
||||
|
||||
- `brand/icon.png` (256×256) + `brand/icon@2x.png` (512×512) shipped inline at `custom_components/omni_pca/brand/` for the HA 2026.3 brands-proxy API.
|
||||
- WebSocket commands + side-panel registration for an in-HA custom panel surfacing decoded programs.
|
||||
- `program_renderer`: structured-English token streams for the HA UI to render conditional logic.
|
||||
- `program_engine`: real AND/OR condition evaluator (StateEvaluator decodes records against MockState; replaces the always-passes-AND/always-fails-OR stub).
|
||||
- `program_engine`: EVENT programs + event taxonomy (Phase 4), clausal chains WHEN/AT/EVERY + AND/OR/THEN (Phase 5).
|
||||
- `__init__.py`: `CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)` to satisfy hassfest.
|
||||
- `manifest.json`: keys sorted (domain, name, then alphabetical), HTML/markdown removed from i18n strings.
|
||||
- Canonical URLs switched to `github.com/rsp2k/omni-pca` (was Gitea-only).
|
||||
|
||||
### Library
|
||||
|
||||
- `pca_file.py`: progressive `SetupData` decoding — zone types, area assignments, entry/exit delays, temperature format, code PINs, installer/PCAccess codes, perimeter chime, audible exit delay, DST, house code format, time clocks, latitude/longitude/timezone, account remarks extended, 9 per-family description tables, zone options, thermostat type + areas, time_adj / alarm_reset_time / arming_confirmation / two_way_audio scalars.
|
||||
- `iter_programs()` for both v1 (UDP) and v2 (TCP) wire dialects.
|
||||
- `mock_panel`: v1 `UploadPrograms` streaming + program-echo tests; `MockState.from_pca()` builds state from a real `.pca`.
|
||||
- `programs`: multi-record decoder properties (firmware ≥3.0 records), structured-OP AND decoder properties, AND-record u16 fields documented as big-endian on disk.
|
||||
|
||||
### CI / packaging
|
||||
|
||||
- `.github/workflows/validate.yml`: HACS action + hassfest on push / PR / weekly.
|
||||
- `pyproject.toml`: full `[project.urls]` with Repository / Issues / Changelog / Documentation.
|
||||
|
||||
## [2026.5.10] — 2026-05-10
|
||||
|
||||
First release. Working library + Home Assistant custom component, validated end-to-end against an in-process mock panel and a real HA instance running in Docker. Not yet validated against a live panel because the user's panel's network module is currently off.
|
||||
@ -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.
|
||||
- **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
|
||||
|
||||
16
README.md
@ -4,7 +4,7 @@ Async Python client for HAI/Leviton Omni-Link II home automation panels — Omni
|
||||
|
||||
Includes a Home Assistant custom component (`custom_components/omni_pca/`).
|
||||
|
||||
**Project home:** <https://git.supported.systems/warehack.ing/omni-pca>
|
||||
**Project home:** <https://github.com/rsp2k/omni-pca>
|
||||
**Documentation:** <https://hai-omni-pro-ii.warehack.ing/>
|
||||
|
||||
## Status
|
||||
@ -18,20 +18,14 @@ The full byte-level protocol spec lives at <https://hai-omni-pro-ii.warehack.ing
|
||||
|
||||
## Install
|
||||
|
||||
The library isn't on PyPI yet (pending), so install directly from the Gitea release:
|
||||
|
||||
```bash
|
||||
# Pinned to a specific release (recommended)
|
||||
pip install "omni-pca @ git+https://git.supported.systems/warehack.ing/omni-pca.git@v2026.5.10"
|
||||
|
||||
# Or the wheel from the release page
|
||||
pip install https://git.supported.systems/warehack.ing/omni-pca/releases/download/v2026.5.10/omni_pca-2026.5.10-py3-none-any.whl
|
||||
pip install omni-pca
|
||||
|
||||
# Or with uv
|
||||
uv add "omni-pca @ git+https://git.supported.systems/warehack.ing/omni-pca.git@v2026.5.10"
|
||||
uv add omni-pca
|
||||
```
|
||||
|
||||
Once published to PyPI, the canonical install will be `pip install 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)
|
||||
|
||||
@ -79,7 +73,7 @@ The HA integration picks the right client automatically based on the **Transport
|
||||
cd /path/to/your/homeassistant/config/
|
||||
mkdir -p custom_components
|
||||
cd custom_components
|
||||
git clone https://git.supported.systems/warehack.ing/omni-pca tmp-omni
|
||||
git clone https://github.com/rsp2k/omni-pca tmp-omni
|
||||
cp -r tmp-omni/custom_components/omni_pca .
|
||||
rm -rf tmp-omni
|
||||
```
|
||||
|
||||
@ -6,16 +6,18 @@ opens an encrypted session straight to the panel and listens for unsolicited
|
||||
push messages.
|
||||
|
||||
This integration is the HA-facing wrapper around the
|
||||
[`omni-pca`](https://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.
|
||||
|
||||
## Install
|
||||
|
||||
### HACS (recommended once published)
|
||||
### HACS
|
||||
|
||||
1. HACS → Integrations → custom repository → add
|
||||
`https://git.supported.systems/warehack.ing/omni-pca`, category **Integration**.
|
||||
2. Install **HAI / Leviton Omni Panel**, then restart Home Assistant.
|
||||
1. HACS → Integrations → search **HAI / Leviton Omni Panel**.
|
||||
2. Install, then restart Home Assistant.
|
||||
|
||||
(If not yet in the HACS default catalog: HACS → Integrations → custom
|
||||
repository → add `https://github.com/rsp2k/omni-pca`, category **Integration**.)
|
||||
|
||||
### Manual
|
||||
|
||||
@ -121,6 +123,6 @@ hashed) — useful for bug reports.
|
||||
- **No entities for X**: only objects with a name configured on the panel
|
||||
are discovered. PC Access's "Names" page is where they live.
|
||||
|
||||
See the [parent README](https://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
|
||||
[`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,9 +13,12 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_CONTROLLER_KEY,
|
||||
CONF_PCA_KEY,
|
||||
CONF_PCA_PATH,
|
||||
CONF_TRANSPORT,
|
||||
DEFAULT_TRANSPORT,
|
||||
DOMAIN,
|
||||
@ -23,6 +26,12 @@ from .const import (
|
||||
)
|
||||
from .coordinator import OmniDataUpdateCoordinator
|
||||
from .services import async_setup_services, async_unload_services
|
||||
from .websocket import (
|
||||
async_register_side_panel,
|
||||
async_register_websocket_commands,
|
||||
)
|
||||
|
||||
_PANEL_REGISTERED_KEY = "_panel_registered"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@ -39,6 +48,8 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||
"""No YAML support; everything is config-flow driven."""
|
||||
@ -57,6 +68,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return False
|
||||
|
||||
transport: str = entry.data.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
|
||||
pca_path: str = entry.data.get(CONF_PCA_PATH, "") or ""
|
||||
pca_key: int = entry.data.get(CONF_PCA_KEY, 0)
|
||||
coordinator = OmniDataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
@ -64,6 +77,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
port=port,
|
||||
controller_key=controller_key,
|
||||
transport=transport,
|
||||
pca_path=pca_path or None,
|
||||
pca_key=pca_key,
|
||||
)
|
||||
|
||||
try:
|
||||
@ -77,6 +92,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await async_setup_services(hass)
|
||||
# Websocket commands are global (not per-entry) but the registration
|
||||
# helper is idempotent, so calling it for each entry is harmless.
|
||||
async_register_websocket_commands(hass)
|
||||
# Panel registration must happen exactly once — guard with a flag in
|
||||
# hass.data. The flag survives entry reloads; only a full HA restart
|
||||
# clears it (matching when HA itself would need to re-register).
|
||||
if not hass.data[DOMAIN].get(_PANEL_REGISTERED_KEY):
|
||||
try:
|
||||
await async_register_side_panel(hass)
|
||||
except Exception:
|
||||
LOGGER.warning(
|
||||
"omni_pca: side panel registration failed", exc_info=True,
|
||||
)
|
||||
hass.data[DOMAIN][_PANEL_REGISTERED_KEY] = True
|
||||
return True
|
||||
|
||||
|
||||
|
||||
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,6 +20,8 @@ from omni_pca.connection import (
|
||||
|
||||
from .const import (
|
||||
CONF_CONTROLLER_KEY,
|
||||
CONF_PCA_KEY,
|
||||
CONF_PCA_PATH,
|
||||
CONF_TRANSPORT,
|
||||
CONTROLLER_KEY_HEX_LEN,
|
||||
DEFAULT_PORT,
|
||||
@ -70,6 +72,15 @@ _USER_SCHEMA = vol.Schema(
|
||||
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)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@ -90,6 +101,8 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
host: str = user_input[CONF_HOST].strip()
|
||||
port: int = user_input[CONF_PORT]
|
||||
transport: str = user_input.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
|
||||
pca_path: str = (user_input.get(CONF_PCA_PATH) or "").strip()
|
||||
pca_key: int = user_input.get(CONF_PCA_KEY, 0)
|
||||
unique_id = f"{host}:{port}"
|
||||
|
||||
await self.async_set_unique_id(unique_id)
|
||||
@ -101,19 +114,24 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
LOGGER.debug("controller key rejected: %s", err)
|
||||
errors[CONF_CONTROLLER_KEY] = "invalid_key"
|
||||
else:
|
||||
title, error = await self._probe(host, port, key, transport)
|
||||
if error is not None:
|
||||
errors["base"] = error
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=title or f"Omni Panel ({host})",
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: port,
|
||||
CONF_CONTROLLER_KEY: key.hex(),
|
||||
CONF_TRANSPORT: transport,
|
||||
},
|
||||
)
|
||||
if pca_path and (pca_err := await self._validate_pca(pca_path, pca_key)):
|
||||
errors[CONF_PCA_PATH] = pca_err
|
||||
if not errors:
|
||||
title, error = await self._probe(host, port, key, transport)
|
||||
if error is not None:
|
||||
errors["base"] = error
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=title or f"Omni Panel ({host})",
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: port,
|
||||
CONF_CONTROLLER_KEY: key.hex(),
|
||||
CONF_TRANSPORT: transport,
|
||||
CONF_PCA_PATH: pca_path,
|
||||
CONF_PCA_KEY: pca_key,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
@ -121,6 +139,33 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _validate_pca(self, path: str, key: int) -> str | None:
|
||||
"""Validate the user-supplied .pca path is readable and decrypts.
|
||||
|
||||
Returns ``None`` on success or an error code string on failure
|
||||
(matches the {code: message} keys in strings.json).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from omni_pca.pca_file import parse_pca_file
|
||||
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return "pca_not_found"
|
||||
try:
|
||||
data = await self.hass.async_add_executor_job(p.read_bytes)
|
||||
acct = await self.hass.async_add_executor_job(
|
||||
lambda: parse_pca_file(data, key=key)
|
||||
)
|
||||
except Exception as err:
|
||||
LOGGER.debug("pca file rejected: %s", err)
|
||||
return "pca_decode_failed"
|
||||
# Sanity: programs block decoded cleanly. Empty is allowed
|
||||
# (legitimate brand-new install with no programs).
|
||||
if not isinstance(acct.programs, tuple):
|
||||
return "pca_decode_failed"
|
||||
return None
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@ -14,6 +14,13 @@ DEFAULT_TIMEOUT: Final = 5.0
|
||||
CONF_CONTROLLER_KEY: Final = "controller_key"
|
||||
CONF_TRANSPORT: Final = "transport"
|
||||
|
||||
# Optional: when set, load panel programs from a .pca file at this path
|
||||
# instead of enumerating them over the wire on every entry refresh. The
|
||||
# .pca file is decrypted with CONF_PCA_KEY (the per-install key from
|
||||
# PCA01.CFG, or 0 for a plain-text dump). Both must be set together.
|
||||
CONF_PCA_PATH: Final = "pca_path"
|
||||
CONF_PCA_KEY: Final = "pca_key"
|
||||
|
||||
TRANSPORT_TCP: Final = "tcp"
|
||||
TRANSPORT_UDP: Final = "udp"
|
||||
DEFAULT_TRANSPORT: Final = TRANSPORT_TCP
|
||||
|
||||
@ -62,7 +62,6 @@ from omni_pca.models import (
|
||||
AreaStatus,
|
||||
ButtonProperties,
|
||||
ObjectType,
|
||||
ProgramProperties,
|
||||
SystemInformation,
|
||||
SystemStatus,
|
||||
ThermostatProperties,
|
||||
@ -73,6 +72,7 @@ from omni_pca.models import (
|
||||
ZoneStatus,
|
||||
)
|
||||
from omni_pca.opcodes import OmniLink2MessageType
|
||||
from omni_pca.programs import Program
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
@ -108,7 +108,7 @@ class OmniData:
|
||||
areas: dict[int, AreaProperties] = field(default_factory=dict)
|
||||
thermostats: dict[int, ThermostatProperties] = field(default_factory=dict)
|
||||
buttons: dict[int, ButtonProperties] = field(default_factory=dict)
|
||||
programs: dict[int, ProgramProperties] = field(default_factory=dict)
|
||||
programs: dict[int, Program] = field(default_factory=dict)
|
||||
|
||||
zone_status: dict[int, ZoneStatus] = field(default_factory=dict)
|
||||
unit_status: dict[int, UnitStatus] = field(default_factory=dict)
|
||||
@ -138,6 +138,8 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
||||
port: int,
|
||||
controller_key: bytes,
|
||||
transport: str = "tcp",
|
||||
pca_path: str | None = None,
|
||||
pca_key: int = 0,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
@ -150,6 +152,8 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
||||
self._port = port
|
||||
self._controller_key = controller_key
|
||||
self._transport = transport
|
||||
self._pca_path = pca_path
|
||||
self._pca_key = pca_key
|
||||
self._client: OmniClient | None = None
|
||||
self._discovery_done = False
|
||||
self._discovered: OmniData | None = None
|
||||
@ -382,15 +386,70 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
||||
|
||||
async def _discover_programs(
|
||||
self, client: OmniClient
|
||||
) -> dict[int, ProgramProperties]:
|
||||
# Programs aren't reachable via the Properties opcode (the C# side
|
||||
# uses a separate request/reply pair), so we just return an empty
|
||||
# dict. We keep the field on OmniData so Phase B can plug in real
|
||||
# discovery the moment the library exposes it. AMBIGUITY: the spec
|
||||
# asks for "named programs" — there's no on-the-wire path for that
|
||||
# in v1.0 of omni_pca, so an empty mapping is the honest answer.
|
||||
_ = client, ProgramProperties
|
||||
return {}
|
||||
) -> dict[int, Program]:
|
||||
"""Enumerate defined panel programs.
|
||||
|
||||
Two sources, in order of preference:
|
||||
|
||||
1. ``CONF_PCA_PATH`` is configured → parse the .pca file and
|
||||
extract the programs block. Avoids streaming 1500 records on
|
||||
every entry refresh and works against an offline snapshot.
|
||||
2. Otherwise → enumerate over the wire:
|
||||
* v2 (TCP): ``client.iter_programs()`` drives UploadProgram
|
||||
with request_reason=1 ("next defined after slot").
|
||||
* v1 (UDP): adapter forwards to OmniClientV1.iter_programs(),
|
||||
a bare UploadPrograms stream ack-walked to EOD.
|
||||
|
||||
Both paths yield :class:`omni_pca.programs.Program` and skip
|
||||
empty slots. Errors are logged and swallowed — programs are
|
||||
non-critical discovery, so a partial list beats blocking setup.
|
||||
"""
|
||||
if self._pca_path:
|
||||
return await self._discover_programs_from_pca()
|
||||
out: dict[int, Program] = {}
|
||||
try:
|
||||
async for prog in client.iter_programs():
|
||||
if prog.slot is not None:
|
||||
out[prog.slot] = prog
|
||||
except (OmniConnectionError, RequestTimeoutError):
|
||||
raise
|
||||
except Exception:
|
||||
LOGGER.debug(
|
||||
"program enumeration interrupted (kept %d)", len(out), exc_info=True
|
||||
)
|
||||
return out
|
||||
|
||||
async def _discover_programs_from_pca(self) -> dict[int, Program]:
|
||||
"""Parse the configured .pca file and pull out its programs block.
|
||||
|
||||
Runs the disk I/O on the executor since :mod:`pca_file` does
|
||||
sync reads. Any failure (missing file, bad key, malformed block)
|
||||
is logged and downgraded to an empty dict — the rest of
|
||||
discovery still works.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from omni_pca.pca_file import parse_pca_file
|
||||
|
||||
try:
|
||||
data = await self.hass.async_add_executor_job(
|
||||
Path(self._pca_path).read_bytes
|
||||
)
|
||||
acct = await self.hass.async_add_executor_job(
|
||||
lambda: parse_pca_file(data, key=self._pca_key)
|
||||
)
|
||||
except Exception:
|
||||
LOGGER.warning(
|
||||
"failed to load programs from %s — falling back to empty list",
|
||||
self._pca_path, exc_info=True,
|
||||
)
|
||||
return {}
|
||||
out: dict[int, Program] = {}
|
||||
for prog in acct.programs:
|
||||
if prog.slot is None or prog.is_empty():
|
||||
continue
|
||||
out[prog.slot] = prog
|
||||
return out
|
||||
|
||||
async def _walk_properties(
|
||||
self,
|
||||
|
||||
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",
|
||||
"name": "HAI/Leviton Omni Panel",
|
||||
"version": "2026.5.11",
|
||||
"iot_class": "local_push",
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"codeowners": ["@rsp2k"],
|
||||
"requirements": ["omni-pca==2026.5.11"],
|
||||
"documentation": "https://git.supported.systems/warehack.ing/omni-pca",
|
||||
"issue_tracker": "https://git.supported.systems/warehack.ing/omni-pca/issues",
|
||||
"integration_type": "hub"
|
||||
"config_flow": true,
|
||||
"dependencies": ["http", "websocket_api"],
|
||||
"documentation": "https://hai-omni-pro-ii.warehack.ing/",
|
||||
"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(OmniLastEventSensor(coordinator))
|
||||
entities.append(OmniProgramsSensor(coordinator))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@ -261,3 +262,55 @@ class OmniLastEventSensor(
|
||||
if hasattr(ev, key):
|
||||
result[key] = getattr(ev, key)
|
||||
return result
|
||||
|
||||
|
||||
class OmniProgramsSensor(
|
||||
CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity
|
||||
):
|
||||
"""Diagnostic sensor exposing the panel's automation programs.
|
||||
|
||||
State value is the count of defined programs. The ``programs``
|
||||
attribute carries a list of per-program summaries — a stable,
|
||||
JSON-serializable view automations and template sensors can read.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.unique_id}-programs"
|
||||
self._attr_name = "Panel Programs"
|
||||
self._attr_device_info = coordinator.device_info
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
return len(self.coordinator.data.programs)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
from omni_pca.programs import ProgramType
|
||||
|
||||
summaries: list[dict[str, Any]] = []
|
||||
for slot in sorted(self.coordinator.data.programs):
|
||||
p = self.coordinator.data.programs[slot]
|
||||
try:
|
||||
type_name = ProgramType(p.prog_type).name
|
||||
except ValueError:
|
||||
type_name = f"UNKNOWN({p.prog_type})"
|
||||
summaries.append(
|
||||
{
|
||||
"slot": slot,
|
||||
"type": type_name,
|
||||
"cmd": p.cmd,
|
||||
"par": p.par,
|
||||
"pr2": p.pr2,
|
||||
"month": p.month,
|
||||
"day": p.day,
|
||||
"days": p.days,
|
||||
"hour": p.hour,
|
||||
"minute": p.minute,
|
||||
}
|
||||
)
|
||||
return {"programs": summaries}
|
||||
|
||||
@ -3,11 +3,14 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to your Omni panel",
|
||||
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. 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": {
|
||||
"host": "Host",
|
||||
"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": {
|
||||
@ -22,6 +25,8 @@
|
||||
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
|
||||
"invalid_auth": "The Controller Key was rejected by the panel.",
|
||||
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
|
||||
"pca_not_found": "No file at the supplied .pca path.",
|
||||
"pca_decode_failed": "Could not decode the .pca file. Check the per-install key.",
|
||||
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
|
||||
},
|
||||
"abort": {
|
||||
|
||||
@ -3,11 +3,14 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to your Omni panel",
|
||||
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. 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": {
|
||||
"host": "Host",
|
||||
"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": {
|
||||
@ -22,6 +25,8 @@
|
||||
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
|
||||
"invalid_auth": "The Controller Key was rejected by the panel.",
|
||||
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
|
||||
"pca_not_found": "No file at the supplied .pca path.",
|
||||
"pca_decode_failed": "Could not decode the .pca file. Check the per-install key.",
|
||||
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
|
||||
},
|
||||
"abort": {
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Load real `.pca` data into the mock
|
||||
|
||||
By default the mock serves a small synthetic state (five zones, four
|
||||
units, …). Point `OMNI_PCA_FIXTURE` at a real `.pca` file to make the
|
||||
mock indistinguishable on the wire from the source panel:
|
||||
|
||||
```bash
|
||||
# dev/.env (gitignored)
|
||||
OMNI_PCA_FIXTURE=/fixtures/path/to/Account.pca
|
||||
```
|
||||
|
||||
The host directory `/home/kdm/home-auto/HAI` is mounted at `/fixtures`
|
||||
inside the mock-panel container (see `docker-compose.yml`); adjust the
|
||||
mount if your `.pca` lives elsewhere.
|
||||
|
||||
The decryption key is auto-derived from a sibling `PCA01.CFG` if one
|
||||
exists (this is how PC Access exports usually ship). To override:
|
||||
|
||||
```bash
|
||||
OMNI_PCA_FIXTURE_KEY=0xC1A280B2 # or --pca-key on the command line
|
||||
```
|
||||
|
||||
`MockState.from_pca` populates zones, units, areas, thermostats,
|
||||
buttons, programs, model byte, and firmware version from the file —
|
||||
everything the HA integration reads at discovery time.
|
||||
|
||||
## Time-series & dashboards
|
||||
|
||||
`docker compose up -d` also brings up **InfluxDB v2** (port 8086) and
|
||||
**Grafana** (port 3000). Open Grafana at <http://localhost:3000>
|
||||
(login: `admin` / `$GRAFANA_PASSWORD` from `.env`) — the **Omni Pro II
|
||||
— Panel Overview** dashboard loads automatically, pre-provisioned from
|
||||
[`../grafana/`](../grafana/), the shipping bundle.
|
||||
|
||||
To wire HA → InfluxDB, append this block to `ha-config/configuration.yaml`
|
||||
(the directory is gitignored because it contains HA auth/state; the
|
||||
block lives in `../grafana/ha-snippet.yaml` for production users):
|
||||
|
||||
```yaml
|
||||
influxdb:
|
||||
api_version: 2
|
||||
host: influxdb
|
||||
port: 8086
|
||||
ssl: false
|
||||
verify_ssl: false
|
||||
token: dev-token-omnipca-9472-fixed-for-dev-stack
|
||||
organization: omni-pca
|
||||
bucket: ha
|
||||
precision: s
|
||||
tags_attributes: [event_type, event_class]
|
||||
include:
|
||||
domains: [alarm_control_panel, binary_sensor, climate, event, light, sensor, switch]
|
||||
entity_globs: ["*omni*"]
|
||||
```
|
||||
|
||||
Restart HA (`docker compose restart homeassistant`) after editing.
|
||||
Within 30 seconds, panels start populating with live data.
|
||||
|
||||
The dashboard JSON in `../grafana/provisioning/dashboards/` is the
|
||||
source of truth; edits in the Grafana UI don't persist (provisioned
|
||||
dashboards are read-only). Iterate by editing the JSON and running
|
||||
`docker compose restart grafana` — the provisioner picks up changes
|
||||
within ~30s.
|
||||
|
||||
To exercise dashboard panels against the mock, trigger HA actions
|
||||
(arm an area, toggle a light): the mock pushes the resulting
|
||||
`SystemEvent` back to HA, which ships it to InfluxDB, which Grafana
|
||||
queries. Each step takes <1s.
|
||||
|
||||
## Notes
|
||||
|
||||
- The HA container mounts `../custom_components/omni_pca/` read-only, so
|
||||
|
||||
|
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 |
@ -35,8 +35,14 @@ services:
|
||||
volumes:
|
||||
- ../src:/tmp/mock/src:ro
|
||||
- ./run_mock_panel.py:/tmp/mock/run_mock_panel.py:ro
|
||||
# Mount the captured .pca fixtures read-only so the mock can
|
||||
# optionally seed its state from a real export. Set
|
||||
# OMNI_PCA_FIXTURE in dev/.env (or pass on the command line) to
|
||||
# activate; left unset, the mock uses the hard-coded sample.
|
||||
- /home/kdm/home-auto/HAI:/fixtures:ro
|
||||
environment:
|
||||
PYTHONPATH: /tmp/mock/src
|
||||
OMNI_PCA_FIXTURE: ${OMNI_PCA_FIXTURE:-}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@ -96,6 +102,74 @@ services:
|
||||
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
|
||||
|
||||
@ -10,8 +10,10 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from omni_pca.mock_panel import (
|
||||
MockAreaState,
|
||||
@ -22,10 +24,55 @@ from omni_pca.mock_panel import (
|
||||
MockUnitState,
|
||||
MockZoneState,
|
||||
)
|
||||
from omni_pca.commands import Command
|
||||
from omni_pca.pca_file import KEY_EXPORT, parse_pca01_cfg
|
||||
from omni_pca.programs import Days, Program, ProgramType
|
||||
|
||||
DEFAULT_KEY_HEX = "000102030405060708090a0b0c0d0e0f"
|
||||
|
||||
|
||||
def _seed_programs() -> dict[int, bytes]:
|
||||
"""A handful of programs covering compact-form + clausal-chain shapes.
|
||||
|
||||
Slot 200..202 is a chain with a structured-AND condition whose Arg2
|
||||
is itself a Thermostat reference — exercises the Arg2-as-object
|
||||
editor controls.
|
||||
"""
|
||||
programs: dict[int, Program] = {
|
||||
12: Program(
|
||||
slot=12, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.UNIT_ON), pr2=1,
|
||||
hour=6, minute=0, days=int(Days.MONDAY | Days.FRIDAY),
|
||||
),
|
||||
42: Program(
|
||||
slot=42, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.UNIT_OFF), pr2=2,
|
||||
hour=22, minute=30, days=int(Days.SUNDAY),
|
||||
),
|
||||
# Chain: WHEN zone 1 not-ready, AND IF Thermostat 1.Temp >
|
||||
# Thermostat 2.Temp, THEN turn ON unit 3. The AND record is a
|
||||
# structured-OP comparison with Arg2 as a Thermostat reference.
|
||||
200: Program(
|
||||
slot=200, prog_type=int(ProgramType.WHEN),
|
||||
month=0x04, day=0x01,
|
||||
),
|
||||
201: Program(
|
||||
slot=201, prog_type=int(ProgramType.AND),
|
||||
cond=(4 << 8) | 4, # op=GT (4), arg1Type=Thermostat (4)
|
||||
cond2=1, # arg1Ix=1
|
||||
cmd=1, # arg1Field=current temp
|
||||
par=4, # arg2Type=Thermostat (4)
|
||||
pr2=2, # arg2Ix=2
|
||||
month=1, # arg2Field=current temp
|
||||
),
|
||||
202: Program(
|
||||
slot=202, prog_type=int(ProgramType.THEN),
|
||||
cmd=int(Command.UNIT_ON), pr2=3,
|
||||
),
|
||||
}
|
||||
return {slot: p.encode_wire_bytes() for slot, p in programs.items()}
|
||||
|
||||
|
||||
def _populated_state() -> MockState:
|
||||
"""A small but representative set of objects so HA shows real entities."""
|
||||
return MockState(
|
||||
@ -56,11 +103,50 @@ def _populated_state() -> MockState:
|
||||
3: MockButtonState(name="GOODNIGHT"),
|
||||
},
|
||||
user_codes={1: 1234, 2: 5678},
|
||||
programs=_seed_programs(),
|
||||
)
|
||||
|
||||
|
||||
async def _serve(host: str, port: int, key: bytes) -> None:
|
||||
panel = MockPanel(controller_key=key, state=_populated_state())
|
||||
def _key_for_pca(path: Path, override: int | None) -> int:
|
||||
"""Pick the decryption key for a .pca file.
|
||||
|
||||
Priority:
|
||||
1. Explicit override (CLI / env var).
|
||||
2. Per-installation key from a sibling ``PCA01.CFG`` (most common —
|
||||
PC Access ships each export with a matching config file).
|
||||
3. ``KEY_EXPORT`` as a last resort for vanilla exports.
|
||||
"""
|
||||
if override is not None:
|
||||
return override
|
||||
cfg_path = path.parent / "PCA01.CFG"
|
||||
if cfg_path.is_file():
|
||||
cfg = parse_pca01_cfg(cfg_path.read_bytes())
|
||||
logging.info("derived pca_key from %s: 0x%08X", cfg_path.name, cfg.pca_key)
|
||||
return cfg.pca_key
|
||||
logging.info("no sibling PCA01.CFG; falling back to KEY_EXPORT")
|
||||
return KEY_EXPORT
|
||||
|
||||
|
||||
def _state_from_pca(path: Path, key: int) -> MockState:
|
||||
"""Seed a MockState from a real .pca file."""
|
||||
state = MockState.from_pca(str(path), key=key)
|
||||
logging.info(
|
||||
"loaded %s: %d zones, %d units, %d areas, %d thermostats, %d programs",
|
||||
path.name,
|
||||
len(state.zones), len(state.units), len(state.areas),
|
||||
len(state.thermostats), len(state.programs),
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
async def _serve(
|
||||
host: str, port: int, key: bytes, pca: Path | None, pca_key: int | None,
|
||||
) -> None:
|
||||
if pca is not None:
|
||||
state = _state_from_pca(pca, _key_for_pca(pca, pca_key))
|
||||
else:
|
||||
state = _populated_state()
|
||||
panel = MockPanel(controller_key=key, state=state)
|
||||
async with panel.serve(host=host, port=port) as (bound_host, bound_port):
|
||||
logging.info("MockPanel listening on %s:%d", bound_host, bound_port)
|
||||
logging.info("Use this controller key in HA: %s", key.hex())
|
||||
@ -86,6 +172,23 @@ def main() -> int:
|
||||
default=DEFAULT_KEY_HEX,
|
||||
help="32 hex chars; default is the docker-compose value",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pca",
|
||||
default=os.environ.get("OMNI_PCA_FIXTURE"),
|
||||
help="Path to a .pca file. When supplied, the mock seeds its "
|
||||
"state from this file instead of the hard-coded sample. "
|
||||
"Can also be set via OMNI_PCA_FIXTURE.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pca-key",
|
||||
type=lambda s: int(s, 0),
|
||||
default=(
|
||||
int(os.environ["OMNI_PCA_FIXTURE_KEY"], 0)
|
||||
if os.environ.get("OMNI_PCA_FIXTURE_KEY") else None
|
||||
),
|
||||
help="32-bit decryption key for --pca. Default: auto-derive from "
|
||||
"a sibling PCA01.CFG, or fall back to KEY_EXPORT.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
@ -104,7 +207,14 @@ def main() -> int:
|
||||
file=sys.stderr)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -298,6 +298,72 @@ async def _take_screenshots(ha_url: str, token: str, outdir: Path) -> list[Path]
|
||||
await shot("06-developer-states.png",
|
||||
"/developer-tools/state", wait_secs=4.0)
|
||||
|
||||
# The side panel registered by panel_custom (websocket.py:
|
||||
# async_register_side_panel). If pointed at a real panel the
|
||||
# program list is whatever the homeowner has authored; against
|
||||
# the mock it's whatever ``run_mock_panel.py`` seeded. We
|
||||
# deliberately do NOT write programs from here because the
|
||||
# screenshot script may be aimed at real hardware.
|
||||
await shot("08-side-panel-programs.png",
|
||||
"/omni-panel-programs", wait_secs=6.0)
|
||||
# Helper: locate the omni-panel-programs element regardless of
|
||||
# what shadow-DOM path HA's panel host wraps it in. Recursive
|
||||
# walk because partial-panel-resolver / hui-view / etc. can
|
||||
# vary between HA versions.
|
||||
find_panel_js = """
|
||||
(() => {
|
||||
function find(root, depth=0) {
|
||||
if (!root || depth > 15) return null;
|
||||
if (root.tagName === 'OMNI-PANEL-PROGRAMS') return root;
|
||||
for (const k of Array.from(root.children || [])) {
|
||||
const r = find(k, depth+1);
|
||||
if (r) return r;
|
||||
}
|
||||
if (root.shadowRoot) {
|
||||
const r = find(root.shadowRoot, depth+1);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return find(document.body);
|
||||
})()
|
||||
"""
|
||||
|
||||
# Click into the first program row to capture the detail panel.
|
||||
try:
|
||||
await page.evaluate(f"""() => {{
|
||||
const panel = {find_panel_js};
|
||||
if (!panel) {{ console.warn('omni panel not found'); return; }}
|
||||
const row = panel.shadowRoot.querySelector('.row');
|
||||
if (row) row.click();
|
||||
}}""")
|
||||
await page.wait_for_timeout(800)
|
||||
except Exception as e:
|
||||
print(f" click-into-row warning: {e}")
|
||||
# Re-shoot WITHOUT a navigate (page.goto would reset selection).
|
||||
await page.screenshot(path=str(outdir / "09-side-panel-detail.png"),
|
||||
full_page=False)
|
||||
shots.append(outdir / "09-side-panel-detail.png")
|
||||
print(f" → 09-side-panel-detail.png (in-place)")
|
||||
|
||||
# Click "Edit" to capture the editor mode.
|
||||
try:
|
||||
await page.evaluate(f"""() => {{
|
||||
const panel = {find_panel_js};
|
||||
if (!panel) {{ console.warn('omni panel not found'); return; }}
|
||||
const buttons = panel.shadowRoot.querySelectorAll('.detail button');
|
||||
for (const b of buttons) {{
|
||||
if (b.textContent.trim() === 'Edit') {{ b.click(); break; }}
|
||||
}}
|
||||
}}""")
|
||||
await page.wait_for_timeout(800)
|
||||
except Exception as e:
|
||||
print(f" click-edit warning: {e}")
|
||||
await page.screenshot(path=str(outdir / "10-side-panel-editor.png"),
|
||||
full_page=False)
|
||||
shots.append(outdir / "10-side-panel-editor.png")
|
||||
print(f" → 10-side-panel-editor.png (in-place)")
|
||||
|
||||
await browser.close()
|
||||
return shots
|
||||
|
||||
|
||||
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]
|
||||
name = "omni-pca"
|
||||
version = "2026.5.11"
|
||||
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)."
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@ -24,12 +24,20 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
cli = ["rich>=13.9.0", "typer>=0.15.0"]
|
||||
# astral provides sunrise/sunset computation for the program engine's
|
||||
# AT_SUNRISE / AT_SUNSET TIMED-program sentinels. Pure Python, MIT.
|
||||
# Pinned to 2.2 for compatibility with Home Assistant (which pins it
|
||||
# to exactly that version) so the `ha` group still resolves.
|
||||
engine = ["astral>=2.2,<3"]
|
||||
|
||||
[project.scripts]
|
||||
omni-pca = "omni_pca.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://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]
|
||||
requires = ["uv_build>=0.11.8,<0.12.0"]
|
||||
|
||||
@ -2,9 +2,34 @@
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
from .programs import (
|
||||
Condition,
|
||||
ConditionFamily,
|
||||
Days,
|
||||
MiscConditional,
|
||||
Program,
|
||||
ProgramCond,
|
||||
ProgramType,
|
||||
TimeKind,
|
||||
decode_program_table,
|
||||
iter_defined,
|
||||
)
|
||||
|
||||
try:
|
||||
__version__ = version("omni-pca")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.0.0+unknown"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__all__ = [
|
||||
"Condition",
|
||||
"ConditionFamily",
|
||||
"Days",
|
||||
"MiscConditional",
|
||||
"Program",
|
||||
"ProgramCond",
|
||||
"ProgramType",
|
||||
"TimeKind",
|
||||
"__version__",
|
||||
"decode_program_table",
|
||||
"iter_defined",
|
||||
]
|
||||
|
||||
@ -607,6 +607,92 @@ class OmniClient:
|
||||
"""
|
||||
await self.execute_command(Command.CLEAR_MESSAGE, parameter2=index)
|
||||
|
||||
# ---- program enumeration --------------------------------------------
|
||||
|
||||
async def iter_programs(self) -> AsyncIterator["Program"]:
|
||||
"""Stream every defined program from the panel.
|
||||
|
||||
v2 has no bulk "send all programs" opcode; instead the panel
|
||||
exposes an iterator semantic via ``UploadProgram`` with
|
||||
``request_reason=1`` ("next defined after this slot"). We seed
|
||||
with slot 0 and follow each reply's ``ProgramNumber`` back into
|
||||
the next request until the panel sends EOD.
|
||||
|
||||
Mirrors the C# ReadConfig loop at ``clsHAC.OL2ReadConfigProcessProgramData``
|
||||
(clsHAC.cs:5323-5332) and the seed call at clsHAC.cs:4985.
|
||||
|
||||
Yields decoded :class:`omni_pca.programs.Program` instances, one
|
||||
per defined slot in ascending slot order. Empty slots are
|
||||
skipped by the panel — the iterator only sees defined programs.
|
||||
"""
|
||||
from .programs import Program # local import: avoids cycle in __init__
|
||||
slot = 0
|
||||
while True:
|
||||
payload = bytes([(slot >> 8) & 0xFF, slot & 0xFF, 1])
|
||||
reply = await self._conn.request(
|
||||
OmniLink2MessageType.UploadProgram, payload
|
||||
)
|
||||
if reply.opcode == int(OmniLink2MessageType.EOD):
|
||||
return
|
||||
if reply.opcode != int(OmniLink2MessageType.ProgramData):
|
||||
raise OmniConnectionError(
|
||||
f"unexpected opcode {reply.opcode} during UploadProgram iteration "
|
||||
f"(expected {int(OmniLink2MessageType.ProgramData)})"
|
||||
)
|
||||
if len(reply.payload) < 2 + 14:
|
||||
raise OmniConnectionError(
|
||||
f"ProgramData payload too short ({len(reply.payload)} bytes)"
|
||||
)
|
||||
slot = (reply.payload[0] << 8) | reply.payload[1]
|
||||
yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot)
|
||||
|
||||
async def download_program(self, slot: int, program: "Program") -> None:
|
||||
"""Write ``program`` into the panel at the given 1-based ``slot``.
|
||||
|
||||
Wire opcode: 8 (DownloadProgram) per clsOLMsg2DownloadProgram
|
||||
(clsHAC.cs:1133-1140). Payload is the same 2-byte BE slot
|
||||
number + 14-byte wire body the UploadProgram reply uses, so
|
||||
``Program.encode_wire_bytes`` produces the right thing.
|
||||
|
||||
The panel responds with ``Ack`` on success; we raise
|
||||
:class:`CommandFailedError` on ``Nak`` and
|
||||
:class:`OmniConnectionError` for any other opcode.
|
||||
|
||||
Writing an all-zero body clears the slot (treats the slot as
|
||||
``ProgramType.FREE``) — matches the panel's behaviour for an
|
||||
empty record.
|
||||
"""
|
||||
if not 1 <= slot <= 1500:
|
||||
raise ValueError(f"program slot {slot} out of range 1..1500")
|
||||
body = program.encode_wire_bytes()
|
||||
if len(body) != 14:
|
||||
raise ValueError(
|
||||
f"encoded program body must be 14 bytes, got {len(body)}"
|
||||
)
|
||||
payload = bytes([(slot >> 8) & 0xFF, slot & 0xFF]) + body
|
||||
reply = await self._conn.request(
|
||||
OmniLink2MessageType.DownloadProgram, payload
|
||||
)
|
||||
if reply.opcode == int(OmniLink2MessageType.Nak):
|
||||
raise CommandFailedError(
|
||||
f"panel NAK'd DownloadProgram for slot {slot}"
|
||||
)
|
||||
if reply.opcode != int(OmniLink2MessageType.Ack):
|
||||
raise OmniConnectionError(
|
||||
f"unexpected opcode {reply.opcode} after DownloadProgram "
|
||||
f"(expected {int(OmniLink2MessageType.Ack)})"
|
||||
)
|
||||
|
||||
async def clear_program(self, slot: int) -> None:
|
||||
"""Convenience: clear a program slot by writing an all-zero body.
|
||||
|
||||
On the panel this marks the slot as :class:`ProgramType.FREE`,
|
||||
same as ``DownloadProgram(slot, all-zero)``.
|
||||
"""
|
||||
from .programs import Program, ProgramType
|
||||
empty = Program(slot=slot, prog_type=int(ProgramType.FREE))
|
||||
await self.download_program(slot, empty)
|
||||
|
||||
# ---- helpers (status) -----------------------------------------------
|
||||
|
||||
async def _fetch_status_range(
|
||||
@ -691,10 +777,22 @@ class OmniClient:
|
||||
)
|
||||
|
||||
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,
|
||||
lambda r: (r.index, r.name) if isinstance(r, AreaProperties) else None,
|
||||
)
|
||||
if named:
|
||||
return named
|
||||
return {i: f"Area {i}" for i in range(1, 9)}
|
||||
|
||||
async def subscribe(
|
||||
self, callback: Callable[[Message], Awaitable[None]]
|
||||
|
||||
@ -39,6 +39,19 @@ The reply (ExecuteSecurityCommandResponse, opcode 75) carries a single
|
||||
status byte at ``payload[0]`` whose values are listed in
|
||||
``enuSecurityCommnadResponse.cs`` — :class:`SecurityCommandResponse`
|
||||
mirrors that enum.
|
||||
|
||||
Cross-references (HAI OmniPro II Owner's Manual):
|
||||
Chapter "CONTROL" (pca-re/docs/owner_manual/05_CONTROL/) documents
|
||||
the user-facing keypad keys that map to these commands —
|
||||
e.g. UNIT_ON/OFF + UNIT_LEVEL are what a homeowner triggers via
|
||||
the "Control → 1 (Unit)" menu, SHOW_MESSAGE_WITH_BEEP is
|
||||
invoked from "Control → Message → Show".
|
||||
Chapter "Scene Commands" (06_Scene_Commands/) covers
|
||||
COMPOSE_SCENE and the per-room scene-recall path.
|
||||
Chapter "SECURITY SYSTEM OPERATION" (03_SECURITY_SYSTEM_OPERATION/)
|
||||
documents what each SecurityMode byte (0-6) means at the user
|
||||
level — the arming menu, entry/exit-delay semantics, and which
|
||||
zones each mode arms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -29,6 +29,16 @@ References (decompiled C# source):
|
||||
— bit-mask classifier we mirror below
|
||||
clsText.cs:1693-1911 (GetEventText)
|
||||
— per-category sub-field extraction
|
||||
|
||||
Cross-references (HAI OmniPro II Installation Manual):
|
||||
APPENDIX A — CONTACT ID REPORTING FORMAT (p68): the Contact ID
|
||||
event codes the panel transmits to a central monitoring station
|
||||
for each :class:`AlarmKind`. The class names below mirror those
|
||||
codes one-for-one. (pca-re/docs/manuals/installation_manual/
|
||||
10_APPENDIX_A_CONTACT_ID_REPORTING_FORMAT/)
|
||||
APPENDIX B — DIGITAL COMMUNICATOR CODE SHEET (p69-73): the 4/2 and
|
||||
3/1 reporting-format code tables. Useful when correlating a
|
||||
SystemEvents word with what a central station would see. (12_…)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -31,6 +31,16 @@ References:
|
||||
clsOL2MsgRequestStatus.cs / clsOL2MsgStatus.cs
|
||||
clsOL2MsgRequestExtendedStatus.cs / clsOL2MsgExtendedStatus.cs
|
||||
clsOLMsgSystemEvents.cs
|
||||
|
||||
Cross-references (HAI OmniPro II Installation Manual):
|
||||
*INSTALLER SETUP* (pca-re/docs/manuals/installation_manual/
|
||||
04_INSTALLER_SETUP/) is the human-side counterpart to the data
|
||||
this mock serves: the panel's response to a RequestProperties /
|
||||
RequestStatus would on real hardware reflect whatever an installer
|
||||
typed into SETUP CONTROL / SETUP ZONES / SETUP AREAS / SETUP
|
||||
TEMPERATURES / SETUP MISC for that object. The mock just makes up
|
||||
plausible bytes; production fixtures should pre-seed the
|
||||
``MockPanel`` state to match a known SETUP configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -42,6 +52,7 @@ import secrets
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from .commands import Command
|
||||
from .crypto import (
|
||||
@ -50,8 +61,14 @@ from .crypto import (
|
||||
derive_session_key,
|
||||
encrypt_message_payload,
|
||||
)
|
||||
from .message import Message, MessageCrcError, MessageFormatError, encode_v2
|
||||
from .opcodes import OmniLink2MessageType, PacketType
|
||||
from .message import (
|
||||
Message,
|
||||
MessageCrcError,
|
||||
MessageFormatError,
|
||||
encode_v1,
|
||||
encode_v2,
|
||||
)
|
||||
from .opcodes import OmniLink2MessageType, OmniLinkMessageType, PacketType
|
||||
from .packet import Packet
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
@ -115,6 +132,8 @@ class MockUnitState:
|
||||
name: str = ""
|
||||
state: int = 0 # 0=off, 1=on, 100..200=brightness percent (raw Omni)
|
||||
time_remaining: int = 0
|
||||
unit_type: int = 1 # enuOL2UnitType (1=Standard); see clsUnit.cs:928 family
|
||||
areas: int = 0x01 # bitmask of area membership (bit 0 = area 1, ...)
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -127,6 +146,19 @@ class MockAreaState:
|
||||
entry_timer: int = 0
|
||||
exit_timer: int = 0
|
||||
alarms: int = 0
|
||||
entry_delay: int = 30 # seconds; configured grace period after a door opens
|
||||
exit_delay: int = 60 # seconds; configured grace period after arming
|
||||
enabled: bool = True # whether this area is part of NumAreasUsed
|
||||
# Boolean configuration flags — not on the wire (the Properties
|
||||
# reply doesn't carry them); kept here for snapshots from .pca and
|
||||
# any future SetupData-aware code paths.
|
||||
entry_chime: bool = False
|
||||
quick_arm: bool = False
|
||||
auto_bypass: bool = False
|
||||
all_on_for_alarm: bool = False
|
||||
trouble_beep: bool = False
|
||||
perimeter_chime: bool = False
|
||||
audible_exit_delay: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -139,6 +171,9 @@ class MockZoneState:
|
||||
arming_state: int = 0 # 0=disarmed, 16=armed, 32=bypassed, 48=auto-bypassed
|
||||
is_bypassed: bool = False
|
||||
loop: int = 0 # analog loop reading
|
||||
zone_type: int = 0 # raw enuZoneType byte (0=EntryExit default)
|
||||
area: int = 1 # 1-based area assignment
|
||||
options: int = 0 # raw ZoneOptions bitmask (SetupData; panel default 4)
|
||||
|
||||
@property
|
||||
def status_byte(self) -> int:
|
||||
@ -183,6 +218,8 @@ class MockThermostatState:
|
||||
outdoor_temperature_raw: int = 0
|
||||
horc_status: int = 0
|
||||
status: int = 1 # 1 = communicating with the panel
|
||||
thermostat_type: int = 1 # raw enuThermostatType (1=AutoHeatCool)
|
||||
areas: int = 0x01 # 8-bit area-membership bitmask
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -214,6 +251,12 @@ class MockState:
|
||||
# matched code_index in the area's last_user field on success.
|
||||
user_codes: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
# Program table — slot number (1-based) → raw 14-byte wire body.
|
||||
# Wire layout (no Mon/Day swap) so a v2 ``ProgramData`` reply is a
|
||||
# direct copy. Slots not present in this dict respond with 14 zero
|
||||
# bytes, matching real-panel "unused slot" behavior.
|
||||
programs: dict[int, bytes] = field(default_factory=dict)
|
||||
|
||||
# SystemStatus snapshot. Defaults: time set, battery good, no alarms.
|
||||
time_set: bool = True
|
||||
year: int = 26 # 2026
|
||||
@ -243,6 +286,126 @@ class MockState:
|
||||
self.thermostats = _promote_dict(self.thermostats, MockThermostatState)
|
||||
self.buttons = _promote_dict(self.buttons, MockButtonState)
|
||||
|
||||
@classmethod
|
||||
def from_pca(
|
||||
cls,
|
||||
path_or_bytes: str | bytes,
|
||||
key: int,
|
||||
**overrides: Any,
|
||||
) -> MockState:
|
||||
"""Build a MockState seeded from a real .pca file.
|
||||
|
||||
Populated from the .pca:
|
||||
|
||||
* ``model_byte`` + ``firmware_*`` — drive SystemInformation replies
|
||||
so a connected client sees the panel the .pca came from.
|
||||
* ``programs`` — every non-empty Program record from the 1500-slot
|
||||
table, encoded back to wire bytes so UploadProgram /
|
||||
UploadPrograms serve them exactly as a real panel would.
|
||||
* ``zones`` / ``units`` / ``areas`` / ``thermostats`` / ``buttons``
|
||||
— populated with the names from the .pca's Names section.
|
||||
``MockZoneState`` entries additionally carry ``zone_type``
|
||||
(the raw ``enuZoneType`` byte from the SetupData installer
|
||||
section), so the mock's Properties replies categorise zones
|
||||
as security / temperature / humidity matching the source
|
||||
panel. Other SetupData-resident fields (per-zone area
|
||||
assignment, unit type, thermostat type, exit/entry delays,
|
||||
options bitmasks) aren't extracted yet — those default to 0.
|
||||
|
||||
``user_codes`` is not seeded — the .pca only stores code *names*,
|
||||
not the PIN values; the panel keeps PINs in SetupData. Override
|
||||
explicitly if a test needs them.
|
||||
|
||||
Anything else uses MockState defaults. Pass kwargs to override
|
||||
any seeded field.
|
||||
|
||||
``key=0`` only works for files where the export keystream was
|
||||
already applied (e.g., the result of ``decrypt_pca_bytes`` with
|
||||
the same key); use ``KEY_EXPORT`` (391549495) for unmodified
|
||||
PC Access exports.
|
||||
"""
|
||||
from .pca_file import parse_pca_file
|
||||
|
||||
acct = parse_pca_file(path_or_bytes, key=key)
|
||||
programs = {
|
||||
p.slot: p.encode_wire_bytes()
|
||||
for p in acct.programs
|
||||
if p.slot is not None and not p.is_empty()
|
||||
}
|
||||
# Union of named areas and the "in-use" range from NumAreasUsed —
|
||||
# an area is part of the install if either it has a user-assigned
|
||||
# name OR the installer told the panel to use it. Most homes have
|
||||
# a single unnamed area 1 + num_areas_used=1, which produces
|
||||
# areas={1: MockAreaState(name="", enabled=True, ...)}.
|
||||
in_use_areas = set(acct.area_names) | set(
|
||||
range(1, acct.num_areas_used + 1)
|
||||
)
|
||||
defaults: dict[str, Any] = {
|
||||
"model_byte": acct.model,
|
||||
"firmware_major": acct.firmware_major,
|
||||
"firmware_minor": acct.firmware_minor,
|
||||
"firmware_revision": acct.firmware_revision,
|
||||
"programs": programs,
|
||||
"zones": {
|
||||
i: MockZoneState(
|
||||
name=n,
|
||||
zone_type=acct.zone_types.get(i, 0),
|
||||
area=acct.zone_areas.get(i, 1),
|
||||
options=acct.zone_options.get(i, 0),
|
||||
)
|
||||
for i, n in acct.zone_names.items()
|
||||
},
|
||||
"units": {
|
||||
i: MockUnitState(
|
||||
name=n,
|
||||
unit_type=acct.unit_types.get(i, 1),
|
||||
# 0xFF (uninitialised → "all areas") and 0x01 are the
|
||||
# two common values. If the panel doesn't have a
|
||||
# specific restriction, fall back to "area 1 only"
|
||||
# so HA's area-filtering produces sensible defaults.
|
||||
areas=(acct.unit_areas.get(i, 0x01) or 0x01)
|
||||
if acct.unit_areas.get(i, 0x01) != 0xFF
|
||||
else 0x01,
|
||||
)
|
||||
for i, n in acct.unit_names.items()
|
||||
},
|
||||
"areas": {
|
||||
i: MockAreaState(
|
||||
name=acct.area_names.get(i, ""),
|
||||
entry_delay=acct.area_entry_delays.get(i, 30),
|
||||
exit_delay=acct.area_exit_delays.get(i, 60),
|
||||
enabled=i <= acct.num_areas_used,
|
||||
entry_chime=acct.area_entry_chime.get(i, False),
|
||||
quick_arm=acct.area_quick_arm.get(i, False),
|
||||
auto_bypass=acct.area_auto_bypass.get(i, False),
|
||||
all_on_for_alarm=acct.area_all_on_for_alarm.get(i, False),
|
||||
trouble_beep=acct.area_trouble_beep.get(i, False),
|
||||
perimeter_chime=acct.area_perimeter_chime.get(i, False),
|
||||
audible_exit_delay=acct.area_audible_exit_delay.get(i, False),
|
||||
)
|
||||
for i in sorted(in_use_areas)
|
||||
},
|
||||
"thermostats": {
|
||||
i: MockThermostatState(
|
||||
name=n,
|
||||
thermostat_type=acct.thermostat_types.get(i, 1),
|
||||
# 0xFF (uninitialised → "all areas") normalises to
|
||||
# area 1 only, consistent with the unit-area handling.
|
||||
areas=(
|
||||
0x01
|
||||
if acct.thermostat_areas.get(i, 0xFF) == 0xFF
|
||||
else acct.thermostat_areas[i]
|
||||
),
|
||||
)
|
||||
for i, n in acct.thermostat_names.items()
|
||||
},
|
||||
"buttons": {
|
||||
i: MockButtonState(name=n) for i, n in acct.button_names.items()
|
||||
},
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return cls(**defaults)
|
||||
|
||||
# ---- name-bytes helpers (kept for back-compat with old callers) -----
|
||||
|
||||
def zone_name_bytes(self, idx: int) -> bytes:
|
||||
@ -318,6 +481,12 @@ class MockPanel:
|
||||
self._active_writer: asyncio.StreamWriter | None = None
|
||||
self._active_session_key: bytes | None = None
|
||||
self._push_tasks: set[asyncio.Task[None]] = set()
|
||||
# v1 UploadNames cursor: index into self._v1_name_stream() while a
|
||||
# streaming download is in flight, ``None`` when no stream active.
|
||||
self._upload_names_cursor: int | None = None
|
||||
# v1 UploadPrograms cursor: index into self._v1_program_stream() while
|
||||
# a streaming download is in flight, ``None`` when no stream active.
|
||||
self._upload_programs_cursor: int | None = None
|
||||
|
||||
# -------- public observables (handy in tests) --------
|
||||
|
||||
@ -451,6 +620,19 @@ class MockPanel:
|
||||
if not cont:
|
||||
break
|
||||
|
||||
elif pkt_type is PacketType.OmniLinkMessage:
|
||||
# v1 wire dialect — UDP-only panels speak this on the
|
||||
# wire. We accept it over TCP too so the same mock
|
||||
# server can fixture both transports for tests.
|
||||
if session_key is None:
|
||||
_log.debug("mock panel: v1 message before secure session")
|
||||
break
|
||||
cont = await self._handle_encrypted_message_v1(
|
||||
reader, seq, session_key, writer
|
||||
)
|
||||
if not cont:
|
||||
break
|
||||
|
||||
else:
|
||||
_log.debug("mock panel: unhandled packet type %s", pkt_type.name)
|
||||
break
|
||||
@ -578,6 +760,68 @@ class MockPanel:
|
||||
self._schedule_event_push(push_words, session_key, writer)
|
||||
return True
|
||||
|
||||
async def _handle_encrypted_message_v1(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
client_seq: int,
|
||||
session_key: bytes,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> bool:
|
||||
"""v1 counterpart of :meth:`_handle_encrypted_message`.
|
||||
|
||||
Reads, decrypts, decodes a v1 inner message (StartChar 0x5A),
|
||||
dispatches via :meth:`_dispatch_v1`, and writes the reply back
|
||||
wrapped in an ``OmniLinkMessage`` (16) outer packet.
|
||||
"""
|
||||
first_block = await _read_exact(reader, BLOCK_SIZE)
|
||||
if first_block is None:
|
||||
return False
|
||||
first_plain = decrypt_message_payload(first_block, client_seq, session_key)
|
||||
# first_plain[0] = StartChar (0x5A), first_plain[1] = MessageLength
|
||||
msg_length = first_plain[1]
|
||||
extra_needed = max(0, msg_length + 4 - BLOCK_SIZE)
|
||||
rem = (-extra_needed) % BLOCK_SIZE
|
||||
extra_aligned = extra_needed + rem
|
||||
ciphertext = first_block
|
||||
if extra_aligned > 0:
|
||||
extra = await _read_exact(reader, extra_aligned)
|
||||
if extra is None:
|
||||
return False
|
||||
ciphertext = first_block + extra
|
||||
plaintext = decrypt_message_payload(ciphertext, client_seq, session_key)
|
||||
|
||||
try:
|
||||
inner = Message.decode(plaintext)
|
||||
except MessageCrcError:
|
||||
_log.debug("mock panel: v1 inner message CRC failure")
|
||||
await self._send_v1_reply(
|
||||
client_seq, _build_v1_nak(0), session_key, writer
|
||||
)
|
||||
return True
|
||||
except MessageFormatError as exc:
|
||||
_log.debug("mock panel: malformed v1 inner message: %s", exc)
|
||||
return False
|
||||
|
||||
opcode = inner.opcode
|
||||
self._last_request_opcode = opcode
|
||||
try:
|
||||
opcode_name = OmniLinkMessageType(opcode).name
|
||||
except ValueError:
|
||||
opcode_name = f"Unknown({opcode})"
|
||||
_log.debug(
|
||||
"mock panel: v1 dispatch opcode=%s payload=%d bytes",
|
||||
opcode_name, len(inner.payload),
|
||||
)
|
||||
|
||||
reply, push_words = self._dispatch_v1(opcode, inner.payload)
|
||||
await self._send_v1_reply(client_seq, reply, session_key, writer)
|
||||
# v1 push events use opcode 35 instead of 55; the existing
|
||||
# _schedule_event_push helper is v2-shaped and would emit a
|
||||
# frame the v1 client can't parse. Skip pushes on v1 for now
|
||||
# -- streaming UploadNames is the dominant flow we care about.
|
||||
_ = push_words
|
||||
return True
|
||||
|
||||
def _dispatch_v2(
|
||||
self, opcode: int, payload: bytes
|
||||
) -> tuple[Message, tuple[int, ...]]:
|
||||
@ -603,8 +847,74 @@ class MockPanel:
|
||||
return self._reply_extended_status(payload), ()
|
||||
if opcode == OmniLink2MessageType.AcknowledgeAlerts:
|
||||
return _build_ack(), ()
|
||||
if opcode == OmniLink2MessageType.UploadProgram:
|
||||
return self._reply_program_data(payload), ()
|
||||
if opcode == OmniLink2MessageType.DownloadProgram:
|
||||
return self._handle_download_program(payload), ()
|
||||
return _build_nak(opcode), ()
|
||||
|
||||
def _handle_download_program(self, payload: bytes) -> Message:
|
||||
"""Write the 14-byte program body at ``payload[2:16]`` to slot
|
||||
``payload[0..1]`` (BE u16). Acks on success, NAKs on bad shape.
|
||||
|
||||
Mirrors :meth:`_reply_program_data` in reverse — same wire
|
||||
framing as the UploadProgram reply, just inbound. Writing an
|
||||
all-zero body removes the slot from ``state.programs`` so
|
||||
subsequent UploadProgram requests treat it as undefined
|
||||
(matches real-panel behaviour for cleared slots).
|
||||
"""
|
||||
if len(payload) < 2 + 14:
|
||||
return _build_nak(OmniLink2MessageType.DownloadProgram)
|
||||
number = (payload[0] << 8) | payload[1]
|
||||
if not 1 <= number <= 1500:
|
||||
return _build_nak(OmniLink2MessageType.DownloadProgram)
|
||||
body = bytes(payload[2 : 2 + 14])
|
||||
if body == b"\x00" * 14:
|
||||
self.state.programs.pop(number, None)
|
||||
else:
|
||||
self.state.programs[number] = body
|
||||
return _build_ack()
|
||||
|
||||
def _reply_program_data(self, payload: bytes) -> Message:
|
||||
"""v2 program read — single-slot OR iterator.
|
||||
|
||||
Request payload: ``[number_hi, number_lo, request_reason]`` (3 bytes
|
||||
per ``clsOL2MsgUploadProgram``). Reply payload: ``[number_hi,
|
||||
number_lo] + raw_14_byte_body`` per ``clsOL2MsgProgramData``.
|
||||
|
||||
``request_reason`` semantics mirror the C# ReadConfig flow at
|
||||
clsHAC.cs:4985 / 5331:
|
||||
|
||||
0 → return the exact requested slot (zero body if undefined).
|
||||
1 → "next defined": return the lowest slot strictly greater
|
||||
than the requested number. If none, return EOD. The
|
||||
C# client iterates by feeding back each received slot
|
||||
number with reason=1 until EOD.
|
||||
|
||||
Any other reason value is treated as reason=0 (we have no other
|
||||
captures showing alternate semantics).
|
||||
"""
|
||||
if len(payload) < 2:
|
||||
return _build_nak(OmniLink2MessageType.UploadProgram)
|
||||
number = (payload[0] << 8) | payload[1]
|
||||
reason = payload[2] if len(payload) >= 3 else 0
|
||||
if reason == 1:
|
||||
# "Next defined after this slot." If start_slot=0 (initial
|
||||
# call) and no programs are defined, we fall straight to EOD.
|
||||
next_slot = min(
|
||||
(s for s in self.state.programs if s > number), default=None
|
||||
)
|
||||
if next_slot is None:
|
||||
return encode_v2(OmniLink2MessageType.EOD, b"")
|
||||
number = next_slot
|
||||
body = self.state.programs.get(number, b"\x00" * 14)
|
||||
if len(body) != 14:
|
||||
return _build_nak(OmniLink2MessageType.UploadProgram)
|
||||
return encode_v2(
|
||||
OmniLink2MessageType.ProgramData,
|
||||
bytes([(number >> 8) & 0xFF, number & 0xFF]) + body,
|
||||
)
|
||||
|
||||
# -------- reply builders (byte-exact per clsOL2Msg*.cs) --------
|
||||
|
||||
def _reply_system_information(self) -> Message:
|
||||
@ -713,9 +1023,9 @@ class MockPanel:
|
||||
index & 0xFF,
|
||||
zone.status_byte if zone else 0,
|
||||
zone.loop if zone else 0,
|
||||
0, # Type: EntryExit
|
||||
1, # Area: default to area 1
|
||||
0, # Options
|
||||
zone.zone_type if zone else 0,
|
||||
zone.area if zone else 1,
|
||||
zone.options if zone else 0,
|
||||
]
|
||||
)
|
||||
+ self.state.zone_name_bytes(index)
|
||||
@ -737,11 +1047,11 @@ class MockPanel:
|
||||
unit.state if unit else 0,
|
||||
(unit.time_remaining >> 8) & 0xFF if unit else 0,
|
||||
unit.time_remaining & 0xFF if unit else 0,
|
||||
1, # UnitType: Standard
|
||||
unit.unit_type if unit else 1,
|
||||
]
|
||||
)
|
||||
+ self.state.unit_name_bytes(index)
|
||||
+ bytes([0, 1]) # reserved + UnitAreas (default area 1)
|
||||
+ bytes([0, (unit.areas if unit else 0x01)])
|
||||
)
|
||||
return encode_v2(OmniLink2MessageType.Properties, body)
|
||||
|
||||
@ -773,7 +1083,7 @@ class MockPanel:
|
||||
t.system_mode if t else 0,
|
||||
t.fan_mode if t else 0,
|
||||
t.hold_mode if t else 0,
|
||||
1, # thermostat type: AUTO_HEAT_COOL
|
||||
t.thermostat_type if t else 1,
|
||||
]
|
||||
)
|
||||
+ self.state.thermostat_name_bytes(index)
|
||||
@ -814,9 +1124,9 @@ class MockPanel:
|
||||
area.alarms if area else 0,
|
||||
area.entry_timer if area else 0,
|
||||
area.exit_timer if area else 0,
|
||||
1, # Enabled
|
||||
60, # ExitDelay (s)
|
||||
30, # EntryDelay (s)
|
||||
(1 if (area and area.enabled) else 0),
|
||||
area.exit_delay if area else 60,
|
||||
area.entry_delay if area else 30,
|
||||
]
|
||||
)
|
||||
+ self.state.area_name_bytes(index)
|
||||
@ -1081,6 +1391,343 @@ class MockPanel:
|
||||
writer.write(pkt.encode())
|
||||
await writer.drain()
|
||||
|
||||
# ============================================================
|
||||
# v1 wire-dialect dispatch (panels listening UDP-only)
|
||||
# ============================================================
|
||||
#
|
||||
# Same crypto + handshake as v2; only the outer ``PacketType``
|
||||
# (OmniLinkMessage = 16 vs OmniLink2Message = 32) and the inner
|
||||
# ``Message.start_char`` (0x5A NonAddressable vs 0x21 OmniLink2)
|
||||
# differ. The dispatch table here mirrors what ``OmniClientV1``
|
||||
# actually sends — see omni_pca.v1.client.
|
||||
|
||||
def _dispatch_v1(
|
||||
self, opcode: int, payload: bytes
|
||||
) -> tuple[Message, tuple[int, ...]]:
|
||||
"""Dispatch a single v1 request, return (reply_msg, push_words).
|
||||
|
||||
Mirrors :meth:`_dispatch_v2` but for v1 opcodes (see enuOmniLinkMessageType).
|
||||
"""
|
||||
if opcode == OmniLinkMessageType.RequestSystemInformation:
|
||||
return self._v1_reply_system_information(), ()
|
||||
if opcode == OmniLinkMessageType.RequestSystemStatus:
|
||||
return self._v1_reply_system_status(), ()
|
||||
if opcode == OmniLinkMessageType.RequestZoneStatus:
|
||||
return self._v1_reply_zone_status(payload), ()
|
||||
if opcode == OmniLinkMessageType.RequestUnitStatus:
|
||||
return self._v1_reply_unit_status(payload), ()
|
||||
if opcode == OmniLinkMessageType.RequestThermostatStatus:
|
||||
return self._v1_reply_thermostat_status(payload), ()
|
||||
if opcode == OmniLinkMessageType.RequestAuxiliaryStatus:
|
||||
return self._v1_reply_auxiliary_status(payload), ()
|
||||
if opcode == OmniLinkMessageType.UploadNames:
|
||||
return self._v1_start_upload_names_stream(), ()
|
||||
if opcode == OmniLinkMessageType.UploadPrograms:
|
||||
return self._v1_start_upload_programs_stream(), ()
|
||||
if opcode == OmniLinkMessageType.Ack:
|
||||
# During an active stream, each client Ack advances the
|
||||
# appropriate cursor. With no active stream, Ack as a request
|
||||
# opcode is only meaningful mid-stream — NAK it.
|
||||
if self._upload_programs_cursor is not None:
|
||||
return self._v1_advance_upload_programs_stream(), ()
|
||||
if self._upload_names_cursor is not None:
|
||||
return self._v1_advance_upload_names_stream(), ()
|
||||
return _build_v1_nak(opcode), ()
|
||||
if opcode == OmniLinkMessageType.Command:
|
||||
return self._v1_handle_command(payload)
|
||||
if opcode == OmniLinkMessageType.ExecuteSecurityCommand:
|
||||
return self._v1_handle_execute_security_command(payload)
|
||||
return _build_v1_nak(opcode), ()
|
||||
|
||||
# ---- v1 reply builders ----
|
||||
|
||||
def _v1_reply_system_information(self) -> Message:
|
||||
# Wire layout is byte-identical to v2 (clsOLMsgSystemInformation.cs
|
||||
# vs clsOL2MsgSystemInformation.cs) -- only the opcode differs.
|
||||
s = self.state
|
||||
body = bytes(
|
||||
[
|
||||
s.model_byte & 0xFF,
|
||||
s.firmware_major & 0xFF,
|
||||
s.firmware_minor & 0xFF,
|
||||
s.firmware_revision & 0xFF,
|
||||
]
|
||||
) + _name_bytes(s.local_phone, _PHONE_LEN)
|
||||
return encode_v1(OmniLinkMessageType.SystemInformation, body)
|
||||
|
||||
def _v1_reply_system_status(self) -> Message:
|
||||
# Bytes 0..13 byte-identical to v2 SystemStatus.
|
||||
# After byte 13 the v1 wire carries per-area Mode bytes (one byte
|
||||
# each) instead of v2's 2-byte alarm pairs. We emit eight zero
|
||||
# mode bytes so OmniClientV1 sees 8 areas reporting OFF.
|
||||
s = self.state
|
||||
body = bytes(
|
||||
[
|
||||
1 if s.time_set else 0,
|
||||
s.year & 0xFF, s.month & 0xFF, s.day & 0xFF,
|
||||
s.day_of_week & 0xFF,
|
||||
s.hour & 0xFF, s.minute & 0xFF, s.second & 0xFF,
|
||||
s.daylight_saving & 0xFF,
|
||||
s.sunrise_hour & 0xFF, s.sunrise_minute & 0xFF,
|
||||
s.sunset_hour & 0xFF, s.sunset_minute & 0xFF,
|
||||
s.battery & 0xFF,
|
||||
]
|
||||
)
|
||||
# Per-area mode bytes (8 areas on Omni Pro II).
|
||||
for idx in range(1, 9):
|
||||
area = self.state.areas.get(idx)
|
||||
body += bytes([area.mode if area else 0])
|
||||
return encode_v1(OmniLinkMessageType.SystemStatus, body)
|
||||
|
||||
@staticmethod
|
||||
def _v1_decode_range(payload: bytes) -> tuple[int, int] | None:
|
||||
"""Decode RequestUnitStatus / RequestZoneStatus / ... range payload.
|
||||
|
||||
Short form (both ≤ 255): 2 bytes [start, end].
|
||||
Long form (either > 255): 4 bytes [start_hi, start_lo, end_hi, end_lo].
|
||||
See clsOLMsgRequestUnitStatus.cs:18-31.
|
||||
"""
|
||||
if len(payload) == 2:
|
||||
return payload[0], payload[1]
|
||||
if len(payload) == 4:
|
||||
return (payload[0] << 8) | payload[1], (payload[2] << 8) | payload[3]
|
||||
return None
|
||||
|
||||
def _v1_reply_zone_status(self, payload: bytes) -> Message:
|
||||
rng = self._v1_decode_range(payload)
|
||||
if rng is None or rng[0] > rng[1]:
|
||||
return _build_v1_nak(OmniLinkMessageType.RequestZoneStatus)
|
||||
start, end = rng
|
||||
records = b""
|
||||
for idx in range(start, end + 1):
|
||||
z = self.state.zones.get(idx)
|
||||
if z is not None:
|
||||
records += bytes([z.status_byte, z.loop])
|
||||
else:
|
||||
# Slots without a defined zone still respond with
|
||||
# zero bytes -- real panels do this too.
|
||||
records += b"\x00\x00"
|
||||
return encode_v1(OmniLinkMessageType.ZoneStatus, records)
|
||||
|
||||
def _v1_reply_unit_status(self, payload: bytes) -> Message:
|
||||
rng = self._v1_decode_range(payload)
|
||||
if rng is None or rng[0] > rng[1]:
|
||||
return _build_v1_nak(OmniLinkMessageType.RequestUnitStatus)
|
||||
start, end = rng
|
||||
records = b""
|
||||
for idx in range(start, end + 1):
|
||||
u = self.state.units.get(idx)
|
||||
if u is not None:
|
||||
records += bytes(
|
||||
[u.state, (u.time_remaining >> 8) & 0xFF, u.time_remaining & 0xFF]
|
||||
)
|
||||
else:
|
||||
records += b"\x00\x00\x00"
|
||||
return encode_v1(OmniLinkMessageType.UnitStatus, records)
|
||||
|
||||
def _v1_reply_thermostat_status(self, payload: bytes) -> Message:
|
||||
rng = self._v1_decode_range(payload)
|
||||
if rng is None or rng[0] > rng[1]:
|
||||
return _build_v1_nak(OmniLinkMessageType.RequestThermostatStatus)
|
||||
start, end = rng
|
||||
records = b""
|
||||
for idx in range(start, end + 1):
|
||||
t = self.state.thermostats.get(idx)
|
||||
if t is not None:
|
||||
records += bytes(
|
||||
[
|
||||
1, # communicating
|
||||
t.temperature_raw, t.heat_setpoint_raw,
|
||||
t.cool_setpoint_raw,
|
||||
t.system_mode, t.fan_mode, t.hold_mode,
|
||||
]
|
||||
)
|
||||
else:
|
||||
records += b"\x00" * 7
|
||||
return encode_v1(OmniLinkMessageType.ThermostatStatus, records)
|
||||
|
||||
def _v1_reply_auxiliary_status(self, payload: bytes) -> Message:
|
||||
rng = self._v1_decode_range(payload)
|
||||
if rng is None or rng[0] > rng[1]:
|
||||
return _build_v1_nak(OmniLinkMessageType.RequestAuxiliaryStatus)
|
||||
start, end = rng
|
||||
# MockState has no aux sensors -- return zero records.
|
||||
records = b"\x00\x00\x00\x00" * (end - start + 1)
|
||||
return encode_v1(OmniLinkMessageType.AuxiliaryStatus, records)
|
||||
|
||||
# ---- UploadNames streaming ----
|
||||
|
||||
# NameType enum (from omni_pca.v1.messages.NameType -- duplicated here
|
||||
# so the mock doesn't depend on the v1 subpackage at import time).
|
||||
_V1_NAME_TYPE_ZONE: ClassVar[int] = 1
|
||||
_V1_NAME_TYPE_UNIT: ClassVar[int] = 2
|
||||
_V1_NAME_TYPE_BUTTON: ClassVar[int] = 3
|
||||
_V1_NAME_TYPE_AREA: ClassVar[int] = 5
|
||||
_V1_NAME_TYPE_THERMOSTAT: ClassVar[int] = 6
|
||||
|
||||
_V1_NAME_TYPE_LENGTH: ClassVar[dict[int, int]] = {
|
||||
1: 15, # Zone
|
||||
2: 12, # Unit
|
||||
3: 12, # Button
|
||||
4: 12, # Code (unused by mock)
|
||||
5: 12, # Area
|
||||
6: 12, # Thermostat
|
||||
7: 15, # Message (unused by mock)
|
||||
}
|
||||
|
||||
def _v1_name_stream(self) -> list[tuple[int, int, str]]:
|
||||
"""Flat list of (NameType, number, name) tuples — the panel emits
|
||||
these in the order Zone → Unit → Button → Code → Area →
|
||||
Thermostat → Message during ``UploadNames`` streaming.
|
||||
Empty-named objects are skipped (matches real-panel behavior).
|
||||
"""
|
||||
items: list[tuple[int, int, str]] = []
|
||||
for idx in sorted(self.state.zones):
|
||||
n = self.state.zones[idx].name
|
||||
if n:
|
||||
items.append((self._V1_NAME_TYPE_ZONE, idx, n))
|
||||
for idx in sorted(self.state.units):
|
||||
n = self.state.units[idx].name
|
||||
if n:
|
||||
items.append((self._V1_NAME_TYPE_UNIT, idx, n))
|
||||
for idx in sorted(self.state.buttons):
|
||||
n = self.state.buttons[idx].name
|
||||
if n:
|
||||
items.append((self._V1_NAME_TYPE_BUTTON, idx, n))
|
||||
for idx in sorted(self.state.areas):
|
||||
n = self.state.areas[idx].name
|
||||
if n:
|
||||
items.append((self._V1_NAME_TYPE_AREA, idx, n))
|
||||
for idx in sorted(self.state.thermostats):
|
||||
n = self.state.thermostats[idx].name
|
||||
if n:
|
||||
items.append((self._V1_NAME_TYPE_THERMOSTAT, idx, n))
|
||||
return items
|
||||
|
||||
def _v1_namedata_msg(self, type_byte: int, num: int, name: str) -> Message:
|
||||
"""Encode a single NameData reply payload (clsOLMsgNameData.cs)."""
|
||||
L = self._V1_NAME_TYPE_LENGTH.get(type_byte, 12)
|
||||
encoded = name.encode("utf-8")[:L].ljust(L, b"\x00")
|
||||
if num <= 0xFF:
|
||||
body = bytes([type_byte, num]) + encoded + b"\x00"
|
||||
else:
|
||||
body = bytes(
|
||||
[type_byte, (num >> 8) & 0xFF, num & 0xFF]
|
||||
) + encoded + b"\x00"
|
||||
return encode_v1(OmniLinkMessageType.NameData, body)
|
||||
|
||||
def _v1_start_upload_names_stream(self) -> Message:
|
||||
"""Handle bare ``UploadNames`` request -- send first NameData
|
||||
(or EOD immediately if no defined names)."""
|
||||
names = self._v1_name_stream()
|
||||
if not names:
|
||||
self._upload_names_cursor = None
|
||||
return _build_v1_eod()
|
||||
self._upload_names_cursor = 0
|
||||
t, n, name = names[0]
|
||||
return self._v1_namedata_msg(t, n, name)
|
||||
|
||||
def _v1_advance_upload_names_stream(self) -> Message:
|
||||
"""Handle client ``Acknowledge`` during an active stream -- send
|
||||
the next NameData or EOD when exhausted."""
|
||||
names = self._v1_name_stream()
|
||||
# _upload_names_cursor != None implied by caller
|
||||
assert self._upload_names_cursor is not None
|
||||
self._upload_names_cursor += 1
|
||||
if self._upload_names_cursor >= len(names):
|
||||
self._upload_names_cursor = None
|
||||
return _build_v1_eod()
|
||||
t, n, name = names[self._upload_names_cursor]
|
||||
return self._v1_namedata_msg(t, n, name)
|
||||
|
||||
# ---- UploadPrograms streaming ----
|
||||
#
|
||||
# Wire flow per clsHAC.OL1ReadConfig (clsHAC.cs:4403, 4538-4540, 4642-4651):
|
||||
# client → UploadPrograms (bare)
|
||||
# panel → ProgramData (slot N body)
|
||||
# client → Ack
|
||||
# panel → ProgramData (slot N+1 body) ...
|
||||
# panel → EOD
|
||||
#
|
||||
# ProgramData body layout matches v2 exactly (clsOLMsgProgramData
|
||||
# mirrors clsOL2MsgProgramData byte-for-byte) — both prepend a 2-byte
|
||||
# BE ProgramNumber to the 14-byte wire body. Only the outer envelope
|
||||
# opcode differs (v1 vs v2).
|
||||
|
||||
def _v1_program_stream(self) -> list[int]:
|
||||
"""Sorted list of defined program slot numbers."""
|
||||
return sorted(self.state.programs)
|
||||
|
||||
def _v1_programdata_msg(self, slot: int) -> Message:
|
||||
body = self.state.programs.get(slot, b"\x00" * 14)
|
||||
payload = bytes([(slot >> 8) & 0xFF, slot & 0xFF]) + body
|
||||
return encode_v1(OmniLinkMessageType.ProgramData, payload)
|
||||
|
||||
def _v1_start_upload_programs_stream(self) -> Message:
|
||||
slots = self._v1_program_stream()
|
||||
if not slots:
|
||||
self._upload_programs_cursor = None
|
||||
return _build_v1_eod()
|
||||
self._upload_programs_cursor = 0
|
||||
return self._v1_programdata_msg(slots[0])
|
||||
|
||||
def _v1_advance_upload_programs_stream(self) -> Message:
|
||||
slots = self._v1_program_stream()
|
||||
assert self._upload_programs_cursor is not None
|
||||
self._upload_programs_cursor += 1
|
||||
if self._upload_programs_cursor >= len(slots):
|
||||
self._upload_programs_cursor = None
|
||||
return _build_v1_eod()
|
||||
return self._v1_programdata_msg(slots[self._upload_programs_cursor])
|
||||
|
||||
# ---- v1 Command / ExecuteSecurityCommand wrappers ----
|
||||
# The wire payload format is byte-identical to v2 (clsOLMsgCommand.cs
|
||||
# vs clsOL2MsgCommand.cs); only the outer opcode and the reply Ack
|
||||
# opcode (v1=5 vs v2=1) differ. We reuse the v2 state-mutation
|
||||
# helper and just wrap the reply.
|
||||
|
||||
def _v1_handle_command(
|
||||
self, payload: bytes
|
||||
) -> tuple[Message, tuple[int, ...]]:
|
||||
v2_reply, push_words = self._handle_command(payload)
|
||||
if v2_reply.opcode == int(OmniLink2MessageType.Ack):
|
||||
return _build_v1_ack(), push_words
|
||||
# Pass-through Nak (no state mutation push when command refused).
|
||||
return _build_v1_nak(OmniLinkMessageType.Command), ()
|
||||
|
||||
def _v1_handle_execute_security_command(
|
||||
self, payload: bytes
|
||||
) -> tuple[Message, tuple[int, ...]]:
|
||||
v2_reply, push_words = self._handle_execute_security_command(payload)
|
||||
if v2_reply.opcode == int(OmniLink2MessageType.Ack):
|
||||
return _build_v1_ack(), push_words
|
||||
if v2_reply.opcode == int(
|
||||
OmniLink2MessageType.ExecuteSecurityCommandResponse
|
||||
):
|
||||
# Preserve the structured response (status byte) but rebuild
|
||||
# with v1 opcode so OmniClientV1 sees opcode 103, not 75.
|
||||
return (
|
||||
encode_v1(
|
||||
OmniLinkMessageType.ExecuteSecurityCommandResponse,
|
||||
v2_reply.payload,
|
||||
),
|
||||
push_words,
|
||||
)
|
||||
return _build_v1_nak(OmniLinkMessageType.ExecuteSecurityCommand), ()
|
||||
|
||||
async def _send_v1_reply(
|
||||
self,
|
||||
client_seq: int,
|
||||
message: Message,
|
||||
session_key: bytes,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
plaintext = message.encode()
|
||||
ciphertext = encrypt_message_payload(plaintext, client_seq, session_key)
|
||||
pkt = Packet(seq=client_seq, type=PacketType.OmniLinkMessage, data=ciphertext)
|
||||
writer.write(pkt.encode())
|
||||
await writer.drain()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Status / ExtendedStatus per-record builders
|
||||
@ -1261,6 +1908,26 @@ def _build_nak(in_reply_to_opcode: int) -> Message:
|
||||
return encode_v2(OmniLink2MessageType.Nak, bytes([in_reply_to_opcode & 0xFF]))
|
||||
|
||||
|
||||
# ---- v1 wire-dialect counterparts ----
|
||||
|
||||
def _build_v1_ack() -> Message:
|
||||
"""Build a v1 Ack (opcode 5) with no payload."""
|
||||
return encode_v1(OmniLinkMessageType.Ack, b"")
|
||||
|
||||
|
||||
def _build_v1_nak(in_reply_to_opcode: int) -> Message:
|
||||
"""Build a v1 Nak (opcode 6) carrying the offending opcode byte."""
|
||||
return encode_v1(
|
||||
OmniLinkMessageType.Nak, bytes([int(in_reply_to_opcode) & 0xFF])
|
||||
)
|
||||
|
||||
|
||||
def _build_v1_eod() -> Message:
|
||||
"""Build a v1 EOD (opcode 3) -- the end-of-stream marker for bulk
|
||||
downloads like ``UploadNames`` and ``UploadSetup``."""
|
||||
return encode_v1(OmniLinkMessageType.EOD, b"")
|
||||
|
||||
|
||||
async def _read_exact(reader: asyncio.StreamReader, n: int) -> bytes | None:
|
||||
"""Read exactly ``n`` bytes or return None if EOF arrives early."""
|
||||
try:
|
||||
@ -1401,6 +2068,15 @@ class _MockServerDatagramProtocol(asyncio.DatagramProtocol):
|
||||
await self._handle_encrypted_udp(pkt, addr)
|
||||
return
|
||||
|
||||
if pkt.type is PacketType.OmniLinkMessage:
|
||||
# v1 wire dialect — the canonical UDP-only dialect real
|
||||
# panels speak. Routes through MockPanel._dispatch_v1.
|
||||
if self._session_key is None:
|
||||
_log.debug("mock panel (udp) v1 message before secure session")
|
||||
return
|
||||
await self._handle_encrypted_udp_v1(pkt, addr)
|
||||
return
|
||||
|
||||
_log.debug("mock panel (udp) unhandled packet type %s", pkt.type.name)
|
||||
|
||||
async def _handle_encrypted_udp(
|
||||
@ -1430,6 +2106,34 @@ class _MockServerDatagramProtocol(asyncio.DatagramProtocol):
|
||||
if push_words:
|
||||
self._schedule_udp_push(push_words, addr)
|
||||
|
||||
async def _handle_encrypted_udp_v1(
|
||||
self, pkt: Packet, addr: tuple[str, int]
|
||||
) -> None:
|
||||
"""v1 UDP counterpart of :meth:`_handle_encrypted_udp`.
|
||||
|
||||
Same crypto, different inner-message dialect (StartChar 0x5A,
|
||||
v1 opcodes) and different outer reply type (``OmniLinkMessage``
|
||||
= 16, not 32).
|
||||
"""
|
||||
assert self._session_key is not None
|
||||
try:
|
||||
plaintext = decrypt_message_payload(
|
||||
pkt.data, pkt.seq, self._session_key
|
||||
)
|
||||
except Exception:
|
||||
_log.debug("mock panel (udp) failed to decrypt v1 message")
|
||||
return
|
||||
try:
|
||||
inner = Message.decode(plaintext)
|
||||
except (MessageCrcError, MessageFormatError):
|
||||
await self._send_reply_v1(pkt.seq, _build_v1_nak(0), addr)
|
||||
return
|
||||
|
||||
opcode = inner.opcode
|
||||
self._panel._last_request_opcode = opcode
|
||||
reply, _push_words = self._panel._dispatch_v1(opcode, inner.payload)
|
||||
await self._send_reply_v1(pkt.seq, reply, addr)
|
||||
|
||||
def _schedule_udp_push(
|
||||
self, words: tuple[int, ...], addr: tuple[str, int]
|
||||
) -> None:
|
||||
@ -1470,3 +2174,19 @@ class _MockServerDatagramProtocol(asyncio.DatagramProtocol):
|
||||
data=ciphertext,
|
||||
)
|
||||
self._send(pkt, addr)
|
||||
|
||||
async def _send_reply_v1(
|
||||
self, client_seq: int, message: Message, addr: tuple[str, int]
|
||||
) -> None:
|
||||
"""v1 counterpart of :meth:`_send_reply` -- wraps the encrypted
|
||||
reply in an ``OmniLinkMessage`` (16) outer packet instead of
|
||||
``OmniLink2Message`` (32)."""
|
||||
assert self._session_key is not None
|
||||
plaintext = message.encode()
|
||||
ciphertext = encrypt_message_payload(plaintext, client_seq, self._session_key)
|
||||
pkt = Packet(
|
||||
seq=client_seq,
|
||||
type=PacketType.OmniLinkMessage,
|
||||
data=ciphertext,
|
||||
)
|
||||
self._send(pkt, addr)
|
||||
|
||||
@ -490,18 +490,25 @@ class ObjectType(IntEnum):
|
||||
class SecurityMode(IntEnum):
|
||||
"""Area security mode (enuSecurityMode.cs).
|
||||
|
||||
Values 9..14 are the "arming in progress" variants the panel reports
|
||||
while a delayed-arm timer is running.
|
||||
The first 7 values are what the user actually picks at the keypad
|
||||
when arming. Values 9..14 are the "arming in progress" variants the
|
||||
panel reports while a delayed-arm timer is running.
|
||||
|
||||
Reference: HAI OmniPro II Owner's Manual, *Security System
|
||||
Operation* chapter (pca-re/docs/owner_manual/
|
||||
03_SECURITY_SYSTEM_OPERATION/) — the user-facing semantics of each
|
||||
mode (entry/exit delays, which zones are armed, when to use which)
|
||||
come from there.
|
||||
"""
|
||||
|
||||
OFF = 0
|
||||
DAY = 1
|
||||
NIGHT = 2
|
||||
AWAY = 3
|
||||
VACATION = 4
|
||||
DAY_INSTANT = 5
|
||||
NIGHT_DELAYED = 6
|
||||
ANY_CHANGE = 7
|
||||
OFF = 0 # disarmed; resets fire / emergency alarms, silences sirens
|
||||
DAY = 1 # perimeter armed, interior motion NOT armed, entry delay
|
||||
NIGHT = 2 # perimeter + non-sleeping-area motion armed, NO entry delay
|
||||
AWAY = 3 # everything armed, both exit + entry delays
|
||||
VACATION = 4 # same as AWAY but for multi-day absences
|
||||
DAY_INSTANT = 5 # DAY with no entry delay (instant alarm on perimeter)
|
||||
NIGHT_DELAYED = 6 # NIGHT with entry delay on entry-exit zones
|
||||
ANY_CHANGE = 7 # programming-condition wildcard, NOT a real arming state
|
||||
ARMING_DAY = 9
|
||||
ARMING_NIGHT = 10
|
||||
ARMING_AWAY = 11
|
||||
@ -511,7 +518,13 @@ class SecurityMode(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
|
||||
HEAT = 1
|
||||
@ -521,7 +534,12 @@ class HvacMode(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
|
||||
ON = 1
|
||||
@ -531,8 +549,12 @@ class FanMode(IntEnum):
|
||||
class HoldMode(IntEnum):
|
||||
"""Thermostat hold mode (enuThermostatHoldMode.cs).
|
||||
|
||||
Value 255 (``OLD_ON``) is a legacy "Hold" sentinel from older firmware
|
||||
that some panels still emit; treat it as equivalent to ``HOLD``.
|
||||
``OFF`` / ``HOLD`` are the two states surfaced at the keypad's
|
||||
"Thermostat → HOLD" menu (Owner's Manual *Scene Commands*,
|
||||
06_Scene_Commands/). ``VACATION`` (2) is a programmable mode the
|
||||
panel uses while a Vacation security mode is active. Value 255
|
||||
(``OLD_ON``) is a legacy "Hold" sentinel from older firmware that
|
||||
some panels still emit; treat it as equivalent to ``HOLD``.
|
||||
"""
|
||||
|
||||
OFF = 0
|
||||
@ -560,6 +582,13 @@ class ZoneType(IntEnum):
|
||||
the temperature/humidity sensors and a handful of utility types. Any
|
||||
raw byte value still round-trips through ``ZoneStatus.zone_type`` —
|
||||
it just won't have a named enum member.
|
||||
|
||||
Reference: HAI OmniPro II Installation Manual, *Installer Setup →
|
||||
SETUP ZONES → ZONE TYPES* table (pca-re/docs/manuals/
|
||||
installation_manual/04_INSTALLER_SETUP/INSTALLER_SETUP.md, p38-39).
|
||||
The byte values and short names here match the installer-setup
|
||||
keypad selections one-for-one (e.g. ``PERIMETER = 1`` is the same
|
||||
"PERIMETER" the installer scrolls to when setting Z1..Z176 types).
|
||||
"""
|
||||
|
||||
ENTRY_EXIT = 0
|
||||
@ -657,7 +686,9 @@ class ZoneStatus:
|
||||
bytes[2] status byte (current+latched+arming, see below)
|
||||
bytes[3] analog loop reading (0-255)
|
||||
|
||||
Status byte bit layout (clsZone.cs:385, clsText.cs:3110):
|
||||
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
|
||||
0=Secure, 1=NotReady, 2=Trouble, 3=Tamper
|
||||
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[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
|
||||
1 On
|
||||
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
@ -172,6 +172,23 @@ class OmniClientV1Adapter:
|
||||
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(
|
||||
|
||||
@ -208,6 +208,61 @@ class OmniClientV1:
|
||||
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
|
||||
|
||||
@ -21,6 +21,14 @@ Reference: clsOmniLinkConnection.cs (UDP path):
|
||||
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
|
||||
|
||||
@ -21,6 +21,18 @@ Per-record byte counts (verified against firmware 2.12 over UDP):
|
||||
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
|
||||
|
||||
@ -64,6 +64,26 @@ def _short_scan_interval(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@pytest.fixture
|
||||
def populated_state() -> MockState:
|
||||
"""A lightly-populated mock state covering every entity platform."""
|
||||
from omni_pca.programs import Days, Program, ProgramType
|
||||
programs = {
|
||||
slot: prog.encode_wire_bytes()
|
||||
for slot, prog in {
|
||||
12: Program(
|
||||
slot=12, prog_type=int(ProgramType.TIMED),
|
||||
cmd=3, hour=6, minute=0,
|
||||
days=int(Days.MONDAY | Days.FRIDAY),
|
||||
),
|
||||
42: Program(
|
||||
slot=42, prog_type=int(ProgramType.TIMED),
|
||||
cmd=4, hour=22, minute=30,
|
||||
days=int(Days.SUNDAY),
|
||||
),
|
||||
99: Program(
|
||||
slot=99, prog_type=int(ProgramType.EVENT),
|
||||
cmd=5, month=5, day=12,
|
||||
),
|
||||
}.items()
|
||||
}
|
||||
return MockState(
|
||||
zones={
|
||||
1: MockZoneState(name="FRONT_DOOR"),
|
||||
@ -84,6 +104,7 @@ def populated_state() -> MockState:
|
||||
1: MockButtonState(name="GOOD_MORNING"),
|
||||
},
|
||||
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
|
||||
|
||||
|
||||
async def test_programs_sensor_reflects_seeded_panel(
|
||||
hass: HomeAssistant, configured_panel
|
||||
) -> None:
|
||||
"""The diagnostic Panel Programs sensor enumerates discovered programs.
|
||||
|
||||
Mock seed has 3 programs at slots 12 / 42 / 99 (see
|
||||
:func:`populated_state`); after discovery the coordinator stashes them
|
||||
on ``OmniData.programs`` and the sensor surfaces a count + per-slot
|
||||
summary attribute.
|
||||
"""
|
||||
programs_states = [
|
||||
s for s in hass.states.async_all("sensor")
|
||||
if s.entity_id.endswith("_panel_programs") or "panel_programs" in s.entity_id
|
||||
]
|
||||
assert len(programs_states) == 1, (
|
||||
f"expected one panel_programs sensor, got {[s.entity_id for s in programs_states]}"
|
||||
)
|
||||
s = programs_states[0]
|
||||
assert int(s.state) == 3
|
||||
summaries = s.attributes["programs"]
|
||||
assert [p["slot"] for p in summaries] == [12, 42, 99]
|
||||
# Spot-check the per-record fields landed in summary form.
|
||||
by_slot = {p["slot"]: p for p in summaries}
|
||||
assert by_slot[12]["type"] == "TIMED"
|
||||
assert by_slot[12]["hour"] == 6 and by_slot[12]["minute"] == 0
|
||||
assert by_slot[99]["type"] == "EVENT"
|
||||
assert by_slot[99]["month"] == 5 and by_slot[99]["day"] == 12
|
||||
|
||||
|
||||
async def test_event_entity_per_panel(
|
||||
hass: HomeAssistant, configured_panel
|
||||
) -> None:
|
||||
|
||||
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
|
||||
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)
|
||||
@ -94,6 +94,252 @@ def test_full_pca_parse_against_real_fixture() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ---- Programs block extraction against the live decrypted fixture ----
|
||||
#
|
||||
# These tests need the plaintext .pca dump at the path below — gitignored.
|
||||
# If absent, they skip cleanly. If present, they assert the decode against
|
||||
# the values established in the Phase 1 RE pass (Programs block, slot 22,
|
||||
# the TIMED/EVENT/YEARLY type-distribution counts).
|
||||
|
||||
_FIXTURE = "/home/kdm/home-auto/HAI/pca-re/extracted/Our_House.pca.plain"
|
||||
|
||||
|
||||
def _load_programs_blob_or_skip() -> bytes:
|
||||
from pathlib import Path
|
||||
|
||||
p = Path(_FIXTURE)
|
||||
if not p.exists():
|
||||
pytest.skip(f"fixture not available: {_FIXTURE}")
|
||||
from omni_pca.pca_file import (
|
||||
_CAP_OMNI_PRO_II,
|
||||
PcaReader,
|
||||
_parse_header,
|
||||
_walk_to_connection,
|
||||
)
|
||||
|
||||
r = PcaReader(p.read_bytes())
|
||||
_parse_header(r)
|
||||
return _walk_to_connection(r, _CAP_OMNI_PRO_II).programs_blob
|
||||
|
||||
|
||||
def test_programs_block_decodes_against_live_fixture() -> None:
|
||||
"""All 1500 slots decode without raising; counts match Phase 1 recon."""
|
||||
from collections import Counter
|
||||
|
||||
from omni_pca.programs import ProgramType, decode_program_table, iter_defined
|
||||
|
||||
blob = _load_programs_blob_or_skip()
|
||||
assert len(blob) == 1500 * 14
|
||||
|
||||
programs = decode_program_table(blob)
|
||||
assert len(programs) == 1500
|
||||
defined = list(iter_defined(programs))
|
||||
assert len(defined) == 330
|
||||
|
||||
types = Counter(p.prog_type for p in defined)
|
||||
assert types[int(ProgramType.TIMED)] == 209
|
||||
assert types[int(ProgramType.EVENT)] == 105
|
||||
assert types[int(ProgramType.YEARLY)] == 16
|
||||
|
||||
|
||||
def test_programs_block_round_trips_byte_for_byte() -> None:
|
||||
"""The strongest correctness signal: decode → encode → compare.
|
||||
|
||||
If a single byte of the 21,000-byte blob is off, this test catches it.
|
||||
"""
|
||||
from omni_pca.programs import decode_program_table
|
||||
|
||||
blob = _load_programs_blob_or_skip()
|
||||
programs = decode_program_table(blob)
|
||||
rebuilt = b"".join(p.encode_file_record() for p in programs)
|
||||
assert rebuilt == blob
|
||||
|
||||
|
||||
def test_programs_sanity_invariants() -> None:
|
||||
"""Coarse invariants on the 330 defined programs.
|
||||
|
||||
The byte-for-byte round-trip test above is the load-bearing
|
||||
correctness signal. The asserts here are belt-and-suspenders:
|
||||
|
||||
* **YEARLY** uses bytes 9/10 as a real calendar date.
|
||||
* **TIMED** programs come in two flavors:
|
||||
ABSOLUTE (``hour`` 0..23, ``minute`` 0..59) and
|
||||
sunrise/sunset-relative (``hour`` == 25 or 26 — see
|
||||
:class:`omni_pca.programs.TimeKind`). The decoder classifies via
|
||||
``Program.time_kind``; ABSOLUTE-time programs must hit real
|
||||
wall-clock ranges.
|
||||
* **EVENT** encodes a u16 event ID in bytes 9/10 rather than
|
||||
a calendar date (see ``clsProgram.Evt``); no calendar assertion.
|
||||
"""
|
||||
from omni_pca.programs import (
|
||||
ProgramType,
|
||||
TimeKind,
|
||||
decode_program_table,
|
||||
iter_defined,
|
||||
)
|
||||
|
||||
blob = _load_programs_blob_or_skip()
|
||||
programs = decode_program_table(blob)
|
||||
defined = list(iter_defined(programs))
|
||||
|
||||
yearly = [p for p in defined if p.prog_type == int(ProgramType.YEARLY)]
|
||||
assert yearly, "fixture should have YEARLY programs"
|
||||
for p in yearly:
|
||||
assert 1 <= p.month <= 12, (
|
||||
f"slot {p.slot} YEARLY: month={p.month}"
|
||||
)
|
||||
assert 1 <= p.day <= 31, (
|
||||
f"slot {p.slot} YEARLY: day={p.day}"
|
||||
)
|
||||
|
||||
timed = [p for p in defined if p.prog_type == int(ProgramType.TIMED)]
|
||||
assert timed, "fixture should have TIMED programs"
|
||||
for p in timed:
|
||||
assert p.days != 0, f"slot {p.slot}: TIMED with no days mask"
|
||||
if p.time_kind == TimeKind.ABSOLUTE:
|
||||
assert 0 <= p.hour <= 23, (
|
||||
f"slot {p.slot} TIMED-ABSOLUTE: hour={p.hour}"
|
||||
)
|
||||
assert 0 <= p.minute <= 59, (
|
||||
f"slot {p.slot} TIMED-ABSOLUTE: minute={p.minute}"
|
||||
)
|
||||
else:
|
||||
# Sunrise/sunset offsets fit in a signed byte.
|
||||
assert -128 <= p.time_offset_minutes <= 127
|
||||
|
||||
|
||||
def test_remarks_walker_on_empty_table() -> None:
|
||||
"""Hand-built minimal tail with zero description entries + zero remarks."""
|
||||
import struct
|
||||
|
||||
from omni_pca.pca_file import PcaReader, _walk_to_remarks
|
||||
|
||||
blob = (
|
||||
struct.pack("<H", 9) # ModemBaud
|
||||
+ b"\x01\x00\x00" # 3 init-enable flags
|
||||
+ struct.pack("<H", 0) # AccountRemarks_Extended length 0
|
||||
+ (struct.pack("<I", 0) * 9) # 9 description blocks, each count=0
|
||||
+ struct.pack("<I", 1234) # _RemarksNextID
|
||||
+ struct.pack("<I", 0) # count = 0
|
||||
)
|
||||
r = PcaReader(blob)
|
||||
walk = _walk_to_remarks(r)
|
||||
assert walk.remarks == {}
|
||||
assert walk.account_remarks_extended == ""
|
||||
assert walk.zone_descriptions == {}
|
||||
|
||||
|
||||
def test_remarks_walker_decodes_real_entries() -> None:
|
||||
"""Hand-built tail with two non-empty description entries + three remarks."""
|
||||
import struct
|
||||
|
||||
from omni_pca.pca_file import (
|
||||
PcaReader,
|
||||
_DESCRIPTION_SLOT_BYTES,
|
||||
_walk_to_remarks,
|
||||
)
|
||||
|
||||
# Two zones with descriptions; everything else has zero entries.
|
||||
zone_desc = b"\x06" + b"FOYER!" + b"\x00" * (32 - 6) # 33 bytes
|
||||
other_desc = b"\x09" + b"GARAGE LT" + b"\x00" * (32 - 9)
|
||||
assert len(zone_desc) == _DESCRIPTION_SLOT_BYTES
|
||||
assert len(other_desc) == _DESCRIPTION_SLOT_BYTES
|
||||
description_blocks = (
|
||||
struct.pack("<I", 2) + zone_desc + other_desc # Zones
|
||||
+ struct.pack("<I", 0) * 8 # Units .. AudioZones
|
||||
)
|
||||
|
||||
def _remark_entry(rid: int, text: str) -> bytes:
|
||||
t = text.encode("utf-8")
|
||||
return struct.pack("<I", rid) + struct.pack("<H", len(t)) + t
|
||||
|
||||
remarks_block = (
|
||||
struct.pack("<I", 99) # _RemarksNextID
|
||||
+ struct.pack("<I", 3) # count
|
||||
+ _remark_entry(1, "TURN ON LIVING ROOM LIGHTS")
|
||||
+ _remark_entry(7, "DOG WALK TIME")
|
||||
+ _remark_entry(0xDEADBEEF, "UTF-8 ✓ ☃ ♥")
|
||||
)
|
||||
|
||||
tail = (
|
||||
struct.pack("<H", 9) + b"\x01\x00\x00"
|
||||
+ struct.pack("<H", 0)
|
||||
+ description_blocks
|
||||
+ remarks_block
|
||||
)
|
||||
r = PcaReader(tail)
|
||||
walk = _walk_to_remarks(r)
|
||||
assert walk.remarks == {
|
||||
1: "TURN ON LIVING ROOM LIGHTS",
|
||||
7: "DOG WALK TIME",
|
||||
0xDEADBEEF: "UTF-8 ✓ ☃ ♥",
|
||||
}
|
||||
# The two synthetic zone-description entries decoded too.
|
||||
assert walk.zone_descriptions == {1: "FOYER!", 2: "GARAGE LT"}
|
||||
|
||||
|
||||
def test_remarks_walker_returns_empty_on_truncated_input() -> None:
|
||||
"""A short/garbage tail should yield an empty walk record, not raise."""
|
||||
from omni_pca.pca_file import PcaReader, _walk_to_remarks
|
||||
|
||||
# Way too short to hold even the prelude.
|
||||
walk = _walk_to_remarks(PcaReader(b"\x00" * 5))
|
||||
assert walk.remarks == {}
|
||||
assert walk.account_remarks_extended == ""
|
||||
assert walk.zone_descriptions == {}
|
||||
|
||||
|
||||
def test_remarks_resolved_against_live_fixture_is_empty_dict() -> None:
|
||||
"""Our live fixture has zero remarks programmed; the walker must
|
||||
still consume the prelude + nine description blocks + the zero
|
||||
count without raising."""
|
||||
blob = _load_programs_blob_or_skip() # establishes the fixture exists
|
||||
# We've already validated the position at end-of-programs above; now
|
||||
# re-walk and continue past Connection through the remarks walker.
|
||||
from omni_pca.pca_file import (
|
||||
_CAP_OMNI_PRO_II,
|
||||
PcaReader,
|
||||
_parse_header,
|
||||
_walk_to_connection,
|
||||
_walk_to_remarks,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
raw = Path(_FIXTURE).read_bytes()
|
||||
r = PcaReader(raw)
|
||||
_parse_header(r)
|
||||
_walk_to_connection(r, _CAP_OMNI_PRO_II)
|
||||
r.string8_fixed(120)
|
||||
r.string8_fixed(5)
|
||||
r.string8_fixed(32)
|
||||
walk = _walk_to_remarks(r)
|
||||
assert walk.remarks == {}
|
||||
# Live fixture description tables — homeowner left them blank.
|
||||
assert walk.zone_descriptions == {}
|
||||
assert walk.unit_descriptions == {}
|
||||
|
||||
|
||||
def test_pca_account_dataclass_has_programs_field() -> None:
|
||||
"""``PcaAccount`` exposes ``programs`` with the expected type + default.
|
||||
|
||||
Verifies the API surface without needing a working .pca decrypt
|
||||
key — the integration from raw blob through ``decode_program_table``
|
||||
is covered by the other three live-fixture tests above.
|
||||
"""
|
||||
from omni_pca.pca_file import PcaAccount
|
||||
|
||||
fields = {f.name: f for f in PcaAccount.__dataclass_fields__.values()}
|
||||
assert "programs" in fields
|
||||
assert "remarks" in fields
|
||||
# Defaults: empty tuple for programs, empty dict for remarks.
|
||||
inst = PcaAccount(
|
||||
version_tag="PCA03", file_version=3,
|
||||
model=16, firmware_major=2, firmware_minor=12, firmware_revision=1,
|
||||
)
|
||||
assert inst.programs == ()
|
||||
assert inst.remarks == {}
|
||||
|
||||
|
||||
def test_pca_reader_io_state_introspection() -> None:
|
||||
r = PcaReader(b"abcdef")
|
||||
assert isinstance(r.buf, io.BytesIO)
|
||||
|
||||
1354
tests/test_program_engine.py
Normal file
744
tests/test_program_renderer.py
Normal file
@ -0,0 +1,744 @@
|
||||
"""Tests for the structured-English program renderer.
|
||||
|
||||
Coverage strategy:
|
||||
* Each trigger / condition / action branch gets at least one focused
|
||||
test asserting the rendered tokens (or plain-text projection).
|
||||
* End-to-end tests build a Program (or ClausalChain) that mirrors what
|
||||
PC Access produces and verify the renderer's output reads cleanly.
|
||||
* Live-state overlay is tested separately via a small fake StateResolver
|
||||
so we can assert the badges land on the right REF tokens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from omni_pca.commands import Command
|
||||
from omni_pca.mock_panel import (
|
||||
MockAreaState,
|
||||
MockState,
|
||||
MockThermostatState,
|
||||
MockUnitState,
|
||||
MockZoneState,
|
||||
)
|
||||
from omni_pca.program_engine import (
|
||||
ClausalChain,
|
||||
EVENT_AC_POWER_OFF,
|
||||
event_id_unit_state,
|
||||
event_id_user_macro_button,
|
||||
event_id_zone_state,
|
||||
)
|
||||
from omni_pca.program_renderer import (
|
||||
AccountNameResolver,
|
||||
MockStateResolver,
|
||||
NameResolver,
|
||||
ProgramRenderer,
|
||||
StateResolver,
|
||||
Token,
|
||||
TokenKind,
|
||||
_format_interval,
|
||||
format_days,
|
||||
tokens_to_string,
|
||||
)
|
||||
from omni_pca.programs import (
|
||||
CondArgType,
|
||||
CondOP,
|
||||
Days,
|
||||
Program,
|
||||
ProgramType,
|
||||
)
|
||||
|
||||
|
||||
# ---- Test helpers --------------------------------------------------------
|
||||
|
||||
|
||||
class _StaticNameResolver:
|
||||
"""Trivial name resolver — explicit name dict, useful in unit tests."""
|
||||
|
||||
def __init__(self, names: dict[tuple[str, int], str]) -> None:
|
||||
self._names = names
|
||||
|
||||
def name_of(self, kind: str, index: int) -> str | None:
|
||||
return self._names.get((kind, index))
|
||||
|
||||
|
||||
class _StaticStateResolver:
|
||||
"""Trivial state resolver — explicit state dict."""
|
||||
|
||||
def __init__(self, states: dict[tuple[str, int], str]) -> None:
|
||||
self._states = states
|
||||
|
||||
def state_of(self, kind: str, index: int) -> str | None:
|
||||
return self._states.get((kind, index))
|
||||
|
||||
|
||||
def _renderer_with(
|
||||
names: dict[tuple[str, int], str] | None = None,
|
||||
states: dict[tuple[str, int], str] | None = None,
|
||||
) -> ProgramRenderer:
|
||||
return ProgramRenderer(
|
||||
names=_StaticNameResolver(names or {}),
|
||||
state=_StaticStateResolver(states) if states is not None else None,
|
||||
)
|
||||
|
||||
|
||||
# ---- Format helpers ------------------------------------------------------
|
||||
|
||||
|
||||
def test_format_days_everyday() -> None:
|
||||
assert format_days(int(
|
||||
Days.MONDAY | Days.TUESDAY | Days.WEDNESDAY | Days.THURSDAY
|
||||
| Days.FRIDAY | Days.SATURDAY | Days.SUNDAY
|
||||
)) == "every day"
|
||||
|
||||
|
||||
def test_format_days_weekdays() -> None:
|
||||
assert format_days(int(
|
||||
Days.MONDAY | Days.TUESDAY | Days.WEDNESDAY | Days.THURSDAY | Days.FRIDAY
|
||||
)) == "weekdays"
|
||||
|
||||
|
||||
def test_format_days_weekend() -> None:
|
||||
assert format_days(int(Days.SATURDAY | Days.SUNDAY)) == "weekends"
|
||||
|
||||
|
||||
def test_format_days_individual_days() -> None:
|
||||
assert format_days(int(Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY)) == "Mon, Wed, Fri"
|
||||
|
||||
|
||||
def test_format_days_zero() -> None:
|
||||
assert format_days(0) == "never"
|
||||
|
||||
|
||||
def test_format_interval_seconds() -> None:
|
||||
assert _format_interval(5) == "5 sec"
|
||||
assert _format_interval(45) == "45 sec"
|
||||
|
||||
|
||||
def test_format_interval_minutes_and_hours() -> None:
|
||||
assert _format_interval(300) == "5 min"
|
||||
assert _format_interval(7200) == "2 hr"
|
||||
|
||||
|
||||
def test_format_interval_disabled() -> None:
|
||||
assert _format_interval(0) == "(disabled)"
|
||||
|
||||
|
||||
# ---- Tokens to string ----------------------------------------------------
|
||||
|
||||
|
||||
def test_tokens_to_string_with_state_badge() -> None:
|
||||
"""REF tokens with `state` set surface as ``name [state]``."""
|
||||
tokens = [
|
||||
Token(TokenKind.KEYWORD, "WHEN"),
|
||||
Token(TokenKind.TEXT, " "),
|
||||
Token(TokenKind.REF, "Front Door",
|
||||
entity_kind="zone", entity_id=1, state="SECURE"),
|
||||
Token(TokenKind.TEXT, " is opened"),
|
||||
]
|
||||
assert tokens_to_string(tokens) == "WHEN Front Door [SECURE] is opened"
|
||||
|
||||
|
||||
def test_tokens_to_string_handles_newline_and_indent() -> None:
|
||||
tokens = [
|
||||
Token(TokenKind.KEYWORD, "WHEN"),
|
||||
Token(TokenKind.TEXT, " trigger"),
|
||||
Token(TokenKind.NEWLINE, ""),
|
||||
Token(TokenKind.INDENT, " "),
|
||||
Token(TokenKind.KEYWORD, "AND IF"),
|
||||
Token(TokenKind.TEXT, " condition"),
|
||||
]
|
||||
assert tokens_to_string(tokens) == "WHEN trigger\n AND IF condition"
|
||||
|
||||
|
||||
# ---- Trigger rendering ---------------------------------------------------
|
||||
|
||||
|
||||
def test_render_timed_program() -> None:
|
||||
"""AT 06:00 weekdays → Turn ON LIVING_LAMP."""
|
||||
p = Program(
|
||||
slot=42, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.UNIT_ON), pr2=7,
|
||||
hour=6, minute=0,
|
||||
days=int(Days.MONDAY | Days.TUESDAY | Days.WEDNESDAY
|
||||
| Days.THURSDAY | Days.FRIDAY),
|
||||
)
|
||||
r = _renderer_with(names={("unit", 7): "LIVING_LAMP"})
|
||||
text = tokens_to_string(r.render_program(p))
|
||||
assert text == "AT 06:00 weekdays\nTHEN Turn ON LIVING_LAMP"
|
||||
|
||||
|
||||
def test_render_event_program_zone_state_change() -> None:
|
||||
"""EVENT triggered by zone 5 not-ready → unit 3 OFF."""
|
||||
evt = event_id_zone_state(5, 1) # zone 5 becomes not-ready
|
||||
p = Program(
|
||||
slot=10, prog_type=int(ProgramType.EVENT),
|
||||
cmd=int(Command.UNIT_OFF), pr2=3,
|
||||
month=(evt >> 8) & 0xFF, day=evt & 0xFF,
|
||||
)
|
||||
r = _renderer_with(names={
|
||||
("zone", 5): "FRONT_DOOR",
|
||||
("unit", 3): "PORCH_LIGHT",
|
||||
})
|
||||
assert tokens_to_string(r.render_program(p)) == (
|
||||
"WHEN FRONT_DOOR becomes not ready\n"
|
||||
"THEN Turn OFF PORCH_LIGHT"
|
||||
)
|
||||
|
||||
|
||||
def test_render_yearly_program() -> None:
|
||||
p = Program(
|
||||
slot=99, prog_type=int(ProgramType.YEARLY),
|
||||
cmd=int(Command.UNIT_ON), pr2=12,
|
||||
month=12, day=25, hour=18, minute=30,
|
||||
)
|
||||
r = _renderer_with(names={("unit", 12): "CHRISTMAS_LIGHTS"})
|
||||
assert tokens_to_string(r.render_program(p)) == (
|
||||
"ON 12/25 at 18:30\n"
|
||||
"THEN Turn ON CHRISTMAS_LIGHTS"
|
||||
)
|
||||
|
||||
|
||||
def test_render_remark_program() -> None:
|
||||
"""Remark records render as 'REMARK #N'."""
|
||||
p = Program(
|
||||
slot=5, prog_type=int(ProgramType.REMARK),
|
||||
remark_id=42,
|
||||
)
|
||||
r = _renderer_with()
|
||||
assert tokens_to_string(r.render_program(p)) == "REMARK #42"
|
||||
|
||||
|
||||
def test_render_free_slot() -> None:
|
||||
p = Program(slot=1, prog_type=int(ProgramType.FREE))
|
||||
r = _renderer_with()
|
||||
assert tokens_to_string(r.render_program(p)) == "(empty slot)"
|
||||
|
||||
|
||||
# ---- Event-ID decoding ---------------------------------------------------
|
||||
|
||||
|
||||
def test_render_event_button_press() -> None:
|
||||
"""Button-press events render via the button name."""
|
||||
evt = event_id_user_macro_button(7)
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.EVENT),
|
||||
cmd=int(Command.UNIT_ON), pr2=1,
|
||||
month=(evt >> 8) & 0xFF, day=evt & 0xFF,
|
||||
)
|
||||
r = _renderer_with(names={
|
||||
("button", 7): "GOOD_NIGHT",
|
||||
("unit", 1): "BEDROOM_LAMP",
|
||||
})
|
||||
assert tokens_to_string(r.render_program(p)).startswith(
|
||||
"WHEN GOOD_NIGHT is pressed\n"
|
||||
)
|
||||
|
||||
|
||||
def test_render_event_unit_state_change() -> None:
|
||||
evt = event_id_unit_state(4, on=True)
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.EVENT),
|
||||
cmd=int(Command.UNIT_OFF), pr2=5,
|
||||
month=(evt >> 8) & 0xFF, day=evt & 0xFF,
|
||||
)
|
||||
r = _renderer_with(names={("unit", 4): "ALARM", ("unit", 5): "SIREN"})
|
||||
assert tokens_to_string(r.render_program(p)) == (
|
||||
"WHEN ALARM turns ON\n"
|
||||
"THEN Turn OFF SIREN"
|
||||
)
|
||||
|
||||
|
||||
def test_render_event_ac_power_lost() -> None:
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.EVENT),
|
||||
cmd=int(Command.UNIT_ON), pr2=1,
|
||||
month=(EVENT_AC_POWER_OFF >> 8) & 0xFF,
|
||||
day=EVENT_AC_POWER_OFF & 0xFF,
|
||||
)
|
||||
r = _renderer_with(names={("unit", 1): "EMERGENCY_LIGHT"})
|
||||
assert tokens_to_string(r.render_program(p)) == (
|
||||
"WHEN AC power lost\n"
|
||||
"THEN Turn ON EMERGENCY_LIGHT"
|
||||
)
|
||||
|
||||
|
||||
# ---- Inline AND conditions (compact form) -------------------------------
|
||||
|
||||
|
||||
def test_render_timed_with_inline_zone_condition() -> None:
|
||||
"""TIMED program with an inline AND IF ZONE ... SECURE condition."""
|
||||
# cond = high byte: 0x04 (ZONE family), low byte: zone 5
|
||||
cond = (0x04 << 8) | 5
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.UNIT_ON), pr2=7,
|
||||
hour=22, minute=30,
|
||||
days=int(Days.MONDAY),
|
||||
cond=cond,
|
||||
)
|
||||
r = _renderer_with(names={
|
||||
("zone", 5): "FRONT_DOOR", ("unit", 7): "PORCH_LIGHT",
|
||||
})
|
||||
assert tokens_to_string(r.render_program(p)) == (
|
||||
"AT 22:30 Mon\n"
|
||||
" AND IF FRONT_DOOR is secure\n"
|
||||
"THEN Turn ON PORCH_LIGHT"
|
||||
)
|
||||
|
||||
|
||||
def test_render_timed_with_inline_unit_on_condition() -> None:
|
||||
"""TIMED + AND IF UNIT ... ON. Compact cond high byte 0x0A = CTRL+ON."""
|
||||
cond = (0x0A << 8) | 3 # CTRL family + ON, unit 3
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.UNIT_ON), pr2=7,
|
||||
hour=6, minute=0,
|
||||
days=int(Days.MONDAY),
|
||||
cond=cond,
|
||||
)
|
||||
r = _renderer_with(names={
|
||||
("unit", 3): "OCCUPANCY", ("unit", 7): "KITCHEN_LIGHT",
|
||||
})
|
||||
assert tokens_to_string(r.render_program(p)) == (
|
||||
"AT 06:00 Mon\n"
|
||||
" AND IF OCCUPANCY is ON\n"
|
||||
"THEN Turn ON KITCHEN_LIGHT"
|
||||
)
|
||||
|
||||
|
||||
# ---- Clausal chain rendering --------------------------------------------
|
||||
|
||||
|
||||
def _and_traditional(slot: int, family: int, instance: int = 0) -> Program:
|
||||
return Program(
|
||||
slot=slot, prog_type=int(ProgramType.AND),
|
||||
cond=family & 0xFF, cond2=(instance & 0xFF) << 8,
|
||||
)
|
||||
|
||||
|
||||
def _or_record(slot: int) -> Program:
|
||||
"""An empty OR-separator record. PC Access in practice always
|
||||
bundles a condition into the OR record itself; use ``_or_traditional``
|
||||
for that case. This helper exists for the rare empty-OR cases."""
|
||||
return Program(slot=slot, prog_type=int(ProgramType.OR))
|
||||
|
||||
|
||||
def _or_traditional(slot: int, family: int, instance: int = 0) -> Program:
|
||||
"""OR-alternative record carrying a Traditional condition inline."""
|
||||
return Program(
|
||||
slot=slot, prog_type=int(ProgramType.OR),
|
||||
cond=family & 0xFF, cond2=(instance & 0xFF) << 8,
|
||||
)
|
||||
|
||||
|
||||
def _then_record(slot: int, cmd: int, pr2: int, par: int = 0) -> Program:
|
||||
return Program(
|
||||
slot=slot, prog_type=int(ProgramType.THEN),
|
||||
cmd=cmd, pr2=pr2, par=par,
|
||||
)
|
||||
|
||||
|
||||
def test_render_when_chain_simple() -> None:
|
||||
"""WHEN button N pressed → 1 cond → 1 action."""
|
||||
evt = event_id_user_macro_button(5)
|
||||
when = Program(
|
||||
slot=1, prog_type=int(ProgramType.WHEN),
|
||||
month=(evt >> 8) & 0xFF, day=evt & 0xFF,
|
||||
)
|
||||
and_cond = _and_traditional(2, family=0x04, instance=7) # ZONE 7 secure
|
||||
then = _then_record(3, int(Command.UNIT_ON), 9)
|
||||
chain = ClausalChain(head=when, conditions=(and_cond,), actions=(then,))
|
||||
r = _renderer_with(names={
|
||||
("button", 5): "GOODNIGHT",
|
||||
("zone", 7): "BACK_DOOR",
|
||||
("unit", 9): "HALLWAY",
|
||||
})
|
||||
assert tokens_to_string(r.render_chain(chain)) == (
|
||||
"WHEN GOODNIGHT is pressed\n"
|
||||
" AND IF BACK_DOOR is secure\n"
|
||||
"THEN Turn ON HALLWAY"
|
||||
)
|
||||
|
||||
|
||||
def test_render_when_chain_with_or_branch_and_multiple_actions() -> None:
|
||||
"""Full clausal program with OR branch and two THEN actions."""
|
||||
evt = event_id_user_macro_button(5)
|
||||
when = Program(
|
||||
slot=1, prog_type=int(ProgramType.WHEN),
|
||||
month=(evt >> 8) & 0xFF, day=evt & 0xFF,
|
||||
)
|
||||
chain = ClausalChain(
|
||||
head=when,
|
||||
conditions=(
|
||||
_and_traditional(2, family=0x04, instance=7), # ZONE 7 secure
|
||||
_or_traditional(3, family=0x0A, instance=3), # OR IF UNIT 3 ON
|
||||
),
|
||||
actions=(
|
||||
_then_record(4, int(Command.UNIT_ON), 9), # Turn ON HALLWAY
|
||||
_then_record(5, int(Command.UNIT_OFF), 10), # Turn OFF FOYER
|
||||
),
|
||||
)
|
||||
r = _renderer_with(names={
|
||||
("button", 5): "GOODNIGHT",
|
||||
("zone", 7): "BACK_DOOR",
|
||||
("unit", 3): "MOTION",
|
||||
("unit", 9): "HALLWAY",
|
||||
("unit", 10): "FOYER",
|
||||
})
|
||||
assert tokens_to_string(r.render_chain(chain)) == (
|
||||
"WHEN GOODNIGHT is pressed\n"
|
||||
" AND IF BACK_DOOR is secure\n"
|
||||
" OR IF MOTION is ON\n"
|
||||
"THEN Turn ON HALLWAY\n"
|
||||
"AND Turn OFF FOYER"
|
||||
)
|
||||
|
||||
|
||||
def test_render_at_chain() -> None:
|
||||
"""AT-headed clausal chain with structured-English output."""
|
||||
head = Program(
|
||||
slot=1, prog_type=int(ProgramType.AT),
|
||||
hour=7, minute=15, days=int(Days.SATURDAY | Days.SUNDAY),
|
||||
)
|
||||
chain = ClausalChain(
|
||||
head=head, conditions=(),
|
||||
actions=(_then_record(2, int(Command.UNIT_ON), 12),),
|
||||
)
|
||||
r = _renderer_with(names={("unit", 12): "COFFEE_MAKER"})
|
||||
assert tokens_to_string(r.render_chain(chain)) == (
|
||||
"AT 07:15 weekends\n"
|
||||
"THEN Turn ON COFFEE_MAKER"
|
||||
)
|
||||
|
||||
|
||||
def test_render_every_chain() -> None:
|
||||
head = Program(
|
||||
slot=1, prog_type=int(ProgramType.EVERY),
|
||||
# every_interval = ((cond & 0xFF) << 8) | ((cond2 >> 8) & 0xFF).
|
||||
# For interval=60: cond=0, cond2=60<<8=0x3C00 → 60 sec = 1 min.
|
||||
cond=0, cond2=60 << 8,
|
||||
)
|
||||
chain = ClausalChain(
|
||||
head=head, conditions=(),
|
||||
actions=(_then_record(2, int(Command.UNIT_ON), 1),),
|
||||
)
|
||||
r = _renderer_with(names={("unit", 1): "AERATOR"})
|
||||
assert tokens_to_string(r.render_chain(chain)) == (
|
||||
"EVERY 1 min\n"
|
||||
"THEN Turn ON AERATOR"
|
||||
)
|
||||
|
||||
|
||||
# ---- Structured AND/OR rendering ----------------------------------------
|
||||
|
||||
|
||||
def _and_structured(
|
||||
slot: int, op: int,
|
||||
arg1_type: int, arg1_ix: int, arg1_field: int,
|
||||
arg2_type: int, arg2_ix: int, arg2_field: int = 0,
|
||||
) -> Program:
|
||||
return Program(
|
||||
slot=slot, prog_type=int(ProgramType.AND),
|
||||
cond=(op << 8) | arg1_type,
|
||||
cond2=arg1_ix,
|
||||
cmd=arg1_field,
|
||||
par=arg2_type,
|
||||
pr2=arg2_ix,
|
||||
month=arg2_field,
|
||||
)
|
||||
|
||||
|
||||
def test_render_structured_zone_current_state_eq_constant() -> None:
|
||||
"""AND IF Zone(5).CurrentState == 1"""
|
||||
and_rec = _and_structured(
|
||||
slot=1, op=int(CondOP.ARG1_EQ_ARG2),
|
||||
arg1_type=int(CondArgType.ZONE), arg1_ix=5, arg1_field=2,
|
||||
arg2_type=int(CondArgType.CONSTANT), arg2_ix=1,
|
||||
)
|
||||
chain = ClausalChain(
|
||||
head=Program(slot=0, prog_type=int(ProgramType.WHEN),
|
||||
month=0, day=1),
|
||||
conditions=(and_rec,),
|
||||
actions=(_then_record(2, int(Command.UNIT_ON), 9),),
|
||||
)
|
||||
r = _renderer_with(names={
|
||||
("zone", 5): "FRONT_DOOR",
|
||||
("button", 1): "BTN_1",
|
||||
("unit", 9): "HALLWAY",
|
||||
})
|
||||
text = tokens_to_string(r.render_chain(chain))
|
||||
assert "AND IF FRONT_DOOR.CurrentState == 1" in text
|
||||
|
||||
|
||||
def test_render_structured_thermostat_temp_gt_constant() -> None:
|
||||
"""AND IF Thermostat(1).Temperature > 75"""
|
||||
and_rec = _and_structured(
|
||||
slot=1, op=int(CondOP.ARG1_GT_ARG2),
|
||||
arg1_type=int(CondArgType.THERMOSTAT), arg1_ix=1, arg1_field=1,
|
||||
arg2_type=int(CondArgType.CONSTANT), arg2_ix=75,
|
||||
)
|
||||
chain = ClausalChain(
|
||||
head=Program(slot=0, prog_type=int(ProgramType.WHEN), month=0, day=1),
|
||||
conditions=(and_rec,),
|
||||
actions=(_then_record(2, int(Command.UNIT_ON), 1),),
|
||||
)
|
||||
r = _renderer_with(names={
|
||||
("thermostat", 1): "DOWNSTAIRS",
|
||||
("button", 1): "BTN_1",
|
||||
("unit", 1): "AC",
|
||||
})
|
||||
text = tokens_to_string(r.render_chain(chain))
|
||||
assert "AND IF DOWNSTAIRS.Temperature > 75" in text
|
||||
|
||||
|
||||
def test_render_structured_timedate_hour_eq() -> None:
|
||||
"""AND IF TimeDate.Hour == 22"""
|
||||
and_rec = _and_structured(
|
||||
slot=1, op=int(CondOP.ARG1_EQ_ARG2),
|
||||
arg1_type=int(CondArgType.TIME_DATE), arg1_ix=0, arg1_field=8,
|
||||
arg2_type=int(CondArgType.CONSTANT), arg2_ix=22,
|
||||
)
|
||||
chain = ClausalChain(
|
||||
head=Program(slot=0, prog_type=int(ProgramType.WHEN), month=0, day=1),
|
||||
conditions=(and_rec,),
|
||||
actions=(_then_record(2, int(Command.UNIT_ON), 1),),
|
||||
)
|
||||
r = _renderer_with(names={("button", 1): "BTN", ("unit", 1): "LIGHT"})
|
||||
text = tokens_to_string(r.render_chain(chain))
|
||||
assert "AND IF Hour == 22" in text
|
||||
|
||||
|
||||
# ---- Action verb rendering ----------------------------------------------
|
||||
|
||||
|
||||
def test_render_action_bypass_zone() -> None:
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.BYPASS_ZONE), pr2=5,
|
||||
hour=22, minute=0, days=int(Days.MONDAY),
|
||||
)
|
||||
r = _renderer_with(names={("zone", 5): "WINDOW"})
|
||||
assert "THEN Bypass WINDOW" in tokens_to_string(r.render_program(p))
|
||||
|
||||
|
||||
def test_render_action_unit_level_with_percentage() -> None:
|
||||
"""UNIT_LEVEL uses ``par`` as the percentage."""
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.UNIT_LEVEL), pr2=7, par=50,
|
||||
hour=6, minute=0, days=int(Days.MONDAY),
|
||||
)
|
||||
r = _renderer_with(names={("unit", 7): "DIMMER"})
|
||||
assert "THEN Set level DIMMER to 50%" in tokens_to_string(r.render_program(p))
|
||||
|
||||
|
||||
def test_render_action_security_arm() -> None:
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.SECURITY_AWAY), pr2=1,
|
||||
hour=22, minute=0, days=int(Days.MONDAY),
|
||||
)
|
||||
r = _renderer_with(names={("area", 1): "MAIN"})
|
||||
assert "THEN Arm Away MAIN" in tokens_to_string(r.render_program(p))
|
||||
|
||||
|
||||
# ---- Live-state overlay --------------------------------------------------
|
||||
|
||||
|
||||
def test_live_state_overlay_appears_in_string() -> None:
|
||||
"""When a state resolver is set, REF tokens get bracketed badges."""
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.UNIT_ON), pr2=7,
|
||||
hour=6, minute=0, days=int(Days.MONDAY),
|
||||
)
|
||||
r = _renderer_with(
|
||||
names={("unit", 7): "LIVING_LAMP"},
|
||||
states={("unit", 7): "OFF"},
|
||||
)
|
||||
text = tokens_to_string(r.render_program(p))
|
||||
assert "Turn ON LIVING_LAMP [OFF]" in text
|
||||
|
||||
|
||||
def test_live_state_overlay_tokens_carry_state_field() -> None:
|
||||
"""REF tokens themselves have .state populated — not just the text."""
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.UNIT_ON), pr2=7,
|
||||
hour=6, minute=0, days=int(Days.MONDAY),
|
||||
)
|
||||
r = _renderer_with(
|
||||
names={("unit", 7): "LIVING_LAMP"},
|
||||
states={("unit", 7): "ON 50%"},
|
||||
)
|
||||
refs = [t for t in r.render_program(p) if t.kind == TokenKind.REF]
|
||||
assert len(refs) == 1
|
||||
assert refs[0].entity_kind == "unit"
|
||||
assert refs[0].entity_id == 7
|
||||
assert refs[0].state == "ON 50%"
|
||||
|
||||
|
||||
def test_live_state_absent_when_resolver_returns_none() -> None:
|
||||
"""A resolver that doesn't know about an entity omits the badge."""
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.UNIT_ON), pr2=99,
|
||||
hour=6, minute=0, days=int(Days.MONDAY),
|
||||
)
|
||||
r = _renderer_with(states={("unit", 7): "ON"}) # nothing for unit 99
|
||||
text = tokens_to_string(r.render_program(p))
|
||||
assert "[" not in text # no badge anywhere
|
||||
|
||||
|
||||
# ---- MockStateResolver end-to-end ---------------------------------------
|
||||
|
||||
|
||||
def test_mock_state_resolver_zone_badge() -> None:
|
||||
state = MockState(zones={
|
||||
5: MockZoneState(name="FRONT_DOOR", current_state=1), # not-ready
|
||||
})
|
||||
res = MockStateResolver(state)
|
||||
assert res.name_of("zone", 5) == "FRONT_DOOR"
|
||||
assert res.state_of("zone", 5) == "NOT READY"
|
||||
|
||||
|
||||
def test_mock_state_resolver_unit_on_with_dim_level() -> None:
|
||||
state = MockState(units={3: MockUnitState(name="DIMMER", state=150)})
|
||||
res = MockStateResolver(state)
|
||||
assert res.state_of("unit", 3) == "ON 50%"
|
||||
|
||||
|
||||
def test_mock_state_resolver_area_security_mode() -> None:
|
||||
state = MockState(areas={1: MockAreaState(name="MAIN", mode=3)})
|
||||
res = MockStateResolver(state)
|
||||
assert res.state_of("area", 1) == "Away"
|
||||
|
||||
|
||||
def test_mock_state_resolver_thermostat_temperature() -> None:
|
||||
state = MockState(thermostats={1: MockThermostatState(temperature_raw=170)})
|
||||
res = MockStateResolver(state)
|
||||
# raw 170 / 2 - 40 = 45°F (low side of the linear scale)
|
||||
assert res.state_of("thermostat", 1) == "45°F"
|
||||
|
||||
|
||||
def test_mock_state_resolver_unknown_kind_returns_none() -> None:
|
||||
res = MockStateResolver(MockState())
|
||||
assert res.state_of("nonexistent", 1) is None
|
||||
|
||||
|
||||
# ---- AccountNameResolver end-to-end -------------------------------------
|
||||
|
||||
|
||||
def test_account_name_resolver_pulls_from_account() -> None:
|
||||
@dataclass
|
||||
class _AcctStub:
|
||||
zone_names: dict[int, str]
|
||||
unit_names: dict[int, str]
|
||||
|
||||
acct = _AcctStub(
|
||||
zone_names={1: "FRONT", 2: "BACK"},
|
||||
unit_names={5: "LAMP"},
|
||||
)
|
||||
res = AccountNameResolver(acct)
|
||||
assert res.name_of("zone", 1) == "FRONT"
|
||||
assert res.name_of("unit", 5) == "LAMP"
|
||||
assert res.name_of("zone", 99) is None
|
||||
assert res.name_of("area", 1) is None # no area_names on stub
|
||||
|
||||
|
||||
# ---- Summary (one-liner) --------------------------------------------------
|
||||
|
||||
|
||||
def test_summarize_timed_program() -> None:
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.UNIT_ON), pr2=7,
|
||||
hour=22, minute=30, days=int(Days.MONDAY),
|
||||
)
|
||||
r = _renderer_with(names={("unit", 7): "LAMP"})
|
||||
assert tokens_to_string(r.summarize_program(p)) == (
|
||||
"22:30 Mon → Turn ON LAMP"
|
||||
)
|
||||
|
||||
|
||||
def test_summarize_compact_program_with_conditions() -> None:
|
||||
"""Summary shows count of inline conditions."""
|
||||
cond = (0x04 << 8) | 5 # AND IF zone 5 secure
|
||||
p = Program(
|
||||
slot=1, prog_type=int(ProgramType.TIMED),
|
||||
cmd=int(Command.UNIT_ON), pr2=7,
|
||||
hour=22, minute=30, days=int(Days.MONDAY),
|
||||
cond=cond,
|
||||
)
|
||||
r = _renderer_with(names={("unit", 7): "LAMP", ("zone", 5): "DOOR"})
|
||||
text = tokens_to_string(r.summarize_program(p))
|
||||
assert "(+1 cond)" in text
|
||||
|
||||
|
||||
# ---- Live-fixture smoke test --------------------------------------------
|
||||
|
||||
|
||||
def test_renderer_handles_every_program_in_live_fixture() -> None:
|
||||
"""Every defined program in the live .pca fixture renders cleanly.
|
||||
|
||||
This is the broadest correctness signal: 330 real homeowner-authored
|
||||
programs with names, conditions, and actions, all decoded by the
|
||||
same code path the HA panel will use. Skipped when the gitignored
|
||||
fixture isn't on disk.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
fixture = Path("/home/kdm/home-auto/HAI/pca-re/extracted/Our_House.pca.plain")
|
||||
if not fixture.is_file():
|
||||
pytest.skip(f"fixture not available: {fixture}")
|
||||
from omni_pca.pca_file import KEY_EXPORT, decrypt_pca_bytes, parse_pca_file
|
||||
|
||||
acct = parse_pca_file(decrypt_pca_bytes(fixture.read_bytes(), KEY_EXPORT),
|
||||
key=KEY_EXPORT)
|
||||
r = ProgramRenderer(names=AccountNameResolver(acct))
|
||||
defined = [p for p in acct.programs if not p.is_empty()]
|
||||
assert len(defined) == 330
|
||||
|
||||
# Every program produces a non-empty summary + full render. No
|
||||
# exception should escape — the renderer's job is to be informative
|
||||
# even for records it doesn't fully understand.
|
||||
for p in defined:
|
||||
summary = tokens_to_string(r.summarize_program(p))
|
||||
full = tokens_to_string(r.render_program(p))
|
||||
assert summary
|
||||
assert full
|
||||
|
||||
# The first few programs in this fixture are button-press chains
|
||||
# against the garage doors — confirm the rendering reads the way
|
||||
# we expect ("WHEN ... is pressed AND IF ... is secure THEN ...").
|
||||
slot1 = tokens_to_string(r.render_program(acct.programs[0]))
|
||||
assert slot1.startswith("WHEN ")
|
||||
assert "is pressed" in slot1
|
||||
assert "\n AND IF " in slot1
|
||||
assert "\nTHEN " in slot1
|
||||
|
||||
|
||||
def test_summarize_chain() -> None:
|
||||
evt = event_id_user_macro_button(5)
|
||||
chain = ClausalChain(
|
||||
head=Program(
|
||||
slot=1, prog_type=int(ProgramType.WHEN),
|
||||
month=(evt >> 8) & 0xFF, day=evt & 0xFF,
|
||||
),
|
||||
conditions=(
|
||||
_and_traditional(2, family=0x04, instance=7),
|
||||
_and_traditional(3, family=0x0A, instance=3),
|
||||
),
|
||||
actions=(
|
||||
_then_record(4, int(Command.UNIT_ON), 9),
|
||||
_then_record(5, int(Command.UNIT_OFF), 10),
|
||||
),
|
||||
)
|
||||
r = _renderer_with(names={
|
||||
("button", 5): "BTN", ("unit", 9): "L1", ("unit", 10): "L2",
|
||||
})
|
||||
text = tokens_to_string(r.summarize_chain(chain))
|
||||
assert text == "WHEN BTN is pressed (+2 cond) → Turn ON L1 (+1 more)"
|
||||
618
tests/test_programs.py
Normal file
@ -0,0 +1,618 @@
|
||||
"""Unit tests for omni_pca.programs.
|
||||
|
||||
Three layers of evidence (no external oracle, so we triangulate):
|
||||
|
||||
* **Golden bytes per ProgramType** — hand-curated byte vectors that
|
||||
exercise the layout-specific paths (Mon/Day swap for Event, Remark
|
||||
variant with RemarkID at bytes 1-4).
|
||||
* **Round-trip property** — random 14-byte inputs survive
|
||||
``decode(b).encode() == b`` for both wire and file layouts.
|
||||
* **Unknown-enum tolerance** — bytes outside ``ProgramType`` /
|
||||
``Command`` enum domains pass through without raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import struct
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
from omni_pca.programs import (
|
||||
MAX_PROGRAMS,
|
||||
PROGRAM_BYTES,
|
||||
Days,
|
||||
Program,
|
||||
ProgramType,
|
||||
decode_program_table,
|
||||
iter_defined,
|
||||
)
|
||||
|
||||
# ---- golden bytes ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_timed_decodes_canonical_example() -> None:
|
||||
"""The worked example from the docs page — TIMED program.
|
||||
|
||||
``cond``, ``cond2`` and ``pr2`` are **little-endian** u16 fields:
|
||||
byte N is the low byte, byte N+1 the high byte. The byte vector
|
||||
below comes from ``Our_House.pca`` slot 22 (a real TIMED program
|
||||
for an HLC scene at 07:15 weekday mornings).
|
||||
"""
|
||||
body = bytes.fromhex("018d099b094403010008 0c3e070f".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=22)
|
||||
assert p.slot == 22
|
||||
assert p.prog_type == ProgramType.TIMED
|
||||
# bytes 1,2 = [8d 09] → LE u16 = 0x098D
|
||||
assert p.cond == 0x098D
|
||||
# bytes 3,4 = [9b 09] → LE u16 = 0x099B
|
||||
assert p.cond2 == 0x099B
|
||||
assert p.cmd == 0x44
|
||||
assert p.par == 3
|
||||
# bytes 7,8 = [01 00] → LE u16 = 0x0001 (object #1)
|
||||
assert p.pr2 == 0x0001
|
||||
assert p.month == 8
|
||||
assert p.day == 12
|
||||
assert p.days == 0x3E
|
||||
assert p.hour == 7
|
||||
assert p.minute == 15
|
||||
assert p.remark_id is None
|
||||
# round-trip on file form
|
||||
assert p.encode_file_record() == body
|
||||
|
||||
|
||||
def test_event_swaps_mon_day_on_file_layout() -> None:
|
||||
"""EVENT programs store [day, month] at offsets 9/10 on disk.
|
||||
|
||||
File bytes 9-10 = ``05 0c`` should decode to day=5, month=12 (not
|
||||
month=5, day=12). Encoding back must preserve the swap so the
|
||||
raw .pca slot bytes don't drift.
|
||||
"""
|
||||
body = bytes.fromhex("020c04000001010000050cff070f") # 14 bytes
|
||||
assert len(body) == 14
|
||||
p = Program.from_file_record(body, slot=1)
|
||||
assert p.prog_type == ProgramType.EVENT
|
||||
# Disk had [05, 0c] but EVENT swap means [day, mon].
|
||||
assert p.day == 5
|
||||
assert p.month == 12
|
||||
# Round-trip MUST re-apply the swap.
|
||||
assert p.encode_file_record() == body
|
||||
|
||||
|
||||
def test_event_no_swap_on_wire_layout() -> None:
|
||||
"""Same EVENT-type bytes via the wire decoder: NO swap.
|
||||
|
||||
On the wire ``clsOLMsgProgramData`` always stores [month, day] at
|
||||
offsets 9/10 regardless of prog_type — only the .pca file form
|
||||
swaps. This test catches a regression where we accidentally swap
|
||||
in the wire path.
|
||||
"""
|
||||
body = bytes.fromhex("020c04000001010000050cff070f")
|
||||
p = Program.from_wire_bytes(body, slot=1)
|
||||
assert p.prog_type == ProgramType.EVENT
|
||||
# Wire form: byte 9 = month, byte 10 = day. NO swap.
|
||||
assert p.month == 5
|
||||
assert p.day == 12
|
||||
assert p.encode_wire_bytes() == body
|
||||
|
||||
|
||||
def test_yearly_program() -> None:
|
||||
"""YEARLY programs use month + day fields semantically — no swap."""
|
||||
body = bytes.fromhex("03000000000100008b010a0f00000001")[:14]
|
||||
p = Program.from_file_record(body, slot=10)
|
||||
assert p.prog_type == ProgramType.YEARLY
|
||||
assert p.month == 0x01
|
||||
assert p.day == 0x0A
|
||||
assert p.encode_file_record() == body
|
||||
|
||||
|
||||
def test_remark_uses_bytes_1_to_4_as_remark_id() -> None:
|
||||
"""REMARK programs (prog_type=4) pack a 32-bit BE RemarkID into
|
||||
bytes 1-4 in place of cond + cond2."""
|
||||
remark_id = 0xDEADBEEF
|
||||
body = (
|
||||
bytes([int(ProgramType.REMARK)])
|
||||
+ struct.pack(">I", remark_id)
|
||||
+ bytes(9)
|
||||
)
|
||||
assert len(body) == 14
|
||||
p = Program.from_file_record(body)
|
||||
assert p.prog_type == ProgramType.REMARK
|
||||
assert p.remark_id == remark_id
|
||||
# cond / cond2 are zeroed in the dataclass — the bytes there are
|
||||
# the RemarkID, not condition fields.
|
||||
assert p.cond == 0
|
||||
assert p.cond2 == 0
|
||||
# Round-trip restores the RemarkID bytes verbatim.
|
||||
assert p.encode_file_record() == body
|
||||
|
||||
|
||||
def test_all_zero_slot_is_empty() -> None:
|
||||
"""A free slot decodes cleanly and round-trips."""
|
||||
p = Program.from_file_record(b"\x00" * 14, slot=999)
|
||||
assert p.is_empty()
|
||||
assert p.prog_type == ProgramType.FREE
|
||||
assert p.encode_file_record() == b"\x00" * 14
|
||||
assert p.encode_wire_bytes() == b"\x00" * 14
|
||||
|
||||
|
||||
# ---- round-trip property ---------------------------------------------------
|
||||
|
||||
|
||||
def test_random_round_trip_wire() -> None:
|
||||
"""500 random 14-byte inputs: ``decode_wire → encode_wire == input``.
|
||||
|
||||
The wire path is the simpler one (no Mon/Day swap), so it should
|
||||
round-trip every byte pattern losslessly.
|
||||
"""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
for _ in range(500):
|
||||
body = os.urandom(14)
|
||||
# Skip Remark inputs in this round — the dataclass discards
|
||||
# cond/cond2 for Remark types and re-derives them from
|
||||
# remark_id, but with no separate cond field we'd lose
|
||||
# bytes that happen to differ; the next test covers Remark
|
||||
# explicitly.
|
||||
if body[0] == int(ProgramType.REMARK):
|
||||
continue
|
||||
p = Program.from_wire_bytes(body)
|
||||
assert p.encode_wire_bytes() == body
|
||||
|
||||
|
||||
def test_random_round_trip_file() -> None:
|
||||
"""500 random 14-byte inputs through the file (Mon/Day swap) form."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
for _ in range(500):
|
||||
body = os.urandom(14)
|
||||
if body[0] == int(ProgramType.REMARK):
|
||||
continue
|
||||
p = Program.from_file_record(body)
|
||||
assert p.encode_file_record() == body
|
||||
|
||||
|
||||
def test_remark_round_trip() -> None:
|
||||
"""Remark variant round-trip — explicitly, with random RemarkIDs."""
|
||||
for _ in range(200):
|
||||
remark_id_bytes = os.urandom(4)
|
||||
body = (
|
||||
bytes([int(ProgramType.REMARK)])
|
||||
+ remark_id_bytes
|
||||
+ os.urandom(9)
|
||||
)
|
||||
p_file = Program.from_file_record(body)
|
||||
assert p_file.encode_file_record() == body
|
||||
p_wire = Program.from_wire_bytes(body)
|
||||
assert p_wire.encode_wire_bytes() == body
|
||||
|
||||
|
||||
# ---- unknown-enum tolerance ------------------------------------------------
|
||||
|
||||
|
||||
def test_unknown_prog_type_passes_through_with_warning() -> None:
|
||||
"""Bytes outside ProgramType (0..10) decode to a raw int + warning.
|
||||
|
||||
Reset the once-per-process cache first; otherwise earlier random
|
||||
round-trip tests may have already seen this value and silenced
|
||||
the warning.
|
||||
"""
|
||||
import omni_pca.programs as pm
|
||||
pm._warned_unknown.clear()
|
||||
body = bytes([0x42]) + bytes(13)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
p = Program.from_wire_bytes(body)
|
||||
assert p.prog_type == 0x42
|
||||
assert p.encode_wire_bytes() == body
|
||||
assert any("ProgramType" in str(w.message) for w in caught)
|
||||
|
||||
|
||||
def test_unknown_cmd_passes_through() -> None:
|
||||
"""Unrecognised cmd bytes decode without raising."""
|
||||
body = bytes([int(ProgramType.TIMED), 0, 0, 0, 0, 0xFA, 0]) + bytes(7)
|
||||
p = Program.from_wire_bytes(body)
|
||||
assert p.cmd == 0xFA
|
||||
assert p.encode_wire_bytes() == body
|
||||
|
||||
|
||||
# ---- table decode ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_decode_program_table_size_validation() -> None:
|
||||
with pytest.raises(ValueError, match="must be 21000 bytes"):
|
||||
decode_program_table(b"\x00" * 100)
|
||||
|
||||
|
||||
def test_decode_program_table_round_trip_all_zero() -> None:
|
||||
"""All-zero 21000-byte blob round-trips, slot numbers are 1..1500."""
|
||||
blob = b"\x00" * (MAX_PROGRAMS * PROGRAM_BYTES)
|
||||
programs = decode_program_table(blob)
|
||||
assert len(programs) == MAX_PROGRAMS
|
||||
assert programs[0].slot == 1
|
||||
assert programs[-1].slot == MAX_PROGRAMS
|
||||
assert all(p.is_empty() for p in programs)
|
||||
assert list(iter_defined(programs)) == []
|
||||
rebuilt = b"".join(p.encode_file_record() for p in programs)
|
||||
assert rebuilt == blob
|
||||
|
||||
|
||||
def test_iter_defined_filters_empty() -> None:
|
||||
p1 = Program(prog_type=int(ProgramType.TIMED), slot=1, cmd=1, hour=8)
|
||||
p2 = Program(slot=2) # empty
|
||||
p3 = Program(prog_type=int(ProgramType.EVENT), slot=3, cmd=2)
|
||||
defined = list(iter_defined((p1, p2, p3)))
|
||||
assert defined == [p1, p3]
|
||||
|
||||
|
||||
# ---- Days bitmask sanity --------------------------------------------------
|
||||
|
||||
|
||||
def test_days_bitmask_values() -> None:
|
||||
"""Sanity-check the enuDays values against the C# definition."""
|
||||
assert Days.MONDAY == 0x02
|
||||
assert Days.SUNDAY == 0x80
|
||||
assert Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY == 0x2A
|
||||
|
||||
|
||||
# ---- TimeKind classification + sunrise/sunset offsets ----------------------
|
||||
|
||||
|
||||
from omni_pca.programs import TimeKind # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hour, minute, expected_kind, expected_offset, expected_label",
|
||||
[
|
||||
# Absolute times — hour 0..23, minute 0..59.
|
||||
(0, 0, TimeKind.ABSOLUTE, 0, "00:00"),
|
||||
(7, 15, TimeKind.ABSOLUTE, 0, "07:15"),
|
||||
(23, 59, TimeKind.ABSOLUTE, 0, "23:59"),
|
||||
# Sunrise-relative.
|
||||
(25, 0, TimeKind.SUNRISE, 0, "at sunrise"),
|
||||
(25, 30, TimeKind.SUNRISE, 30, "30 min after sunrise"),
|
||||
(25, 226, TimeKind.SUNRISE, -30, "30 min before sunrise"),
|
||||
(25, 255, TimeKind.SUNRISE, -1, "1 min before sunrise"),
|
||||
(25, 127, TimeKind.SUNRISE, 127, "127 min after sunrise"),
|
||||
# Sunset-relative.
|
||||
(26, 0, TimeKind.SUNSET, 0, "at sunset"),
|
||||
(26, 10, TimeKind.SUNSET, 10, "10 min after sunset"),
|
||||
(26, 246, TimeKind.SUNSET, -10, "10 min before sunset"),
|
||||
(26, 128, TimeKind.SUNSET, -128, "128 min before sunset"),
|
||||
],
|
||||
)
|
||||
def test_time_kind_classification(
|
||||
hour, minute, expected_kind, expected_offset, expected_label
|
||||
) -> None:
|
||||
p = Program(
|
||||
prog_type=int(ProgramType.TIMED),
|
||||
hour=hour, minute=minute, days=int(Days.MONDAY),
|
||||
)
|
||||
assert p.time_kind == expected_kind
|
||||
assert p.time_offset_minutes == expected_offset
|
||||
assert p.format_time() == expected_label
|
||||
|
||||
|
||||
def test_time_kind_round_trip_through_wire() -> None:
|
||||
"""Build a sunset-relative program, encode → decode → assert preserved."""
|
||||
p = Program(
|
||||
prog_type=int(ProgramType.TIMED),
|
||||
hour=26, minute=246, # 10 min before sunset
|
||||
days=int(Days.FRIDAY | Days.SATURDAY),
|
||||
)
|
||||
body = p.encode_wire_bytes()
|
||||
p2 = Program.from_wire_bytes(body)
|
||||
assert p2.time_kind == TimeKind.SUNSET
|
||||
assert p2.time_offset_minutes == -10
|
||||
assert p2.format_time() == "10 min before sunset"
|
||||
|
||||
|
||||
# ---- Condition bit-split decoder -----------------------------------------
|
||||
|
||||
|
||||
from omni_pca.programs import ( # noqa: E402
|
||||
Condition,
|
||||
ConditionFamily,
|
||||
MiscConditional,
|
||||
)
|
||||
|
||||
|
||||
def test_condition_empty() -> None:
|
||||
"""cond == 0 → no condition applies."""
|
||||
c = Condition.decode(0)
|
||||
assert c.is_empty()
|
||||
assert c.family is ConditionFamily.OTHER
|
||||
assert c.describe() == "(no condition)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cond, family, selector, operand, expected_describe",
|
||||
[
|
||||
# OTHER family — bits 0-3 = MiscConditional value
|
||||
(0x0000, ConditionFamily.OTHER, 0, 0, "(no condition)"),
|
||||
(0x0002, ConditionFamily.OTHER, 2, 0, "LIGHT"),
|
||||
(0x0003, ConditionFamily.OTHER, 3, 0, "DARK"),
|
||||
(0x0008, ConditionFamily.OTHER, 8, 0, "AC_POWER_OFF"),
|
||||
(0x000B, ConditionFamily.OTHER, 11, 0, "BATTERY_OK"),
|
||||
(0x010B, ConditionFamily.OTHER, 11, 0, "BATTERY_OK"), # high bits ignored
|
||||
# ZONE family — bits 0-7 = zone, bit 9 = NOT_READY (1) / SECURE (0)
|
||||
(0x0405, ConditionFamily.ZONE, 5, 0, "Zone 5 SECURE"),
|
||||
(0x0605, ConditionFamily.ZONE, 5, 1, "Zone 5 NOT_READY"),
|
||||
(0x040B, ConditionFamily.ZONE, 11, 0, "Zone 11 SECURE"),
|
||||
# CTRL family — bits 0-8 = unit, bit 9 = ON (1) / OFF (0)
|
||||
(0x0801, ConditionFamily.CTRL, 1, 0, "Unit 1 OFF"),
|
||||
(0x0A01, ConditionFamily.CTRL, 1, 1, "Unit 1 ON"),
|
||||
(0x09FF, ConditionFamily.CTRL, 0x1FF, 0, "Unit 511 OFF"), # 9-bit unit
|
||||
# TIME family — bits 0-7 = clock, bit 9 = ENABLED (1) / DISABLED (0)
|
||||
(0x0C04, ConditionFamily.TIME, 4, 0, "Time clock 4 DISABLED"),
|
||||
(0x0E03, ConditionFamily.TIME, 3, 1, "Time clock 3 ENABLED"),
|
||||
# SEC family — bits 8-11 = area, bits 12-14 = mode, bit 15 = arming flag
|
||||
# mode=Off (0) with bit 15: encode of "area X is in mode Off"
|
||||
(0x8100, ConditionFamily.SEC, 1, 0, "Area 1 OFF"),
|
||||
(0x8800, ConditionFamily.SEC, 8, 0, "Area 8 OFF"),
|
||||
# mode=Day (1), no exit-delay flag → not arming-transition
|
||||
(0x1100, ConditionFamily.SEC, 1, 1, "Area 1 DAY"),
|
||||
# mode=Away (3), bit 15 set → ARMING (in transition)
|
||||
(0xB100, ConditionFamily.SEC, 1, 3, "Area 1 ARMING AWAY"),
|
||||
# area=0 selector → "(any area)"
|
||||
(0x9000, ConditionFamily.SEC, 0, 1, "(any area) ARMING DAY"),
|
||||
],
|
||||
)
|
||||
def test_condition_decode_per_family(
|
||||
cond, family, selector, operand, expected_describe
|
||||
) -> None:
|
||||
c = Condition.decode(cond)
|
||||
assert c.family == family, (
|
||||
f"cond={cond:#06x} family expected {family.name}, got {c.family.name}"
|
||||
)
|
||||
assert c.selector == selector, f"cond={cond:#06x} selector"
|
||||
assert c.operand == operand, f"cond={cond:#06x} operand"
|
||||
assert c.describe() == expected_describe
|
||||
|
||||
|
||||
def test_condition_arming_transition_flag_only_when_mode_nonzero() -> None:
|
||||
"""Bit 15 + mode=Off is the 'plain off' encoding, NOT an arming transition.
|
||||
|
||||
Per clsText.cs:2263, the arming-transition branch requires
|
||||
``(cond & 0xF000) != 0x8000``, which fails when only bit 15 is set
|
||||
(mode bits 12-14 are zero).
|
||||
"""
|
||||
plain_off = Condition.decode(0x8100)
|
||||
assert plain_off.arming_transition is False
|
||||
assert plain_off.describe() == "Area 1 OFF"
|
||||
|
||||
arming = Condition.decode(0xB100) # bit 15 + mode=3 (AWAY)
|
||||
assert arming.arming_transition is True
|
||||
assert "ARMING" in arming.describe()
|
||||
|
||||
|
||||
def test_program_condition_helpers() -> None:
|
||||
"""Program.condition() / condition2() decode the raw u16 fields."""
|
||||
p = Program(
|
||||
prog_type=int(ProgramType.TIMED),
|
||||
cond=0x0605, # Zone 5 NOT_READY
|
||||
cond2=0xB100, # Area 1 ARMING AWAY
|
||||
)
|
||||
c1 = p.condition()
|
||||
c2 = p.condition2()
|
||||
assert c1.family is ConditionFamily.ZONE
|
||||
assert c1.selector == 5
|
||||
assert c1.describe() == "Zone 5 NOT_READY"
|
||||
assert c2.family is ConditionFamily.SEC
|
||||
assert c2.describe() == "Area 1 ARMING AWAY"
|
||||
|
||||
|
||||
def test_condition_rejects_out_of_u16_range() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
Condition.decode(-1)
|
||||
with pytest.raises(ValueError):
|
||||
Condition.decode(0x10000)
|
||||
|
||||
|
||||
def test_misc_conditional_enum_matches_csharp() -> None:
|
||||
"""enuMiscConditional values mirrored from clsText.cs."""
|
||||
assert MiscConditional.NONE == 0
|
||||
assert MiscConditional.DARK == 3
|
||||
assert MiscConditional.AC_POWER_OFF == 8
|
||||
assert MiscConditional.BATTERY_OK == 11
|
||||
assert MiscConditional.ENERGY_COST_CRITICAL == 15
|
||||
|
||||
|
||||
# ---- multi-record (firmware ≥3.0.0) decoder properties ----------------
|
||||
|
||||
|
||||
def test_is_multi_record_classifier() -> None:
|
||||
"""Compact-form ProgTypes (0-4) are NOT multi-record; 5-10 ARE."""
|
||||
for pt in (
|
||||
ProgramType.FREE,
|
||||
ProgramType.TIMED,
|
||||
ProgramType.EVENT,
|
||||
ProgramType.YEARLY,
|
||||
ProgramType.REMARK,
|
||||
):
|
||||
p = Program(prog_type=int(pt))
|
||||
assert not p.is_multi_record(), f"{pt.name} should NOT be multi-record"
|
||||
for pt in (
|
||||
ProgramType.WHEN,
|
||||
ProgramType.AT,
|
||||
ProgramType.EVERY,
|
||||
ProgramType.AND,
|
||||
ProgramType.OR,
|
||||
ProgramType.THEN,
|
||||
):
|
||||
p = Program(prog_type=int(pt))
|
||||
assert p.is_multi_record(), f"{pt.name} SHOULD be multi-record"
|
||||
|
||||
|
||||
def test_when_event_id_zone_5_secure() -> None:
|
||||
"""WHEN record bytes 9-10 = (family, instance) in BE wire form.
|
||||
|
||||
Empirical capture: "WHEN ZONE 5 SECURE" yields bytes 9-10 = [04, 05]
|
||||
→ event_id = 0x0405 (= (ZONE=4, instance=5)).
|
||||
"""
|
||||
body = bytes.fromhex("05 00 00 00 00 00 00 00 00 04 05 00 00 00".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=17)
|
||||
assert p.prog_type == ProgramType.WHEN
|
||||
assert p.event_id == 0x0405
|
||||
# The family code 0x04 in the high byte matches ProgramCond.ZONE
|
||||
assert (p.event_id >> 8) & 0xFC == 0x04 # ZONE family
|
||||
assert p.event_id & 0xFF == 0x05 # zone # 5
|
||||
|
||||
|
||||
def test_when_event_id_zone_1_secure() -> None:
|
||||
"""Second WHEN capture: ZONE 1 SECURE → event_id 0x0401."""
|
||||
body = bytes.fromhex("05 00 00 00 00 00 00 00 00 04 01 00 00 00".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=6)
|
||||
assert p.prog_type == ProgramType.WHEN
|
||||
assert p.event_id == 0x0401
|
||||
|
||||
|
||||
def test_every_interval_5_seconds() -> None:
|
||||
"""EVERY record: interval at bytes 3-4 BE.
|
||||
|
||||
Empirical capture: "EVERY 5 SECONDS" trigger yields
|
||||
08 00 00 00 05 00 ... at byte positions 0-5 (ProgType=7 at byte 0,
|
||||
then zeros until byte 4 = 0x05 holding the interval low byte).
|
||||
"""
|
||||
body = bytes.fromhex("07 00 00 00 05 00 00 00 00 00 00 00 00 00".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=2)
|
||||
assert p.prog_type == ProgramType.EVERY
|
||||
assert p.every_interval == 5
|
||||
|
||||
|
||||
def test_and_unit_1_on() -> None:
|
||||
"""AND IF UNIT 1 ON: byte 1 = 0x0A (CTRL family + ON bit), bytes 3-4 BE = 1.
|
||||
|
||||
Empirical capture from block 9 slot 18 — the structured AND test.
|
||||
"""
|
||||
body = bytes.fromhex("08 0a 00 00 01 00 00 00 00 00 00 00 00 00".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=18)
|
||||
assert p.prog_type == ProgramType.AND
|
||||
# Byte 1 = 0x0a in the high byte means CTRL family (0x08) + ON bit (0x02)
|
||||
assert p.and_family == 0x0A
|
||||
# Family code (top 6 bits): CTRL = 0x08
|
||||
assert p.and_family & 0xFC == 0x08
|
||||
# Operand bit (bit 1 of family byte = bit 9 of compact cond u16): ON
|
||||
assert p.and_family & 0x02 == 0x02
|
||||
# Instance = unit #
|
||||
assert p.and_instance == 1
|
||||
|
||||
|
||||
def test_and_zone_5_secure() -> None:
|
||||
"""AND IF ZONE 5 SECURE: byte 1 = 0x04 (ZONE + SECURE), bytes 3-4 BE = 5."""
|
||||
body = bytes.fromhex("08 04 00 00 05 00 00 00 00 00 00 00 00 00".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=7)
|
||||
assert p.prog_type == ProgramType.AND
|
||||
assert p.and_family == 0x04 # ZONE family, SECURE operand (bit 1 = 0)
|
||||
assert p.and_family & 0xFC == 0x04 # ZONE family
|
||||
assert p.and_family & 0x02 == 0 # SECURE (operand bit clear)
|
||||
assert p.and_instance == 5 # zone # 5
|
||||
|
||||
|
||||
def test_and_never() -> None:
|
||||
"""AND IF NEVER: byte 1 = 0x00 (OTHER family), bytes 3-4 BE = 1 (NEVER value)."""
|
||||
body = bytes.fromhex("08 00 00 00 01 00 00 00 00 00 00 00 00 00".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=8)
|
||||
assert p.prog_type == ProgramType.AND
|
||||
assert p.and_family == 0x00 # OTHER family
|
||||
assert p.and_instance == int(MiscConditional.NEVER) # = 1
|
||||
|
||||
|
||||
def test_at_record_layout() -> None:
|
||||
"""AT record (multi-record TIMED): same byte layout as compact TIMED.
|
||||
|
||||
Empirical capture: AT 12:01 AM all-7-days yields:
|
||||
06 00 00 00 00 00 00 00 00 05 0c fe 00 01
|
||||
Where bytes 9-10 = [05, 0c] (month=5, day=12; no Mon/Day swap
|
||||
since AT isn't EVENT-typed), byte 11 = 0xfe (Days: all 7),
|
||||
bytes 12-13 = 00:01.
|
||||
"""
|
||||
body = bytes.fromhex("06 00 00 00 00 00 00 00 00 05 0c fe 00 01".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=7)
|
||||
assert p.prog_type == ProgramType.AT
|
||||
assert p.month == 5
|
||||
assert p.day == 12
|
||||
assert p.days == 0xFE # MTWTFSS (bit 1 through bit 7)
|
||||
assert p.hour == 0
|
||||
assert p.minute == 1
|
||||
|
||||
|
||||
def test_or_record_is_pure_discriminator() -> None:
|
||||
"""OR record: only ProgType set, all other bytes zero."""
|
||||
body = bytes.fromhex("09 00 00 00 00 00 00 00 00 00 00 00 00 00".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=10)
|
||||
assert p.prog_type == ProgramType.OR
|
||||
assert p.cond == 0
|
||||
assert p.cond2 == 0
|
||||
assert p.cmd == 0
|
||||
assert p.par == 0
|
||||
assert p.pr2 == 0
|
||||
assert p.month == 0
|
||||
assert p.day == 0
|
||||
assert p.days == 0
|
||||
assert p.hour == 0
|
||||
assert p.minute == 0
|
||||
|
||||
|
||||
def test_then_record_uses_compact_action_layout() -> None:
|
||||
"""THEN record (multi-record action): same cmd/par/pr2 layout as compact form.
|
||||
|
||||
Empirical capture: THEN UNIT 1 ON yields
|
||||
0a 00 00 00 00 01 00 01 00 00 00 00 00 00
|
||||
with cmd=1 (On), par=0, pr2=1 (UNIT 1, LE).
|
||||
"""
|
||||
body = bytes.fromhex("0a 00 00 00 00 01 00 01 00 00 00 00 00 00".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=10)
|
||||
assert p.prog_type == ProgramType.THEN
|
||||
assert p.cmd == 1 # enuUnitCommand.On
|
||||
assert p.par == 0
|
||||
assert p.pr2 == 1 # UNIT 1 (LE u16 at bytes 7-8, same as compact)
|
||||
|
||||
|
||||
# ---- structured AND records (firmware ≥3.0, OP > 0) ------------------
|
||||
|
||||
|
||||
def test_and_structured_date_eq_1231() -> None:
|
||||
"""Structured AND IF DATE IS EQUAL TO 12/31 (block 12 slot 13).
|
||||
|
||||
Captured bytes: 08 07 01 00 00 01 00 1f 0c 00 00 00 00 00
|
||||
|
||||
Decodes per clsProgram.cs:326-436 accessors after Read's LE-to-BE
|
||||
byte swap. The OP is non-zero (Arg1_EQ_Arg2), so this is the
|
||||
"structured" case where Arg1_ArgType holds an actual enuCondArgType
|
||||
value (TimeDate=7) rather than a compact-form family code.
|
||||
"""
|
||||
body = bytes.fromhex("08 07 01 00 00 01 00 1f 0c 00 00 00 00 00".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=13)
|
||||
assert p.prog_type == ProgramType.AND
|
||||
assert p.and_op == 1 # enuCondOP.Arg1_EQ_Arg2
|
||||
assert p.and_arg1_argtype == 7 # enuCondArgType.TimeDate
|
||||
assert p.and_instance == 0 # Arg1_IX = 0 (CURRENT_DATE)
|
||||
assert p.and_arg1_field == 1 # Date sub-field
|
||||
assert p.and_arg2_argtype == 0 # enuCondArgType.Constant
|
||||
# Arg2_IX = (month << 8) | day = (12 << 8) | 31 = 0x0c1f
|
||||
assert p.and_arg2_ix == 0x0C1F
|
||||
assert p.and_arg2_ix >> 8 == 12 # month
|
||||
assert p.and_arg2_ix & 0xFF == 31 # day
|
||||
assert p.and_arg2_field == 0
|
||||
assert p.and_compconst == 0
|
||||
|
||||
|
||||
def test_and_traditional_zone_5_secure_via_structured_view() -> None:
|
||||
"""Traditional AND (OP=0) read via the structured-AND accessors.
|
||||
|
||||
For the Traditional case, Arg1_ArgType holds the compact-form
|
||||
family code (ZONE=4) — NOT the enuCondArgType Zone=2. This is the
|
||||
"dual-use byte" behavior documented at clsConditionLine.cs:17-33.
|
||||
"""
|
||||
# AND IF ZONE 5 SECURE — same byte vector as earlier and_zone_5_secure test
|
||||
body = bytes.fromhex("08 04 00 00 05 00 00 00 00 00 00 00 00 00".replace(" ", ""))
|
||||
p = Program.from_file_record(body, slot=7)
|
||||
assert p.and_op == 0 # Arg1_Traditional
|
||||
# Arg1_ArgType holds the ProgramCond family code (ZONE=4), not enuCondArgType.Zone=2
|
||||
assert p.and_arg1_argtype == 4
|
||||
# and_family is the same byte for this case
|
||||
assert p.and_family == p.and_arg1_argtype
|
||||
# The instance number is still in bytes 3-4 BE
|
||||
assert p.and_instance == 5
|
||||
8
uv.lock
generated
@ -1511,7 +1511,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "omni-pca"
|
||||
version = "2026.5.10"
|
||||
version = "2026.5.16"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
@ -1522,6 +1522,9 @@ cli = [
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
engine = [
|
||||
{ name = "astral" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
@ -1537,11 +1540,12 @@ ha = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "astral", marker = "extra == 'engine'", specifier = ">=2.2,<3" },
|
||||
{ name = "cryptography", specifier = ">=44.0.0" },
|
||||
{ name = "rich", marker = "extra == 'cli'", specifier = ">=13.9.0" },
|
||||
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.15.0" },
|
||||
]
|
||||
provides-extras = ["cli"]
|
||||
provides-extras = ["cli", "engine"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
|
||||