program writeback: DownloadProgram wire + HA write API + Clear/Clone UI
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:
parent
f38777e219
commit
9cdb312baf
@ -59,6 +59,10 @@ export class OmniPanelPrograms extends LitElement {
|
||||
@state() private _detail: ProgramDetail | null = null;
|
||||
@state() private _detailLoading = false;
|
||||
@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;
|
||||
|
||||
@ -185,6 +189,68 @@ export class OmniPanelPrograms extends LitElement {
|
||||
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 ----------------------------------------------------
|
||||
|
||||
private _startRefreshTimer(): void {
|
||||
@ -357,9 +423,67 @@ export class OmniPanelPrograms extends LitElement {
|
||||
class="fire"
|
||||
@click=${() => this._fireProgram(d.slot)}
|
||||
>▶ 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`
|
||||
<span class="fire-feedback">${this._fireFeedback}</span>` : ""}
|
||||
${this._writeFeedback ? html`
|
||||
<span class="fire-feedback">${this._writeFeedback}</span>` : ""}
|
||||
</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`
|
||||
<div class="chain-info">
|
||||
spans slots
|
||||
@ -579,16 +703,56 @@ export class OmniPanelPrograms extends LitElement {
|
||||
.detail footer {
|
||||
display: flex; align-items: center; gap: 12px; margin-top: 14px;
|
||||
}
|
||||
.fire {
|
||||
background: var(--primary-color, #03a9f4);
|
||||
color: var(--text-primary-color, #fff);
|
||||
.fire, .primary, .secondary, .danger {
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.92rem;
|
||||
border-radius: 4px;
|
||||
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 {
|
||||
font-size: 0.85rem; color: var(--secondary-text-color, #666);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
{
|
||||
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_get_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
@ -646,6 +646,53 @@ class OmniClient:
|
||||
slot = (reply.payload[0] << 8) | reply.payload[1]
|
||||
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) -----------------------------------------------
|
||||
|
||||
async def _fetch_status_range(
|
||||
|
||||
@ -849,8 +849,32 @@ class MockPanel:
|
||||
return _build_ack(), ()
|
||||
if opcode == OmniLink2MessageType.UploadProgram:
|
||||
return self._reply_program_data(payload), ()
|
||||
if opcode == OmniLink2MessageType.DownloadProgram:
|
||||
return self._handle_download_program(payload), ()
|
||||
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:
|
||||
"""v2 program read — single-slot OR iterator.
|
||||
|
||||
|
||||
@ -182,6 +182,13 @@ class OmniClientV1Adapter:
|
||||
"""
|
||||
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 ------------------------------------------
|
||||
|
||||
async def get_object_properties(
|
||||
|
||||
@ -241,6 +241,28 @@ class OmniClientV1:
|
||||
slot = (reply.payload[0] << 8) | reply.payload[1]
|
||||
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) ----------------
|
||||
#
|
||||
# The Command and ExecuteSecurityCommand payloads are byte-identical
|
||||
|
||||
@ -241,6 +241,90 @@ async def test_ws_fire_program_executes_command(
|
||||
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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# ---- 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
|
||||
async def test_v1_client_iter_programs_enumerates_all_seeded() -> None:
|
||||
seeded = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user