Coverage for src/ramses_tx/const.py: 95%

1#!/usr/bin/env python3

2"""RAMSES RF - a RAMSES-II protocol decoder & analyser.

3

4 This module contains constants, enums, and helper classes used throughout the

5 library to decode and encode RAMSES-II protocol packets.

6"""

7

8 from __future__ import annotations

9

10 import re

11 from enum import EnumCheck, IntEnum, StrEnum, verify

12 from types import SimpleNamespace

13 from typing import Any, Final, Literal, NoReturn

14

15 __dev_mode__ = False # NOTE: this is const.py

16 DEV_MODE = __dev_mode__

17

18# used by protocol QoS FSM (echo tout is different? for MQTT)...

19 DEFAULT_DISABLE_QOS: Final[bool | None] = None

20 DEFAULT_WAIT_FOR_REPLY: Final[bool | None] = None

21

22#: Waiting for echo pkt after cmd sent (seconds)

23 DEFAULT_ECHO_TIMEOUT: Final[float] = 0.50

24

25#: Waiting for reply pkt after echo pkt rcvd (seconds)

26 DEFAULT_RPLY_TIMEOUT: Final[float] = 0.50

27 DEFAULT_BUFFER_SIZE: Final[int] = 32

28

29#: Total waiting for successful send (seconds)

30 DEFAULT_SEND_TIMEOUT: Final[float] = 20.0

31#: For a command to be sent, incl. queuing time (seconds)

32 MAX_SEND_TIMEOUT: Final[float] = 20.0

33

34#: For a command to be re-sent (not incl. 1st send)

35 MAX_RETRY_LIMIT: Final[int] = 3

36

37#: Minimum gap between writes (seconds)

38 MIN_INTER_WRITE_GAP: Final[float] = 0.05

39 DEFAULT_GAP_DURATION: Final[float] = MIN_INTER_WRITE_GAP

40 DEFAULT_MAX_RETRIES: Final[int] = 3

41 DEFAULT_NUM_REPEATS: Final[int] = 0

42

43 SZ_QOS: Final = "qos"

44

45 SZ_CALLBACK: Final = "callback"

46 SZ_GAP_DURATION: Final = "gap_duration"

47 SZ_MAX_RETRIES: Final = "max_retries"

48 SZ_NUM_REPEATS: Final = "num_repeats"

49 SZ_PRIORITY: Final = "priority"

50 SZ_TIMEOUT: Final = "timeout"

51

52

53# used by transport...

54 SZ_ACTIVE_HGI: Final = "active_gwy"

55 SZ_SIGNATURE: Final = "signature"

56 SZ_IS_EVOFW3: Final = "is_evofw3"

57

58# default values for transmit rate governers...

59 DUTY_CYCLE_DURATION = 60 # time window (seconds) where rate limiting occurs

60 MAX_DUTY_CYCLE_RATE = 0.01 # % bandwidth used per cycle

61 MAX_TRANSMIT_RATE_TOKENS = 80 # transmits per cycle

62

63

64# used by schedule.py...

65 SZ_FRAGMENT: Final = "fragment"

66 SZ_FRAG_NUMBER: Final = "frag_number"

67 SZ_FRAG_LENGTH: Final = "frag_length"

68 SZ_TOTAL_FRAGS: Final = "total_frags"

69

70 SZ_SCHEDULE: Final = "schedule"

71 SZ_CHANGE_COUNTER: Final = "change_counter"

72

73 SZ_SENSOR_FAULT: Final = "sensor_fault"

74

75

76# used by 31DA

77 SZ_AIR_QUALITY: Final = "air_quality"

78 SZ_AIR_QUALITY_BASIS: Final = "air_quality_basis"

79 SZ_BOOST_TIMER: Final = "boost_timer"

80 SZ_BYPASS_MODE: Final = "bypass_mode"

81 SZ_BYPASS_POSITION: Final = "bypass_position"

82 SZ_BYPASS_STATE: Final = "bypass_state"

83 SZ_CO2_LEVEL: Final = "co2_level"

