Compare commits

...

10 Commits

Author SHA1 Message Date
4ba8c2043e grafana: dashboard bundle + dev-stack integration
Some checks failed
Validate / HACS validation (push) Has been cancelled
Validate / Hassfest (push) Has been cancelled
Adds a self-contained omni-pca/grafana/ bundle (InfluxDB v2 + Grafana
with pre-provisioned datasource and dashboard) plus dev-stack wiring
so iterating against the mock or real panel is one docker-compose-up.

The dashboard has four rows plus an insights row:

  System health   AC, battery, trouble, 24h event count
  Security        area arming state, recent events table, zone trips
  Climate         thermostat temperatures, HVAC mode
  Activity        event rate by type, top toggled units
  Insights        active zone bypasses, button press log, event distribution

Color-coded event_type tags persist across panels (alarms red, restores
green, batteries orange, etc.); explicit no-purple palette per CLAUDE.md.

The bundle is portable: any HA install can use it by running grafana/
docker compose up -d and pasting ha-snippet.yaml into configuration.yaml.
For the dev stack, dev/docker-compose.yml mounts the same provisioning
files so dev and prod stay in lockstep.

Verified end-to-end against the real Our House.pca panel (192.168.1.9):
the dashboard fills with live zone trips, X-10 unit toggles, and
push-event traffic within 30s of HA bootup.
2026-05-17 23:43:01 -06:00
9726ee36bb dev: load real .pca fixtures into the mock-panel
Some checks failed
Validate / Hassfest (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
Adds an OMNI_PCA_FIXTURE escape hatch so the mock can serve real
panel data instead of the synthetic five-zone state. With this in
place the dev stack is wire-indistinguishable from the source
panel for everything the HA integration touches: 330 programs,
16 zones, 44 units, 2 thermostats etc. from our test fixture.

- run_mock_panel.py: --pca / OMNI_PCA_FIXTURE accepts a path; the
  decryption key is auto-derived from a sibling PCA01.CFG when one
  exists (the common PC Access export layout), with --pca-key /
  OMNI_PCA_FIXTURE_KEY as override. Falls back to KEY_EXPORT for
  vanilla unsigned exports.
- docker-compose.yml: mount /home/kdm/home-auto/HAI as /fixtures
  read-only and surface OMNI_PCA_FIXTURE so dev/.env can drive it.
- dev/README.md: new section documenting fixture loading.
- dev/screenshot_overview.py: quick playwright helper for capturing
  the side-panel landing page with whatever fixture is loaded.
- dev/artifacts/screenshots/2026-05-17/real-pca-overview.png: snapshot
  of the Omni Programs side panel against the real .pca fixture
  (330 programs).
2026-05-17 13:06:19 -06:00
8a0fb1e4fe panel: Arg2-as-object editor for structured-AND records
Lets structured-AND IF rows compare a typed field against another
typed field, not just a constant. Authoring "Thermostat 1.Temp >
Thermostat 2.Temp" now works in-place; previously Arg2 was locked
to Constant in the editor.

- types.ts: relax isEditableStructuredAnd to permit Zone/Unit/
  Thermostat/Area/TimeDate as Arg2 types (the same editable set
  already accepted for Arg1).
- omni-panel-programs.ts: replace the lone constant input with
  Arg2 type/object/field controls that mirror the Arg1 layout;
  switching Arg2 between Constant and a reference type swaps the
  sub-controls and resets defaults sensibly.
- _renderStructuredArg1Picker generalised to _renderStructuredObjectPicker
  driving both sides; _defaultIxForKind extracted as a shared helper.
- Bundle rebuilt.
- dev/screenshot_arg2_object.py: targeted playwright helper that
  opens the chain at slot 200 and screenshots the editor for
  visual verification.
2026-05-17 13:06:07 -06:00
486258a034 panel: structured-OP AND record editor (TEMP > 70 etc.)
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Replaces the read-only "structured comparison" banner with a real
editor. Structured AND records encode ``Arg1 OP Arg2`` where Arg1 is
a typed reference (Zone / Unit / Thermostat / Area / TimeDate) plus a
per-type field selector, and Arg2 is either another typed reference
or a literal constant.

I1 — TS types + decoders:

Wire layout (programs.py decoders, clsProgram.cs):
  cond  high byte  = and_op           (CondOP: 1=EQ, 2=NE, 3=LT,
                                       4=GT, 5=ODD, 6=EVEN, 7=MULT,
                                       8=IN, 9=NOT_IN)
  cond  low byte   = and_arg1_argtype (CondArgType)
  cond2 (whole)    = and_arg1_ix      (object idx; 0 for TimeDate)
  cmd              = and_arg1_field   (per-type field selector)
  par              = and_arg2_argtype (Constant most common)
  pr2              = and_arg2_ix      (constant value or 2nd obj idx)
  month            = and_arg2_field
  day,days         = and_compconst    (BE u16; usually 0)

decodeStructuredAnd / encodeStructuredAnd handle both directions;
round-trip exact.

Per-Arg1Type field menus in FIELDS_BY_TYPE — exact 1:1 with the
Python enuZoneField / enuUnitField / enuThermostatField /
enuTimeDateField enums in omni_pca.programs and the field handling
in StateEvaluator. Areas only expose "Security mode" (single useful
field). TimeDate exposes Year / Month / Day / DoW / Time / Hour /
Minute (skips the rarely-used Date / DST / SunriseSunset fields).

I2 — editor UI:

isEditableStructuredAnd guard: only opens the editor for records
matching the editor's scope (Arg1 in supported types, Arg2=Constant,
compConst=0). Out-of-scope structured records render with a
"read-only" tag — preserved on save, still removable.

