Coverage for src/ramses_tx/const.py: 95%
512 statements
« prev ^ index » next coverage.py v7.11.3, created at 2026-01-05 21:46 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2026-01-05 21:46 +0100
1#!/usr/bin/env python3
2"""RAMSES RF - a RAMSES-II protocol decoder & analyser.
4This module contains constants, enums, and helper classes used throughout the
5library to decode and encode RAMSES-II protocol packets.
6"""
8from __future__ import annotations
10import re
11from enum import EnumCheck, IntEnum, StrEnum, verify
12from types import SimpleNamespace
13from typing import Any, Final, Literal, NoReturn
15__dev_mode__ = False # NOTE: this is const.py
16DEV_MODE = __dev_mode__
18# used by protocol QoS FSM (echo tout is different? for MQTT)...
19DEFAULT_DISABLE_QOS: Final[bool | None] = None
20DEFAULT_WAIT_FOR_REPLY: Final[bool | None] = None
22#: Waiting for echo pkt after cmd sent (seconds)
23DEFAULT_ECHO_TIMEOUT: Final[float] = 0.50
25#: Waiting for reply pkt after echo pkt rcvd (seconds)
26DEFAULT_RPLY_TIMEOUT: Final[float] = 0.50
27DEFAULT_BUFFER_SIZE: Final[int] = 32
29#: Total waiting for successful send (seconds)
30DEFAULT_SEND_TIMEOUT: Final[float] = 20.0
31#: For a command to be sent, incl. queuing time (seconds)
32MAX_SEND_TIMEOUT: Final[float] = 20.0
34#: For a command to be re-sent (not incl. 1st send)
35MAX_RETRY_LIMIT: Final[int] = 3
37#: Minimum gap between writes (seconds)
38MIN_INTER_WRITE_GAP: Final[float] = 0.05
39DEFAULT_GAP_DURATION: Final[float] = MIN_INTER_WRITE_GAP
40DEFAULT_MAX_RETRIES: Final[int] = 3
41DEFAULT_NUM_REPEATS: Final[int] = 0
43SZ_QOS: Final = "qos"
45SZ_CALLBACK: Final = "callback"
46SZ_GAP_DURATION: Final = "gap_duration"
47SZ_MAX_RETRIES: Final = "max_retries"
48SZ_NUM_REPEATS: Final = "num_repeats"
49SZ_PRIORITY: Final = "priority"
50SZ_TIMEOUT: Final = "timeout"
53# used by transport...
54SZ_ACTIVE_HGI: Final = "active_gwy"
55SZ_SIGNATURE: Final = "signature"
56SZ_IS_EVOFW3: Final = "is_evofw3"
58# default values for transmit rate governers...
59DUTY_CYCLE_DURATION = 60 # time window (seconds) where rate limiting occurs
60MAX_DUTY_CYCLE_RATE = 0.01 # % bandwidth used per cycle
61MAX_TRANSMIT_RATE_TOKENS = 80 # transmits per cycle
64# used by schedule.py...
65SZ_FRAGMENT: Final = "fragment"
66SZ_FRAG_NUMBER: Final = "frag_number"
67SZ_FRAG_LENGTH: Final = "frag_length"
68SZ_TOTAL_FRAGS: Final = "total_frags"
70SZ_SCHEDULE: Final = "schedule"
71SZ_CHANGE_COUNTER: Final = "change_counter"
73SZ_SENSOR_FAULT: Final = "sensor_fault"
76# used by 31DA
77SZ_AIR_QUALITY: Final = "air_quality"
78SZ_AIR_QUALITY_BASIS: Final = "air_quality_basis"
79SZ_BOOST_TIMER: Final = "boost_timer"
80SZ_BYPASS_MODE: Final = "bypass_mode"
81SZ_BYPASS_POSITION: Final = "bypass_position"
82SZ_BYPASS_STATE: Final = "bypass_state"
83SZ_CO2_LEVEL: Final = "co2_level"
84SZ_DEWPOINT_TEMP: Final = "dewpoint_temp"
85SZ_EXHAUST_FAN_SPEED: Final = "exhaust_fan_speed"
86SZ_EXHAUST_FLOW: Final = "exhaust_flow"
87SZ_EXHAUST_TEMP: Final = "exhaust_temp"
88SZ_FAN_INFO: Final = "fan_info"
89SZ_FAN_MODE: Final = "fan_mode"
90SZ_FAN_RATE: Final = "fan_rate"
91SZ_FILTER_REMAINING: Final = "filter_remaining"
92SZ_INDOOR_HUMIDITY: Final = "indoor_humidity"
93SZ_INDOOR_TEMP: Final = "indoor_temp"
94SZ_OUTDOOR_HUMIDITY: Final = "outdoor_humidity"
95SZ_OUTDOOR_TEMP: Final = "outdoor_temp"
96SZ_POST_HEAT: Final = "post_heat"
97SZ_PRE_HEAT: Final = "pre_heat"
98SZ_REL_HUMIDITY: Final = "rel_humidity"
99SZ_REMAINING_DAYS: Final = "days_remaining"
100SZ_REMAINING_MINS: Final = "remaining_mins"
101SZ_REMAINING_PERCENT: Final = "percent_remaining"
102SZ_REQ_REASON: Final = "req_reason"
103SZ_REQ_SPEED: Final = "req_speed"
104SZ_SUPPLY_FAN_SPEED: Final = "supply_fan_speed"
105SZ_SUPPLY_FLOW: Final = "supply_flow"
106SZ_SUPPLY_TEMP: Final = "supply_temp"
107SZ_SPEED_CAPABILITIES: Final = "speed_capabilities"
109SZ_PRESENCE_DETECTED: Final = "presence_detected"
112# used by OTB
113SZ_BURNER_HOURS: Final = "burner_hours"
114SZ_BURNER_STARTS: Final = "burner_starts"
115SZ_BURNER_FAILED_STARTS: Final = "burner_failed_starts"
116SZ_CH_PUMP_HOURS: Final = "ch_pump_hours"
117SZ_CH_PUMP_STARTS: Final = "ch_pump_starts"
118SZ_DHW_BURNER_HOURS: Final = "dhw_burner_hours"
119SZ_DHW_BURNER_STARTS: Final = "dhw_burner_starts"
120SZ_DHW_PUMP_HOURS: Final = "dhw_pump_hours"
121SZ_DHW_PUMP_STARTS: Final = "dhw_pump_starts"
122SZ_FLAME_SIGNAL_LOW: Final = "flame_signal_low"
124SZ_BOILER_OUTPUT_TEMP: Final = "boiler_output_temp"
125SZ_BOILER_RETURN_TEMP: Final = "boiler_return_temp"
126SZ_BOILER_SETPOINT: Final = "boiler_setpoint"
127SZ_CH_MAX_SETPOINT: Final = "ch_max_setpoint"
128SZ_CH_SETPOINT: Final = "ch_setpoint"
129SZ_CH_WATER_PRESSURE: Final = "ch_water_pressure"
130SZ_DHW_FLOW_RATE: Final = "dhw_flow_rate"
131SZ_DHW_SETPOINT: Final = "dhw_setpoint"
132SZ_DHW_TEMP: Final = "dhw_temp"
133SZ_MAX_REL_MODULATION: Final = "max_rel_modulation"
134# SZ_OEM_CODE:Final[str] = "oem_code"
135SZ_OUTSIDE_TEMP: Final = "outside_temp"
136SZ_REL_MODULATION_LEVEL: Final = "rel_modulation_level"
138SZ_CH_ACTIVE: Final = "ch_active"
139SZ_CH_ENABLED: Final = "ch_enabled"
140SZ_COOLING_ACTIVE: Final = "cooling_active"
141SZ_COOLING_ENABLED: Final = "cooling_enabled"
142SZ_DHW_ACTIVE: Final = "dhw_active"
143SZ_DHW_BLOCKING: Final = "dhw_blocking"
144SZ_DHW_ENABLED: Final = "dhw_enabled"
145SZ_FAULT_PRESENT: Final = "fault_present"
146SZ_FLAME_ACTIVE: Final = "flame_active"
147SZ_SUMMER_MODE: Final = "summer_mode"
148SZ_OTC_ACTIVE: Final = "otc_active"
151@verify(EnumCheck.UNIQUE)
152class Priority(IntEnum):
153 """Priority levels for protocol messages."""
155 LOWEST = 4
156 LOW = 2
157 DEFAULT = 0
158 HIGH = -2
159 HIGHEST = -4
162def slug(string: str) -> str:
163 """Convert a string to snake_case.
165 :param string: The input string to convert.
166 :return: The string converted to snake_case (lowercase, with non-alphanumerics replaced by underscores).
167 """
168 return re.sub(r"[\W_]+", "_", string.lower())
171# TODO: FIXME: This is a mess - needs converting to StrEnum
172class AttrDict(dict): # type: ignore[type-arg]
173 """A read-only dictionary that supports dot-access and two-way lookup.
175 This class is typically used to map hex codes (keys) to human-readable slugs (values),
176 while also allowing reverse lookup via dot notation (e.g., ``map.SLUG``).
178 .. warning::
179 This class is immutable. Attempting to modify it will raise a :exc:`TypeError`.
180 """
182 _SZ_AKA_SLUG: Final = "_root_slug"
183 _SZ_DEFAULT: Final = "_default"
184 _SZ_SLUGS: Final = "SLUGS"
186 def _readonly(self, *args: Any, **kwargs: Any) -> NoReturn:
187 """Raise TypeError for read-only operations."""
188 raise TypeError(f"'{self.__class__.__name__}' object is read only")
190 def __setitem__(self, key: Any, value: Any) -> NoReturn:
191 self._readonly()
193 def __delitem__(self, key: Any) -> NoReturn:
194 self._readonly()
196 def clear(self) -> NoReturn:
197 self._readonly()
199 def pop(self, *args: Any, **kwargs: Any) -> NoReturn:
200 self._readonly()
202 def popitem(self) -> NoReturn:
203 self._readonly()
205 def setdefault(self, *args: Any, **kwargs: Any) -> NoReturn:
206 self._readonly()
208 def update(self, *args: Any, **kwargs: Any) -> NoReturn:
209 self._readonly()
211 def __init__(self, main_table: dict[str, dict], attr_table: dict[str, Any]) -> None: # type: ignore[type-arg]
212 """Initialize the AttrDict.
214 :param main_table: A dictionary mapping keys (usually hex codes) to property dictionaries.
215 :param attr_table: A dictionary of additional attributes to expose on the object.
216 """
217 self._main_table = main_table
218 self._attr_table = attr_table
219 self._attr_table[self._SZ_SLUGS] = tuple(sorted(main_table.keys()))
221 self._slug_lookup: dict = { # type: ignore[type-arg]
222 None: slug # noqa: B035
223 for slug, table in main_table.items()
224 for k in table.values()
225 if isinstance(k, str) and table.get(self._SZ_DEFAULT)
226 } # i.e. {None: 'HEA'}
227 self._slug_lookup.update(
228 {
229 k: table.get(self._SZ_AKA_SLUG, slug)
230 for slug, table in main_table.items()
231 for k in table
232 if isinstance(k, str) and len(k) == 2
233 } # e.g. {'00': 'TRV', '01': 'CTL', '04': 'TRV', ...}
234 )
235 self._slug_lookup.update(
236 {
237 k: slug
238 for slug, table in main_table.items()
239 for k in table.values()
240 if isinstance(k, str) and table.get(self._SZ_AKA_SLUG) is None
241 } # e.g. {'heat_device':'HEA', 'dhw_sensor':'DHW', ...}
242 )
244 self._forward = {
245 k: v
246 for table in main_table.values()
247 for k, v in table.items()
248 if isinstance(k, str) and k[:1] != "_"
249 } # e.g. {'00': 'radiator_valve', '01': 'controller', ...}
250 self._reverse = {
251 v: k
252 for table in main_table.values()
253 for k, v in table.items()
254 if isinstance(k, str) and k[:1] != "_" and self._SZ_AKA_SLUG not in table
255 } # e.g. {'radiator_valve': '00', 'controller': '01', ...}
256 self._forward = dict(sorted(self._forward.items(), key=lambda item: item[0]))
258 super().__init__(self._forward)
260 def __getitem__(self, key: str) -> Any:
261 if key in self._main_table: # map[ZON_ROLE.DHW] -> "dhw_sensor"
262 return list(self._main_table[key].values())[0]
263 # if key in self._forward: # map["0D"] -> "dhw_sensor"
264 # return self._forward.__getitem__(key)
265 if key in self._reverse: # map["dhw_sensor"] -> "0D"
266 return self._reverse.__getitem__(key)
267 return super().__getitem__(key)
269 def __getattr__(self, name: str) -> Any:
270 if name in self._main_table: # map.DHW -> "0D" (using slug)
271 if (result := list(self._main_table[name].keys())[0]) is not None:
272 return result
273 elif name in self._attr_table: # bespoke attrs
274 return self._attr_table[name]
275 elif len(name) and name[1:] in self._forward: # map._0D -> "dhw_sensor"
276 return self._forward[name[1:]]
277 elif name.isupper() and name.lower() in self._reverse: # map.DHW_SENSOR -> "0D"
278 return self[name.lower()]
279 return self.__getattribute__(name)
281 def _hex(self, key: str) -> str:
282 """Return the key/ID (2-byte hex string) of the two-way dict (e.g. '04').
284 :param key: The lookup key (can be slug or code).
285 :raises KeyError: If the key is not found.
286 :return: The 2-byte hex string identifier.
287 """
288 if key in self._main_table:
289 return list(self._main_table[key].keys())[0] # type: ignore[no-any-return]
290 if key in self._reverse:
291 return self._reverse[key]
292 raise KeyError(key)
294 def _str(self, key: str) -> str:
295 """Return the value (string) of the two-way dict (e.g. 'radiator_valve').
297 :param key: The lookup key.
298 :raises KeyError: If the key is not found.
299 :return: The human-readable slug string.
300 """
301 if key in self._main_table:
302 return list(self._main_table[key].values())[0] # type: ignore[no-any-return]
303 if key in self:
304 return self[key] # type: ignore[no-any-return]
305 raise KeyError(key)
307 # def values(self):
308 # return {k: k for k in super().values()}.values()
310 def slug(self, key: str) -> str:
311 """Return master slug for a hex key/ID.
313 Example: 00 -> 'TRV' (master), not 'TR0'.
315 :param key: The hex key to look up.
316 :return: The master slug.
317 """
318 slug_ = self._slug_lookup[key]
319 # if slug_ in self._attr_table["_TRANSFORMS"]:
320 # return self._attr_table["_TRANSFORMS"][slug_]
321 return slug_ # type: ignore[no-any-return]
323 def slugs(self) -> tuple[str]:
324 """Return the slugs from the main table.
326 :return: A tuple of all available slugs.
327 """
328 return self._attr_table[self._SZ_SLUGS] # type: ignore[no-any-return]
331def attr_dict_factory(
332 main_table: dict[str, dict], # type: ignore[type-arg]
333 attr_table: dict | None = None, # type: ignore[type-arg]
334) -> AttrDict: # is: SlottedAttrDict
335 """Create a new AttrDict instance with a slotted subclass.
337 :param main_table: The primary mapping of codes to slugs.
338 :param attr_table: Optional additional attributes to attach to the instance.
339 :return: An instance of a dynamic AttrDict subclass.
340 """
341 if attr_table is None:
342 attr_table = {}
344 class SlottedAttrDict(AttrDict):
345 pass # TODO: low priority
346 # __slots__ = (
347 # list(main_table.keys())
348 # + [
349 # f"_{k}"
350 # for t in main_table.values()
351 # for k in t.keys()
352 # if isinstance(k, str) and len(k) == 2
353 # ]
354 # + [v for t in main_table.values() for v in t.values()]
355 # + list(attr_table.keys())
356 # + [AttrDict._SZ_AKA_SLUG, AttrDict._SZ_SLUGS]
357 # )
359 return SlottedAttrDict(main_table, attr_table=attr_table)
362# slugs for device/zone entity klasses, used by 0005/000C
363@verify(EnumCheck.UNIQUE)
364class DevRole(StrEnum):
365 """Slugs for device/zone entity classes, used by commands 0005/000C."""
367 #
368 # Generic device/zone classes
369 ACT = "ACT" # Generic heating zone actuator group
370 SEN = "SEN" # Generic heating zone sensor group
371 #
372 # Standard device/zone classes
373 ELE = "ELE" # BDRs (no heat demand)
374 MIX = "MIX" # HM8s
375 RAD = "RAD" # TRVs
376 UFH = "UFH" # UFC (circuits)
377 VAL = "VAL" # BDRs
378 #
379 # DHW device/zone classes
380 DHW = "DHW" # DHW sensor (a zone, but not a heating zone)
381 HTG = "HTG" # BDR (DHW relay, HTG relay)
382 HT1 = "HT1" # BDR (HTG relay)
383 #
384 # Other device/zone classes
385 OUT = "OUT" # OUT (external weather sensor)
386 RFG = "RFG" # RFG
387 APP = "APP" # BDR/OTB (appliance relay)
390DEV_ROLE_MAP = attr_dict_factory(
391 {
392 DevRole.ACT: {"00": "zone_actuator"},
393 DevRole.SEN: {"04": "zone_sensor"},
394 DevRole.RAD: {"08": "rad_actuator"},
395 DevRole.UFH: {"09": "ufh_actuator"},
396 DevRole.VAL: {"0A": "val_actuator"},
397 DevRole.MIX: {"0B": "mix_actuator"},
398 DevRole.OUT: {"0C": "out_sensor"},
399 DevRole.DHW: {"0D": "dhw_sensor"},
400 DevRole.HTG: {"0E": "hotwater_valve"}, # payload[:4] == 000E
401 DevRole.HT1: {None: "heating_valve"}, # payload[:4] == 010E
402 DevRole.APP: {"0F": "appliance_control"}, # the heat/cool source
403 DevRole.RFG: {"10": "remote_gateway"},
404 DevRole.ELE: {"11": "ele_actuator"}, # ELE(VAL) - no RP from older evos
405 }, # 03, 05, 06, 07: & >11 - no response from an 01:
406 {
407 "HEAT_DEVICES": ("00", "04", "08", "09", "0A", "0B", "11"),
408 "DHW_DEVICES": ("0D", "0E"),
409 "SENSORS": ("04", "0C", "0D"),
410 },
411)
414# slugs for device entity types, used in device_ids
415@verify(EnumCheck.UNIQUE)
416class DevType(StrEnum):
417 """Slugs for device entity types, used in device_ids."""
419 #
420 # Promotable/Generic devices
421 DEV = "DEV" # xx: Promotable device
422 HEA = "HEA" # xx: Promotable Heat device, aka CH/DHW device
423 HVC = "HVC" # xx: Promotable HVAC device
424 THM = "THM" # xx: Generic thermostat
425 #
426 # Heat (CH/DHW) devices
427 BDR = "BDR" # 13: Electrical relay
428 CTL = "CTL" # 01: Controller (zoned)
429 DHW = "DHW" # 07: DHW sensor
430 DTS = "DTS" # 12: Thermostat, DTS92(E)
431 DT2 = "DT2" # 22: Thermostat, DTS92(E)
432 HCW = "HCW" # 03: Thermostat - don't use STA
433 HGI = "HGI" # 18: Gateway interface (RF to USB), HGI80
434 # 8 = "HM8" # xx: HM80 mixer valve (Rx-only, does not Tx)
435 OTB = "OTB" # 10: OpenTherm bridge
436 OUT = "OUT" # 17: External weather sensor
437 PRG = "PRG" # 23: Programmer
438 RFG = "RFG" # 30: RF gateway (RF to ethernet), RFG100
439 RND = "RND" # 34: Thermostat, TR87RF
440 TRV = "TRV" # 04: Thermostatic radiator valve
441 TR0 = "TR0" # 00: Thermostatic radiator valve
442 UFC = "UFC" # 02: UFH controller
443 #
444 # Honeywell Jasper, other Heat devices
445 JIM = "JIM" # 08: Jasper Interface Module (EIM?)
446 JST = "JST" # 31: Jasper Stat
447 #
448 # HVAC devices, these are more like classes (i.e. no reliable device type)
449 RFS = "RFS" # ??: HVAC spIDer gateway
450 FAN = "FAN" # ??: HVAC fan, 31D[9A]: 20|29|30|37 (some, e.g. 29: only 31D9)
451 CO2 = "CO2" # ??: HVAC CO2 sensor
452 HUM = "HUM" # ??: HVAC humidity sensor, 1260: 32
453 PIR = "PIR" # ??: HVAC pesence sensor, 2E10
454 REM = "REM" # ??: HVAC switch, 22F[13]: 02|06|20|32|39|42|49|59 (no 20: are both)
455 SW2 = "SW2" # ??: HVAC switch, Orcon variant
456 DIS = "DIS" # ??: HVAC switch with display
459DEV_TYPE_MAP = attr_dict_factory(
460 {
461 # Generic devices (would be promoted)
462 DevType.DEV: {None: "generic_device"}, # , AttrDict._SZ_DEFAULT: True},
463 DevType.HEA: {None: "heat_device"},
464 DevType.HVC: {None: "hvac_device"},
465 # HGI80
466 DevType.HGI: {"18": "gateway_interface"}, # HGI80
467 # Heat (CH/DHW) devices
468 DevType.TR0: {"00": "radiator_valve", AttrDict._SZ_AKA_SLUG: DevType.TRV},
469 DevType.CTL: {"01": "controller"},
470 DevType.UFC: {"02": "ufh_controller"},
471 DevType.HCW: {"03": "analog_thermostat"},
472 DevType.THM: {None: "thermostat"},
473 DevType.TRV: {"04": "radiator_valve"},
474 DevType.DHW: {"07": "dhw_sensor"},
475 DevType.OTB: {"10": "opentherm_bridge"},
476 DevType.DTS: {"12": "digital_thermostat"},
477 DevType.BDR: {"13": "electrical_relay"},
478 DevType.OUT: {"17": "outdoor_sensor"},
479 DevType.DT2: {"22": "digital_thermostat", AttrDict._SZ_AKA_SLUG: DevType.DTS},
480 DevType.PRG: {"23": "programmer"},
481 DevType.RFG: {"30": "rf_gateway"}, # RFG100
482 DevType.RND: {"34": "round_thermostat"},
483 # Other (jasper) devices
484 DevType.JIM: {"08": "jasper_interface"},
485 DevType.JST: {"31": "jasper_thermostat"},
486 # Ventilation devices
487 DevType.CO2: {None: "co2_sensor"},
488 DevType.DIS: {None: "switch_display"},
489 DevType.FAN: {None: "ventilator"}, # Both Fans and HRUs
490 DevType.HUM: {None: "rh_sensor"},
491 DevType.PIR: {None: "presence_sensor"},
492 DevType.RFS: {None: "hvac_gateway"}, # Spider
493 DevType.REM: {None: "switch"},
494 DevType.SW2: {None: "switch_variant"},
495 },
496 {
497 "HEAT_DEVICES": (
498 "00",
499 "01",
500 "02",
501 "03",
502 "04",
503 "07",
504 "10",
505 "12",
506 "13",
507 "17",
508 "22",
509 "30",
510 "34",
511 ), # CH/DHW devices instead of HVAC/other
512 "HEAT_ZONE_SENSORS": ("00", "01", "03", "04", "12", "22", "34"),
513 "HEAT_ZONE_ACTUATORS": ("00", "02", "04", "13"),
514 "THM_DEVICES": ("03", "12", "21", "22", "34"),
515 "TRV_DEVICES": ("00", "04"),
516 "CONTROLLERS": ("01", "02", "12", "22", "23", "34"), # potentially controllers
517 "PROMOTABLE_SLUGS": (DevType.DEV, DevType.HEA, DevType.HVC),
518 "HVAC_SLUGS": {
519 DevType.CO2: "co2_sensor",
520 DevType.FAN: "ventilator", # Both Fans and HRUs
521 DevType.HUM: "rh_sensor",
522 DevType.RFS: "hvac_gateway", # Spider
523 DevType.REM: "switch",
524 },
525 },
526)
529# slugs for zone entity klasses, used by 0005/000C
530class ZoneRole(StrEnum):
531 """Slugs for zone entity classes, used by commands 0005/000C."""
533 #
534 # Generic device/zone classes
535 ACT = "ACT" # Generic heating zone actuator group
536 SEN = "SEN" # Generic heating zone sensor group
537 #
538 # Standard device/zone classes
539 ELE = "ELE" # heating zone with BDRs (no heat demand)
540 MIX = "MIX" # heating zone with HM8s
541 RAD = "RAD" # heating zone with TRVs
542 UFH = "UFH" # heating zone with UFC circuits
543 VAL = "VAL" # zheating one with BDRs
544 # Standard device/zone classes *not a heating zone)
545 DHW = "DHW" # DHW zone with BDRs
548ZON_ROLE_MAP = attr_dict_factory(
549 {
550 ZoneRole.ACT: {"00": "heating_zone"}, # any actuator
551 ZoneRole.SEN: {"04": "heating_zone"}, # any sensor
552 ZoneRole.RAD: {"08": "radiator_valve"}, # TRVs
553 ZoneRole.UFH: {"09": "underfloor_heating"}, # UFCs
554 ZoneRole.VAL: {"0A": "zone_valve"}, # BDRs
555 ZoneRole.MIX: {"0B": "mixing_valve"}, # HM8s
556 ZoneRole.DHW: {"0D": "stored_hotwater"}, # DHWs
557 # N_CLASS.HTG: {"0E": "stored_hotwater", AttrDict._SZ_AKA_SLUG: ZON_ROLE.DHW},
558 ZoneRole.ELE: {"11": "electric_heat"}, # BDRs
559 },
560 {
561 "HEAT_ZONES": ("08", "09", "0A", "0B", "11"),
562 },
563)
565# Zone modes
566ZON_MODE_MAP = attr_dict_factory(
567 {
568 "FOLLOW": {"00": "follow_schedule"},
569 "ADVANCED": {"01": "advanced_override"}, # . until the next scheduled setpoint
570 "PERMANENT": {"02": "permanent_override"}, # indefinitely, until auto_reset
571 "COUNTDOWN": {"03": "countdown_override"}, # for x mins (duration, max 1,215?)
572 "TEMPORARY": {"04": "temporary_override"}, # until a given date/time (until)
573 }
574)
576# System modes
577SYS_MODE_MAP = attr_dict_factory(
578 {
579 "au_00": {"00": "auto"}, # . indef (only)
580 "ho_01": {"01": "heat_off"}, # . indef (only)
581 "eb_02": {"02": "eco_boost"}, # . indef/<=24h: is either Eco, *or* Boost
582 "aw_03": {"03": "away"}, # . indef/<=99d (0d = end of today, 00:00)
583 "do_04": {"04": "day_off"}, # . indef/<=99d: rounded down to 00:00 by CTL
584 "de_05": {"05": "day_off_eco"}, # . indef/<=99d: set to Eco when DayOff ends
585 "ar_06": {"06": "auto_with_reset"}, # indef (only)
586 "cu_07": {"07": "custom"}, # . indef/<=99d
587 }
588)
591SZ_ACTIVE: Final = "active"
592SZ_ACTUATOR: Final = "actuator"
593SZ_ACTUATORS: Final = "actuators"
594SZ_BINDINGS: Final = "bindings"
595SZ_CONFIG: Final = "config"
596SZ_DATETIME: Final = "datetime"
597SZ_DEMAND: Final = "demand"
598SZ_DEVICE_ID: Final = "device_id"
599SZ_DEVICE_ROLE: Final = "device_role"
600SZ_DEVICES: Final = "devices"
601SZ_DHW_IDX: Final = "dhw_idx"
602SZ_DOMAIN_ID: Final = "domain_id"
603SZ_DURATION: Final = "duration"
604SZ_HEAT_DEMAND: Final = "heat_demand"
605SZ_IS_DST: Final = "is_dst"
606SZ_LANGUAGE: Final = "language"
607SZ_LOCAL_OVERRIDE: Final = "local_override"
608SZ_MAX_TEMP: Final = "max_temp"
609SZ_MIN_TEMP: Final = "min_temp"
610SZ_MIX_CONFIG: Final = "mix_config"
611SZ_MODE: Final = "mode"
612SZ_MULTIROOM_MODE: Final = "multiroom_mode"
613SZ_NAME: Final = "name"
614SZ_OEM_CODE: Final = "oem_code"
615SZ_OPENWINDOW_FUNCTION: Final = "openwindow_function"
616SZ_PAYLOAD: Final = "payload"
617SZ_PERCENTAGE: Final = "percentage"
618SZ_PRESSURE: Final = "pressure"
619SZ_RELAY_DEMAND: Final = "relay_demand"
620SZ_RELAY_FAILSAFE: Final = "relay_failsafe"
621SZ_SENSOR: Final = "sensor"
622SZ_SETPOINT: Final = "setpoint"
623SZ_SETPOINT_BOUNDS: Final = "setpoint_bounds"
624SZ_SLUG: Final = "_SLUG"
625SZ_SYSTEM_MODE: Final = "system_mode"
626SZ_TEMPERATURE: Final = "temperature"
627SZ_UFH_IDX: Final = "ufh_idx"
628SZ_UNKNOWN: Final = "unknown"
629SZ_UNTIL: Final = "until"
630SZ_VALUE: Final = "value"
631SZ_WINDOW_OPEN: Final = "window_open"
632SZ_ZONE_CLASS: Final = "zone_class"
633SZ_ZONE_IDX: Final = "zone_idx"
634SZ_ZONE_MASK: Final = "zone_mask"
635SZ_ZONE_TYPE: Final = "zone_type"
636SZ_ZONES: Final = "zones"
638# used in 0418 only?
639SZ_DEVICE_CLASS: Final = "device_class"
640# _DEVICE_ID: Final = "device_id"
641SZ_DOMAIN_IDX: Final = "domain_idx"
642SZ_FAULT_STATE: Final = "fault_state"
643SZ_FAULT_TYPE: Final = "fault_type"
644SZ_LOG_ENTRY: Final = "log_entry"
645SZ_LOG_IDX: Final = "log_idx"
646SZ_TIMESTAMP: Final = "timestamp"
648# used in 1FC9
649SZ_OFFER: Final = "offer"
650SZ_ACCEPT: Final = "accept"
651SZ_CONFIRM: Final = "confirm"
652SZ_PHASE: Final = "phase"
655DEFAULT_MAX_ZONES: Final = 16 if DEV_MODE else 12
656# Evohome: 12 (0-11), older/initial version was 8
657# Hometronics: 16 (0-15), or more?
658# Sundial RF2: 2 (0-1), usually only one, but ST9520C can do two zones
661DEVICE_ID_REGEX = SimpleNamespace(
662 ANY=re.compile(r"^[0-9]{2}:[0-9]{6}$"),
663 BDR=re.compile(r"^13:[0-9]{6}$"),
664 CTL=re.compile(r"^(01|23):[0-9]{6}$"),
665 DHW=re.compile(r"^07:[0-9]{6}$"),
666 HGI=re.compile(r"^18:[0-9]{6}$"),
667 APP=re.compile(r"^(10|13):[0-9]{6}$"),
668 UFC=re.compile(r"^02:[0-9]{6}$"),
669 SEN=re.compile(r"^(01|03|04|12|22|34):[0-9]{6}$"),
670)
672# Domains
673F6: Final = "F6"
674F7: Final = "F7"
675F8: Final = "F8"
676F9: Final = "F9"
677FA: Final = "FA"
678FB: Final = "FB"
679FC: Final = "FC"
680FD: Final = "FD"
681FE: Final = "FE"
682FF: Final = "FF"
684DOMAIN_TYPE_MAP: dict[str, str] = {
685 F6: "cooling_valve", # cooling
686 F7: "domain_f7",
687 F8: "domain_f8",
688 F9: DEV_ROLE_MAP[DevRole.HT1], # Heating Valve
689 FA: DEV_ROLE_MAP[DevRole.HTG], # HW Valve (or UFH loop if src.type == UFC?)
690 FB: "domain_fb", # also: cooling valve?
691 FC: DEV_ROLE_MAP[DevRole.APP], # appliance_control
692 FD: "domain_fd", # seen with hometronics
693 # "FE": ???
694 # FF: "system", # TODO: remove this, is not a domain
695} # "21": "Ventilation", "88": ???
696DOMAIN_TYPE_LOOKUP = {v: k for k, v in DOMAIN_TYPE_MAP.items() if k != FF}
698DHW_STATE_MAP: dict[str, str] = {"00": "off", "01": "on"}
699DHW_STATE_LOOKUP = {v: k for k, v in DHW_STATE_MAP.items()}
701DTM_LONG_REGEX = re.compile(
702 r"\d{4}-[01]\d-[0-3]\d(T| )[0-2]\d:[0-5]\d:[0-5]\d\.\d{6} ?"
703) # 2020-11-30T13:15:00.123456
704DTM_TIME_REGEX = re.compile(r"[0-2]\d:[0-5]\d:[0-5]\d\.\d{3} ?") # 13:15:00.123
706# Used by packet structure validators
707r = r"(-{3}|\d{3}|\.{3})" # RSSI, '...' was used by an older version of evofw3
708v = r"( I|RP|RQ| W)" # verb
709d = r"(-{2}:-{6}|\d{2}:\d{6})" # device ID
710c = r"[0-9A-F]{4}" # code
711l = r"\d{3}" # length # noqa: E741
712p = r"([0-9A-F]{2}){1,48}" # payload
714# DEVICE_ID_REGEX = re.compile(f"^{d}$")
715COMMAND_REGEX = re.compile(f"^{v} {r} {d} {d} {d} {c} {l} {p}$")
716MESSAGE_REGEX = re.compile(f"^{r} {v} {r} {d} {d} {d} {c} {l} {p}$")
719# Used by 0418/system_fault parser
720class FaultDeviceClass(StrEnum):
721 """Device classes for system faults."""
723 CONTROLLER = "controller"
724 SENSOR = "sensor"
725 SETPOINT = "setpoint"
726 ACTUATOR = "actuator" # if domain is FC, then "boiler_relay"
727 DHW_ACTUATOR = "dhw_sensor"
728 RF_GATEWAY = "rf_gateway"
729 BOILER_RELAY = "boiler_relay"
730 UNKNOWN = "unknown"
733FAULT_DEVICE_CLASS: Final[dict[str, FaultDeviceClass]] = {
734 "00": FaultDeviceClass.CONTROLLER,
735 "01": FaultDeviceClass.SENSOR,
736 "02": FaultDeviceClass.SETPOINT,
737 "04": FaultDeviceClass.ACTUATOR, # if domain is FC, then BOILER_RELAY
738 "05": FaultDeviceClass.DHW_ACTUATOR,
739 "06": FaultDeviceClass.RF_GATEWAY,
740}
743class FaultState(StrEnum):
744 """States for system faults."""
746 FAULT = "fault"
747 RESTORE = "restore"
748 UNKNOWN_C0 = "unknown_c0"
749 UNKNOWN = "unknown"
752FAULT_STATE: Final[dict[str, FaultState]] = { # a bitmap?
753 "00": FaultState.FAULT,
754 "40": FaultState.RESTORE,
755 "C0": FaultState.UNKNOWN_C0, # C0s do not appear in the evohome UI
756}
759class FaultType(StrEnum):
760 """Types of system faults."""
762 SYSTEM_FAULT = "system_fault"
763 MAINS_LOW = "mains_low"
764 BATTERY_LOW = "battery_low"
765 BATTERY_ERROR = "battery_error" # actually: 'evotouch_battery_error'
766 COMMS_FAULT = "comms_fault"
767 SENSOR_FAULT = "sensor_fault" # seen with zone sensor
768 SENSOR_ERROR = "sensor_error"
769 BAD_VALUE = "bad_value"
770 UNKNOWN = "unknown"
773FAULT_TYPE: Final[dict[str, FaultType]] = {
774 "01": FaultType.SYSTEM_FAULT,
775 "03": FaultType.MAINS_LOW,
776 "04": FaultType.BATTERY_LOW,
777 "05": FaultType.BATTERY_ERROR, # actually: 'evotouch_battery_error'
778 "06": FaultType.COMMS_FAULT,
779 "07": FaultType.SENSOR_FAULT, # seen with zone sensor
780 "0A": FaultType.SENSOR_ERROR,
781}
784class SystemType(StrEnum):
785 """System types (e.g. Evohome, Hometronics)."""
787 CHRONOTHERM = "chronotherm"
788 EVOHOME = "evohome"
789 HOMETRONICS = "hometronics"
790 PROGRAMMER = "programmer"
791 SUNDIAL = "sundial"
792 GENERIC = "generic"
795# used by 22Fx parser, and FanSwitch devices
796# SZ_BOOST_TIMER:Final = "boost_timer" # minutes, e.g. 10, 20, 30 minutes
797HEATER_MODE: Final = "heater_mode" # e.g. auto, off
798FAN_MODE: Final = "fan_mode" # e.g. low. high # . deprecated, use SZ_FAN_MODE, to be removed in Q1 2026
799FAN_RATE: Final = "fan_rate" # percentage, 0.0 - 1.0 # deprecated, use SZ_FAN_MODE, to be removed in Q1 2026
802# RP --- 01:054173 18:006402 --:------ 0005 004 00100000 # before adding RFG100
803# .I --- 01:054173 --:------ 01:054173 1FC9 012 0010E004D39D001FC904D39D
804# .W --- 30:248208 01:054173 --:------ 1FC9 012 0010E07BC9900012907BC990
805# .I --- 01:054173 30:248208 --:------ 1FC9 006 00FFFF04D39D
807# RP --- 01:054173 18:006402 --:------ 0005 004 00100100 # after adding RFG100
808# RP --- 01:054173 18:006402 --:------ 000C 006 0010007BC990 # 30:082155
809# RP --- 01:054173 18:006402 --:------ 0005 004 00100100 # before deleting RFG from CTL
810# .I --- 01:054173 --:------ 01:054173 0005 004 00100000 # when the RFG was deleted
811# RP --- 01:054173 18:006402 --:------ 0005 004 00100000 # after deleting the RFG
813# RP|zone_devices | 000E0... || {'domain_id': 'FA', 'device_role': 'dhw_valve', 'devices': ['13:081807']} # noqa: E501
814# RP|zone_devices | 010E0... || {'domain_id': 'FA', 'device_role': 'htg_valve', 'devices': ['13:106039']} # noqa: E501
816# Example of:
817# - Sundial RF2 Pack 3: 23:(ST9420C), 07:(CS92), and 22:(DTS92(E))
819# HCW80 has option of being wired (normally wireless)
820# ST9420C has battery back-up (as does evohome)
823# Below, verbs & codes - can use Verb/Code/Index for mypy type checking
824@verify(EnumCheck.UNIQUE)
825class VerbT(StrEnum):
826 """Protocol verbs (message types)."""
828 I_ = " I"
829 RQ = "RQ"
830 RP = "RP"
831 W_ = " W"
834I_: Final = VerbT.I_
835RQ: Final = VerbT.RQ
836RP: Final = VerbT.RP
837W_: Final = VerbT.W_
840@verify(EnumCheck.UNIQUE)
841class MsgId(StrEnum):
842 """Message identifiers."""
844 _00 = "00"
845 _03 = "03"
846 _06 = "06"
847 _01 = "01"
848 _05 = "05"
849 _0E = "0E"
850 _0F = "0F"
851 _11 = "11"
852 _12 = "12"
853 _13 = "13"
854 _19 = "19"
855 _1A = "1A"
856 _1B = "1B"
857 _1C = "1C"
858 _30 = "30"
859 _31 = "31"
860 _38 = "38"
861 _39 = "39"
862 _71 = "71" # unclear if is supported bt OTB
863 _72 = "72" # unclear if is supported bt OTB
864 _73 = "73"
865 _74 = "74" # unclear if is supported bt OTB
866 _75 = "75" # unclear if is supported bt OTB
867 _76 = "76" # unclear if is supported bt OTB
868 _77 = "77" # unclear if is supported bt OTB
869 _78 = "78" # unclear if is supported bt OTB
870 _79 = "79" # unclear if is supported bt OTB
871 _7A = "7A" # unclear if is supported bt OTB
872 _7B = "7B" # unclear if is supported bt OTB
873 _7F = "7F"
876# StrEnum is intended to include all known codes, see: test suite, code schema in ramses.py
877@verify(EnumCheck.UNIQUE)
878class Code(StrEnum):
879 """Protocol command codes."""
881 _0001 = "0001"
882 _0002 = "0002"
883 _0004 = "0004"
884 _0005 = "0005"
885 _0006 = "0006"
886 _0008 = "0008"
887 _0009 = "0009"
888 _000A = "000A"
889 _000C = "000C"
890 _000E = "000E"
891 _0016 = "0016"
892 _0100 = "0100"
893 _0150 = "0150"
894 _01D0 = "01D0"
895 _01E9 = "01E9"
896 _01FF = "01FF"
897 _0404 = "0404"
898 _0418 = "0418"
899 _042F = "042F"
900 _0B04 = "0B04"
901 _1030 = "1030"
902 _1060 = "1060"
903 _1081 = "1081"
904 _1090 = "1090"
905 _1098 = "1098"
906 _10A0 = "10A0"
907 _10B0 = "10B0"
908 _10D0 = "10D0"
909 _10E0 = "10E0"
910 _10E1 = "10E1"
911 _10E2 = "10E2"
912 _1100 = "1100"
913 _11F0 = "11F0"
914 _1260 = "1260"
915 _1280 = "1280"
916 _1290 = "1290"
917 _1298 = "1298"
918 _12A0 = "12A0"
919 _12B0 = "12B0"
920 _12C0 = "12C0"
921 _12C8 = "12C8"
922 _12F0 = "12F0"
923 _1300 = "1300"
924 _1470 = "1470"
925 _1F09 = "1F09"
926 _1F41 = "1F41"
927 _1F70 = "1F70"
928 _1FC9 = "1FC9"
929 _1FCA = "1FCA"
930 _1FD0 = "1FD0"
931 _1FD4 = "1FD4"
932 _2210 = "2210"
933 _2249 = "2249"
934 _22C9 = "22C9"
935 _22D0 = "22D0"
936 _22D9 = "22D9"
937 _22E0 = "22E0"
938 _22E5 = "22E5"
939 _22E9 = "22E9"
940 _22F1 = "22F1"
941 _22F2 = "22F2"
942 _22F3 = "22F3"
943 _22F4 = "22F4"
944 _22F7 = "22F7"
945 _22F8 = "22F8"
946 _22B0 = "22B0"
947 _2309 = "2309"
948 _2349 = "2349"
949 _2389 = "2389"
950 _2400 = "2400"
951 _2401 = "2401"
952 _2410 = "2410"
953 _2411 = "2411"
954 _2420 = "2420"
955 _2D49 = "2D49"
956 _2E04 = "2E04"
957 _2E10 = "2E10"
958 _30C9 = "30C9"
959 _3110 = "3110"
960 _3120 = "3120"
961 _313E = "313E"
962 _313F = "313F"
963 _3150 = "3150"
964 _31D9 = "31D9"
965 _31DA = "31DA"
966 _31E0 = "31E0"
967 _3200 = "3200"
968 _3210 = "3210"
969 _3220 = "3220"
970 _3221 = "3221"
971 _3222 = "3222"
972 _3223 = "3223"
973 _3B00 = "3B00"
974 _3EF0 = "3EF0"
975 _3EF1 = "3EF1"
976 _4401 = "4401"
977 _4E01 = "4E01"
978 _4E02 = "4E02"
979 _4E04 = "4E04"
980 _4E0D = "4E0D"
981 _4E15 = "4E15"
982 _4E16 = "4E16"
983 _PUZZ = "7FFF" # for internal use: not to be a RAMSES II code
986# fmt: off
987IndexT = Literal[
988 "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "0A", "0B", "0C", "0D", "0E", "0F",
989 "21", # used by Nuaire
990 "F0", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "FA", "FB", "FC", "FD", "FE", "FF"
991]
992# fmt: on