84 SZ_DEWPOINT_TEMP: Final = "dewpoint_temp"

85 SZ_EXHAUST_FAN_SPEED: Final = "exhaust_fan_speed"

86 SZ_EXHAUST_FLOW: Final = "exhaust_flow"

87 SZ_EXHAUST_TEMP: Final = "exhaust_temp"

88 SZ_FAN_INFO: Final = "fan_info"

89 SZ_FAN_MODE: Final = "fan_mode"

90 SZ_FAN_RATE: Final = "fan_rate"

91 SZ_FILTER_REMAINING: Final = "filter_remaining"

92 SZ_INDOOR_HUMIDITY: Final = "indoor_humidity"

93 SZ_INDOOR_TEMP: Final = "indoor_temp"

94 SZ_OUTDOOR_HUMIDITY: Final = "outdoor_humidity"

95 SZ_OUTDOOR_TEMP: Final = "outdoor_temp"

96 SZ_POST_HEAT: Final = "post_heat"

97 SZ_PRE_HEAT: Final = "pre_heat"

98 SZ_REL_HUMIDITY: Final = "rel_humidity"

99 SZ_REMAINING_DAYS: Final = "days_remaining"

100 SZ_REMAINING_MINS: Final = "remaining_mins"

101 SZ_REMAINING_PERCENT: Final = "percent_remaining"

102 SZ_REQ_REASON: Final = "req_reason"

103 SZ_REQ_SPEED: Final = "req_speed"

104 SZ_SUPPLY_FAN_SPEED: Final = "supply_fan_speed"

105 SZ_SUPPLY_FLOW: Final = "supply_flow"

106 SZ_SUPPLY_TEMP: Final = "supply_temp"

107 SZ_SPEED_CAPABILITIES: Final = "speed_capabilities"

108

109 SZ_PRESENCE_DETECTED: Final = "presence_detected"

110

111

112# used by OTB

113 SZ_BURNER_HOURS: Final = "burner_hours"

114 SZ_BURNER_STARTS: Final = "burner_starts"

115 SZ_BURNER_FAILED_STARTS: Final = "burner_failed_starts"

116 SZ_CH_PUMP_HOURS: Final = "ch_pump_hours"

117 SZ_CH_PUMP_STARTS: Final = "ch_pump_starts"

118 SZ_DHW_BURNER_HOURS: Final = "dhw_burner_hours"

119 SZ_DHW_BURNER_STARTS: Final = "dhw_burner_starts"

120 SZ_DHW_PUMP_HOURS: Final = "dhw_pump_hours"

121 SZ_DHW_PUMP_STARTS: Final = "dhw_pump_starts"

122 SZ_FLAME_SIGNAL_LOW: Final = "flame_signal_low"

123

124 SZ_BOILER_OUTPUT_TEMP: Final = "boiler_output_temp"

125 SZ_BOILER_RETURN_TEMP: Final = "boiler_return_temp"

126 SZ_BOILER_SETPOINT: Final = "boiler_setpoint"

127 SZ_CH_MAX_SETPOINT: Final = "ch_max_setpoint"

128 SZ_CH_SETPOINT: Final = "ch_setpoint"

129 SZ_CH_WATER_PRESSURE: Final = "ch_water_pressure"

130 SZ_DHW_FLOW_RATE: Final = "dhw_flow_rate"

131 SZ_DHW_SETPOINT: Final = "dhw_setpoint"

132 SZ_DHW_TEMP: Final = "dhw_temp"

133 SZ_MAX_REL_MODULATION: Final = "max_rel_modulation"

134# SZ_OEM_CODE:Final[str] = "oem_code"

135 SZ_OUTSIDE_TEMP: Final = "outside_temp"

136 SZ_REL_MODULATION_LEVEL: Final = "rel_modulation_level"

137

138 SZ_CH_ACTIVE: Final = "ch_active"

139 SZ_CH_ENABLED: Final = "ch_enabled"

140 SZ_COOLING_ACTIVE: Final = "cooling_active"

141 SZ_COOLING_ENABLED: Final = "cooling_enabled"

142 SZ_DHW_ACTIVE: Final = "dhw_active"