Structured rows render with a "structured" tag and an orange-tinted
background to distinguish them from Traditional rows. Layout:

  Arg1 type ▸ object picker ▸ Field ▸ Operator ▸ Compare against

Unary operators (ODD / EVEN) hide the Arg2 input. Changing Arg1 type
resets the Arg1 index + field to defaults so the form stays self-
consistent (no stale picker values from a previous type).

Arg2 is locked to Constant in this pass. Editing record-vs-record
comparisons (e.g. "Thermostat 1 temp > Thermostat 2 temp") is a
future cut — current real-world programs use the Constant form
exclusively per my homeowner-panel sample.

_pickBucket gains the missing "thermostat" branch (was missed in
earlier passes; only mattered now that thermostat is an Arg1Type).

Live screenshot 12-structured-and.png shows an injected chain with
both a Traditional AND (CTRL UNIT 1 ON) and a Structured AND
(Thermostat(1).Temperature > 70) — both editable end-to-end.

Frontend bundle: 88 KB minified (up from 82 KB).
Full suite: 653 passed, 1 skipped (no test changes).
2026-05-17 02:24:59 -06:00
9ca4da98e8 panel: clausal chain editor (WHEN/AT/EVERY + AND/OR/THEN)
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Multi-record clausal programs are now editable end-to-end. A chain
spans N consecutive slots — head (WHEN/AT/EVERY) + zero-or-more
AND/OR condition records + one-or-more THEN action records — so
"editing" means rewriting the whole run, validating that any
expansion doesn't trample adjacent programs, and clearing any old
slots when the chain shrinks.

H1 — backend:

* programs/get for chains now returns chain_members[] with each
  member's slot + role + raw fields. The editor uses this to seed
  one editable form-row per slot.
* New programs/chain/write command: takes head_slot + head dict +
  conditions[] + actions[], does N sequential download_program
  calls, then clears any old chain slots that fell outside the new
  range. Validates:
    - head_slot + new_len doesn't extend past slot 1500
    - any expansion-into slot not already part of THIS chain is FREE
      (anti-trample: refuse rather than overwrite an adjacent program)
    - at least one THEN action present (empty chain rejected)
  Updates coordinator.data.programs immediately so subsequent list
  calls reflect the edit before the next poll.

