The program viewer goes from read-only to write-capable. Three layers
land together because a partial implementation isn't actionable.
D1 — wire path:
* OmniClient.download_program(slot, program) — sends opcode 8
(clsOLMsg2DownloadProgram, clsHAC.cs:1133-1140) with the 2-byte BE
slot + Program.encode_wire_bytes(). Validates slot range 1..1500
client-side. Maps Ack → success, Nak → CommandFailedError, any
other opcode → OmniConnectionError.
* OmniClient.clear_program(slot) — convenience that writes an all-zero
body. Mock treats this as deletion (removes the slot from
state.programs) so subsequent reads see it as undefined.
* MockPanel handles DownloadProgram on the v2 dispatch path —
receive 2-byte slot + 14-byte body, store in state.programs, ack.
* OmniClientV1.download_program raises NotImplementedError. v1 only
has the bulk DownloadPrograms flow which clears everything before
rewriting — destructive for HA's edit-one-program use case.
Documented in the docstring so callers know to route v1 users to
a v2 connection.
Tests cover: write-then-read round-trip, overwrite of existing slot,
clear deletes the slot, range validation, v1 not-implemented.
D2 — HA websocket commands:
* omni_pca/programs/clear — writes zero body, updates coordinator.
data.programs immediately so the next list call shows the deletion.
Returns ``{slot, cleared: true}``. Maps NotImplementedError on v1
panels to the ``not_supported`` error code.
* omni_pca/programs/clone — copies source_slot → target_slot, with
the slot field re-stamped. Refuses identical source/target,
refuses missing source. Same coordinator update pattern.
5 new HA-integration tests covering clear, clone happy path, clone
to same slot, clone from missing source.
D3 — Clear/Clone UI in the side panel:
* "Clone…" button reveals an inline target-slot input (number,
1..1500). Enter or "Clone" button calls the WS command, then
navigates the detail panel to the new clone so the user sees the
result.
* "Clear" button shows an inline confirmation row ("Clear slot N?
This deletes the program from the panel.") with Yes/Cancel. Yes
closes the detail panel and refreshes the list — the slot is gone.
* Both surface feedback via the same _writeFeedback state used by
Fire now (auto-clears after 4 seconds).
* Three new button styles (.primary, .secondary, .danger) and the
.action-row composite used for both inline prompts.
What's NOT shipped here: a real visual editor for trigger/condition/
action fields. That's a follow-up (~600 lines of new TS + careful
validation work). The current "Cut 1" UX is enough for the common
"I accidentally created a program, clear it" and "I want a variant
of this program, give me a copy in an empty slot" workflows.
Full suite: 643 passed, 1 skipped (up from 634).
Frontend bundle: 38 KB minified (up from 34 KB with the write UI).
2193 lines
88 KiB
Python
2193 lines
88 KiB
Python
"""Mock Omni-Link II controller — async TCP server speaking the panel side.
|
|
|
|
A drop-in test fixture that lets us exercise the client end of the protocol
|
|
without touching real hardware. Reuses the project's own primitives
|
|
(``crypto``, ``packet``, ``message``, ``opcodes``) — the wire-level
|
|
encryption MUST flow through ``omni_pca.crypto`` to avoid a parallel
|
|
implementation drift.
|
|
|
|
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 + 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
|
|
|
|
References:
|
|
notes/handshake.md (whole document)
|
|
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
|
|
|
|
Cross-references (HAI OmniPro II Installation Manual):
|
|
*INSTALLER SETUP* (pca-re/docs/manuals/installation_manual/
|
|
04_INSTALLER_SETUP/) is the human-side counterpart to the data
|
|
this mock serves: the panel's response to a RequestProperties /
|
|
RequestStatus would on real hardware reflect whatever an installer
|
|
typed into SETUP CONTROL / SETUP ZONES / SETUP AREAS / SETUP
|
|
TEMPERATURES / SETUP MISC for that object. The mock just makes up
|
|
plausible bytes; production fixtures should pre-seed the
|
|
``MockPanel`` state to match a known SETUP configuration.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import secrets
|
|
from collections.abc import AsyncIterator, Callable
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, ClassVar
|
|
|
|
from .commands import Command
|
|
from .crypto import (
|
|
BLOCK_SIZE,
|
|
decrypt_message_payload,
|
|
derive_session_key,
|
|
encrypt_message_payload,
|
|
)
|
|
from .message import (
|
|
Message,
|
|
MessageCrcError,
|
|
MessageFormatError,
|
|
encode_v1,
|
|
encode_v2,
|
|
)
|
|
from .opcodes import OmniLink2MessageType, OmniLinkMessageType, PacketType
|
|
from .packet import Packet
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
# enuObjectType (clsOmniLink2.cs / enuObjectType.cs)
|
|
_OBJ_ZONE = 1
|
|
_OBJ_UNIT = 2
|
|
_OBJ_BUTTON = 3
|
|
_OBJ_AREA = 5
|
|
_OBJ_THERMOSTAT = 6
|
|
|
|
# Inner-message size constants (model OMNI_PRO_II)
|
|
_ZONE_NAME_LEN = 15
|
|
_UNIT_NAME_LEN = 12
|
|
_AREA_NAME_LEN = 12
|
|
_BUTTON_NAME_LEN = 12
|
|
_THERMOSTAT_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
|
|
_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
|
|
unit_type: int = 1 # enuOL2UnitType (1=Standard); see clsUnit.cs:928 family
|
|
areas: int = 0x01 # bitmask of area membership (bit 0 = area 1, ...)
|
|
|
|
|
|
@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
|
|
entry_delay: int = 30 # seconds; configured grace period after a door opens
|
|
exit_delay: int = 60 # seconds; configured grace period after arming
|
|
enabled: bool = True # whether this area is part of NumAreasUsed
|
|
# Boolean configuration flags — not on the wire (the Properties
|
|
# reply doesn't carry them); kept here for snapshots from .pca and
|
|
# any future SetupData-aware code paths.
|
|
entry_chime: bool = False
|
|
quick_arm: bool = False
|
|
auto_bypass: bool = False
|
|
all_on_for_alarm: bool = False
|
|
trouble_beep: bool = False
|
|
perimeter_chime: bool = False
|
|
audible_exit_delay: bool = False
|
|
|
|
|
|
@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
|
|
zone_type: int = 0 # raw enuZoneType byte (0=EntryExit default)
|
|
area: int = 1 # 1-based area assignment
|
|
options: int = 0 # raw ZoneOptions bitmask (SetupData; panel default 4)
|
|
|
|
@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 MockButtonState:
|
|
"""One programmable button macro (no live state — buttons just fire programs)."""
|
|
|
|
name: str = ""
|
|
|
|
|
|
@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
|
|
thermostat_type: int = 1 # raw enuThermostatType (1=AutoHeatCool)
|
|
areas: int = 0x01 # 8-bit area-membership bitmask
|
|
|
|
|
|
@dataclass
|
|
class MockState:
|
|
"""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
|
|
firmware_minor: int = 12
|
|
firmware_revision: int = 1
|
|
local_phone: str = ""
|
|
|
|
# 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)
|
|
buttons: dict[int, MockButtonState] = 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)
|
|
|
|
# Program table — slot number (1-based) → raw 14-byte wire body.
|
|
# Wire layout (no Mon/Day swap) so a v2 ``ProgramData`` reply is a
|
|
# direct copy. Slots not present in this dict respond with 14 zero
|
|
# bytes, matching real-panel "unused slot" behavior.
|
|
programs: dict[int, bytes] = field(default_factory=dict)
|
|
|
|
# SystemStatus snapshot. Defaults: time set, battery good, no alarms.
|
|
time_set: bool = True
|
|
year: int = 26 # 2026
|
|
month: int = 5
|
|
day: int = 10
|
|
day_of_week: int = 1 # Sunday=1 in the Omni convention
|
|
hour: int = 12
|
|
minute: int = 0
|
|
second: int = 0
|
|
daylight_saving: int = 0
|
|
sunrise_hour: int = 6
|
|
sunrise_minute: int = 30
|
|
sunset_hour: int = 19
|
|
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)
|
|
self.buttons = _promote_dict(self.buttons, MockButtonState)
|
|
|
|
@classmethod
|
|
def from_pca(
|
|
cls,
|
|
path_or_bytes: str | bytes,
|
|
key: int,
|
|
**overrides: Any,
|
|
) -> MockState:
|
|
"""Build a MockState seeded from a real .pca file.
|
|
|
|
Populated from the .pca:
|
|
|
|
* ``model_byte`` + ``firmware_*`` — drive SystemInformation replies
|
|
so a connected client sees the panel the .pca came from.
|
|
* ``programs`` — every non-empty Program record from the 1500-slot
|
|
table, encoded back to wire bytes so UploadProgram /
|
|
UploadPrograms serve them exactly as a real panel would.
|
|
* ``zones`` / ``units`` / ``areas`` / ``thermostats`` / ``buttons``
|
|
— populated with the names from the .pca's Names section.
|
|
``MockZoneState`` entries additionally carry ``zone_type``
|
|
(the raw ``enuZoneType`` byte from the SetupData installer
|
|
section), so the mock's Properties replies categorise zones
|
|
as security / temperature / humidity matching the source
|
|
panel. Other SetupData-resident fields (per-zone area
|
|
assignment, unit type, thermostat type, exit/entry delays,
|
|
options bitmasks) aren't extracted yet — those default to 0.
|
|
|
|
``user_codes`` is not seeded — the .pca only stores code *names*,
|
|
not the PIN values; the panel keeps PINs in SetupData. Override
|
|
explicitly if a test needs them.
|
|
|
|
Anything else uses MockState defaults. Pass kwargs to override
|
|
any seeded field.
|
|
|
|
``key=0`` only works for files where the export keystream was
|
|
already applied (e.g., the result of ``decrypt_pca_bytes`` with
|
|
the same key); use ``KEY_EXPORT`` (391549495) for unmodified
|
|
PC Access exports.
|
|
"""
|
|
from .pca_file import parse_pca_file
|
|
|
|
acct = parse_pca_file(path_or_bytes, key=key)
|
|
programs = {
|
|
p.slot: p.encode_wire_bytes()
|
|
for p in acct.programs
|
|
if p.slot is not None and not p.is_empty()
|
|
}
|
|
# Union of named areas and the "in-use" range from NumAreasUsed —
|
|
# an area is part of the install if either it has a user-assigned
|
|
# name OR the installer told the panel to use it. Most homes have
|
|
# a single unnamed area 1 + num_areas_used=1, which produces
|
|
# areas={1: MockAreaState(name="", enabled=True, ...)}.
|
|
in_use_areas = set(acct.area_names) | set(
|
|
range(1, acct.num_areas_used + 1)
|
|
)
|
|
defaults: dict[str, Any] = {
|
|
"model_byte": acct.model,
|
|
"firmware_major": acct.firmware_major,
|
|
"firmware_minor": acct.firmware_minor,
|
|
"firmware_revision": acct.firmware_revision,
|
|
"programs": programs,
|
|
"zones": {
|
|
i: MockZoneState(
|
|
name=n,
|
|
zone_type=acct.zone_types.get(i, 0),
|
|
area=acct.zone_areas.get(i, 1),
|
|
options=acct.zone_options.get(i, 0),
|
|
)
|
|
for i, n in acct.zone_names.items()
|
|
},
|
|
"units": {
|
|
i: MockUnitState(
|
|
name=n,
|
|
unit_type=acct.unit_types.get(i, 1),
|
|
# 0xFF (uninitialised → "all areas") and 0x01 are the
|
|
# two common values. If the panel doesn't have a
|
|
# specific restriction, fall back to "area 1 only"
|
|
# so HA's area-filtering produces sensible defaults.
|
|
areas=(acct.unit_areas.get(i, 0x01) or 0x01)
|
|
if acct.unit_areas.get(i, 0x01) != 0xFF
|
|
else 0x01,
|
|
)
|
|
for i, n in acct.unit_names.items()
|
|
},
|
|
"areas": {
|
|
i: MockAreaState(
|
|
name=acct.area_names.get(i, ""),
|
|
entry_delay=acct.area_entry_delays.get(i, 30),
|
|
exit_delay=acct.area_exit_delays.get(i, 60),
|
|
enabled=i <= acct.num_areas_used,
|
|
entry_chime=acct.area_entry_chime.get(i, False),
|
|
quick_arm=acct.area_quick_arm.get(i, False),
|
|
auto_bypass=acct.area_auto_bypass.get(i, False),
|
|
all_on_for_alarm=acct.area_all_on_for_alarm.get(i, False),
|
|
trouble_beep=acct.area_trouble_beep.get(i, False),
|
|
perimeter_chime=acct.area_perimeter_chime.get(i, False),
|
|
audible_exit_delay=acct.area_audible_exit_delay.get(i, False),
|
|
)
|
|
for i in sorted(in_use_areas)
|
|
},
|
|
"thermostats": {
|
|
i: MockThermostatState(
|
|
name=n,
|
|
thermostat_type=acct.thermostat_types.get(i, 1),
|
|
# 0xFF (uninitialised → "all areas") normalises to
|
|
# area 1 only, consistent with the unit-area handling.
|
|
areas=(
|
|
0x01
|
|
if acct.thermostat_areas.get(i, 0xFF) == 0xFF
|
|
else acct.thermostat_areas[i]
|
|
),
|
|
)
|
|
for i, n in acct.thermostat_names.items()
|
|
},
|
|
"buttons": {
|
|
i: MockButtonState(name=n) for i, n in acct.button_names.items()
|
|
},
|
|
}
|
|
defaults.update(overrides)
|
|
return cls(**defaults)
|
|
|
|
# ---- name-bytes helpers (kept for back-compat with old callers) -----
|
|
|
|
def zone_name_bytes(self, idx: int) -> bytes:
|
|
z = self.zones.get(idx)
|
|
return _name_bytes(z.name if z else "", _ZONE_NAME_LEN)
|
|
|
|
def unit_name_bytes(self, idx: int) -> bytes:
|
|
u = self.units.get(idx)
|
|
return _name_bytes(u.name if u else "", _UNIT_NAME_LEN)
|
|
|
|
def area_name_bytes(self, idx: int) -> bytes:
|
|
a = self.areas.get(idx)
|
|
return _name_bytes(a.name if a else "", _AREA_NAME_LEN)
|
|
|
|
def thermostat_name_bytes(self, idx: int) -> bytes:
|
|
t = self.thermostats.get(idx)
|
|
return _name_bytes(t.name if t else "", _THERMOSTAT_NAME_LEN)
|
|
|
|
def button_name_bytes(self, idx: int) -> bytes:
|
|
b = self.buttons.get(idx)
|
|
return _name_bytes(b.name if b else "", _BUTTON_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:
|
|
"""Encode a panel name as ASCII, right-padded with NULs to a fixed width."""
|
|
raw = name.encode("ascii", errors="replace")[:width]
|
|
return raw + b"\x00" * (width - len(raw))
|
|
|
|
|
|
class MockPanel:
|
|
"""Async TCP server that speaks Omni-Link II from the controller side.
|
|
|
|
One client at a time — Omni's real controllers are single-session too.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
controller_key: bytes,
|
|
state: MockState | None = None,
|
|
session_id_provider: Callable[[], bytes] | None = None,
|
|
) -> None:
|
|
if len(controller_key) != 16:
|
|
raise ValueError("controller_key must be 16 bytes")
|
|
self._controller_key = bytes(controller_key)
|
|
self.state = state or MockState()
|
|
self._session_id_provider = session_id_provider or (
|
|
lambda: secrets.token_bytes(_SESSION_ID_BYTES)
|
|
)
|
|
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()
|
|
# v1 UploadNames cursor: index into self._v1_name_stream() while a
|
|
# streaming download is in flight, ``None`` when no stream active.
|
|
self._upload_names_cursor: int | None = None
|
|
# v1 UploadPrograms cursor: index into self._v1_program_stream() while
|
|
# a streaming download is in flight, ``None`` when no stream active.
|
|
self._upload_programs_cursor: int | None = None
|
|
|
|
# -------- public observables (handy in tests) --------
|
|
|
|
@property
|
|
def session_count(self) -> int:
|
|
return self._session_count
|
|
|
|
@property
|
|
def last_request_opcode(self) -> int | None:
|
|
return self._last_request_opcode
|
|
|
|
# -------- server lifecycle --------
|
|
|
|
@asynccontextmanager
|
|
async def serve(
|
|
self,
|
|
host: str = "127.0.0.1",
|
|
port: int = 0,
|
|
transport: str = "tcp",
|
|
) -> AsyncIterator[tuple[str, int]]:
|
|
"""Start listening; yield ``(host, actual_port)``; tear down on exit.
|
|
|
|
``transport='tcp'`` (default) opens a stream server matching modern
|
|
PC Access. ``transport='udp'`` opens a datagram socket — the panel
|
|
path used by some firmware versions and by the Omni-Link II
|
|
``Network_UDP`` configuration. Same wire format either way.
|
|
"""
|
|
if transport == "tcp":
|
|
async with self._serve_tcp(host, port) as bound:
|
|
yield bound
|
|
elif transport == "udp":
|
|
async with self._serve_udp(host, port) as bound:
|
|
yield bound
|
|
else:
|
|
raise ValueError(f"transport must be 'tcp' or 'udp', got {transport!r}")
|
|
|
|
@asynccontextmanager
|
|
async def _serve_tcp(
|
|
self, host: str, port: int
|
|
) -> AsyncIterator[tuple[str, int]]:
|
|
server = await asyncio.start_server(self._handle_client, host=host, port=port)
|
|
sockets = server.sockets or ()
|
|
if not sockets: # pragma: no cover -- start_server always populates this
|
|
raise RuntimeError("asyncio.start_server returned no sockets")
|
|
bound_host, bound_port = sockets[0].getsockname()[:2]
|
|
_log.debug("mock panel (tcp) listening on %s:%d", bound_host, bound_port)
|
|
try:
|
|
async with server:
|
|
yield bound_host, bound_port
|
|
finally:
|
|
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()
|
|
|
|
@asynccontextmanager
|
|
async def _serve_udp(
|
|
self, host: str, port: int
|
|
) -> AsyncIterator[tuple[str, int]]:
|
|
loop = asyncio.get_running_loop()
|
|
transport, _protocol = await loop.create_datagram_endpoint(
|
|
lambda: _MockServerDatagramProtocol(self),
|
|
local_addr=(host, port),
|
|
)
|
|
sockname = transport.get_extra_info("sockname")
|
|
bound_host, bound_port = sockname[0], sockname[1]
|
|
_log.debug("mock panel (udp) listening on %s:%d", bound_host, bound_port)
|
|
try:
|
|
yield bound_host, bound_port
|
|
finally:
|
|
for t in list(self._push_tasks):
|
|
if not t.done():
|
|
t.cancel()
|
|
self._push_tasks.clear()
|
|
transport.close()
|
|
|
|
# -------- connection handling --------
|
|
|
|
async def _handle_client(
|
|
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
) -> None:
|
|
peer = writer.get_extra_info("peername")
|
|
_log.debug("mock panel: client connected from %s", peer)
|
|
session_key: bytes | None = None
|
|
session_id: bytes | None = None
|
|
try:
|
|
while True:
|
|
header = await _read_exact(reader, 4)
|
|
if header is None:
|
|
break
|
|
seq = (header[0] << 8) | header[1]
|
|
try:
|
|
pkt_type = PacketType(header[2])
|
|
except ValueError:
|
|
_log.debug("mock panel: unknown packet type %#x", header[2])
|
|
break
|
|
|
|
if pkt_type is PacketType.ClientRequestNewSession:
|
|
session_id, session_key = await self._handle_new_session(seq, writer)
|
|
|
|
elif pkt_type is PacketType.ClientRequestSecureSession:
|
|
if session_key is None or session_id is None:
|
|
_log.debug("mock panel: secure-session before NewSession")
|
|
break
|
|
body = await _read_exact(reader, BLOCK_SIZE)
|
|
if body is None:
|
|
break
|
|
handled = await self._handle_secure_session(
|
|
seq, body, session_id, session_key, writer
|
|
)
|
|
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")
|
|
break
|
|
|
|
elif pkt_type is PacketType.OmniLink2Message:
|
|
if session_key is None:
|
|
_log.debug("mock panel: encrypted message before secure session")
|
|
break
|
|
cont = await self._handle_encrypted_message(
|
|
reader, seq, session_key, writer
|
|
)
|
|
if not cont:
|
|
break
|
|
|
|
elif pkt_type is PacketType.OmniLinkMessage:
|
|
# v1 wire dialect — UDP-only panels speak this on the
|
|
# wire. We accept it over TCP too so the same mock
|
|
# server can fixture both transports for tests.
|
|
if session_key is None:
|
|
_log.debug("mock panel: v1 message before secure session")
|
|
break
|
|
cont = await self._handle_encrypted_message_v1(
|
|
reader, seq, session_key, writer
|
|
)
|
|
if not cont:
|
|
break
|
|
|
|
else:
|
|
_log.debug("mock panel: unhandled packet type %s", pkt_type.name)
|
|
break
|
|
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()
|
|
_log.debug("mock panel: client %s disconnected", peer)
|
|
|
|
# -------- handshake steps --------
|
|
|
|
async def _handle_new_session(
|
|
self, client_seq: int, writer: asyncio.StreamWriter
|
|
) -> tuple[bytes, bytes]:
|
|
session_id = self._session_id_provider()
|
|
if len(session_id) != _SESSION_ID_BYTES:
|
|
raise RuntimeError(
|
|
f"session_id_provider returned {len(session_id)} bytes,"
|
|
f" need {_SESSION_ID_BYTES}"
|
|
)
|
|
session_key = derive_session_key(self._controller_key, session_id)
|
|
payload = bytes([_PROTO_HI, _PROTO_LO]) + session_id
|
|
ack = Packet(seq=client_seq, type=PacketType.ControllerAckNewSession, data=payload)
|
|
_log.debug("mock panel: ack new session, sid=%s", session_id.hex())
|
|
writer.write(ack.encode())
|
|
await writer.drain()
|
|
return session_id, session_key
|
|
|
|
async def _handle_secure_session(
|
|
self,
|
|
client_seq: int,
|
|
ciphertext: bytes,
|
|
session_id: bytes,
|
|
session_key: bytes,
|
|
writer: asyncio.StreamWriter,
|
|
) -> bool:
|
|
try:
|
|
plaintext = decrypt_message_payload(ciphertext, client_seq, session_key)
|
|
except Exception:
|
|
_log.debug("mock panel: failed to decrypt secure-session request")
|
|
return False
|
|
if not plaintext.startswith(session_id):
|
|
_log.debug(
|
|
"mock panel: secure-session SID mismatch (got %s, want %s)",
|
|
plaintext[:_SESSION_ID_BYTES].hex(),
|
|
session_id.hex(),
|
|
)
|
|
# The real controller replies with ControllerSessionTerminated
|
|
# to signal "your key didn't decrypt right". Mirror that.
|
|
term = Packet(
|
|
seq=client_seq, type=PacketType.ControllerSessionTerminated, data=b""
|
|
)
|
|
writer.write(term.encode())
|
|
await writer.drain()
|
|
return False
|
|
|
|
# Echo SessionID back, encrypted with the freshly derived key.
|
|
echo_plain = session_id # encrypt_message_payload zero-pads for us
|
|
ciphertext_out = encrypt_message_payload(echo_plain, client_seq, session_key)
|
|
ack = Packet(
|
|
seq=client_seq, type=PacketType.ControllerAckSecureSession, data=ciphertext_out
|
|
)
|
|
writer.write(ack.encode())
|
|
await writer.drain()
|
|
self._session_count += 1
|
|
_log.debug("mock panel: secure session up (#%d)", self._session_count)
|
|
return True
|
|
|
|
# -------- encrypted message dispatch --------
|
|
|
|
async def _handle_encrypted_message(
|
|
self,
|
|
reader: asyncio.StreamReader,
|
|
client_seq: int,
|
|
session_key: bytes,
|
|
writer: asyncio.StreamWriter,
|
|
) -> bool:
|
|
first_block = await _read_exact(reader, BLOCK_SIZE)
|
|
if first_block is None:
|
|
return False
|
|
first_plain = decrypt_message_payload(first_block, client_seq, session_key)
|
|
# first_plain[0] = StartChar (0x21), first_plain[1] = MessageLength
|
|
msg_length = first_plain[1]
|
|
# Total inner message bytes = msg_length + 4 (start, length, ..., crc1, crc2)
|
|
# We have BLOCK_SIZE bytes; need additional bytes rounded up to BLOCK_SIZE.
|
|
extra_needed = max(0, msg_length + 4 - BLOCK_SIZE)
|
|
rem = (-extra_needed) % BLOCK_SIZE
|
|
extra_aligned = extra_needed + rem
|
|
ciphertext = first_block
|
|
if extra_aligned > 0:
|
|
extra = await _read_exact(reader, extra_aligned)
|
|
if extra is None:
|
|
return False
|
|
ciphertext = first_block + extra
|
|
plaintext = decrypt_message_payload(ciphertext, client_seq, session_key)
|
|
|
|
try:
|
|
inner = Message.decode(plaintext)
|
|
except MessageCrcError:
|
|
_log.debug("mock panel: inner message CRC failure")
|
|
await self._send_v2_reply(
|
|
client_seq, _build_nak(0), session_key, writer
|
|
)
|
|
return True
|
|
except MessageFormatError as exc:
|
|
_log.debug("mock panel: malformed inner message: %s", exc)
|
|
return False
|
|
|
|
opcode = inner.opcode
|
|
self._last_request_opcode = opcode
|
|
try:
|
|
opcode_name = OmniLink2MessageType(opcode).name
|
|
except ValueError:
|
|
opcode_name = f"Unknown({opcode})"
|
|
_log.debug("mock panel: dispatch opcode=%s payload=%d bytes",
|
|
opcode_name, len(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
|
|
|
|
async def _handle_encrypted_message_v1(
|
|
self,
|
|
reader: asyncio.StreamReader,
|
|
client_seq: int,
|
|
session_key: bytes,
|
|
writer: asyncio.StreamWriter,
|
|
) -> bool:
|
|
"""v1 counterpart of :meth:`_handle_encrypted_message`.
|
|
|
|
Reads, decrypts, decodes a v1 inner message (StartChar 0x5A),
|
|
dispatches via :meth:`_dispatch_v1`, and writes the reply back
|
|
wrapped in an ``OmniLinkMessage`` (16) outer packet.
|
|
"""
|
|
first_block = await _read_exact(reader, BLOCK_SIZE)
|
|
if first_block is None:
|
|
return False
|
|
first_plain = decrypt_message_payload(first_block, client_seq, session_key)
|
|
# first_plain[0] = StartChar (0x5A), first_plain[1] = MessageLength
|
|
msg_length = first_plain[1]
|
|
extra_needed = max(0, msg_length + 4 - BLOCK_SIZE)
|
|
rem = (-extra_needed) % BLOCK_SIZE
|
|
extra_aligned = extra_needed + rem
|
|
ciphertext = first_block
|
|
if extra_aligned > 0:
|
|
extra = await _read_exact(reader, extra_aligned)
|
|
if extra is None:
|
|
return False
|
|
ciphertext = first_block + extra
|
|
plaintext = decrypt_message_payload(ciphertext, client_seq, session_key)
|
|
|
|
try:
|
|
inner = Message.decode(plaintext)
|
|
except MessageCrcError:
|
|
_log.debug("mock panel: v1 inner message CRC failure")
|
|
await self._send_v1_reply(
|
|
client_seq, _build_v1_nak(0), session_key, writer
|
|
)
|
|
return True
|
|
except MessageFormatError as exc:
|
|
_log.debug("mock panel: malformed v1 inner message: %s", exc)
|
|
return False
|
|
|
|
opcode = inner.opcode
|
|
self._last_request_opcode = opcode
|
|
try:
|
|
opcode_name = OmniLinkMessageType(opcode).name
|
|
except ValueError:
|
|
opcode_name = f"Unknown({opcode})"
|
|
_log.debug(
|
|
"mock panel: v1 dispatch opcode=%s payload=%d bytes",
|
|
opcode_name, len(inner.payload),
|
|
)
|
|
|
|
reply, push_words = self._dispatch_v1(opcode, inner.payload)
|
|
await self._send_v1_reply(client_seq, reply, session_key, writer)
|
|
# v1 push events use opcode 35 instead of 55; the existing
|
|
# _schedule_event_push helper is v2-shaped and would emit a
|
|
# frame the v1 client can't parse. Skip pushes on v1 for now
|
|
# -- streaming UploadNames is the dominant flow we care about.
|
|
_ = push_words
|
|
return True
|
|
|
|
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(), ()
|
|
if opcode == OmniLink2MessageType.RequestSystemStatus:
|
|
return self._reply_system_status(), ()
|
|
if opcode == OmniLink2MessageType.RequestProperties:
|
|
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(), ()
|
|
if opcode == OmniLink2MessageType.UploadProgram:
|
|
return self._reply_program_data(payload), ()
|
|
if opcode == OmniLink2MessageType.DownloadProgram:
|
|
return self._handle_download_program(payload), ()
|
|
return _build_nak(opcode), ()
|
|
|
|
def _handle_download_program(self, payload: bytes) -> Message:
|
|
"""Write the 14-byte program body at ``payload[2:16]`` to slot
|
|
``payload[0..1]`` (BE u16). Acks on success, NAKs on bad shape.
|
|
|
|
Mirrors :meth:`_reply_program_data` in reverse — same wire
|
|
framing as the UploadProgram reply, just inbound. Writing an
|
|
all-zero body removes the slot from ``state.programs`` so
|
|
subsequent UploadProgram requests treat it as undefined
|
|
(matches real-panel behaviour for cleared slots).
|
|
"""
|
|
if len(payload) < 2 + 14:
|
|
return _build_nak(OmniLink2MessageType.DownloadProgram)
|
|
number = (payload[0] << 8) | payload[1]
|
|
if not 1 <= number <= 1500:
|
|
return _build_nak(OmniLink2MessageType.DownloadProgram)
|
|
body = bytes(payload[2 : 2 + 14])
|
|
if body == b"\x00" * 14:
|
|
self.state.programs.pop(number, None)
|
|
else:
|
|
self.state.programs[number] = body
|
|
return _build_ack()
|
|
|
|
def _reply_program_data(self, payload: bytes) -> Message:
|
|
"""v2 program read — single-slot OR iterator.
|
|
|
|
Request payload: ``[number_hi, number_lo, request_reason]`` (3 bytes
|
|
per ``clsOL2MsgUploadProgram``). Reply payload: ``[number_hi,
|
|
number_lo] + raw_14_byte_body`` per ``clsOL2MsgProgramData``.
|
|
|
|
``request_reason`` semantics mirror the C# ReadConfig flow at
|
|
clsHAC.cs:4985 / 5331:
|
|
|
|
0 → return the exact requested slot (zero body if undefined).
|
|
1 → "next defined": return the lowest slot strictly greater
|
|
than the requested number. If none, return EOD. The
|
|
C# client iterates by feeding back each received slot
|
|
number with reason=1 until EOD.
|
|
|
|
Any other reason value is treated as reason=0 (we have no other
|
|
captures showing alternate semantics).
|
|
"""
|
|
if len(payload) < 2:
|
|
return _build_nak(OmniLink2MessageType.UploadProgram)
|
|
number = (payload[0] << 8) | payload[1]
|
|
reason = payload[2] if len(payload) >= 3 else 0
|
|
if reason == 1:
|
|
# "Next defined after this slot." If start_slot=0 (initial
|
|
# call) and no programs are defined, we fall straight to EOD.
|
|
next_slot = min(
|
|
(s for s in self.state.programs if s > number), default=None
|
|
)
|
|
if next_slot is None:
|
|
return encode_v2(OmniLink2MessageType.EOD, b"")
|
|
number = next_slot
|
|
body = self.state.programs.get(number, b"\x00" * 14)
|
|
if len(body) != 14:
|
|
return _build_nak(OmniLink2MessageType.UploadProgram)
|
|
return encode_v2(
|
|
OmniLink2MessageType.ProgramData,
|
|
bytes([(number >> 8) & 0xFF, number & 0xFF]) + body,
|
|
)
|
|
|
|
# -------- reply builders (byte-exact per clsOL2Msg*.cs) --------
|
|
|
|
def _reply_system_information(self) -> Message:
|
|
s = self.state
|
|
revision_byte = s.firmware_revision & 0xFF
|
|
phone = _name_bytes(s.local_phone, _PHONE_LEN)
|
|
body = bytes(
|
|
[
|
|
s.model_byte & 0xFF,
|
|
s.firmware_major & 0xFF,
|
|
s.firmware_minor & 0xFF,
|
|
revision_byte,
|
|
]
|
|
) + phone
|
|
return encode_v2(OmniLink2MessageType.SystemInformation, body)
|
|
|
|
def _reply_system_status(self) -> Message:
|
|
s = self.state
|
|
body = bytes(
|
|
[
|
|
1 if s.time_set else 0,
|
|
s.year & 0xFF,
|
|
s.month & 0xFF,
|
|
s.day & 0xFF,
|
|
s.day_of_week & 0xFF,
|
|
s.hour & 0xFF,
|
|
s.minute & 0xFF,
|
|
s.second & 0xFF,
|
|
s.daylight_saving & 0xFF,
|
|
s.sunrise_hour & 0xFF,
|
|
s.sunrise_minute & 0xFF,
|
|
s.sunset_hour & 0xFF,
|
|
s.sunset_minute & 0xFF,
|
|
s.battery & 0xFF,
|
|
]
|
|
)
|
|
# No area alarms appended — real panels can append 2 bytes per area.
|
|
return encode_v2(OmniLink2MessageType.SystemStatus, body)
|
|
|
|
def _reply_properties(self, payload: bytes) -> Message:
|
|
# RequestProperties payload (after opcode): ObjectType, IndexNumber(2),
|
|
# RelativeDirection(sbyte), Filter1, Filter2, Filter3.
|
|
if len(payload) < 7:
|
|
return _build_nak(OmniLink2MessageType.RequestProperties)
|
|
obj_type = payload[0]
|
|
index = (payload[1] << 8) | payload[2]
|
|
rel = payload[3]
|
|
|
|
store = self._object_store(obj_type)
|
|
if store is None:
|
|
return _build_nak(OmniLink2MessageType.RequestProperties)
|
|
|
|
# rel: 0 = exact, 1 = next defined > index, -1/0xFF = previous defined < index.
|
|
if rel == 0:
|
|
target = index if index in store else None
|
|
elif rel == 1:
|
|
candidates = sorted(i for i in store if i > index)
|
|
target = candidates[0] if candidates else None
|
|
elif rel in (0xFF, -1 & 0xFF): # signed -1 byte
|
|
candidates = sorted((i for i in store if i < index), reverse=True)
|
|
target = candidates[0] if candidates else None
|
|
else:
|
|
return _build_nak(OmniLink2MessageType.RequestProperties)
|
|
|
|
if target is None:
|
|
# End of iteration: real panels return EOD (opcode 3) here.
|
|
return encode_v2(OmniLink2MessageType.EOD, b"")
|
|
|
|
if obj_type == _OBJ_ZONE:
|
|
return self._build_zone_properties(target)
|
|
if obj_type == _OBJ_UNIT:
|
|
return self._build_unit_properties(target)
|
|
if obj_type == _OBJ_AREA:
|
|
return self._build_area_properties(target)
|
|
if obj_type == _OBJ_THERMOSTAT:
|
|
return self._build_thermostat_properties(target)
|
|
if obj_type == _OBJ_BUTTON:
|
|
return self._build_button_properties(target)
|
|
return _build_nak(OmniLink2MessageType.RequestProperties)
|
|
|
|
def _object_store(self, obj_type: int) -> dict[int, object] | None:
|
|
if obj_type == _OBJ_ZONE:
|
|
return self.state.zones # type: ignore[return-value]
|
|
if obj_type == _OBJ_UNIT:
|
|
return self.state.units # type: ignore[return-value]
|
|
if obj_type == _OBJ_AREA:
|
|
return self.state.areas # type: ignore[return-value]
|
|
if obj_type == _OBJ_THERMOSTAT:
|
|
return self.state.thermostats # type: ignore[return-value]
|
|
if obj_type == _OBJ_BUTTON:
|
|
return self.state.buttons # type: ignore[return-value]
|
|
return None
|
|
|
|
def _build_zone_properties(self, index: int) -> Message:
|
|
# Properties.Data layout for Zone (1-indexed offsets are into Data[]):
|
|
# [0]=opcode, [1]=ObjectType, [2..3]=ObjectNumber,
|
|
# [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,
|
|
zone.status_byte if zone else 0,
|
|
zone.loop if zone else 0,
|
|
zone.zone_type if zone else 0,
|
|
zone.area if zone else 1,
|
|
zone.options if zone else 0,
|
|
]
|
|
)
|
|
+ self.state.zone_name_bytes(index)
|
|
)
|
|
return encode_v2(OmniLink2MessageType.Properties, body)
|
|
|
|
def _build_unit_properties(self, index: int) -> Message:
|
|
# Properties.Data for Unit:
|
|
# [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,
|
|
unit.state if unit else 0,
|
|
(unit.time_remaining >> 8) & 0xFF if unit else 0,
|
|
unit.time_remaining & 0xFF if unit else 0,
|
|
unit.unit_type if unit else 1,
|
|
]
|
|
)
|
|
+ self.state.unit_name_bytes(index)
|
|
+ bytes([0, (unit.areas if unit else 0x01)])
|
|
)
|
|
return encode_v2(OmniLink2MessageType.Properties, body)
|
|
|
|
def _build_thermostat_properties(self, index: int) -> Message:
|
|
# Properties.Data layout for Thermostat (Data[0]=opcode, body starts
|
|
# at Data[1]. ``payload`` here strips the opcode; payload[i]==Data[i+1]):
|
|
# payload[0] object type (Thermostat = 6)
|
|
# payload[1..2] object number (BE u16)
|
|
# payload[3] communicating flag
|
|
# payload[4] temperature raw
|
|
# payload[5] heat setpoint raw
|
|
# payload[6] cool setpoint raw
|
|
# payload[7] mode
|
|
# payload[8] fan mode
|
|
# payload[9] hold mode
|
|
# payload[10] thermostat type
|
|
# payload[11..22] 12-byte name
|
|
t = self.state.thermostats.get(index)
|
|
body = (
|
|
bytes(
|
|
[
|
|
_OBJ_THERMOSTAT,
|
|
(index >> 8) & 0xFF,
|
|
index & 0xFF,
|
|
t.status if t else 0, # communicating flag
|
|
t.temperature_raw if t else 0,
|
|
t.heat_setpoint_raw if t else 0,
|
|
t.cool_setpoint_raw if t else 0,
|
|
t.system_mode if t else 0,
|
|
t.fan_mode if t else 0,
|
|
t.hold_mode if t else 0,
|
|
t.thermostat_type if t else 1,
|
|
]
|
|
)
|
|
+ self.state.thermostat_name_bytes(index)
|
|
)
|
|
return encode_v2(OmniLink2MessageType.Properties, body)
|
|
|
|
def _build_button_properties(self, index: int) -> Message:
|
|
# Properties.Data layout for Button:
|
|
# payload[0] object type (Button = 3)
|
|
# payload[1..2] object number (BE u16)
|
|
# payload[3..14] 12-byte name (NUL-padded)
|
|
body = (
|
|
bytes(
|
|
[
|
|
_OBJ_BUTTON,
|
|
(index >> 8) & 0xFF,
|
|
index & 0xFF,
|
|
]
|
|
)
|
|
+ self.state.button_name_bytes(index)
|
|
)
|
|
return encode_v2(OmniLink2MessageType.Properties, body)
|
|
|
|
def _build_area_properties(self, index: int) -> Message:
|
|
# Properties.Data for Area:
|
|
# [0]=opcode, [1]=ObjectType, [2..3]=ObjectNumber,
|
|
# [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,
|
|
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 if (area and area.enabled) else 0),
|
|
area.exit_delay if area else 60,
|
|
area.entry_delay if area else 30,
|
|
]
|
|
)
|
|
+ self.state.area_name_bytes(index)
|
|
)
|
|
return encode_v2(OmniLink2MessageType.Properties, body)
|
|
|
|
# -------- 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,
|
|
client_seq: int,
|
|
message: Message,
|
|
session_key: bytes,
|
|
writer: asyncio.StreamWriter,
|
|
) -> None:
|
|
plaintext = message.encode()
|
|
ciphertext = encrypt_message_payload(plaintext, client_seq, session_key)
|
|
pkt = Packet(seq=client_seq, type=PacketType.OmniLink2Message, data=ciphertext)
|
|
writer.write(pkt.encode())
|
|
await writer.drain()
|
|
|
|
# ============================================================
|
|
# v1 wire-dialect dispatch (panels listening UDP-only)
|
|
# ============================================================
|
|
#
|
|
# Same crypto + handshake as v2; only the outer ``PacketType``
|
|
# (OmniLinkMessage = 16 vs OmniLink2Message = 32) and the inner
|
|
# ``Message.start_char`` (0x5A NonAddressable vs 0x21 OmniLink2)
|
|
# differ. The dispatch table here mirrors what ``OmniClientV1``
|
|
# actually sends — see omni_pca.v1.client.
|
|
|
|
def _dispatch_v1(
|
|
self, opcode: int, payload: bytes
|
|
) -> tuple[Message, tuple[int, ...]]:
|
|
"""Dispatch a single v1 request, return (reply_msg, push_words).
|
|
|
|
Mirrors :meth:`_dispatch_v2` but for v1 opcodes (see enuOmniLinkMessageType).
|
|
"""
|
|
if opcode == OmniLinkMessageType.RequestSystemInformation:
|
|
return self._v1_reply_system_information(), ()
|
|
if opcode == OmniLinkMessageType.RequestSystemStatus:
|
|
return self._v1_reply_system_status(), ()
|
|
if opcode == OmniLinkMessageType.RequestZoneStatus:
|
|
return self._v1_reply_zone_status(payload), ()
|
|
if opcode == OmniLinkMessageType.RequestUnitStatus:
|
|
return self._v1_reply_unit_status(payload), ()
|
|
if opcode == OmniLinkMessageType.RequestThermostatStatus:
|
|
return self._v1_reply_thermostat_status(payload), ()
|
|
if opcode == OmniLinkMessageType.RequestAuxiliaryStatus:
|
|
return self._v1_reply_auxiliary_status(payload), ()
|
|
if opcode == OmniLinkMessageType.UploadNames:
|
|
return self._v1_start_upload_names_stream(), ()
|
|
if opcode == OmniLinkMessageType.UploadPrograms:
|
|
return self._v1_start_upload_programs_stream(), ()
|
|
if opcode == OmniLinkMessageType.Ack:
|
|
# During an active stream, each client Ack advances the
|
|
# appropriate cursor. With no active stream, Ack as a request
|
|
# opcode is only meaningful mid-stream — NAK it.
|
|
if self._upload_programs_cursor is not None:
|
|
return self._v1_advance_upload_programs_stream(), ()
|
|
if self._upload_names_cursor is not None:
|
|
return self._v1_advance_upload_names_stream(), ()
|
|
return _build_v1_nak(opcode), ()
|
|
if opcode == OmniLinkMessageType.Command:
|
|
return self._v1_handle_command(payload)
|
|
if opcode == OmniLinkMessageType.ExecuteSecurityCommand:
|
|
return self._v1_handle_execute_security_command(payload)
|
|
return _build_v1_nak(opcode), ()
|
|
|
|
# ---- v1 reply builders ----
|
|
|
|
def _v1_reply_system_information(self) -> Message:
|
|
# Wire layout is byte-identical to v2 (clsOLMsgSystemInformation.cs
|
|
# vs clsOL2MsgSystemInformation.cs) -- only the opcode differs.
|
|
s = self.state
|
|
body = bytes(
|
|
[
|
|
s.model_byte & 0xFF,
|
|
s.firmware_major & 0xFF,
|
|
s.firmware_minor & 0xFF,
|
|
s.firmware_revision & 0xFF,
|
|
]
|
|
) + _name_bytes(s.local_phone, _PHONE_LEN)
|
|
return encode_v1(OmniLinkMessageType.SystemInformation, body)
|
|
|
|
def _v1_reply_system_status(self) -> Message:
|
|
# Bytes 0..13 byte-identical to v2 SystemStatus.
|
|
# After byte 13 the v1 wire carries per-area Mode bytes (one byte
|
|
# each) instead of v2's 2-byte alarm pairs. We emit eight zero
|
|
# mode bytes so OmniClientV1 sees 8 areas reporting OFF.
|
|
s = self.state
|
|
body = bytes(
|
|
[
|
|
1 if s.time_set else 0,
|
|
s.year & 0xFF, s.month & 0xFF, s.day & 0xFF,
|
|
s.day_of_week & 0xFF,
|
|
s.hour & 0xFF, s.minute & 0xFF, s.second & 0xFF,
|
|
s.daylight_saving & 0xFF,
|
|
s.sunrise_hour & 0xFF, s.sunrise_minute & 0xFF,
|
|
s.sunset_hour & 0xFF, s.sunset_minute & 0xFF,
|
|
s.battery & 0xFF,
|
|
]
|
|
)
|
|
# Per-area mode bytes (8 areas on Omni Pro II).
|
|
for idx in range(1, 9):
|
|
area = self.state.areas.get(idx)
|
|
body += bytes([area.mode if area else 0])
|
|
return encode_v1(OmniLinkMessageType.SystemStatus, body)
|
|
|
|
@staticmethod
|
|
def _v1_decode_range(payload: bytes) -> tuple[int, int] | None:
|
|
"""Decode RequestUnitStatus / RequestZoneStatus / ... range payload.
|
|
|
|
Short form (both ≤ 255): 2 bytes [start, end].
|
|
Long form (either > 255): 4 bytes [start_hi, start_lo, end_hi, end_lo].
|
|
See clsOLMsgRequestUnitStatus.cs:18-31.
|
|
"""
|
|
if len(payload) == 2:
|
|
return payload[0], payload[1]
|
|
if len(payload) == 4:
|
|
return (payload[0] << 8) | payload[1], (payload[2] << 8) | payload[3]
|
|
return None
|
|
|
|
def _v1_reply_zone_status(self, payload: bytes) -> Message:
|
|
rng = self._v1_decode_range(payload)
|
|
if rng is None or rng[0] > rng[1]:
|
|
return _build_v1_nak(OmniLinkMessageType.RequestZoneStatus)
|
|
start, end = rng
|
|
records = b""
|
|
for idx in range(start, end + 1):
|
|
z = self.state.zones.get(idx)
|
|
if z is not None:
|
|
records += bytes([z.status_byte, z.loop])
|
|
else:
|
|
# Slots without a defined zone still respond with
|
|
# zero bytes -- real panels do this too.
|
|
records += b"\x00\x00"
|
|
return encode_v1(OmniLinkMessageType.ZoneStatus, records)
|
|
|
|
def _v1_reply_unit_status(self, payload: bytes) -> Message:
|
|
rng = self._v1_decode_range(payload)
|
|
if rng is None or rng[0] > rng[1]:
|
|
return _build_v1_nak(OmniLinkMessageType.RequestUnitStatus)
|
|
start, end = rng
|
|
records = b""
|
|
for idx in range(start, end + 1):
|
|
u = self.state.units.get(idx)
|
|
if u is not None:
|
|
records += bytes(
|
|
[u.state, (u.time_remaining >> 8) & 0xFF, u.time_remaining & 0xFF]
|
|
)
|
|
else:
|
|
records += b"\x00\x00\x00"
|
|
return encode_v1(OmniLinkMessageType.UnitStatus, records)
|
|
|
|
def _v1_reply_thermostat_status(self, payload: bytes) -> Message:
|
|
rng = self._v1_decode_range(payload)
|
|
if rng is None or rng[0] > rng[1]:
|
|
return _build_v1_nak(OmniLinkMessageType.RequestThermostatStatus)
|
|
start, end = rng
|
|
records = b""
|
|
for idx in range(start, end + 1):
|
|
t = self.state.thermostats.get(idx)
|
|
if t is not None:
|
|
records += bytes(
|
|
[
|
|
1, # communicating
|
|
t.temperature_raw, t.heat_setpoint_raw,
|
|
t.cool_setpoint_raw,
|
|
t.system_mode, t.fan_mode, t.hold_mode,
|
|
]
|
|
)
|
|
else:
|
|
records += b"\x00" * 7
|
|
return encode_v1(OmniLinkMessageType.ThermostatStatus, records)
|
|
|
|
def _v1_reply_auxiliary_status(self, payload: bytes) -> Message:
|
|
rng = self._v1_decode_range(payload)
|
|
if rng is None or rng[0] > rng[1]:
|
|
return _build_v1_nak(OmniLinkMessageType.RequestAuxiliaryStatus)
|
|
start, end = rng
|
|
# MockState has no aux sensors -- return zero records.
|
|
records = b"\x00\x00\x00\x00" * (end - start + 1)
|
|
return encode_v1(OmniLinkMessageType.AuxiliaryStatus, records)
|
|
|
|
# ---- UploadNames streaming ----
|
|
|
|
# NameType enum (from omni_pca.v1.messages.NameType -- duplicated here
|
|
# so the mock doesn't depend on the v1 subpackage at import time).
|
|
_V1_NAME_TYPE_ZONE: ClassVar[int] = 1
|
|
_V1_NAME_TYPE_UNIT: ClassVar[int] = 2
|
|
_V1_NAME_TYPE_BUTTON: ClassVar[int] = 3
|
|
_V1_NAME_TYPE_AREA: ClassVar[int] = 5
|
|
_V1_NAME_TYPE_THERMOSTAT: ClassVar[int] = 6
|
|
|
|
_V1_NAME_TYPE_LENGTH: ClassVar[dict[int, int]] = {
|
|
1: 15, # Zone
|
|
2: 12, # Unit
|
|
3: 12, # Button
|
|
4: 12, # Code (unused by mock)
|
|
5: 12, # Area
|
|
6: 12, # Thermostat
|
|
7: 15, # Message (unused by mock)
|
|
}
|
|
|
|
def _v1_name_stream(self) -> list[tuple[int, int, str]]:
|
|
"""Flat list of (NameType, number, name) tuples — the panel emits
|
|
these in the order Zone → Unit → Button → Code → Area →
|
|
Thermostat → Message during ``UploadNames`` streaming.
|
|
Empty-named objects are skipped (matches real-panel behavior).
|
|
"""
|
|
items: list[tuple[int, int, str]] = []
|
|
for idx in sorted(self.state.zones):
|
|
n = self.state.zones[idx].name
|
|
if n:
|
|
items.append((self._V1_NAME_TYPE_ZONE, idx, n))
|
|
for idx in sorted(self.state.units):
|
|
n = self.state.units[idx].name
|
|
if n:
|
|
items.append((self._V1_NAME_TYPE_UNIT, idx, n))
|
|
for idx in sorted(self.state.buttons):
|
|
n = self.state.buttons[idx].name
|
|
if n:
|
|
items.append((self._V1_NAME_TYPE_BUTTON, idx, n))
|
|
for idx in sorted(self.state.areas):
|
|
n = self.state.areas[idx].name
|
|
if n:
|
|
items.append((self._V1_NAME_TYPE_AREA, idx, n))
|
|
for idx in sorted(self.state.thermostats):
|
|
n = self.state.thermostats[idx].name
|
|
if n:
|
|
items.append((self._V1_NAME_TYPE_THERMOSTAT, idx, n))
|
|
return items
|
|
|
|
def _v1_namedata_msg(self, type_byte: int, num: int, name: str) -> Message:
|
|
"""Encode a single NameData reply payload (clsOLMsgNameData.cs)."""
|
|
L = self._V1_NAME_TYPE_LENGTH.get(type_byte, 12)
|
|
encoded = name.encode("utf-8")[:L].ljust(L, b"\x00")
|
|
if num <= 0xFF:
|
|
body = bytes([type_byte, num]) + encoded + b"\x00"
|
|
else:
|
|
body = bytes(
|
|
[type_byte, (num >> 8) & 0xFF, num & 0xFF]
|
|
) + encoded + b"\x00"
|
|
return encode_v1(OmniLinkMessageType.NameData, body)
|
|
|
|
def _v1_start_upload_names_stream(self) -> Message:
|
|
"""Handle bare ``UploadNames`` request -- send first NameData
|
|
(or EOD immediately if no defined names)."""
|
|
names = self._v1_name_stream()
|
|
if not names:
|
|
self._upload_names_cursor = None
|
|
return _build_v1_eod()
|
|
self._upload_names_cursor = 0
|
|
t, n, name = names[0]
|
|
return self._v1_namedata_msg(t, n, name)
|
|
|
|
def _v1_advance_upload_names_stream(self) -> Message:
|
|
"""Handle client ``Acknowledge`` during an active stream -- send
|
|
the next NameData or EOD when exhausted."""
|
|
names = self._v1_name_stream()
|
|
# _upload_names_cursor != None implied by caller
|
|
assert self._upload_names_cursor is not None
|
|
self._upload_names_cursor += 1
|
|
if self._upload_names_cursor >= len(names):
|
|
self._upload_names_cursor = None
|
|
return _build_v1_eod()
|
|
t, n, name = names[self._upload_names_cursor]
|
|
return self._v1_namedata_msg(t, n, name)
|
|
|
|
# ---- UploadPrograms streaming ----
|
|
#
|
|
# Wire flow per clsHAC.OL1ReadConfig (clsHAC.cs:4403, 4538-4540, 4642-4651):
|
|
# client → UploadPrograms (bare)
|
|
# panel → ProgramData (slot N body)
|
|
# client → Ack
|
|
# panel → ProgramData (slot N+1 body) ...
|
|
# panel → EOD
|
|
#
|
|
# ProgramData body layout matches v2 exactly (clsOLMsgProgramData
|
|
# mirrors clsOL2MsgProgramData byte-for-byte) — both prepend a 2-byte
|
|
# BE ProgramNumber to the 14-byte wire body. Only the outer envelope
|
|
# opcode differs (v1 vs v2).
|
|
|
|
def _v1_program_stream(self) -> list[int]:
|
|
"""Sorted list of defined program slot numbers."""
|
|
return sorted(self.state.programs)
|
|
|
|
def _v1_programdata_msg(self, slot: int) -> Message:
|
|
body = self.state.programs.get(slot, b"\x00" * 14)
|
|
payload = bytes([(slot >> 8) & 0xFF, slot & 0xFF]) + body
|
|
return encode_v1(OmniLinkMessageType.ProgramData, payload)
|
|
|
|
def _v1_start_upload_programs_stream(self) -> Message:
|
|
slots = self._v1_program_stream()
|
|
if not slots:
|
|
self._upload_programs_cursor = None
|
|
return _build_v1_eod()
|
|
self._upload_programs_cursor = 0
|
|
return self._v1_programdata_msg(slots[0])
|
|
|
|
def _v1_advance_upload_programs_stream(self) -> Message:
|
|
slots = self._v1_program_stream()
|
|
assert self._upload_programs_cursor is not None
|
|
self._upload_programs_cursor += 1
|
|
if self._upload_programs_cursor >= len(slots):
|
|
self._upload_programs_cursor = None
|
|
return _build_v1_eod()
|
|
return self._v1_programdata_msg(slots[self._upload_programs_cursor])
|
|
|
|
# ---- v1 Command / ExecuteSecurityCommand wrappers ----
|
|
# The wire payload format is byte-identical to v2 (clsOLMsgCommand.cs
|
|
# vs clsOL2MsgCommand.cs); only the outer opcode and the reply Ack
|
|
# opcode (v1=5 vs v2=1) differ. We reuse the v2 state-mutation
|
|
# helper and just wrap the reply.
|
|
|
|
def _v1_handle_command(
|
|
self, payload: bytes
|
|
) -> tuple[Message, tuple[int, ...]]:
|
|
v2_reply, push_words = self._handle_command(payload)
|
|
if v2_reply.opcode == int(OmniLink2MessageType.Ack):
|
|
return _build_v1_ack(), push_words
|
|
# Pass-through Nak (no state mutation push when command refused).
|
|
return _build_v1_nak(OmniLinkMessageType.Command), ()
|
|
|
|
def _v1_handle_execute_security_command(
|
|
self, payload: bytes
|
|
) -> tuple[Message, tuple[int, ...]]:
|
|
v2_reply, push_words = self._handle_execute_security_command(payload)
|
|
if v2_reply.opcode == int(OmniLink2MessageType.Ack):
|
|
return _build_v1_ack(), push_words
|
|
if v2_reply.opcode == int(
|
|
OmniLink2MessageType.ExecuteSecurityCommandResponse
|
|
):
|
|
# Preserve the structured response (status byte) but rebuild
|
|
# with v1 opcode so OmniClientV1 sees opcode 103, not 75.
|
|
return (
|
|
encode_v1(
|
|
OmniLinkMessageType.ExecuteSecurityCommandResponse,
|
|
v2_reply.payload,
|
|
),
|
|
push_words,
|
|
)
|
|
return _build_v1_nak(OmniLinkMessageType.ExecuteSecurityCommand), ()
|
|
|
|
async def _send_v1_reply(
|
|
self,
|
|
client_seq: int,
|
|
message: Message,
|
|
session_key: bytes,
|
|
writer: asyncio.StreamWriter,
|
|
) -> None:
|
|
plaintext = message.encode()
|
|
ciphertext = encrypt_message_payload(plaintext, client_seq, session_key)
|
|
pkt = Packet(seq=client_seq, type=PacketType.OmniLinkMessage, data=ciphertext)
|
|
writer.write(pkt.encode())
|
|
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.
|
|
|
|
The C# clsOL2MsgNegativeAcknowledge has only the opcode byte; some HAI
|
|
docs show a single trailing data byte but it is not defined. We include
|
|
the offending opcode for ease of debugging — the client side cares only
|
|
that the opcode is Nak.
|
|
"""
|
|
return encode_v2(OmniLink2MessageType.Nak, bytes([in_reply_to_opcode & 0xFF]))
|
|
|
|
|
|
# ---- v1 wire-dialect counterparts ----
|
|
|
|
def _build_v1_ack() -> Message:
|
|
"""Build a v1 Ack (opcode 5) with no payload."""
|
|
return encode_v1(OmniLinkMessageType.Ack, b"")
|
|
|
|
|
|
def _build_v1_nak(in_reply_to_opcode: int) -> Message:
|
|
"""Build a v1 Nak (opcode 6) carrying the offending opcode byte."""
|
|
return encode_v1(
|
|
OmniLinkMessageType.Nak, bytes([int(in_reply_to_opcode) & 0xFF])
|
|
)
|
|
|
|
|
|
def _build_v1_eod() -> Message:
|
|
"""Build a v1 EOD (opcode 3) -- the end-of-stream marker for bulk
|
|
downloads like ``UploadNames`` and ``UploadSetup``."""
|
|
return encode_v1(OmniLinkMessageType.EOD, b"")
|
|
|
|
|
|
async def _read_exact(reader: asyncio.StreamReader, n: int) -> bytes | None:
|
|
"""Read exactly ``n`` bytes or return None if EOF arrives early."""
|
|
try:
|
|
return await reader.readexactly(n)
|
|
except asyncio.IncompleteReadError:
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# UDP server protocol
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class _MockServerDatagramProtocol(asyncio.DatagramProtocol):
|
|
"""Datagram-side implementation of the mock panel.
|
|
|
|
Tracks a single active client (the most-recent peer to issue a
|
|
``ClientRequestNewSession``) and routes its session state. The same
|
|
reply-builder helpers (``_reply_system_information``,
|
|
``_build_zone_properties``, etc.) on the parent ``MockPanel`` are
|
|
reused for both transports — the only thing UDP-specific here is
|
|
framing (each datagram is one complete Packet) and the absence of
|
|
per-block stream reads.
|
|
"""
|
|
|
|
def __init__(self, panel: MockPanel) -> None:
|
|
self._panel = panel
|
|
self._transport: asyncio.DatagramTransport | None = None
|
|
self._client_addr: tuple[str, int] | None = None
|
|
self._session_id: bytes | None = None
|
|
self._session_key: bytes | None = None
|
|
|
|
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
|
self._transport = transport # type: ignore[assignment]
|
|
|
|
def connection_lost(self, exc: Exception | None) -> None:
|
|
if exc is not None:
|
|
_log.warning("mock panel (udp) transport lost: %s", exc)
|
|
|
|
def error_received(self, exc: Exception) -> None:
|
|
_log.warning("mock panel (udp) socket error: %s", exc)
|
|
|
|
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
# Each datagram is a complete Packet. Spawn a task so we can do
|
|
# async work (drain push-event tasks, etc.) without blocking the
|
|
# protocol callback. We track the task so the GC doesn't drop the
|
|
# reference mid-flight (RUF006).
|
|
task = asyncio.create_task(
|
|
self._handle_datagram(data, addr),
|
|
name=f"mock-panel-udp-rx-{addr[0]}-{addr[1]}",
|
|
)
|
|
self._panel._push_tasks.add(task)
|
|
task.add_done_callback(self._panel._push_tasks.discard)
|
|
|
|
async def _handle_datagram(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
try:
|
|
pkt = Packet.decode(data)
|
|
except Exception as exc:
|
|
_log.warning("mock panel (udp) malformed datagram from %s: %s", addr, exc)
|
|
return
|
|
|
|
try:
|
|
await self._dispatch_packet(pkt, addr)
|
|
except Exception:
|
|
_log.exception("mock panel (udp) crashed on packet seq=%d", pkt.seq)
|
|
|
|
def _send(self, pkt: Packet, addr: tuple[str, int]) -> None:
|
|
if self._transport is None:
|
|
return
|
|
self._transport.sendto(pkt.encode(), addr)
|
|
|
|
async def _dispatch_packet(
|
|
self, pkt: Packet, addr: tuple[str, int]
|
|
) -> None:
|
|
if pkt.type is PacketType.ClientRequestNewSession:
|
|
session_id = self._panel._session_id_provider()
|
|
self._session_id = session_id
|
|
self._session_key = derive_session_key(
|
|
self._panel._controller_key, session_id
|
|
)
|
|
self._client_addr = addr
|
|
payload = bytes([_PROTO_HI, _PROTO_LO]) + session_id
|
|
self._send(
|
|
Packet(
|
|
seq=pkt.seq,
|
|
type=PacketType.ControllerAckNewSession,
|
|
data=payload,
|
|
),
|
|
addr,
|
|
)
|
|
return
|
|
|
|
if pkt.type is PacketType.ClientRequestSecureSession:
|
|
if self._session_key is None or self._session_id is None:
|
|
_log.debug("mock panel (udp) secure-session before NewSession")
|
|
return
|
|
try:
|
|
plaintext = decrypt_message_payload(
|
|
pkt.data, pkt.seq, self._session_key
|
|
)
|
|
except Exception:
|
|
_log.debug("mock panel (udp) failed to decrypt secure-session")
|
|
return
|
|
if not plaintext.startswith(self._session_id):
|
|
self._send(
|
|
Packet(
|
|
seq=pkt.seq,
|
|
type=PacketType.ControllerSessionTerminated,
|
|
data=b"",
|
|
),
|
|
addr,
|
|
)
|
|
return
|
|
ciphertext_out = encrypt_message_payload(
|
|
self._session_id, pkt.seq, self._session_key
|
|
)
|
|
self._send(
|
|
Packet(
|
|
seq=pkt.seq,
|
|
type=PacketType.ControllerAckSecureSession,
|
|
data=ciphertext_out,
|
|
),
|
|
addr,
|
|
)
|
|
self._panel._session_count += 1
|
|
return
|
|
|
|
if pkt.type is PacketType.ClientSessionTerminated:
|
|
self._session_id = None
|
|
self._session_key = None
|
|
self._client_addr = None
|
|
return
|
|
|
|
if pkt.type is PacketType.OmniLink2Message:
|
|
if self._session_key is None:
|
|
_log.debug("mock panel (udp) encrypted message before secure session")
|
|
return
|
|
await self._handle_encrypted_udp(pkt, addr)
|
|
return
|
|
|
|
if pkt.type is PacketType.OmniLinkMessage:
|
|
# v1 wire dialect — the canonical UDP-only dialect real
|
|
# panels speak. Routes through MockPanel._dispatch_v1.
|
|
if self._session_key is None:
|
|
_log.debug("mock panel (udp) v1 message before secure session")
|
|
return
|
|
await self._handle_encrypted_udp_v1(pkt, addr)
|
|
return
|
|
|
|
_log.debug("mock panel (udp) unhandled packet type %s", pkt.type.name)
|
|
|
|
async def _handle_encrypted_udp(
|
|
self, pkt: Packet, addr: tuple[str, int]
|
|
) -> None:
|
|
assert self._session_key is not None
|
|
try:
|
|
plaintext = decrypt_message_payload(
|
|
pkt.data, pkt.seq, self._session_key
|
|
)
|
|
except Exception:
|
|
_log.debug("mock panel (udp) failed to decrypt v2 message")
|
|
return
|
|
try:
|
|
inner = Message.decode(plaintext)
|
|
except (MessageCrcError, MessageFormatError):
|
|
await self._send_reply(pkt.seq, _build_nak(0), addr)
|
|
return
|
|
|
|
opcode = inner.opcode
|
|
self._panel._last_request_opcode = opcode
|
|
# _dispatch_v2 is the same opcode-dispatch table the TCP path uses,
|
|
# returning (reply_message, push_event_words) so we can write the
|
|
# synchronous reply first then schedule an unsolicited push.
|
|
reply, push_words = self._panel._dispatch_v2(opcode, inner.payload)
|
|
await self._send_reply(pkt.seq, reply, addr)
|
|
if push_words:
|
|
self._schedule_udp_push(push_words, addr)
|
|
|
|
async def _handle_encrypted_udp_v1(
|
|
self, pkt: Packet, addr: tuple[str, int]
|
|
) -> None:
|
|
"""v1 UDP counterpart of :meth:`_handle_encrypted_udp`.
|
|
|
|
Same crypto, different inner-message dialect (StartChar 0x5A,
|
|
v1 opcodes) and different outer reply type (``OmniLinkMessage``
|
|
= 16, not 32).
|
|
"""
|
|
assert self._session_key is not None
|
|
try:
|
|
plaintext = decrypt_message_payload(
|
|
pkt.data, pkt.seq, self._session_key
|
|
)
|
|
except Exception:
|
|
_log.debug("mock panel (udp) failed to decrypt v1 message")
|
|
return
|
|
try:
|
|
inner = Message.decode(plaintext)
|
|
except (MessageCrcError, MessageFormatError):
|
|
await self._send_reply_v1(pkt.seq, _build_v1_nak(0), addr)
|
|
return
|
|
|
|
opcode = inner.opcode
|
|
self._panel._last_request_opcode = opcode
|
|
reply, _push_words = self._panel._dispatch_v1(opcode, inner.payload)
|
|
await self._send_reply_v1(pkt.seq, reply, addr)
|
|
|
|
def _schedule_udp_push(
|
|
self, words: tuple[int, ...], addr: tuple[str, int]
|
|
) -> None:
|
|
"""Fire-and-forget unsolicited SystemEvents push to ``addr``."""
|
|
assert self._session_key is not None
|
|
session_key = self._session_key
|
|
|
|
async def _push() -> None:
|
|
await asyncio.sleep(_PUSH_DELAY)
|
|
try:
|
|
msg = _build_system_events_message(words)
|
|
plaintext = msg.encode()
|
|
ciphertext = encrypt_message_payload(plaintext, 0, session_key)
|
|
pkt = Packet(
|
|
seq=0,
|
|
type=PacketType.OmniLink2Message,
|
|
data=ciphertext,
|
|
)
|
|
self._send(pkt, addr)
|
|
except (ConnectionError, asyncio.CancelledError):
|
|
pass
|
|
except Exception: # pragma: no cover - diagnostic only
|
|
_log.exception("mock panel (udp) failed to push event")
|
|
|
|
task = asyncio.create_task(_push(), name="mock-panel-udp-event-push")
|
|
self._panel._push_tasks.add(task)
|
|
task.add_done_callback(self._panel._push_tasks.discard)
|
|
|
|
async def _send_reply(
|
|
self, client_seq: int, message: Message, addr: tuple[str, int]
|
|
) -> None:
|
|
assert self._session_key is not None
|
|
plaintext = message.encode()
|
|
ciphertext = encrypt_message_payload(plaintext, client_seq, self._session_key)
|
|
pkt = Packet(
|
|
seq=client_seq,
|
|
type=PacketType.OmniLink2Message,
|
|
data=ciphertext,
|
|
)
|
|
self._send(pkt, addr)
|
|
|
|
async def _send_reply_v1(
|
|
self, client_seq: int, message: Message, addr: tuple[str, int]
|
|
) -> None:
|
|
"""v1 counterpart of :meth:`_send_reply` -- wraps the encrypted
|
|
reply in an ``OmniLinkMessage`` (16) outer packet instead of
|
|
``OmniLink2Message`` (32)."""
|
|
assert self._session_key is not None
|
|
plaintext = message.encode()
|
|
ciphertext = encrypt_message_payload(plaintext, client_seq, self._session_key)
|
|
pkt = Packet(
|
|
seq=client_seq,
|
|
type=PacketType.OmniLinkMessage,
|
|
data=ciphertext,
|
|
)
|
|
self._send(pkt, addr)
|