From 4ad20c935044465abbbf31b080fe5b5cefa14f8f Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 12 May 2026 19:07:42 -0600 Subject: [PATCH] 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. --- src/omni_pca/client.py | 39 ++++++++++ src/omni_pca/mock_panel.py | 25 ++++++- src/omni_pca/v1/client.py | 33 +++++++++ tests/test_e2e_program_echo.py | 125 +++++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 3 deletions(-) diff --git a/src/omni_pca/client.py b/src/omni_pca/client.py index 934da45..7cce095 100644 --- a/src/omni_pca/client.py +++ b/src/omni_pca/client.py @@ -607,6 +607,45 @@ class OmniClient: """ 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) ----------------------------------------------- async def _fetch_status_range( diff --git a/src/omni_pca/mock_panel.py b/src/omni_pca/mock_panel.py index 128fc95..9c9313a 100644 --- a/src/omni_pca/mock_panel.py +++ b/src/omni_pca/mock_panel.py @@ -712,18 +712,37 @@ class MockPanel: return _build_nak(opcode), () 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 per ``clsOL2MsgUploadProgram``). Reply payload: ``[number_hi, number_lo] + raw_14_byte_body`` per ``clsOL2MsgProgramData``. - If the slot is missing from ``state.programs`` we serve 14 zero - bytes — same as a real panel reporting an empty slot. + ``request_reason`` semantics mirror the C# ReadConfig flow at + 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: return _build_nak(OmniLink2MessageType.UploadProgram) 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) if len(body) != 14: return _build_nak(OmniLink2MessageType.UploadProgram) diff --git a/src/omni_pca/v1/client.py b/src/omni_pca/v1/client.py index ea1cfda..5ac8451 100644 --- a/src/omni_pca/v1/client.py +++ b/src/omni_pca/v1/client.py @@ -208,6 +208,39 @@ class OmniClientV1: async def list_button_names(self) -> dict[int, str]: 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) ---------------- # # The Command and ExecuteSecurityCommand payloads are byte-identical diff --git a/tests/test_e2e_program_echo.py b/tests/test_e2e_program_echo.py index 1e568bf..ca09680 100644 --- a/tests/test_e2e_program_echo.py +++ b/tests/test_e2e_program_echo.py @@ -217,3 +217,128 @@ async def test_v1_upload_programs_empty_state_yields_immediate_eod() -> None: ) ] 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