143 SZ_DHW_BLOCKING: Final = "dhw_blocking"

144 SZ_DHW_ENABLED: Final = "dhw_enabled"

145 SZ_FAULT_PRESENT: Final = "fault_present"

146 SZ_FLAME_ACTIVE: Final = "flame_active"

147 SZ_SUMMER_MODE: Final = "summer_mode"

148 SZ_OTC_ACTIVE: Final = "otc_active"

149

150

151@verify(EnumCheck.UNIQUE)

152 class Priority(IntEnum):

153 """Priority levels for protocol messages."""

154

155 LOWEST = 4

156 LOW = 2

157 DEFAULT = 0

158 HIGH = -2

159 HIGHEST = -4

160

161

162 def slug(string: str) -> str:

163 """Convert a string to snake_case.

164

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())

169

170

171# TODO: FIXME: This is a mess - needs converting to StrEnum

172 class AttrDict(dict): # type: ignore[type-arg]

173 """A read-only dictionary that supports dot-access and two-way lookup.

174

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``).

177

178 .. warning::

179 This class is immutable. Attempting to modify it will raise a :exc:`TypeError`.

180 """

181

182 _SZ_AKA_SLUG: Final = "_root_slug"

183 _SZ_DEFAULT: Final = "_default"

184 _SZ_SLUGS: Final = "SLUGS"

185

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")

189

190 def __setitem__(self, key: Any, value: Any) -> NoReturn:

191 self._readonly()

192

193 def __delitem__(self, key: Any) -> NoReturn:

194 self._readonly()

195

196 def clear(self) -> NoReturn:

197 self._readonly()

198

199 def pop(self, *args: Any, **kwargs: Any) -> NoReturn:

200 self._readonly()

201

202 def popitem(self) -> NoReturn:

203 self._readonly()

204

205 def setdefault(self, *args: Any, **kwargs: Any) -> NoReturn:

206 self._readonly()

207

208 def update(self, *args: Any, **kwargs: Any) -> NoReturn:

209 self._readonly()

210

211 def __init__(self, main_table: dict[str, dict], attr_table: dict[str, Any]) -> None: # type: ignore[type-arg]

212 """Initialize the AttrDict.

213

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()))

220

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 )

243

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]))

257

258 super().__init__(self._forward)

259

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)

268

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)

280

281 def _hex(self, key: str) -> str:

282 """Return the key/ID (2-byte hex string) of the two-way dict (e.g. '04').

283

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)

293

294 def _str(self, key: str) -> str:

295 """Return the value (string) of the two-way dict (e.g. 'radiator_valve').

296

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)

306

307 # def values(self):

308 # return {k: k for k in super().values()}.values()

309

310 def slug(self, key: str) -> str:

311 """Return master slug for a hex key/ID.

312

313 Example: 00 -> 'TRV' (master), not 'TR0'.

314

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]

322

323 def slugs(self) -> tuple[str]:

324 """Return the slugs from the main table.

325

326 :return: A tuple of all available slugs.

