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

1#!/usr/bin/env python3 

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

3 

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

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

6""" 

7 

8from __future__ import annotations 

9 

10import re 

11from enum import EnumCheck, IntEnum, StrEnum, verify 

12from types import SimpleNamespace 

13from typing import Any, Final, Literal, NoReturn 

14 

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

16DEV_MODE = __dev_mode__ 

17 

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 

21 

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

23DEFAULT_ECHO_TIMEOUT: Final[float] = 0.50 

24 

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

26DEFAULT_RPLY_TIMEOUT: Final[float] = 0.50 

27DEFAULT_BUFFER_SIZE: Final[int] = 32 

28 

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 

33 

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

35MAX_RETRY_LIMIT: Final[int] = 3 

36 

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 

42 

43SZ_QOS: Final = "qos" 

44 

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" 

51 

52 

53# used by transport... 

54SZ_ACTIVE_HGI: Final = "active_gwy" 

55SZ_SIGNATURE: Final = "signature" 

56SZ_IS_EVOFW3: Final = "is_evofw3" 

57 

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 

62 

63 

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" 

69 

70SZ_SCHEDULE: Final = "schedule" 

71SZ_CHANGE_COUNTER: Final = "change_counter" 

72 

73SZ_SENSOR_FAULT: Final = "sensor_fault" 

74 

75 

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" 

108 

109SZ_PRESENCE_DETECTED: Final = "presence_detected" 

110 

111 

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" 

123 

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" 

137 

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" 

149 

150 

151@verify(EnumCheck.UNIQUE) 

152class 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 

162def 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 

172class 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 

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. 

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) 

364class 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 

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) 

412 

413 

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

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 

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) 

527 

528 

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

530class 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 

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) 

564 

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) 

575 

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) 

589 

590 

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" 

637 

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" 

647 

648# used in 1FC9 

649SZ_OFFER: Final = "offer" 

650SZ_ACCEPT: Final = "accept" 

651SZ_CONFIRM: Final = "confirm" 

652SZ_PHASE: Final = "phase" 

653 

654 

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 

659 

660 

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) 

671 

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" 

683 

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} 

697 

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

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

700 

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 

705 

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 

713 

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

717 

718 

719# Used by 0418/system_fault parser 

720class 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 

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} 

741 

742 

743class FaultState(StrEnum): 

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

745 

746 FAULT = "fault" 

747 RESTORE = "restore" 

748 UNKNOWN_C0 = "unknown_c0" 

749 UNKNOWN = "unknown" 

750 

751 

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} 

757 

758 

759class 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 

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} 

782 

783 

784class 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 

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 

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) 

825class VerbT(StrEnum): 

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

827 

828 I_ = " I" 

829 RQ = "RQ" 

830 RP = "RP" 

831 W_ = " W" 

832 

833 

834I_: Final = VerbT.I_ 

835RQ: Final = VerbT.RQ 

836RP: Final = VerbT.RP 

837W_: Final = VerbT.W_ 

838 

839 

840@verify(EnumCheck.UNIQUE) 

841class 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) 

878class 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 

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