omni-pca/tests/test_e2e_client_mock.py
Ryan Malloy c26db62959 Library v1.0 phase C: stateful mock + e2e for the new surface
src/omni_pca/client.py — wire OmniClient.events() that returns an async
iterator over typed SystemEvent objects (built on events.EventStream).

src/omni_pca/mock_panel.py — substantial expansion:
- Per-object state dataclasses (MockUnitState, MockAreaState, MockZoneState,
  MockThermostatState) plus user_codes table for security validation
- Backward-compat: existing callers passing {idx: 'NAME'} strings still work
  via __post_init__ string-promotion to the matching Mock*State instance
- New opcode handlers:
    Command (20)                  -> Ack with state mutation, dispatches
                                     UNIT_ON/OFF/LEVEL, BYPASS/RESTORE_ZONE,
                                     SET_THERMOSTAT_HEAT/COOL/SYS/FAN/HOLD
    ExecuteSecurityCommand (74)   -> Ack on valid code (mode applied);
                                     Nak on invalid code
    RequestStatus (34)            -> Status (35) for Zone/Unit/Area/Thermostat
                                     hard-coded record sizes per
                                     clsOL2MsgStatus.cs:13-27
    RequestExtendedStatus (58)    -> ExtendedStatus (59) with object_length
                                     prefix, richer fields per object type
    AcknowledgeAlerts (60)        -> Ack
- Synthesized SystemEvents (55) push on state change with seq=0; events round-
  trip cleanly through events.parse_events() (validated by tests, not just
  asserted in code)

tests/test_e2e_client_mock.py — +9 e2e tests covering arm/disarm with code
validation, unit on/off/level, zone bypass/restore, thermostat setpoint,
push events for arming and unit changes, acknowledge_alerts.

203 passed (was 194), 2 skipped (HA harness + .pca fixture). Ruff clean.

Library v1.0 surface complete: read-only, command, status, extended status,
events. Next: rebuild the HA custom_component on top of this.
2026-05-10 14:28:35 -06:00

283 lines
10 KiB
Python