327 """

328 return self._attr_table[self._SZ_SLUGS] # type: ignore[no-any-return]

329

330

331 def 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.

336

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 = {}

343

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 # )

358

359 return SlottedAttrDict(main_table, attr_table=attr_table)

360

361

362# slugs for device/zone entity klasses, used by 0005/000C

363@verify(EnumCheck.UNIQUE)

364 class DevRole(StrEnum):

365 """Slugs for device/zone entity classes, used by commands 0005/000C."""

366

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)

388

389

390 DEV_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)

412

413

414# slugs for device entity types, used in device_ids

415@verify(EnumCheck.UNIQUE)

416 class DevType(StrEnum):

417 """Slugs for device entity types, used in device_ids."""

418

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

457

458

459 DEV_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)

527

528

529# slugs for zone entity klasses, used by 0005/000C

530 class ZoneRole(StrEnum):

531 """Slugs for zone entity classes, used by commands 0005/000C."""

532

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

546

547

548 ZON_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)

564

565# Zone modes

566 ZON_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)

575

576# System modes

577 SYS_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)

589

590

591 SZ_ACTIVE: Final = "active"

592 SZ_ACTUATOR: Final = "actuator"

593 SZ_ACTUATORS: Final = "actuators"

594 SZ_BINDINGS: Final = "bindings"

595 SZ_CONFIG: Final = "config"

596 SZ_DATETIME: Final = "datetime"

597 SZ_DEMAND: Final = "demand"

598 SZ_DEVICE_ID: Final = "device_id"

599 SZ_DEVICE_ROLE: Final = "device_role"

600 SZ_DEVICES: Final = "devices"

601 SZ_DHW_IDX: Final = "dhw_idx"

602 SZ_DOMAIN_ID: Final = "domain_id"

603 SZ_DURATION: Final = "duration"

604 SZ_HEAT_DEMAND: Final = "heat_demand"

605 SZ_IS_DST: Final = "is_dst"

606 SZ_LANGUAGE: Final = "language"

607 SZ_LOCAL_OVERRIDE: Final = "local_override"

608 SZ_MAX_TEMP: Final = "max_temp"

609 SZ_MIN_TEMP: Final = "min_temp"

610 SZ_MIX_CONFIG: Final = "mix_config"

611 SZ_MODE: Final = "mode"

612 SZ_MULTIROOM_MODE: Final = "multiroom_mode"

613 SZ_NAME: Final = "name"

614 SZ_OEM_CODE: Final = "oem_code"

615 SZ_OPENWINDOW_FUNCTION: Final = "openwindow_function"

616 SZ_PAYLOAD: Final = "payload"

617 SZ_PERCENTAGE: Final = "percentage"

618 SZ_PRESSURE: Final = "pressure"

619 SZ_RELAY_DEMAND: Final = "relay_demand"

620 SZ_RELAY_FAILSAFE: Final = "relay_failsafe"

621 SZ_SENSOR: Final = "sensor"

622 SZ_SETPOINT: Final = "setpoint"

623 SZ_SETPOINT_BOUNDS: Final = "setpoint_bounds"

624 SZ_SLUG: Final = "_SLUG"

625 SZ_SYSTEM_MODE: Final = "system_mode"

626 SZ_TEMPERATURE: Final = "temperature"

627 SZ_UFH_IDX: Final = "ufh_idx"

628 SZ_UNKNOWN: Final = "unknown"

629 SZ_UNTIL: Final = "until"

630 SZ_VALUE: Final = "value"

631 SZ_WINDOW_OPEN: Final = "window_open"

632 SZ_ZONE_CLASS: Final = "zone_class"

633 SZ_ZONE_IDX: Final = "zone_idx"

634 SZ_ZONE_MASK: Final = "zone_mask"

635 SZ_ZONE_TYPE: Final = "zone_type"

636 SZ_ZONES: Final = "zones"

637

638# used in 0418 only?

639 SZ_DEVICE_CLASS: Final = "device_class"

640# _DEVICE_ID: Final = "device_id"

641 SZ_DOMAIN_IDX: Final = "domain_idx"

642 SZ_FAULT_STATE: Final = "fault_state"

643 SZ_FAULT_TYPE: Final = "fault_type"

644 SZ_LOG_ENTRY: Final = "log_entry"

645 SZ_LOG_IDX: Final = "log_idx"

646 SZ_TIMESTAMP: Final = "timestamp"

647

648# used in 1FC9

649 SZ_OFFER: Final = "offer"

650 SZ_ACCEPT: Final = "accept"

651 SZ_CONFIRM: Final = "confirm"

652 SZ_PHASE: Final = "phase"

653

654

655 DEFAULT_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

659

660

661 DEVICE_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)

671

672# Domains

673 F6: Final = "F6"

674 F7: Final = "F7"

675 F8: Final = "F8"

676 F9: Final = "F9"

677 FA: Final = "FA"

678 FB: Final = "FB"

679 FC: Final = "FC"

680 FD: Final = "FD"

681 FE: Final = "FE"

682 FF: Final = "FF"

683

684 DOMAIN_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": ???

696 DOMAIN_TYPE_LOOKUP = {v: k for k, v in DOMAIN_TYPE_MAP.items() if k != FF}

697

698 DHW_STATE_MAP: dict[str, str] = {"00": "off", "01": "on"}

699 DHW_STATE_LOOKUP = {v: k for k, v in DHW_STATE_MAP.items()}

700

701 DTM_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

704 DTM_TIME_REGEX = re.compile(r"[0-2]\d:[0-5]\d:[0-5]\d\.\d{3} ?") # 13:15:00.123

705

706# Used by packet structure validators

707 r = r"(-{3}|\d{3}|\.{3})" # RSSI, '...' was used by an older version of evofw3

708 v = r"( I|RP|RQ| W)" # verb

709 d = r"(-{2}:-{6}|\d{2}:\d{6})" # device ID

710 c = r"[0-9A-F]{4}" # code

711 l = r"\d{3}" # length # noqa: E741

712 p = r"([0-9A-F]{2}){1,48}" # payload

713

714# DEVICE_ID_REGEX = re.compile(f"^{d}$")

715 COMMAND_REGEX = re.compile(f"^{v} {r} {d} {d} {d} {c} {l} {p}$")

716 MESSAGE_REGEX = re.compile(f"^{r} {v} {r} {d} {d} {d} {c} {l} {p}$")

717

718

719# Used by 0418/system_fault parser

720 class FaultDeviceClass(StrEnum):

721 """Device classes for system faults."""

722

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"

731

732

733 FAULT_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}

741

742

743 class FaultState(StrEnum):

744 """States for system faults."""

745

746 FAULT = "fault"

747 RESTORE = "restore"

748 UNKNOWN_C0 = "unknown_c0"

749 UNKNOWN = "unknown"

750

751

752 FAULT_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}

757

758

759 class FaultType(StrEnum):

760 """Types of system faults."""

761

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"

771

772

773 FAULT_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}

782

783

784 class SystemType(StrEnum):

785 """System types (e.g. Evohome, Hometronics)."""

786

787 CHRONOTHERM = "chronotherm"

788 EVOHOME = "evohome"

789 HOMETRONICS = "hometronics"

790 PROGRAMMER = "programmer"

791 SUNDIAL = "sundial"

792 GENERIC = "generic"

793

794

795# used by 22Fx parser, and FanSwitch devices

796# SZ_BOOST_TIMER:Final = "boost_timer" # minutes, e.g. 10, 20, 30 minutes

797 HEATER_MODE: Final = "heater_mode" # e.g. auto, off

798 FAN_MODE: Final = "fan_mode" # e.g. low. high # . deprecated, use SZ_FAN_MODE, to be removed in Q1 2026

799 FAN_RATE: Final = "fan_rate" # percentage, 0.0 - 1.0 # deprecated, use SZ_FAN_MODE, to be removed in Q1 2026

800

801

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

806

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

812

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

815

816# Example of:

817# - Sundial RF2 Pack 3: 23:(ST9420C), 07:(CS92), and 22:(DTS92(E))

818

819# HCW80 has option of being wired (normally wireless)

820# ST9420C has battery back-up (as does evohome)

821

822

823# Below, verbs & codes - can use Verb/Code/Index for mypy type checking

824@verify(EnumCheck.UNIQUE)

825 class VerbT(StrEnum):

826 """Protocol verbs (message types)."""

827

828 I_ = " I"

829 RQ = "RQ"

830 RP = "RP"

831 W_ = " W"

832

833

834 I_: Final = VerbT.I_

835 RQ: Final = VerbT.RQ

836 RP: Final = VerbT.RP

837 W_: Final = VerbT.W_

838

839

840@verify(EnumCheck.UNIQUE)

841 class MsgId(StrEnum):

842 """Message identifiers."""

843

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"

874

875

876# StrEnum is intended to include all known codes, see: test suite, code schema in ramses.py

877@verify(EnumCheck.UNIQUE)

878 class Code(StrEnum):

879 """Protocol command codes."""

880

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

984

985

986# fmt: off

987 IndexT = 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