From 08974e2ec476fef33fbac94cbd022691117bc0c5 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 10 May 2026 14:02:16 -0600 Subject: [PATCH] Models: 16 status/properties dataclasses + enums + temp converters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/omni_pca/models.py — adds typed parsers for the full Omni object surface beyond the initial Zone/Unit/Area properties: - ZoneStatus, UnitStatus, AreaStatus (live state) - ThermostatProperties, ThermostatStatus (with omni_temp_to_celsius/_fahrenheit via clsText.DecodeTempRaw — the scale is LINEAR, not non-linear as I'd hypothesized: C = raw/2 - 40) - ButtonProperties, ProgramProperties, CodeProperties, MessageProperties - AuxSensorStatus - AudioZoneProperties + AudioZoneStatus - AudioSourceProperties + AudioSourceStatus - UserSettingProperties + UserSettingStatus Module helpers: - IntEnums: ObjectType, SecurityMode, HvacMode, FanMode, HoldMode, ThermostatKind, ZoneType, UserSettingKind - OBJECT_TYPE_TO_PROPERTIES / OBJECT_TYPE_TO_STATUS dispatch tables - omni_temp_to_celsius/_fahrenheit linear conversion (citation: clsText.cs) 42 new tests in tests/test_models_extended.py; 139 total pass. --- src/omni_pca/models.py | 1120 ++++++++++++++++++++++++++++++++- tests/test_models_extended.py | 519 +++++++++++++++ 2 files changed, 1638 insertions(+), 1 deletion(-) create mode 100644 tests/test_models_extended.py diff --git a/src/omni_pca/models.py b/src/omni_pca/models.py index b3ddcb9..7c4c509 100644 --- a/src/omni_pca/models.py +++ b/src/omni_pca/models.py @@ -9,14 +9,26 @@ References: clsOL2MsgSystemInformation.cs — model byte + firmware + phone clsOL2MsgSystemStatus.cs — date/time + battery + alarms clsOL2MsgProperties.cs — per-object-type field offsets + clsOL2MsgExtendedStatus.cs — per-object-type live status + clsOL2MsgAudioSourceStatus.cs — audio source metadata stream + clsZone.cs / clsUnit.cs / clsArea.cs / clsThermostat.cs / clsButton.cs / + clsCode.cs / clsMessage.cs / clsAudioZone.cs / clsAudioSource.cs / + clsUserSetting.cs / clsProgram.cs — domain object semantics + enuObjectType.cs / enuSecurityMode.cs / enuThermostatMode.cs / + enuThermostatFanMode.cs / enuThermostatHoldMode.cs / enuZoneType.cs / + enuZoneCurrentCondition.cs / enuZoneArmingStatus.cs / + enuZoneLachedAlarmStatus.cs / enuUserSettingType.cs / + enuThermostatType.cs — value enums enuModel.cs — model byte → human name clsUtil.ByteArrayToString — null-terminated, latin-1, fixed-width + clsText.DecodeTempRaw — Omni temperature byte → °F/°C """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime +from enum import IntEnum from typing import ClassVar, Self # -------------------------------------------------------------------------- @@ -444,8 +456,1114 @@ class AreaProperties: ) +# -------------------------------------------------------------------------- +# Object type constants and value enums +# -------------------------------------------------------------------------- + + +class ObjectType(IntEnum): + """Object-type byte values (enuObjectType.cs). + + These are the same byte that prefixes every Properties / ExtendedStatus + payload — i.e. ``payload[0]`` for a Properties reply and ``payload[0]`` + for an ExtendedStatus reply (after the opcode is stripped). + """ + + INVALID = 0 + ZONE = 1 + UNIT = 2 + BUTTON = 3 + CODE = 4 + AREA = 5 + THERMOSTAT = 6 + MESSAGE = 7 + AUXILIARY = 8 + AUDIO_SOURCE = 9 + AUDIO_ZONE = 10 + EXPANSION = 11 + CONSOLE = 12 + USER_SETTING = 13 + ACCESS_CONTROL_READER = 14 + ACCESS_CONTROL_LOCK = 15 + + +class SecurityMode(IntEnum): + """Area security mode (enuSecurityMode.cs). + + Values 9..14 are the "arming in progress" variants the panel reports + while a delayed-arm timer is running. + """ + + OFF = 0 + DAY = 1 + NIGHT = 2 + AWAY = 3 + VACATION = 4 + DAY_INSTANT = 5 + NIGHT_DELAYED = 6 + ANY_CHANGE = 7 + ARMING_DAY = 9 + ARMING_NIGHT = 10 + ARMING_AWAY = 11 + ARMING_VACATION = 12 + ARMING_DAY_INSTANT = 13 + ARMING_NIGHT_DELAYED = 14 + + +class HvacMode(IntEnum): + """Thermostat system mode (enuThermostatMode.cs).""" + + OFF = 0 + HEAT = 1 + COOL = 2 + AUTO = 3 + EMERGENCY_HEAT = 4 + + +class FanMode(IntEnum): + """Thermostat fan mode (enuThermostatFanMode.cs).""" + + AUTO = 0 + ON = 1 + CYCLE = 2 + + +class HoldMode(IntEnum): + """Thermostat hold mode (enuThermostatHoldMode.cs). + + Value 255 (``OLD_ON``) is a legacy "Hold" sentinel from older firmware + that some panels still emit; treat it as equivalent to ``HOLD``. + """ + + OFF = 0 + HOLD = 1 + VACATION = 2 + OLD_ON = 0xFF + + +class ThermostatKind(IntEnum): + """Thermostat hardware classification (enuThermostatType.cs).""" + + NOT_USED = 0 + AUTO_HEAT_COOL = 1 + HEAT_COOL = 2 + HEAT_ONLY = 3 + COOL_ONLY = 4 + SETPOINT_ONLY = 5 + + +class ZoneType(IntEnum): + """Zone type (enuZoneType.cs) — common subset. + + The full enum has ~30 entries (extended-range temperature sensors, + DSC-specific types, etc.); we surface the security-relevant ones plus + the temperature/humidity sensors and a handful of utility types. Any + raw byte value still round-trips through ``ZoneStatus.zone_type`` — + it just won't have a named enum member. + """ + + ENTRY_EXIT = 0 + PERIMETER = 1 + NIGHT_INTERIOR = 2 + AWAY_INTERIOR = 3 + DOUBLE_ENTRY_DELAY = 4 + QUAD_ENTRY_DELAY = 5 + LATCHING_PERIMETER = 6 + LATCHING_NIGHT_INTERIOR = 7 + LATCHING_AWAY_INTERIOR = 8 + PANIC = 16 + POLICE_EMERGENCY = 17 + SILENT_DURESS = 18 + TAMPER = 19 + LATCHING_TAMPER = 20 + FIRE = 32 + FIRE_EMERGENCY = 33 + GAS = 34 + AUX_EMERGENCY = 48 + TROUBLE = 49 + FREEZE = 54 + WATER = 55 + FIRE_TAMPER = 56 + AUXILIARY = 64 + KEYSWITCH = 65 + SHUNT_LOCK = 66 + EXIT_TERMINATOR = 67 + ENERGY_SAVER = 80 + OUTDOOR_TEMP = 81 + TEMPERATURE = 82 + TEMP_ALARM = 83 + HUMIDITY = 84 + + +class UserSettingKind(IntEnum): + """User-setting value type (enuUserSettingType.cs).""" + + UNUSED = 0 + NUMBER = 1 + DURATION = 2 + TEMPERATURE = 3 + HUMIDITY = 4 + DATE = 5 + TIME = 6 + DAYS_OF_WEEK = 7 + LEVEL = 8 + + +# -------------------------------------------------------------------------- +# Temperature conversions +# -------------------------------------------------------------------------- + + +def omni_temp_to_celsius(raw: int) -> float: + """Convert Omni's raw temperature byte to °C. + + The panel uses a single linear scale: ``°C = raw / 2 - 40``. Domain + runs from raw=1 (-39.5 °C) through raw=200 (60 °C); raw=0 means "not + available / unknown" and raw>200 is reserved for User-Setting + references — this helper still returns the linear value so callers + can decide how to handle sentinels. + + Reference: clsText.cs:301 (DecodeTempRaw, Celsius branch). + """ + return raw / 2.0 - 40.0 + + +def omni_temp_to_fahrenheit(raw: int) -> float: + """Convert Omni's raw temperature byte to °F. + + The C# code rounds to whole degrees: + ``°F = int(raw * 9 / 10 + 0.5) - 40``. We keep the ``int(...+0.5)`` + rounding to match what PC Access shows on screen — callers that want + the underlying continuous value can derive it from the Celsius + helper instead (``°C * 9/5 + 32``). + + Reference: clsText.cs:301-308 (DecodeTempRaw, Fahrenheit branch). + """ + return float(int(raw * 9.0 / 10.0 + 0.5) - 40) + + +# -------------------------------------------------------------------------- +# ZoneStatus +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class ZoneStatus: + """Live state of a single zone, decoded from one record of an + ExtendedStatus (opcode 35) reply or a per-zone Status reply. + + Wire layout (each record, ObjectType=Zone, ObjectLength=4): + bytes[0..1] zone number (BE u16) + bytes[2] status byte (current+latched+arming, see below) + bytes[3] analog loop reading (0-255) + + Status byte bit layout (clsZone.cs:385, clsText.cs:3110): + bits 0-1 (mask 0x03): current condition + 0=Secure, 1=NotReady, 2=Trouble, 3=Tamper + bits 2-3 (mask 0x0C): latched alarm status + 0=Secure, 4=Tripped, 8=Reset + bits 4-5 (mask 0x30): arming status + 0=Disarmed, 16=Armed, 32=Bypassed, 48=AutoBypassed + bit 6 (mask 0x40): "had trouble" history bit + + Reference: clsOL2MsgExtendedStatus.cs:282-307, clsZone.cs:385-414. + """ + + index: int + raw_status: int + loop: int + + # Sub-field views derived from raw_status. We keep them as ints so + # the caller can pattern-match against ``enuZoneCurrentCondition``, + # ``enuZoneLachedAlarmStatus``, and ``enuZoneArmingStatus`` from the + # decompiled C# without any conversion at our boundary. + + @property + def current_state(self) -> int: + """Low 2 bits — Secure/NotReady/Trouble/Tamper.""" + return self.raw_status & 0x03 + + @property + def latched_state(self) -> int: + """Mid 2 bits as a raw value (0/4/8) — Secure/Tripped/Reset.""" + return self.raw_status & 0x0C + + @property + def arming_state(self) -> int: + """Upper 2 bits as a raw value (0/16/32/48) — Disarmed/Armed/Bypassed/AutoBypassed.""" + return self.raw_status & 0x30 + + @property + def is_secure(self) -> bool: + """True iff current condition is Secure (low 2 bits == 0).""" + return self.current_state == 0 + + @property + def is_open(self) -> bool: + """Convenience: opposite of ``is_secure`` (door/window open or sensor active).""" + return not self.is_secure + + @property + def is_in_alarm(self) -> bool: + """True if the zone is currently tripped (latched bit 0x04 set).""" + return (self.raw_status & 0x04) == 0x04 + + @property + def is_bypassed(self) -> bool: + """True for either user-bypassed or auto-bypassed (bits 0x20/0x30).""" + return (self.raw_status & 0x20) == 0x20 or ( + self.raw_status & 0x30 + ) == 0x30 + + @property + def is_trouble(self) -> bool: + """True if current condition is Trouble or Tamper, OR the + "had trouble" history bit (0x40) is set.""" + return (self.raw_status & 0x02) == 0x02 or ( + self.raw_status & 0x40 + ) == 0x40 + + @classmethod + def parse(cls, payload: bytes) -> Self: + if len(payload) < 4: + raise ValueError( + f"ZoneStatus record too short: {len(payload)} bytes" + ) + return cls( + index=(payload[0] << 8) | payload[1], + raw_status=payload[2], + loop=payload[3], + ) + + +# -------------------------------------------------------------------------- +# UnitStatus +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class UnitStatus: + """Live state of a single unit (light/output), one record of an + ExtendedStatus (opcode 35) reply or a per-unit Status reply. + + Wire layout (each record, ObjectType=Unit, ObjectLength≥5): + bytes[0..1] unit number (BE u16) + bytes[2] state byte (see decoding below) + bytes[3..4] remaining time in seconds (BE u16, 0 = indefinite) + bytes[5..6] optional ZigBee instantaneous power (W, BE u16) + + State byte semantics (clsUnit.cs:405-533): + 0 Off + 1 On + 2..13 Scene A..L (state - 63 → 'A'..'L' as ASCII char) + 17..25 Dim 1..9 (state - 16) + 26 Blink + 33..41 Brighten 1..9 (state - 32) + 100..200 Brightness level percentage (state - 100, range 0-100) + + Reference: clsOL2MsgExtendedStatus.cs:35-73, clsUnit.cs:405-533. + """ + + index: int + state: int + time_remaining_secs: int + + @property + def is_on(self) -> bool: + """Anything other than the explicit Off state (0) counts as on.""" + return self.state != 0 + + @property + def brightness(self) -> int | None: + """Percentage 0-100 if the state byte encodes an absolute level; + otherwise ``None`` (relays, scenes, ramping, blink).""" + if 100 <= self.state <= 200: + return self.state - 100 + if self.state == 0: + return 0 + if self.state == 1: + # On with no level info → treat as 100% so callers don't have + # to special-case relays vs. dimmers when the panel only + # reports On. + return 100 + return None + + @classmethod + def parse(cls, payload: bytes) -> Self: + if len(payload) < 5: + raise ValueError( + f"UnitStatus record too short: {len(payload)} bytes" + ) + return cls( + index=(payload[0] << 8) | payload[1], + state=payload[2], + time_remaining_secs=(payload[3] << 8) | payload[4], + ) + + +# -------------------------------------------------------------------------- +# AreaStatus +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class AreaStatus: + """Live arming state of a single area, one record of an + ExtendedStatus (opcode 35) reply. + + Wire layout (each record, ObjectType=Area, ObjectLength=6): + bytes[0..1] area number (BE u16) + bytes[2] security mode (enuSecurityMode) + bytes[3] area alarms bitfield + bytes[4] entry timer remaining (seconds) + bytes[5] exit timer remaining (seconds) + + The Omni-Link II ExtendedStatus reply does NOT carry "last user" — + that field is exposed only via the EventLog opcode. We keep + ``last_user`` in the dataclass for API parity with the spec; it + defaults to 0 and stays 0 here. + + Reference: clsOL2MsgExtendedStatus.cs:75-118. + """ + + index: int + mode: int + last_user: int + entry_timer_secs: int + exit_timer_secs: int + alarms: int + + @property + def mode_name(self) -> str: + """Human-friendly mode label from ``SecurityMode`` (or ``"Unknown(N)"``).""" + try: + return SecurityMode(self.mode).name + except ValueError: + return f"Unknown({self.mode})" + + @property + def is_armed(self) -> bool: + """True for any mode other than OFF and ANY_CHANGE. + + ``ANY_CHANGE`` (7) is a programming-condition wildcard, not a + real arming state, so we treat it as not-armed for status + purposes. + """ + return self.mode not in ( + SecurityMode.OFF, + SecurityMode.ANY_CHANGE, + ) + + @property + def alarm_active(self) -> bool: + """True if any alarm bit in the bitfield is set.""" + return self.alarms != 0 + + @classmethod + def parse(cls, payload: bytes) -> Self: + if len(payload) < 6: + raise ValueError( + f"AreaStatus record too short: {len(payload)} bytes" + ) + return cls( + index=(payload[0] << 8) | payload[1], + mode=payload[2], + alarms=payload[3], + entry_timer_secs=payload[4], + exit_timer_secs=payload[5], + last_user=0, + ) + + +# -------------------------------------------------------------------------- +# ThermostatProperties +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class ThermostatProperties: + """Parsed Properties (opcode 33) reply for a Thermostat object. + + Wire layout (clsOL2MsgProperties.cs, ObjectType=Thermostat): + payload[0] object type (Thermostat = 6) + payload[1..2] object number (BE u16) + payload[3] communicating flag (1 = thermostat is talking to panel) + payload[4] temperature (raw) + payload[5] heat setpoint (raw) + payload[6] cool setpoint (raw) + payload[7] mode (enuThermostatMode) + payload[8] fan mode (enuThermostatFanMode) + payload[9] hold mode (enuThermostatHoldMode) + payload[10] thermostat type (enuThermostatType) + payload[11..22] 12-byte name, NUL-padded + + Mapping note: the C# accessors index ``Data[N]`` where ``Data[0]`` + is the opcode byte. Our ``payload`` strips that opcode, so + ``payload[i] == Data[i+1]``. That's why the type byte sits at + ``payload[10]`` (Data[11]) and the name at ``payload[11..22]`` + (Data[12..23]). + + Reference: clsOL2MsgProperties.cs:287-393, 694, clsThermostat.cs. + """ + + index: int + name: str + thermostat_type: int + communicating: bool + + @classmethod + def parse(cls, payload: bytes) -> Self: + hdr = _PropertiesHeader.from_payload(payload) + if hdr.object_type != ObjectType.THERMOSTAT: + raise ValueError( + f"expected Thermostat (object_type=6), got {hdr.object_type}" + ) + if len(payload) < 11 + 12: + raise ValueError( + f"ThermostatProperties payload too short: {len(payload)} bytes" + ) + return cls( + index=hdr.object_number, + communicating=payload[3] != 0, + thermostat_type=payload[10], + name=_decode_name(payload[11 : 11 + 12]), + ) + + +# -------------------------------------------------------------------------- +# ThermostatStatus +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class ThermostatStatus: + """Live state of a single thermostat from one record of an + ExtendedStatus reply. + + Wire layout (each record, ObjectType=Thermostat, ObjectLength=14): + bytes[0..1] thermostat number (BE u16) + bytes[2] communicating/status flag + bytes[3] current temperature (raw) + bytes[4] heat setpoint (raw) + bytes[5] cool setpoint (raw) + bytes[6] system mode (enuThermostatMode) + bytes[7] fan mode (enuThermostatFanMode) + bytes[8] hold mode (enuThermostatHoldMode) + bytes[9] humidity (raw, Fahrenheit-scale even on °C panels) + bytes[10] humidify setpoint (raw) + bytes[11] dehumidify setpoint (raw) + bytes[12] outdoor temperature (raw) + bytes[13] H or C status (1=heating, 2=cooling — model-dependent) + + All ``*_raw`` values are bytes on Omni's combined temperature scale + (``°C = raw/2 - 40``); we expose ``*_f`` and ``*_c`` properties that + apply the scale for the common cases. + + Reference: clsOL2MsgExtendedStatus.cs:120-235. + """ + + index: int + status: int + temperature_raw: int + heat_setpoint_raw: int + cool_setpoint_raw: int + system_mode: int + fan_mode: int + hold_mode: int + humidity_raw: int + humidify_setpoint_raw: int + dehumidify_setpoint_raw: int + outdoor_temperature_raw: int + horc_status: int + + @property + def temperature_c(self) -> float: + return omni_temp_to_celsius(self.temperature_raw) + + @property + def temperature_f(self) -> float: + return omni_temp_to_fahrenheit(self.temperature_raw) + + @property + def heat_setpoint_c(self) -> float: + return omni_temp_to_celsius(self.heat_setpoint_raw) + + @property + def heat_setpoint_f(self) -> float: + return omni_temp_to_fahrenheit(self.heat_setpoint_raw) + + @property + def cool_setpoint_c(self) -> float: + return omni_temp_to_celsius(self.cool_setpoint_raw) + + @property + def cool_setpoint_f(self) -> float: + return omni_temp_to_fahrenheit(self.cool_setpoint_raw) + + @property + def outdoor_temperature_c(self) -> float: + return omni_temp_to_celsius(self.outdoor_temperature_raw) + + @property + def outdoor_temperature_f(self) -> float: + return omni_temp_to_fahrenheit(self.outdoor_temperature_raw) + + @property + def humidity_percent(self) -> float: + """Relative humidity as percentage (0-100). + + The panel stores humidity on the same DecodeTemp scale but + always interpreted as Fahrenheit, where the 0..100% range + roughly maps to bytes 89..200 (``F = raw*9/10 + 0.5 - 40``, + clamped 0-100 by the firmware). + """ + return omni_temp_to_fahrenheit(self.humidity_raw) + + @classmethod + def parse(cls, payload: bytes) -> Self: + if len(payload) < 14: + raise ValueError( + f"ThermostatStatus record too short: {len(payload)} bytes" + ) + return cls( + index=(payload[0] << 8) | payload[1], + status=payload[2], + temperature_raw=payload[3], + heat_setpoint_raw=payload[4], + cool_setpoint_raw=payload[5], + system_mode=payload[6], + fan_mode=payload[7], + hold_mode=payload[8], + humidity_raw=payload[9], + humidify_setpoint_raw=payload[10], + dehumidify_setpoint_raw=payload[11], + outdoor_temperature_raw=payload[12], + horc_status=payload[13], + ) + + +# -------------------------------------------------------------------------- +# ButtonProperties +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class ButtonProperties: + """Parsed Properties (opcode 33) reply for a Button object. + + Wire layout (clsOL2MsgProperties.cs, ObjectType=Button): + payload[0] object type (Button = 3) + payload[1..2] object number (BE u16) + payload[3..14] 12-byte name, NUL-padded + + Buttons carry no state of their own (you push them, panel runs the + associated program); only the index + name are exposed here. + + Reference: clsOL2MsgProperties.cs:691, clsButton.cs. + """ + + index: int + name: str + + @classmethod + def parse(cls, payload: bytes) -> Self: + hdr = _PropertiesHeader.from_payload(payload) + if hdr.object_type != ObjectType.BUTTON: + raise ValueError( + f"expected Button (object_type=3), got {hdr.object_type}" + ) + if len(payload) < 3 + 12: + raise ValueError( + f"ButtonProperties payload too short: {len(payload)} bytes" + ) + return cls( + index=hdr.object_number, + name=_decode_name(payload[3 : 3 + 12]), + ) + + +# -------------------------------------------------------------------------- +# ProgramProperties +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class ProgramProperties: + """Parsed program record. + + Programs are not exposed via the standard Properties (opcode 33) + object-type table — clsOL2MsgProperties.ObjectName has no Program + branch, and the panel returns program data through its own + request/reply pair (clsOL2MsgRequestProgramData / Msg2Program in + clsProgram.cs:540). We model what that reply looks like once + deserialised: an index, a name (only meaningful when the program is + a Remark), and the raw 14-byte program body so callers can decode + the conditional/command/schedule fields with help from the + ``clsProgram.cs`` getter accessors. + + Wire layout assumed (after the opcode byte is stripped): + payload[0..1] program number (BE u16) + payload[2..15] 14-byte raw program body (clsProgram.ToByteArray) + payload[16..] optional NUL-terminated remark text + + AMBIGUITY: there is no canonical OL2 Properties opcode for programs, + and clsProgram has no name field of its own — RemarkText is stored + in a separate dictionary keyed by RemarkID. We follow the layout + that the on-disk .pca file uses (number + body + optional remark). + + Reference: clsProgram.cs:564-585 (ToByteArray), clsProgram.cs:301-323 + (RemarkText). + """ + + index: int + name: str + raw_body: bytes + + @classmethod + def parse(cls, payload: bytes) -> Self: + if len(payload) < 2 + 14: + raise ValueError( + f"ProgramProperties payload too short: {len(payload)} bytes" + ) + index = (payload[0] << 8) | payload[1] + body = bytes(payload[2 : 2 + 14]) + remark = _decode_name(payload[2 + 14 :]) if len(payload) > 16 else "" + return cls(index=index, name=remark, raw_body=body) + + +# -------------------------------------------------------------------------- +# CodeProperties +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class CodeProperties: + """Parsed Properties (opcode 33) reply for a Code object. + + Wire layout (clsOL2MsgProperties.cs, ObjectType=Code): + payload[0] object type (Code = 4) + payload[1..2] object number (BE u16) + payload[3..14] 12-byte name, NUL-padded + + NOTE: The actual digit value of the user code is stored on the panel + (clsCode.Code) but the Properties reply only carries the name. Even + if a future firmware were to embed the digits, this dataclass would + deliberately not expose them — printing real PINs through the model + layer is a security-by-design no. + + Reference: clsOL2MsgProperties.cs:692, clsCode.cs. + """ + + index: int + name: str + + @classmethod + def parse(cls, payload: bytes) -> Self: + hdr = _PropertiesHeader.from_payload(payload) + if hdr.object_type != ObjectType.CODE: + raise ValueError( + f"expected Code (object_type=4), got {hdr.object_type}" + ) + if len(payload) < 3 + 12: + raise ValueError( + f"CodeProperties payload too short: {len(payload)} bytes" + ) + return cls( + index=hdr.object_number, + name=_decode_name(payload[3 : 3 + 12]), + ) + + +# -------------------------------------------------------------------------- +# MessageProperties +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class MessageProperties: + """Parsed Properties (opcode 33) reply for a Message object. + + Wire layout (clsOL2MsgProperties.cs, ObjectType=Message): + payload[0] object type (Message = 7) + payload[1..2] object number (BE u16) + payload[3..17] 15-byte name (also used as the message text on + text-display models), NUL-padded + payload[19] area-group bitfield (Data[20] in C# offset) + + Omni's Message objects double as the "name" and the "text"; longer + free-form messages are not part of the v2 properties exchange. + + Reference: clsOL2MsgProperties.cs:455-465, 695, clsMessage.cs. + """ + + index: int + name: str + text: str + areas: int + + @classmethod + def parse(cls, payload: bytes) -> Self: + hdr = _PropertiesHeader.from_payload(payload) + if hdr.object_type != ObjectType.MESSAGE: + raise ValueError( + f"expected Message (object_type=7), got {hdr.object_type}" + ) + if len(payload) < 3 + 15: + raise ValueError( + f"MessageProperties payload too short: {len(payload)} bytes" + ) + name = _decode_name(payload[3 : 3 + 15]) + areas = payload[19] if len(payload) > 19 else 0 + # text == name in OL2; preserved as a distinct field so a future + # extended message reply (clsOLMsgMessageStatus) can populate + # them independently without breaking callers. + return cls(index=hdr.object_number, name=name, text=name, areas=areas) + + +# -------------------------------------------------------------------------- +# AuxSensorStatus +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class AuxSensorStatus: + """Live state of an auxiliary sensor (one record of an + ExtendedStatus reply, ObjectType=Auxiliary). + + Wire layout (each record, ObjectType=Auxillary, ObjectLength=6): + bytes[0..1] aux sensor number (BE u16) + bytes[2] output state byte + bytes[3] current temperature/humidity (raw) + bytes[4] low setpoint (raw) + bytes[5] high setpoint (raw) + + The C# wraps these as a special kind of "zone" (clsZone.Output/High/ + Low/Temp), but the wire reply has its own ObjectType=8 layout. The + raw byte uses Omni's standard temperature scale for temperature + sensors and the Fahrenheit-only scale for humidity sensors. + + Reference: clsOL2MsgExtendedStatus.cs:237-280, clsZone.cs:79-93. + """ + + index: int + output: int + value_raw: int + low_raw: int + high_raw: int + + @property + def temperature_c(self) -> float: + return omni_temp_to_celsius(self.value_raw) + + @property + def temperature_f(self) -> float: + return omni_temp_to_fahrenheit(self.value_raw) + + @property + def low_c(self) -> float: + return omni_temp_to_celsius(self.low_raw) + + @property + def high_c(self) -> float: + return omni_temp_to_celsius(self.high_raw) + + @classmethod + def parse(cls, payload: bytes) -> Self: + if len(payload) < 6: + raise ValueError( + f"AuxSensorStatus record too short: {len(payload)} bytes" + ) + return cls( + index=(payload[0] << 8) | payload[1], + output=payload[2], + value_raw=payload[3], + low_raw=payload[4], + high_raw=payload[5], + ) + + +# -------------------------------------------------------------------------- +# AudioZoneProperties / AudioZoneStatus +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class AudioZoneProperties: + """Parsed Properties (opcode 33) reply for an AudioZone object. + + Wire layout (clsOL2MsgProperties.cs, ObjectType=AudioZone): + payload[0] object type (AudioZone = 10) + payload[1..2] object number (BE u16) + payload[3] power on/off (0 = off) + payload[4] currently selected source + payload[5] volume (0-100) + payload[6] mute (0 = un-muted) + payload[7..18] 12-byte name, NUL-padded + + Reference: clsOL2MsgProperties.cs:527-580, 698, clsAudioZone.cs. + """ + + index: int + name: str + power: bool + source: int + volume: int + mute: bool + + @classmethod + def parse(cls, payload: bytes) -> Self: + hdr = _PropertiesHeader.from_payload(payload) + if hdr.object_type != ObjectType.AUDIO_ZONE: + raise ValueError( + f"expected AudioZone (object_type=10), got {hdr.object_type}" + ) + if len(payload) < 7 + 12: + raise ValueError( + f"AudioZoneProperties payload too short: {len(payload)} bytes" + ) + return cls( + index=hdr.object_number, + power=payload[3] != 0, + source=payload[4], + volume=payload[5], + mute=payload[6] != 0, + name=_decode_name(payload[7 : 7 + 12]), + ) + + +@dataclass(frozen=True, slots=True) +class AudioZoneStatus: + """Live state of one audio zone, one record of an ExtendedStatus reply. + + Wire layout (each record, ObjectType=AudioZone, ObjectLength=6): + bytes[0..1] zone number (BE u16) + bytes[2] power on/off (0 = off) + bytes[3] selected source + bytes[4] volume (0-100) + bytes[5] mute (0 = un-muted) + + Reference: clsOL2MsgExtendedStatus.cs:309-360. + """ + + index: int + power: bool + source: int + volume: int + mute: bool + + @classmethod + def parse(cls, payload: bytes) -> Self: + if len(payload) < 6: + raise ValueError( + f"AudioZoneStatus record too short: {len(payload)} bytes" + ) + return cls( + index=(payload[0] << 8) | payload[1], + power=payload[2] != 0, + source=payload[3], + volume=payload[4], + mute=payload[5] != 0, + ) + + +# -------------------------------------------------------------------------- +# AudioSourceProperties / AudioSourceStatus +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class AudioSourceProperties: + """Parsed Properties (opcode 33) reply for an AudioSource object. + + Wire layout (clsOL2MsgProperties.cs, ObjectType=AudioSource): + payload[0] object type (AudioSource = 9) + payload[1..2] object number (BE u16) + payload[3..14] 12-byte name, NUL-padded + + Reference: clsOL2MsgProperties.cs:697, clsAudioSource.cs. + """ + + index: int + name: str + + @classmethod + def parse(cls, payload: bytes) -> Self: + hdr = _PropertiesHeader.from_payload(payload) + if hdr.object_type != ObjectType.AUDIO_SOURCE: + raise ValueError( + f"expected AudioSource (object_type=9), got {hdr.object_type}" + ) + if len(payload) < 3 + 12: + raise ValueError( + f"AudioSourceProperties payload too short: {len(payload)} bytes" + ) + return cls( + index=hdr.object_number, + name=_decode_name(payload[3 : 3 + 12]), + ) + + +@dataclass(frozen=True, slots=True) +class AudioSourceStatus: + """Parsed AudioSourceStatus (opcode-specific) reply. + + Wire layout (clsOL2MsgAudioSourceStatus.cs): + payload[0..1] source number (BE u16) + payload[2] sequence number (lets clients detect duplicates) + payload[3] position (which metadata field this reply is) + payload[4] field id (track / artist / album / time / etc.) + payload[5..] metadata text, ASCII-ish, NUL-terminated + + Reference: clsOL2MsgAudioSourceStatus.cs. + """ + + index: int + sequence: int + position: int + field_id: int + text: str + + @classmethod + def parse(cls, payload: bytes) -> Self: + if len(payload) < 5: + raise ValueError( + f"AudioSourceStatus payload too short: {len(payload)} bytes" + ) + return cls( + index=(payload[0] << 8) | payload[1], + sequence=payload[2], + position=payload[3], + field_id=payload[4], + text=_decode_name(payload[5:]), + ) + + +# -------------------------------------------------------------------------- +# UserSettingProperties / UserSettingStatus +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class UserSettingProperties: + """Parsed Properties (opcode 33) reply for a UserSetting object. + + Wire layout (clsOL2MsgProperties.cs, ObjectType=UserSetting): + payload[0] object type (UserSetting = 13) + payload[1..2] object number (BE u16) + payload[3] setting type (enuUserSettingType) + payload[4..5] raw value (BE u16, interpretation depends on type) + payload[6..20] 15-byte name, NUL-padded + + Reference: clsOL2MsgProperties.cs:583-605, 699, clsUserSetting.cs. + """ + + index: int + name: str + setting_type: int + value: int + + @classmethod + def parse(cls, payload: bytes) -> Self: + hdr = _PropertiesHeader.from_payload(payload) + if hdr.object_type != ObjectType.USER_SETTING: + raise ValueError( + f"expected UserSetting (object_type=13), got {hdr.object_type}" + ) + if len(payload) < 6 + 15: + raise ValueError( + f"UserSettingProperties payload too short: {len(payload)} bytes" + ) + return cls( + index=hdr.object_number, + setting_type=payload[3], + value=(payload[4] << 8) | payload[5], + name=_decode_name(payload[6 : 6 + 15]), + ) + + +@dataclass(frozen=True, slots=True) +class UserSettingStatus: + """Live value of one user setting, one record of an ExtendedStatus reply. + + Wire layout (each record, ObjectType=UserSetting, ObjectLength=5): + bytes[0..1] setting number (BE u16) + bytes[2] setting type (enuUserSettingType) + bytes[3..4] raw value (BE u16) + + Reference: clsOL2MsgExtendedStatus.cs:389-414. + """ + + index: int + setting_type: int + value: int + + @classmethod + def parse(cls, payload: bytes) -> Self: + if len(payload) < 5: + raise ValueError( + f"UserSettingStatus record too short: {len(payload)} bytes" + ) + return cls( + index=(payload[0] << 8) | payload[1], + setting_type=payload[2], + value=(payload[3] << 8) | payload[4], + ) + + +# -------------------------------------------------------------------------- +# Object-type → parser dispatch tables +# -------------------------------------------------------------------------- + +OBJECT_TYPE_TO_PROPERTIES: dict[int, type] = { + ObjectType.ZONE: ZoneProperties, + ObjectType.UNIT: UnitProperties, + ObjectType.BUTTON: ButtonProperties, + ObjectType.CODE: CodeProperties, + ObjectType.AREA: AreaProperties, + ObjectType.THERMOSTAT: ThermostatProperties, + ObjectType.MESSAGE: MessageProperties, + ObjectType.AUDIO_SOURCE: AudioSourceProperties, + ObjectType.AUDIO_ZONE: AudioZoneProperties, + ObjectType.USER_SETTING: UserSettingProperties, +} + +OBJECT_TYPE_TO_STATUS: dict[int, type] = { + ObjectType.ZONE: ZoneStatus, + ObjectType.UNIT: UnitStatus, + ObjectType.AREA: AreaStatus, + ObjectType.THERMOSTAT: ThermostatStatus, + ObjectType.AUXILIARY: AuxSensorStatus, + ObjectType.AUDIO_ZONE: AudioZoneStatus, + ObjectType.AUDIO_SOURCE: AudioSourceStatus, + ObjectType.USER_SETTING: UserSettingStatus, +} + + # -------------------------------------------------------------------------- # Convenience union for callers that don't know the type at compile time # -------------------------------------------------------------------------- -PropertiesReply = ZoneProperties | UnitProperties | AreaProperties +PropertiesReply = ( + ZoneProperties + | UnitProperties + | AreaProperties + | ThermostatProperties + | ButtonProperties + | CodeProperties + | MessageProperties + | AudioZoneProperties + | AudioSourceProperties + | UserSettingProperties + | ProgramProperties +) + +StatusReply = ( + ZoneStatus + | UnitStatus + | AreaStatus + | ThermostatStatus + | AuxSensorStatus + | AudioZoneStatus + | AudioSourceStatus + | UserSettingStatus +) diff --git a/tests/test_models_extended.py b/tests/test_models_extended.py new file mode 100644 index 0000000..9ce218a --- /dev/null +++ b/tests/test_models_extended.py @@ -0,0 +1,519 @@ +"""Unit tests for the extended omni_pca.models classes & helpers. + +These cover the second wave of typed dataclasses (ZoneStatus, UnitStatus, +ThermostatStatus, etc.) plus the temperature converters and value enums. +The first-wave dataclasses live in test_models.py. +""" + +from __future__ import annotations + +import pytest + +from omni_pca.models import ( + OBJECT_TYPE_TO_PROPERTIES, + OBJECT_TYPE_TO_STATUS, + AreaStatus, + AudioSourceProperties, + AudioSourceStatus, + AudioZoneProperties, + AudioZoneStatus, + AuxSensorStatus, + ButtonProperties, + CodeProperties, + FanMode, + HoldMode, + HvacMode, + MessageProperties, + ObjectType, + ProgramProperties, + SecurityMode, + ThermostatKind, + ThermostatProperties, + ThermostatStatus, + UnitStatus, + UserSettingKind, + UserSettingProperties, + UserSettingStatus, + ZoneStatus, + ZoneType, + omni_temp_to_celsius, + omni_temp_to_fahrenheit, +) + + +def _name_field(name: str, width: int) -> bytes: + encoded = name.encode("latin-1") + if len(encoded) > width: + raise ValueError("name too long for field") + return encoded + b"\x00" * (width - len(encoded)) + + +# ---- Enums ---------------------------------------------------------------- + + +def test_models_security_mode_enum_values() -> None: + """Pinned values from enuSecurityMode.cs.""" + assert SecurityMode.OFF == 0 + assert SecurityMode.DAY == 1 + assert SecurityMode.NIGHT == 2 + assert SecurityMode.AWAY == 3 + assert SecurityMode.VACATION == 4 + assert SecurityMode.DAY_INSTANT == 5 + assert SecurityMode.NIGHT_DELAYED == 6 + # arming-in-progress family + assert SecurityMode.ARMING_AWAY == 11 + assert SecurityMode.ARMING_NIGHT_DELAYED == 14 + + +def test_models_hvac_mode_enum_values() -> None: + assert HvacMode.OFF == 0 + assert HvacMode.HEAT == 1 + assert HvacMode.COOL == 2 + assert HvacMode.AUTO == 3 + assert HvacMode.EMERGENCY_HEAT == 4 + + +def test_models_fan_and_hold_mode_enums() -> None: + assert FanMode.AUTO == 0 + assert FanMode.ON == 1 + assert FanMode.CYCLE == 2 + assert HoldMode.OFF == 0 + assert HoldMode.HOLD == 1 + assert HoldMode.VACATION == 2 + assert HoldMode.OLD_ON == 0xFF + + +def test_models_zone_type_enum_subset() -> None: + assert ZoneType.ENTRY_EXIT == 0 + assert ZoneType.PERIMETER == 1 + assert ZoneType.FIRE == 32 + assert ZoneType.AUXILIARY == 64 + assert ZoneType.HUMIDITY == 84 + + +def test_models_object_type_enum_values() -> None: + assert ObjectType.ZONE == 1 + assert ObjectType.UNIT == 2 + assert ObjectType.AREA == 5 + assert ObjectType.THERMOSTAT == 6 + assert ObjectType.AUDIO_ZONE == 10 + assert ObjectType.USER_SETTING == 13 + + +def test_models_thermostat_kind_and_user_setting_enums() -> None: + assert ThermostatKind.NOT_USED == 0 + assert ThermostatKind.AUTO_HEAT_COOL == 1 + assert ThermostatKind.HEAT_ONLY == 3 + assert UserSettingKind.UNUSED == 0 + assert UserSettingKind.TEMPERATURE == 3 + assert UserSettingKind.LEVEL == 8 + + +# ---- Temperature converters ----------------------------------------------- + + +def test_models_omni_temp_to_celsius_kat() -> None: + """Pinned values from clsText.DecodeTempRaw, Celsius branch.""" + assert omni_temp_to_celsius(0) == -40.0 + assert omni_temp_to_celsius(80) == 0.0 + assert omni_temp_to_celsius(128) == 24.0 + assert omni_temp_to_celsius(140) == 30.0 + assert omni_temp_to_celsius(200) == 60.0 + + +def test_models_omni_temp_to_fahrenheit_kat() -> None: + """Pinned values from clsText.DecodeTempRaw, Fahrenheit branch. + + The C# rounds with int(raw*9/10 + 0.5) - 40, so we replicate that. + """ + # raw=44 → int(44*0.9+0.5)-40 = int(40.1)-40 = 40-40 = 0°F + assert omni_temp_to_fahrenheit(44) == 0.0 + # raw=128 → int(128*0.9+0.5)-40 = int(115.7)-40 = 115-40 = 75°F + assert omni_temp_to_fahrenheit(128) == 75.0 + # raw=200 → int(200*0.9+0.5)-40 = int(180.5)-40 = 180-40 = 140°F + assert omni_temp_to_fahrenheit(200) == 140.0 + # raw=0 (sentinel "not available") still goes through linearly + assert omni_temp_to_fahrenheit(0) == -40.0 + + +# ---- ZoneStatus ----------------------------------------------------------- + + +def test_models_zone_status_secure() -> None: + payload = bytes([0, 5, 0x00, 128]) # zone 5, all bits clear, loop=128 + z = ZoneStatus.parse(payload) + assert z.index == 5 + assert z.raw_status == 0x00 + assert z.loop == 128 + assert z.current_state == 0 + assert z.latched_state == 0 + assert z.arming_state == 0 + assert z.is_secure + assert not z.is_open + assert not z.is_in_alarm + assert not z.is_bypassed + assert not z.is_trouble + + +def test_models_zone_status_tripped_armed() -> None: + # current=NotReady(1) + tripped(4) + armed(16) = 0x15 + payload = bytes([0, 1, 0x15, 0]) + z = ZoneStatus.parse(payload) + assert z.current_state == 1 + assert z.latched_state == 4 + assert z.arming_state == 16 + assert not z.is_secure + assert z.is_in_alarm + assert not z.is_bypassed + + +def test_models_zone_status_bypassed_and_trouble() -> None: + # bypassed(32) + trouble-now(2) = 0x22 + payload = bytes([0, 2, 0x22, 0]) + z = ZoneStatus.parse(payload) + assert z.is_bypassed + assert z.is_trouble + assert not z.is_secure + + +def test_models_zone_status_had_trouble_history_bit() -> None: + # only the "had trouble" history bit (0x40) set + payload = bytes([0, 7, 0x40, 0]) + z = ZoneStatus.parse(payload) + assert z.is_trouble + assert z.is_secure # current condition still 0 + assert not z.is_in_alarm + + +def test_models_zone_status_short_payload_rejected() -> None: + with pytest.raises(ValueError): + ZoneStatus.parse(b"\x00\x01\x00") + + +# ---- UnitStatus ----------------------------------------------------------- + + +def test_models_unit_status_off() -> None: + payload = bytes([0, 3, 0, 0, 0]) + u = UnitStatus.parse(payload) + assert u.index == 3 + assert u.state == 0 + assert u.time_remaining_secs == 0 + assert not u.is_on + assert u.brightness == 0 + + +def test_models_unit_status_relay_on() -> None: + payload = bytes([0, 4, 1, 0x00, 60]) # On, 60s remaining + u = UnitStatus.parse(payload) + assert u.is_on + assert u.brightness == 100 # On with no level → treat as 100% + assert u.time_remaining_secs == 60 + + +def test_models_unit_status_dimmer_level() -> None: + # state 175 = 75% brightness + payload = bytes([0, 12, 175, 0, 0]) + u = UnitStatus.parse(payload) + assert u.is_on + assert u.brightness == 75 + + +def test_models_unit_status_scene_no_brightness() -> None: + # state 5 = Scene C (clsUnit.cs:517-525). brightness undefined. + payload = bytes([0, 9, 5, 0, 0]) + u = UnitStatus.parse(payload) + assert u.is_on + assert u.brightness is None + + +def test_models_unit_status_short_payload_rejected() -> None: + with pytest.raises(ValueError): + UnitStatus.parse(b"\x00\x01\x01") + + +# ---- AreaStatus ----------------------------------------------------------- + + +def test_models_area_status_armed_away() -> None: + # area 1, mode=Away(3), no alarms, no timers + payload = bytes([0, 1, 3, 0x00, 0, 0]) + a = AreaStatus.parse(payload) + assert a.index == 1 + assert a.mode == 3 + assert a.mode_name == "AWAY" + assert a.is_armed + assert not a.alarm_active + assert a.last_user == 0 # documented: not in wire format + + +def test_models_area_status_off_with_entry_timer() -> None: + payload = bytes([0, 2, 0, 0x00, 30, 0]) + a = AreaStatus.parse(payload) + assert a.mode == 0 + assert a.mode_name == "OFF" + assert not a.is_armed + assert a.entry_timer_secs == 30 + + +def test_models_area_status_active_alarm() -> None: + # mode=Day(1), alarms bitfield non-zero + payload = bytes([0, 1, 1, 0x04, 0, 0]) + a = AreaStatus.parse(payload) + assert a.mode == 1 + assert a.alarm_active + assert a.is_armed + + +def test_models_area_status_unknown_mode() -> None: + payload = bytes([0, 1, 99, 0, 0, 0]) + a = AreaStatus.parse(payload) + assert a.mode_name.startswith("Unknown") + + +# ---- ThermostatProperties ------------------------------------------------- + + +def test_models_thermostat_properties_parse() -> None: + payload = ( + bytes([6]) # Thermostat + + bytes([0, 1]) # index 1 + + bytes([1]) # communicating + + bytes([0]) # temperature + + bytes([0]) # heat sp + + bytes([0]) # cool sp + + bytes([0]) # mode + + bytes([0]) # fan + + bytes([0]) # hold + + bytes([1]) # type=AutoHeatCool + + _name_field("Den", 12) + ) + t = ThermostatProperties.parse(payload) + assert t.index == 1 + assert t.name == "Den" + assert t.thermostat_type == 1 + assert t.communicating is True + + +def test_models_thermostat_properties_wrong_type_rejected() -> None: + payload = bytes([1] + [0] * 22) + with pytest.raises(ValueError, match="expected Thermostat"): + ThermostatProperties.parse(payload) + + +# ---- ThermostatStatus ----------------------------------------------------- + + +def test_models_thermostat_status_parse() -> None: + # idx=2, status=1, temp=128 (24°C/75°F), heat=120 (20°C), + # cool=140 (30°C), mode=Heat, fan=Auto, hold=Off, + # humidity=130 (33% via F decode), + # humidify=120, dehumidify=140, outdoor=110, h_or_c=1 + payload = bytes([0, 2, 1, 128, 120, 140, 1, 0, 0, 130, 120, 140, 110, 1]) + t = ThermostatStatus.parse(payload) + assert t.index == 2 + assert t.temperature_raw == 128 + assert t.temperature_c == 24.0 + assert t.temperature_f == 75.0 + assert t.heat_setpoint_raw == 120 + assert t.heat_setpoint_c == 20.0 + assert t.cool_setpoint_raw == 140 + assert t.cool_setpoint_c == 30.0 + assert t.system_mode == 1 + assert t.fan_mode == 0 + assert t.hold_mode == 0 + assert t.humidity_raw == 130 + assert t.outdoor_temperature_raw == 110 + assert t.horc_status == 1 + + +def test_models_thermostat_status_short_payload_rejected() -> None: + with pytest.raises(ValueError): + ThermostatStatus.parse(b"\x00\x02\x01") + + +# ---- ButtonProperties ----------------------------------------------------- + + +def test_models_button_properties_parse() -> None: + payload = bytes([3, 0, 4]) + _name_field("Welcome", 12) + b = ButtonProperties.parse(payload) + assert b.index == 4 + assert b.name == "Welcome" + + +def test_models_button_properties_wrong_type_rejected() -> None: + with pytest.raises(ValueError, match="expected Button"): + ButtonProperties.parse(bytes([1, 0, 1]) + _name_field("X", 12)) + + +# ---- CodeProperties ------------------------------------------------------- + + +def test_models_code_properties_parse_no_digits_exposed() -> None: + payload = bytes([4, 0, 2]) + _name_field("Alice", 12) + c = CodeProperties.parse(payload) + assert c.index == 2 + assert c.name == "Alice" + # Belt-and-suspenders: the dataclass really has only these two fields + # of interest. There is no "digits" / "code" attribute. + assert not hasattr(c, "digits") + assert not hasattr(c, "code") + + +# ---- MessageProperties ---------------------------------------------------- + + +def test_models_message_properties_parse() -> None: + payload = ( + bytes([7, 0, 1]) + + _name_field("Hello world!!", 15) + + bytes([0]) # gap (Data[19] in C# offset) + + bytes([0xFF]) # area-group bitfield + ) + m = MessageProperties.parse(payload) + assert m.index == 1 + assert m.name == "Hello world!!" + assert m.text == m.name # OL2 v1 quirk: name doubles as text + assert m.areas == 0xFF + + +# ---- ProgramProperties ---------------------------------------------------- + + +def test_models_program_properties_parse_with_remark() -> None: + body = bytes(range(14)) # 14 bytes + payload = bytes([0, 7]) + body + b"sunset blink\x00" + p = ProgramProperties.parse(payload) + assert p.index == 7 + assert p.raw_body == body + assert p.name == "sunset blink" + + +def test_models_program_properties_parse_no_remark() -> None: + body = b"\x00" * 14 + payload = bytes([0, 1]) + body + p = ProgramProperties.parse(payload) + assert p.index == 1 + assert p.name == "" + assert len(p.raw_body) == 14 + + +# ---- AuxSensorStatus ------------------------------------------------------ + + +def test_models_aux_sensor_status_parse() -> None: + # idx=4, output=0, value=140 (30°C), low=120 (20°C), high=160 (40°C) + payload = bytes([0, 4, 0, 140, 120, 160]) + a = AuxSensorStatus.parse(payload) + assert a.index == 4 + assert a.value_raw == 140 + assert a.temperature_c == 30.0 + assert a.low_c == 20.0 + assert a.high_c == 40.0 + + +# ---- AudioZone ------------------------------------------------------------ + + +def test_models_audio_zone_properties_parse() -> None: + payload = ( + bytes([10, 0, 1]) # AudioZone, idx 1 + + bytes([1]) # power on + + bytes([2]) # source 2 + + bytes([60]) # volume 60 + + bytes([0]) # mute off + + _name_field("Living", 12) + ) + az = AudioZoneProperties.parse(payload) + assert az.index == 1 + assert az.name == "Living" + assert az.power is True + assert az.source == 2 + assert az.volume == 60 + assert az.mute is False + + +def test_models_audio_zone_status_parse() -> None: + # idx=3, power on, source 5, vol 80, mute on + payload = bytes([0, 3, 1, 5, 80, 1]) + s = AudioZoneStatus.parse(payload) + assert s.index == 3 + assert s.power is True + assert s.source == 5 + assert s.volume == 80 + assert s.mute is True + + +# ---- AudioSource ---------------------------------------------------------- + + +def test_models_audio_source_properties_parse() -> None: + payload = bytes([9, 0, 2]) + _name_field("Spotify", 12) + s = AudioSourceProperties.parse(payload) + assert s.index == 2 + assert s.name == "Spotify" + + +def test_models_audio_source_status_parse() -> None: + # idx=1, seq=42, position=1, field=2, text="Now Playing" + payload = bytes([0, 1, 42, 1, 2]) + b"Now Playing\x00rest" + s = AudioSourceStatus.parse(payload) + assert s.index == 1 + assert s.sequence == 42 + assert s.position == 1 + assert s.field_id == 2 + assert s.text == "Now Playing" + + +# ---- UserSetting ---------------------------------------------------------- + + +def test_models_user_setting_properties_parse() -> None: + # type=Temperature(3), value=140 (= 30°C) + payload = bytes([13, 0, 5, 3, 0, 140]) + _name_field("HotPoint", 15) + s = UserSettingProperties.parse(payload) + assert s.index == 5 + assert s.name == "HotPoint" + assert s.setting_type == 3 + assert s.value == 140 + + +def test_models_user_setting_status_parse() -> None: + payload = bytes([0, 5, 3, 0, 140]) + s = UserSettingStatus.parse(payload) + assert s.index == 5 + assert s.setting_type == 3 + assert s.value == 140 + + +# ---- Dispatch tables ------------------------------------------------------ + + +def test_models_object_type_to_properties_dispatch() -> None: + assert OBJECT_TYPE_TO_PROPERTIES[ObjectType.ZONE].__name__ == "ZoneProperties" + assert OBJECT_TYPE_TO_PROPERTIES[ObjectType.UNIT].__name__ == "UnitProperties" + assert ( + OBJECT_TYPE_TO_PROPERTIES[ObjectType.THERMOSTAT].__name__ + == "ThermostatProperties" + ) + assert OBJECT_TYPE_TO_PROPERTIES[ObjectType.MESSAGE].__name__ == "MessageProperties" + + +def test_models_object_type_to_status_dispatch() -> None: + assert OBJECT_TYPE_TO_STATUS[ObjectType.ZONE].__name__ == "ZoneStatus" + assert OBJECT_TYPE_TO_STATUS[ObjectType.AUXILIARY].__name__ == "AuxSensorStatus" + assert OBJECT_TYPE_TO_STATUS[ObjectType.AUDIO_ZONE].__name__ == "AudioZoneStatus" + + +def test_models_dispatch_round_trip_zone() -> None: + """A caller that only knows the object_type byte can still parse + a Properties payload through the dispatch table.""" + payload = ( + bytes([1, 0, 42, 0, 0, 0, 1, 0]) + + _name_field("Front Door", 15) + ) + parser = OBJECT_TYPE_TO_PROPERTIES[payload[0]] + parsed = parser.parse(payload) + assert parsed.index == 42 + assert parsed.name == "Front Door"