Compare commits

..

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

50 changed files with 43 additions and 8218 deletions

5
.gitignore vendored
View File

@ -42,8 +42,3 @@ panel_key*
ha-config/ ha-config/
dist/ dist/
dev/.omni_key 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,17 +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. 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 ## [2026.5.14] — 2026-05-14
HACS publishing release — brand assets and validation tooling. HACS publishing release — brand assets and validation tooling.
@ -120,6 +109,5 @@ First release. Working library + Home Assistant custom component, validated end-
- **PyPI publish**: `omni-pca` not yet on PyPI; HA `manifest.json` requirements line will only resolve once it is. For now users either install the wheel manually or pip-install from a Git URL. - **PyPI publish**: `omni-pca` not yet on PyPI; HA `manifest.json` requirements line will only resolve once it is. For now users either install the wheel manually or pip-install from a Git URL.
- **HACS submission**: pending live-panel validation. - **HACS submission**: pending live-panel validation.
[2026.5.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.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://github.com/rsp2k/omni-pca/releases/tag/v2026.5.10

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

@ -4,10 +4,10 @@
"codeowners": ["@rsp2k"], "codeowners": ["@rsp2k"],
"config_flow": true, "config_flow": true,
"dependencies": ["http", "websocket_api"], "dependencies": ["http", "websocket_api"],
"documentation": "https://hai-omni-pro-ii.warehack.ing/", "documentation": "https://github.com/rsp2k/omni-pca",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",
"issue_tracker": "https://github.com/rsp2k/omni-pca/issues", "issue_tracker": "https://github.com/rsp2k/omni-pca/issues",
"requirements": ["omni-pca==2026.5.16"], "requirements": ["omni-pca==2026.5.14"],
"version": "2026.5.16" "version": "2026.5.14"
} }

View File

