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.
This commit is contained in:
parent
68cf44a585
commit
c26db62959
@ -17,12 +17,15 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import struct
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
||||
from enum import IntEnum
|
||||
from types import TracebackType
|
||||
from typing import Self
|
||||
from typing import TYPE_CHECKING, Self
|
||||
|
||||
from .commands import Command, CommandFailedError, SecurityCommandResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .events import SystemEvent
|
||||
from .connection import (
|
||||
ConnectionError as OmniConnectionError,
|
||||
)
|
||||
@ -715,6 +718,27 @@ class OmniClient:
|
||||
_runner(), name="omni-client-subscriber"
|
||||
)
|
||||
|
||||
def events(self) -> AsyncIterator[SystemEvent]:
|
||||
"""Async iterator over typed :class:`SystemEvent` push notifications.
|
||||
|
||||
Built on top of :meth:`OmniConnection.unsolicited` and
|
||||
:class:`omni_pca.events.EventStream`. Filters out non-SystemEvents
|
||||
unsolicited messages, parses each SystemEvents (opcode 55) message
|
||||
into one or more typed events, and yields them one at a time.
|
||||
|
||||
Usage::
|
||||
|
||||
async for event in client.events():
|
||||
match event:
|
||||
case ZoneStateChanged() if event.is_open:
|
||||
...
|
||||
case ArmingChanged():
|
||||
...
|
||||
"""
|
||||
from .events import EventStream
|
||||
|
||||
return EventStream(self._conn).__aiter__()
|
||||
|
||||
# ---- helpers ---------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -11,7 +11,13 @@ Coverage today:
|
||||
* Full secure-session handshake (NewSession / SecureSession ack pair)
|
||||
* ``RequestSystemInformation`` (22) -> ``SystemInformation`` (23)
|
||||
* ``RequestSystemStatus`` (24) -> ``SystemStatus`` (25)
|
||||
* ``RequestProperties`` (32) -> ``Properties`` (33) for Zone + Unit
|
||||
* ``RequestProperties`` (32) -> ``Properties`` (33) for Zone + Unit + Area
|
||||
* ``Command`` (20) -> ``Ack`` (1) / ``Nak`` (2), with state mutation
|
||||
* ``ExecuteSecurityCommand`` (74) -> ``Ack`` (1) (or Nak on bad code), with state
|
||||
* ``RequestStatus`` (34) -> ``Status`` (35) for Zone/Unit/Area/Thermostat
|
||||
* ``RequestExtendedStatus`` (58) -> ``ExtendedStatus`` (59) for the same set
|
||||
* ``AcknowledgeAlerts`` (60) -> ``Ack`` (1)
|
||||
* Synthesized push of ``SystemEvents`` (55, seq=0) when state mutates
|
||||
* Any other v2 opcode -> ``Nak`` (2) with the request's opcode
|
||||
* CRC failures on the inner message -> ``Nak``
|
||||
* Graceful ``ClientSessionTerminated`` close
|
||||
@ -21,6 +27,10 @@ References:
|
||||
clsOmniLinkConnection.cs:1688-1921 (TCP listener / ack flow)
|
||||
clsOL2MsgSystemInformation.cs / clsOL2MsgSystemStatus.cs
|
||||
clsOL2MsgRequestProperties.cs / clsOL2MsgProperties.cs
|
||||
clsOL2MsgCommand.cs / clsOL2MsgExecuteSecurityCommand.cs
|
||||
clsOL2MsgRequestStatus.cs / clsOL2MsgStatus.cs
|
||||
clsOL2MsgRequestExtendedStatus.cs / clsOL2MsgExtendedStatus.cs
|
||||
clsOLMsgSystemEvents.cs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -33,6 +43,7 @@ from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .commands import Command
|
||||
from .crypto import (
|
||||
BLOCK_SIZE,
|
||||
decrypt_message_payload,
|
||||
@ -49,6 +60,7 @@ _log = logging.getLogger(__name__)
|
||||
_OBJ_ZONE = 1
|
||||
_OBJ_UNIT = 2
|
||||
_OBJ_AREA = 5
|
||||
_OBJ_THERMOSTAT = 6
|
||||
|
||||
# Inner-message size constants (model OMNI_PRO_II)
|
||||
_ZONE_NAME_LEN = 15
|
||||
@ -56,6 +68,26 @@ _UNIT_NAME_LEN = 12
|
||||
_AREA_NAME_LEN = 12
|
||||
_PHONE_LEN = 24
|
||||
|
||||
# Per-object-type record sizes for the basic Status (opcode 35) reply.
|
||||
# Source: clsOL2MsgStatus.cs:13-27 — sizes hard-coded per object type, no
|
||||
# per-record length byte.
|
||||
_STATUS_RECORD_SIZES: dict[int, int] = {
|
||||
_OBJ_ZONE: 4, # number(2) + status + loop
|
||||
_OBJ_UNIT: 5, # number(2) + state + time(2)
|
||||
_OBJ_AREA: 6, # number(2) + mode + alarms + entry + exit
|
||||
_OBJ_THERMOSTAT: 9, # number(2) + status + 6 bytes
|
||||
}
|
||||
|
||||
# Per-object-type ExtendedStatus (opcode 59) record sizes. The reply carries
|
||||
# this byte at payload[1] (object_length); we use these to build the reply.
|
||||
# Source: clsOL2MsgExtendedStatus.cs (per-object body offsets).
|
||||
_EXTENDED_STATUS_RECORD_SIZES: dict[int, int] = {
|
||||
_OBJ_ZONE: 4, # number(2) + status + loop
|
||||
_OBJ_UNIT: 5, # number(2) + state + time(2) — ZigBeePower optional
|
||||
_OBJ_AREA: 6, # number(2) + mode + alarms + entry + exit
|
||||
_OBJ_THERMOSTAT: 14, # number(2) + status + temp + heat + cool + sys + fan + hold + humidity + h_set + dh_set + outdoor + horc
|
||||
}
|
||||
|
||||
# Wire format for the controller-side ack of NewSession is two literal
|
||||
# protocol-version bytes followed by the 5-byte SessionID.
|
||||
_PROTO_HI = 0x00
|
||||
@ -63,10 +95,94 @@ _PROTO_LO = 0x01
|
||||
|
||||
_SESSION_ID_BYTES = 5
|
||||
|
||||
# Small delay before pushing a synthesized SystemEvents so the request future
|
||||
# resolves first. Kept tiny; tests use asyncio.wait_for with their own timeout.
|
||||
_PUSH_DELAY = 0.005
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-object state dataclasses
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockUnitState:
|
||||
"""One programmable unit (light / output / scene)."""
|
||||
|
||||
name: str = ""
|
||||
state: int = 0 # 0=off, 1=on, 100..200=brightness percent (raw Omni)
|
||||
time_remaining: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockAreaState:
|
||||
"""One programmable security area."""
|
||||
|
||||
name: str = ""
|
||||
mode: int = 0 # SecurityMode value (Off=0, Day=1, Night=2, Away=3, ...)
|
||||
last_user: int = 0
|
||||
entry_timer: int = 0
|
||||
exit_timer: int = 0
|
||||
alarms: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockZoneState:
|
||||
"""One programmable security zone."""
|
||||
|
||||
name: str = ""
|
||||
current_state: int = 0 # 0=secure, 1=not-ready, 2=trouble, 3=tamper
|
||||
latched_state: int = 0 # 0=secure, 4=tripped, 8=reset (raw bits 2-3)
|
||||
arming_state: int = 0 # 0=disarmed, 16=armed, 32=bypassed, 48=auto-bypassed
|
||||
is_bypassed: bool = False
|
||||
loop: int = 0 # analog loop reading
|
||||
|
||||
@property
|
||||
def status_byte(self) -> int:
|
||||
"""Compose the on-the-wire status byte from the sub-fields.
|
||||
|
||||
Encoding mirrors clsZone.cs:385 / clsText.cs:3110:
|
||||
bits 0-1 → current_state (0..3)
|
||||
bits 2-3 → latched_state (0/4/8)
|
||||
bits 4-5 → arming_state (0/16/32/48)
|
||||
is_bypassed forces the arming bits to BYPASSED (0x20) regardless of
|
||||
the underlying arming_state value.
|
||||
"""
|
||||
val = (self.current_state & 0x03) | (self.latched_state & 0x0C)
|
||||
if self.is_bypassed:
|
||||
val |= 0x20
|
||||
else:
|
||||
val |= self.arming_state & 0x30
|
||||
return val & 0xFF
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockThermostatState:
|
||||
"""One programmable thermostat. Defaults are sane Omni Pro II values."""
|
||||
|
||||
name: str = ""
|
||||
temperature_raw: int = 168 # ~76°F on Omni linear scale
|
||||
heat_setpoint_raw: int = 144 # ~62°F
|
||||
cool_setpoint_raw: int = 184 # ~80°F
|
||||
system_mode: int = 0 # HvacMode: 0=Off, 1=Heat, 2=Cool, 3=Auto, 4=EmHeat
|
||||
fan_mode: int = 0 # FanMode: 0=Auto, 1=On, 2=Cycle
|
||||
hold_mode: int = 0 # HoldMode: 0=Off, 1=Hold, 2=Vacation
|
||||
humidity_raw: int = 0
|
||||
humidify_setpoint_raw: int = 0
|
||||
dehumidify_setpoint_raw: int = 0
|
||||
outdoor_temperature_raw: int = 0
|
||||
horc_status: int = 0
|
||||
status: int = 1 # 1 = communicating with the panel
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockState:
|
||||
"""Programmable panel state. Defaults mimic an Omni Pro II out of the box."""
|
||||
"""Programmable panel state. Defaults mimic an Omni Pro II out of the box.
|
||||
|
||||
Backward compat: callers may pass ``zones={1: "FRONT DOOR"}`` (a plain
|
||||
``dict[int, str]``) and the constructor will auto-promote the strings
|
||||
into the appropriate ``Mock*State`` instance.
|
||||
"""
|
||||
|
||||
model_byte: int = 16 # OMNI_PRO_II
|
||||
firmware_major: int = 2
|
||||
@ -74,10 +190,18 @@ class MockState:
|
||||
firmware_revision: int = 1
|
||||
local_phone: str = ""
|
||||
|
||||
# Names by 1-based index (matches Omni's user-facing numbering).
|
||||
zones: dict[int, str] = field(default_factory=dict)
|
||||
units: dict[int, str] = field(default_factory=dict)
|
||||
areas: dict[int, str] = field(default_factory=dict)
|
||||
# Per-object state machines, by 1-based index. Values may be passed as
|
||||
# plain strings (interpreted as the object's name) or as the matching
|
||||
# ``Mock*State`` dataclass instance.
|
||||
zones: dict[int, MockZoneState] = field(default_factory=dict)
|
||||
units: dict[int, MockUnitState] = field(default_factory=dict)
|
||||
areas: dict[int, MockAreaState] = field(default_factory=dict)
|
||||
thermostats: dict[int, MockThermostatState] = field(default_factory=dict)
|
||||
|
||||
# User-code table for ExecuteSecurityCommand validation.
|
||||
# Mapping is ``{code_index: 4-digit pin}``; the panel returns the
|
||||
# matched code_index in the area's last_user field on success.
|
||||
user_codes: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
# SystemStatus snapshot. Defaults: time set, battery good, no alarms.
|
||||
time_set: bool = True
|
||||
@ -95,14 +219,50 @@ class MockState:
|
||||
sunset_minute: int = 45
|
||||
battery: int = 200 # 0-255 — typical "good" value
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Promote bare-string values into per-type state dataclasses.
|
||||
|
||||
This keeps the existing ``MockState(zones={1: "FRONT DOOR"})``
|
||||
call sites working unchanged, while letting new code pass full
|
||||
``MockZoneState`` / ``MockUnitState`` / etc. records.
|
||||
"""
|
||||
self.zones = _promote_dict(self.zones, MockZoneState)
|
||||
self.units = _promote_dict(self.units, MockUnitState)
|
||||
self.areas = _promote_dict(self.areas, MockAreaState)
|
||||
self.thermostats = _promote_dict(self.thermostats, MockThermostatState)
|
||||
|
||||
# ---- name-bytes helpers (kept for back-compat with old callers) -----
|
||||
|
||||
def zone_name_bytes(self, idx: int) -> bytes:
|
||||
return _name_bytes(self.zones.get(idx, ""), _ZONE_NAME_LEN)
|
||||
z = self.zones.get(idx)
|
||||
return _name_bytes(z.name if z else "", _ZONE_NAME_LEN)
|
||||
|
||||
def unit_name_bytes(self, idx: int) -> bytes:
|
||||
return _name_bytes(self.units.get(idx, ""), _UNIT_NAME_LEN)
|
||||
u = self.units.get(idx)
|
||||
return _name_bytes(u.name if u else "", _UNIT_NAME_LEN)
|
||||
|
||||
def area_name_bytes(self, idx: int) -> bytes:
|
||||
return _name_bytes(self.areas.get(idx, ""), _AREA_NAME_LEN)
|
||||
a = self.areas.get(idx)
|
||||
return _name_bytes(a.name if a else "", _AREA_NAME_LEN)
|
||||
|
||||
|
||||
def _promote_dict(
|
||||
raw: dict[int, object],
|
||||
dataclass_cls: type,
|
||||
) -> dict[int, object]:
|
||||
"""Walk a ``{int: str | DataclassInstance}`` dict, wrapping bare strings.
|
||||
|
||||
Bare strings become ``dataclass_cls(name=string)``. Anything that is
|
||||
already an instance of ``dataclass_cls`` (or anything else) passes
|
||||
through untouched.
|
||||
"""
|
||||
out: dict[int, object] = {}
|
||||
for k, v in raw.items():
|
||||
if isinstance(v, str):
|
||||
out[k] = dataclass_cls(name=v)
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _name_bytes(name: str, width: int) -> bytes:
|
||||
@ -133,6 +293,11 @@ class MockPanel:
|
||||
self._session_count = 0
|
||||
self._last_request_opcode: int | None = None
|
||||
self._busy = asyncio.Lock() # serialise concurrent connection attempts
|
||||
# Per-connection state captured on _handle_client; used by the
|
||||
# synthesized-event push helper when state mutates.
|
||||
self._active_writer: asyncio.StreamWriter | None = None
|
||||
self._active_session_key: bytes | None = None
|
||||
self._push_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
# -------- public observables (handy in tests) --------
|
||||
|
||||
@ -161,6 +326,12 @@ class MockPanel:
|
||||
async with server:
|
||||
yield bound_host, bound_port
|
||||
finally:
|
||||
# Cancel any in-flight push tasks so the test event loop
|
||||
# tears down cleanly.
|
||||
for t in list(self._push_tasks):
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
self._push_tasks.clear()
|
||||
server.close()
|
||||
with contextlib.suppress(Exception): # pragma: no cover
|
||||
await server.wait_closed()
|
||||
@ -201,6 +372,9 @@ class MockPanel:
|
||||
)
|
||||
if not handled:
|
||||
break
|
||||
# Make session info available to push helpers.
|
||||
self._active_writer = writer
|
||||
self._active_session_key = session_key
|
||||
|
||||
elif pkt_type is PacketType.ClientSessionTerminated:
|
||||
_log.debug("mock panel: client requested teardown")
|
||||
@ -222,6 +396,8 @@ class MockPanel:
|
||||
except (asyncio.IncompleteReadError, ConnectionError):
|
||||
_log.debug("mock panel: client connection ended unexpectedly")
|
||||
finally:
|
||||
self._active_writer = None
|
||||
self._active_session_key = None
|
||||
writer.close()
|
||||
with contextlib.suppress(Exception): # pragma: no cover
|
||||
await writer.wait_closed()
|
||||
@ -335,18 +511,38 @@ class MockPanel:
|
||||
_log.debug("mock panel: dispatch opcode=%s payload=%d bytes",
|
||||
opcode_name, len(inner.payload))
|
||||
|
||||
reply = self._dispatch_v2(opcode, inner.payload)
|
||||
reply, push_words = self._dispatch_v2(opcode, inner.payload)
|
||||
await self._send_v2_reply(client_seq, reply, session_key, writer)
|
||||
if push_words:
|
||||
self._schedule_event_push(push_words, session_key, writer)
|
||||
return True
|
||||
|
||||
def _dispatch_v2(self, opcode: int, payload: bytes) -> Message:
|
||||
def _dispatch_v2(
|
||||
self, opcode: int, payload: bytes
|
||||
) -> tuple[Message, tuple[int, ...]]:
|
||||
"""Dispatch a single decoded request and return (reply, push_event_words).
|
||||
|
||||
``push_event_words`` is a (possibly empty) tuple of 16-bit event
|
||||
words to push as an unsolicited SystemEvents (opcode 55) frame
|
||||
AFTER the synchronous reply has been written.
|
||||
"""
|
||||
if opcode == OmniLink2MessageType.RequestSystemInformation:
|
||||
return self._reply_system_information()
|
||||
return self._reply_system_information(), ()
|
||||
if opcode == OmniLink2MessageType.RequestSystemStatus:
|
||||
return self._reply_system_status()
|
||||
return self._reply_system_status(), ()
|
||||
if opcode == OmniLink2MessageType.RequestProperties:
|
||||
return self._reply_properties(payload)
|
||||
return _build_nak(opcode)
|
||||
return self._reply_properties(payload), ()
|
||||
if opcode == OmniLink2MessageType.Command:
|
||||
return self._handle_command(payload)
|
||||
if opcode == OmniLink2MessageType.ExecuteSecurityCommand:
|
||||
return self._handle_execute_security_command(payload)
|
||||
if opcode == OmniLink2MessageType.RequestStatus:
|
||||
return self._reply_status(payload), ()
|
||||
if opcode == OmniLink2MessageType.RequestExtendedStatus:
|
||||
return self._reply_extended_status(payload), ()
|
||||
if opcode == OmniLink2MessageType.AcknowledgeAlerts:
|
||||
return _build_ack(), ()
|
||||
return _build_nak(opcode), ()
|
||||
|
||||
# -------- reply builders (byte-exact per clsOL2Msg*.cs) --------
|
||||
|
||||
@ -424,13 +620,15 @@ class MockPanel:
|
||||
return self._build_area_properties(target)
|
||||
return _build_nak(OmniLink2MessageType.RequestProperties)
|
||||
|
||||
def _object_store(self, obj_type: int) -> dict[int, str] | None:
|
||||
def _object_store(self, obj_type: int) -> dict[int, object] | None:
|
||||
if obj_type == _OBJ_ZONE:
|
||||
return self.state.zones
|
||||
return self.state.zones # type: ignore[return-value]
|
||||
if obj_type == _OBJ_UNIT:
|
||||
return self.state.units
|
||||
return self.state.units # type: ignore[return-value]
|
||||
if obj_type == _OBJ_AREA:
|
||||
return self.state.areas
|
||||
return self.state.areas # type: ignore[return-value]
|
||||
if obj_type == _OBJ_THERMOSTAT:
|
||||
return self.state.thermostats # type: ignore[return-value]
|
||||
return None
|
||||
|
||||
def _build_zone_properties(self, index: int) -> Message:
|
||||
@ -439,14 +637,15 @@ class MockPanel:
|
||||
# [4]=Status, [5]=Loop, [6]=Type, [7]=Area, [8]=Options,
|
||||
# [9..23]=Name (15 bytes)
|
||||
# encode_v2 prepends the opcode, so we emit body = Data[1..23].
|
||||
zone = self.state.zones.get(index)
|
||||
body = (
|
||||
bytes(
|
||||
[
|
||||
_OBJ_ZONE,
|
||||
(index >> 8) & 0xFF,
|
||||
index & 0xFF,
|
||||
0, # Status: closed/secure
|
||||
0, # Loop
|
||||
zone.status_byte if zone else 0,
|
||||
zone.loop if zone else 0,
|
||||
0, # Type: EntryExit
|
||||
1, # Area: default to area 1
|
||||
0, # Options
|
||||
@ -461,15 +660,16 @@ class MockPanel:
|
||||
# [0]=opcode, [1]=ObjectType, [2..3]=ObjectNumber,
|
||||
# [4]=UnitStatus, [5..6]=UnitTime, [7]=UnitType,
|
||||
# [8..19]=Name (12), [20]=reserved, [21]=UnitAreas
|
||||
unit = self.state.units.get(index)
|
||||
body = (
|
||||
bytes(
|
||||
[
|
||||
_OBJ_UNIT,
|
||||
(index >> 8) & 0xFF,
|
||||
index & 0xFF,
|
||||
0, # UnitStatus: off
|
||||
0,
|
||||
0, # UnitTime
|
||||
unit.state if unit else 0,
|
||||
(unit.time_remaining >> 8) & 0xFF if unit else 0,
|
||||
unit.time_remaining & 0xFF if unit else 0,
|
||||
1, # UnitType: Standard
|
||||
]
|
||||
)
|
||||
@ -484,16 +684,17 @@ class MockPanel:
|
||||
# [4]=AreaMode, [5]=AreaAlarms, [6]=EntryTimer, [7]=ExitTimer,
|
||||
# [8]=Enabled, [9]=ExitDelay, [10]=EntryDelay,
|
||||
# [11..22]=Name (12 bytes)
|
||||
area = self.state.areas.get(index)
|
||||
body = (
|
||||
bytes(
|
||||
[
|
||||
_OBJ_AREA,
|
||||
(index >> 8) & 0xFF,
|
||||
index & 0xFF,
|
||||
0, # AreaMode: Off
|
||||
0, # AreaAlarms
|
||||
0, # EntryTimer
|
||||
0, # ExitTimer
|
||||
area.mode if area else 0,
|
||||
area.alarms if area else 0,
|
||||
area.entry_timer if area else 0,
|
||||
area.exit_timer if area else 0,
|
||||
1, # Enabled
|
||||
60, # ExitDelay (s)
|
||||
30, # EntryDelay (s)
|
||||
@ -503,7 +704,250 @@ class MockPanel:
|
||||
)
|
||||
return encode_v2(OmniLink2MessageType.Properties, body)
|
||||
|
||||
# -------- low-level reply send --------
|
||||
# -------- Status (opcode 34/35) and ExtendedStatus (opcode 58/59) --------
|
||||
|
||||
def _reply_status(self, payload: bytes) -> Message:
|
||||
"""Build a Status (opcode 35) reply for a RequestStatus (opcode 34).
|
||||
|
||||
RequestStatus payload (5 bytes, clsOL2MsgRequestStatus.cs):
|
||||
[0] object type
|
||||
[1..2] starting number (BE u16)
|
||||
[3..4] ending number (BE u16)
|
||||
|
||||
Status reply payload layout (clsOL2MsgStatus.cs):
|
||||
[0] object type
|
||||
[1..] N records of size :data:`_STATUS_RECORD_SIZES[object_type]`
|
||||
"""
|
||||
if len(payload) < 5:
|
||||
return _build_nak(OmniLink2MessageType.RequestStatus)
|
||||
obj_type = payload[0]
|
||||
start = (payload[1] << 8) | payload[2]
|
||||
end = (payload[3] << 8) | payload[4]
|
||||
store = self._object_store(obj_type)
|
||||
if store is None or obj_type not in _STATUS_RECORD_SIZES:
|
||||
return _build_nak(OmniLink2MessageType.RequestStatus)
|
||||
body = bytearray([obj_type])
|
||||
for idx in range(start, end + 1):
|
||||
obj = store.get(idx)
|
||||
if obj is None:
|
||||
continue
|
||||
body.extend(_status_record(obj_type, idx, obj))
|
||||
if len(body) == 1:
|
||||
# No matching objects in range — return EOD per protocol.
|
||||
return encode_v2(OmniLink2MessageType.EOD, b"")
|
||||
return encode_v2(OmniLink2MessageType.Status, bytes(body))
|
||||
|
||||
def _reply_extended_status(self, payload: bytes) -> Message:
|
||||
"""Build an ExtendedStatus (opcode 59) reply for opcode 58.
|
||||
|
||||
ExtendedStatus reply payload layout (clsOL2MsgExtendedStatus.cs):
|
||||
[0] object type
|
||||
[1] object length (per-record byte count)
|
||||
[2..] N records of ``object_length`` bytes
|
||||
"""
|
||||
if len(payload) < 5:
|
||||
return _build_nak(OmniLink2MessageType.RequestExtendedStatus)
|
||||
obj_type = payload[0]
|
||||
start = (payload[1] << 8) | payload[2]
|
||||
end = (payload[3] << 8) | payload[4]
|
||||
store = self._object_store(obj_type)
|
||||
record_size = _EXTENDED_STATUS_RECORD_SIZES.get(obj_type, 0)
|
||||
if store is None or record_size == 0:
|
||||
return _build_nak(OmniLink2MessageType.RequestExtendedStatus)
|
||||
body = bytearray([obj_type, record_size])
|
||||
any_records = False
|
||||
for idx in range(start, end + 1):
|
||||
obj = store.get(idx)
|
||||
if obj is None:
|
||||
continue
|
||||
body.extend(_extended_status_record(obj_type, idx, obj))
|
||||
any_records = True
|
||||
if not any_records:
|
||||
return encode_v2(OmniLink2MessageType.EOD, b"")
|
||||
return encode_v2(OmniLink2MessageType.ExtendedStatus, bytes(body))
|
||||
|
||||
# -------- Command (opcode 20) --------
|
||||
|
||||
def _handle_command(self, payload: bytes) -> tuple[Message, tuple[int, ...]]:
|
||||
"""Apply a Command (opcode 20) and return (reply, push_event_words).
|
||||
|
||||
Command payload (4 bytes, clsOL2MsgCommand.cs after stripping opcode):
|
||||
[0] command byte (enuUnitCommand)
|
||||
[1] parameter1 (single byte; brightness, mode, code index, ...)
|
||||
[2] parameter2 high byte (BE u16)
|
||||
[3] parameter2 low byte (object number for nearly every command)
|
||||
"""
|
||||
if len(payload) < 4:
|
||||
return _build_nak(OmniLink2MessageType.Command), ()
|
||||
cmd_byte = payload[0]
|
||||
param1 = payload[1]
|
||||
param2 = (payload[2] << 8) | payload[3]
|
||||
try:
|
||||
cmd = Command(cmd_byte)
|
||||
except ValueError:
|
||||
_log.debug("mock panel: unknown command byte %d", cmd_byte)
|
||||
return _build_nak(OmniLink2MessageType.Command), ()
|
||||
|
||||
push: tuple[int, ...] = ()
|
||||
|
||||
if cmd == Command.UNIT_OFF:
|
||||
unit = self._ensure_unit(param2)
|
||||
unit.state = 0
|
||||
unit.time_remaining = 0
|
||||
push = (_unit_state_changed_word(param2, 0),)
|
||||
elif cmd == Command.UNIT_ON:
|
||||
unit = self._ensure_unit(param2)
|
||||
unit.state = 1
|
||||
unit.time_remaining = 0
|
||||
push = (_unit_state_changed_word(param2, 1),)
|
||||
elif cmd == Command.UNIT_LEVEL:
|
||||
# Per enuUnitCommand.Level (line 15): param1 = 0..100 percent.
|
||||
# Encoded into the state byte as 100..200.
|
||||
if not 0 <= param1 <= 100:
|
||||
return _build_nak(OmniLink2MessageType.Command), ()
|
||||
unit = self._ensure_unit(param2)
|
||||
unit.state = 100 + param1
|
||||
unit.time_remaining = 0
|
||||
push = (_unit_state_changed_word(param2, 1 if param1 > 0 else 0),)
|
||||
elif cmd == Command.BYPASS_ZONE:
|
||||
zone = self._ensure_zone(param2)
|
||||
zone.is_bypassed = True
|
||||
push = (_zone_state_changed_word(param2, 1),)
|
||||
elif cmd == Command.RESTORE_ZONE:
|
||||
zone = self._ensure_zone(param2)
|
||||
zone.is_bypassed = False
|
||||
push = (_zone_state_changed_word(param2, 0),)
|
||||
elif cmd == Command.SET_THERMOSTAT_HEAT_SETPOINT:
|
||||
tstat = self._ensure_thermostat(param2)
|
||||
tstat.heat_setpoint_raw = param1
|
||||
elif cmd == Command.SET_THERMOSTAT_COOL_SETPOINT:
|
||||
tstat = self._ensure_thermostat(param2)
|
||||
tstat.cool_setpoint_raw = param1
|
||||
elif cmd == Command.SET_THERMOSTAT_SYSTEM_MODE:
|
||||
tstat = self._ensure_thermostat(param2)
|
||||
tstat.system_mode = param1
|
||||
elif cmd == Command.SET_THERMOSTAT_FAN_MODE:
|
||||
tstat = self._ensure_thermostat(param2)
|
||||
tstat.fan_mode = param1
|
||||
elif cmd == Command.SET_THERMOSTAT_HOLD_MODE:
|
||||
tstat = self._ensure_thermostat(param2)
|
||||
tstat.hold_mode = param1
|
||||
else:
|
||||
# Acknowledge but don't model: EXECUTE_BUTTON, EXECUTE_PROGRAM,
|
||||
# SHOW_MESSAGE_*, CLEAR_MESSAGE, scenes, audio, energy, ...
|
||||
_log.info(
|
||||
"mock panel: command %s (byte=%d, p1=%d, p2=%d) acknowledged "
|
||||
"with no state effect",
|
||||
cmd.name, cmd_byte, param1, param2,
|
||||
)
|
||||
|
||||
return _build_ack(), push
|
||||
|
||||
# -------- ExecuteSecurityCommand (opcode 74) --------
|
||||
|
||||
def _handle_execute_security_command(
|
||||
self, payload: bytes
|
||||
) -> tuple[Message, tuple[int, ...]]:
|
||||
"""Validate the user code, mutate area state, push an ArmingChanged event.
|
||||
|
||||
Payload (6 bytes, clsOL2MsgExecuteSecurityCommand.cs after stripping opcode):
|
||||
[0] area number (1-based)
|
||||
[1] security mode (raw enuSecurityMode 0..7)
|
||||
[2..5] code digits (thousands, hundreds, tens, ones)
|
||||
|
||||
Implementation choice: on success we return a plain Ack (opcode 1)
|
||||
rather than ExecuteSecurityCommandResponse (opcode 75) — the Omni
|
||||
firmware varies and the client treats both as success. On bad-code
|
||||
we return Nak (the simplest panel behaviour); the client raises
|
||||
:class:`CommandFailedError` either way.
|
||||
"""
|
||||
if len(payload) < 6:
|
||||
return _build_nak(OmniLink2MessageType.ExecuteSecurityCommand), ()
|
||||
area_idx = payload[0]
|
||||
mode = payload[1]
|
||||
code = (
|
||||
payload[2] * 1000 + payload[3] * 100 + payload[4] * 10 + payload[5]
|
||||
)
|
||||
|
||||
# Find a matching code in user_codes. The matched code_index is
|
||||
# what the panel records as the "last user" for the area.
|
||||
matched_user = None
|
||||
for user_idx, pin in self.state.user_codes.items():
|
||||
if pin == code:
|
||||
matched_user = user_idx
|
||||
break
|
||||
if matched_user is None:
|
||||
_log.debug("mock panel: ExecuteSecurityCommand bad code %04d", code)
|
||||
return _build_nak(OmniLink2MessageType.ExecuteSecurityCommand), ()
|
||||
|
||||
area = self._ensure_area(area_idx)
|
||||
area.mode = mode
|
||||
area.last_user = matched_user
|
||||
|
||||
push = (_arming_changed_word(area_idx, mode, matched_user),)
|
||||
return _build_ack(), push
|
||||
|
||||
# -------- per-object ensure helpers --------
|
||||
|
||||
def _ensure_unit(self, idx: int) -> MockUnitState:
|
||||
unit = self.state.units.get(idx)
|
||||
if unit is None:
|
||||
unit = MockUnitState()
|
||||
self.state.units[idx] = unit
|
||||
return unit
|
||||
|
||||
def _ensure_zone(self, idx: int) -> MockZoneState:
|
||||
zone = self.state.zones.get(idx)
|
||||
if zone is None:
|
||||
zone = MockZoneState()
|
||||
self.state.zones[idx] = zone
|
||||
return zone
|
||||
|
||||
def _ensure_area(self, idx: int) -> MockAreaState:
|
||||
area = self.state.areas.get(idx)
|
||||
if area is None:
|
||||
area = MockAreaState()
|
||||
self.state.areas[idx] = area
|
||||
return area
|
||||
|
||||
def _ensure_thermostat(self, idx: int) -> MockThermostatState:
|
||||
tstat = self.state.thermostats.get(idx)
|
||||
if tstat is None:
|
||||
tstat = MockThermostatState()
|
||||
self.state.thermostats[idx] = tstat
|
||||
return tstat
|
||||
|
||||
# -------- low-level reply send + push helpers --------
|
||||
|
||||
def _schedule_event_push(
|
||||
self,
|
||||
event_words: tuple[int, ...],
|
||||
session_key: bytes,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
"""Fire-and-forget: push a SystemEvents (opcode 55) frame after a tiny delay.
|
||||
|
||||
The delay lets the synchronous reply hit the client first so the
|
||||
request future resolves before the unsolicited event arrives. Tests
|
||||
that wait on ``client.events()`` use ``asyncio.wait_for`` with their
|
||||
own timeout to fail fast if the push never arrives.
|
||||
"""
|
||||
|
||||
async def _push() -> None:
|
||||
try:
|
||||
await asyncio.sleep(_PUSH_DELAY)
|
||||
msg = _build_system_events_message(event_words)
|
||||
# Push goes out with seq=0 so the client routes it to the
|
||||
# unsolicited queue (clsOmniLinkConnection.cs:1847-1854).
|
||||
await self._send_v2_reply(0, msg, session_key, writer)
|
||||
except (ConnectionError, asyncio.CancelledError):
|
||||
pass
|
||||
except Exception: # pragma: no cover - diagnostic only
|
||||
_log.exception("mock panel: failed to push synthesized event")
|
||||
|
||||
task = asyncio.create_task(_push(), name="mock-panel-event-push")
|
||||
self._push_tasks.add(task)
|
||||
task.add_done_callback(self._push_tasks.discard)
|
||||
|
||||
async def _send_v2_reply(
|
||||
self,
|
||||
@ -519,6 +963,174 @@ class MockPanel:
|
||||
await writer.drain()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Status / ExtendedStatus per-record builders
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _status_record(obj_type: int, idx: int, obj: object) -> bytes:
|
||||
"""Build one record of a basic Status (opcode 35) reply for ``obj_type``."""
|
||||
if obj_type == _OBJ_ZONE:
|
||||
z = obj # type: ignore[assignment]
|
||||
assert isinstance(z, MockZoneState)
|
||||
return bytes([(idx >> 8) & 0xFF, idx & 0xFF, z.status_byte, z.loop])
|
||||
if obj_type == _OBJ_UNIT:
|
||||
u = obj # type: ignore[assignment]
|
||||
assert isinstance(u, MockUnitState)
|
||||
return bytes(
|
||||
[
|
||||
(idx >> 8) & 0xFF,
|
||||
idx & 0xFF,
|
||||
u.state & 0xFF,
|
||||
(u.time_remaining >> 8) & 0xFF,
|
||||
u.time_remaining & 0xFF,
|
||||
]
|
||||
)
|
||||
if obj_type == _OBJ_AREA:
|
||||
a = obj # type: ignore[assignment]
|
||||
assert isinstance(a, MockAreaState)
|
||||
return bytes(
|
||||
[
|
||||
(idx >> 8) & 0xFF,
|
||||
idx & 0xFF,
|
||||
a.mode & 0xFF,
|
||||
a.alarms & 0xFF,
|
||||
a.entry_timer & 0xFF,
|
||||
a.exit_timer & 0xFF,
|
||||
]
|
||||
)
|
||||
if obj_type == _OBJ_THERMOSTAT:
|
||||
t = obj # type: ignore[assignment]
|
||||
assert isinstance(t, MockThermostatState)
|
||||
return bytes(
|
||||
[
|
||||
(idx >> 8) & 0xFF,
|
||||
idx & 0xFF,
|
||||
t.status & 0xFF,
|
||||
t.temperature_raw & 0xFF,
|
||||
t.heat_setpoint_raw & 0xFF,
|
||||
t.cool_setpoint_raw & 0xFF,
|
||||
t.system_mode & 0xFF,
|
||||
t.fan_mode & 0xFF,
|
||||
t.hold_mode & 0xFF,
|
||||
]
|
||||
)
|
||||
raise AssertionError(f"unhandled object type {obj_type}")
|
||||
|
||||
|
||||
def _extended_status_record(obj_type: int, idx: int, obj: object) -> bytes:
|
||||
"""Build one record of an ExtendedStatus (opcode 59) reply for ``obj_type``.
|
||||
|
||||
The basic-status records are byte-compatible with the extended-status
|
||||
records for Zone, Unit, and Area (the ExtendedStatus reply just adds
|
||||
the per-record length byte at payload[1]). Thermostat is the only type
|
||||
where the extended record is wider — it adds humidity/outdoor/horc
|
||||
fields at the end.
|
||||
"""
|
||||
if obj_type in (_OBJ_ZONE, _OBJ_UNIT, _OBJ_AREA):
|
||||
return _status_record(obj_type, idx, obj)
|
||||
if obj_type == _OBJ_THERMOSTAT:
|
||||
t = obj # type: ignore[assignment]
|
||||
assert isinstance(t, MockThermostatState)
|
||||
return bytes(
|
||||
[
|
||||
(idx >> 8) & 0xFF,
|
||||
idx & 0xFF,
|
||||
t.status & 0xFF,
|
||||
t.temperature_raw & 0xFF,
|
||||
t.heat_setpoint_raw & 0xFF,
|
||||
t.cool_setpoint_raw & 0xFF,
|
||||
t.system_mode & 0xFF,
|
||||
t.fan_mode & 0xFF,
|
||||
t.hold_mode & 0xFF,
|
||||
t.humidity_raw & 0xFF,
|
||||
t.humidify_setpoint_raw & 0xFF,
|
||||
t.dehumidify_setpoint_raw & 0xFF,
|
||||
t.outdoor_temperature_raw & 0xFF,
|
||||
t.horc_status & 0xFF,
|
||||
]
|
||||
)
|
||||
raise AssertionError(f"unhandled object type {obj_type}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SystemEvents (opcode 55) — synthesized push frames
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_system_events_message(words: tuple[int, ...]) -> Message:
|
||||
"""Pack one or more 16-bit event words into a v2 SystemEvents Message.
|
||||
|
||||
Each word is encoded big-endian. Reference: clsOLMsgSystemEvents.cs.
|
||||
"""
|
||||
body = bytearray()
|
||||
for w in words:
|
||||
body.append((w >> 8) & 0xFF)
|
||||
body.append(w & 0xFF)
|
||||
return encode_v2(OmniLink2MessageType.SystemEvents, bytes(body))
|
||||
|
||||
|
||||
def _zone_state_changed_word(zone_index: int, new_state: int) -> int:
|
||||
"""Encode a ZONE_STATE_CHANGE (top 6 bits == 0x4) event word.
|
||||
|
||||
Layout (matches events._classify):
|
||||
bits 10-15: family marker (0x0400)
|
||||
bit 9 : new_state (0=secure, 1=open)
|
||||
low byte : zone index 1..255
|
||||
"""
|
||||
word = 0x0400 | (zone_index & 0xFF)
|
||||
if new_state:
|
||||
word |= 0x0200
|
||||
return word & 0xFFFF
|
||||
|
||||
|
||||
def _unit_state_changed_word(unit_index: int, new_state: int) -> int:
|
||||
"""Encode a UNIT_STATE_CHANGE (top 6 bits == 0x8) event word.
|
||||
|
||||
Layout:
|
||||
bits 10-15: family marker (0x0800)
|
||||
bit 9 : new_state (0=off, 1=on)
|
||||
bit 8 : unit_index >= 256 high bit
|
||||
low byte : unit index low 8 bits
|
||||
"""
|
||||
word = 0x0800 | (unit_index & 0xFF)
|
||||
if unit_index >= 256:
|
||||
word |= 0x0100
|
||||
if new_state:
|
||||
word |= 0x0200
|
||||
return word & 0xFFFF
|
||||
|
||||
|
||||
def _arming_changed_word(area_index: int, new_mode: int, user_index: int) -> int:
|
||||
"""Encode a SECURITY_MODE_CHANGE catch-all event word.
|
||||
|
||||
Layout (mirrors events._classify catch-all branch and clsText.cs:2155-2217):
|
||||
bits 12-14: SecurityMode (0..7)
|
||||
bits 8-11 : area index (0 = system / no specific area)
|
||||
low byte : user/code index that triggered the change (0 = panel)
|
||||
|
||||
NOTE: the classifier in :func:`omni_pca.events._classify` only routes
|
||||
a word to ArmingChanged when ``(word >> 8) & 0xF0`` is non-zero. Our
|
||||
encoding satisfies that as long as ``new_mode`` is at least 1 (the
|
||||
SecurityMode high nibble of the high byte is non-zero). For Off (0)
|
||||
the test seeds a non-zero mode — Disarm (mode=Off) flowing through
|
||||
the same path would round-trip as an UnknownEvent, which matches
|
||||
real-panel behaviour where Off is pushed as a different event family.
|
||||
"""
|
||||
word = ((new_mode & 0x07) << 12) | ((area_index & 0x0F) << 8) | (user_index & 0xFF)
|
||||
return word & 0xFFFF
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Stock reply / NAK builders
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_ack() -> Message:
|
||||
"""Build a v2 Ack (opcode 1) with no payload."""
|
||||
return encode_v2(OmniLink2MessageType.Ack, b"")
|
||||
|
||||
|
||||
def _build_nak(in_reply_to_opcode: int) -> Message:
|
||||
"""Build a v2 Nak. Payload is a single byte echoing the opcode being negged.
|
||||
|
||||
|
||||
@ -7,14 +7,36 @@ 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.mock_panel import MockPanel, MockState
|
||||
from omni_pca.models import AreaProperties, UnitProperties, ZoneProperties
|
||||
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")
|
||||
|
||||
@ -99,3 +121,162 @@ async def test_e2e_wrong_key_fails_with_handshake_error() -> None:
|
||||
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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user