Compare commits
No commits in common. "main" and "v2026.5.14" have entirely different histories.
main
...
v2026.5.14
5
.gitignore
vendored
@ -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/
|
|
||||||
|
|||||||
12
CHANGELOG.md
@ -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
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
# Omni Programs side panel — frontend
|
|
||||||
|
|
||||||
Lit/TypeScript source for the HA side panel registered by
|
|
||||||
`websocket.py:async_register_side_panel`. The build output
|
|
||||||
(`../www/panel.js`) is committed so end-users don't need Node installed.
|
|
||||||
|
|
||||||
## Edit / rebuild
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd custom_components/omni_pca/frontend
|
|
||||||
npm install # one-time
|
|
||||||
npm run build # one-shot — drops a fresh ../www/panel.js
|
|
||||||
npm run watch # rebuild on change (use during HA dev)
|
|
||||||
```
|
|
||||||
|
|
||||||
The build script (`build.mjs`) bundles the entry point + Lit + all
|
|
||||||
imports into a single ESM file at `../www/panel.js`. Source maps are
|
|
||||||
inlined in `--watch` mode and stripped in production builds. Output is
|
|
||||||
~34 KB minified.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `src/omni-panel-programs.ts` | The custom-element entry point. Defines `<omni-panel-programs>` (matching the panel_custom registration). |
|
|
||||||
| `src/token-renderer.ts` | Token stream → Lit `TemplateResult`. Each TokenKind gets distinctive styling; REF tokens become buttons that dispatch a click. |
|
|
||||||
| `src/types.ts` | TS interfaces mirroring the Phase-B websocket wire shapes. Short keys (`k`/`t`/`ek`/`ei`/`s`) match `websocket.py:_tokens_to_json`. |
|
|
||||||
|
|
||||||
## Wire contract
|
|
||||||
|
|
||||||
The panel calls three websocket commands (all defined in
|
|
||||||
`../websocket.py`):
|
|
||||||
|
|
||||||
* `omni_pca/programs/list` — paginated, filterable summaries.
|
|
||||||
* `omni_pca/programs/get` — full structured-English detail for one slot.
|
|
||||||
* `omni_pca/programs/fire` — sends `Command.EXECUTE_PROGRAM` over the wire.
|
|
||||||
|
|
||||||
The frontend doesn't subscribe to push events; live-state badges
|
|
||||||
refresh on a low-frequency poll (`REFRESH_MS = 5000`). That's a
|
|
||||||
deliberate scope choice — switching to per-entity event subscription
|
|
||||||
is a follow-up if the polling overhead becomes visible on huge installs.
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
// Bundle the omni_pca side panel into a single ESM file the HA static
|
|
||||||
// path serves at /api/omni_pca/panel.js.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// node build.mjs # one-shot production build
|
|
||||||
// node build.mjs --watch # rebuild on source change
|
|
||||||
//
|
|
||||||
// Output is intentionally placed at ../www/panel.js so the Python side
|
|
||||||
// (websocket.py:async_register_side_panel) finds it without extra
|
|
||||||
// configuration. The frontend dir + the Python integration sit in the
|
|
||||||
// same custom_components/omni_pca/ tree so end-users just install the
|
|
||||||
// integration; no separate HACS package needed.
|
|
||||||
|
|
||||||
import { build, context } from "esbuild";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const watch = process.argv.includes("--watch");
|
|
||||||
|
|
||||||
const opts = {
|
|
||||||
entryPoints: [resolve(__dirname, "src/omni-panel-programs.ts")],
|
|
||||||
bundle: true,
|
|
||||||
format: "esm",
|
|
||||||
target: "es2022",
|
|
||||||
minify: !watch,
|
|
||||||
sourcemap: watch ? "inline" : false,
|
|
||||||
outfile: resolve(__dirname, "../www/panel.js"),
|
|
||||||
// Lit ships its own ESM build; bundle it inline so the panel is a
|
|
||||||
// single self-contained file (matches how HACS-distributed cards work).
|
|
||||||
loader: { ".ts": "ts" },
|
|
||||||
banner: {
|
|
||||||
js: "// omni_pca side panel — generated by frontend/build.mjs. Edit src/, not this file.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (watch) {
|
|
||||||
const ctx = await context(opts);
|
|
||||||
await ctx.watch();
|
|
||||||
console.log("watching for changes…");
|
|
||||||
} else {
|
|
||||||
await build(opts);
|
|
||||||
console.log("built ->", opts.outfile);
|
|
||||||
}
|
|
||||||
551
custom_components/omni_pca/frontend/package-lock.json
generated
@ -1,551 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "omni-pca-panel",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "omni-pca-panel",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"lit": "^3.2.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"esbuild": "^0.24.0",
|
|
||||||
"typescript": "^5.5.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lit-labs/ssr-dom-shim": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/@lit/reactive-element": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"@lit-labs/ssr-dom-shim": "^1.5.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/trusted-types": {
|
|
||||||
"version": "2.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
|
||||||
"version": "0.24.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
|
||||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.24.2",
|
|
||||||
"@esbuild/android-arm": "0.24.2",
|
|
||||||
"@esbuild/android-arm64": "0.24.2",
|
|
||||||
"@esbuild/android-x64": "0.24.2",
|
|
||||||
"@esbuild/darwin-arm64": "0.24.2",
|
|
||||||
"@esbuild/darwin-x64": "0.24.2",
|
|
||||||
"@esbuild/freebsd-arm64": "0.24.2",
|
|
||||||
"@esbuild/freebsd-x64": "0.24.2",
|
|
||||||
"@esbuild/linux-arm": "0.24.2",
|
|
||||||
"@esbuild/linux-arm64": "0.24.2",
|
|
||||||
"@esbuild/linux-ia32": "0.24.2",
|
|
||||||
"@esbuild/linux-loong64": "0.24.2",
|
|
||||||
"@esbuild/linux-mips64el": "0.24.2",
|
|
||||||
"@esbuild/linux-ppc64": "0.24.2",
|
|
||||||
"@esbuild/linux-riscv64": "0.24.2",
|
|
||||||
"@esbuild/linux-s390x": "0.24.2",
|
|
||||||
"@esbuild/linux-x64": "0.24.2",
|
|
||||||
"@esbuild/netbsd-arm64": "0.24.2",
|
|
||||||
"@esbuild/netbsd-x64": "0.24.2",
|
|
||||||
"@esbuild/openbsd-arm64": "0.24.2",
|
|
||||||
"@esbuild/openbsd-x64": "0.24.2",
|
|
||||||
"@esbuild/sunos-x64": "0.24.2",
|
|
||||||
"@esbuild/win32-arm64": "0.24.2",
|
|
||||||
"@esbuild/win32-ia32": "0.24.2",
|
|
||||||
"@esbuild/win32-x64": "0.24.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lit": {
|
|
||||||
"version": "3.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
|
|
||||||
"integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"@lit/reactive-element": "^2.1.0",
|
|
||||||
"lit-element": "^4.2.0",
|
|
||||||
"lit-html": "^3.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lit-element": {
|
|
||||||
"version": "4.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz",
|
|
||||||
"integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"@lit-labs/ssr-dom-shim": "^1.5.0",
|
|
||||||
"@lit/reactive-element": "^2.1.0",
|
|
||||||
"lit-html": "^3.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lit-html": {
|
|
||||||
"version": "3.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz",
|
|
||||||
"integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/trusted-types": "^2.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "omni-pca-panel",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "HA side panel for browsing HAI Omni Panel programs.",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "node build.mjs",
|
|
||||||
"watch": "node build.mjs --watch"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"esbuild": "^0.24.0",
|
|
||||||
"typescript": "^5.5.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"lit": "^3.2.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
// Token-stream → DOM. Each TokenKind gets distinctive styling so the
|
|
||||||
// structured-English programs read cleanly even at a glance.
|
|
||||||
//
|
|
||||||
// REF tokens are rendered as <button> nodes so they can dispatch a
|
|
||||||
// "ref-click" event for the parent component to act on (filter the
|
|
||||||
// list, jump to that entity's HA page, etc.).
|
|
||||||
|
|
||||||
import { html, TemplateResult } from "lit";
|
|
||||||
import { Token } from "./types.js";
|
|
||||||
|
|
||||||
export function renderTokens(
|
|
||||||
tokens: Token[],
|
|
||||||
onRefClick?: (kind: string, id: number) => void,
|
|
||||||
): TemplateResult {
|
|
||||||
return html`${tokens.map((t) => renderToken(t, onRefClick))}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderToken(
|
|
||||||
t: Token,
|
|
||||||
onRefClick?: (kind: string, id: number) => void,
|
|
||||||
): TemplateResult {
|
|
||||||
switch (t.k) {
|
|
||||||
case "newline":
|
|
||||||
return html`<br />`;
|
|
||||||
case "indent":
|
|
||||||
// Convert leading spaces to a CSS class so the panel can switch
|
|
||||||
// indent styling (e.g. left border) without re-rendering tokens.
|
|
||||||
return html`<span class="indent">${t.t}</span>`;
|
|
||||||
case "keyword":
|
|
||||||
return html`<span class="keyword">${t.t}</span>`;
|
|
||||||
case "operator":
|
|
||||||
return html`<span class="operator">${t.t}</span>`;
|
|
||||||
case "value":
|
|
||||||
return html`<span class="value">${t.t}</span>`;
|
|
||||||
case "ref": {
|
|
||||||
const handler = onRefClick && t.ek && typeof t.ei === "number"
|
|
||||||
? () => onRefClick(t.ek!, t.ei!)
|
|
||||||
: undefined;
|
|
||||||
return html`<button
|
|
||||||
type="button"
|
|
||||||
class="ref ref-${t.ek}"
|
|
||||||
title=${t.ek ?? ""}
|
|
||||||
@click=${handler}
|
|
||||||
>
|
|
||||||
<span class="ref-name">${t.t}</span>
|
|
||||||
${t.s ? html`<span class="ref-state">${t.s}</span>` : ""}
|
|
||||||
</button>`;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return html`<span>${t.t}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,737 +0,0 @@
|
|||||||
// TS mirrors of the Phase-B websocket wire shapes. Short field names
|
|
||||||
// match websocket.py's _tokens_to_json — keep these in sync if the
|
|
||||||
// Python side changes.
|
|
||||||
|
|
||||||
export interface Token {
|
|
||||||
/** "keyword" / "operator" / "ref" / "value" / "text" / "indent" / "newline" */
|
|
||||||
k: string;
|
|
||||||
/** Display text for this token. Empty for newline. */
|
|
||||||
t: string;
|
|
||||||
/** Object kind for REF tokens (zone / unit / area / thermostat / button / message / code / timeclock). */
|
|
||||||
ek?: string;
|
|
||||||
/** 1-based slot for REF tokens. */
|
|
||||||
ei?: number;
|
|
||||||
/** Live-state badge for REF tokens (e.g. "SECURE", "ON 60%"). */
|
|
||||||
s?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProgramRow {
|
|
||||||
/** 1-based slot number. For chains, the head slot. */
|
|
||||||
slot: number;
|
|
||||||
/** "compact" or "chain". */
|
|
||||||
kind: string;
|
|
||||||
/** TIMED / EVENT / YEARLY / WHEN / AT / EVERY / REMARK / FREE. */
|
|
||||||
trigger_type: string;
|
|
||||||
/** One-line summary token stream. */
|
|
||||||
summary: Token[];
|
|
||||||
/** Flat ["unit:7", "zone:5", ...] for filter chips. */
|
|
||||||
references: string[];
|
|
||||||
condition_count: number;
|
|
||||||
action_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProgramListResponse {
|
|
||||||
programs: ProgramRow[];
|
|
||||||
total: number;
|
|
||||||
filtered_total: number;
|
|
||||||
offset: number;
|
|
||||||
limit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProgramDetail {
|
|
||||||
slot: number;
|
|
||||||
kind: string;
|
|
||||||
trigger_type: string;
|
|
||||||
/** Full structured-English token stream. */
|
|
||||||
tokens: Token[];
|
|
||||||
references: string[];
|
|
||||||
/** For chain detail: every slot the chain spans. */
|
|
||||||
chain_slots?: number[];
|
|
||||||
/** Raw Program field values; included for compact-form programs so
|
|
||||||
* the editor can seed its form from real data rather than defaults. */
|
|
||||||
fields?: ProgramFields;
|
|
||||||
/** For chain detail: per-member role + raw fields. Drives the
|
|
||||||
* chain editor's row-per-slot rendering. */
|
|
||||||
chain_members?: Array<{
|
|
||||||
slot: number;
|
|
||||||
role: "head" | "condition" | "action";
|
|
||||||
fields: ProgramFields;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProgramListRequest {
|
|
||||||
type: "omni_pca/programs/list";
|
|
||||||
entry_id: string;
|
|
||||||
trigger_types?: string[];
|
|
||||||
references_entity?: string;
|
|
||||||
search?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProgramGetRequest {
|
|
||||||
type: "omni_pca/programs/get";
|
|
||||||
entry_id: string;
|
|
||||||
slot: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProgramFireRequest {
|
|
||||||
type: "omni_pca/programs/fire";
|
|
||||||
entry_id: string;
|
|
||||||
slot: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raw Program dict — mirrors the dataclass on the Python side. Sent
|
|
||||||
// over the wire by ``omni_pca/programs/write``; the websocket validates
|
|
||||||
// each field's range and constructs the typed dataclass server-side.
|
|
||||||
export interface ProgramFields {
|
|
||||||
prog_type: number;
|
|
||||||
cond?: number;
|
|
||||||
cond2?: number;
|
|
||||||
cmd?: number;
|
|
||||||
par?: number;
|
|
||||||
pr2?: number;
|
|
||||||
month?: number;
|
|
||||||
day?: number;
|
|
||||||
days?: number;
|
|
||||||
hour?: number;
|
|
||||||
minute?: number;
|
|
||||||
remark_id?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProgramWriteRequest {
|
|
||||||
type: "omni_pca/programs/write";
|
|
||||||
entry_id: string;
|
|
||||||
slot: number;
|
|
||||||
program: ProgramFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NamedObject {
|
|
||||||
index: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ObjectListResponse {
|
|
||||||
zones: NamedObject[];
|
|
||||||
units: NamedObject[];
|
|
||||||
areas: NamedObject[];
|
|
||||||
thermostats: NamedObject[];
|
|
||||||
buttons: NamedObject[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Command enum values we let the user pick from the editor. Mirrors the
|
|
||||||
// most useful subset of omni_pca.commands.Command. The second element
|
|
||||||
// is what object kind (if any) the command's pr2 parameter references —
|
|
||||||
// drives the object picker's filter.
|
|
||||||
export interface CommandOption {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
ref_kind: "unit" | "zone" | "area" | "button" | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const COMMAND_OPTIONS: CommandOption[] = [
|
|
||||||
{ value: 0, label: "Turn OFF unit", ref_kind: "unit" },
|
|
||||||
{ value: 1, label: "Turn ON unit", ref_kind: "unit" },
|
|
||||||
{ value: 2, label: "All OFF", ref_kind: null },
|
|
||||||
{ value: 3, label: "All ON", ref_kind: null },
|
|
||||||
{ value: 4, label: "Bypass zone", ref_kind: "zone" },
|
|
||||||
{ value: 5, label: "Restore zone", ref_kind: "zone" },
|
|
||||||
{ value: 7, label: "Execute button", ref_kind: "button" },
|
|
||||||
{ value: 9, label: "Set unit level %", ref_kind: "unit" },
|
|
||||||
{ value: 48, label: "Disarm area", ref_kind: "area" },
|
|
||||||
{ value: 49, label: "Arm area Day", ref_kind: "area" },
|
|
||||||
{ value: 50, label: "Arm area Night", ref_kind: "area" },
|
|
||||||
{ value: 51, label: "Arm area Away", ref_kind: "area" },
|
|
||||||
{ value: 52, label: "Arm area Vacation", ref_kind: "area" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function commandOptionFor(value: number): CommandOption | undefined {
|
|
||||||
return COMMAND_OPTIONS.find((c) => c.value === value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Days bitmask bits (matches omni_pca.programs.Days). Bit 0 is unused.
|
|
||||||
export const DAY_BITS: ReadonlyArray<{ bit: number; label: string }> = [
|
|
||||||
{ bit: 0x02, label: "Mon" },
|
|
||||||
{ bit: 0x04, label: "Tue" },
|
|
||||||
{ bit: 0x08, label: "Wed" },
|
|
||||||
{ bit: 0x10, label: "Thu" },
|
|
||||||
{ bit: 0x20, label: "Fri" },
|
|
||||||
{ bit: 0x40, label: "Sat" },
|
|
||||||
{ bit: 0x80, label: "Sun" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Program type constants (matches omni_pca.programs.ProgramType).
|
|
||||||
export const PROGRAM_TYPE_TIMED = 1;
|
|
||||||
export const PROGRAM_TYPE_EVENT = 2;
|
|
||||||
export const PROGRAM_TYPE_YEARLY = 3;
|
|
||||||
export const PROGRAM_TYPE_REMARK = 4;
|
|
||||||
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// Event-ID encode/decode for the EVENT-program editor.
|
|
||||||
//
|
|
||||||
// Mirrors the Python helpers in omni_pca.program_engine — the 16-bit
|
|
||||||
// event_id uses different bit patterns per category. Each "category"
|
|
||||||
// in the UI maps to a different chunk of the ID space.
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
export type EventCategory =
|
|
||||||
| "button" // USER_MACRO_BUTTON (evt & 0xFF00) == 0x0000
|
|
||||||
| "zone" // ZONE_STATE_CHANGE (evt & 0xFC00) == 0x0400
|
|
||||||
| "unit" // UNIT_STATE_CHANGE (evt & 0xFC00) == 0x0800
|
|
||||||
| "fixed" // hard-coded IDs (phone / AC power)
|
|
||||||
| "raw"; // anything else — show numeric
|
|
||||||
|
|
||||||
export interface DecodedEvent {
|
|
||||||
category: EventCategory;
|
|
||||||
/** For "button": 1..255 */
|
|
||||||
button?: number;
|
|
||||||
/** For "zone": 1..256, plus state 0=secure / 1=not-ready / 2=trouble / 3=tamper */
|
|
||||||
zone?: number;
|
|
||||||
zoneState?: number;
|
|
||||||
/** For "unit": 1..511 plus on bool */
|
|
||||||
unit?: number;
|
|
||||||
unitOn?: boolean;
|
|
||||||
/** For "fixed": the literal event ID. */
|
|
||||||
fixedId?: number;
|
|
||||||
/** For "raw": the literal event ID we couldn't classify. */
|
|
||||||
raw?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hand-rolled fixed IDs and labels (matches Python EVENT_* constants).
|
|
||||||
export const FIXED_EVENTS: ReadonlyArray<{ id: number; label: string }> = [
|
|
||||||
{ id: 768, label: "Phone line dead" },
|
|
||||||
{ id: 769, label: "Phone ringing" },
|
|
||||||
{ id: 770, label: "Phone off hook" },
|
|
||||||
{ id: 771, label: "Phone on hook" },
|
|
||||||
{ id: 772, label: "AC power lost" },
|
|
||||||
{ id: 773, label: "AC power restored" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ZONE_STATE_LABELS = ["secure", "not ready", "trouble", "tamper"];
|
|
||||||
|
|
||||||
export function decodeEventId(eventId: number): DecodedEvent {
|
|
||||||
// FIXED first — the bit patterns below would otherwise collapse
|
|
||||||
// 768..773 into the "zone state change" category since their top
|
|
||||||
// bits look the same.
|
|
||||||
if (FIXED_EVENTS.some((f) => f.id === eventId)) {
|
|
||||||
return { category: "fixed", fixedId: eventId };
|
|
||||||
}
|
|
||||||
if ((eventId & 0xFF00) === 0x0000) {
|
|
||||||
return { category: "button", button: eventId & 0xFF };
|
|
||||||
}
|
|
||||||
if ((eventId & 0xFC00) === 0x0400) {
|
|
||||||
const zs = eventId & 0x03FF;
|
|
||||||
return {
|
|
||||||
category: "zone",
|
|
||||||
zone: Math.floor(zs / 4) + 1,
|
|
||||||
zoneState: zs % 4,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if ((eventId & 0xFC00) === 0x0800) {
|
|
||||||
const us = eventId & 0x03FF;
|
|
||||||
return {
|
|
||||||
category: "unit",
|
|
||||||
unit: Math.floor(us / 2) + 1,
|
|
||||||
unitOn: (us & 1) === 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { category: "raw", raw: eventId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeEventId(ev: DecodedEvent): number {
|
|
||||||
switch (ev.category) {
|
|
||||||
case "button":
|
|
||||||
return (ev.button ?? 1) & 0xFF;
|
|
||||||
case "zone": {
|
|
||||||
const zone = (ev.zone ?? 1) - 1;
|
|
||||||
const state = (ev.zoneState ?? 0) & 0x03;
|
|
||||||
return 0x0400 | ((zone * 4 + state) & 0x03FF);
|
|
||||||
}
|
|
||||||
case "unit": {
|
|
||||||
const unit = (ev.unit ?? 1) - 1;
|
|
||||||
const on = ev.unitOn ? 1 : 0;
|
|
||||||
return 0x0800 | ((unit * 2 + on) & 0x03FF);
|
|
||||||
}
|
|
||||||
case "fixed":
|
|
||||||
return ev.fixedId ?? 768;
|
|
||||||
case "raw":
|
|
||||||
default:
|
|
||||||
return ev.raw ?? 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventIdFromFields(fields: ProgramFields): number {
|
|
||||||
return ((fields.month ?? 0) << 8) | (fields.day ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function packEventIdIntoFields(
|
|
||||||
fields: ProgramFields, eventId: number,
|
|
||||||
): ProgramFields {
|
|
||||||
return {
|
|
||||||
...fields,
|
|
||||||
month: (eventId >> 8) & 0xFF,
|
|
||||||
day: eventId & 0xFF,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function zoneStateLabel(state: number): string {
|
|
||||||
return ZONE_STATE_LABELS[state] ?? `state ${state}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Month abbreviations for the YEARLY editor.
|
|
||||||
export const MONTH_NAMES = [
|
|
||||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// Compact-form AND-IF condition encode/decode for the inline-conditions
|
|
||||||
// editor (TIMED/EVENT/YEARLY cond + cond2 fields).
|
|
||||||
//
|
|
||||||
// Mirrors clsText.GetConditionalText (clsText.cs:2224-2274) and the
|
|
||||||
// Python _emit_traditional_cond in program_renderer.py. Bit layout:
|
|
||||||
//
|
|
||||||
// family = (cond >> 8) & 0xFC
|
|
||||||
// selector bit = (cond & 0x0200) — meaning depends on family
|
|
||||||
//
|
|
||||||
// family 0x00 OTHER — cond & 0x0F = enuMiscConditional (NONE=0,
|
|
||||||
// NEVER=1, LIGHT=2, DARK=3, ...)
|
|
||||||
// family 0x04 ZONE — low 8 bits = zone index; selector bit
|
|
||||||
// 0=secure, 1=not ready
|
|
||||||
// family 0x08 CTRL — low 9 bits = unit index; selector bit
|
|
||||||
// 0=OFF, 1=ON
|
|
||||||
// family 0x0C TIME — low 8 bits = time-clock index; selector bit
|
|
||||||
// 0=disabled, 1=enabled
|
|
||||||
// family >= 0x10 SEC — (cond >> 8) & 0x0F = area, (cond >> 12) & 0x07 = mode
|
|
||||||
//
|
|
||||||
// cond == 0 means "no condition" (NONE).
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
export type CondFamily =
|
|
||||||
| "none" // cond = 0 — no inline condition
|
|
||||||
| "misc" // OTHER family (NEVER, LIGHT, DARK, PHONE_*, AC_POWER_*, …)
|
|
||||||
| "zone" // ZONE family — zone + secure/not-ready
|
|
||||||
| "unit" // CTRL family — unit + on/off
|
|
||||||
| "time" // TIME family — time-clock + enabled/disabled
|
|
||||||
| "sec"; // SEC family — area + security mode
|
|
||||||
|
|
||||||
export interface DecodedCondition {
|
|
||||||
family: CondFamily;
|
|
||||||
/** misc-conditional index (0..15) — used when family == "misc". */
|
|
||||||
misc?: number;
|
|
||||||
/** Zone / unit / time-clock / area index — used by the named families. */
|
|
||||||
index?: number;
|
|
||||||
/** Selector bit: zone "not ready", unit "on", time-clock "enabled". */
|
|
||||||
active?: boolean;
|
|
||||||
/** SEC family security mode (0..7). */
|
|
||||||
mode?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MiscConditional enum (matches omni_pca.programs.MiscConditional).
|
|
||||||
// Each entry: { value, label }. NONE renders as "always" and NEVER as
|
|
||||||
// "never" — both common authoring patterns.
|
|
||||||
export const MISC_CONDITIONALS: ReadonlyArray<{ value: number; label: string }> = [
|
|
||||||
{ value: 0, label: "always" },
|
|
||||||
{ value: 1, label: "never" },
|
|
||||||
{ value: 2, label: "it is light outside" },
|
|
||||||
{ value: 3, label: "it is dark outside" },
|
|
||||||
{ value: 4, label: "phone line is dead" },
|
|
||||||
{ value: 5, label: "phone is ringing" },
|
|
||||||
{ value: 6, label: "phone is off hook" },
|
|
||||||
{ value: 7, label: "phone is on hook" },
|
|
||||||
{ value: 8, label: "AC power is off" },
|
|
||||||
{ value: 9, label: "AC power is on" },
|
|
||||||
{ value: 10, label: "battery is low" },
|
|
||||||
{ value: 11, label: "battery is OK" },
|
|
||||||
{ value: 12, label: "energy cost is low" },
|
|
||||||
{ value: 13, label: "energy cost is mid" },
|
|
||||||
{ value: 14, label: "energy cost is high" },
|
|
||||||
{ value: 15, label: "energy cost is critical" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Security modes for the SEC family (matches enuSecurityMode order).
|
|
||||||
export const SECURITY_MODE_NAMES: ReadonlyArray<{ value: number; label: string }> = [
|
|
||||||
{ value: 0, label: "Off (disarmed)" },
|
|
||||||
{ value: 1, label: "Day" },
|
|
||||||
{ value: 2, label: "Night" },
|
|
||||||
{ value: 3, label: "Away" },
|
|
||||||
{ value: 4, label: "Vacation" },
|
|
||||||
{ value: 5, label: "Day Instant" },
|
|
||||||
{ value: 6, label: "Night Delayed" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function decodeCondition(cond: number): DecodedCondition {
|
|
||||||
if (cond === 0) return { family: "none" };
|
|
||||||
const family = (cond >> 8) & 0xFC;
|
|
||||||
const active = (cond & 0x0200) !== 0;
|
|
||||||
if (family === 0x00) {
|
|
||||||
return { family: "misc", misc: cond & 0x0F };
|
|
||||||
}
|
|
||||||
if (family === 0x04) {
|
|
||||||
return { family: "zone", index: cond & 0xFF, active };
|
|
||||||
}
|
|
||||||
if (family === 0x08) {
|
|
||||||
return { family: "unit", index: cond & 0x01FF, active };
|
|
||||||
}
|
|
||||||
if (family === 0x0C) {
|
|
||||||
return { family: "time", index: cond & 0xFF, active };
|
|
||||||
}
|
|
||||||
// SEC family (family >= 0x10): area in high nibble of upper byte,
|
|
||||||
// mode in top nibble.
|
|
||||||
return {
|
|
||||||
family: "sec",
|
|
||||||
index: (cond >> 8) & 0x0F,
|
|
||||||
mode: (cond >> 12) & 0x07,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeCondition(c: DecodedCondition): number {
|
|
||||||
switch (c.family) {
|
|
||||||
case "none":
|
|
||||||
return 0;
|
|
||||||
case "misc":
|
|
||||||
return (c.misc ?? 0) & 0x0F; // family 0x00, low nibble = misc
|
|
||||||
case "zone": {
|
|
||||||
const idx = (c.index ?? 0) & 0xFF;
|
|
||||||
return 0x0400 | (c.active ? 0x0200 : 0) | idx;
|
|
||||||
}
|
|
||||||
case "unit": {
|
|
||||||
const idx = (c.index ?? 0) & 0x01FF;
|
|
||||||
return 0x0800 | (c.active ? 0x0200 : 0) | idx;
|
|
||||||
}
|
|
||||||
case "time": {
|
|
||||||
const idx = (c.index ?? 0) & 0xFF;
|
|
||||||
return 0x0C00 | (c.active ? 0x0200 : 0) | idx;
|
|
||||||
}
|
|
||||||
case "sec": {
|
|
||||||
const area = (c.index ?? 1) & 0x0F;
|
|
||||||
const mode = (c.mode ?? 0) & 0x07;
|
|
||||||
return (mode << 12) | (area << 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// Clausal chain (multi-record) editor types
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
/** ProgramType values for the chain head/body/tail records. */
|
|
||||||
export const PROGRAM_TYPE_WHEN = 5;
|
|
||||||
export const PROGRAM_TYPE_AT = 6;
|
|
||||||
export const PROGRAM_TYPE_EVERY = 7;
|
|
||||||
export const PROGRAM_TYPE_AND = 8;
|
|
||||||
export const PROGRAM_TYPE_OR = 9;
|
|
||||||
export const PROGRAM_TYPE_THEN = 10;
|
|
||||||
|
|
||||||
/** Roles assigned by the backend's chain_members payload. */
|
|
||||||
export type ChainMemberRole = "head" | "condition" | "action";
|
|
||||||
|
|
||||||
export interface ChainMember {
|
|
||||||
slot: number;
|
|
||||||
role: ChainMemberRole;
|
|
||||||
fields: ProgramFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decoded view of a Traditional AND/OR record's condition.
|
|
||||||
*
|
|
||||||
* AND records use the SAME family encoding as compact-form cond, but
|
|
||||||
* the bytes land in different ProgramFields slots:
|
|
||||||
*
|
|
||||||
* family = fields.cond & 0xFF (disk byte 1)
|
|
||||||
* instance = (fields.cond2 >> 8) & 0xFF (disk byte 3)
|
|
||||||
*
|
|
||||||
* The selector bit (`0x0200`) doesn't apply to AND records the same
|
|
||||||
* way — instead the family byte's bit 1 (0x02) carries the
|
|
||||||
* secure/not-ready or off/on selector. For example:
|
|
||||||
* 0x04 = ZONE secure 0x06 = ZONE not-ready
|
|
||||||
* 0x08 = CTRL off 0x0A = CTRL on
|
|
||||||
* 0x0C = TIME disabled 0x0E = TIME enabled
|
|
||||||
*/
|
|
||||||
export function decodeAndCondition(fields: ProgramFields): DecodedCondition {
|
|
||||||
const family = (fields.cond ?? 0) & 0xFF;
|
|
||||||
const instance = ((fields.cond2 ?? 0) >> 8) & 0xFF;
|
|
||||||
const familyMajor = family & 0xFC;
|
|
||||||
const selector = (family & 0x02) !== 0;
|
|
||||||
if (family === 0 && instance === 0) return { family: "none" };
|
|
||||||
if (familyMajor === 0x00) return { family: "misc", misc: family & 0x0F };
|
|
||||||
if (familyMajor === 0x04) return { family: "zone", index: instance, active: selector };
|
|
||||||
if (familyMajor === 0x08) return { family: "unit", index: instance, active: selector };
|
|
||||||
if (familyMajor === 0x0C) return { family: "time", index: instance, active: selector };
|
|
||||||
// SEC: high nibble of family = mode, low nibble = area.
|
|
||||||
return {
|
|
||||||
family: "sec",
|
|
||||||
index: family & 0x0F,
|
|
||||||
mode: (family >> 4) & 0x07,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-encode a DecodedCondition into the cond/cond2 fields of an
|
|
||||||
* AND/OR record. Returns a partial ProgramFields with cond + cond2
|
|
||||||
* set; the caller should merge with the rest of the record (cmd/par/
|
|
||||||
* etc. stay zero for Traditional AND records).
|
|
||||||
*/
|
|
||||||
export function encodeAndCondition(c: DecodedCondition): {
|
|
||||||
cond: number; cond2: number;
|
|
||||||
} {
|
|
||||||
switch (c.family) {
|
|
||||||
case "none":
|
|
||||||
return { cond: 0, cond2: 0 };
|
|
||||||
case "misc":
|
|
||||||
return { cond: (c.misc ?? 0) & 0x0F, cond2: 0 };
|
|
||||||
case "zone": {
|
|
||||||
const family = 0x04 | (c.active ? 0x02 : 0);
|
|
||||||
return { cond: family, cond2: ((c.index ?? 0) & 0xFF) << 8 };
|
|
||||||
}
|
|
||||||
case "unit": {
|
|
||||||
const family = 0x08 | (c.active ? 0x02 : 0);
|
|
||||||
return { cond: family, cond2: ((c.index ?? 0) & 0xFF) << 8 };
|
|
||||||
}
|
|
||||||
case "time": {
|
|
||||||
const family = 0x0C | (c.active ? 0x02 : 0);
|
|
||||||
return { cond: family, cond2: ((c.index ?? 0) & 0xFF) << 8 };
|
|
||||||
}
|
|
||||||
case "sec": {
|
|
||||||
const area = (c.index ?? 1) & 0x0F;
|
|
||||||
const mode = (c.mode ?? 0) & 0x07;
|
|
||||||
const family = (mode << 4) | area;
|
|
||||||
return { cond: family, cond2: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if the AND/OR record's op byte indicates a Structured-OP
|
|
||||||
* comparison (TEMP > 70 etc.) rather than the Traditional bit-packed
|
|
||||||
* condition. Structured records use entirely different field
|
|
||||||
* semantics; the editor in this pass renders them read-only.
|
|
||||||
*
|
|
||||||
* OP byte lives at fields.cond >> 8 (disk byte 2). 0 = Traditional;
|
|
||||||
* 1..9 = Structured (CondOP enum).
|
|
||||||
*/
|
|
||||||
export function isStructuredAnd(fields: ProgramFields): boolean {
|
|
||||||
return (((fields.cond ?? 0) >> 8) & 0xFF) !== 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a fresh empty AND record (Traditional, NEVER condition). */
|
|
||||||
export function emptyAndRecord(): ProgramFields {
|
|
||||||
return {
|
|
||||||
prog_type: PROGRAM_TYPE_AND,
|
|
||||||
cond: 0x01, // family OTHER (0x00) + misc NEVER (0x01)
|
|
||||||
cond2: 0, cmd: 0, par: 0, pr2: 0,
|
|
||||||
month: 0, day: 0, days: 0, hour: 0, minute: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a fresh empty OR record. Same shape as AND with a different
|
|
||||||
* prog_type — semantically starts a new group in the conditions list.
|
|
||||||
*/
|
|
||||||
export function emptyOrRecord(): ProgramFields {
|
|
||||||
return { ...emptyAndRecord(), prog_type: PROGRAM_TYPE_OR };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a fresh empty THEN action record (Turn OFF unit 1). */
|
|
||||||
export function emptyThenRecord(firstUnit: number = 1): ProgramFields {
|
|
||||||
return {
|
|
||||||
prog_type: PROGRAM_TYPE_THEN,
|
|
||||||
cmd: 0, // UNIT_OFF
|
|
||||||
par: 0,
|
|
||||||
pr2: firstUnit,
|
|
||||||
cond: 0, cond2: 0,
|
|
||||||
month: 0, day: 0, days: 0, hour: 0, minute: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// Structured-OP AND record editing.
|
|
||||||
//
|
|
||||||
// When ``and_op`` (= ``(cond >> 8) & 0xFF``) is non-zero, the record
|
|
||||||
// encodes ``Arg1 OP Arg2`` where Arg1 and Arg2 are typed references
|
|
||||||
// (Zone, Unit, Thermostat, Area, TimeDate, Constant) plus per-type
|
|
||||||
// field selectors. This is fundamentally a different shape from the
|
|
||||||
// Traditional encoding handled by decodeAndCondition above.
|
|
||||||
//
|
|
||||||
// Wire layout (from programs.py decoders + clsProgram.cs):
|
|
||||||
//
|
|
||||||
// cond high byte (>>8) = and_op (CondOP)
|
|
||||||
// cond low byte (& FF) = and_arg1_argtype (CondArgType)
|
|
||||||
// cond2 (whole u16) = and_arg1_ix (object index or 0)
|
|
||||||
// cmd = and_arg1_field (per-type field selector)
|
|
||||||
// par = and_arg2_argtype (CondArgType — usually Constant)
|
|
||||||
// pr2 = and_arg2_ix (constant value OR second object idx)
|
|
||||||
// month = and_arg2_field (per-type field selector for arg2)
|
|
||||||
// day, days = and_compconst (BE u16 — extra constant, rarely used)
|
|
||||||
//
|
|
||||||
// Editor cuts:
|
|
||||||
// * Arg1 and Arg2 both restricted to Constant / Zone / Unit /
|
|
||||||
// Thermostat / Area / TimeDate. Anything else (Aux / Audio /
|
|
||||||
// System / etc.) stays read-only.
|
|
||||||
// * Non-zero CompConst stays read-only (rarely used; preserved on
|
|
||||||
// save).
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
// CondOP enum (matches omni_pca.programs.CondOP). 0=Traditional is
|
|
||||||
// excluded from the editor — picking it would switch to Traditional
|
|
||||||
// editing semantics.
|
|
||||||
export const COND_OPS: ReadonlyArray<{ value: number; label: string }> = [
|
|
||||||
{ value: 1, label: "==" },
|
|
||||||
{ value: 2, label: "!=" },
|
|
||||||
{ value: 3, label: "<" },
|
|
||||||
{ value: 4, label: ">" },
|
|
||||||
{ value: 5, label: "is odd" },
|
|
||||||
{ value: 6, label: "is even" },
|
|
||||||
{ value: 7, label: "is multiple of" },
|
|
||||||
{ value: 8, label: "in (bitmask)" },
|
|
||||||
{ value: 9, label: "not in (bitmask)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** True iff the operator only uses Arg1 (no Arg2). */
|
|
||||||
export function isUnaryOp(op: number): boolean {
|
|
||||||
return op === 5 || op === 6; // ODD, EVEN
|
|
||||||
}
|
|
||||||
|
|
||||||
// CondArgType enum (matches omni_pca.programs.CondArgType). Only the
|
|
||||||
// editor-supported subset; full list is in programs.py.
|
|
||||||
export const ARG_TYPES: ReadonlyArray<{
|
|
||||||
value: number; label: string; kind: string | null;
|
|
||||||
}> = [
|
|
||||||
{ value: 0, label: "Constant", kind: null },
|
|
||||||
{ value: 2, label: "Zone", kind: "zone" },
|
|
||||||
{ value: 3, label: "Unit", kind: "unit" },
|
|
||||||
{ value: 4, label: "Thermostat", kind: "thermostat" },
|
|
||||||
{ value: 6, label: "Area", kind: "area" },
|
|
||||||
{ value: 7, label: "Time / Date", kind: null }, // no object picker
|
|
||||||
];
|
|
||||||
|
|
||||||
export function isEditableArg1Type(argtype: number): boolean {
|
|
||||||
return [2, 3, 4, 6, 7].includes(argtype);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function argTypeKind(argtype: number): string | null {
|
|
||||||
const a = ARG_TYPES.find((x) => x.value === argtype);
|
|
||||||
return a ? a.kind : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-Arg1Type field menus. Numbers match omni_pca.programs enums
|
|
||||||
// (enuZoneField / enuUnitField / enuThermostatField / enuTimeDateField).
|
|
||||||
export const FIELDS_BY_TYPE: Readonly<Record<number, ReadonlyArray<{
|
|
||||||
value: number; label: string;
|
|
||||||
}>>> = {
|
|
||||||
// Zone (argtype 2) — enuZoneField
|
|
||||||
2: [
|
|
||||||
{ value: 1, label: "Loop reading" },
|
|
||||||
{ value: 2, label: "Current state" },
|
|
||||||
{ value: 3, label: "Arming state" },
|
|
||||||
{ value: 4, label: "Alarm state" },
|
|
||||||
],
|
|
||||||
// Unit (argtype 3) — enuUnitField
|
|
||||||
3: [
|
|
||||||
{ value: 1, label: "Current state" },
|
|
||||||
{ value: 2, label: "Previous state" },
|
|
||||||
{ value: 3, label: "Timer" },
|
|
||||||
{ value: 4, label: "Level" },
|
|
||||||
],
|
|
||||||
// Thermostat (argtype 4) — enuThermostatField
|
|
||||||
4: [
|
|
||||||
{ value: 1, label: "Current temperature" },
|
|
||||||
{ value: 2, label: "Heat setpoint" },
|
|
||||||
{ value: 3, label: "Cool setpoint" },
|
|
||||||
{ value: 4, label: "System mode" },
|
|
||||||
{ value: 5, label: "Fan mode" },
|
|
||||||
{ value: 6, label: "Hold mode" },
|
|
||||||
{ value: 7, label: "Freeze alarm" },
|
|
||||||
{ value: 8, label: "Comm error" },
|
|
||||||
{ value: 9, label: "Humidity" },
|
|
||||||
{ value: 10, label: "Humidify setpoint" },
|
|
||||||
{ value: 11, label: "Dehumidify setpoint" },
|
|
||||||
{ value: 12, label: "Outdoor temperature" },
|
|
||||||
{ value: 13, label: "System status" },
|
|
||||||
],
|
|
||||||
// Area (argtype 6) — single useful field
|
|
||||||
6: [
|
|
||||||
{ value: 1, label: "Security mode" },
|
|
||||||
],
|
|
||||||
// TimeDate (argtype 7) — enuTimeDateField
|
|
||||||
7: [
|
|
||||||
{ value: 2, label: "Year" },
|
|
||||||
{ value: 3, label: "Month" },
|
|
||||||
{ value: 4, label: "Day" },
|
|
||||||
{ value: 5, label: "Day of week (1=Mon..7=Sun)" },
|
|
||||||
{ value: 6, label: "Time (minutes since midnight)" },
|
|
||||||
{ value: 8, label: "Hour" },
|
|
||||||
{ value: 9, label: "Minute" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DecodedStructuredAnd {
|
|
||||||
op: number; // CondOP value (1..9)
|
|
||||||
arg1Type: number; // CondArgType
|
|
||||||
arg1Ix: number; // 1-based object index (0 for TimeDate)
|
|
||||||
arg1Field: number; // per-type field
|
|
||||||
arg2Type: number; // CondArgType (locked to Constant in editor)
|
|
||||||
arg2Ix: number; // constant value OR second object index
|
|
||||||
arg2Field: number; // per-type field (usually 0 for constants)
|
|
||||||
compConst: number; // extra constant; preserved verbatim
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeStructuredAnd(fields: ProgramFields): DecodedStructuredAnd {
|
|
||||||
return {
|
|
||||||
op: ((fields.cond ?? 0) >> 8) & 0xFF,
|
|
||||||
arg1Type: (fields.cond ?? 0) & 0xFF,
|
|
||||||
arg1Ix: fields.cond2 ?? 0,
|
|
||||||
arg1Field: fields.cmd ?? 0,
|
|
||||||
arg2Type: fields.par ?? 0,
|
|
||||||
arg2Ix: fields.pr2 ?? 0,
|
|
||||||
arg2Field: fields.month ?? 0,
|
|
||||||
compConst: ((fields.day ?? 0) << 8) | (fields.days ?? 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeStructuredAnd(s: DecodedStructuredAnd): Partial<ProgramFields> {
|
|
||||||
return {
|
|
||||||
cond: ((s.op & 0xFF) << 8) | (s.arg1Type & 0xFF),
|
|
||||||
cond2: s.arg1Ix & 0xFFFF,
|
|
||||||
cmd: s.arg1Field & 0xFF,
|
|
||||||
par: s.arg2Type & 0xFF,
|
|
||||||
pr2: s.arg2Ix & 0xFFFF,
|
|
||||||
month: s.arg2Field & 0xFF,
|
|
||||||
day: (s.compConst >> 8) & 0xFF,
|
|
||||||
days: s.compConst & 0xFF,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True iff the structured AND record is in a shape the editor can
|
|
||||||
* fully drive. Arg1 must be one of the editable reference types;
|
|
||||||
* Arg2 must be Constant or one of the editable reference types
|
|
||||||
* (unary operators ignore Arg2 entirely). Non-zero compConst stays
|
|
||||||
* read-only — preserved on save but not exposed as a form control. */
|
|
||||||
export function isEditableStructuredAnd(s: DecodedStructuredAnd): boolean {
|
|
||||||
if (!isEditableArg1Type(s.arg1Type)) return false;
|
|
||||||
if (!isUnaryOp(s.op) && s.arg2Type !== 0 && !isEditableArg1Type(s.arg2Type)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (s.compConst !== 0) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** HA's hass object — minimal surface we use. */
|
|
||||||
export interface Hass {
|
|
||||||
connection: {
|
|
||||||
sendMessagePromise<T>(msg: unknown): Promise<T>;
|
|
||||||
subscribeEvents<T>(
|
|
||||||
callback: (event: T) => void,
|
|
||||||
eventType: string,
|
|
||||||
): Promise<() => Promise<void>>;
|
|
||||||
};
|
|
||||||
config?: {
|
|
||||||
entries?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
// Whole hass is much larger; we only touch what we need.
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ES2022",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"useDefineForClassFields": false,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"allowImportingTsExtensions": false,
|
|
||||||
"noEmit": true,
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"]
|
|
||||||
}
|
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 113 KiB |
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -1,150 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Focused screenshot of the structured-AND Arg2-as-object editor.
|
|
||||||
|
|
||||||
Drives an already-onboarded HA at localhost:8123, opens the side panel,
|
|
||||||
clicks into the chain at slot 200, hits Edit, and snaps the form.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
|
|
||||||
HA_URL = "http://localhost:8123"
|
|
||||||
USERNAME = "demo"
|
|
||||||
PASSWORD = "demo-password-1234"
|
|
||||||
|
|
||||||
|
|
||||||
async def _login_token() -> str:
|
|
||||||
async with httpx.AsyncClient(base_url=HA_URL, timeout=30) as c:
|
|
||||||
r = await c.post(
|
|
||||||
"/auth/login_flow",
|
|
||||||
json={
|
|
||||||
"client_id": HA_URL,
|
|
||||||
"handler": ["homeassistant", None],
|
|
||||||
"redirect_uri": HA_URL,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
flow_id = r.json()["flow_id"]
|
|
||||||
r = await c.post(
|
|
||||||
f"/auth/login_flow/{flow_id}",
|
|
||||||
json={
|
|
||||||
"username": USERNAME,
|
|
||||||
"password": PASSWORD,
|
|
||||||
"client_id": HA_URL,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
code = r.json()["result"]
|
|
||||||
r = await c.post(
|
|
||||||
"/auth/token",
|
|
||||||
data={
|
|
||||||
"client_id": HA_URL,
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": code,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return r.json()["access_token"]
|
|
||||||
|
|
||||||
|
|
||||||
FIND_PANEL = """
|
|
||||||
(() => {
|
|
||||||
function find(root, depth=0) {
|
|
||||||
if (!root || depth > 15) return null;
|
|
||||||
if (root.tagName === 'OMNI-PANEL-PROGRAMS') return root;
|
|
||||||
for (const k of Array.from(root.children || [])) {
|
|
||||||
const r = find(k, depth+1);
|
|
||||||
if (r) return r;
|
|
||||||
}
|
|
||||||
if (root.shadowRoot) {
|
|
||||||
const r = find(root.shadowRoot, depth+1);
|
|
||||||
if (r) return r;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return find(document.body);
|
|
||||||
})()
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
async def amain(outdir: Path) -> None:
|
|
||||||
token = await _login_token()
|
|
||||||
outdir.mkdir(parents=True, exist_ok=True)
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch()
|
|
||||||
context = await browser.new_context(viewport={"width": 1400, "height": 900})
|
|
||||||
await context.add_init_script(f"""
|
|
||||||
window.localStorage.setItem(
|
|
||||||
'hassTokens',
|
|
||||||
JSON.stringify({{
|
|
||||||
access_token: '{token}',
|
|
||||||
token_type: 'Bearer',
|
|
||||||
refresh_token: '',
|
|
||||||
expires: Date.now() + 3600000,
|
|
||||||
hassUrl: '{HA_URL}',
|
|
||||||
clientId: '{HA_URL}',
|
|
||||||
}})
|
|
||||||
);
|
|
||||||
window.localStorage.setItem('selectedTheme', JSON.stringify({{dark: false}}));
|
|
||||||
""")
|
|
||||||
page = await context.new_page()
|
|
||||||
|
|
||||||
page.on("console", lambda m: print(f" [browser] {m.type}: {m.text}"))
|
|
||||||
|
|
||||||
await page.goto(f"{HA_URL}/omni-panel-programs", wait_until="domcontentloaded")
|
|
||||||
await page.wait_for_timeout(6000)
|
|
||||||
|
|
||||||
# Click the chain row (slot 200).
|
|
||||||
ok = await page.evaluate(f"""() => {{
|
|
||||||
const panel = {FIND_PANEL};
|
|
||||||
if (!panel) return 'no-panel';
|
|
||||||
const rows = Array.from(panel.shadowRoot.querySelectorAll('.row'));
|
|
||||||
const target = rows.find(r => r.textContent.includes('200'));
|
|
||||||
if (!target) return 'no-row-200 ' + rows.map(r => r.textContent.slice(0,40)).join(' | ');
|
|
||||||
target.click();
|
|
||||||
return 'clicked';
|
|
||||||
}}""")
|
|
||||||
print(f" row-click: {ok}")
|
|
||||||
await page.wait_for_timeout(800)
|
|
||||||
|
|
||||||
# Click Edit.
|
|
||||||
ok = await page.evaluate(f"""() => {{
|
|
||||||
const panel = {FIND_PANEL};
|
|
||||||
if (!panel) return 'no-panel';
|
|
||||||
const buttons = panel.shadowRoot.querySelectorAll('.detail button');
|
|
||||||
for (const b of buttons) {{
|
|
||||||
if (b.textContent.trim() === 'Edit') {{ b.click(); return 'clicked'; }}
|
|
||||||
}}
|
|
||||||
return 'no-edit-button';
|
|
||||||
}}""")
|
|
||||||
print(f" edit-click: {ok}")
|
|
||||||
await page.wait_for_timeout(1500)
|
|
||||||
|
|
||||||
path = outdir / "arg2-object-editor.png"
|
|
||||||
await page.screenshot(path=str(path), full_page=True)
|
|
||||||
print(f" wrote {path}")
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument(
|
|
||||||
"--outdir",
|
|
||||||
type=Path,
|
|
||||||
default=Path(__file__).parent / "artifacts" / "screenshots" /
|
|
||||||
datetime.now().strftime("%Y-%m-%d"),
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
asyncio.run(amain(args.outdir))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Quick screenshot of the Omni Programs side panel landing page."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
|
|
||||||
HA_URL = "http://localhost:8123"
|
|
||||||
USERNAME = "demo"
|
|
||||||
PASSWORD = "demo-password-1234"
|
|
||||||
|
|
||||||
|
|
||||||
async def _login_token() -> str:
|
|
||||||
async with httpx.AsyncClient(base_url=HA_URL, timeout=30) as c:
|
|
||||||
r = await c.post("/auth/login_flow", json={
|
|
||||||
"client_id": HA_URL, "handler": ["homeassistant", None],
|
|
||||||
"redirect_uri": HA_URL,
|
|
||||||
})
|
|
||||||
flow_id = r.json()["flow_id"]
|
|
||||||
r = await c.post(f"/auth/login_flow/{flow_id}", json={
|
|
||||||
"username": USERNAME, "password": PASSWORD, "client_id": HA_URL,
|
|
||||||
})
|
|
||||||
code = r.json()["result"]
|
|
||||||
r = await c.post("/auth/token", data={
|
|
||||||
"client_id": HA_URL, "grant_type": "authorization_code", "code": code,
|
|
||||||
})
|
|
||||||
return r.json()["access_token"]
|
|
||||||
|
|
||||||
|
|
||||||
async def amain(outdir: Path) -> None:
|
|
||||||
token = await _login_token()
|
|
||||||
outdir.mkdir(parents=True, exist_ok=True)
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch()
|
|
||||||
context = await browser.new_context(viewport={"width": 1400, "height": 900})
|
|
||||||
await context.add_init_script(f"""
|
|
||||||
window.localStorage.setItem('hassTokens', JSON.stringify({{
|
|
||||||
access_token: '{token}', token_type: 'Bearer', refresh_token: '',
|
|
||||||
expires: Date.now() + 3600000, hassUrl: '{HA_URL}', clientId: '{HA_URL}',
|
|
||||||
}}));
|
|
||||||
window.localStorage.setItem('selectedTheme', JSON.stringify({{dark: false}}));
|
|
||||||
""")
|
|
||||||
page = await context.new_page()
|
|
||||||
await page.goto(f"{HA_URL}/omni-panel-programs", wait_until="domcontentloaded")
|
|
||||||
await page.wait_for_timeout(8000)
|
|
||||||
path = outdir / "real-pca-overview.png"
|
|
||||||
await page.screenshot(path=str(path), full_page=True)
|
|
||||||
print(f" wrote {path}")
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
outdir = Path(sys.argv[1]) if len(sys.argv) > 1 else (
|
|
||||||
Path(__file__).parent / "artifacts" / "screenshots" /
|
|
||||||
datetime.now().strftime("%Y-%m-%d")
|
|
||||||
)
|
|
||||||
asyncio.run(amain(outdir))
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Copy to .env and fill in. Both files in this directory load .env
|
|
||||||
# automatically via docker compose; ./env.example is committed, .env
|
|
||||||
# is gitignored.
|
|
||||||
|
|
||||||
# InfluxDB v2 admin user (created on first boot).
|
|
||||||
INFLUX_USERNAME=admin
|
|
||||||
INFLUX_PASSWORD=change-me-strong-password-here
|
|
||||||
|
|
||||||
# Admin token used by Home Assistant (writes) and Grafana (reads).
|
|
||||||
# Generate one with: openssl rand -hex 32
|
|
||||||
INFLUX_TOKEN=replace-with-a-real-token-from-openssl-rand-hex-32
|
|
||||||
|
|
||||||
# Grafana admin password (UI login as "admin"/this value).
|
|
||||||
GRAFANA_PASSWORD=change-me-too
|
|
||||||
|
|
||||||
# Public hostnames if you're putting either service behind a reverse
|
|
||||||
# proxy. Leave blank for localhost-only access.
|
|
||||||
INFLUX_PUBLIC_HOST=
|
|
||||||
GRAFANA_PUBLIC_HOST=
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
# Grafana dashboard for omni_pca
|
|
||||||
|
|
||||||
InfluxDB v2 + Grafana stack pre-provisioned to visualise an HAI/Leviton
|
|
||||||
Omni Pro II panel via the `omni_pca` Home Assistant integration.
|
|
||||||
Drop-in for any existing HA install — no integration changes required.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## What you get
|
|
||||||
|
|
||||||
One dashboard, four rows:
|
|
||||||
|
|
||||||
- **System health** — AC power, backup battery, system trouble, event count (24h).
|
|
||||||
- **Security** — area arming state timeline, recent push-event log, zone trip timeline.
|
|
||||||
- **Climate** — per-thermostat current temperatures + setpoints, HVAC mode timeline.
|
|
||||||
- **Activity** — event rate by typed event class, unit brightness heatmap.
|
|
||||||
|
|
||||||
Data flows: HA entity state → HA's `influxdb:` integration → InfluxDB
|
|
||||||
v2 bucket → Grafana Flux queries → dashboard panels.
|
|
||||||
|
|
||||||
## Quick start (~5 minutes)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd grafana/
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env — set strong INFLUX_PASSWORD, INFLUX_TOKEN, GRAFANA_PASSWORD.
|
|
||||||
# Generate the token with: openssl rand -hex 32
|
|
||||||
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Wait ~30 seconds. InfluxDB does first-boot setup (creates the
|
|
||||||
`omni-pca` org, `ha` bucket, admin token); Grafana then auto-provisions
|
|
||||||
the InfluxDB datasource and the dashboard.
|
|
||||||
|
|
||||||
Then add the influxdb integration to your Home Assistant config:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Paste the contents of ha-snippet.yaml into your configuration.yaml.
|
|
||||||
# Add `influxdb_token: <your INFLUX_TOKEN from .env>` to your secrets.yaml.
|
|
||||||
# Restart HA.
|
|
||||||
```
|
|
||||||
|
|
||||||
Within ~30 seconds you should see real-time data populating the
|
|
||||||
dashboard at <http://localhost:3000> (login: `admin` / your
|
|
||||||
`GRAFANA_PASSWORD`).
|
|
||||||
|
|
||||||
## Networking notes
|
|
||||||
|
|
||||||
The default `ha-snippet.yaml` assumes HA and InfluxDB sit on the same
|
|
||||||
docker network and HA can reach `influxdb:8086` by container name.
|
|
||||||
Three common variants:
|
|
||||||
|
|
||||||
| HA layout | `host:` value |
|
|
||||||
|---|---|
|
|
||||||
| Same compose stack as this bundle | `influxdb` |
|
|
||||||
| HA on the host, InfluxDB in docker | `host.docker.internal` or your LAN IP |
|
|
||||||
| Different machine entirely | the InfluxDB host's IP / FQDN |
|
|
||||||
|
|
||||||
If you put either service behind a reverse proxy with TLS, set `ssl:
|
|
||||||
true` in the HA snippet and supply the public hostname.
|
|
||||||
|
|
||||||
## Iterating on the dashboard
|
|
||||||
|
|
||||||
The dashboard JSON at `provisioning/dashboards/omni-pro-ii.json` is
|
|
||||||
loaded read-only by the provisioner. To change it:
|
|
||||||
|
|
||||||
1. Edit the JSON directly, then `docker compose restart grafana`
|
|
||||||
(provisioner picks up changes within ~30s).
|
|
||||||
2. Or use the Grafana UI to experiment, then **Dashboard settings →
|
|
||||||
JSON Model → Save to file** and overwrite the file in this repo.
|
|
||||||
|
|
||||||
Provisioned dashboards can't be saved from the UI by design — this is
|
|
||||||
intentional, so the file on disk stays the source of truth.
|
|
||||||
|
|
||||||
## Extending coverage
|
|
||||||
|
|
||||||
The bundle is scoped to the `omni_pca` entity surface via the
|
|
||||||
`entity_globs: ["*omni*"]` filter in `ha-snippet.yaml`. Drop that
|
|
||||||
filter (or add a second `include:` block) if you want to graph other
|
|
||||||
HA entities alongside omni data — Grafana's datasource is general
|
|
||||||
InfluxDB v2, nothing in the dashboard JSON hard-codes omni-specific
|
|
||||||
field names beyond what you'd want to scope to anyway.
|
|
||||||
|
|
||||||
A few panel ideas not yet shipped:
|
|
||||||
|
|
||||||
- Alarm activation drill-down — filter the event log to
|
|
||||||
`event_type == "alarm_activated"` and show the `alarm_type`
|
|
||||||
(Burglary / Fire / Auxiliary / …) distribution.
|
|
||||||
- Zone trip rate histogram — `binary_sensor` zone changes per zone
|
|
||||||
per hour, useful for spotting flaky sensors.
|
|
||||||
- Comm health — track integration coordinator state via the panel
|
|
||||||
device's "Comm error" attribute.
|
|
||||||
|
|
||||||
## Files in this bundle
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `docker-compose.yml` | InfluxDB v2 + Grafana services |
|
|
||||||
| `.env.example` | Required environment template |
|
|
||||||
| `ha-snippet.yaml` | HA configuration.yaml additions |
|
|
||||||
| `provisioning/datasources/influxdb.yml` | Auto-wires the datasource |
|
|
||||||
| `provisioning/dashboards/dashboards.yml` | Provisioner config |
|
|
||||||
| `provisioning/dashboards/omni-pro-ii.json` | The dashboard JSON |
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**"No data" in panels.** Most panels need either continuous state
|
|
||||||
updates (climate, security) or push events (event-driven panels).
|
|
||||||
Verify HA is shipping data:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it omni-pca-influxdb influx query \
|
|
||||||
'from(bucket:"ha") |> range(start:-5m) |> limit(n:5)' \
|
|
||||||
--token "$INFLUX_TOKEN" --org omni-pca
|
|
||||||
```
|
|
||||||
|
|
||||||
If this returns rows, the pipeline is healthy and panels will fill in
|
|
||||||
as the panel does interesting things. If it's empty, check HA logs for
|
|
||||||
`[homeassistant.components.influxdb]` errors.
|
|
||||||
|
|
||||||
**Dashboard didn't auto-load.** Check `docker logs omni-pca-grafana
|
|
||||||
2>&1 | grep -i provision` — provisioner errors show up there.
|
|
||||||
|
|
||||||
**Stat panels show duplicate values.** Your HA has multiple entities
|
|
||||||
matching the regex (e.g. `omni_pro_ii_ac_power` AND
|
|
||||||
`omni_pro_ii_ac_power_2` from prior integration reloads). Clean up the
|
|
||||||
duplicates in HA's entity registry, or tighten the filter in the
|
|
||||||
dashboard JSON.
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
# Self-contained InfluxDB v2 + Grafana stack for the omni_pca
|
|
||||||
# integration. Pre-provisioned with the InfluxDB datasource and the
|
|
||||||
# "Omni Pro II — Panel Overview" dashboard.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# cp .env.example .env && edit the secrets && docker compose up -d
|
|
||||||
# open http://localhost:3000 (admin / $GRAFANA_PASSWORD)
|
|
||||||
#
|
|
||||||
# Then paste the contents of ha-snippet.yaml into your HA
|
|
||||||
# configuration.yaml (and add `influxdb_token: $INFLUX_TOKEN` to
|
|
||||||
# secrets.yaml). Restart HA. Within 30s the dashboard's panels start
|
|
||||||
# filling in.
|
|
||||||
|
|
||||||
services:
|
|
||||||
influxdb:
|
|
||||||
image: influxdb:2.7-alpine
|
|
||||||
container_name: omni-pca-influxdb
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
DOCKER_INFLUXDB_INIT_MODE: setup
|
|
||||||
DOCKER_INFLUXDB_INIT_USERNAME: ${INFLUX_USERNAME:-admin}
|
|
||||||
DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUX_PASSWORD}
|
|
||||||
DOCKER_INFLUXDB_INIT_ORG: omni-pca
|
|
||||||
DOCKER_INFLUXDB_INIT_BUCKET: ha
|
|
||||||
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUX_TOKEN}
|
|
||||||
DOCKER_INFLUXDB_INIT_RETENTION: 30d
|
|
||||||
volumes:
|
|
||||||
- influxdb-data:/var/lib/influxdb2
|
|
||||||
- influxdb-config:/etc/influxdb2
|
|
||||||
ports:
|
|
||||||
- "8086:8086"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8086/health"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:11.4.0
|
|
||||||
container_name: omni-pca-grafana
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
influxdb:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
|
|
||||||
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
|
||||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
|
||||||
GF_LOG_LEVEL: warn
|
|
||||||
# Consumed by ./provisioning/datasources/influxdb.yml
|
|
||||||
INFLUX_URL: http://influxdb:8086
|
|
||||||
INFLUX_TOKEN: ${INFLUX_TOKEN}
|
|
||||||
volumes:
|
|
||||||
- grafana-data:/var/lib/grafana
|
|
||||||
- ./provisioning:/etc/grafana/provisioning:ro
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/health || exit 1"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
start_period: 15s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
influxdb-data:
|
|
||||||
influxdb-config:
|
|
||||||
grafana-data:
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
# Paste this block into your Home Assistant configuration.yaml.
|
|
||||||
#
|
|
||||||
# Prerequisites:
|
|
||||||
# 1. The grafana stack from this directory is running:
|
|
||||||
# cd grafana/ && cp .env.example .env && docker compose up -d
|
|
||||||
# 2. Your HA instance can reach the influxdb container on port 8086.
|
|
||||||
# Common patterns:
|
|
||||||
# - HA and InfluxDB on the same compose stack: use host=influxdb
|
|
||||||
# - HA and InfluxDB on different hosts: use host=<your-influx-ip>
|
|
||||||
# - HA on the host network, InfluxDB in docker: use
|
|
||||||
# host=host.docker.internal or the host's LAN IP
|
|
||||||
# 3. Add `influxdb_token: <your INFLUX_TOKEN from .env>` to your
|
|
||||||
# secrets.yaml. Restart HA after editing both files.
|
|
||||||
#
|
|
||||||
# What this ships:
|
|
||||||
# - All state changes from omni_pca entities (alarm_control_panel,
|
|
||||||
# binary_sensor, climate, event, light, sensor, switch).
|
|
||||||
# - Event entity attributes carried as fields, including the typed
|
|
||||||
# event_class and event_data payload — so Flux queries can filter
|
|
||||||
# by alarm_type, zone_index, etc.
|
|
||||||
#
|
|
||||||
# Adjust the entity_globs filter if you also want non-omni entities in
|
|
||||||
# the dashboard, or tighten it further to scope by area / device.
|
|
||||||
|
|
||||||
influxdb:
|
|
||||||
api_version: 2
|
|
||||||
host: influxdb # change to match your network layout
|
|
||||||
port: 8086
|
|
||||||
ssl: false
|
|
||||||
verify_ssl: false
|
|
||||||
token: !secret influxdb_token
|
|
||||||
organization: omni-pca
|
|
||||||
bucket: ha
|
|
||||||
precision: s
|
|
||||||
|
|
||||||
# Tag the typed event kind so Flux queries can filter by it cheaply.
|
|
||||||
tags_attributes:
|
|
||||||
- event_type
|
|
||||||
- event_class
|
|
||||||
|
|
||||||
include:
|
|
||||||
domains:
|
|
||||||
- alarm_control_panel
|
|
||||||
- binary_sensor
|
|
||||||
- climate
|
|
||||||
- event
|
|
||||||
- light
|
|
||||||
- sensor
|
|
||||||
- switch
|
|
||||||
entity_globs:
|
|
||||||
- "*omni*" # scope to omni_pca entities only
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Tells Grafana to scan /etc/grafana/provisioning/dashboards for
|
|
||||||
# *.json dashboard files at boot. Picks up omni-pro-ii.json
|
|
||||||
# automatically. Dashboards loaded this way are read-only in the UI;
|
|
||||||
# the source of truth is the JSON in this directory.
|
|
||||||
|
|
||||||
apiVersion: 1
|
|
||||||
|
|
||||||
providers:
|
|
||||||
- name: omni-pca
|
|
||||||
orgId: 1
|
|
||||||
folder: ''
|
|
||||||
folderUid: ''
|
|
||||||
type: file
|
|
||||||
disableDeletion: false
|
|
||||||
updateIntervalSeconds: 30
|
|
||||||
allowUiUpdates: false
|
|
||||||
options:
|
|
||||||
path: /etc/grafana/provisioning/dashboards
|
|
||||||
foldersFromFilesStructure: false
|
|
||||||
@ -1,682 +0,0 @@
|
|||||||
{
|
|
||||||
"annotations": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"builtIn": 1,
|
|
||||||
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
|
|
||||||
"enable": true,
|
|
||||||
"hide": true,
|
|
||||||
"iconColor": "rgba(0, 211, 255, 1)",
|
|
||||||
"name": "Annotations & Alerts",
|
|
||||||
"type": "dashboard"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Live view of an HAI/Leviton Omni Pro II panel surfaced by the omni_pca Home Assistant integration. System health, security activity, climate trends, and the typed push-event stream — all sourced from InfluxDB writes shipped by HA's influxdb integration.",
|
|
||||||
"editable": true,
|
|
||||||
"fiscalYearStartMonth": 0,
|
|
||||||
"graphTooltip": 1,
|
|
||||||
"id": null,
|
|
||||||
"links": [],
|
|
||||||
"liveNow": false,
|
|
||||||
"panels": [
|
|
||||||
{
|
|
||||||
"collapsed": false,
|
|
||||||
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 0},
|
|
||||||
"id": 100,
|
|
||||||
"panels": [],
|
|
||||||
"title": "System health",
|
|
||||||
"type": "row"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "thresholds"},
|
|
||||||
"mappings": [
|
|
||||||
{"options": {"0": {"color": "red", "index": 0, "text": "LOST"}}, "type": "value"},
|
|
||||||
{"options": {"1": {"color": "green", "index": 1, "text": "OK"}}, "type": "value"}
|
|
||||||
],
|
|
||||||
"thresholds": {"mode": "absolute", "steps": [{"color": "red"}, {"color": "green", "value": 1}]},
|
|
||||||
"unit": "none"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 5, "w": 6, "x": 0, "y": 1},
|
|
||||||
"id": 101,
|
|
||||||
"options": {
|
|
||||||
"colorMode": "background",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "auto",
|
|
||||||
"orientation": "auto",
|
|
||||||
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
|
||||||
"textMode": "auto"
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: -24h)\n |> filter(fn: (r) => r.domain == \"binary_sensor\")\n |> filter(fn: (r) => r.entity_id =~ /ac_power/ and not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> last()\n |> group()",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "AC power",
|
|
||||||
"type": "stat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "thresholds"},
|
|
||||||
"mappings": [
|
|
||||||
{"options": {"0": {"color": "green", "index": 0, "text": "OK"}}, "type": "value"},
|
|
||||||
{"options": {"1": {"color": "red", "index": 1, "text": "LOW"}}, "type": "value"}
|
|
||||||
],
|
|
||||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "red", "value": 1}]}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 5, "w": 6, "x": 6, "y": 1},
|
|
||||||
"id": 102,
|
|
||||||
"options": {
|
|
||||||
"colorMode": "background",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "auto",
|
|
||||||
"orientation": "auto",
|
|
||||||
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
|
||||||
"textMode": "auto"
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: -24h)\n |> filter(fn: (r) => r.domain == \"binary_sensor\")\n |> filter(fn: (r) => r.entity_id =~ /battery/ and not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> last()\n |> group()",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Backup battery",
|
|
||||||
"type": "stat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "thresholds"},
|
|
||||||
"mappings": [
|
|
||||||
{"options": {"0": {"color": "green", "index": 0, "text": "Clear"}}, "type": "value"},
|
|
||||||
{"options": {"1": {"color": "red", "index": 1, "text": "Trouble"}}, "type": "value"}
|
|
||||||
],
|
|
||||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "red", "value": 1}]}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 5, "w": 6, "x": 12, "y": 1},
|
|
||||||
"id": 103,
|
|
||||||
"options": {
|
|
||||||
"colorMode": "background",
|
|
||||||
"graphMode": "none",
|
|
||||||
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
|
||||||
"textMode": "auto"
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: -24h)\n |> filter(fn: (r) => r.domain == \"binary_sensor\")\n |> filter(fn: (r) => r.entity_id =~ /trouble/ and not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> last()\n |> group()",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "System trouble",
|
|
||||||
"type": "stat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"description": "Count of panel push events in the last 24 hours. Empty until the panel pushes its first event (the mock fires events when HA actions trigger panel state changes; a real panel pushes continuously).",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "thresholds"},
|
|
||||||
"thresholds": {"mode": "absolute", "steps": [{"color": "blue"}, {"color": "green", "value": 1}]},
|
|
||||||
"unit": "short"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 5, "w": 6, "x": 18, "y": 1},
|
|
||||||
"id": 104,
|
|
||||||
"options": {
|
|
||||||
"colorMode": "background",
|
|
||||||
"graphMode": "area",
|
|
||||||
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
|
||||||
"textMode": "auto"
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: -24h)\n |> filter(fn: (r) => r.domain == \"event\")\n |> filter(fn: (r) => r._field == \"state\")\n |> group()\n |> count()",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Events (24h)",
|
|
||||||
"type": "stat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collapsed": false,
|
|
||||||
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 6},
|
|
||||||
"id": 200,
|
|
||||||
"panels": [],
|
|
||||||
"title": "Security",
|
|
||||||
"type": "row"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"description": "Arming state per area. Disarmed = green, day = teal, night = blue, away = orange, vacation = magenta, triggered = red, arming/pending = yellow.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "thresholds"},
|
|
||||||
"custom": {"fillOpacity": 80, "lineWidth": 0},
|
|
||||||
"mappings": [
|
|
||||||
{"options": {"disarmed": {"color": "#43aa8b", "text": "disarmed"}}, "type": "value"},
|
|
||||||
{"options": {"armed_home": {"color": "#577590", "text": "armed home"}}, "type": "value"},
|
|
||||||
{"options": {"armed_night": {"color": "#277da1", "text": "armed night"}}, "type": "value"},
|
|
||||||
{"options": {"armed_away": {"color": "#f8961e", "text": "armed away"}}, "type": "value"},
|
|
||||||
{"options": {"armed_vacation": {"color": "#a663cc", "text": "armed vacation"}}, "type": "value"},
|
|
||||||
{"options": {"armed_custom_bypass": {"color": "#90be6d", "text": "armed custom"}}, "type": "value"},
|
|
||||||
{"options": {"arming": {"color": "#f9c74f", "text": "arming"}}, "type": "value"},
|
|
||||||
{"options": {"pending": {"color": "#f9c74f", "text": "pending"}}, "type": "value"},
|
|
||||||
{"options": {"triggered": {"color": "#d62828", "text": "TRIGGERED"}}, "type": "value"}
|
|
||||||
],
|
|
||||||
"thresholds": {"mode": "absolute", "steps": [{"color": "#6c757d"}]}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 7},
|
|
||||||
"id": 201,
|
|
||||||
"options": {
|
|
||||||
"legend": {"displayMode": "list", "placement": "bottom", "showLegend": true},
|
|
||||||
"mergeValues": true,
|
|
||||||
"rowHeight": 0.9,
|
|
||||||
"showValue": "auto",
|
|
||||||
"tooltip": {"mode": "single"}
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"alarm_control_panel\")\n |> filter(fn: (r) => r._field == \"state\")\n |> filter(fn: (r) => not (r.entity_id =~ /_[0-9]+$/))\n |> keep(columns: [\"_time\", \"_value\", \"entity_id\"])\n |> group(columns: [\"entity_id\"])",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Area arming state",
|
|
||||||
"type": "state-timeline"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"description": "Push events the panel sent in the selected window. Columns: time, typed event_type, object index (zone / unit / area / user), and new_state for state-changed events.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "thresholds"},
|
|
||||||
"custom": {
|
|
||||||
"align": "auto",
|
|
||||||
"cellOptions": {"type": "auto"},
|
|
||||||
"inspect": false
|
|
||||||
},
|
|
||||||
"thresholds": {"mode": "absolute", "steps": [{"color": "transparent"}]}
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"matcher": {"id": "byName", "options": "event_type"},
|
|
||||||
"properties": [
|
|
||||||
{"id": "custom.cellOptions", "value": {"type": "color-background", "mode": "basic"}},
|
|
||||||
{"id": "mappings", "value": [
|
|
||||||
{"options": {"alarm_activated": {"color": "#d62828", "text": "alarm_activated"}}, "type": "value"},
|
|
||||||
{"options": {"alarm_cleared": {"color": "#43aa8b", "text": "alarm_cleared"}}, "type": "value"},
|
|
||||||
{"options": {"ac_lost": {"color": "#d62828", "text": "ac_lost"}}, "type": "value"},
|
|
||||||
{"options": {"ac_restored": {"color": "#43aa8b", "text": "ac_restored"}}, "type": "value"},
|
|
||||||
{"options": {"battery_low": {"color": "#f8961e", "text": "battery_low"}}, "type": "value"},
|
|
||||||
{"options": {"battery_restored": {"color": "#43aa8b", "text": "battery_restored"}}, "type": "value"},
|
|
||||||
{"options": {"zone_state_changed": {"color": "#577590", "text": "zone_state_changed"}}, "type": "value"},
|
|
||||||
{"options": {"unit_state_changed": {"color": "#90be6d", "text": "unit_state_changed"}}, "type": "value"},
|
|
||||||
{"options": {"arming_changed": {"color": "#f9c74f", "text": "arming_changed"}}, "type": "value"},
|
|
||||||
{"options": {"user_macro_button": {"color": "#277da1", "text": "user_macro_button"}}, "type": "value"},
|
|
||||||
{"options": {"phone_line_dead": {"color": "#f8961e", "text": "phone_line_dead"}}, "type": "value"},
|
|
||||||
{"options": {"phone_line_restored": {"color": "#43aa8b", "text": "phone_line_restored"}}, "type": "value"}
|
|
||||||
]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": {"id": "byName", "options": "_time"},
|
|
||||||
"properties": [
|
|
||||||
{"id": "custom.width", "value": 175},
|
|
||||||
{"id": "displayName", "value": "time"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 7},
|
|
||||||
"id": 202,
|
|
||||||
"options": {
|
|
||||||
"cellHeight": "sm",
|
|
||||||
"footer": {"countRows": false, "fields": "", "reducer": ["sum"], "show": false},
|
|
||||||
"showHeader": true,
|
|
||||||
"sortBy": [{"desc": true, "displayName": "time"}]
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"event\")\n |> filter(fn: (r) => r._field == \"new_state\" or r._field == \"unit_index\" or r._field == \"zone_index\" or r._field == \"area_index\" or r._field == \"user_index\" or r._field == \"alarm_type\" or r._field == \"button_index\")\n |> pivot(rowKey: [\"_time\", \"event_type\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> drop(columns: [\"_start\", \"_stop\", \"_measurement\", \"domain\", \"entity_id\", \"event_class\"])\n |> group()\n |> sort(columns: [\"_time\"], desc: true)\n |> limit(n: 50)",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Recent panel events",
|
|
||||||
"type": "table"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"description": "Zone open/closed timeline. Painted segments = zone is_on (open / tripped).",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "thresholds"},
|
|
||||||
"custom": {"fillOpacity": 70, "lineWidth": 0},
|
|
||||||
"mappings": [
|
|
||||||
{"options": {"0": {"color": "green", "index": 0, "text": "secure"}}, "type": "value"},
|
|
||||||
{"options": {"1": {"color": "orange", "index": 1, "text": "open"}}, "type": "value"}
|
|
||||||
],
|
|
||||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green"}, {"color": "orange", "value": 1}]}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 15},
|
|
||||||
"id": 203,
|
|
||||||
"options": {
|
|
||||||
"legend": {"displayMode": "list", "placement": "bottom", "showLegend": false},
|
|
||||||
"mergeValues": true,
|
|
||||||
"rowHeight": 0.9,
|
|
||||||
"showValue": "never",
|
|
||||||
"tooltip": {"mode": "single"}
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"binary_sensor\")\n |> filter(fn: (r) => not (r.entity_id =~ /ac_power|battery|trouble|bypass|_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> keep(columns: [\"_time\", \"_value\", \"entity_id\"])\n |> group(columns: [\"entity_id\"])",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Zone trip timeline",
|
|
||||||
"type": "state-timeline"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collapsed": false,
|
|
||||||
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 23},
|
|
||||||
"id": 300,
|
|
||||||
"panels": [],
|
|
||||||
"title": "Climate",
|
|
||||||
"type": "row"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"description": "Current temperature per thermostat. Mock fixture values are raw panel format; a real panel reports °F.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "fixed", "fixedColor": "#f1faee"},
|
|
||||||
"custom": {
|
|
||||||
"axisPlacement": "auto",
|
|
||||||
"drawStyle": "line",
|
|
||||||
"fillOpacity": 8,
|
|
||||||
"gradientMode": "opacity",
|
|
||||||
"lineInterpolation": "stepBefore",
|
|
||||||
"lineWidth": 2,
|
|
||||||
"pointSize": 4,
|
|
||||||
"showPoints": "auto",
|
|
||||||
"spanNulls": true
|
|
||||||
},
|
|
||||||
"unit": "celsius"
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"matcher": {"id": "byFrameRefID", "options": "A"},
|
|
||||||
"properties": [{"id": "color", "value": {"mode": "palette-classic-by-name"}}]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 9, "w": 16, "x": 0, "y": 24},
|
|
||||||
"id": 301,
|
|
||||||
"options": {
|
|
||||||
"legend": {"calcs": ["mean", "lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true},
|
|
||||||
"tooltip": {"mode": "multi", "sort": "desc"}
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"climate\")\n |> filter(fn: (r) => not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"current_temperature\")\n |> keep(columns: [\"_time\", \"_value\", \"entity_id\"])\n |> group(columns: [\"entity_id\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Thermostat temperatures",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"description": "HVAC system mode per thermostat over the selected window. Off = grey, Heat = orange, Cool = blue, Auto = green, Dry = teal, Fan only = yellow.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "thresholds"},
|
|
||||||
"custom": {"fillOpacity": 80, "lineWidth": 0},
|
|
||||||
"mappings": [
|
|
||||||
{"options": {"off": {"color": "#adb5bd", "text": "off"}}, "type": "value"},
|
|
||||||
{"options": {"heat": {"color": "#f3722c", "text": "heat"}}, "type": "value"},
|
|
||||||
{"options": {"cool": {"color": "#277da1", "text": "cool"}}, "type": "value"},
|
|
||||||
{"options": {"heat_cool":{"color": "#43aa8b", "text": "auto"}}, "type": "value"},
|
|
||||||
{"options": {"auto": {"color": "#43aa8b", "text": "auto"}}, "type": "value"},
|
|
||||||
{"options": {"dry": {"color": "#577590", "text": "dry"}}, "type": "value"},
|
|
||||||
{"options": {"fan_only": {"color": "#f9c74f", "text": "fan only"}}, "type": "value"}
|
|
||||||
],
|
|
||||||
"thresholds": {"mode": "absolute", "steps": [{"color": "#adb5bd"}]}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 9, "w": 8, "x": 16, "y": 24},
|
|
||||||
"id": 302,
|
|
||||||
"options": {
|
|
||||||
"legend": {"displayMode": "list", "placement": "bottom", "showLegend": true},
|
|
||||||
"mergeValues": true,
|
|
||||||
"rowHeight": 0.9,
|
|
||||||
"showValue": "auto",
|
|
||||||
"tooltip": {"mode": "single"}
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"climate\")\n |> filter(fn: (r) => r._field == \"state\")\n |> filter(fn: (r) => not (r.entity_id =~ /_[0-9]+$/))\n |> keep(columns: [\"_time\", \"_value\", \"entity_id\"])\n |> group(columns: [\"entity_id\"])",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "HVAC mode",
|
|
||||||
"type": "state-timeline"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collapsed": false,
|
|
||||||
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 33},
|
|
||||||
"id": 400,
|
|
||||||
"panels": [],
|
|
||||||
"title": "Activity",
|
|
||||||
"type": "row"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"description": "Panel event rate, bucketed by event_type. Tracks zone state changes, button presses, alarm activation, AC/battery events, etc. Each event_type has its own color matching the events table.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "palette-classic"},
|
|
||||||
"custom": {
|
|
||||||
"drawStyle": "bars",
|
|
||||||
"fillOpacity": 80,
|
|
||||||
"lineWidth": 0,
|
|
||||||
"showPoints": "never",
|
|
||||||
"stacking": {"mode": "normal"}
|
|
||||||
},
|
|
||||||
"unit": "short"
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{"matcher": {"id": "byName", "options": "alarm_activated"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#d62828"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "alarm_cleared"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "ac_lost"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#d62828"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "ac_restored"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "battery_low"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f8961e"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "battery_restored"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "zone_state_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#577590"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "unit_state_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#90be6d"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "arming_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f9c74f"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "user_macro_button"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#277da1"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "phone_line_dead"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f8961e"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "phone_line_restored"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 34},
|
|
||||||
"id": 401,
|
|
||||||
"options": {
|
|
||||||
"legend": {"displayMode": "table", "placement": "right", "showLegend": true, "calcs": ["sum"]},
|
|
||||||
"tooltip": {"mode": "multi", "sort": "desc"}
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"event\")\n |> filter(fn: (r) => r._field == \"state\")\n |> group(columns: [\"event_type\"])\n |> aggregateWindow(every: v.windowPeriod, fn: count, createEmpty: true)",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Event rate by type",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"description": "Top 15 most-toggled units in the selected window — bar length = number of state changes. Reveals which lights/relays get used most.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "thresholds"},
|
|
||||||
"mappings": [],
|
|
||||||
"thresholds": {
|
|
||||||
"mode": "absolute",
|
|
||||||
"steps": [
|
|
||||||
{"color": "#f9c74f", "value": null},
|
|
||||||
{"color": "#f8961e", "value": 5},
|
|
||||||
{"color": "#f3722c", "value": 15},
|
|
||||||
{"color": "#d62828", "value": 30}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"min": 0,
|
|
||||||
"unit": "short"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 10, "w": 12, "x": 12, "y": 34},
|
|
||||||
"id": 402,
|
|
||||||
"options": {
|
|
||||||
"displayMode": "gradient",
|
|
||||||
"valueMode": "color",
|
|
||||||
"showUnfilled": true,
|
|
||||||
"orientation": "horizontal",
|
|
||||||
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "/^_value$/", "values": true},
|
|
||||||
"minVizHeight": 10,
|
|
||||||
"minVizWidth": 0,
|
|
||||||
"namePlacement": "left"
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"light\")\n |> filter(fn: (r) => not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> toFloat()\n |> keep(columns: [\"_value\", \"entity_id\"])\n |> group(columns: [\"entity_id\"])\n |> count()\n |> group()\n |> sort(columns: [\"_value\"], desc: true)\n |> limit(n: 15)",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Top toggled units (24h)",
|
|
||||||
"type": "bargauge"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collapsed": false,
|
|
||||||
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 44},
|
|
||||||
"id": 500,
|
|
||||||
"panels": [],
|
|
||||||
"title": "Insights",
|
|
||||||
"type": "row"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"description": "Zones currently bypassed. Bypass = the panel ignores this zone for arming/alarm purposes. Empty when nothing is bypassed; rows accrue when a switch is flipped.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "thresholds"},
|
|
||||||
"custom": {
|
|
||||||
"align": "auto",
|
|
||||||
"cellOptions": {"type": "auto"},
|
|
||||||
"inspect": false
|
|
||||||
},
|
|
||||||
"thresholds": {"mode": "absolute", "steps": [{"color": "transparent"}]}
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"matcher": {"id": "byName", "options": "entity_id"},
|
|
||||||
"properties": [
|
|
||||||
{"id": "displayName", "value": "zone bypass switch"},
|
|
||||||
{"id": "custom.cellOptions", "value": {"type": "color-text", "wrapText": false}},
|
|
||||||
{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f9c74f"}}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": {"id": "byName", "options": "_time"},
|
|
||||||
"properties": [
|
|
||||||
{"id": "displayName", "value": "since"},
|
|
||||||
{"id": "custom.width", "value": 175}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": {"id": "byName", "options": "_value"},
|
|
||||||
"properties": [{"id": "custom.hidden", "value": true}]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 8, "w": 8, "x": 0, "y": 45},
|
|
||||||
"id": 501,
|
|
||||||
"options": {
|
|
||||||
"cellHeight": "sm",
|
|
||||||
"footer": {"countRows": true, "fields": "", "reducer": ["sum"], "show": true},
|
|
||||||
"showHeader": true,
|
|
||||||
"sortBy": [{"desc": true, "displayName": "since"}]
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: -24h)\n |> filter(fn: (r) => r.domain == \"switch\")\n |> filter(fn: (r) => not (r.entity_id =~ /_[0-9]+$/))\n |> filter(fn: (r) => r._field == \"value\")\n |> last()\n |> filter(fn: (r) => r._value > 0.0)\n |> keep(columns: [\"_time\", \"_value\", \"entity_id\"])\n |> group()\n |> sort(columns: [\"_time\"], desc: true)",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Active zone bypasses",
|
|
||||||
"type": "table"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"description": "User-macro button press events from the panel. Each row = one press; button_index identifies which scene/macro fired.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "thresholds"},
|
|
||||||
"custom": {
|
|
||||||
"align": "auto",
|
|
||||||
"cellOptions": {"type": "auto"},
|
|
||||||
"inspect": false
|
|
||||||
},
|
|
||||||
"thresholds": {"mode": "absolute", "steps": [{"color": "transparent"}]}
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"matcher": {"id": "byName", "options": "button_index"},
|
|
||||||
"properties": [
|
|
||||||
{"id": "displayName", "value": "button #"},
|
|
||||||
{"id": "custom.cellOptions", "value": {"type": "color-background", "mode": "basic"}},
|
|
||||||
{"id": "color", "value": {"mode": "fixed", "fixedColor": "#277da1"}}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": {"id": "byName", "options": "_time"},
|
|
||||||
"properties": [
|
|
||||||
{"id": "displayName", "value": "time"},
|
|
||||||
{"id": "custom.width", "value": 175}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 8, "w": 8, "x": 8, "y": 45},
|
|
||||||
"id": 502,
|
|
||||||
"options": {
|
|
||||||
"cellHeight": "sm",
|
|
||||||
"footer": {"countRows": true, "fields": "", "reducer": ["sum"], "show": true},
|
|
||||||
"showHeader": true,
|
|
||||||
"sortBy": [{"desc": true, "displayName": "time"}]
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"event\" and r.event_type == \"user_macro_button\")\n |> filter(fn: (r) => r._field == \"button_index\")\n |> keep(columns: [\"_time\", \"_value\"])\n |> group()\n |> sort(columns: [\"_time\"], desc: true)\n |> limit(n: 25)\n |> rename(columns: {_value: \"button_index\"})",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Button press log",
|
|
||||||
"type": "table"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"description": "Distribution of panel push events by typed kind across the selected window. Matches the colors used in the event rate and events table panels.",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {"mode": "palette-classic"},
|
|
||||||
"custom": {
|
|
||||||
"hideFrom": {"legend": false, "tooltip": false, "viz": false}
|
|
||||||
},
|
|
||||||
"mappings": []
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{"matcher": {"id": "byName", "options": "alarm_activated"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#d62828"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "alarm_cleared"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "ac_lost"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#d62828"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "ac_restored"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "battery_low"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f8961e"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "battery_restored"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#43aa8b"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "zone_state_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#577590"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "unit_state_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#90be6d"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "arming_changed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#f9c74f"}}]},
|
|
||||||
{"matcher": {"id": "byName", "options": "user_macro_button"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "#277da1"}}]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gridPos": {"h": 8, "w": 8, "x": 16, "y": 45},
|
|
||||||
"id": 503,
|
|
||||||
"options": {
|
|
||||||
"displayLabels": ["percent", "name"],
|
|
||||||
"legend": {"displayMode": "table", "placement": "right", "showLegend": true, "values": ["value"]},
|
|
||||||
"pieType": "donut",
|
|
||||||
"reduceOptions": {"calcs": ["sum"], "fields": "", "values": false},
|
|
||||||
"tooltip": {"mode": "single", "sort": "none"}
|
|
||||||
},
|
|
||||||
"pluginVersion": "11.4.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"query": "from(bucket: \"ha\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r.domain == \"event\" and r._field == \"state\")\n |> keep(columns: [\"_time\", \"_value\", \"event_type\"])\n |> group(columns: [\"event_type\"])\n |> count(column: \"_value\")\n |> map(fn: (r) => ({_time: now(), _value: r._value, event_type: r.event_type}))\n |> pivot(rowKey: [\"_time\"], columnKey: [\"event_type\"], valueColumn: \"_value\")",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Event distribution",
|
|
||||||
"type": "piechart"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"refresh": "30s",
|
|
||||||
"schemaVersion": 39,
|
|
||||||
"tags": ["omni-pca", "hai", "omni-pro-ii", "home-assistant"],
|
|
||||||
"templating": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"current": {"selected": true, "text": "All", "value": "$__all"},
|
|
||||||
"datasource": {"type": "influxdb", "uid": "InfluxDB"},
|
|
||||||
"definition": "from(bucket: \"ha\") |> range(start: -7d) |> filter(fn: (r) => r.domain == \"event\" and r._field == \"state\") |> keep(columns: [\"event_type\"]) |> group() |> distinct(column: \"event_type\")",
|
|
||||||
"hide": 0,
|
|
||||||
"includeAll": true,
|
|
||||||
"label": "Event type",
|
|
||||||
"multi": true,
|
|
||||||
"name": "event_type",
|
|
||||||
"options": [],
|
|
||||||
"query": "from(bucket: \"ha\") |> range(start: -7d) |> filter(fn: (r) => r.domain == \"event\" and r._field == \"state\") |> keep(columns: [\"event_type\"]) |> group() |> distinct(column: \"event_type\")",
|
|
||||||
"refresh": 2,
|
|
||||||
"regex": "",
|
|
||||||
"skipUrlSync": false,
|
|
||||||
"sort": 1,
|
|
||||||
"type": "query"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"time": {"from": "now-24h", "to": "now"},
|
|
||||||
"timepicker": {
|
|
||||||
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h"],
|
|
||||||
"time_options": ["1h", "6h", "24h", "2d", "7d", "30d"]
|
|
||||||
},
|
|
||||||
"timezone": "browser",
|
|
||||||
"title": "Omni Pro II — Panel Overview",
|
|
||||||
"uid": "omni-pro-ii-overview",
|
|
||||||
"version": 1,
|
|
||||||
"weekStart": ""
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
# Auto-wires the InfluxDB v2 datasource at Grafana boot. Picks up
|
|
||||||
# INFLUX_URL and INFLUX_TOKEN from the grafana container's environment
|
|
||||||
# (set in docker-compose.yml from .env). No manual datasource config
|
|
||||||
# needed.
|
|
||||||
|
|
||||||
apiVersion: 1
|
|
||||||
|
|
||||||
datasources:
|
|
||||||
- name: InfluxDB
|
|
||||||
type: influxdb
|
|
||||||
access: proxy
|
|
||||||
url: ${INFLUX_URL}
|
|
||||||
isDefault: true
|
|
||||||
editable: false
|
|
||||||
jsonData:
|
|
||||||
version: Flux
|
|
||||||
organization: omni-pca
|
|
||||||
defaultBucket: ha
|
|
||||||
tlsSkipVerify: true
|
|
||||||
secureJsonData:
|
|
||||||
token: ${INFLUX_TOKEN}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[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" }
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
@ -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" },
|
||||||
|
|||||||