The program viewer goes from read-only to write-capable. Three layers
land together because a partial implementation isn't actionable.
D1 — wire path:
* OmniClient.download_program(slot, program) — sends opcode 8
(clsOLMsg2DownloadProgram, clsHAC.cs:1133-1140) with the 2-byte BE
slot + Program.encode_wire_bytes(). Validates slot range 1..1500
client-side. Maps Ack → success, Nak → CommandFailedError, any
other opcode → OmniConnectionError.
* OmniClient.clear_program(slot) — convenience that writes an all-zero
body. Mock treats this as deletion (removes the slot from
state.programs) so subsequent reads see it as undefined.
* MockPanel handles DownloadProgram on the v2 dispatch path —
receive 2-byte slot + 14-byte body, store in state.programs, ack.
* OmniClientV1.download_program raises NotImplementedError. v1 only
has the bulk DownloadPrograms flow which clears everything before
rewriting — destructive for HA's edit-one-program use case.
Documented in the docstring so callers know to route v1 users to
a v2 connection.
Tests cover: write-then-read round-trip, overwrite of existing slot,
clear deletes the slot, range validation, v1 not-implemented.
D2 — HA websocket commands:
* omni_pca/programs/clear — writes zero body, updates coordinator.
data.programs immediately so the next list call shows the deletion.
Returns ``{slot, cleared: true}``. Maps NotImplementedError on v1
panels to the ``not_supported`` error code.
* omni_pca/programs/clone — copies source_slot → target_slot, with
the slot field re-stamped. Refuses identical source/target,
refuses missing source. Same coordinator update pattern.
5 new HA-integration tests covering clear, clone happy path, clone
to same slot, clone from missing source.
D3 — Clear/Clone UI in the side panel:
* "Clone…" button reveals an inline target-slot input (number,
1..1500). Enter or "Clone" button calls the WS command, then
navigates the detail panel to the new clone so the user sees the
result.
* "Clear" button shows an inline confirmation row ("Clear slot N?
This deletes the program from the panel.") with Yes/Cancel. Yes
closes the detail panel and refreshes the list — the slot is gone.
* Both surface feedback via the same _writeFeedback state used by
Fire now (auto-clears after 4 seconds).
* Three new button styles (.primary, .secondary, .danger) and the
.action-row composite used for both inline prompts.
What's NOT shipped here: a real visual editor for trigger/condition/
action fields. That's a follow-up (~600 lines of new TS + careful
validation work). The current "Cut 1" UX is enough for the common
"I accidentally created a program, clear it" and "I want a variant
of this program, give me a copy in an empty slot" workflows.
Full suite: 643 passed, 1 skipped (up from 634).
Frontend bundle: 38 KB minified (up from 34 KB with the write UI).
350 lines
12 KiB
Python
350 lines
12 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,
|
|
),
|
|
}
|
|
|
|
|
|
@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"]
|
|
assert result["total"] == 3
|
|
assert result["filtered_total"] == 3
|
|
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}
|
|
# Each row has the metadata the frontend needs.
|
|
for row in result["programs"]:
|
|
assert row["kind"] == "compact"
|
|
assert row["trigger_type"] in ("TIMED", "EVENT")
|
|
assert isinstance(row["summary"], list)
|
|
assert row["summary"] # non-empty token list
|
|
|
|
|
|
async def test_ws_list_programs_filter_by_trigger_type(
|
|
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"]
|
|
assert result["filtered_total"] == 1
|
|
assert result["programs"][0]["slot"] == 42
|
|
|
|
|
|
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"]
|
|
# Only slot 42 ("Turn ON KITCHEN_OVERHEAD") mentions kitchen.
|
|
assert result["filtered_total"] == 1
|
|
assert result["programs"][0]["slot"] == 42
|
|
|
|
|
|
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"]
|
|
assert result["filtered_total"] == 3
|
|
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_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_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
|