"""End-to-end: OmniClient drives a real MockPanel over a real TCP socket.
This is the integration smoke test that proves the protocol stack actually
roundtrips. Both sides built independently — if framing, sequence numbers,
session-key derivation, or per-block whitening disagree, the handshake fails.
"""
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 HandshakeError
from omni_pca.events import ArmingChanged, UnitStateChanged
from omni_pca.mock_panel import (
MockAreaState,
MockPanel,
MockState,
MockThermostatState,
MockUnitState,
MockZoneState,
)
from omni_pca.models import (
AreaProperties,
AreaStatus,
SecurityMode,
ThermostatStatus,
UnitProperties,
UnitStatus,
ZoneProperties,
ZoneStatus,
)
from omni_pca.models import (
ObjectType as ModelObjectType,
)
CONTROLLER_KEY = bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09")
@pytest.fixture
def seeded_state() -> MockState:
return MockState(
model_byte=16,
firmware_major=2,
firmware_minor=12,
firmware_revision=1,
zones={1: "FRONT DOOR", 2: "GARAGE ENTRY", 7: "MASTER BED MOT"},
units={1: "FRONT PORCH", 2: "STAIRS"},
areas={1: "Main", 2: "Guest"},
)
async def test_e2e_handshake_then_system_information(seeded_state: MockState) -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=seeded_state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
info = await cli.get_system_information()
assert info.model_byte == 16
assert info.model_name == "Omni Pro II"
assert info.firmware_version.startswith("2.12")
assert panel.session_count == 1
async def test_e2e_get_zone_properties(seeded_state: MockState) -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=seeded_state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
zone = await cli.get_object_properties(ObjectType.ZONE, 1)
assert isinstance(zone, ZoneProperties)
assert zone.index == 1
assert zone.name == "FRONT DOOR"
async def test_e2e_get_unit_properties(seeded_state: MockState) -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=seeded_state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
unit = await cli.get_object_properties(ObjectType.UNIT, 2)
assert isinstance(unit, UnitProperties)
assert unit.index == 2
assert unit.name == "STAIRS"
async def test_e2e_get_area_properties(seeded_state: MockState) -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=seeded_state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
area = await cli.get_object_properties(ObjectType.AREA, 1)
assert isinstance(area, AreaProperties)
assert area.index == 1
assert area.name == "Main"
async def test_e2e_list_zone_names(seeded_state: MockState) -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=seeded_state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
names = await cli.list_zone_names()
assert names == {1: "FRONT DOOR", 2: "GARAGE ENTRY", 7: "MASTER BED MOT"}
async def test_e2e_wrong_key_fails_with_handshake_error() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY)
wrong_key = secrets.token_bytes(16)
async with panel.serve() as (host, port):
# pytest.raises is sync; can't combine into the async with above.
with pytest.raises(HandshakeError):
async with OmniClient(host=host, port=port, controller_key=wrong_key) as cli:
await cli.get_system_information()
# --------------------------------------------------------------------------
# New surface: typed commands + status + event push
# --------------------------------------------------------------------------
def _state_with_area_and_codes() -> MockState:
"""Common fixture: one area with one valid user-code mapping."""
return MockState(
areas={1: MockAreaState(name="Main")},
user_codes={1: 1234},
)
async def test_e2e_arm_area() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_state_with_area_and_codes())
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
await cli.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=1234
)
statuses = await cli.get_object_status(ModelObjectType.AREA, 1)
assert len(statuses) == 1
area = statuses[0]
assert isinstance(area, AreaStatus)
assert area.index == 1
assert area.mode == int(SecurityMode.AWAY)
assert area.mode_name == "AWAY"
async def test_e2e_arm_with_wrong_code_raises() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_state_with_area_and_codes())
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
with pytest.raises(CommandFailedError):
await cli.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=9999
)
async def test_e2e_turn_unit_on_off() -> None:
state = MockState(units={1: MockUnitState(name="Lamp")})
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
await cli.turn_unit_on(1)
statuses = await cli.get_object_status(ModelObjectType.UNIT, 1)
assert len(statuses) == 1
unit = statuses[0]
assert isinstance(unit, UnitStatus)
assert unit.state == 1
assert unit.is_on is True
await cli.turn_unit_off(1)
statuses = await cli.get_object_status(ModelObjectType.UNIT, 1)
assert statuses[0].state == 0
assert statuses[0].is_on is False
async def test_e2e_set_unit_level() -> None:
state = MockState(units={1: MockUnitState(name="Dimmer")})
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
await cli.set_unit_level(1, 60)
statuses = await cli.get_extended_status(ModelObjectType.UNIT, 1)
assert len(statuses) == 1
unit = statuses[0]
assert isinstance(unit, UnitStatus)
# state byte 100..200 encodes brightness percent (state - 100).
assert unit.state == 160
assert unit.brightness == 60
async def test_e2e_bypass_restore_zone() -> None:
state = MockState(zones={1: MockZoneState(name="Front Door")})
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
# Initially not bypassed.
statuses = await cli.get_object_status(ModelObjectType.ZONE, 1)
assert isinstance(statuses[0], ZoneStatus)
assert statuses[0].is_bypassed is False
await cli.bypass_zone(1)
statuses = await cli.get_object_status(ModelObjectType.ZONE, 1)
assert statuses[0].is_bypassed is True
await cli.restore_zone(1)
statuses = await cli.get_object_status(ModelObjectType.ZONE, 1)
assert statuses[0].is_bypassed is False
async def test_e2e_set_thermostat_heat_setpoint() -> None:
state = MockState(thermostats={1: MockThermostatState(name="Living")})
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
await cli.set_thermostat_heat_setpoint_raw(1, 150)
statuses = await cli.get_extended_status(ModelObjectType.THERMOSTAT, 1)
assert len(statuses) == 1
tstat = statuses[0]
assert isinstance(tstat, ThermostatStatus)
assert tstat.heat_setpoint_raw == 150
async def test_e2e_arm_pushes_arming_changed_event() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_state_with_area_and_codes())
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
events = cli.events()
await cli.execute_security_command(
area=1, mode=SecurityMode.AWAY, code=1234
)
ev = await asyncio.wait_for(events.__anext__(), timeout=1.0)
assert isinstance(ev, ArmingChanged)
assert ev.area_index == 1
assert ev.new_mode == int(SecurityMode.AWAY)
assert ev.user_index == 1
async def test_e2e_unit_command_pushes_unit_state_changed_event() -> None:
state = MockState(units={1: MockUnitState(name="Lamp")})
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
events = cli.events()
await cli.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_e2e_acknowledge_alerts() -> None:
panel = MockPanel(controller_key=CONTROLLER_KEY)
async with (
panel.serve() as (host, port),
OmniClient(host=host, port=port, controller_key=CONTROLLER_KEY) as cli,
):
# Should complete without raising.
await cli.acknowledge_alerts()