Multi-record clausal programs are now editable end-to-end. A chain
spans N consecutive slots — head (WHEN/AT/EVERY) + zero-or-more
AND/OR condition records + one-or-more THEN action records — so
"editing" means rewriting the whole run, validating that any
expansion doesn't trample adjacent programs, and clearing any old
slots when the chain shrinks.
H1 — backend:
* programs/get for chains now returns chain_members[] with each
member's slot + role + raw fields. The editor uses this to seed
one editable form-row per slot.
* New programs/chain/write command: takes head_slot + head dict +
conditions[] + actions[], does N sequential download_program
calls, then clears any old chain slots that fell outside the new
range. Validates:
- head_slot + new_len doesn't extend past slot 1500
- any expansion-into slot not already part of THIS chain is FREE
(anti-trample: refuse rather than overwrite an adjacent program)
- at least one THEN action present (empty chain rejected)
Updates coordinator.data.programs immediately so subsequent list
calls reflect the edit before the next poll.
H2 — TS helpers:
* AND-record encoding mirrors compact-form cond family bytes
(0x04 ZONE / 0x08 CTRL / 0x0C TIME / 0x00 OTHER + 0x10+ SEC) but
with a slightly different bit layout: the family byte lives at
fields.cond & 0xFF (disk byte 1) and the instance at
(fields.cond2 >> 8) & 0xFF (disk byte 3). The selector bit is
family's bit 0x02 instead of cond's 0x0200. decodeAndCondition /
encodeAndCondition handle both directions; round-trip exact.
* isStructuredAnd helper detects records with OP > 0 (TEMP > N
comparisons etc.); those render read-only in the chain editor
with a warning banner.
* emptyAndRecord / emptyOrRecord / emptyThenRecord helpers for
the add-condition / add-action buttons.
H3 — chain editor UI:
* New _chainDraft state (parallel to _editingDraft for compact form)
with head + conditions[] + actions[] arrays. Mutation helpers
preserve immutability via array-copy-then-patch.
* "Edit" button on chain detail now opens the chain editor instead
of returning early (previous read-only behaviour).
* Three sub-renderers: trigger section dispatches on prog_type
(WHEN→event-id builder reusing the EVENT helpers, AT→time+days
reusing TIMED layout, EVERY→single seconds input that packs into
cond+cond2), conditions section with per-row add/remove (separate
+ AND IF and + OR IF buttons in the legend), actions section with
per-row add/remove (+ THEN button; at least one action enforced).
* Structured-OP AND records render with an explanatory read-only
banner and a × button to drop the row entirely — preserves the
data when the user doesn't touch it, lets them remove it cleanly
when they want to.
* Each row picks objects via _bucketWithPreserve so out-of-range
zone/unit/area indices stay safe.
5 new HA integration tests:
* get-chain returns chain_members with correct roles + raw fields
* chain/write in-place rewrite preserves footprint, updates bytes
* chain/write shrink clears the trailing old slots
* chain/write refuses to trample an adjacent program on expansion
* chain/write rejects zero-actions submission
Live screenshot 11-chain-editor.png: state injection into the side
panel (real panel has no chains) shows the editor rendering a sample
WHEN zone-state → AND IF unit ON → 2x THEN action chain with every
control populated and functional.
Full suite: 653 passed, 1 skipped (up from 648, 5 new chain tests).
Frontend bundle: 82 KB minified (up from 63 KB).
674 lines
25 KiB
Python
674 lines
25 KiB
Python
"""HA websocket commands for the program viewer.
|
|
|
|
Tests run against the live HA test harness so they exercise:
|
|
* websocket command registration during integration setup
|
|
* filter / pagination / search of the program list
|
|
* detail rendering for compact-form + clausal-chain slots
|
|
* fire-program over the wire to the mock panel
|
|
|
|
Each test uses the already-seeded ``configured_panel`` fixture from
|
|
conftest.py plus the test-harness-provided ``hass_ws_client``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from custom_components.omni_pca.const import DOMAIN
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
from omni_pca.commands import Command
|
|
from omni_pca.programs import Days, Program, ProgramType
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_programs() -> dict[int, Program]:
|
|
"""A small set of programs covering the main shapes the viewer renders.
|
|
|
|
Three compact-form TIMED programs and one clausal chain — enough
|
|
to exercise summary rendering, the chain group-and-render path, and
|
|
the filter/search dimensions.
|
|
"""
|
|
return {
|
|
12: Program(
|
|
slot=12, prog_type=int(ProgramType.TIMED),
|
|
cmd=int(Command.UNIT_ON), pr2=1, # unit 1 in fixture
|
|
hour=6, minute=0, days=int(Days.MONDAY | Days.FRIDAY),
|
|
),
|
|
42: Program(
|
|
slot=42, prog_type=int(ProgramType.TIMED),
|
|
cmd=int(Command.UNIT_ON), pr2=2, # unit 2
|
|
hour=22, minute=30, days=int(Days.SUNDAY),
|
|
),
|
|
99: Program(
|
|
slot=99, prog_type=int(ProgramType.EVENT),
|
|
cmd=int(Command.UNIT_ON), pr2=1,
|
|
# WHEN zone 1 changes to NOT_READY (event_id = 0x0401)
|
|
month=0x04, day=0x01,
|
|
),
|
|
# A clausal chain spanning slots 200..203: WHEN zone 1 not-ready
|
|
# AND IF unit 1 ON THEN turn ON unit 2 AND turn OFF unit 1.
|
|
200: Program(
|
|
slot=200, prog_type=int(ProgramType.WHEN),
|
|
# event_id = 0x0401 (zone 1 not-ready) packed in month/day
|
|
month=0x04, day=0x01,
|
|
),
|
|
201: Program(
|
|
slot=201, prog_type=int(ProgramType.AND),
|
|
# Traditional AND: family byte 0x0A = CTRL+ON, instance 1.
|
|
# and_family = cond & 0xFF, and_instance = (cond2>>8) & 0xFF.
|
|
cond=0x000A, cond2=0x0100,
|
|
),
|
|
202: Program(
|
|
slot=202, prog_type=int(ProgramType.THEN),
|
|
cmd=int(Command.UNIT_ON), pr2=2,
|
|
),
|
|
203: Program(
|
|
slot=203, prog_type=int(ProgramType.THEN),
|
|
cmd=int(Command.UNIT_OFF), pr2=1,
|
|
),
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def populated_state(seeded_programs):
|
|
"""Override the conftest fixture to inject our test programs."""
|
|
from omni_pca.mock_panel import (
|
|
MockAreaState,
|
|
MockButtonState,
|
|
MockState,
|
|
MockThermostatState,
|
|
MockUnitState,
|
|
MockZoneState,
|
|
)
|
|
|
|
return MockState(
|
|
zones={
|
|
1: MockZoneState(name="FRONT_DOOR"),
|
|
2: MockZoneState(name="GARAGE_ENTRY"),
|
|
10: MockZoneState(name="LIVING_MOTION"),
|
|
},
|
|
units={
|
|
1: MockUnitState(name="LIVING_LAMP"),
|
|
2: MockUnitState(name="KITCHEN_OVERHEAD"),
|
|
},
|
|
areas={1: MockAreaState(name="MAIN")},
|
|
thermostats={1: MockThermostatState(name="LIVING_ROOM")},
|
|
buttons={1: MockButtonState(name="GOOD_MORNING")},
|
|
user_codes={1: 1234},
|
|
programs={
|
|
slot: p.encode_wire_bytes() for slot, p in seeded_programs.items()
|
|
},
|
|
)
|
|
|
|
|
|
async def test_ws_list_programs_returns_summaries(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""The list command returns rendered summary tokens for every
|
|
program the coordinator discovered."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/list",
|
|
"entry_id": configured_panel.entry_id,
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
result = response["result"]
|
|
# 3 compact-form programs (12, 42, 99) + 1 clausal chain (head at
|
|
# slot 200, spanning 200..203). The chain renders as a single row.
|
|
assert result["total"] == 4
|
|
assert result["filtered_total"] == 4
|
|
rows_by_slot = {row["slot"]: row for row in result["programs"]}
|
|
assert rows_by_slot.keys() == {12, 42, 99, 200}
|
|
assert rows_by_slot[200]["kind"] == "chain"
|
|
assert rows_by_slot[12]["kind"] == "compact"
|
|
|
|
|
|
async def test_ws_list_programs_filter_by_trigger_type(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""``trigger_types=["TIMED"]`` filters out the EVENT-typed row."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/list",
|
|
"entry_id": configured_panel.entry_id,
|
|
"trigger_types": ["TIMED"],
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
result = response["result"]
|
|
assert result["filtered_total"] == 2 # only the two TIMED rows
|
|
assert {row["slot"] for row in result["programs"]} == {12, 42}
|
|
|
|
|
|
async def test_ws_list_programs_filter_by_referenced_entity(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""``references_entity="unit:2"`` returns only programs that mention unit 2."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/list",
|
|
"entry_id": configured_panel.entry_id,
|
|
"references_entity": "unit:2",
|
|
})
|
|
response = await client.receive_json()
|
|
result = response["result"]
|
|
# Slot 42 ("Turn ON KITCHEN_OVERHEAD" = unit 2) plus the seeded chain
|
|
# at slot 200 (action: Turn ON unit 2) both reference unit:2.
|
|
assert result["filtered_total"] == 2
|
|
slots = {r["slot"] for r in result["programs"]}
|
|
assert slots == {42, 200}
|
|
|
|
|
|
async def test_ws_list_programs_search_substring(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Search is a case-insensitive substring match on the rendered text."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/list",
|
|
"entry_id": configured_panel.entry_id,
|
|
"search": "kitchen",
|
|
})
|
|
response = await client.receive_json()
|
|
result = response["result"]
|
|
# Slot 42 ("Turn ON KITCHEN_OVERHEAD" — truncated to 12 chars on
|
|
# wire = "KITCHEN_OVER") matches. The chain at slot 200 also has
|
|
# an action against unit 2 which renders with the same truncated
|
|
# name, so it matches too.
|
|
assert result["filtered_total"] == 2
|
|
slots = {r["slot"] for r in result["programs"]}
|
|
assert slots == {42, 200}
|
|
|
|
|
|
async def test_ws_list_programs_pagination(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/list",
|
|
"entry_id": configured_panel.entry_id,
|
|
"limit": 2,
|
|
"offset": 1,
|
|
})
|
|
response = await client.receive_json()
|
|
result = response["result"]
|
|
# 4 list rows total: 3 compact + 1 chain head.
|
|
assert result["filtered_total"] == 4
|
|
assert len(result["programs"]) == 2
|
|
assert [row["slot"] for row in result["programs"]] == [42, 99]
|
|
|
|
|
|
async def test_ws_get_program_returns_full_token_stream(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Detail of a single slot returns the full structured-English tokens."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/get",
|
|
"entry_id": configured_panel.entry_id,
|
|
"slot": 42,
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
result = response["result"]
|
|
assert result["slot"] == 42
|
|
assert result["kind"] == "compact"
|
|
assert result["trigger_type"] == "TIMED"
|
|
text = "".join(
|
|
tok["t"] for tok in result["tokens"] if tok.get("k") != "newline"
|
|
)
|
|
assert "Turn ON" in text
|
|
# Unit names cap at 12 bytes on Omni Pro II (lenUnitName), so the
|
|
# 16-char "KITCHEN_OVERHEAD" lands on the wire as "KITCHEN_OVER".
|
|
assert "KITCHEN_OVER" in text
|
|
|
|
|
|
async def test_ws_get_program_returns_raw_fields_for_editor(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""The detail response includes a 'fields' dict carrying raw Program
|
|
integer values, so the editor can seed forms from actual data rather
|
|
than defaults. Round-trip: get → fields → write back should preserve
|
|
every byte (idempotent under no-op edits)."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/get",
|
|
"entry_id": configured_panel.entry_id,
|
|
"slot": 42,
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
fields = response["result"]["fields"]
|
|
# Slot 42 is the seeded TIMED 22:30 Sunday → Turn ON unit 2 program.
|
|
assert fields["prog_type"] == 1
|
|
assert fields["hour"] == 22
|
|
assert fields["minute"] == 30
|
|
assert fields["days"] == int(Days.SUNDAY)
|
|
assert fields["cmd"] == int(Command.UNIT_ON)
|
|
assert fields["pr2"] == 2
|
|
|
|
# Round-trip: write those same fields back; nothing should change.
|
|
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
|
|
before = coordinator.data.programs[42]
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/write",
|
|
"entry_id": configured_panel.entry_id,
|
|
"slot": 42,
|
|
"program": fields,
|
|
})
|
|
write_response = await client.receive_json()
|
|
assert write_response["success"] is True
|
|
after = coordinator.data.programs[42]
|
|
assert before.encode_wire_bytes() == after.encode_wire_bytes()
|
|
|
|
|
|
async def test_ws_get_program_missing_slot_returns_error(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/get",
|
|
"entry_id": configured_panel.entry_id,
|
|
"slot": 500, # not seeded
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is False
|
|
assert response["error"]["code"] == "not_found"
|
|
|
|
|
|
async def test_ws_list_programs_unknown_entry_id(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Bad entry_id returns a structured ``not_found`` error, not a crash."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/list",
|
|
"entry_id": "does-not-exist",
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is False
|
|
assert response["error"]["code"] == "not_found"
|
|
|
|
|
|
async def test_ws_fire_program_executes_command(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Fire sends Command.EXECUTE_PROGRAM over the wire — the mock acks."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/fire",
|
|
"entry_id": configured_panel.entry_id,
|
|
"slot": 42,
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
assert response["result"] == {"slot": 42, "fired": True}
|
|
|
|
|
|
async def test_ws_clear_program_writes_zero_body(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Clear erases a slot end-to-end: ws command → DownloadProgram on
|
|
the wire → mock state loses the slot → coordinator drops it from
|
|
its in-memory map."""
|
|
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
|
|
assert 42 in coordinator.data.programs
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/clear",
|
|
"entry_id": configured_panel.entry_id,
|
|
"slot": 42,
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
assert response["result"] == {"slot": 42, "cleared": True}
|
|
# The coordinator's view drops the slot immediately so a follow-up
|
|
# list reflects the deletion without waiting for the next poll.
|
|
assert 42 not in coordinator.data.programs
|
|
|
|
|
|
async def test_ws_clone_program_copies_to_empty_slot(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Cloning slot 12 to slot 500 lands a copy at the target with the
|
|
right fields and leaves the source untouched."""
|
|
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
|
|
assert 12 in coordinator.data.programs
|
|
assert 500 not in coordinator.data.programs
|
|
source = coordinator.data.programs[12]
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/clone",
|
|
"entry_id": configured_panel.entry_id,
|
|
"source_slot": 12,
|
|
"target_slot": 500,
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
assert response["result"] == {
|
|
"source_slot": 12, "target_slot": 500, "cloned": True,
|
|
}
|
|
# New program landed at the target with re-stamped slot.
|
|
cloned = coordinator.data.programs[500]
|
|
assert cloned.slot == 500
|
|
assert cloned.prog_type == source.prog_type
|
|
assert cloned.cmd == source.cmd
|
|
assert cloned.pr2 == source.pr2
|
|
# Source remains.
|
|
assert 12 in coordinator.data.programs
|
|
|
|
|
|
async def test_ws_clone_program_rejects_same_slot(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/clone",
|
|
"entry_id": configured_panel.entry_id,
|
|
"source_slot": 12,
|
|
"target_slot": 12,
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is False
|
|
assert response["error"]["code"] == "invalid"
|
|
|
|
|
|
async def test_ws_clone_program_rejects_missing_source(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Cloning from a slot that has no program is a structured error."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/clone",
|
|
"entry_id": configured_panel.entry_id,
|
|
"source_slot": 999, # not seeded
|
|
"target_slot": 100,
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is False
|
|
assert response["error"]["code"] == "not_found"
|
|
|
|
|
|
async def test_ws_write_program_creates_new_slot(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Writing a Program dict to an empty slot lands a new program."""
|
|
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
|
|
assert 700 not in coordinator.data.programs
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/write",
|
|
"entry_id": configured_panel.entry_id,
|
|
"slot": 700,
|
|
"program": {
|
|
"prog_type": 1, # TIMED
|
|
"cmd": int(Command.UNIT_ON),
|
|
"pr2": 2,
|
|
"hour": 7, "minute": 30,
|
|
"days": int(Days.SATURDAY | Days.SUNDAY),
|
|
},
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
assert response["result"] == {"slot": 700, "written": True}
|
|
new_program = coordinator.data.programs[700]
|
|
assert new_program.slot == 700
|
|
assert new_program.cmd == int(Command.UNIT_ON)
|
|
assert new_program.pr2 == 2
|
|
assert new_program.hour == 7 and new_program.minute == 30
|
|
|
|
|
|
async def test_ws_write_program_overwrites_existing_slot(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Writing to a slot that has a program replaces the existing one."""
|
|
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
|
|
# Slot 12 is seeded (TIMED hour=6 minute=0). Rewrite it.
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/write",
|
|
"entry_id": configured_panel.entry_id,
|
|
"slot": 12,
|
|
"program": {
|
|
"prog_type": 1,
|
|
"cmd": int(Command.UNIT_OFF),
|
|
"pr2": 99,
|
|
"hour": 23, "minute": 45, "days": int(Days.MONDAY),
|
|
},
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
updated = coordinator.data.programs[12]
|
|
assert updated.cmd == int(Command.UNIT_OFF)
|
|
assert updated.pr2 == 99
|
|
assert updated.hour == 23 and updated.minute == 45
|
|
|
|
|
|
async def test_ws_write_program_validates_payload(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Bad program dict (out-of-range field) returns structured error."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/write",
|
|
"entry_id": configured_panel.entry_id,
|
|
"slot": 12,
|
|
"program": {
|
|
"prog_type": 99, # invalid (max 10)
|
|
"cmd": 1, "pr2": 1, "hour": 6, "minute": 0,
|
|
},
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is False
|
|
assert response["error"]["code"] == "invalid"
|
|
|
|
|
|
async def test_ws_list_objects_returns_named_buckets(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""objects/list returns zones/units/areas/thermostats/buttons in
|
|
slot-sorted order with their HA-discovered names."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/objects/list",
|
|
"entry_id": configured_panel.entry_id,
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
result = response["result"]
|
|
assert {"zones", "units", "areas", "thermostats", "buttons"} <= result.keys()
|
|
# Fixture has units at indexes 1, 2 (LIVING_LAMP, KITCHEN_OVERHEAD-truncated).
|
|
units = result["units"]
|
|
assert len(units) == 2
|
|
assert units[0]["index"] == 1
|
|
assert units[0]["name"] == "LIVING_LAMP"
|
|
# And zones come back with their fixture names too.
|
|
zones_by_idx = {z["index"]: z["name"] for z in result["zones"]}
|
|
assert zones_by_idx[1] == "FRONT_DOOR"
|
|
|
|
|
|
async def test_ws_get_chain_returns_member_fields(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Chain detail response includes a chain_members array with each
|
|
member's role + raw fields, so the editor can render an editable
|
|
row per slot."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/get",
|
|
"entry_id": configured_panel.entry_id,
|
|
"slot": 200, # head of the seeded chain
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
result = response["result"]
|
|
assert result["kind"] == "chain"
|
|
members = result["chain_members"]
|
|
roles = [m["role"] for m in members]
|
|
assert roles == ["head", "condition", "action", "action"]
|
|
# Head carries the event_id (zone 1 NOT_READY = 0x0401).
|
|
head_fields = members[0]["fields"]
|
|
assert head_fields["prog_type"] == int(ProgramType.WHEN)
|
|
assert head_fields["month"] == 0x04
|
|
assert head_fields["day"] == 0x01
|
|
# Condition is a Traditional AND record with family CTRL+ON, unit 1.
|
|
cond_fields = members[1]["fields"]
|
|
assert cond_fields["prog_type"] == int(ProgramType.AND)
|
|
assert cond_fields["cond"] == 0x000A
|
|
assert cond_fields["cond2"] == 0x0100
|
|
|
|
|
|
async def test_ws_chain_write_replaces_in_place(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Same-length rewrite leaves the chain footprint unchanged but
|
|
updates every member's bytes."""
|
|
client = await hass_ws_client(hass)
|
|
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
|
|
# Existing chain: slots 200..203.
|
|
assert {200, 201, 202, 203} <= coordinator.data.programs.keys()
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/chain/write",
|
|
"entry_id": configured_panel.entry_id,
|
|
"head_slot": 200,
|
|
"head": {
|
|
"prog_type": int(ProgramType.WHEN),
|
|
"month": 0x04, "day": 0x02, # zone 1 trouble (id 0x0402)
|
|
},
|
|
"conditions": [
|
|
# AND IF unit 2 ON (family 0x0A, instance 2)
|
|
{"prog_type": int(ProgramType.AND),
|
|
"cond": 0x000A, "cond2": 0x0200},
|
|
],
|
|
"actions": [
|
|
{"prog_type": int(ProgramType.THEN),
|
|
"cmd": int(Command.UNIT_OFF), "pr2": 2},
|
|
{"prog_type": int(ProgramType.THEN),
|
|
"cmd": int(Command.UNIT_ON), "pr2": 1},
|
|
],
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
assert response["result"]["written_slots"] == [200, 201, 202, 203]
|
|
assert response["result"]["cleared_slots"] == []
|
|
# Coordinator state reflects the new bytes.
|
|
assert coordinator.data.programs[200].day == 0x02
|
|
assert coordinator.data.programs[201].cond2 == 0x0200
|
|
assert coordinator.data.programs[202].cmd == int(Command.UNIT_OFF)
|
|
|
|
|
|
async def test_ws_chain_write_shrinks_and_clears(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Shorter rewrite clears the trailing old chain slots."""
|
|
client = await hass_ws_client(hass)
|
|
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/chain/write",
|
|
"entry_id": configured_panel.entry_id,
|
|
"head_slot": 200,
|
|
"head": {
|
|
"prog_type": int(ProgramType.WHEN),
|
|
"month": 0x04, "day": 0x01,
|
|
},
|
|
# No conditions, one action — chain shrinks from 4 slots to 2.
|
|
"conditions": [],
|
|
"actions": [
|
|
{"prog_type": int(ProgramType.THEN),
|
|
"cmd": int(Command.UNIT_ON), "pr2": 1},
|
|
],
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is True
|
|
assert response["result"]["written_slots"] == [200, 201]
|
|
assert sorted(response["result"]["cleared_slots"]) == [202, 203]
|
|
# Cleared slots are gone from the coordinator's view.
|
|
assert 202 not in coordinator.data.programs
|
|
assert 203 not in coordinator.data.programs
|
|
|
|
|
|
async def test_ws_chain_write_refuses_to_trample(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Expanding a chain into a slot that already holds another program
|
|
is refused — protects against accidental data loss."""
|
|
client = await hass_ws_client(hass)
|
|
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
|
|
# Seed a sentinel program at slot 204 (right after the chain) so an
|
|
# expand attempt collides.
|
|
coordinator.data.programs[204] = Program(
|
|
slot=204, prog_type=int(ProgramType.TIMED),
|
|
cmd=int(Command.UNIT_ON), pr2=1,
|
|
hour=12, minute=0, days=int(Days.MONDAY),
|
|
)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/chain/write",
|
|
"entry_id": configured_panel.entry_id,
|
|
"head_slot": 200,
|
|
"head": {"prog_type": int(ProgramType.WHEN),
|
|
"month": 0x04, "day": 0x01},
|
|
"conditions": [
|
|
{"prog_type": int(ProgramType.AND),
|
|
"cond": 0x000A, "cond2": 0x0100},
|
|
# Adding a second condition pushes the chain from 4 to 5
|
|
# slots → slot 204 collision.
|
|
{"prog_type": int(ProgramType.AND),
|
|
"cond": 0x000A, "cond2": 0x0200},
|
|
],
|
|
"actions": [
|
|
{"prog_type": int(ProgramType.THEN),
|
|
"cmd": int(Command.UNIT_ON), "pr2": 2},
|
|
{"prog_type": int(ProgramType.THEN),
|
|
"cmd": int(Command.UNIT_OFF), "pr2": 1},
|
|
],
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is False
|
|
assert response["error"]["code"] == "invalid"
|
|
# The sentinel program is untouched.
|
|
assert coordinator.data.programs[204].cmd == int(Command.UNIT_ON)
|
|
|
|
|
|
async def test_ws_chain_write_rejects_zero_actions(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""A chain with no THEN actions is meaningless — refuse it."""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/chain/write",
|
|
"entry_id": configured_panel.entry_id,
|
|
"head_slot": 200,
|
|
"head": {"prog_type": int(ProgramType.WHEN),
|
|
"month": 0x04, "day": 0x01},
|
|
"conditions": [],
|
|
"actions": [],
|
|
})
|
|
response = await client.receive_json()
|
|
assert response["success"] is False
|
|
assert response["error"]["code"] == "invalid"
|
|
|
|
|
|
async def test_ws_list_programs_live_state_overlay_zone(
|
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
|
) -> None:
|
|
"""Summary tokens carry live-state badges on REF tokens.
|
|
|
|
The EVENT program at slot 99 references zone 1; the coordinator's
|
|
``zone_status[1]`` carries SECURE / NOT READY etc. and we expect
|
|
that label to flow through to the token's ``s`` field.
|
|
"""
|
|
client = await hass_ws_client(hass)
|
|
await client.send_json_auto_id({
|
|
"type": "omni_pca/programs/list",
|
|
"entry_id": configured_panel.entry_id,
|
|
})
|
|
response = await client.receive_json()
|
|
rows_by_slot = {row["slot"]: row for row in response["result"]["programs"]}
|
|
event_row = rows_by_slot[99]
|
|
refs = [tok for tok in event_row["summary"] if tok.get("k") == "ref"]
|
|
# At least one REF should be the zone-1 reference with a state label.
|
|
zone_refs = [r for r in refs if r.get("ek") == "zone" and r.get("ei") == 1]
|
|
assert zone_refs, f"expected zone:1 ref in {refs!r}"
|
|
assert "s" in zone_refs[0] # state badge populated
|