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)
|
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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user