Adds OmniLinkMessage (0x10) outer-packet handling to the mock so the
v1 path no longer requires a real panel for testing. Exercised over
UDP because OmniClientV1 is UDP-only by design, but the dispatcher
itself is transport-agnostic and the TCP _handle_client routes
OmniLinkMessage packets through the same _dispatch_v1 method too.
Coverage today:
* RequestSystemInformation (17) -> SystemInformation (18)
* RequestSystemStatus (19) -> SystemStatus (20), 8 area mode bytes
* RequestZoneStatus (21) -> ZoneStatus (22), short + long form
* RequestUnitStatus (23) -> UnitStatus (24), short + long form
(long form auto-selected for indices > 255)
* RequestThermostatStatus (30) -> ThermostatStatus (31)
* RequestAuxiliaryStatus (25) -> AuxiliaryStatus (26) (zero records)
* UploadNames (12) -> NameData (11) streaming, lock-step
Ack-driven across Zone/Unit/Button/
Area/Thermostat, terminated by EOD (3)
* Command (15) -> Ack (5) / Nak (6), reuses v2 state
mutator so light-on/off, set-level,
bypass-zone, restore-zone all work
* ExecuteSecurityCommand (102) -> Ack (5) / ExecuteSecurityCommandResponse
(103) on bad code, with structured
status byte preserved
* MessageCrcError -> v1 Nak (opcode 6)
The dispatcher writes replies wrapped in OmniLinkMessage (16) outer
packets (vs OmniLink2Message (32) used by v2) so OmniClientV1 routes
them correctly. The 4-step handshake is shared with v2 -- it's
protocol-version-agnostic at the outer-packet layer.
UploadNames state is panel-instance scoped via _upload_names_cursor
(int | None) -- there is only one active session at a time on the
mock so a single cursor suffices.
tests/test_e2e_v1_mock.py: 13 cases driving OmniClientV1 through the
mock's UDP socket, covering the full read API + UploadNames streaming
+ write methods + structured-failure path on a wrong security code.
Full suite: 400 passed, 1 skipped (was 387 / 1).
253 lines
9.6 KiB
Python
253 lines
9.6 KiB
Python
"""End-to-end: OmniClientV1 ↔ MockPanel speaking the v1 wire dialect.
|
|
|
|
Exercises the MockPanel's new ``_dispatch_v1`` path over UDP (which
|
|
is what ``OmniClientV1`` opens — see :class:`omni_pca.v1.connection.
|
|
OmniConnectionV1`). The packets travel ``127.0.0.1`` so there is no
|
|
real packet-loss risk; we still set a 2 s per-reply timeout to fail
|
|
fast if the dispatcher hangs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from omni_pca.commands import CommandFailedError
|
|
from omni_pca.mock_panel import (
|
|
MockAreaState,
|
|
MockButtonState,
|
|
MockPanel,
|
|
MockState,
|
|
MockThermostatState,
|
|
MockUnitState,
|
|
MockZoneState,
|
|
)
|
|
from omni_pca.models import SecurityMode
|
|
from omni_pca.v1 import NameType, OmniClientV1
|
|
|
|
CONTROLLER_KEY = bytes.fromhex("6ba7b4e9b4656de3cd7edd4c650cdb09")
|
|
|
|
|
|
def _populated_state() -> MockState:
|
|
return MockState(
|
|
zones={
|
|
1: MockZoneState(name="FRONT DOOR"),
|
|
2: MockZoneState(name="BACK DOOR"),
|
|
3: MockZoneState(name="LIVING MOT", current_state=1, loop=0xFD),
|
|
},
|
|
units={
|
|
1: MockUnitState(name="FRONT PORCH", state=1), # on
|
|
2: MockUnitState(name="LIVING LAMP", state=0x96), # 50% brightness
|
|
},
|
|
areas={1: MockAreaState(name="MAIN", mode=int(SecurityMode.OFF))},
|
|
thermostats={
|
|
1: MockThermostatState(
|
|
name="DOWNSTAIRS",
|
|
temperature_raw=170, heat_setpoint_raw=140,
|
|
cool_setpoint_raw=200, system_mode=1, fan_mode=0, hold_mode=0,
|
|
),
|
|
},
|
|
buttons={1: MockButtonState(name="GOOD MORNING")},
|
|
user_codes={1: 1234},
|
|
)
|
|
|
|
|
|
# ---- handshake + read API ------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_handshake_and_system_information() -> None:
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
|
|
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:
|
|
info = await c.get_system_information()
|
|
assert info.model_name == "Omni Pro II"
|
|
assert info.firmware_version == "2.12r1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_get_system_status_reports_areas() -> None:
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
|
|
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:
|
|
status = await c.get_system_status()
|
|
# Mock emits 8 area mode bytes (Omni Pro II cap).
|
|
assert len(status.area_alarms) == 8
|
|
# Each tuple is (mode, 0); area 1 was OFF (0).
|
|
assert status.area_alarms[0] == (0, 0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_zone_status_short_form() -> None:
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
|
|
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:
|
|
zones = await c.get_zone_status(1, 8)
|
|
assert len(zones) == 8
|
|
assert zones[1].is_secure
|
|
# Zone 3 has current_state=1 (NotReady -> open).
|
|
assert zones[3].is_open
|
|
assert zones[3].loop == 0xFD
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_unit_status_short_form() -> None:
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
|
|
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:
|
|
units = await c.get_unit_status(1, 4)
|
|
assert units[1].is_on
|
|
assert units[2].brightness == 50 # state=0x96 == 150 -> 50%
|
|
assert not units[3].is_on # undefined slot, defaults
|
|
assert not units[4].is_on
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_unit_status_long_form() -> None:
|
|
"""Force the BE-u16 wire form by including indices > 255."""
|
|
state = _populated_state()
|
|
state.units[300] = MockUnitState(name="SPRINKLER-Z3", state=1)
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
|
|
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:
|
|
units = await c.get_unit_status(298, 302)
|
|
assert len(units) == 5
|
|
assert units[300].is_on
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_thermostat_status() -> None:
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
|
|
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:
|
|
tstats = await c.get_thermostat_status(1, 1)
|
|
t = tstats[1]
|
|
assert t.temperature_raw == 170
|
|
assert t.heat_setpoint_raw == 140
|
|
assert t.cool_setpoint_raw == 200
|
|
assert t.system_mode == 1
|
|
assert t.fan_mode == 0
|
|
assert t.hold_mode == 0
|
|
|
|
|
|
# ---- UploadNames streaming ----------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_upload_names_streams_all_objects() -> None:
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
|
|
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:
|
|
all_names = await c.list_all_names()
|
|
|
|
# Expected: Zones 1-3, Units 1-2, Button 1, Area 1, Thermostat 1.
|
|
assert set(all_names.keys()) == {
|
|
int(NameType.ZONE),
|
|
int(NameType.UNIT),
|
|
int(NameType.BUTTON),
|
|
int(NameType.AREA),
|
|
int(NameType.THERMOSTAT),
|
|
}
|
|
assert all_names[int(NameType.ZONE)] == {
|
|
1: "FRONT DOOR", 2: "BACK DOOR", 3: "LIVING MOT",
|
|
}
|
|
assert all_names[int(NameType.UNIT)] == {
|
|
1: "FRONT PORCH", 2: "LIVING LAMP",
|
|
}
|
|
assert all_names[int(NameType.BUTTON)] == {1: "GOOD MORNING"}
|
|
assert all_names[int(NameType.AREA)] == {1: "MAIN"}
|
|
assert all_names[int(NameType.THERMOSTAT)] == {1: "DOWNSTAIRS"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_upload_names_empty_panel_returns_no_records() -> None:
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY)
|
|
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:
|
|
all_names = await c.list_all_names()
|
|
assert all_names == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_upload_names_two_byte_form_for_high_indices() -> None:
|
|
state = _populated_state()
|
|
state.units[300] = MockUnitState(name="Z-LANDSCAPE") # > 255
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
|
|
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:
|
|
all_names = await c.list_all_names()
|
|
assert all_names[int(NameType.UNIT)][300] == "Z-LANDSCAPE"
|
|
|
|
|
|
# ---- write methods ------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_turn_unit_on_mutates_mock_state() -> None:
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
|
|
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:
|
|
assert panel.state.units[2].state == 0x96 # 50%
|
|
await c.set_unit_level(2, 75)
|
|
assert panel.state.units[2].state == 100 + 75 # 175 = 75%
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_bypass_and_restore_zone() -> None:
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
|
|
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:
|
|
await c.bypass_zone(1, code=1)
|
|
assert panel.state.zones[1].is_bypassed
|
|
await c.restore_zone(1, code=1)
|
|
assert not panel.state.zones[1].is_bypassed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_execute_security_command_arm_away() -> None:
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
|
|
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:
|
|
await c.execute_security_command(
|
|
area=1, mode=SecurityMode.AWAY, code=1234
|
|
)
|
|
assert panel.state.areas[1].mode == int(SecurityMode.AWAY)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v1_execute_security_command_wrong_code() -> None:
|
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=_populated_state())
|
|
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:
|
|
with pytest.raises(CommandFailedError):
|
|
await c.execute_security_command(
|
|
area=1, mode=SecurityMode.AWAY, code=9999
|
|
)
|
|
# State unchanged after failed command.
|
|
assert panel.state.areas[1].mode == int(SecurityMode.OFF)
|