The HA coordinator walks ObjectType.THERMOSTAT (6) and ObjectType.BUTTON (3) via raw RequestProperties to discover them — the high-level get_object_properties() path only knows zones/units/areas in v1.0. The mock was returning Nak for both, which made HA discover zero thermostats and zero buttons no matter how MockState was seeded. src/omni_pca/mock_panel.py: - New MockButtonState dataclass (just a name) - MockState gains buttons: dict[int, MockButtonState] (with the same bare-string -> dataclass __post_init__ promotion as the others) - _OBJ_BUTTON = 3, _BUTTON_NAME_LEN = 12, _THERMOSTAT_NAME_LEN = 12 constants - thermostat_name_bytes() / button_name_bytes() helpers - _build_thermostat_properties() emits the 23-byte Properties body matching ThermostatProperties.parse offsets (object number BE u16, communicating flag, current temp, heat/cool setpoints, system/fan/ hold modes, thermostat type, 12-byte NUL-padded name) - _build_button_properties() emits the 15-byte body (object number BE u16 + 12-byte name) - _reply_properties / _object_store dispatch both new types tests/test_e2e_client_mock.py — two new e2e tests drive raw RequestProperties walks for thermostats and buttons against a seeded mock and assert ThermostatProperties / ButtonProperties parse cleanly, mirroring what the HA coordinator's _walk_properties() does. 333 tests pass (was 331); ruff clean. Mock surface now matches every opcode the HA coordinator and entity platforms actually call.
354 lines
13 KiB
Python
354 lines
13 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,
|
|
MockButtonState,
|
|
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_thermostat_properties_discovery() -> None:
|
|
"""The HA coordinator walks thermostats via raw RequestProperties; ensure
|
|
the mock answers and the response parses cleanly into ThermostatProperties.
|
|
"""
|
|
from omni_pca.models import ObjectType as ObjType
|
|
from omni_pca.models import ThermostatProperties
|
|
from omni_pca.opcodes import OmniLink2MessageType
|
|
|
|
state = MockState(
|
|
thermostats={
|
|
1: MockThermostatState(name="LIVING_ROOM"),
|
|
3: MockThermostatState(name="MASTER"),
|
|
}
|
|
)
|
|
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,
|
|
):
|
|
found: dict[int, str] = {}
|
|
cursor = 0
|
|
for _ in range(10):
|
|
payload = bytes(
|
|
[int(ObjType.THERMOSTAT), (cursor >> 8) & 0xFF, cursor & 0xFF, 1, 0, 0, 0]
|
|
)
|
|
reply = await cli.connection.request(
|
|
OmniLink2MessageType.RequestProperties, payload
|
|
)
|
|
if reply.opcode == int(OmniLink2MessageType.EOD):
|
|
break
|
|
t = ThermostatProperties.parse(reply.payload)
|
|
found[t.index] = t.name
|
|
cursor = t.index
|
|
assert found == {1: "LIVING_ROOM", 3: "MASTER"}
|
|
|
|
|
|
async def test_e2e_button_properties_discovery() -> None:
|
|
"""Same idea for button discovery (the HA coordinator drives this too)."""
|
|
from omni_pca.models import ButtonProperties
|
|
from omni_pca.models import ObjectType as ObjType
|
|
from omni_pca.opcodes import OmniLink2MessageType
|
|
|
|
state = MockState(
|
|
buttons={
|
|
1: MockButtonState(name="GOOD_MORNING"),
|
|
5: MockButtonState(name="MOVIE_MODE"),
|
|
}
|
|
)
|
|
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,
|
|
):
|
|
found: dict[int, str] = {}
|
|
cursor = 0
|
|
for _ in range(10):
|
|
payload = bytes(
|
|
[int(ObjType.BUTTON), (cursor >> 8) & 0xFF, cursor & 0xFF, 1, 0, 0, 0]
|
|
)
|
|
reply = await cli.connection.request(
|
|
OmniLink2MessageType.RequestProperties, payload
|
|
)
|
|
if reply.opcode == int(OmniLink2MessageType.EOD):
|
|
break
|
|
b = ButtonProperties.parse(reply.payload)
|
|
found[b.index] = b.name
|
|
cursor = b.index
|
|
assert found == {1: "GOOD_MORNING", 5: "MOVIE_MODE"}
|
|
|
|
|
|
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()
|