Compare commits
10 Commits
v2026.5.16
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ba8c2043e | |||
| 9726ee36bb | |||
| 8a0fb1e4fe | |||
| 486258a034 | |||
| 9ca4da98e8 | |||
| 5870e2f7ee | |||
| 6f92671cf2 | |||
| 4781f4d276 | |||
| 14d16a5a4c | |||
| e6308c5624 |
@ -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: {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
BIN
dev/artifacts/screenshots/2026-05-16/01-overview.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/02-integrations-list.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/03-omni-pca-config.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/04-panel-device.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/05-entities-omni.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/06-developer-states.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/07-side-panel-empty.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/08-side-panel-programs.png
Normal file
|
After Width: | Height: | Size: 295 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/09-side-panel-detail.png
Normal file
|
After Width: | Height: | Size: 325 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/10-side-panel-editor.png
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/11-chain-editor.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/12-structured-and.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
dev/artifacts/screenshots/2026-05-17/arg2-object-editor.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
dev/artifacts/screenshots/2026-05-17/grafana-iter-final.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 281 KiB |
BIN
dev/artifacts/screenshots/2026-05-17/real-pca-overview.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
150
dev/screenshot_arg2_object.py
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Focused screenshot of the structured-AND Arg2-as-object editor.
|
||||||
|
|
||||||
|
Drives an already-onboarded HA at localhost:8123, opens the side panel,
|
||||||
|
clicks into the chain at slot 200, hits Edit, and snaps the form.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
HA_URL = "http://localhost:8123"
|
||||||
|
USERNAME = "demo"
|
||||||
|
PASSWORD = "demo-password-1234"
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_token() -> str:
|
||||||
|
async with httpx.AsyncClient(base_url=HA_URL, timeout=30) as c:
|
||||||
|
r = await c.post(
|
||||||
|
"/auth/login_flow",
|
||||||
|
json={
|
||||||
|
"client_id": HA_URL,
|
||||||
|
"handler": ["homeassistant", None],
|
||||||
|
"redirect_uri": HA_URL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
flow_id = r.json()["flow_id"]
|
||||||
|
r = await c.post(
|
||||||
|
f"/auth/login_flow/{flow_id}",
|
||||||
|
json={
|
||||||
|
"username": USERNAME,
|
||||||
|
"password": PASSWORD,
|
||||||
|
"client_id": HA_URL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
code = r.json()["result"]
|
||||||
|
r = await c.post(
|
||||||
|
"/auth/token",
|
||||||
|
data={
|
||||||
|
"client_id": HA_URL,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return r.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
FIND_PANEL = """
|
||||||
|
(() => {
|
||||||
|
function find(root, depth=0) {
|
||||||
|
if (!root || depth > 15) return null;
|
||||||
|
if (root.tagName === 'OMNI-PANEL-PROGRAMS') return root;
|
||||||
|
for (const k of Array.from(root.children || [])) {
|
||||||
|
const r = find(k, depth+1);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
if (root.shadowRoot) {
|
||||||
|
const r = find(root.shadowRoot, depth+1);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return find(document.body);
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def amain(outdir: Path) -> None:
|
||||||
|
token = await _login_token()
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch()
|
||||||
|
context = await browser.new_context(viewport={"width": 1400, "height": 900})
|
||||||
|
await context.add_init_script(f"""
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'hassTokens',
|
||||||
|
JSON.stringify({{
|
||||||
|
access_token: '{token}',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
refresh_token: '',
|
||||||
|
expires: Date.now() + 3600000,
|
||||||
|
hassUrl: '{HA_URL}',
|
||||||
|
clientId: '{HA_URL}',
|
||||||
|
}})
|
||||||
|
);
|
||||||
|
window.localStorage.setItem('selectedTheme', JSON.stringify({{dark: false}}));
|
||||||
|
""")
|
||||||
|
page = await context.new_page()
|
||||||
|
|
||||||
|
page.on("console", lambda m: print(f" [browser] {m.type}: {m.text}"))
|
||||||
|
|
||||||
|
await page.goto(f"{HA_URL}/omni-panel-programs", wait_until="domcontentloaded")
|
||||||
|
await page.wait_for_timeout(6000)
|
||||||
|
|
||||||
|
# Click the chain row (slot 200).
|
||||||
|
ok = await page.evaluate(f"""() => {{
|
||||||
|
const panel = {FIND_PANEL};
|
||||||
|
if (!panel) return 'no-panel';
|
||||||
|
const rows = Array.from(panel.shadowRoot.querySelectorAll('.row'));
|
||||||
|
const target = rows.find(r => r.textContent.includes('200'));
|
||||||
|
if (!target) return 'no-row-200 ' + rows.map(r => r.textContent.slice(0,40)).join(' | ');
|
||||||
|
target.click();
|
||||||
|
return 'clicked';
|
||||||
|
}}""")
|
||||||
|
print(f" row-click: {ok}")
|
||||||
|
await page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
# Click Edit.
|
||||||
|
ok = await page.evaluate(f"""() => {{
|
||||||
|
const panel = {FIND_PANEL};
|
||||||
|
if (!panel) return 'no-panel';
|
||||||
|
const buttons = panel.shadowRoot.querySelectorAll('.detail button');
|
||||||
|
for (const b of buttons) {{
|
||||||
|
if (b.textContent.trim() === 'Edit') {{ b.click(); return 'clicked'; }}
|
||||||
|
}}
|
||||||
|
return 'no-edit-button';
|
||||||
|
}}""")
|
||||||
|
print(f" edit-click: {ok}")
|
||||||
|
await page.wait_for_timeout(1500)
|
||||||
|
|
||||||
|
path = outdir / "arg2-object-editor.png"
|
||||||
|
await page.screenshot(path=str(path), full_page=True)
|
||||||
|
print(f" wrote {path}")
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--outdir",
|
||||||
|
type=Path,
|
||||||
|
default=Path(__file__).parent / "artifacts" / "screenshots" /
|
||||||
|
datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(amain(args.outdir))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
63
dev/screenshot_overview.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Quick screenshot of the Omni Programs side panel landing page."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
HA_URL = "http://localhost:8123"
|
||||||
|
USERNAME = "demo"
|
||||||
|
PASSWORD = "demo-password-1234"
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_token() -> str:
|
||||||
|
async with httpx.AsyncClient(base_url=HA_URL, timeout=30) as c:
|
||||||
|
r = await c.post("/auth/login_flow", json={
|
||||||
|
"client_id": HA_URL, "handler": ["homeassistant", None],
|
||||||
|
"redirect_uri": HA_URL,
|
||||||
|
})
|
||||||
|
flow_id = r.json()["flow_id"]
|
||||||
|
r = await c.post(f"/auth/login_flow/{flow_id}", json={
|
||||||
|
"username": USERNAME, "password": PASSWORD, "client_id": HA_URL,
|
||||||
|
})
|
||||||
|
code = r.json()["result"]
|
||||||
|
r = await c.post("/auth/token", data={
|
||||||
|
"client_id": HA_URL, "grant_type": "authorization_code", "code": code,
|
||||||
|
})
|
||||||
|
return r.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
async def amain(outdir: Path) -> None:
|
||||||
|
token = await _login_token()
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch()
|
||||||
|
context = await browser.new_context(viewport={"width": 1400, "height": 900})
|
||||||
|
await context.add_init_script(f"""
|
||||||
|
window.localStorage.setItem('hassTokens', JSON.stringify({{
|
||||||
|
access_token: '{token}', token_type: 'Bearer', refresh_token: '',
|
||||||
|
expires: Date.now() + 3600000, hassUrl: '{HA_URL}', clientId: '{HA_URL}',
|
||||||
|
}}));
|
||||||
|
window.localStorage.setItem('selectedTheme', JSON.stringify({{dark: false}}));
|
||||||
|
""")
|
||||||
|
page = await context.new_page()
|
||||||
|
await page.goto(f"{HA_URL}/omni-panel-programs", wait_until="domcontentloaded")
|
||||||
|
await page.wait_for_timeout(8000)
|
||||||
|
path = outdir / "real-pca-overview.png"
|
||||||
|
await page.screenshot(path=str(path), full_page=True)
|
||||||
|
print(f" wrote {path}")
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
outdir = Path(sys.argv[1]) if len(sys.argv) > 1 else (
|
||||||
|
Path(__file__).parent / "artifacts" / "screenshots" /
|
||||||
|
datetime.now().strftime("%Y-%m-%d")
|
||||||
|
)
|
||||||
|
asyncio.run(amain(outdir))
|
||||||
19
grafana/.env.example
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Copy to .env and fill in. Both files in this directory load .env
|
||||||
|
# automatically via docker compose; ./env.example is committed, .env
|
||||||
|
# is gitignored.
|
||||||
|
|
||||||
|
# InfluxDB v2 admin user (created on first boot).
|
||||||
|
INFLUX_USERNAME=admin
|
||||||
|
INFLUX_PASSWORD=change-me-strong-password-here
|
||||||
|
|
||||||
|
# Admin token used by Home Assistant (writes) and Grafana (reads).
|
||||||
|
# Generate one with: openssl rand -hex 32
|
||||||
|
INFLUX_TOKEN=replace-with-a-real-token-from-openssl-rand-hex-32
|
||||||
|
|
||||||
|
# Grafana admin password (UI login as "admin"/this value).
|
||||||
|
GRAFANA_PASSWORD=change-me-too
|
||||||
|
|
||||||
|
# Public hostnames if you're putting either service behind a reverse
|
||||||
|
# proxy. Leave blank for localhost-only access.
|
||||||
|
INFLUX_PUBLIC_HOST=
|
||||||
|
GRAFANA_PUBLIC_HOST=
|
||||||
129
grafana/README.md
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
# Grafana dashboard for omni_pca
|
||||||
|
|
||||||
|
InfluxDB v2 + Grafana stack pre-provisioned to visualise an HAI/Leviton
|
||||||
|
Omni Pro II panel via the `omni_pca` Home Assistant integration.
|
||||||
|
Drop-in for any existing HA install — no integration changes required.
|
||||||
|
|
||||||
|

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