Compare commits

..

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

94 changed files with 108 additions and 18752 deletions

View File

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

5
.gitignore vendored
View File

@ -42,8 +42,3 @@ 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/

View File

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

View File

@ -4,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://github.com/rsp2k/omni-pca>
**Project home:** <https://git.supported.systems/warehack.ing/omni-pca>
**Documentation:** <https://hai-omni-pro-ii.warehack.ing/>
## Status
@ -18,14 +18,20 @@ 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
pip install omni-pca
# 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
# Or with uv
uv add omni-pca
uv add "omni-pca @ git+https://git.supported.systems/warehack.ing/omni-pca.git@v2026.5.10"
```
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/).
Once published to PyPI, the canonical install will be `pip install omni-pca`.
## Quick start (library)
@ -73,7 +79,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://github.com/rsp2k/omni-pca tmp-omni
git clone https://git.supported.systems/warehack.ing/omni-pca tmp-omni
cp -r tmp-omni/custom_components/omni_pca .
rm -rf tmp-omni
```

View File

@ -6,18 +6,16 @@ opens an encrypted session straight to the panel and listens for unsolicited
push messages.
This integration is the HA-facing wrapper around the
[`omni-pca`](https://github.com/rsp2k/omni-pca) Python library; the library
[`omni-pca`](https://git.supported.systems/warehack.ing/omni-pca) Python library; the library
handles the wire protocol, this component surfaces it as HA entities.
## Install
### HACS
### HACS (recommended once published)
1. HACS → Integrations → search **HAI / Leviton Omni Panel**.
2. Install, then restart Home Assistant.
(If not yet in the HACS default catalog: HACS → Integrations → custom
repository → add `https://github.com/rsp2k/omni-pca`, category **Integration**.)
1. HACS → Integrations → custom repository → add
`https://git.supported.systems/warehack.ing/omni-pca`, category **Integration**.
2. Install **HAI / Leviton Omni Panel**, then restart Home Assistant.
### Manual
@ -123,6 +121,6 @@ hashed) — useful for bug reports.
- **No entities for X**: only objects with a name configured on the panel
are discovered. PC Access's "Names" page is where they live.
See the [parent README](https://github.com/rsp2k/omni-pca) for protocol /
See the [parent README](https://git.supported.systems/warehack.ing/omni-pca) for protocol /
library details. Detailed reverse-engineering notes are in
[`docs/JOURNEY.md`](https://github.com/rsp2k/omni-pca/blob/main/docs/JOURNEY.md).
[`docs/JOURNEY.md`](https://git.supported.systems/warehack.ing/omni-pca/blob/main/docs/JOURNEY.md).

View File

@ -13,12 +13,9 @@ 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,
@ -26,12 +23,6 @@ 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
@ -48,8 +39,6 @@ PLATFORMS: list[Platform] = [
Platform.SWITCH,
]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""No YAML support; everything is config-flow driven."""
@ -68,8 +57,6 @@ 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,
@ -77,8 +64,6 @@ 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:
@ -92,20 +77,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await async_setup_services(hass)
# Websocket commands are global (not per-entry) but the registration
# helper is idempotent, so calling it for each entry is harmless.
async_register_websocket_commands(hass)
# Panel registration must happen exactly once — guard with a flag in
# hass.data. The flag survives entry reloads; only a full HA restart
# clears it (matching when HA itself would need to re-register).
if not hass.data[DOMAIN].get(_PANEL_REGISTERED_KEY):
try:
await async_register_side_panel(hass)
except Exception:
LOGGER.warning(
"omni_pca: side panel registration failed", exc_info=True,
)
hass.data[DOMAIN][_PANEL_REGISTERED_KEY] = True
return True

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -20,8 +20,6 @@ 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,
@ -72,15 +70,6 @@ _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)
),
}
)
@ -101,8 +90,6 @@ 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)
@ -114,24 +101,19 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
LOGGER.debug("controller key rejected: %s", err)
errors[CONF_CONTROLLER_KEY] = "invalid_key"
else:
if pca_path and (pca_err := await self._validate_pca(pca_path, pca_key)):
errors[CONF_PCA_PATH] = pca_err
if not errors:
title, error = await self._probe(host, port, key, transport)
if error is not None:
errors["base"] = error
else:
return self.async_create_entry(
title=title or f"Omni Panel ({host})",
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_CONTROLLER_KEY: key.hex(),
CONF_TRANSPORT: transport,
CONF_PCA_PATH: pca_path,
CONF_PCA_KEY: pca_key,
},
)
title, error = await self._probe(host, port, key, 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,
},
)
return self.async_show_form(
step_id="user",
@ -139,33 +121,6 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def _validate_pca(self, path: str, key: int) -> str | None:
"""Validate the user-supplied .pca path is readable and decrypts.
Returns ``None`` on success or an error code string on failure
(matches the {code: message} keys in strings.json).
"""
from pathlib import Path
from omni_pca.pca_file import parse_pca_file
p = Path(path)
if not p.is_file():
return "pca_not_found"
try:
data = await self.hass.async_add_executor_job(p.read_bytes)
acct = await self.hass.async_add_executor_job(
lambda: parse_pca_file(data, key=key)
)
except Exception as err:
LOGGER.debug("pca file rejected: %s", err)
return "pca_decode_failed"
# Sanity: programs block decoded cleanly. Empty is allowed
# (legitimate brand-new install with no programs).
if not isinstance(acct.programs, tuple):
return "pca_decode_failed"
return None
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:

View File

@ -14,13 +14,6 @@ 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

View File

@ -62,6 +62,7 @@ from omni_pca.models import (
AreaStatus,
ButtonProperties,
ObjectType,
ProgramProperties,
SystemInformation,
SystemStatus,
ThermostatProperties,
@ -72,7 +73,6 @@ from omni_pca.models import (
ZoneStatus,
)
from omni_pca.opcodes import OmniLink2MessageType
from omni_pca.programs import Program
from .const import (
DOMAIN,
@ -108,7 +108,7 @@ class OmniData:
areas: dict[int, AreaProperties] = field(default_factory=dict)
thermostats: dict[int, ThermostatProperties] = field(default_factory=dict)
buttons: dict[int, ButtonProperties] = field(default_factory=dict)
programs: dict[int, Program] = field(default_factory=dict)
programs: dict[int, ProgramProperties] = field(default_factory=dict)
zone_status: dict[int, ZoneStatus] = field(default_factory=dict)
unit_status: dict[int, UnitStatus] = field(default_factory=dict)
@ -138,8 +138,6 @@ 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,
@ -152,8 +150,6 @@ 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
@ -386,70 +382,15 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
async def _discover_programs(
self, client: OmniClient
) -> dict[int, Program]:
"""Enumerate defined panel programs.
Two sources, in order of preference:
1. ``CONF_PCA_PATH`` is configured parse the .pca file and
extract the programs block. Avoids streaming 1500 records on
every entry refresh and works against an offline snapshot.
2. Otherwise enumerate over the wire:
* v2 (TCP): ``client.iter_programs()`` drives UploadProgram
with request_reason=1 ("next defined after slot").
* v1 (UDP): adapter forwards to OmniClientV1.iter_programs(),
a bare UploadPrograms stream ack-walked to EOD.
Both paths yield :class:`omni_pca.programs.Program` and skip
empty slots. Errors are logged and swallowed programs are
non-critical discovery, so a partial list beats blocking setup.
"""
if self._pca_path:
return await self._discover_programs_from_pca()
out: dict[int, Program] = {}
try:
async for prog in client.iter_programs():
if prog.slot is not None:
out[prog.slot] = prog
except (OmniConnectionError, RequestTimeoutError):
raise
except Exception:
LOGGER.debug(
"program enumeration interrupted (kept %d)", len(out), exc_info=True
)
return out
async def _discover_programs_from_pca(self) -> dict[int, Program]:
"""Parse the configured .pca file and pull out its programs block.
Runs the disk I/O on the executor since :mod:`pca_file` does
sync reads. Any failure (missing file, bad key, malformed block)
is logged and downgraded to an empty dict the rest of
discovery still works.
"""
from pathlib import Path
from omni_pca.pca_file import parse_pca_file
try:
data = await self.hass.async_add_executor_job(
Path(self._pca_path).read_bytes
)
acct = await self.hass.async_add_executor_job(
lambda: parse_pca_file(data, key=self._pca_key)
)
except Exception:
LOGGER.warning(
"failed to load programs from %s — falling back to empty list",
self._pca_path, exc_info=True,
)
return {}
out: dict[int, Program] = {}
for prog in acct.programs:
if prog.slot is None or prog.is_empty():
continue
out[prog.slot] = prog
return out
) -> dict[int, ProgramProperties]:
# Programs aren't reachable via the Properties opcode (the C# side
# uses a separate request/reply pair), so we just return an empty
# dict. We keep the field on OmniData so Phase B can plug in real
# discovery the moment the library exposes it. AMBIGUITY: the spec
# asks for "named programs" — there's no on-the-wire path for that
# in v1.0 of omni_pca, so an empty mapping is the honest answer.
_ = client, ProgramProperties
return {}
async def _walk_properties(
self,

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -35,14 +35,8 @@ 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
@ -102,74 +96,6 @@ 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

View File

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

View File

@ -298,72 +298,6 @@ async def _take_screenshots(ha_url: str, token: str, outdir: Path) -> list[Path]
await shot("06-developer-states.png",
"/developer-tools/state", wait_secs=4.0)
# The side panel registered by panel_custom (websocket.py:
# async_register_side_panel). If pointed at a real panel the
# program list is whatever the homeowner has authored; against
# the mock it's whatever ``run_mock_panel.py`` seeded. We
# deliberately do NOT write programs from here because the
# screenshot script may be aimed at real hardware.
await shot("08-side-panel-programs.png",
"/omni-panel-programs", wait_secs=6.0)
# Helper: locate the omni-panel-programs element regardless of
# what shadow-DOM path HA's panel host wraps it in. Recursive
# walk because partial-panel-resolver / hui-view / etc. can
# vary between HA versions.
find_panel_js = """
(() => {
function find(root, depth=0) {
if (!root || depth > 15) return null;
if (root.tagName === 'OMNI-PANEL-PROGRAMS') return root;
for (const k of Array.from(root.children || [])) {
const r = find(k, depth+1);
if (r) return r;
}
if (root.shadowRoot) {
const r = find(root.shadowRoot, depth+1);
if (r) return r;
}
return null;
}
return find(document.body);
})()
"""
# Click into the first program row to capture the detail panel.
try:
await page.evaluate(f"""() => {{
const panel = {find_panel_js};
if (!panel) {{ console.warn('omni panel not found'); return; }}
const row = panel.shadowRoot.querySelector('.row');
if (row) row.click();
}}""")
await page.wait_for_timeout(800)
except Exception as e:
print(f" click-into-row warning: {e}")
# Re-shoot WITHOUT a navigate (page.goto would reset selection).
await page.screenshot(path=str(outdir / "09-side-panel-detail.png"),
full_page=False)
shots.append(outdir / "09-side-panel-detail.png")
print(f" → 09-side-panel-detail.png (in-place)")
# Click "Edit" to capture the editor mode.
try:
await page.evaluate(f"""() => {{
const panel = {find_panel_js};
if (!panel) {{ console.warn('omni panel not found'); return; }}
const buttons = panel.shadowRoot.querySelectorAll('.detail button');
for (const b of buttons) {{
if (b.textContent.trim() === 'Edit') {{ b.click(); break; }}
}}
}}""")
await page.wait_for_timeout(800)
except Exception as e:
print(f" click-edit warning: {e}")
await page.screenshot(path=str(outdir / "10-side-panel-editor.png"),
full_page=False)
shots.append(outdir / "10-side-panel-editor.png")
print(f" → 10-side-panel-editor.png (in-place)")
await browser.close()
return shots

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,19 +39,6 @@ The reply (ExecuteSecurityCommandResponse, opcode 75) carries a single
status byte at ``payload[0]`` whose values are listed in
``enuSecurityCommnadResponse.cs`` :class:`SecurityCommandResponse`
mirrors that enum.
Cross-references (HAI OmniPro II Owner's Manual):
Chapter "CONTROL" (pca-re/docs/owner_manual/05_CONTROL/) documents
the user-facing keypad keys that map to these commands
e.g. UNIT_ON/OFF + UNIT_LEVEL are what a homeowner triggers via
the "Control → 1 (Unit)" menu, SHOW_MESSAGE_WITH_BEEP is
invoked from "Control → Message → Show".
Chapter "Scene Commands" (06_Scene_Commands/) covers
COMPOSE_SCENE and the per-room scene-recall path.
Chapter "SECURITY SYSTEM OPERATION" (03_SECURITY_SYSTEM_OPERATION/)
documents what each SecurityMode byte (0-6) means at the user
level the arming menu, entry/exit-delay semantics, and which
zones each mode arms.
"""
from __future__ import annotations

View File

@ -29,16 +29,6 @@ References (decompiled C# source):
bit-mask classifier we mirror below
clsText.cs:1693-1911 (GetEventText)
per-category sub-field extraction
Cross-references (HAI OmniPro II Installation Manual):
APPENDIX A CONTACT ID REPORTING FORMAT (p68): the Contact ID
event codes the panel transmits to a central monitoring station
for each :class:`AlarmKind`. The class names below mirror those
codes one-for-one. (pca-re/docs/manuals/installation_manual/
10_APPENDIX_A_CONTACT_ID_REPORTING_FORMAT/)
APPENDIX B DIGITAL COMMUNICATOR CODE SHEET (p69-73): the 4/2 and
3/1 reporting-format code tables. Useful when correlating a
SystemEvents word with what a central station would see. (12_)
"""
from __future__ import annotations

View File

@ -31,16 +31,6 @@ 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
@ -52,7 +42,6 @@ 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 (
@ -61,14 +50,8 @@ from .crypto import (
derive_session_key,
encrypt_message_payload,
)
from .message import (
Message,
MessageCrcError,
MessageFormatError,
encode_v1,
encode_v2,
)
from .opcodes import OmniLink2MessageType, OmniLinkMessageType, PacketType
from .message import Message, MessageCrcError, MessageFormatError, encode_v2
from .opcodes import OmniLink2MessageType, PacketType
from .packet import Packet
_log = logging.getLogger(__name__)
@ -132,8 +115,6 @@ 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
@ -146,19 +127,6 @@ 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
@ -171,9 +139,6 @@ 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:
@ -218,8 +183,6 @@ 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
@ -251,12 +214,6 @@ 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
@ -286,126 +243,6 @@ 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:
@ -481,12 +318,6 @@ 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) --------
@ -620,19 +451,6 @@ 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
@ -760,68 +578,6 @@ 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, ...]]:
@ -847,74 +603,8 @@ 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:
@ -1023,9 +713,9 @@ class MockPanel:
index & 0xFF,
zone.status_byte if zone else 0,
zone.loop if zone else 0,
zone.zone_type if zone else 0,
zone.area if zone else 1,
zone.options if zone else 0,
0, # Type: EntryExit
1, # Area: default to area 1
0, # Options
]
)
+ self.state.zone_name_bytes(index)
@ -1047,11 +737,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,
unit.unit_type if unit else 1,
1, # UnitType: Standard
]
)
+ self.state.unit_name_bytes(index)
+ bytes([0, (unit.areas if unit else 0x01)])
+ bytes([0, 1]) # reserved + UnitAreas (default area 1)
)
return encode_v2(OmniLink2MessageType.Properties, body)
@ -1083,7 +773,7 @@ class MockPanel:
t.system_mode if t else 0,
t.fan_mode if t else 0,
t.hold_mode if t else 0,
t.thermostat_type if t else 1,
1, # thermostat type: AUTO_HEAT_COOL
]
)
+ self.state.thermostat_name_bytes(index)
@ -1124,9 +814,9 @@ class MockPanel:
area.alarms if area else 0,
area.entry_timer if area else 0,
area.exit_timer if area else 0,
(1 if (area and area.enabled) else 0),
area.exit_delay if area else 60,
area.entry_delay if area else 30,
1, # Enabled
60, # ExitDelay (s)
30, # EntryDelay (s)
]
)
+ self.state.area_name_bytes(index)
@ -1391,343 +1081,6 @@ 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
@ -1908,26 +1261,6 @@ 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:
@ -2068,15 +1401,6 @@ 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(
@ -2106,34 +1430,6 @@ 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:
@ -2174,19 +1470,3 @@ 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)

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -172,23 +172,6 @@ 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(

View File

@ -208,61 +208,6 @@ 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

View File

@ -21,14 +21,6 @@ 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

View File

@ -21,18 +21,6 @@ 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -94,252 +94,6 @@ 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)

File diff suppressed because it is too large Load Diff

View File

@ -1,744 +0,0 @@
"""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)"

View File

@ -1,618 +0,0 @@
"""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
View File

@ -1511,7 +1511,7 @@ wheels = [
[[package]]
name = "omni-pca"
version = "2026.5.16"
version = "2026.5.10"
source = { editable = "." }
dependencies = [
{ name = "cryptography" },
@ -1522,9 +1522,6 @@ cli = [
{ name = "rich" },
{ name = "typer" },
]
engine = [
{ name = "astral" },
]
[package.dev-dependencies]
dev = [
@ -1540,12 +1537,11 @@ 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", "engine"]
provides-extras = ["cli"]
[package.metadata.requires-dev]
dev = [