program writeback: DownloadProgram wire + HA write API + Clear/Clone UI
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run

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).
This commit is contained in:
Ryan Malloy 2026-05-16 01:14:54 -06:00
parent f38777e219
commit 9cdb312baf
9 changed files with 713 additions and 26 deletions

View File

@ -59,6 +59,10 @@ export class OmniPanelPrograms extends LitElement {
@state() private _detail: ProgramDetail | null = null; @state() private _detail: ProgramDetail | null = null;
@state() private _detailLoading = false; @state() private _detailLoading = false;
@state() private _fireFeedback: string | null = null; @state() private _fireFeedback: string | null = null;
@state() private _writeFeedback: string | null = null;
@state() private _cloneTargetSlot: string = "";
@state() private _showCloneInput: boolean = false;
@state() private _confirmingClear: boolean = false;
private _refreshTimer: number | null = null; private _refreshTimer: number | null = null;
@ -185,6 +189,68 @@ export class OmniPanelPrograms extends LitElement {
setTimeout(() => { this._fireFeedback = null; }, 4000); setTimeout(() => { this._fireFeedback = null; }, 4000);
} }
private async _clearProgram(slot: number): Promise<void> {
if (!this._entryId) return;
this._writeFeedback = "clearing…";
try {
await this.hass.connection.sendMessagePromise({
type: "omni_pca/programs/clear",
entry_id: this._entryId,
slot,
});
this._writeFeedback = `cleared slot ${slot}`;
this._confirmingClear = false;
// Refresh the list + close the detail panel; the slot is gone.
this._selectedSlot = null;
this._detail = null;
await this._loadList();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this._writeFeedback = `error: ${message}`;
}
setTimeout(() => { this._writeFeedback = null; }, 4000);
}
private async _cloneProgram(sourceSlot: number): Promise<void> {
if (!this._entryId) return;
const targetRaw = this._cloneTargetSlot.trim();
const target = parseInt(targetRaw, 10);
if (!Number.isFinite(target) || target < 1 || target > 1500) {
this._writeFeedback = "target slot must be 1..1500";
setTimeout(() => { this._writeFeedback = null; }, 4000);
return;
}
if (target === sourceSlot) {
this._writeFeedback = "target must differ from source";
setTimeout(() => { this._writeFeedback = null; }, 4000);
return;
}
this._writeFeedback = "cloning…";
try {
await this.hass.connection.sendMessagePromise({
type: "omni_pca/programs/clone",
entry_id: this._entryId,
source_slot: sourceSlot,
target_slot: target,
});
this._writeFeedback = `cloned to slot ${target}`;
this._showCloneInput = false;
this._cloneTargetSlot = "";
// Navigate to the new clone so the user sees the result.
this._selectedSlot = target;
await this._loadList();
await this._loadDetail(target);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this._writeFeedback = `error: ${message}`;
}
setTimeout(() => { this._writeFeedback = null; }, 4000);
}
private _onCloneTargetInput(e: Event): void {
this._cloneTargetSlot = (e.target as HTMLInputElement).value;
}
// -- refresh timer ---------------------------------------------------- // -- refresh timer ----------------------------------------------------
private _startRefreshTimer(): void { private _startRefreshTimer(): void {
@ -357,9 +423,67 @@ export class OmniPanelPrograms extends LitElement {
class="fire" class="fire"
@click=${() => this._fireProgram(d.slot)} @click=${() => this._fireProgram(d.slot)}
> Fire now</button> > Fire now</button>
<button
type="button"
class="secondary"
@click=${() => {
this._showCloneInput = !this._showCloneInput;
this._confirmingClear = false;
}}
>Clone</button>
<button
type="button"
class="danger"
@click=${() => {
this._confirmingClear = !this._confirmingClear;
this._showCloneInput = false;
}}
>Clear</button>
${this._fireFeedback ? html` ${this._fireFeedback ? html`
<span class="fire-feedback">${this._fireFeedback}</span>` : ""} <span class="fire-feedback">${this._fireFeedback}</span>` : ""}
${this._writeFeedback ? html`
<span class="fire-feedback">${this._writeFeedback}</span>` : ""}
</footer> </footer>
${this._showCloneInput ? html`
<div class="action-row">
<label>Clone slot ${d.slot} target slot:
<input
type="number"
min="1"
max="1500"
.value=${this._cloneTargetSlot}
@input=${this._onCloneTargetInput}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") this._cloneProgram(d.slot);
}}
/>
</label>
<button
type="button"
class="primary"
@click=${() => this._cloneProgram(d.slot)}
>Clone</button>
<button
type="button"
@click=${() => { this._showCloneInput = false; }}
>Cancel</button>
</div>` : ""}
${this._confirmingClear ? html`
<div class="action-row danger-row">
<span>
<strong>Clear slot ${d.slot}?</strong>
This deletes the program from the panel.
</span>
<button
type="button"
class="danger"
@click=${() => this._clearProgram(d.slot)}
>Yes, clear</button>
<button
type="button"
@click=${() => { this._confirmingClear = false; }}
>Cancel</button>
</div>` : ""}
${d.chain_slots && d.chain_slots.length > 1 ? html` ${d.chain_slots && d.chain_slots.length > 1 ? html`
<div class="chain-info"> <div class="chain-info">
spans slots spans slots
@ -579,16 +703,56 @@ export class OmniPanelPrograms extends LitElement {
.detail footer { .detail footer {
display: flex; align-items: center; gap: 12px; margin-top: 14px; display: flex; align-items: center; gap: 12px; margin-top: 14px;
} }
.fire { .fire, .primary, .secondary, .danger {
background: var(--primary-color, #03a9f4);
color: var(--text-primary-color, #fff);
border: none; border: none;
padding: 8px 16px; padding: 8px 16px;
font-size: 0.92rem; font-size: 0.92rem;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-family: inherit;
}
.fire, .primary {
background: var(--primary-color, #03a9f4);
color: var(--text-primary-color, #fff);
}
.secondary {
background: var(--secondary-background-color, #eee);
color: var(--primary-text-color, #000);
}
.danger {
background: transparent;
color: var(--error-color, #db4437);
border: 1px solid var(--error-color, #db4437);
}
.fire:hover, .primary:hover, .secondary:hover, .danger:hover {
filter: brightness(0.9);
}
.action-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px;
background: var(--secondary-background-color, #f5f5f5);
border-radius: 4px;
font-size: 0.88rem;
}
.action-row.danger-row {
background: var(--error-color, #db4437);
color: white;
}
.action-row input[type="number"] {
width: 70px;
padding: 4px 6px;
font-size: 0.9rem;
border: 1px solid var(--divider-color, #ccc);
border-radius: 3px;
margin-left: 6px;
}
.action-row button {
padding: 4px 12px;
font-size: 0.85rem;
} }
.fire:hover { filter: brightness(0.9); }
.fire-feedback { .fire-feedback {
font-size: 0.85rem; color: var(--secondary-text-color, #666); font-size: 0.85rem; color: var(--secondary-text-color, #666);
} }

View File

@ -381,6 +381,128 @@ async def _ws_get_program(
}) })
@websocket_api.websocket_command(
{
vol.Required("type"): "omni_pca/programs/clear",
vol.Required("entry_id"): str,
vol.Required("slot"): vol.All(int, vol.Range(min=1, max=1500)),
}
)
@websocket_api.async_response
async def _ws_clear_program(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Erase a program slot by writing an all-zero 14-byte body.
Equivalent to "delete this program". v1 panels report
``not_supported`` because their wire protocol only allows bulk
rewrites (which would clear everything).
"""
coordinator = _coordinator_for_entry(hass, msg["entry_id"])
if coordinator is None:
connection.send_error(msg["id"], "not_found", "panel not configured")
return
try:
client = coordinator.client
except RuntimeError as err:
connection.send_error(msg["id"], "not_connected", str(err))
return
try:
await client.clear_program(msg["slot"])
except NotImplementedError as err:
connection.send_error(msg["id"], "not_supported", str(err))
return
except Exception as err:
connection.send_error(msg["id"], "clear_failed", str(err))
return
# Drop the entry from the coordinator's in-memory view so subsequent
# ``list`` calls reflect the deletion before the next poll catches up.
if coordinator.data is not None:
coordinator.data.programs.pop(msg["slot"], None)
connection.send_result(msg["id"], {"slot": msg["slot"], "cleared": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "omni_pca/programs/clone",
vol.Required("entry_id"): str,
vol.Required("source_slot"): vol.All(int, vol.Range(min=1, max=1500)),
vol.Required("target_slot"): vol.All(int, vol.Range(min=1, max=1500)),
}
)
@websocket_api.async_response
async def _ws_clone_program(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Copy ``source_slot``'s program into ``target_slot``.
Useful for "I want a slightly different version of this program"
user clones into an empty slot, then (eventually, when the editor
UI lands) tweaks the fields and saves.
Refuses to clone when source and target are the same slot or when
the source slot is empty / not defined.
"""
coordinator = _coordinator_for_entry(hass, msg["entry_id"])
if coordinator is None:
connection.send_error(msg["id"], "not_found", "panel not configured")
return
src = msg["source_slot"]
dst = msg["target_slot"]
if src == dst:
connection.send_error(
msg["id"], "invalid", "source and target slots must differ",
)
return
programs = coordinator.data.programs if coordinator.data else {}
source_program = programs.get(src)
if source_program is None or source_program.is_empty():
connection.send_error(
msg["id"], "not_found", f"no program at source slot {src}",
)
return
try:
client = coordinator.client
except RuntimeError as err:
connection.send_error(msg["id"], "not_connected", str(err))
return
# The Program dataclass carries the slot field; re-stamp it for the
# destination so the on-the-wire bytes are correctly addressed.
from omni_pca.programs import Program # local — avoid cycle
cloned = Program(
slot=dst,
prog_type=source_program.prog_type,
cond=source_program.cond,
cond2=source_program.cond2,
cmd=source_program.cmd,
par=source_program.par,
pr2=source_program.pr2,
month=source_program.month,
day=source_program.day,
days=source_program.days,
hour=source_program.hour,
minute=source_program.minute,
remark_id=source_program.remark_id,
)
try:
await client.download_program(dst, cloned)
except NotImplementedError as err:
connection.send_error(msg["id"], "not_supported", str(err))
return
except Exception as err:
connection.send_error(msg["id"], "clone_failed", str(err))
return
if coordinator.data is not None:
coordinator.data.programs[dst] = cloned
connection.send_result(
msg["id"], {"source_slot": src, "target_slot": dst, "cloned": True},
)
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "omni_pca/programs/fire", vol.Required("type"): "omni_pca/programs/fire",
@ -433,6 +555,8 @@ def async_register_websocket_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, _ws_list_programs) websocket_api.async_register_command(hass, _ws_list_programs)
websocket_api.async_register_command(hass, _ws_get_program) websocket_api.async_register_command(hass, _ws_get_program)
websocket_api.async_register_command(hass, _ws_fire_program) websocket_api.async_register_command(hass, _ws_fire_program)
websocket_api.async_register_command(hass, _ws_clear_program)
websocket_api.async_register_command(hass, _ws_clone_program)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------

File diff suppressed because one or more lines are too long

View File

@ -646,6 +646,53 @@ class OmniClient:
slot = (reply.payload[0] << 8) | reply.payload[1] slot = (reply.payload[0] << 8) | reply.payload[1]
yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot) yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot)
async def download_program(self, slot: int, program: "Program") -> None:
"""Write ``program`` into the panel at the given 1-based ``slot``.
Wire opcode: 8 (DownloadProgram) per clsOLMsg2DownloadProgram
(clsHAC.cs:1133-1140). Payload is the same 2-byte BE slot
number + 14-byte wire body the UploadProgram reply uses, so
``Program.encode_wire_bytes`` produces the right thing.
The panel responds with ``Ack`` on success; we raise
:class:`CommandFailedError` on ``Nak`` and
:class:`OmniConnectionError` for any other opcode.
Writing an all-zero body clears the slot (treats the slot as
``ProgramType.FREE``) matches the panel's behaviour for an
empty record.
"""
if not 1 <= slot <= 1500:
raise ValueError(f"program slot {slot} out of range 1..1500")
body = program.encode_wire_bytes()
if len(body) != 14:
raise ValueError(
f"encoded program body must be 14 bytes, got {len(body)}"
)
payload = bytes([(slot >> 8) & 0xFF, slot & 0xFF]) + body
reply = await self._conn.request(
OmniLink2MessageType.DownloadProgram, payload
)
if reply.opcode == int(OmniLink2MessageType.Nak):
raise CommandFailedError(
f"panel NAK'd DownloadProgram for slot {slot}"
)
if reply.opcode != int(OmniLink2MessageType.Ack):
raise OmniConnectionError(
f"unexpected opcode {reply.opcode} after DownloadProgram "
f"(expected {int(OmniLink2MessageType.Ack)})"
)
async def clear_program(self, slot: int) -> None:
"""Convenience: clear a program slot by writing an all-zero body.
On the panel this marks the slot as :class:`ProgramType.FREE`,
same as ``DownloadProgram(slot, all-zero)``.
"""
from .programs import Program, ProgramType
empty = Program(slot=slot, prog_type=int(ProgramType.FREE))
await self.download_program(slot, empty)
# ---- helpers (status) ----------------------------------------------- # ---- helpers (status) -----------------------------------------------
async def _fetch_status_range( async def _fetch_status_range(

View File

@ -849,8 +849,32 @@ class MockPanel:
return _build_ack(), () return _build_ack(), ()
if opcode == OmniLink2MessageType.UploadProgram: if opcode == OmniLink2MessageType.UploadProgram:
return self._reply_program_data(payload), () return self._reply_program_data(payload), ()
if opcode == OmniLink2MessageType.DownloadProgram:
return self._handle_download_program(payload), ()
return _build_nak(opcode), () return _build_nak(opcode), ()
def _handle_download_program(self, payload: bytes) -> Message:
"""Write the 14-byte program body at ``payload[2:16]`` to slot
``payload[0..1]`` (BE u16). Acks on success, NAKs on bad shape.
Mirrors :meth:`_reply_program_data` in reverse same wire
framing as the UploadProgram reply, just inbound. Writing an
all-zero body removes the slot from ``state.programs`` so
subsequent UploadProgram requests treat it as undefined
(matches real-panel behaviour for cleared slots).
"""
if len(payload) < 2 + 14:
return _build_nak(OmniLink2MessageType.DownloadProgram)
number = (payload[0] << 8) | payload[1]
if not 1 <= number <= 1500:
return _build_nak(OmniLink2MessageType.DownloadProgram)
body = bytes(payload[2 : 2 + 14])
if body == b"\x00" * 14:
self.state.programs.pop(number, None)
else:
self.state.programs[number] = body
return _build_ack()
def _reply_program_data(self, payload: bytes) -> Message: def _reply_program_data(self, payload: bytes) -> Message:
"""v2 program read — single-slot OR iterator. """v2 program read — single-slot OR iterator.

View File

@ -182,6 +182,13 @@ class OmniClientV1Adapter:
""" """
return self._client.iter_programs() return self._client.iter_programs()
async def download_program(self, slot: int, program) -> None:
"""v1 forwarder — raises NotImplementedError. See client.py."""
await self._client.download_program(slot, program)
async def clear_program(self, slot: int) -> None:
await self._client.clear_program(slot)
# ---- properties synthesis ------------------------------------------ # ---- properties synthesis ------------------------------------------
async def get_object_properties( async def get_object_properties(

View File

@ -241,6 +241,28 @@ class OmniClientV1:
slot = (reply.payload[0] << 8) | reply.payload[1] slot = (reply.payload[0] << 8) | reply.payload[1]
yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot) yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot)
async def download_program(self, slot: int, program) -> None:
"""v1 does not expose a single-slot DownloadProgram opcode.
On v1 the only way to change programs is the bulk
``DownloadPrograms`` flow (clsHAC.cs:171, clsOLMsgDownloadPrograms),
which clears the panel's entire program table and re-streams
every record. That's destructive for HA's "edit one program"
use case, so we surface a structured error instead of silently
falling back. Use a v2-capable panel for editing.
"""
raise NotImplementedError(
"v1 panels don't support single-slot program writes; "
"the DownloadPrograms flow clears all programs before "
"rewriting. Use a TCP-mode (v2) connection for editing."
)
async def clear_program(self, slot: int) -> None:
raise NotImplementedError(
"v1 panels don't support single-slot program clears; "
"see download_program for details."
)
# ---- write methods (Command + ExecuteSecurityCommand) ---------------- # ---- write methods (Command + ExecuteSecurityCommand) ----------------
# #
# The Command and ExecuteSecurityCommand payloads are byte-identical # The Command and ExecuteSecurityCommand payloads are byte-identical

View File

@ -241,6 +241,90 @@ async def test_ws_fire_program_executes_command(
assert response["result"] == {"slot": 42, "fired": True} assert response["result"] == {"slot": 42, "fired": True}
async def test_ws_clear_program_writes_zero_body(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Clear erases a slot end-to-end: ws command → DownloadProgram on
the wire mock state loses the slot coordinator drops it from
its in-memory map."""
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
assert 42 in coordinator.data.programs
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/clear",
"entry_id": configured_panel.entry_id,
"slot": 42,
})
response = await client.receive_json()
assert response["success"] is True
assert response["result"] == {"slot": 42, "cleared": True}
# The coordinator's view drops the slot immediately so a follow-up
# list reflects the deletion without waiting for the next poll.
assert 42 not in coordinator.data.programs
async def test_ws_clone_program_copies_to_empty_slot(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Cloning slot 12 to slot 500 lands a copy at the target with the
right fields and leaves the source untouched."""
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
assert 12 in coordinator.data.programs
assert 500 not in coordinator.data.programs
source = coordinator.data.programs[12]
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/clone",
"entry_id": configured_panel.entry_id,
"source_slot": 12,
"target_slot": 500,
})
response = await client.receive_json()
assert response["success"] is True
assert response["result"] == {
"source_slot": 12, "target_slot": 500, "cloned": True,
}
# New program landed at the target with re-stamped slot.
cloned = coordinator.data.programs[500]
assert cloned.slot == 500
assert cloned.prog_type == source.prog_type
assert cloned.cmd == source.cmd
assert cloned.pr2 == source.pr2
# Source remains.
assert 12 in coordinator.data.programs
async def test_ws_clone_program_rejects_same_slot(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/clone",
"entry_id": configured_panel.entry_id,
"source_slot": 12,
"target_slot": 12,
})
response = await client.receive_json()
assert response["success"] is False
assert response["error"]["code"] == "invalid"
async def test_ws_clone_program_rejects_missing_source(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""Cloning from a slot that has no program is a structured error."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/clone",
"entry_id": configured_panel.entry_id,
"source_slot": 999, # not seeded
"target_slot": 100,
})
response = await client.receive_json()
assert response["success"] is False
assert response["error"]["code"] == "not_found"
async def test_ws_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:

View File

@ -511,6 +511,131 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
assert p.prog_type in {1, 2, 3} # TIMED / EVENT / YEARLY from this fixture assert p.prog_type in {1, 2, 3} # TIMED / EVENT / YEARLY from this fixture
# ---- DownloadProgram writeback ------------------------------------------
@pytest.mark.asyncio
async def test_v2_download_program_writes_slot() -> None:
"""Writing a Program via DownloadProgram lands it in MockState; a
subsequent UploadProgram returns the same bytes proving the
full read-then-write-then-read loop works against the mock."""
from omni_pca.client import OmniClient
from omni_pca.commands import Command
target = Program(
slot=42, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=7,
hour=22, minute=30,
days=int(Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY),
)
panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState())
async with panel.serve(transport="tcp") as (host, port):
async with OmniClient(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0,
) as c:
# Slot 42 starts empty.
assert 42 not in panel.state.programs
await c.download_program(42, target)
# Now the mock's state should carry the wire bytes.
assert 42 in panel.state.programs
assert panel.state.programs[42] == target.encode_wire_bytes()
# And a read-back via iter_programs should yield the same program.
programs = [p async for p in c.iter_programs()]
assert len(programs) == 1
p = programs[0]
assert p.slot == 42
assert p.prog_type == int(ProgramType.TIMED)
assert p.cmd == int(Command.UNIT_ON)
assert p.pr2 == 7
assert p.hour == 22 and p.minute == 30
assert p.days == int(Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY)
@pytest.mark.asyncio
async def test_v2_download_program_overwrites_existing_slot() -> None:
"""Writing to a slot that already has a program replaces it."""
from omni_pca.client import OmniClient
from omni_pca.commands import Command
original = Program(
slot=10, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_OFF), pr2=1,
hour=6, minute=0, days=int(Days.MONDAY),
)
replacement = Program(
slot=10, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=99,
hour=22, minute=0, days=int(Days.SUNDAY),
)
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={10: original.encode_wire_bytes()}),
)
async with panel.serve(transport="tcp") as (host, port):
async with OmniClient(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0,
) as c:
await c.download_program(10, replacement)
assert panel.state.programs[10] == replacement.encode_wire_bytes()
@pytest.mark.asyncio
async def test_v2_clear_program_removes_slot() -> None:
"""``clear_program`` writes an all-zero body, which the mock treats
as deletion subsequent reads see the slot as undefined."""
from omni_pca.client import OmniClient
from omni_pca.commands import Command
seed = Program(
slot=5, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=1,
hour=6, minute=0, days=int(Days.MONDAY),
)
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={5: seed.encode_wire_bytes()}),
)
async with panel.serve(transport="tcp") as (host, port):
async with OmniClient(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0,
) as c:
await c.clear_program(5)
assert 5 not in panel.state.programs
@pytest.mark.asyncio
async def test_v2_download_program_rejects_out_of_range_slot() -> None:
"""Client-side range check catches bad slot before sending."""
from omni_pca.client import OmniClient
p = Program(slot=1, prog_type=int(ProgramType.TIMED))
panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState())
async with panel.serve(transport="tcp") as (host, port):
async with OmniClient(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0,
) as c:
with pytest.raises(ValueError, match="out of range"):
await c.download_program(0, p)
with pytest.raises(ValueError, match="out of range"):
await c.download_program(1501, p)
@pytest.mark.asyncio
async def test_v1_download_program_raises_not_implemented() -> None:
"""v1 has no single-slot write; the client raises a structured
NotImplementedError so HA can surface the limitation."""
from omni_pca.v1 import OmniClientV1
p = Program(slot=1, prog_type=int(ProgramType.TIMED))
panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState())
async with panel.serve(transport="udp") as (host, port):
async with OmniClientV1(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0,
) as c:
with pytest.raises(NotImplementedError, match="v1 panels"):
await c.download_program(1, p)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_v1_client_iter_programs_enumerates_all_seeded() -> None: async def test_v1_client_iter_programs_enumerates_all_seeded() -> None:
seeded = { seeded = {