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:
parent
933d326dd3
commit
4ad20c9350
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user