H2 — TS helpers:

* AND-record encoding mirrors compact-form cond family bytes
  (0x04 ZONE / 0x08 CTRL / 0x0C TIME / 0x00 OTHER + 0x10+ SEC) but
  with a slightly different bit layout: the family byte lives at
  fields.cond & 0xFF (disk byte 1) and the instance at
  (fields.cond2 >> 8) & 0xFF (disk byte 3). The selector bit is
  family's bit 0x02 instead of cond's 0x0200. decodeAndCondition /
  encodeAndCondition handle both directions; round-trip exact.
* isStructuredAnd helper detects records with OP > 0 (TEMP > N
  comparisons etc.); those render read-only in the chain editor
  with a warning banner.
* emptyAndRecord / emptyOrRecord / emptyThenRecord helpers for
  the add-condition / add-action buttons.

H3 — chain editor UI:

* New _chainDraft state (parallel to _editingDraft for compact form)
  with head + conditions[] + actions[] arrays. Mutation helpers
  preserve immutability via array-copy-then-patch.
* "Edit" button on chain detail now opens the chain editor instead
  of returning early (previous read-only behaviour).
* Three sub-renderers: trigger section dispatches on prog_type
  (WHEN→event-id builder reusing the EVENT helpers, AT→time+days
  reusing TIMED layout, EVERY→single seconds input that packs into
  cond+cond2), conditions section with per-row add/remove (separate
  + AND IF and + OR IF buttons in the legend), actions section with
  per-row add/remove (+ THEN button; at least one action enforced).
* Structured-OP AND records render with an explanatory read-only
  banner and a × button to drop the row entirely — preserves the
  data when the user doesn't touch it, lets them remove it cleanly
  when they want to.
* Each row picks objects via _bucketWithPreserve so out-of-range
  zone/unit/area indices stay safe.

5 new HA integration tests:
* get-chain returns chain_members with correct roles + raw fields
* chain/write in-place rewrite preserves footprint, updates bytes
* chain/write shrink clears the trailing old slots
* chain/write refuses to trample an adjacent program on expansion
* chain/write rejects zero-actions submission

Live screenshot 11-chain-editor.png: state injection into the side
panel (real panel has no chains) shows the editor rendering a sample
WHEN zone-state → AND IF unit ON → 2x THEN action chain with every
control populated and functional.

Full suite: 653 passed, 1 skipped (up from 648, 5 new chain tests).
Frontend bundle: 82 KB minified (up from 63 KB).
2026-05-17 02:09:04 -06:00
5870e2f7ee panel: inline AND-IF condition editor for compact-form programs
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Replaces the read-only "conditions present but not editable" banner
with a real editor for the cond / cond2 u16 fields on TIMED / EVENT /
YEARLY programs.

Compact-form conditions split into five families per
clsText.GetConditionalText (clsText.cs:2224-2274):

  none     — cond = 0 (no inline condition)
  misc     — family 0x00, low nibble = enuMiscConditional
             (NONE / NEVER / LIGHT / DARK / PHONE_* / AC_POWER_* /
             BATTERY_* / ENERGY_COST_*)
  zone     — family 0x04, low byte = zone, bit 0x0200 = NOT_READY
  unit     — family 0x08, low 9 bits = unit, bit 0x0200 = ON
  time     — family 0x0C, low byte = time-clock #, bit 0x0200 = enabled
  sec      — family >= 0x10, bits 8-11 = area, bits 12-14 = security mode

types.ts gains decodeCondition / encodeCondition + the
MISC_CONDITIONALS / SECURITY_MODE_NAMES enums. Round-trip is exact:
decode(encode(c)) === c for every supported family.