@ -368,21 +368,6 @@ async def _ws_get_program(
"tokens": _tokens_to_json(tokens), "tokens": _tokens_to_json(tokens),
"references": _extract_references(tokens), "references": _extract_references(tokens),
"chain_slots": [m.slot for m in members if m.slot is not None], "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 return
@ -393,431 +378,9 @@ async def _ws_get_program(
"trigger_type": _classify_trigger(target), "trigger_type": _classify_trigger(target),
"tokens": _tokens_to_json(tokens), "tokens": _tokens_to_json(tokens),
"references": _extract_references(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( @websocket_api.websocket_command(
{ {
vol.Required("type"): "omni_pca/programs/fire", vol.Required("type"): "omni_pca/programs/fire",
@ -870,11 +433,6 @@ def async_register_websocket_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, _ws_list_programs) 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_get_program)
websocket_api.async_register_command(hass, _ws_fire_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)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------

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

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

View File

@ -35,14 +35,8 @@ services:
volumes: volumes:
- ../src:/tmp/mock/src:ro - ../src:/tmp/mock/src:ro
- ./run_mock_panel.py:/tmp/mock/run_mock_panel.py:ro - ./run_mock_panel.py:/tmp/mock/run_mock_panel.py:ro
# Mount the captured .pca fixtures read-only so the mock can
# optionally seed its state from a real export. Set
# OMNI_PCA_FIXTURE in dev/.env (or pass on the command line) to
# activate; left unset, the mock uses the hard-coded sample.
- /home/kdm/home-auto/HAI:/fixtures:ro
environment: environment:
PYTHONPATH: /tmp/mock/src PYTHONPATH: /tmp/mock/src
OMNI_PCA_FIXTURE: ${OMNI_PCA_FIXTURE:-}
command: command:
- sh - sh
- -c - -c
@ -102,74 +96,6 @@ services:
pip install --quiet --no-deps --upgrade /opt/omni-pca-src pip install --quiet --no-deps --upgrade /opt/omni-pca-src
exec /init 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: networks:
caddy: caddy:
external: true external: true

View File

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

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

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] [project]
name = "omni-pca" name = "omni-pca"
version = "2026.5.16" version = "2026.5.14"
description = "Async Python client for HAI/Leviton Omni-Link II home automation panels (Omni Pro II, Omni IIe, Omni LTe, Lumina)." description = "Async Python client for HAI/Leviton Omni-Link II home automation panels (Omni Pro II, Omni IIe, Omni LTe, Lumina)."
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }

View File

@ -646,53 +646,6 @@ class OmniClient:
slot = (reply.payload[0] << 8) | reply.payload[1] slot = (reply.payload[0] << 8) | reply.payload[1]
yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot) yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot)
async def download_program(self, slot: int, program: "Program") -> None:
"""Write ``program`` into the panel at the given 1-based ``slot``.
Wire opcode: 8 (DownloadProgram) per clsOLMsg2DownloadProgram
(clsHAC.cs:1133-1140). Payload is the same 2-byte BE slot
number + 14-byte wire body the UploadProgram reply uses, so
``Program.encode_wire_bytes`` produces the right thing.
The panel responds with ``Ack`` on success; we raise
:class:`CommandFailedError` on ``Nak`` and
:class:`OmniConnectionError` for any other opcode.
Writing an all-zero body clears the slot (treats the slot as
``ProgramType.FREE``) matches the panel's behaviour for an
empty record.
"""
if not 1 <= slot <= 1500:
raise ValueError(f"program slot {slot} out of range 1..1500")
body = program.encode_wire_bytes()
if len(body) != 14:
raise ValueError(
f"encoded program body must be 14 bytes, got {len(body)}"
)
payload = bytes([(slot >> 8) & 0xFF, slot & 0xFF]) + body
reply = await self._conn.request(
OmniLink2MessageType.DownloadProgram, payload
)
if reply.opcode == int(OmniLink2MessageType.Nak):
raise CommandFailedError(
f"panel NAK'd DownloadProgram for slot {slot}"
)
if reply.opcode != int(OmniLink2MessageType.Ack):
raise OmniConnectionError(
f"unexpected opcode {reply.opcode} after DownloadProgram "
f"(expected {int(OmniLink2MessageType.Ack)})"
)
async def clear_program(self, slot: int) -> None:
"""Convenience: clear a program slot by writing an all-zero body.
On the panel this marks the slot as :class:`ProgramType.FREE`,
same as ``DownloadProgram(slot, all-zero)``.
"""
from .programs import Program, ProgramType
empty = Program(slot=slot, prog_type=int(ProgramType.FREE))
await self.download_program(slot, empty)
# ---- helpers (status) ----------------------------------------------- # ---- helpers (status) -----------------------------------------------
async def _fetch_status_range( async def _fetch_status_range(

View File

@ -849,32 +849,8 @@ class MockPanel:
return _build_ack(), () return _build_ack(), ()
if opcode == OmniLink2MessageType.UploadProgram: if opcode == OmniLink2MessageType.UploadProgram:
return self._reply_program_data(payload), () return self._reply_program_data(payload), ()
if opcode == OmniLink2MessageType.DownloadProgram:
return self._handle_download_program(payload), ()
return _build_nak(opcode), () 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: def _reply_program_data(self, payload: bytes) -> Message:
"""v2 program read — single-slot OR iterator. """v2 program read — single-slot OR iterator.

View File

@ -182,13 +182,6 @@ class OmniClientV1Adapter:
""" """
return self._client.iter_programs() 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 ------------------------------------------ # ---- properties synthesis ------------------------------------------
async def get_object_properties( async def get_object_properties(

View File

@ -241,28 +241,6 @@ class OmniClientV1:
slot = (reply.payload[0] << 8) | reply.payload[1] slot = (reply.payload[0] << 8) | reply.payload[1]
yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot) 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) ---------------- # ---- write methods (Command + ExecuteSecurityCommand) ----------------
# #
# The Command and ExecuteSecurityCommand payloads are byte-identical # The Command and ExecuteSecurityCommand payloads are byte-identical

View File

@ -45,27 +45,6 @@ def seeded_programs() -> dict[int, Program]:
# WHEN zone 1 changes to NOT_READY (event_id = 0x0401) # WHEN zone 1 changes to NOT_READY (event_id = 0x0401)
month=0x04, day=0x01, 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,
),
} }
@ -114,14 +93,17 @@ async def test_ws_list_programs_returns_summaries(
response = await client.receive_json() response = await client.receive_json()
assert response["success"] is True assert response["success"] is True
result = response["result"] result = response["result"]
# 3 compact-form programs (12, 42, 99) + 1 clausal chain (head at assert result["total"] == 3
# slot 200, spanning 200..203). The chain renders as a single row. assert result["filtered_total"] == 3
assert result["total"] == 4
assert result["filtered_total"] == 4
rows_by_slot = {row["slot"]: row for row in result["programs"]} rows_by_slot = {row["slot"]: row for row in result["programs"]}
assert rows_by_slot.keys() == {12, 42, 99, 200} # Both TIMED programs and the EVENT program land in the response.
assert rows_by_slot[200]["kind"] == "chain" assert rows_by_slot.keys() == {12, 42, 99}
assert rows_by_slot[12]["kind"] == "compact" # Each row has the metadata the frontend needs.
for row in result["programs"]:
assert row["kind"] == "compact"
assert row["trigger_type"] in ("TIMED", "EVENT")
assert isinstance(row["summary"], list)
assert row["summary"] # non-empty token list
async def test_ws_list_programs_filter_by_trigger_type( async def test_ws_list_programs_filter_by_trigger_type(
@ -153,11 +135,8 @@ async def test_ws_list_programs_filter_by_referenced_entity(
}) })
response = await client.receive_json() response = await client.receive_json()
result = response["result"] result = response["result"]
# Slot 42 ("Turn ON KITCHEN_OVERHEAD" = unit 2) plus the seeded chain assert result["filtered_total"] == 1
# at slot 200 (action: Turn ON unit 2) both reference unit:2. assert result["programs"][0]["slot"] == 42
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( async def test_ws_list_programs_search_substring(
@ -172,13 +151,9 @@ async def test_ws_list_programs_search_substring(
}) })
response = await client.receive_json() response = await client.receive_json()
result = response["result"] result = response["result"]
# Slot 42 ("Turn ON KITCHEN_OVERHEAD" — truncated to 12 chars on # Only slot 42 ("Turn ON KITCHEN_OVERHEAD") mentions kitchen.
# wire = "KITCHEN_OVER") matches. The chain at slot 200 also has assert result["filtered_total"] == 1
# an action against unit 2 which renders with the same truncated assert result["programs"][0]["slot"] == 42
# 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( async def test_ws_list_programs_pagination(
@ -193,8 +168,7 @@ async def test_ws_list_programs_pagination(
}) })
response = await client.receive_json() response = await client.receive_json()
result = response["result"] result = response["result"]
# 4 list rows total: 3 compact + 1 chain head. assert result["filtered_total"] == 3
assert result["filtered_total"] == 4
assert len(result["programs"]) == 2 assert len(result["programs"]) == 2
assert [row["slot"] for row in result["programs"]] == [42, 99] assert [row["slot"] for row in result["programs"]] == [42, 99]
@ -224,45 +198,6 @@ async def test_ws_get_program_returns_full_token_stream(
assert "KITCHEN_OVER" in text 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( async def test_ws_get_program_missing_slot_returns_error(
hass: HomeAssistant, configured_panel, hass_ws_client hass: HomeAssistant, configured_panel, hass_ws_client
) -> None: ) -> None:
@ -306,349 +241,6 @@ async def test_ws_fire_program_executes_command(
assert response["result"] == {"slot": 42, "fired": 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( async def test_ws_list_programs_live_state_overlay_zone(
hass: HomeAssistant, configured_panel, hass_ws_client hass: HomeAssistant, configured_panel, hass_ws_client
) -> None: ) -> None:

View File

@ -511,131 +511,6 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
assert p.prog_type in {1, 2, 3} # TIMED / EVENT / YEARLY from this fixture 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 @pytest.mark.asyncio
async def test_v1_client_iter_programs_enumerates_all_seeded() -> None: async def test_v1_client_iter_programs_enumerates_all_seeded() -> None:
seeded = { seeded = {

2
uv.lock generated
View File

@ -1511,7 +1511,7 @@ wheels = [
[[package]] [[package]]
name = "omni-pca" name = "omni-pca"
version = "2026.5.16" version = "2026.5.11"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },