clients: iter_programs() for both v1 and v2 wire dialects

v2 path adds an iterator over UploadProgram with request_reason=1
("next defined after slot"), mirroring the C# ReadConfig loop at
clsHAC.cs:4985 (seed call) and 5331 (per-reply re-issue). The mock
panel now honours reason=1: walks state.programs for the next
slot strictly greater than the requested one, returns EOD when none.

v1 path wraps OmniConnectionV1.iter_streaming(UploadPrograms) and
decodes each ProgramData reply into a Program. The panel already
streams in slot-ascending order from the previous commit, so the
client just decodes-and-yields.

Both methods return AsyncIterator[Program] for HA-side consumption.
Tests cover populated and empty states for both dialects, plus the
raw v2 reason=1 semantics on a single request.
This commit is contained in:
Ryan Malloy 2026-05-12 19:07:42 -06:00
parent 933d326dd3
commit 4ad20c9350
4 changed files with 219 additions and 3 deletions

View File

@ -607,6 +607,45 @@ class OmniClient:
""" """
await self.execute_command(Command.CLEAR_MESSAGE, parameter2=index) await self.execute_command(Command.CLEAR_MESSAGE, parameter2=index)
# ---- program enumeration --------------------------------------------
async def iter_programs(self) -> AsyncIterator["Program"]:
"""Stream every defined program from the panel.
v2 has no bulk "send all programs" opcode; instead the panel
exposes an iterator semantic via ``UploadProgram`` with
``request_reason=1`` ("next defined after this slot"). We seed
with slot 0 and follow each reply's ``ProgramNumber`` back into
the next request until the panel sends EOD.
Mirrors the C# ReadConfig loop at ``clsHAC.OL2ReadConfigProcessProgramData``
(clsHAC.cs:5323-5332) and the seed call at clsHAC.cs:4985.
Yields decoded :class:`omni_pca.programs.Program` instances, one
per defined slot in ascending slot order. Empty slots are
skipped by the panel the iterator only sees defined programs.
"""
from .programs import Program # local import: avoids cycle in __init__
slot = 0
while True:
payload = bytes([(slot >> 8) & 0xFF, slot & 0xFF, 1])
reply = await self._conn.request(
OmniLink2MessageType.UploadProgram, payload
)
if reply.opcode == int(OmniLink2MessageType.EOD):
return
if reply.opcode != int(OmniLink2MessageType.ProgramData):
raise OmniConnectionError(
f"unexpected opcode {reply.opcode} during UploadProgram iteration "
f"(expected {int(OmniLink2MessageType.ProgramData)})"
)
if len(reply.payload) < 2 + 14:
raise OmniConnectionError(
f"ProgramData payload too short ({len(reply.payload)} bytes)"
)
slot = (reply.payload[0] << 8) | reply.payload[1]
yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot)
# ---- helpers (status) ----------------------------------------------- # ---- helpers (status) -----------------------------------------------
async def _fetch_status_range( async def _fetch_status_range(

View File

@ -712,18 +712,37 @@ class MockPanel:
return _build_nak(opcode), () return _build_nak(opcode), ()
def _reply_program_data(self, payload: bytes) -> Message: def _reply_program_data(self, payload: bytes) -> Message:
"""Single-shot v2 program read. """v2 program read — single-slot OR iterator.
Request payload: ``[number_hi, number_lo, request_reason]`` (3 bytes Request payload: ``[number_hi, number_lo, request_reason]`` (3 bytes
per ``clsOL2MsgUploadProgram``). Reply payload: ``[number_hi, per ``clsOL2MsgUploadProgram``). Reply payload: ``[number_hi,
number_lo] + raw_14_byte_body`` per ``clsOL2MsgProgramData``. number_lo] + raw_14_byte_body`` per ``clsOL2MsgProgramData``.
If the slot is missing from ``state.programs`` we serve 14 zero ``request_reason`` semantics mirror the C# ReadConfig flow at
bytes same as a real panel reporting an empty slot. clsHAC.cs:4985 / 5331:
0 return the exact requested slot (zero body if undefined).
1 "next defined": return the lowest slot strictly greater
than the requested number. If none, return EOD. The
C# client iterates by feeding back each received slot
number with reason=1 until EOD.
Any other reason value is treated as reason=0 (we have no other
captures showing alternate semantics).
""" """
if len(payload) < 2: if len(payload) < 2:
return _build_nak(OmniLink2MessageType.UploadProgram) return _build_nak(OmniLink2MessageType.UploadProgram)
number = (payload[0] << 8) | payload[1] number = (payload[0] << 8) | payload[1]
reason = payload[2] if len(payload) >= 3 else 0
if reason == 1:
# "Next defined after this slot." If start_slot=0 (initial
# call) and no programs are defined, we fall straight to EOD.
next_slot = min(
(s for s in self.state.programs if s > number), default=None
)
if next_slot is None:
return encode_v2(OmniLink2MessageType.EOD, b"")
number = next_slot
body = self.state.programs.get(number, b"\x00" * 14) body = self.state.programs.get(number, b"\x00" * 14)
if len(body) != 14: if len(body) != 14:
return _build_nak(OmniLink2MessageType.UploadProgram) return _build_nak(OmniLink2MessageType.UploadProgram)

View File

@ -208,6 +208,39 @@ class OmniClientV1:
async def list_button_names(self) -> dict[int, str]: async def list_button_names(self) -> dict[int, str]:
return (await self.list_all_names()).get(int(NameType.BUTTON), {}) return (await self.list_all_names()).get(int(NameType.BUTTON), {})
# ---- programs (streaming UploadPrograms) -----------------------------
async def iter_programs(self) -> AsyncIterator["Program"]:
"""Stream every defined program from the panel.
v1 has no per-slot request a bare ``UploadPrograms`` triggers
the panel to dump every defined program in ascending slot order,
each as a separate ``ProgramData`` reply that we must
``Acknowledge`` to advance.
Reference: clsHAC.cs:4403 (bare UploadPrograms send), 4642-4651
(per-reply ack-walk), 4538-4540 (dispatch).
Yields decoded :class:`omni_pca.programs.Program` instances.
Empty slots are not transmitted the iterator only sees defined
programs.
"""
from ..programs import Program
async for reply in self._conn.iter_streaming(
OmniLinkMessageType.UploadPrograms
):
if reply.opcode != int(OmniLinkMessageType.ProgramData):
raise OmniProtocolError(
f"unexpected opcode {reply.opcode} during UploadPrograms stream "
f"(expected {int(OmniLinkMessageType.ProgramData)})"
)
if len(reply.payload) < 2 + 14:
raise OmniProtocolError(
f"ProgramData payload too short ({len(reply.payload)} bytes)"
)
slot = (reply.payload[0] << 8) | reply.payload[1]
yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot)
# ---- 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

@ -217,3 +217,128 @@ async def test_v1_upload_programs_empty_state_yields_immediate_eod() -> None:
) )
] ]
assert replies == [] assert replies == []
# ---- v2 iter_programs (reason=1 "next defined" iteration) ---------------
@pytest.mark.asyncio
async def test_v2_upload_program_reason1_returns_next_defined_slot() -> None:
"""``request_reason=1`` should return the lowest defined slot strictly
greater than the requested number the C# panel uses this to iterate
(clsHAC.cs:5331)."""
seeded = {
5: Program(slot=5, prog_type=int(ProgramType.TIMED), cmd=3),
12: Program(slot=12, prog_type=int(ProgramType.TIMED), cmd=3),
99: Program(slot=99, prog_type=int(ProgramType.EVENT), cmd=5),
}
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={s: p.encode_wire_bytes() for s, p in seeded.items()}),
)
async with (
panel.serve(transport="tcp") as (host, port),
OmniConnection(
host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0
) as conn,
):
# Seed slot 0 with reason=1 → first defined slot (5).
reply = await conn.request(
OmniLink2MessageType.UploadProgram, struct.pack(">HB", 0, 1)
)
assert reply.opcode == int(OmniLink2MessageType.ProgramData)
assert (reply.payload[0] << 8) | reply.payload[1] == 5
# From slot 5 with reason=1 → slot 12.
reply = await conn.request(
OmniLink2MessageType.UploadProgram, struct.pack(">HB", 5, 1)
)
assert (reply.payload[0] << 8) | reply.payload[1] == 12
# From slot 12 with reason=1 → slot 99.
reply = await conn.request(
OmniLink2MessageType.UploadProgram, struct.pack(">HB", 12, 1)
)
assert (reply.payload[0] << 8) | reply.payload[1] == 99
# From slot 99 with reason=1 → EOD (no more).
reply = await conn.request(
OmniLink2MessageType.UploadProgram, struct.pack(">HB", 99, 1)
)
assert reply.opcode == int(OmniLink2MessageType.EOD)
@pytest.mark.asyncio
async def test_v2_client_iter_programs_enumerates_all_seeded() -> None:
"""High-level OmniClient.iter_programs() drives the reason=1 iteration
and yields decoded Program records in slot-ascending order."""
from omni_pca.client import OmniClient
seeded = {
12: Program(slot=12, prog_type=int(ProgramType.TIMED), cmd=3, hour=6, minute=0,
days=int(Days.MONDAY | Days.FRIDAY)),
42: Program(slot=42, prog_type=int(ProgramType.TIMED), cond=0x8D09, cond2=0x9B09,
cmd=0x44, par=3, pr2=0x0100, month=8, day=12,
days=int(Days.MONDAY), hour=7, minute=15),
99: Program(slot=99, prog_type=int(ProgramType.EVENT), cmd=5, month=5, day=12),
}
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={s: p.encode_wire_bytes() for s, p in seeded.items()}),
)
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:
received = [p async for p in c.iter_programs()]
assert [p.slot for p in received] == [12, 42, 99]
for got, want in zip(received, seeded.values()):
assert got.prog_type == want.prog_type
assert got.cmd == want.cmd
assert got.hour == want.hour
assert got.minute == want.minute
@pytest.mark.asyncio
async def test_v2_client_iter_programs_empty_state_yields_nothing() -> None:
from omni_pca.client import OmniClient
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:
received = [p async for p in c.iter_programs()]
assert received == []
# ---- v1 client iter_programs (high-level wrapper over iter_streaming) ----
@pytest.mark.asyncio
async def test_v1_client_iter_programs_enumerates_all_seeded() -> None:
seeded = {
12: Program(slot=12, prog_type=int(ProgramType.TIMED), cmd=3, hour=6, minute=0,
days=int(Days.MONDAY | Days.FRIDAY)),
42: Program(slot=42, prog_type=int(ProgramType.TIMED), cond=0x8D09, cond2=0x9B09,
cmd=0x44, par=3, pr2=0x0100, month=8, day=12,
days=int(Days.MONDAY), hour=7, minute=15),
99: Program(slot=99, prog_type=int(ProgramType.EVENT), cmd=5, month=5, day=12),
}
panel = MockPanel(
controller_key=CONTROLLER_KEY,
state=MockState(programs={s: p.encode_wire_bytes() for s, p in seeded.items()}),
)
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:
received = [p async for p in c.iter_programs()]
assert [p.slot for p in received] == [12, 42, 99]
for got, want in zip(received, seeded.values()):
assert got.prog_type == want.prog_type
assert got.cmd == want.cmd
assert got.cond == want.cond
assert got.cond2 == want.cond2
assert got.hour == want.hour
assert got.minute == want.minute