UI: two condition slots per editor (matching the two u16 fields on
the wire). Each slot has a family-picker dropdown that swaps the
sub-fields (zone picker + secure/not-ready, unit picker + on/off,
area picker + security mode, time-clock # + enabled/disabled, misc
condition picker, or "none"). Picking a family seeds sensible defaults
(NEVER for misc, first zone secure, first unit ON, area 1 disarmed,
time clock 1 enabled).

Object pickers reuse the same _bucketWithPreserve helper introduced
for the action editor, so out-of-range zone/unit/area refs in inline
conditions keep their original value with a "preserve" label.

Live smoke test against the real panel: slot #1's actual condition
"AND IF Time clock 4 is disabled" now decodes into the editor as
Family=Time clock / # = 4 / Is = disabled — exactly the on-disk state.

Frontend bundle: 63 KB minified (up from 56 KB with the new editor
section + cond helpers).
2026-05-17 01:43:32 -06:00
6f92671cf2 panel: preserve out-of-range object refs in editor dropdowns
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Real-world programs reference object indexes well past the coordinator's
discovery range — typical example from a live OmniPro II: a program
that's "Turn ON Unit 33025" where the unit number is a raw byte value
from undecoded extended-output addressing. The discovered units bucket
only covers slots up to ~511, so 33025 doesn't match any entry.

Before this commit the dropdown silently fell through to the first
known unit (e.g. ROOM ONE), making it look like the user had selected
that unit. The underlying draft.pr2 stayed at 33025, but a user who
glanced at the form and clicked Save would either preserve the original
(if they didn't touch the select) or accidentally clobber it with the
first list item (if they did).

Fix: _bucketWithPreserve prepends a synthesized option
"(undiscovered <kind> <idx> — preserve original)" when the current
value isn't represented. Applies in all four picker sites:

  * Action object picker (Unit / Zone / Area / Button for action commands)
  * EVENT trigger Button picker
  * EVENT trigger Zone picker
  * EVENT trigger Unit picker

The synthesized entry sits at the top of the list (visually distinct)
and is the selected default. Picking any other entry from the dropdown
then becomes an explicit choice — no more silent coercion.

Smoke-tested against the real panel: slot #1 (WHEN OPEN BIG GAR →
Turn ON Unit 33025) now shows "#33025 (undiscovered unit 33025 —
preserve original)" as the selected Unit option. Screenshots updated.
2026-05-16 19:55:16 -06:00
4781f4d276 panel: trigger initial loadList from discover, prefer loaded entries
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Two bugs surfaced when smoke-testing against a real OmniPro II:

1. Empty list after page load. _discoverViaList ran fire-and-forget;
   connectedCallback then synchronously checked _entryId (still null
   because await hadn't resolved) and skipped _loadList. The panel
   rendered "No programs match the current filters" forever — until
   the next 5-second poll tick, which never fires because
   _startRefreshTimer was also gated on the same null check.

   Fix: have _discoverViaList itself trigger _loadList and
   _startRefreshTimer after _entryId lands. The connectedCallback /
   updated paths can stay gated on _entryId; the discover path now
   takes ownership of "do the initial load too."

2. Dev installs with both a working entry and a setup_retry entry
   (mock container down, real panel up) had the panel pick the
   setup_retry one first and surface "panel not configured" on every
   call. Fix: prefer entries with state === "loaded" in the discover
   step, falling back to first entry only when none are loaded.

Also: screenshot.py drops the seed-via-WS step (was unsafe — would
write Programs to whatever entry is loaded, including real panels).
Updates the in-page click helpers to walk the shadow DOM recursively
instead of hardcoding HA's host-element path, so detail/editor
screenshots work on the actual depth-8 element location.

Smoke test against real panel: 154 programs render correctly with
structured English, BEDTIME / OPEN BIG GAR / Zone 133 events all
decoded, B. GAR MAN DOOR [SECURE] live-state badge visible.
Detail panel + editor mode both function end-to-end.
2026-05-16 17:48:17 -06:00
14d16a5a4c program editor: real edit-existing seed + EVENT/YEARLY editors
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Three pieces close out the editor's main gaps:

F1 — backend includes raw fields in programs/get response:

  _program_to_fields() serialises a Program record into the same
  field dict the editor form consumes. Round-trips through
  programs/write are now lossless (fetch → edit → write produces
  byte-identical wire output if no fields changed). The old TODO
  in _fetchProgramFields was about exactly this — the frontend
  was seeding from sensible defaults rather than real values
  because the wire didn't carry raw fields. Now it does.

  Verified by a new round-trip test: read slot 42, write the same
  fields back, assert the encoded wire bytes are identical.

F2 — EVENT program editor:

  EVENT records pack a 16-bit event_id into (month<<8 | day).
  Editing requires decoding that ID into one of four categories:

    * "button"  — USER_MACRO_BUTTON, low byte = button index
    * "zone"    — ZONE_STATE_CHANGE, packed zone + state-change kind
    * "unit"    — UNIT_STATE_CHANGE, packed unit + on/off
    * "fixed"   — hand-rolled IDs (phone events, AC power) from
                  EVENT_AC_POWER_OFF / EVENT_PHONE_RINGING / etc.

  TS helpers decodeEventId / encodeEventId / packEventIdIntoFields
  mirror the Python helpers in program_engine.py.

  UI: category dropdown switches the sub-fields (button picker,
  zone+state pair, unit+on/off, fixed-event picker). Each change
  re-encodes back to month/day. Existing programs with unrecognised
  IDs fall into a "raw" category that shows the literal hex —
  user can switch category to redefine.

F3 — YEARLY program editor:

  YEARLY records have month + day + hour + minute, no days-bitmask.
  The editor now switches on prog_type to pick the right trigger
  section: month dropdown (named months), day number input,
  hour/minute number inputs.

Editor render path refactored: _renderTriggerSection(draft)
dispatches to _renderTimedTrigger / _renderEventTrigger /
_renderYearlyTrigger by prog_type. _renderActionSection is
shared across all three (command picker + object picker + level%).
Action editing works identically regardless of trigger.

Edit button visibility extended from "TIMED only" to any
program_type in EDITABLE_PROG_TYPES (TIMED / EVENT / YEARLY).
REMARK and clausal chains remain read-only.

Full suite: 648 passed, 1 skipped (up from 647, F1 round-trip test).
Frontend bundle: 56 KB minified (up from 47 KB with EVENT + YEARLY
forms and event-id helpers).
2026-05-16 12:20:21 -06:00
e6308c5624 program editor — Cut 2: TIMED program edit UI
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Three new pieces compose into an inline edit mode for the side panel:

E1 — omni_pca/programs/write websocket command:
  Accepts a Program dict (mirrors the dataclass field by field) plus
  a slot. Validates with a voluptuous schema (range checks on each
  byte field, prog_type 0..10), constructs the typed Program, calls
  client.download_program over the wire. Updates coordinator.data
  .programs on success so the next list call reflects the edit
  before the next poll catches up. Returns {slot, written: true} on
  success; structured errors on validation / not_supported / write_failed.

E2 — omni_pca/objects/list:
  Returns sorted {index, name} entries for zones / units / areas /
  thermostats / buttons sourced from the coordinator's discovered
  topology. Frontend caches the response client-side; the topology
  doesn't change unless the user reloads the integration.

E3 — Frontend TIMED editor:
  Detail panel grows an "Edit" button for TIMED+compact programs
  (other types stay read-only with no button). Click reveals an
  inline form with:
    * Time row — hour / minute number inputs
    * Days row — 7 toggle buttons (Mon..Sun) matching the bitmask
    * Action row — Command dropdown (friendly verbs from the
      COMMAND_OPTIONS table), object picker that auto-filters to
      the right kind for the selected command (zone / unit / area /
      button / none), and a Level % input for UNIT_LEVEL specifically
    * Read-only inline-conditions notice for programs that carry
      cond / cond2 (editing condition fields is a future cut)
  Save sends the draft via programs/write; Cancel discards.
  The poll timer pauses while editing so the form values don't
  flicker mid-edit.

Scope honesty: this pass edits TIMED programs only. Other types
(EVENT / YEARLY / WHEN / AT / EVERY / REMARK) remain read-only
with Fire / Clone / Clear available. Inline AND-IF condition editing
is deferred — the conditions render as a banner. Creating new programs
uses Clone (already shipped) → edit the clone.

The _fetchProgramFields function currently seeds from defaults (6:00
weekdays, UNIT_ON to first unit) rather than pulling raw fields from
the panel because the get-detail websocket response carries rendered
tokens but not raw bytes. That's a TODO marked inline; for the
clone-then-edit workflow the defaults are fine, but editing existing
programs in place will need a tiny backend addition.

4 new HA-integration tests covering write happy path, overwrite,
invalid payload validation, and objects/list returns named buckets.

Full suite: 647 passed, 1 skipped (up from 643, 4 new tests).
Frontend bundle: 47 KB minified (up from 38 KB with editor + form code).
2026-05-16 01:33:55 -06:00
35 changed files with 5694 additions and 72 deletions

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,16 @@ export interface ProgramDetail {
references: string[]; references: string[];
/** For chain detail: every slot the chain spans. */ /** For chain detail: every slot the chain spans. */
chain_slots?: number[]; 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 { export interface ProgramListRequest {
@ -71,6 +81,646 @@ export interface ProgramFireRequest {
slot: number; 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. */ /** HA's hass object — minimal surface we use. */
export interface Hass { export interface Hass {
connection: { connection: {

View File

@ -368,6 +368,21 @@ 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
@ -378,9 +393,309 @@ 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( @websocket_api.websocket_command(
{ {
vol.Required("type"): "omni_pca/programs/clear", vol.Required("type"): "omni_pca/programs/clear",
@ -557,6 +872,9 @@ def async_register_websocket_commands(hass: HomeAssistant) -> None:
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_clear_program)
websocket_api.async_register_command(hass, _ws_clone_program) websocket_api.async_register_command(hass, _ws_clone_program)
websocket_api.async_register_command(hass, _ws_write_program)
websocket_api.async_register_command(hass, _ws_chain_write)
websocket_api.async_register_command(hass, _ws_list_objects)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------

File diff suppressed because one or more lines are too long

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@ -35,8 +35,14 @@ 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
@ -96,6 +102,74 @@ services:
pip install --quiet --no-deps --upgrade /opt/omni-pca-src pip install --quiet --no-deps --upgrade /opt/omni-pca-src
exec /init exec /init
# InfluxDB v2 + Grafana stack — kept inline rather than `extends:`-ing
# ../grafana/docker-compose.yml so this file stays self-contained and
# the named volumes get scoped to this compose project. The bundle
# compose stays the canonical ship-to-users version; we share its
# provisioning files via the volume mount on the grafana service.
influxdb:
image: influxdb:2.7-alpine
container_name: omni-pca-dev-influxdb
restart: unless-stopped
environment:
DOCKER_INFLUXDB_INIT_MODE: setup
DOCKER_INFLUXDB_INIT_USERNAME: ${INFLUX_USERNAME:-admin}
DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUX_PASSWORD}
DOCKER_INFLUXDB_INIT_ORG: omni-pca
DOCKER_INFLUXDB_INIT_BUCKET: ha
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUX_TOKEN}
DOCKER_INFLUXDB_INIT_RETENTION: 30d
volumes:
- influxdb-data:/var/lib/influxdb2
- influxdb-config:/etc/influxdb2
ports:
- "8086:8086"
networks:
- default
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8086/health"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
grafana:
image: grafana/grafana:11.4.0
container_name: omni-pca-dev-grafana
restart: unless-stopped
depends_on:
influxdb:
condition: service_healthy
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
GF_AUTH_ANONYMOUS_ENABLED: "false"
GF_USERS_ALLOW_SIGN_UP: "false"
GF_LOG_LEVEL: warn
INFLUX_URL: http://influxdb:8086
INFLUX_TOKEN: ${INFLUX_TOKEN}
volumes:
- grafana-data:/var/lib/grafana
- ../grafana/provisioning:/etc/grafana/provisioning:ro
ports:
- "3000:3000"
networks:
- default
- caddy
labels:
caddy: grafana-omni.juliet.warehack.ing
caddy.reverse_proxy: "{{upstreams 3000}}"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/health || exit 1"]
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
volumes:
influxdb-data:
influxdb-config:
grafana-data:
networks: networks:
caddy: caddy:
external: true external: true

View File

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

View File

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

View File

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

View File

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

19
grafana/.env.example Normal file
View File

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

129
grafana/README.md Normal file
View File

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

View File

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

51
grafana/ha-snippet.yaml Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -45,6 +45,27 @@ 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,
),
} }
@ -93,17 +114,14 @@ 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"]
assert result["total"] == 3 # 3 compact-form programs (12, 42, 99) + 1 clausal chain (head at
assert result["filtered_total"] == 3 # slot 200, spanning 200..203). The chain renders as a single row.
assert result["total"] == 4
assert result["filtered_total"] == 4
rows_by_slot = {row["slot"]: row for row in result["programs"]} rows_by_slot = {row["slot"]: row for row in result["programs"]}
# Both TIMED programs and the EVENT program land in the response. assert rows_by_slot.keys() == {12, 42, 99, 200}
assert rows_by_slot.keys() == {12, 42, 99} assert rows_by_slot[200]["kind"] == "chain"
# Each row has the metadata the frontend needs. assert rows_by_slot[12]["kind"] == "compact"
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(
@ -135,8 +153,11 @@ 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"]
assert result["filtered_total"] == 1 # Slot 42 ("Turn ON KITCHEN_OVERHEAD" = unit 2) plus the seeded chain
assert result["programs"][0]["slot"] == 42 # at slot 200 (action: Turn ON unit 2) both reference unit:2.
assert result["filtered_total"] == 2
slots = {r["slot"] for r in result["programs"]}
assert slots == {42, 200}
async def test_ws_list_programs_search_substring( async def test_ws_list_programs_search_substring(
@ -151,9 +172,13 @@ async def test_ws_list_programs_search_substring(
}) })
response = await client.receive_json() response = await client.receive_json()
result = response["result"] result = response["result"]
# Only slot 42 ("Turn ON KITCHEN_OVERHEAD") mentions kitchen. # Slot 42 ("Turn ON KITCHEN_OVERHEAD" — truncated to 12 chars on
assert result["filtered_total"] == 1 # wire = "KITCHEN_OVER") matches. The chain at slot 200 also has
assert result["programs"][0]["slot"] == 42 # an action against unit 2 which renders with the same truncated
# name, so it matches too.
assert result["filtered_total"] == 2
slots = {r["slot"] for r in result["programs"]}
assert slots == {42, 200}
async def test_ws_list_programs_pagination( async def test_ws_list_programs_pagination(
@ -168,7 +193,8 @@ async def test_ws_list_programs_pagination(
}) })
response = await client.receive_json() response = await client.receive_json()
result = response["result"] result = response["result"]
assert result["filtered_total"] == 3 # 4 list rows total: 3 compact + 1 chain head.
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]
@ -198,6 +224,45 @@ 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:
@ -325,6 +390,265 @@ async def test_ws_clone_program_rejects_missing_source(
assert response["error"]["code"] == "not_found" 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:

2
uv.lock generated
View File

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