omni-pca/tests/test_e2e_udp.py
Ryan Malloy 7f82dbbbfa UDP transport: parallel codepath in OmniConnection + MockPanel
The C# decompile shows enuOmniLinkConnectionType has both Network_TCP=4
and Network_UDP=3 (clsOmniLinkConnection.cs uses udpSend/tcpSend
parallel paths), and clsHAC carries an enuPreferredNetworkProtocol
{TCP, UDP} per-installation byte. User reports their panel is
configured for UDP. The TCP-only assumption was too narrow.

Wire format is identical: same Packet/Message framing, same handshake,
same per-block whitening, same opcodes, same port. Only differences:
* UDP is connectionless; each datagram = one Packet (no stream framing)
* UDP needs explicit retry-on-timeout for reliability

src/omni_pca/connection.py:
- New constructor args: transport: Literal['tcp','udp']='tcp',
  udp_retry_count: int = 3
- connect()/close() branch on transport — TCP keeps the existing
  asyncio.open_connection + StreamReader/Writer + reader_task path;
  UDP uses asyncio.get_running_loop().create_datagram_endpoint with
  remote_addr= so transport.sendto(data) works without per-datagram
  addrs. The reader_task is TCP-only.
- _write_packet branches between writer.write and udp_transport.sendto
- request() loops up to (1 + udp_retry_count) attempts on UDP, retrying
  on RequestTimeoutError; TCP gets a single attempt (existing behavior)
- New _OmniDatagramProtocol that decodes each datagram into a Packet
  and delegates to the shared _dispatch (which already knows how to
  route handshake / solicited / unsolicited)

src/omni_pca/mock_panel.py:
- serve(transport='tcp'|'udp') public arg; defaults preserve existing
  TCP behavior. Internally splits into _serve_tcp / _serve_udp.
- New _MockServerDatagramProtocol that mirrors _handle_client for UDP.
  Tracks one active client by addr (single-session, matches Omni's
  single-client constraint). Reuses the panel's existing _dispatch_v2,
  _reply_*, _build_* helpers — the dispatch logic is unchanged, only
  the transport framing differs.
- New _schedule_udp_push for synthesized SystemEvents (seq=0) push
  to the active client's addr after state mutations.

src/omni_pca/client.py:
- OmniClient gains transport= and udp_retry_count= kwargs that pass
  through to OmniConnection. Default is 'tcp' so existing callers
  are unaffected.

tests/test_e2e_udp.py — 6 e2e tests:
- handshake roundtrip
- get_system_information
- arm area with right code
- arm with wrong code -> CommandFailedError
- turn unit on -> push UnitStateChanged event
- wrong ControllerKey -> HandshakeError

All run under 0.2s. Combined with the existing TCP suite: 357 tests
pass (was 351), ruff clean across src/ tests/.

The HA integration's config_flow still defaults to TCP; users on UDP
panels can manually set transport= via the OmniClient init path. A
follow-up commit will add transport to the HA config flow as a
dropdown option.
2026-05-10 20:42:43 -06:00

153 lines
4.8 KiB
Python

"""End-to-end: OmniClient ↔ MockPanel over UDP.
Mirrors test_e2e_client_mock.py but with ``transport='udp'`` on both
sides. The protocol/encryption/handshake bytes are identical to TCP;
this proves only the transport layer change is sound.
"""
from __future__ import annotations
import asyncio
import secrets
import pytest
from omni_pca.client import ObjectType, OmniClient
from omni_pca.commands import CommandFailedError
from omni_pca.connection import ConnectionState, HandshakeError, OmniConnection
from omni_pca.events import UnitStateChanged
from omni_pca.mock_panel import (
MockAreaState,
MockButtonState,
MockPanel,
MockState,
MockThermostatState,
MockUnitState,
MockZoneState,
)
from omni_pca.models import (
AreaStatus,
SecurityMode,
)
from omni_pca.opcodes import OmniLink2MessageType
CONTROLLER_KEY = bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09")
def _populated_state() -> MockState:
return MockState(
zones={1: MockZoneState(name="FRONT_DOOR")},
units={1: MockUnitState(name="LIVING_LAMP")},
areas={1: MockAreaState(name="MAIN")},
thermostats={1: MockThermostatState(name="LIVING")},
buttons={1: MockButtonState(name="GOOD_MORNING")},
user_codes={1: 1234},
)
async def test_udp_handshake_roundtrip() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with (
panel.serve(transport="udp") as (host, port),
OmniConnection(
host=host,
port=port,
controller_key=CONTROLLER_KEY,
transport="udp",
timeout=2.0,
) as conn,
):
assert conn.state is ConnectionState.ONLINE
assert panel.session_count == 1
async def test_udp_get_system_information() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with (
panel.serve(transport="udp") as (host, port),
OmniConnection(
host=host,
port=port,
controller_key=CONTROLLER_KEY,
transport="udp",
timeout=2.0,
) as conn,
):
reply = await conn.request(OmniLink2MessageType.RequestSystemInformation)
assert reply.opcode == int(OmniLink2MessageType.SystemInformation)
# First payload byte is the model byte.
assert reply.payload[0] == 16 # OMNI_PRO_II
async def test_udp_arm_area_with_correct_code() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with (
panel.serve(transport="udp") as (host, port),
OmniClient(
host=host,
port=port,
controller_key=CONTROLLER_KEY,
transport="udp",
timeout=2.0,
) as client,
):
await client.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=1234,
)
statuses = await client.get_object_status(ObjectType.AREA, 1)
assert len(statuses) == 1
area = statuses[0]
assert isinstance(area, AreaStatus)
assert area.mode == int(SecurityMode.AWAY)
async def test_udp_arm_with_wrong_code_raises() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with panel.serve(transport="udp") as (host, port):
with pytest.raises(CommandFailedError):
async with OmniClient(
host=host,
port=port,
controller_key=CONTROLLER_KEY,
transport="udp",
timeout=2.0,
) as client:
await client.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=9999,
)
async def test_udp_unit_on_pushes_state_changed_event() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
async with (
panel.serve(transport="udp") as (host, port),
OmniClient(
host=host,
port=port,
controller_key=CONTROLLER_KEY,
transport="udp",
timeout=2.0,
) as client,
):
events = client.events()
await client.turn_unit_on(1)
ev = await asyncio.wait_for(events.__anext__(), timeout=1.0)
assert isinstance(ev, UnitStateChanged)
assert ev.unit_index == 1
assert ev.is_on is True
async def test_udp_wrong_key_fails_handshake() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY)
wrong_key = secrets.token_bytes(16)
async with panel.serve(transport="udp") as (host, port):
with pytest.raises(HandshakeError):
async with OmniConnection(
host=host,
port=port,
controller_key=wrong_key,
transport="udp",
timeout=2.0,
):
pass