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 _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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
@ -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(
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user