ramses-rf 0.22.2__py3-none-any.whl → 0.51.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +286 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +378 -514
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.1.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.1.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.1.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. ramses_tx/parsers.py +2957 -0
  47. ramses_tx/protocol.py +801 -0
  48. ramses_tx/protocol_fsm.py +672 -0
  49. ramses_tx/py.typed +0 -0
  50. {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
  51. {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
  52. ramses_tx/transport.py +1471 -0
  53. ramses_tx/typed_dicts.py +492 -0
  54. ramses_tx/typing.py +181 -0
  55. ramses_tx/version.py +4 -0
  56. ramses_rf/discovery.py +0 -398
  57. ramses_rf/protocol/__init__.py +0 -59
  58. ramses_rf/protocol/backports.py +0 -42
  59. ramses_rf/protocol/command.py +0 -1561
  60. ramses_rf/protocol/const.py +0 -697
  61. ramses_rf/protocol/exceptions.py +0 -111
  62. ramses_rf/protocol/helpers.py +0 -390
  63. ramses_rf/protocol/opentherm.py +0 -1170
  64. ramses_rf/protocol/packet.py +0 -235
  65. ramses_rf/protocol/parsers.py +0 -2673
  66. ramses_rf/protocol/protocol.py +0 -613
  67. ramses_rf/protocol/transport.py +0 -1011
  68. ramses_rf/protocol/version.py +0 -10
  69. ramses_rf/system/hvac.py +0 -82
  70. ramses_rf-0.22.2.dist-info/METADATA +0 -64
  71. ramses_rf-0.22.2.dist-info/RECORD +0 -42
  72. ramses_rf-0.22.2.dist-info/top_level.txt +0 -1
ramses_tx/parsers.py ADDED
@@ -0,0 +1,2957 @@
1
+ #!/usr/bin/env python3
2
+ """RAMSES RF - payload processors.
3
+
4
+ NOTES: aspirations on a consistent Schema, going forward:
5
+
6
+ :mode/state: | :bool: | :mutex (infinitive. vs -ing): | :flags:
7
+ mode (config.) | enabled | disabled, heat, cool, heat_cool... | ch_enabled, dhw_enabled
8
+ state (action) | active | idle, heating, cooling... | is_heating, is_cooling
9
+
10
+ - prefer: enabled: True over xx_enabled: True (if only ever 1 flag)
11
+ - prefer: active: True over is_heating: True (if only ever 1 flag)
12
+ - avoid: is_enabled, is_active
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import re
19
+ from collections.abc import Mapping
20
+ from datetime import datetime as dt, timedelta as td
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from . import exceptions as exc
24
+ from .address import ALL_DEV_ADDR, NON_DEV_ADDR, hex_id_to_dev_id
25
+ from .const import (
26
+ DEV_ROLE_MAP,
27
+ DEV_TYPE_MAP,
28
+ FAULT_DEVICE_CLASS,
29
+ FAULT_STATE,
30
+ FAULT_TYPE,
31
+ SYS_MODE_MAP,
32
+ SZ_ACCEPT,
33
+ SZ_ACTIVE,
34
+ SZ_BINDINGS,
35
+ SZ_BYPASS_MODE,
36
+ SZ_BYPASS_STATE,
37
+ SZ_CHANGE_COUNTER,
38
+ SZ_CONFIRM,
39
+ SZ_DATETIME,
40
+ SZ_DEMAND,
41
+ SZ_DEVICE_CLASS,
42
+ SZ_DEVICE_ID,
43
+ SZ_DEVICE_ROLE,
44
+ SZ_DEVICES,
45
+ SZ_DHW_FLOW_RATE,
46
+ SZ_DOMAIN_ID,
47
+ SZ_DOMAIN_IDX,
48
+ SZ_DURATION,
49
+ SZ_FAN_MODE,
50
+ SZ_FAN_RATE,
51
+ SZ_FAULT_STATE,
52
+ SZ_FAULT_TYPE,
53
+ SZ_FRAG_LENGTH,
54
+ SZ_FRAG_NUMBER,
55
+ SZ_FRAGMENT,
56
+ SZ_IS_DST,
57
+ SZ_LANGUAGE,
58
+ SZ_LOCAL_OVERRIDE,
59
+ SZ_LOG_ENTRY,
60
+ SZ_LOG_IDX,
61
+ SZ_MAX_TEMP,
62
+ SZ_MIN_TEMP,
63
+ SZ_MODE,
64
+ SZ_MULTIROOM_MODE,
65
+ SZ_NAME,
66
+ SZ_OEM_CODE,
67
+ SZ_OFFER,
68
+ SZ_OPENWINDOW_FUNCTION,
69
+ SZ_PAYLOAD,
70
+ SZ_PHASE,
71
+ SZ_PRESSURE,
72
+ SZ_RELAY_DEMAND,
73
+ SZ_REMAINING_DAYS,
74
+ SZ_REMAINING_PERCENT,
75
+ SZ_SETPOINT,
76
+ SZ_SETPOINT_BOUNDS,
77
+ SZ_SYSTEM_MODE,
78
+ SZ_TEMPERATURE,
79
+ SZ_TIMESTAMP,
80
+ SZ_TOTAL_FRAGS,
81
+ SZ_UFH_IDX,
82
+ SZ_UNTIL,
83
+ SZ_VALUE,
84
+ SZ_WINDOW_OPEN,
85
+ SZ_ZONE_CLASS,
86
+ SZ_ZONE_IDX,
87
+ SZ_ZONE_MASK,
88
+ SZ_ZONE_TYPE,
89
+ ZON_MODE_MAP,
90
+ ZON_ROLE_MAP,
91
+ DevRole,
92
+ FaultDeviceClass,
93
+ )
94
+ from .fingerprints import check_signature
95
+ from .helpers import (
96
+ hex_to_bool,
97
+ hex_to_date,
98
+ hex_to_dtm,
99
+ hex_to_dts,
100
+ hex_to_flag8,
101
+ hex_to_percent,
102
+ hex_to_str,
103
+ hex_to_temp,
104
+ parse_air_quality,
105
+ parse_bypass_position,
106
+ parse_capabilities,
107
+ parse_co2_level,
108
+ parse_exhaust_fan_speed,
109
+ parse_exhaust_flow,
110
+ parse_exhaust_temp,
111
+ parse_fan_info,
112
+ parse_fault_log_entry,
113
+ parse_humidity_element,
114
+ parse_indoor_humidity,
115
+ parse_indoor_temp,
116
+ parse_outdoor_humidity,
117
+ parse_outdoor_temp,
118
+ parse_post_heater,
119
+ parse_pre_heater,
120
+ parse_remaining_mins,
121
+ parse_supply_fan_speed,
122
+ parse_supply_flow,
123
+ parse_supply_temp,
124
+ parse_valve_demand,
125
+ )
126
+ from .opentherm import (
127
+ EN,
128
+ SZ_DESCRIPTION,
129
+ SZ_MSG_ID,
130
+ SZ_MSG_NAME,
131
+ SZ_MSG_TYPE,
132
+ OtMsgType,
133
+ decode_frame,
134
+ )
135
+ from .ramses import _31D9_FAN_INFO_VASCO, _2411_PARAMS_SCHEMA
136
+ from .typed_dicts import PayDictT
137
+ from .version import VERSION
138
+
139
+ # Kudos & many thanks to:
140
+ # - Evsdd: 0404 (wow!)
141
+ # - Ierlandfan: 3150, 31D9, 31DA, others
142
+ # - ReneKlootwijk: 3EF0
143
+ # - brucemiranda: 3EF0, others
144
+ # - janvken: 10D0, 1470, 1F70, 22B0, 2411, several others
145
+ # - tomkooij: 3110
146
+ # - RemyDeRuysscher: 10E0, 31DA (and related), others
147
+ # - silverailscolo: 12A0, 31DA, others
148
+
149
+
150
+ from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
151
+ I_,
152
+ RP,
153
+ RQ,
154
+ W_,
155
+ Code,
156
+ )
157
+ from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
158
+ F6,
159
+ F8,
160
+ F9,
161
+ FA,
162
+ FB,
163
+ FC,
164
+ FF,
165
+ )
166
+
167
+ if TYPE_CHECKING:
168
+ from .message import MessageBase as Message # HACK: merge MsgBase into Msg
169
+
170
+ _2411_TABLE = {k: v["description"] for k, v in _2411_PARAMS_SCHEMA.items()}
171
+
172
+ LOOKUP_PUZZ = {
173
+ "10": "engine", # . # version str, e.g. v0.14.0
174
+ "11": "impersonating", # pkt header, e.g. 30C9| I|03:123001 (15 characters, packed)
175
+ "12": "message", # . # message only, max len is 16 ascii characters
176
+ "13": "message", # . # message only, but without a timestamp, max len 22 chars
177
+ "20": "engine", # . # version str, e.g. v0.50.0, has higher-precision timestamp
178
+ "7F": "null", # . # packet is null / was nullified: payload to be ignored
179
+ } # "00" is reserved
180
+
181
+
182
+ _INFORM_DEV_MSG = "Support the development of ramses_rf by reporting this packet"
183
+
184
+
185
+ _LOGGER = _PKT_LOGGER = logging.getLogger(__name__)
186
+
187
+
188
+ # rf_unknown
189
+ def parser_0001(payload: str, msg: Message) -> Mapping[str, bool | str | None]:
190
+ # When in test mode, a 12: will send a W ?every 6 seconds:
191
+ # 12:39:56.099 061 W --- 12:010740 --:------ 12:010740 0001 005 0000000501
192
+ # 12:40:02.098 061 W --- 12:010740 --:------ 12:010740 0001 005 0000000501
193
+ # 12:40:08.099 058 W --- 12:010740 --:------ 12:010740 0001 005 0000000501
194
+
195
+ # sent by a THM when is signal strength test mode (0505, except 1st pkt)
196
+ # 13:48:38.518 080 W --- 12:010740 --:------ 12:010740 0001 005 0000000501
197
+ # 13:48:45.518 074 W --- 12:010740 --:------ 12:010740 0001 005 0000000505
198
+ # 13:48:50.518 077 W --- 12:010740 --:------ 12:010740 0001 005 0000000505
199
+
200
+ # sent by a CTL before a rf_check
201
+ # 15:12:47.769 053 W --- 01:145038 --:------ 01:145038 0001 005 FC00000505
202
+ # 15:12:47.869 053 RQ --- 01:145038 13:237335 --:------ 0016 002 00FF
203
+ # 15:12:47.880 053 RP --- 13:237335 01:145038 --:------ 0016 002 0017
204
+
205
+ # 12:30:18.083 047 W --- 01:145038 --:------ 01:145038 0001 005 0800000505
206
+ # 12:30:23.084 049 W --- 01:145038 --:------ 01:145038 0001 005 0800000505
207
+
208
+ # 15:03:33.187 054 W --- 01:145038 --:------ 01:145038 0001 005 FC00000505
209
+ # 15:03:38.188 063 W --- 01:145038 --:------ 01:145038 0001 005 FC00000505
210
+ # 15:03:43.188 064 W --- 01:145038 --:------ 01:145038 0001 005 FC00000505
211
+ # 15:13:19.757 053 W --- 01:145038 --:------ 01:145038 0001 005 FF00000505
212
+ # 15:13:24.758 054 W --- 01:145038 --:------ 01:145038 0001 005 FF00000505
213
+ # 15:13:29.758 068 W --- 01:145038 --:------ 01:145038 0001 005 FF00000505
214
+ # 15:13:34.759 063 W --- 01:145038 --:------ 01:145038 0001 005 FF00000505
215
+
216
+ # sent by a CTL
217
+ # 16:49:46.125 057 W --- 04:166090 --:------ 01:032820 0001 005 0100000505
218
+ # 16:53:34.635 058 W --- 04:166090 --:------ 01:032820 0001 005 0100000505
219
+
220
+ # loopback (not Tx'd) by a HGI80 whenever its button is pressed
221
+ # 00:22:41.540 --- I --- --:------ --:------ --:------ 0001 005 00FFFF02FF
222
+ # 00:22:41.757 --- I --- --:------ --:------ --:------ 0001 005 00FFFF0200
223
+ # 00:22:43.320 --- I --- --:------ --:------ --:------ 0001 005 00FFFF02FF
224
+ # 00:22:43.415 --- I --- --:------ --:------ --:------ 0001 005 00FFFF0200
225
+
226
+ # From a CM927:
227
+ # W/--:/--:/12:/00-0000-0501 = Test transmit
228
+ # W/--:/--:/12:/00-0000-0505 = Field strength
229
+
230
+ if payload[2:6] in ("2000", "8000", "A000"):
231
+ mode = "hvac"
232
+ elif payload[2:6] in ("0000", "FFFF"):
233
+ mode = "heat"
234
+ else:
235
+ mode = "heat"
236
+
237
+ if mode == "hvac":
238
+ result: dict[str, bool | str | None]
239
+
240
+ assert payload[:2] == "00", payload[:2]
241
+ # assert payload[2:4] in ("20", "80", "A0"), payload[2:4]
242
+ # assert payload[4:6] == "00", payload[4:6]
243
+ assert payload[8:10] in ("00", "04", "10", "20", "FF"), payload[8:10]
244
+
245
+ result = {"payload": payload, "slot_num": payload[6:8]}
246
+ if msg.len >= 6:
247
+ result.update({"param_num": payload[10:12]})
248
+ if msg.len >= 7:
249
+ result.update({"next_slot_num": payload[12:14]})
250
+ if msg.len >= 8:
251
+ _14 = None if payload[14:16] == "FF" else bool(int(payload[14:16]))
252
+ result.update({"boolean_14": _14})
253
+ return result
254
+
255
+ assert payload[2:6] in ("0000", "FFFF"), payload[2:6]
256
+ assert payload[8:10] in ("00", "02", "05"), payload[8:10]
257
+
258
+ return {
259
+ SZ_PAYLOAD: "-".join((payload[:2], payload[2:6], payload[6:8], payload[8:])),
260
+ }
261
+
262
+
263
+ # outdoor_sensor (outdoor_weather / outdoor_temperature)
264
+ def parser_0002(payload: str, msg: Message) -> dict[str, Any]:
265
+ if payload[6:] == "02": # or: msg.src.type == DEV_TYPE_MAP.OUT:
266
+ return {
267
+ SZ_TEMPERATURE: hex_to_temp(payload[2:6]),
268
+ "_unknown": payload[6:],
269
+ }
270
+
271
+ return {"_payload": payload}
272
+
273
+
274
+ # zone_name
275
+ def parser_0004(payload: str, msg: Message) -> PayDictT._0004:
276
+ # RQ payload is zz00; limited to 12 chars in evohome UI? if "7F"*20: not a zone
277
+
278
+ return {} if payload[4:] == "7F" * 20 else {SZ_NAME: hex_to_str(payload[4:])}
279
+
280
+
281
+ # system_zones (add/del a zone?) # TODO: needs a cleanup
282
+ def parser_0005(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
283
+ # .I --- 01:145038 --:------ 01:145038 0005 004 00000100
284
+ # RP --- 02:017205 18:073736 --:------ 0005 004 0009001F
285
+ # .I --- 34:064023 --:------ 34:064023 0005 012 000A0000-000F0000-00100000
286
+
287
+ def _parser(seqx: str) -> dict:
288
+ if msg.src.type == DEV_TYPE_MAP.UFC: # DEX, or use: seqx[2:4] == ...
289
+ zone_mask = hex_to_flag8(seqx[6:8], lsb=True)
290
+ elif msg.len == 3: # ATC928G1000 - 1st gen monochrome model, max 8 zones
291
+ zone_mask = hex_to_flag8(seqx[4:6], lsb=True)
292
+ else:
293
+ zone_mask = hex_to_flag8(seqx[4:6], lsb=True) + hex_to_flag8(
294
+ seqx[6:8], lsb=True
295
+ )
296
+ zone_class = ZON_ROLE_MAP.get(seqx[2:4], DEV_ROLE_MAP[seqx[2:4]])
297
+ return {
298
+ SZ_ZONE_TYPE: seqx[2:4], # TODO: ?remove & keep zone_class?
299
+ SZ_ZONE_MASK: zone_mask,
300
+ SZ_ZONE_CLASS: zone_class, # TODO: ?remove & keep zone_type?
301
+ }
302
+
303
+ if msg.verb == RQ: # RQs have a context: zone_type
304
+ return {SZ_ZONE_TYPE: payload[2:4], SZ_ZONE_CLASS: DEV_ROLE_MAP[payload[2:4]]}
305
+
306
+ if msg._has_array:
307
+ assert msg.verb == I_ and msg.src.type == DEV_TYPE_MAP.RND, (
308
+ f"{msg!r} # expecting I/{DEV_TYPE_MAP.RND}:"
309
+ ) # DEX
310
+ return [_parser(payload[i : i + 8]) for i in range(0, len(payload), 8)]
311
+
312
+ return _parser(payload)
313
+
314
+
315
+ # schedule_sync (any changes?)
316
+ def parser_0006(payload: str, msg: Message) -> PayDictT._0006:
317
+ """Return the total number of changes to the schedules, including the DHW schedule.
318
+
319
+ An RQ is sent every ~60s by a RFG100, an increase will prompt it to send a run of
320
+ RQ|0404s (it seems to assume only the zones may have changed?).
321
+ """
322
+ # 16:10:34.288 053 RQ --- 30:071715 01:145038 --:------ 0006 001 00
323
+ # 16:10:34.291 053 RP --- 01:145038 30:071715 --:------ 0006 004 00050008
324
+
325
+ if payload[2:] == "FFFFFF": # RP to an invalid RQ
326
+ return {}
327
+
328
+ assert payload[2:4] == "05"
329
+
330
+ return {
331
+ SZ_CHANGE_COUNTER: None if payload[4:] == "FFFF" else int(payload[4:], 16),
332
+ }
333
+
334
+
335
+ # relay_demand (domain/zone/device)
336
+ def parser_0008(payload: str, msg: Message) -> PayDictT._0008:
337
+ # https://www.domoticaforum.eu/viewtopic.php?f=7&t=5806&start=105#p73681
338
+ # e.g. Electric Heat Zone
339
+
340
+ # .I --- 01:145038 --:------ 01:145038 0008 002 0314
341
+ # .I --- 01:145038 --:------ 01:145038 0008 002 F914
342
+ # .I --- 01:054173 --:------ 01:054173 0008 002 FA00
343
+ # .I --- 01:145038 --:------ 01:145038 0008 002 FC14
344
+
345
+ # RP --- 13:109598 18:199952 --:------ 0008 002 0000
346
+ # RP --- 13:109598 18:199952 --:------ 0008 002 00C8
347
+
348
+ if msg.src.type == DEV_TYPE_MAP.JST and msg.len == 13: # Honeywell Japser, DEX
349
+ assert msg.len == 13, "expecting length 13"
350
+ return { # type: ignore[typeddict-item]
351
+ "ordinal": f"0x{payload[2:8]}",
352
+ "blob": payload[8:],
353
+ }
354
+
355
+ return {SZ_RELAY_DEMAND: hex_to_percent(payload[2:4])} # 3EF0[2:4], 3EF1[10:12]
356
+
357
+
358
+ # relay_failsafe
359
+ def parser_0009(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
360
+ """The relay failsafe mode.
361
+
362
+ The failsafe mode defines the relay behaviour if the RF communication is lost (e.g.
363
+ when a room thermostat stops communicating due to discharged batteries):
364
+ False (disabled) - if RF comms are lost, relay will be held in OFF position
365
+ True (enabled) - if RF comms are lost, relay will cycle at 20% ON, 80% OFF
366
+
367
+ This setting may need to be enabled to ensure frost protect mode.
368
+ """
369
+ # can get: 003 or 006, e.g.: FC01FF-F901FF or FC00FF-F900FF
370
+ # .I --- 23:100224 --:------ 23:100224 0009 003 0100FF # 2-zone ST9520C
371
+ # .I --- 10:040239 01:223036 --:------ 0009 003 000000
372
+
373
+ def _parser(seqx: str) -> dict:
374
+ assert seqx[:2] in (F9, FC) or int(seqx[:2], 16) < 16
375
+ return {
376
+ SZ_DOMAIN_ID if seqx[:1] == "F" else SZ_ZONE_IDX: seqx[:2],
377
+ "failsafe_enabled": {"00": False, "01": True}.get(seqx[2:4]),
378
+ "unknown_0": seqx[4:],
379
+ }
380
+
381
+ if msg._has_array:
382
+ return [_parser(payload[i : i + 6]) for i in range(0, len(payload), 6)]
383
+
384
+ return {
385
+ "failsafe_enabled": {"00": False, "01": True}.get(payload[2:4]),
386
+ "unknown_0": payload[4:],
387
+ }
388
+
389
+
390
+ # zone_params (zone_config)
391
+ def parser_000a(
392
+ payload: str, msg: Message
393
+ ) -> PayDictT._000A | list[PayDictT._000A] | PayDictT.EMPTY:
394
+ def _parser(seqx: str) -> PayDictT._000A: # null_rp: "007FFF7FFF"
395
+ bitmap = int(seqx[2:4], 16)
396
+ return {
397
+ SZ_MIN_TEMP: hex_to_temp(seqx[4:8]),
398
+ SZ_MAX_TEMP: hex_to_temp(seqx[8:]),
399
+ SZ_LOCAL_OVERRIDE: not bool(bitmap & 1),
400
+ SZ_OPENWINDOW_FUNCTION: not bool(bitmap & 2),
401
+ SZ_MULTIROOM_MODE: not bool(bitmap & 16),
402
+ "_unknown_bitmap": f"0b{bitmap:08b}", # TODO: try W with this
403
+ } # cannot determine zone_type from this information
404
+
405
+ if msg._has_array: # NOTE: these arrays can span 2 pkts!
406
+ return [
407
+ {
408
+ SZ_ZONE_IDX: payload[i : i + 2],
409
+ **_parser(payload[i : i + 12]),
410
+ }
411
+ for i in range(0, len(payload), 12)
412
+ ]
413
+
414
+ if msg.verb == RQ and msg.len <= 2: # some RQs have a payload (why?)
415
+ return {}
416
+
417
+ assert msg.len == 6, f"{msg!r} # expecting length 006"
418
+ return _parser(payload)
419
+
420
+
421
+ # zone_devices
422
+ def parser_000c(payload: str, msg: Message) -> dict[str, Any]:
423
+ # .I --- 34:092243 --:------ 34:092243 000C 018 00-0A-7F-FFFFFF 00-0F-7F-FFFFFF 00-10-7F-FFFFFF # noqa: E501
424
+ # RP --- 01:145038 18:013393 --:------ 000C 006 00-00-00-10DAFD
425
+ # RP --- 01:145038 18:013393 --:------ 000C 012 01-00-00-10DAF5 01-00-00-10DAFB
426
+
427
+ def complex_idx(seqx: str, msg: Message) -> dict: # complex index
428
+ """domain_id, zone_idx, or ufx_idx|zone_idx."""
429
+
430
+ # TODO: 000C to a UFC should be ufh_ifx, not zone_idx
431
+ if msg.src.type == DEV_TYPE_MAP.UFC: # DEX
432
+ assert int(seqx, 16) < 8, f"invalid ufh_idx: '{seqx}' (0x00)"
433
+ return {
434
+ SZ_UFH_IDX: seqx,
435
+ SZ_ZONE_IDX: None if payload[4:6] == "7F" else payload[4:6],
436
+ }
437
+
438
+ if payload[2:4] in (DEV_ROLE_MAP.DHW, DEV_ROLE_MAP.HTG):
439
+ assert int(seqx, 16) < 1 if payload[2:4] == DEV_ROLE_MAP.DHW else 2, (
440
+ f"invalid _idx: '{seqx}' (0x01)"
441
+ )
442
+ return {SZ_DOMAIN_ID: FA if payload[:2] == "00" else F9}
443
+
444
+ if payload[2:4] == DEV_ROLE_MAP.APP:
445
+ assert int(seqx, 16) < 1, f"invalid _idx: '{seqx}' (0x02)"
446
+ return {SZ_DOMAIN_ID: FC}
447
+
448
+ assert int(seqx, 16) < 16, f"invalid zone_idx: '{seqx}' (0x03)"
449
+ return {SZ_ZONE_IDX: seqx}
450
+
451
+ def _parser(
452
+ seqx: str,
453
+ ) -> dict: # TODO: assumption that all id/idx are same is wrong!
454
+ assert seqx[:2] == payload[:2], (
455
+ f"idx != {payload[:2]} (seqx = {seqx}), short={is_short_000C(payload)}"
456
+ )
457
+ assert int(seqx[:2], 16) < 16
458
+ assert seqx[4:6] == "7F" or seqx[6:] != "F" * 6, f"Bad device_id: {seqx[6:]}"
459
+ return {hex_id_to_dev_id(seqx[6:12]): seqx[4:6]}
460
+
461
+ def is_short_000C(payload: str) -> bool:
462
+ """Return True if it is a short 000C (element length is 5, not 6)."""
463
+
464
+ if (pkt_len := len(payload)) != 72:
465
+ return pkt_len % 12 != 0
466
+
467
+ # 0608-001099C3 0608-001099C5 0608-001099BF 0608-001099BE 0608-001099BD 0608-001099BC # len(element) = 6
468
+ # 0508-00109901 0800-10990208 0010-99030800 1099-04080010 9905-08001099 0608-00109907 # len(element) = 5
469
+ elif all(payload[i : i + 4] == payload[:4] for i in range(12, pkt_len, 12)):
470
+ return False # len(element) = 6 (12)
471
+
472
+ # 06 08-001099C3 06-08001099 C5-06080010 99-BF060800 10-99BE0608 00-1099BD06 08-001099BC # len(element) = 6
473
+ # 05 08-00109901 08-00109902 08-00109903 08-00109904 08-00109905 08-00109906 08-00109907 # len(element) = 5
474
+ elif all(payload[i : i + 2] == payload[2:4] for i in range(12, pkt_len, 10)):
475
+ return True # len(element) = 5 (10)
476
+
477
+ raise exc.PacketPayloadInvalid(
478
+ "Unable to determine element length"
479
+ ) # return None
480
+
481
+ if payload[2:4] == DEV_ROLE_MAP.HTG and payload[:2] == "01":
482
+ dev_role = DEV_ROLE_MAP[DevRole.HT1]
483
+ else:
484
+ dev_role = DEV_ROLE_MAP[payload[2:4]]
485
+
486
+ result = {
487
+ SZ_ZONE_TYPE: payload[2:4],
488
+ **complex_idx(payload[:2], msg),
489
+ SZ_DEVICE_ROLE: dev_role,
490
+ }
491
+ if msg.verb == RQ: # RQs have a context: index, zone_type, payload is iitt
492
+ return result
493
+
494
+ # NOTE: Both these are valid! So collision when len = 036!
495
+ # RP --- 01:239474 18:198929 --:------ 000C 012 06-00-00119A99 06-00-00119B21
496
+ # RP --- 01:069616 18:205592 --:------ 000C 011 01-00-00121B54 00-00121B52
497
+ # RP --- 01:239700 18:009874 --:------ 000C 018 07-08-001099C3 07-08-001099C5 07-08-001099BF
498
+ # RP --- 01:059885 18:010642 --:------ 000C 016 00-00-0011EDAA 00-0011ED92 00-0011EDA0
499
+
500
+ devs = (
501
+ [_parser(payload[:2] + payload[i : i + 10]) for i in range(2, len(payload), 10)]
502
+ if is_short_000C(payload)
503
+ else [_parser(payload[i : i + 12]) for i in range(0, len(payload), 12)]
504
+ )
505
+
506
+ return {
507
+ **result,
508
+ SZ_DEVICES: [k for d in devs for k, v in d.items() if v != "7F"],
509
+ }
510
+
511
+
512
+ # unknown_000e, from STA
513
+ def parser_000e(payload: str, msg: Message) -> dict[str, Any]:
514
+ assert payload in ("000014", "000028"), _INFORM_DEV_MSG
515
+
516
+ return {
517
+ SZ_PAYLOAD: payload,
518
+ }
519
+
520
+
521
+ # rf_check
522
+ def parser_0016(payload: str, msg: Message) -> dict[str, Any]:
523
+ # TODO: does 0016 include parent_idx?, but RQ|07:|0000?
524
+ # RQ --- 22:060293 01:078710 --:------ 0016 002 0200
525
+ # RP --- 01:078710 22:060293 --:------ 0016 002 021E
526
+ # RQ --- 12:010740 01:145038 --:------ 0016 002 0800
527
+ # RP --- 01:145038 12:010740 --:------ 0016 002 081E
528
+ # RQ --- 07:031785 01:063844 --:------ 0016 002 0000
529
+ # RP --- 01:063844 07:031785 --:------ 0016 002 002A
530
+
531
+ if msg.verb == RQ: # and msg.len == 1: # TODO: some RQs have a payload
532
+ return {}
533
+
534
+ rf_value = int(payload[2:4], 16)
535
+ return {
536
+ "rf_strength": min(int(rf_value / 5) + 1, 5),
537
+ "rf_value": rf_value,
538
+ }
539
+
540
+
541
+ # language (of device/system)
542
+ def parser_0100(payload: str, msg: Message) -> PayDictT._0100 | PayDictT.EMPTY:
543
+ if msg.verb == RQ and msg.len == 1: # some RQs have a payload
544
+ return {}
545
+
546
+ return {
547
+ SZ_LANGUAGE: hex_to_str(payload[2:6]),
548
+ "_unknown_0": payload[6:],
549
+ }
550
+
551
+
552
+ # unknown_0150, from OTB
553
+ def parser_0150(payload: str, msg: Message) -> dict[str, Any]:
554
+ assert payload == "000000", _INFORM_DEV_MSG
555
+
556
+ return {
557
+ SZ_PAYLOAD: payload,
558
+ }
559
+
560
+
561
+ # unknown_01d0, from a HR91 (when its buttons are pushed)
562
+ def parser_01d0(payload: str, msg: Message) -> dict[str, Any]:
563
+ # 23:57:28.869 045 W --- 04:000722 01:158182 --:------ 01D0 002 0003
564
+ # 23:57:28.931 045 I --- 01:158182 04:000722 --:------ 01D0 002 0003
565
+ # 23:57:31.581 048 W --- 04:000722 01:158182 --:------ 01E9 002 0003
566
+ # 23:57:31.643 045 I --- 01:158182 04:000722 --:------ 01E9 002 0000
567
+ # 23:57:31.749 050 W --- 04:000722 01:158182 --:------ 01D0 002 0000
568
+ # 23:57:31.811 045 I --- 01:158182 04:000722 --:------ 01D0 002 0000
569
+
570
+ assert payload[2:] in ("00", "03"), _INFORM_DEV_MSG
571
+ return {
572
+ "unknown_0": payload[2:],
573
+ }
574
+
575
+
576
+ # unknown_01e9, from a HR91 (when its buttons are pushed)
577
+ def parser_01e9(payload: str, msg: Message) -> dict[str, Any]:
578
+ # 23:57:31.581348 048 W --- 04:000722 01:158182 --:------ 01E9 002 0003
579
+ # 23:57:31.643188 045 I --- 01:158182 04:000722 --:------ 01E9 002 0000
580
+
581
+ assert payload[2:] in ("00", "03"), _INFORM_DEV_MSG
582
+ return {
583
+ "unknown_0": payload[2:],
584
+ }
585
+
586
+
587
+ # unknown_01ff, to/from a Itho Spider/Thermostat
588
+ def parser_01ff(payload: str, msg: Message) -> dict[str, Any]:
589
+ # see: https://github.com/zxdavb/ramses_rf/issues/73 & 101
590
+
591
+ # lots of '80's, and I see temps are `int(payload[6:8], 16) / 2`, so I wonder if 0x80 is N/A?
592
+ # also is '7F'
593
+
594
+ # return {
595
+ # "dis_temp": None if payload[4:6] == "80" else int(payload[4:6], 16) / 2,
596
+ # "set_temp": int(payload[6:8], 16) / 2,
597
+ # "max_temp": int(payload[8:10], 16) / 2, # 22C9 - temp high
598
+ # "mode_val": payload[10:12],
599
+ # "mode_xxx": payload[10:11] in ("9", "B", "D") and payload[11:12] in ("0", "2"),
600
+ # }
601
+
602
+ assert payload[:4] in ("0080", "0180"), f"{_INFORM_DEV_MSG} ({payload[:4]})"
603
+ assert payload[12:14] == "00", f"{_INFORM_DEV_MSG} ({payload[12:14]})"
604
+ # assert payload[16:22] in (
605
+ # "00143C",
606
+ # "002430",
607
+ # "7F8080",
608
+ # ), f"{_INFORM_DEV_MSG} ({payload[16:22]})" # idx|25.9C?
609
+ assert payload[26:30] == "0000", f"{_INFORM_DEV_MSG} ({payload[26:30]})"
610
+ assert payload[34:46] == "80800280FF80", f"{_INFORM_DEV_MSG} ({payload[34:46]})"
611
+ # assert payload[48:] in (
612
+ # "0000",
613
+ # "0020",
614
+ # "0084",
615
+ # "00A4",
616
+ # ), f"{_INFORM_DEV_MSG} ({payload[48:]})"
617
+
618
+ if msg.verb in (I_, RQ): # from Spider thermostat to gateway
619
+ assert payload[14:16] == "80", f"{_INFORM_DEV_MSG} ({payload[14:16]})"
620
+ # assert payload[22:26] in (
621
+ # "2832",
622
+ # "2840",
623
+ # ), f"{_INFORM_DEV_MSG} ({payload[22:26]})"
624
+ # assert payload[30:34] in (
625
+ # "0104",
626
+ # "4402",
627
+ # "C102",
628
+ # "C402",
629
+ # ), f"{_INFORM_DEV_MSG} ({payload[30:34]})"
630
+ assert payload[46:48] in ("04", "07"), f"{_INFORM_DEV_MSG} ({payload[46:48]})"
631
+
632
+ if msg.verb in (RP, W_): # from Spider gateway to thermostat
633
+ # assert payload[14:16] in (
634
+ # "00",
635
+ # "7F",
636
+ # "80",
637
+ # ), f"{_INFORM_DEV_MSG} ({payload[14:16]})"
638
+ # assert payload[22:26] in (
639
+ # "2840",
640
+ # "8080",
641
+ # ), f"{_INFORM_DEV_MSG} ({payload[22:26]})"
642
+ # assert payload[30:34] in (
643
+ # "0104",
644
+ # "3100",
645
+ # "3700",
646
+ # "B400",
647
+ # ), f"{_INFORM_DEV_MSG} ({payload[30:34]})"
648
+ assert payload[46:48] in (
649
+ "00",
650
+ "04",
651
+ "07",
652
+ ), f"{_INFORM_DEV_MSG} ({payload[46:48]})"
653
+
654
+ setpoint_bounds = (
655
+ int(payload[6:8], 16) / 2, # as: 22C9[2:6] and [6:10] ???
656
+ None if msg.verb in (RP, W_) else int(payload[8:10], 16) / 2,
657
+ )
658
+
659
+ return {
660
+ SZ_TEMPERATURE: None if msg.verb in (RP, W_) else int(payload[4:6], 16) / 2,
661
+ SZ_SETPOINT_BOUNDS: setpoint_bounds,
662
+ "time_planning": not bool(int(payload[10:12], 16) & 1 << 6),
663
+ "temp_adjusted": bool(int(payload[10:12], 16) & 1 << 5),
664
+ "_flags_10": payload[10:12], #
665
+ }
666
+
667
+
668
+ # zone_schedule (fragment)
669
+ def parser_0404(payload: str, msg: Message) -> PayDictT._0404:
670
+ # Retrieval of Zone schedule (NB: 200008)
671
+ # RQ --- 30:185469 01:037519 --:------ 0404 007 00-200008-00-0100
672
+ # RP --- 01:037519 30:185469 --:------ 0404 048 00-200008-29-0103-6E2...
673
+ # RQ --- 30:185469 01:037519 --:------ 0404 007 00-200008-00-0203
674
+ # RP --- 01:037519 30:185469 --:------ 0404 048 00-200008-29-0203-4FD...
675
+ # RQ --- 30:185469 01:037519 --:------ 0404 007 00-200008-00-0303
676
+ # RP --- 01:037519 30:185469 --:------ 0404 038 00-200008-1F-0303-C10...
677
+
678
+ # Retrieval of DHW schedule (NB: 230008)
679
+ # RQ --- 30:185469 01:037519 --:------ 0404 007 00-230008-00-0100
680
+ # RP --- 01:037519 30:185469 --:------ 0404 048 00-230008-29-0103-618...
681
+ # RQ --- 30:185469 01:037519 --:------ 0404 007 00-230008-00-0203
682
+ # RP --- 01:037519 30:185469 --:------ 0404 048 00-230008-29-0203-ED6...
683
+ # RQ --- 30:185469 01:037519 --:------ 0404 007 00-230008-00-0303
684
+ # RP --- 01:037519 30:185469 --:------ 0404 014 00-230008-07-0303-13F...
685
+
686
+ # Write a Zone schedule...
687
+ # .W --- 30:042165 01:076010 --:------ 0404 048 08-200808-29-0104-688...
688
+ # .I --- 01:076010 30:042165 --:------ 0404 007 08-200808-29-0104
689
+ # .W --- 30:042165 01:076010 --:------ 0404 048 08-200808-29-0204-007...
690
+ # .I --- 01:076010 30:042165 --:------ 0404 007 08-200808-29-0204
691
+ # .W --- 30:042165 01:076010 --:------ 0404 048 08-200808-29-0304-8DD...
692
+ # .I --- 01:076010 30:042165 --:------ 0404 007 08-200808-29-0304
693
+ # .W --- 30:042165 01:076010 --:------ 0404 048 08-200808-11-0404-970...
694
+ # .I --- 01:076010 30:042165 --:------ 0404 007 08-200808-11-0400
695
+
696
+ # RP --- 01:145038 18:013393 --:------ 0404 007 00-230008-00-01FF # no schedule
697
+
698
+ assert payload[4:6] in ("00", payload[:2]), _INFORM_DEV_MSG
699
+
700
+ if int(payload[8:10], 16) * 2 != (frag_length := len(payload[14:])) and (
701
+ msg.verb != I_ or frag_length != 0
702
+ ):
703
+ raise exc.PacketPayloadInvalid(f"Incorrect fragment length: 0x{payload[8:10]}")
704
+
705
+ if msg.verb == RQ: # have a ctx: idx|frag_idx
706
+ return {
707
+ SZ_FRAG_NUMBER: int(payload[10:12], 16),
708
+ SZ_TOTAL_FRAGS: None if payload[12:14] == "00" else int(payload[12:14], 16),
709
+ }
710
+
711
+ if msg.verb == I_: # have a ctx: idx|frag_idx
712
+ return {
713
+ SZ_FRAG_NUMBER: int(payload[10:12], 16),
714
+ SZ_TOTAL_FRAGS: int(payload[12:14], 16),
715
+ SZ_FRAG_LENGTH: None if payload[8:10] == "00" else int(payload[8:10], 16),
716
+ }
717
+
718
+ if payload[12:14] == FF:
719
+ return {
720
+ SZ_FRAG_NUMBER: int(payload[10:12], 16),
721
+ SZ_TOTAL_FRAGS: None,
722
+ }
723
+
724
+ return {
725
+ SZ_FRAG_NUMBER: int(payload[10:12], 16),
726
+ SZ_TOTAL_FRAGS: int(payload[12:14], 16),
727
+ SZ_FRAG_LENGTH: None if payload[8:10] == "FF" else int(payload[8:10], 16),
728
+ SZ_FRAGMENT: payload[14:],
729
+ }
730
+
731
+
732
+ # system_fault (fault_log_entry) - needs refactoring
733
+ def parser_0418(payload: str, msg: Message) -> PayDictT._0418 | PayDictT._0418_NULL:
734
+ null_result: PayDictT._0418_NULL
735
+ full_result: PayDictT._0418
736
+
737
+ # assert int(payload[4:6], 16) < 64, f"Unexpected log_idx: 0x{payload[4:6]}"
738
+
739
+ # RQ --- 18:017804 01:145038 --:------ 0418 003 000005 # log_idx=0x05
740
+ # RP --- 01:145038 18:017804 --:------ 0418 022 000005B0040000000000CD17B5AE7FFFFF7000000001 # log_idx=0x05
741
+
742
+ # RQ --- 18:017804 01:145038 --:------ 0418 003 000006 # log_idx=0x06
743
+ # RP --- 01:145038 18:017804 --:------ 0418 022 000000B0000000000000000000007FFFFF7000000000 # log_idx=None (00)
744
+
745
+ if msg.verb == RQ: # has a ctx: log_idx
746
+ null_result = {SZ_LOG_IDX: payload[4:6]} # type: ignore[typeddict-item]
747
+ return null_result
748
+
749
+ # NOTE: such payloads have idx=="00": if verb is I, can safely assume log_idx is 0,
750
+ # but for RP it is sentinel for null (we can't know the corresponding RQ's log_idx)
751
+ elif hex_to_dts(payload[18:30]) is None:
752
+ null_result = {SZ_LOG_ENTRY: None}
753
+ if msg.verb == I_:
754
+ null_result = {SZ_LOG_IDX: payload[4:6]} | null_result # type: ignore[assignment]
755
+ return null_result
756
+
757
+ try:
758
+ assert payload[2:4] in FAULT_STATE, f"fault state: {payload[2:4]}"
759
+ assert payload[8:10] in FAULT_TYPE, f"fault type: {payload[8:10]}"
760
+ assert payload[12:14] in FAULT_DEVICE_CLASS, f"device class: {payload[12:14]}"
761
+ # 1C: 'Comms fault, Actuator': seen with boiler relays
762
+ assert int(payload[10:12], 16) < 16 or (
763
+ payload[10:12] in ("1C", F6, F9, FA, FC)
764
+ ), f"domain id: {payload[10:12]}"
765
+ except AssertionError as err:
766
+ _LOGGER.warning(
767
+ f"{msg!r} < {_INFORM_DEV_MSG} ({err}), with a photo of your fault log"
768
+ )
769
+
770
+ # log_entry will not be None, because of guard clauses, above
771
+ log_entry: PayDictT.FAULT_LOG_ENTRY = parse_fault_log_entry(payload) # type: ignore[assignment]
772
+
773
+ # log_idx is not intrinsic to the fault & increments as the fault moves down the log
774
+ log_entry.pop(f"_{SZ_LOG_IDX}") # type: ignore[misc]
775
+
776
+ _KEYS = (SZ_TIMESTAMP, SZ_FAULT_STATE, SZ_FAULT_TYPE)
777
+ entry = [v for k, v in log_entry.items() if k in _KEYS]
778
+
779
+ if log_entry[SZ_DEVICE_CLASS] != FaultDeviceClass.ACTUATOR:
780
+ entry.append(log_entry[SZ_DEVICE_CLASS])
781
+ elif log_entry[SZ_DOMAIN_IDX] == FC:
782
+ entry.append(DEV_ROLE_MAP[DevRole.APP]) # actual evohome UI
783
+ elif log_entry[SZ_DOMAIN_IDX] == FA:
784
+ entry.append(DEV_ROLE_MAP[DevRole.HTG]) # speculative
785
+ elif log_entry[SZ_DOMAIN_IDX] == F9:
786
+ entry.append(DEV_ROLE_MAP[DevRole.HT1]) # speculative
787
+ else:
788
+ entry.append(FaultDeviceClass.ACTUATOR)
789
+
790
+ # TODO: remove the qualifier (the assert is false)
791
+ if log_entry[SZ_DEVICE_CLASS] != FaultDeviceClass.CONTROLLER:
792
+ # assert log_entry[SZ_DOMAIN_IDX] == "00", log_entry[SZ_DOMAIN_IDX]
793
+ # key_name = SZ_ZONE_IDX if int(payload[10:12], 16) < 16 else SZ_DOMAIN_ID
794
+ # log_entry.update({key_name: payload[10:12]})
795
+ entry.append(log_entry[SZ_DOMAIN_IDX])
796
+
797
+ if log_entry[SZ_DEVICE_ID] not in ("00:000000", "00:000001", "00:000002"):
798
+ # "00:000001 for Controller? "00:000002 for Unknown?
799
+ entry.append(log_entry[SZ_DEVICE_ID])
800
+
801
+ entry.extend((payload[6:8], payload[14:18], payload[30:38])) # TODO: remove?
802
+
803
+ full_result = {
804
+ SZ_LOG_IDX: payload[4:6], # type: ignore[typeddict-item]
805
+ SZ_LOG_ENTRY: tuple([str(r) for r in entry]),
806
+ }
807
+ return full_result
808
+
809
+
810
+ # unknown_042f, from STA, VMS
811
+ def parser_042f(payload: str, msg: Message) -> dict[str, Any]:
812
+ return {
813
+ "counter_1": f"0x{payload[2:6]}",
814
+ "counter_3": f"0x{payload[6:10]}",
815
+ "counter_5": f"0x{payload[10:14]}",
816
+ "unknown_7": f"0x{payload[14:]}",
817
+ }
818
+
819
+
820
+ # TODO: unknown_0b04, from THM (only when its a CTL?)
821
+ def parser_0b04(payload: str, msg: Message) -> dict[str, Any]:
822
+ # .I --- --:------ --:------ 12:207082 0B04 002 00C8 # batch of 3, every 24h
823
+
824
+ return {
825
+ "unknown_1": payload[2:],
826
+ }
827
+
828
+
829
+ # mixvalve_config (zone), FAN
830
+ def parser_1030(payload: str, msg: Message) -> PayDictT._1030:
831
+ # .I --- 01:145038 --:------ 01:145038 1030 016 0A-C80137-C9010F-CA0196-CB0100-CC0101
832
+ # .I --- --:------ --:------ 12:144017 1030 016 01-C80137-C9010F-CA0196-CB010F-CC0101
833
+ # RP --- 32:155617 18:005904 --:------ 1030 007 00-200100-21011F
834
+
835
+ def _parser(seqx: str) -> dict:
836
+ assert seqx[2:4] == "01", seqx[2:4]
837
+
838
+ param_name = {
839
+ "20": "unknown_20", # HVAC
840
+ "21": "unknown_21", # HVAC
841
+ "C8": "max_flow_setpoint", # 55 (0-99) C
842
+ "C9": "min_flow_setpoint", # 15 (0-50) C
843
+ "CA": "valve_run_time", # 150 (0-240) sec, aka actuator_run_time
844
+ "CB": "pump_run_time", # 15 (0-99) sec
845
+ "CC": "boolean_cc", # ?boolean?
846
+ }[seqx[:2]]
847
+
848
+ return {param_name: int(seqx[4:], 16)}
849
+
850
+ assert (msg.len - 1) / 3 in (2, 5), msg.len
851
+ # assert payload[30:] in ("00", "01"), payload[30:]
852
+
853
+ params = [_parser(payload[i : i + 6]) for i in range(2, len(payload), 6)]
854
+ return {k: v for x in params for k, v in x.items()} # type: ignore[return-value]
855
+
856
+
857
+ # device_battery (battery_state)
858
+ def parser_1060(payload: str, msg: Message) -> PayDictT._1060:
859
+ """Return the battery state.
860
+
861
+ Some devices (04:) will also report battery level.
862
+ """
863
+
864
+ assert msg.len == 3, msg.len
865
+ assert payload[4:6] in ("00", "01")
866
+
867
+ return {
868
+ "battery_low": payload[4:] == "00",
869
+ "battery_level": None if payload[2:4] == "00" else hex_to_percent(payload[2:4]),
870
+ }
871
+
872
+
873
+ # max_ch_setpoint (supply high limit)
874
+ def parser_1081(payload: str, msg: Message) -> PayDictT._1081:
875
+ return {SZ_SETPOINT: hex_to_temp(payload[2:])}
876
+
877
+
878
+ # unknown_1090 (non-Evohome, e.g. ST9520C)
879
+ def parser_1090(payload: str, msg: Message) -> PayDictT._1090:
880
+ # 14:08:05.176 095 RP --- 23:100224 22:219457 --:------ 1090 005 007FFF01F4
881
+ # 18:08:05.809 095 RP --- 23:100224 22:219457 --:------ 1090 005 007FFF01F4
882
+
883
+ # this is an educated guess
884
+ assert msg.len == 5, _INFORM_DEV_MSG
885
+ assert int(payload[:2], 16) < 2, _INFORM_DEV_MSG
886
+
887
+ return {
888
+ "temperature_0": hex_to_temp(payload[2:6]),
889
+ "temperature_1": hex_to_temp(payload[6:10]),
890
+ }
891
+
892
+
893
+ # unknown_1098, from OTB
894
+ def parser_1098(payload: str, msg: Message) -> dict[str, Any]:
895
+ assert payload == "00C8", _INFORM_DEV_MSG
896
+
897
+ return {
898
+ "_payload": payload,
899
+ "_value": {"00": False, "C8": True}.get(
900
+ payload[2:], hex_to_percent(payload[2:])
901
+ ),
902
+ }
903
+
904
+
905
+ # dhw (cylinder) params # FIXME: a bit messy
906
+ def parser_10a0(payload: str, msg: Message) -> PayDictT._10A0 | PayDictT.EMPTY:
907
+ # RQ --- 07:045960 01:145038 --:------ 10A0 006 00-1087-00-03E4 # RQ/RP, every 24h
908
+ # RP --- 01:145038 07:045960 --:------ 10A0 006 00-109A-00-03E8
909
+ # RP --- 10:048122 18:006402 --:------ 10A0 003 00-1B58
910
+
911
+ # these may not be reliable...
912
+ # RQ --- 01:136410 10:067219 --:------ 10A0 002 0000
913
+ # RQ --- 07:017494 01:078710 --:------ 10A0 006 00-1566-00-03E4
914
+
915
+ # RQ --- 07:045960 01:145038 --:------ 10A0 006 00-31FF-00-31FF # null
916
+ # RQ --- 07:045960 01:145038 --:------ 10A0 006 00-1770-00-03E8
917
+ # RQ --- 07:045960 01:145038 --:------ 10A0 006 00-1374-00-03E4
918
+ # RQ --- 07:030741 01:102458 --:------ 10A0 006 00-181F-00-03E4
919
+ # RQ --- 07:036831 23:100224 --:------ 10A0 006 01-1566-00-03E4 # non-evohome
920
+
921
+ # these from a RFG...
922
+ # RQ --- 30:185469 01:037519 --:------ 0005 002 000E
923
+ # RP --- 01:037519 30:185469 --:------ 0005 004 000E0300 # two DHW valves
924
+ # RQ --- 30:185469 01:037519 --:------ 10A0 001 01 (01 )
925
+
926
+ if msg.verb == RQ and msg.len == 1: # some RQs have a payload (why?)
927
+ # 045 RQ --- 07:045960 01:145038 --:------ 10A0 006 0013740003E4
928
+ # 037 RQ --- 18:013393 01:145038 --:------ 10A0 001 00
929
+ # 054 RP --- 01:145038 18:013393 --:------ 10A0 006 0013880003E8
930
+ return {}
931
+
932
+ assert msg.len in (1, 3, 6), msg.len # OTB uses 3, evohome uses 6
933
+ assert payload[:2] in ("00", "01"), payload[:2] # can be two DHW valves/system
934
+
935
+ result: PayDictT._10A0 = {} # type: ignore[typeddict-item]
936
+ if msg.len >= 2:
937
+ setpoint = hex_to_temp(payload[2:6]) # 255 for OTB? iff no DHW?
938
+ result = {SZ_SETPOINT: None if setpoint == 255 else setpoint} # 30.0-85.0 C
939
+ if msg.len >= 4:
940
+ result["overrun"] = int(payload[6:8], 16) # 0-10 minutes
941
+ if msg.len >= 6:
942
+ result["differential"] = hex_to_temp(payload[8:12]) # 1.0-10.0 C
943
+
944
+ return result
945
+
946
+
947
+ # unknown_10b0, from OTB
948
+ def parser_10b0(payload: str, msg: Message) -> dict[str, Any]:
949
+ assert payload == "0000", _INFORM_DEV_MSG
950
+
951
+ return {
952
+ "_payload": payload,
953
+ "_value": {"00": False, "C8": True}.get(
954
+ payload[2:], hex_to_percent(payload[2:])
955
+ ),
956
+ }
957
+
958
+
959
+ # filter_change, HVAC
960
+ def parser_10d0(payload: str, msg: Message) -> dict[str, Any]:
961
+ # 2022-07-03T22:52:34.571579 045 W --- 37:171871 32:155617 --:------ 10D0 002 00FF
962
+ # 2022-07-03T22:52:34.596526 066 I --- 32:155617 37:171871 --:------ 10D0 006 0047B44F0000
963
+ # then...
964
+ # 2022-07-03T23:14:23.854089 000 RQ --- 37:155617 32:155617 --:------ 10D0 002 0000
965
+ # 2022-07-03T23:14:23.876088 084 RP --- 32:155617 37:155617 --:------ 10D0 006 00B4B4C80000
966
+
967
+ # 00-FF resets the counter, 00-47-B4-4F-0000 is the value (71 180 79).
968
+ # Default is 180 180 200. The returned value is the amount of days (180),
969
+ # total amount of days till change (180), percentage (200)
970
+
971
+ result: dict[str, bool | float | None]
972
+
973
+ if msg.verb == W_:
974
+ return {"reset_counter": payload[2:4] != "00"}
975
+
976
+ result = {}
977
+
978
+ if payload[2:4] not in ("FF", "FE"):
979
+ result[SZ_REMAINING_DAYS] = int(payload[2:4], 16)
980
+
981
+ if payload[4:6] not in ("FF", "FE"):
982
+ result["days_lifetime"] = int(payload[4:6], 16)
983
+
984
+ result[SZ_REMAINING_PERCENT] = hex_to_percent(payload[6:8])
985
+
986
+ return result
987
+
988
+
989
+ # device_info
990
+ def parser_10e0(payload: str, msg: Message) -> dict[str, Any]:
991
+ if payload == "00": # some HVAC devices will RP|10E0|00
992
+ return {}
993
+
994
+ assert msg.len in (19, 28, 29, 30, 36, 38), msg.len # >= 19, msg.len
995
+
996
+ payload = re.sub("(00)*$", "", payload) # remove trailing 00s
997
+ assert len(payload) >= 18 * 2
998
+
999
+ # if DEV_MODE: # TODO
1000
+ try: # DEX
1001
+ check_signature(msg.src.type, payload[2:20])
1002
+ except ValueError as err:
1003
+ _LOGGER.info(
1004
+ f"{msg!r} < {_INFORM_DEV_MSG}, with the make/model of device: {msg.src} ({err})"
1005
+ )
1006
+
1007
+ description, _, unknown = payload[36:].partition("00")
1008
+
1009
+ result = {
1010
+ SZ_OEM_CODE: payload[14:16], # 00/FF is CH/DHW, 01/6x is HVAC
1011
+ # "_manufacturer_group": payload[2:6], # 0001-HVAC, 0002-CH/DHW
1012
+ "manufacturer_sub_id": payload[6:8],
1013
+ "product_id": payload[8:10], # if CH/DHW: matches device_type (sometimes)
1014
+ "date_1": hex_to_date(payload[28:36]) or "0000-00-00", # hardware?
1015
+ "date_2": hex_to_date(payload[20:28]) or "0000-00-00", # firmware?
1016
+ # "software_ver_id": payload[10:12],
1017
+ # "list_ver_id": payload[12:14], # if FF/01 is CH/DHW, then 01/FF
1018
+ # # "additional_ver_a": payload[16:18],
1019
+ # # "additional_ver_b": payload[18:20],
1020
+ # "_signature": payload[2:20],
1021
+ "description": bytearray.fromhex(description).decode(),
1022
+ }
1023
+ if unknown: # TODO: why only RP|OTB, I|DT4s do this?
1024
+ result["_unknown"] = unknown
1025
+ return result
1026
+
1027
+
1028
+ # device_id
1029
+ def parser_10e1(payload: str, msg: Message) -> PayDictT._10E1:
1030
+ return {SZ_DEVICE_ID: hex_id_to_dev_id(payload[2:])}
1031
+
1032
+
1033
+ # unknown_10e2 - HVAC
1034
+ def parser_10e2(payload: str, msg: Message) -> dict[str, Any]:
1035
+ # .I --- --:------ --:------ 20:231151 10E2 003 00AD74 # every 2 minutes
1036
+
1037
+ assert payload[:2] == "00", _INFORM_DEV_MSG
1038
+ assert len(payload) == 6, _INFORM_DEV_MSG
1039
+
1040
+ return {
1041
+ "counter": int(payload[2:], 16),
1042
+ }
1043
+
1044
+
1045
+ # tpi_params (domain/zone/device) # FIXME: a bit messy
1046
+ def parser_1100(
1047
+ payload: str, msg: Message
1048
+ ) -> PayDictT._1100 | PayDictT._1100_IDX | PayDictT._JASPER | PayDictT.EMPTY:
1049
+ def complex_idx(seqx: str) -> PayDictT._1100_IDX | PayDictT.EMPTY:
1050
+ return {SZ_DOMAIN_ID: seqx} if seqx[:1] == "F" else {} # type: ignore[typeddict-item] # only FC
1051
+
1052
+ if msg.src.type == DEV_TYPE_MAP.JIM: # Honeywell Japser, DEX
1053
+ assert msg.len == 19, msg.len
1054
+ return {
1055
+ "ordinal": f"0x{payload[2:8]}",
1056
+ "blob": payload[8:],
1057
+ }
1058
+
1059
+ if msg.verb == RQ and msg.len == 1: # some RQs have a payload (why?)
1060
+ return complex_idx(payload[:2])
1061
+
1062
+ assert int(payload[2:4], 16) / 4 in range(1, 13), payload[2:4]
1063
+ assert int(payload[4:6], 16) / 4 in range(1, 31), payload[4:6]
1064
+ assert int(payload[6:8], 16) / 4 in range(0, 16), payload[6:8]
1065
+
1066
+ # for: TPI // heatpump
1067
+ # - cycle_rate: 6 (3, 6, 9, 12) // ?? (1-9)
1068
+ # - min_on_time: 1 (1-5) // ?? (1, 5, 10,...30)
1069
+ # - min_off_time: 1 (1-?) // ?? (0, 5, 10, 15)
1070
+
1071
+ def _parser(seqx: str) -> PayDictT._1100:
1072
+ return {
1073
+ "cycle_rate": int(int(payload[2:4], 16) / 4), # cycles/hour
1074
+ "min_on_time": int(payload[4:6], 16) / 4, # min
1075
+ "min_off_time": int(payload[6:8], 16) / 4, # min
1076
+ "_unknown_0": payload[8:10], # always 00, FF?
1077
+ }
1078
+
1079
+ result = _parser(payload)
1080
+
1081
+ if msg.len > 5:
1082
+ pbw = hex_to_temp(payload[10:14])
1083
+
1084
+ assert pbw is None or 1.5 <= pbw <= 3.0, (
1085
+ f"unexpected value for PBW: {payload[10:14]}"
1086
+ )
1087
+
1088
+ result.update(
1089
+ {
1090
+ "proportional_band_width": pbw,
1091
+ "_unknown_1": payload[14:], # always 01?
1092
+ }
1093
+ )
1094
+
1095
+ return complex_idx(payload[:2]) | result
1096
+
1097
+
1098
+ # unknown_11f0, from heatpump relay
1099
+ def parser_11f0(payload: str, msg: Message) -> dict[str, Any]:
1100
+ assert payload == "000009000000000000", _INFORM_DEV_MSG
1101
+
1102
+ return {
1103
+ SZ_PAYLOAD: payload,
1104
+ }
1105
+
1106
+
1107
+ # dhw cylinder temperature
1108
+ def parser_1260(payload: str, msg: Message) -> PayDictT._1260:
1109
+ return {SZ_TEMPERATURE: hex_to_temp(payload[2:])}
1110
+
1111
+
1112
+ # HVAC: outdoor humidity
1113
+ def parser_1280(payload: str, msg: Message) -> PayDictT._1280:
1114
+ return parse_outdoor_humidity(payload[2:])
1115
+
1116
+
1117
+ # outdoor temperature
1118
+ def parser_1290(payload: str, msg: Message) -> PayDictT._1290:
1119
+ # evohome responds to an RQ, also from OTB
1120
+ return parse_outdoor_temp(payload[2:])
1121
+
1122
+
1123
+ # HVAC: co2_level, see: 31DA[6:10]
1124
+ def parser_1298(payload: str, msg: Message) -> PayDictT._1298:
1125
+ return parse_co2_level(payload[2:6])
1126
+
1127
+
1128
+ # HVAC: indoor_humidity, array of 3 sets for HRU
1129
+ def parser_12a0(
1130
+ payload: str, msg: Message
1131
+ ) -> PayDictT.INDOOR_HUMIDITY | list[PayDictT._12A0]:
1132
+ if len(payload) <= 14:
1133
+ return parse_indoor_humidity(payload[2:12])
1134
+
1135
+ return [
1136
+ {
1137
+ "hvac_idx": payload[i : i + 2], # used as index
1138
+ **parse_humidity_element(payload[i + 2 : i + 12], payload[i : i + 2]),
1139
+ }
1140
+ for i in range(0, len(payload), 14)
1141
+ ]
1142
+
1143
+
1144
+ # window_state (of a device/zone)
1145
+ def parser_12b0(payload: str, msg: Message) -> PayDictT._12B0:
1146
+ assert payload[2:] in ("0000", "C800", "FFFF"), payload[2:] # "FFFF" means N/A
1147
+
1148
+ return {
1149
+ SZ_WINDOW_OPEN: hex_to_bool(payload[2:4]),
1150
+ }
1151
+
1152
+
1153
+ # displayed temperature (on a TR87RF bound to a RFG100)
1154
+ def parser_12c0(payload: str, msg: Message) -> PayDictT._12C0:
1155
+ if payload[2:4] == "80":
1156
+ temp: float | None = None
1157
+ elif payload[4:6] == "00": # units are 1.0 F
1158
+ temp = int(payload[2:4], 16)
1159
+ else: # if payload[4:] == "01": # units are 0.5 C
1160
+ temp = int(payload[2:4], 16) / 2
1161
+
1162
+ result: PayDictT._12C0 = {
1163
+ SZ_TEMPERATURE: temp,
1164
+ "units": {"00": "Fahrenheit", "01": "Celsius"}[payload[4:6]], # type: ignore[typeddict-item]
1165
+ }
1166
+ if len(payload) > 6:
1167
+ result["_unknown_6"] = payload[6:]
1168
+ return result
1169
+
1170
+
1171
+ # HVAC: air_quality (and air_quality_basis), see: 31DA[2:6]
1172
+ def parser_12c8(payload: str, msg: Message) -> PayDictT._12C8:
1173
+ return parse_air_quality(payload[2:6])
1174
+
1175
+
1176
+ # dhw_flow_rate
1177
+ def parser_12f0(payload: str, msg: Message) -> PayDictT._12F0:
1178
+ return {SZ_DHW_FLOW_RATE: hex_to_temp(payload[2:])}
1179
+
1180
+
1181
+ # ch_pressure
1182
+ def parser_1300(payload: str, msg: Message) -> PayDictT._1300:
1183
+ # 0x9F6 (2550 dec = 2.55 bar) appears to be a sentinel value
1184
+ return {SZ_PRESSURE: None if payload[2:] == "09F6" else hex_to_temp(payload[2:])}
1185
+
1186
+
1187
+ # programme_scheme, HVAC
1188
+ def parser_1470(payload: str, msg: Message) -> dict[str, Any]:
1189
+ # Seen on Orcon: see 1470, 1F70, 22B0
1190
+
1191
+ SCHEDULE_SCHEME = {
1192
+ "9": "one_per_week",
1193
+ "A": "two_per_week", # week_day, week_end
1194
+ "B": "one_each_day", # seven_per_week (default?)
1195
+ }
1196
+
1197
+ assert payload[8:10] == "80", _INFORM_DEV_MSG
1198
+ assert msg.verb == W_ or payload[4:8] == "0E60", _INFORM_DEV_MSG
1199
+ assert msg.verb == W_ or payload[10:] == "2A0108", _INFORM_DEV_MSG
1200
+ assert msg.verb != W_ or payload[4:] == "000080000000", _INFORM_DEV_MSG
1201
+
1202
+ # schedule...
1203
+ # [2:3] - 1, every/all days, 1&6, weekdays/weekends, 1-7, each individual day
1204
+ # [3:4] - # setpoints/day (default 3)
1205
+ assert payload[2:3] in SCHEDULE_SCHEME and (
1206
+ payload[3:4] in ("2", "3", "4", "5", "6")
1207
+ ), _INFORM_DEV_MSG
1208
+
1209
+ return {
1210
+ "scheme": SCHEDULE_SCHEME.get(payload[2:3], f"unknown_{payload[2:3]}"),
1211
+ "daily_setpoints": payload[3:4],
1212
+ "_value_4": payload[4:8],
1213
+ "_value_8": payload[8:10],
1214
+ "_value_10": payload[10:],
1215
+ }
1216
+
1217
+
1218
+ # system_sync
1219
+ def parser_1f09(payload: str, msg: Message) -> PayDictT._1F09:
1220
+ # 22:51:19.287 067 I --- --:------ --:------ 12:193204 1F09 003 010A69
1221
+ # 22:51:19.318 068 I --- --:------ --:------ 12:193204 2309 003 010866
1222
+ # 22:51:19.321 067 I --- --:------ --:------ 12:193204 30C9 003 0108C3
1223
+
1224
+ # domain_id from 01:/CTL:
1225
+ # - FF for regular sync messages
1226
+ # - 00 when responding to a request
1227
+ # - F8 after binding a device
1228
+
1229
+ assert msg.len == 3, f"length is {msg.len}, expecting 3"
1230
+ assert payload[:2] in ("00", "01", F8, FF) # W/F8
1231
+
1232
+ seconds = int(payload[2:6], 16) / 10
1233
+ next_sync = msg.dtm + td(seconds=seconds)
1234
+
1235
+ return {
1236
+ "remaining_seconds": seconds,
1237
+ "_next_sync": dt.strftime(next_sync, "%H:%M:%S"),
1238
+ }
1239
+
1240
+
1241
+ # dhw_mode
1242
+ def parser_1f41(payload: str, msg: Message) -> PayDictT._1F41:
1243
+ # 053 RP --- 01:145038 18:013393 --:------ 1F41 006 00FF00FFFFFF # no stored DHW
1244
+
1245
+ assert payload[4:6] in ZON_MODE_MAP, f"{payload[4:6]} (0xjj)"
1246
+ assert payload[4:6] == ZON_MODE_MAP.TEMPORARY or msg.len == 6, (
1247
+ f"{msg!r}: expected length 6"
1248
+ )
1249
+ assert payload[4:6] != ZON_MODE_MAP.TEMPORARY or msg.len == 12, (
1250
+ f"{msg!r}: expected length 12"
1251
+ )
1252
+ assert payload[6:12] == "FFFFFF", (
1253
+ f"{msg!r}: expected FFFFFF instead of '{payload[6:12]}'"
1254
+ )
1255
+
1256
+ result: PayDictT._1F41 = {SZ_MODE: ZON_MODE_MAP.get(payload[4:6])} # type: ignore[typeddict-item]
1257
+ if payload[2:4] != "FF":
1258
+ result[SZ_ACTIVE] = {"00": False, "01": True, "FF": None}[payload[2:4]]
1259
+ # if payload[4:6] == ZON_MODE_MAP.COUNTDOWN:
1260
+ # result[SZ_UNTIL] = dtm_from_hex(payload[6:12])
1261
+ if payload[4:6] == ZON_MODE_MAP.TEMPORARY:
1262
+ result[SZ_UNTIL] = hex_to_dtm(payload[12:24])
1263
+
1264
+ return result
1265
+
1266
+
1267
+ # programme_config, HVAC
1268
+ def parser_1f70(payload: str, msg: Message) -> dict[str, Any]:
1269
+ # Seen on Orcon: see 1470, 1F70, 22B0
1270
+
1271
+ try:
1272
+ assert payload[:2] == "00", f"expected 00, not {payload[:2]}"
1273
+ assert payload[2:4] in ("00", "01"), f"expected (00|01), not {payload[2:4]}"
1274
+ assert payload[4:8] == "0800", f"expected 0800, not {payload[4:8]}"
1275
+ assert payload[10:14] == "0000", f"expected 0000, not {payload[10:14]}"
1276
+ assert msg.verb in (RQ, W_) or payload[14:16] == "15"
1277
+ assert msg.verb in (I_, RP) or payload[14:16] == "00"
1278
+ assert msg.verb == RQ or payload[22:24] == "60"
1279
+ assert msg.verb != RQ or payload[22:24] == "00"
1280
+ assert msg.verb == RQ or payload[24:26] in ("E4", "E5", "E6"), _INFORM_DEV_MSG
1281
+ assert msg.verb == RP or payload[26:] == "000000"
1282
+ assert msg.verb != RP or payload[26:] == "008000"
1283
+
1284
+ except AssertionError as err:
1285
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1286
+
1287
+ # assert int(payload[16:18], 16) < 7, _INFORM_DEV_MSG
1288
+
1289
+ return {
1290
+ "day_idx": payload[16:18], # depends upon 1470[3:4]?
1291
+ "setpoint_idx": payload[8:10], # needs to be mod 1470[3:4]?
1292
+ "start_time": f"{int(payload[18:20], 16):02d}:{int(payload[20:22], 16):02d}",
1293
+ "fan_speed_wip": payload[24:26], # # E4/E5/E6 / 00(RQ)
1294
+ "_value_02": payload[2:4], # # 00/01 / 00(RQ)
1295
+ "_value_04": payload[4:8], # # 0800
1296
+ "_value_10": payload[10:14], # 0000
1297
+ "_value_14": payload[14:16], # 15(RP,I) / 00(RQ,W)
1298
+ "_value_22": payload[22:24], # 60 / 00(RQ)
1299
+ "_value_26": payload[26:], # # 008000(RP) / 000000(I/RQ/W)
1300
+ }
1301
+
1302
+
1303
+ # rf_bind
1304
+ def parser_1fc9(payload: str, msg: Message) -> PayDictT._1FC9:
1305
+ def _parser(seqx: str) -> list[str]:
1306
+ if seqx[:2] not in ("90",):
1307
+ assert (
1308
+ seqx[6:] == payload[6:12] # [6:12] is repeated
1309
+ ), f"{seqx[6:]} != {payload[6:12]}" # all with same controller
1310
+ if seqx[:2] not in (
1311
+ "21", # HVAC, Nuaire
1312
+ "63", # HVAC
1313
+ "65", # HVAC, ClimaRad
1314
+ "66", # HVAC, Vasco
1315
+ "67", # HVAC
1316
+ "6C", # HVAC
1317
+ "90", # HEAT
1318
+ F6,
1319
+ F9,
1320
+ FA,
1321
+ FB,
1322
+ FC,
1323
+ FF,
1324
+ ): # or: not in DOMAIN_TYPE_MAP: ??
1325
+ assert int(seqx[:2], 16) < 16, _INFORM_DEV_MSG
1326
+ return [seqx[:2], seqx[2:6], hex_id_to_dev_id(seqx[6:])]
1327
+
1328
+ if msg.verb == I_ and msg.dst.id in (msg.src.id, ALL_DEV_ADDR.id):
1329
+ bind_phase = SZ_OFFER
1330
+ elif msg.verb == W_ and msg.src is not msg.dst:
1331
+ bind_phase = SZ_ACCEPT
1332
+ elif msg.verb == I_:
1333
+ bind_phase = SZ_CONFIRM # len(payload) could be 2 (e.g. 00, 21)
1334
+ elif msg.verb == RP:
1335
+ bind_phase = None
1336
+ else:
1337
+ raise exc.PacketPayloadInvalid("Unknown binding format")
1338
+
1339
+ if len(payload) == 2 and bind_phase == SZ_CONFIRM:
1340
+ return {SZ_PHASE: bind_phase, SZ_BINDINGS: [[payload]]} # double-bracket OK
1341
+
1342
+ assert msg.len >= 6 and msg.len % 6 == 0, msg.len # assuming not RQ
1343
+ assert msg.verb in (I_, W_, RP), msg.verb # devices will respond to a RQ!
1344
+ # assert (
1345
+ # msg.src.id == hex_id_to_dev_id(payload[6:12])
1346
+ # ), f"{payload[6:12]} ({hex_id_to_dev_id(payload[6:12])})" # NOTE: use_regex
1347
+ bindings = [
1348
+ _parser(payload[i : i + 12])
1349
+ for i in range(0, len(payload), 12)
1350
+ # if payload[i : i + 2] != "90" # TODO: WIP, what is 90?
1351
+ ]
1352
+ return {SZ_PHASE: bind_phase, SZ_BINDINGS: bindings}
1353
+
1354
+
1355
+ # unknown_1fca, HVAC?
1356
+ def parser_1fca(payload: str, msg: Message) -> Mapping[str, str]:
1357
+ # .W --- 30:248208 34:021943 --:------ 1FCA 009 00-01FF-7BC990-FFFFFF # sent x2
1358
+
1359
+ return {
1360
+ "_unknown_0": payload[:2],
1361
+ "_unknown_1": payload[2:6],
1362
+ "device_id_0": hex_id_to_dev_id(payload[6:12]),
1363
+ "device_id_1": hex_id_to_dev_id(payload[12:]),
1364
+ }
1365
+
1366
+
1367
+ # unknown_1fd0, from OTB
1368
+ def parser_1fd0(payload: str, msg: Message) -> dict[str, Any]:
1369
+ assert payload == "0000000000000000", _INFORM_DEV_MSG
1370
+
1371
+ return {
1372
+ SZ_PAYLOAD: payload,
1373
+ }
1374
+
1375
+
1376
+ # opentherm_sync, otb_sync
1377
+ def parser_1fd4(payload: str, msg: Message) -> PayDictT._1FD4:
1378
+ return {"ticker": int(payload[2:], 16)}
1379
+
1380
+
1381
+ # WIP: unknown, HVAC
1382
+ def parser_2210(payload: str, msg: Message) -> dict[str, Any]:
1383
+ try:
1384
+ assert msg.verb in (RP, I_) or payload == "00"
1385
+ assert payload[10:12] == payload[38:40] and payload[10:12] in (
1386
+ "58",
1387
+ "96",
1388
+ "FF",
1389
+ ), f"expected (58|96|FF), not {payload[10:12]}"
1390
+ assert payload[20:22] == payload[48:50] and payload[20:22] in (
1391
+ "00",
1392
+ "03",
1393
+ ), f"expected (00|03), not {payload[10:12]}"
1394
+ assert payload[78:80] in ("00", "02"), f"expected (00|02), not {payload[78:80]}"
1395
+ assert payload[80:82] in ("01", "08"), f"expected (01|08), not {payload[80:82]}"
1396
+ assert payload[82:] in ("00", "40"), f"expected (00|40), not {payload[82:]}"
1397
+
1398
+ except AssertionError as err:
1399
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1400
+
1401
+ return {
1402
+ "unknown_10": payload[10:12],
1403
+ "unknown_20": payload[20:22],
1404
+ "unknown_78": payload[78:80],
1405
+ "unknown_80": payload[80:82],
1406
+ "unknown_82": payload[82:],
1407
+ }
1408
+
1409
+
1410
+ # now_next_setpoint - Programmer/Hometronics
1411
+ def parser_2249(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
1412
+ # see: https://github.com/jrosser/honeymon/blob/master/decoder.cpp#L357-L370
1413
+ # .I --- 23:100224 --:------ 23:100224 2249 007 00-7EFF-7EFF-FFFF
1414
+
1415
+ def _parser(seqx: str) -> dict[str, bool | float | int | str | None]:
1416
+ minutes = int(seqx[10:], 16)
1417
+ next_setpoint = msg.dtm + td(minutes=minutes)
1418
+ return {
1419
+ "setpoint_now": hex_to_temp(seqx[2:6]),
1420
+ "setpoint_next": hex_to_temp(seqx[6:10]),
1421
+ "minutes_remaining": minutes,
1422
+ "_next_setpoint": dt.strftime(next_setpoint, "%H:%M:%S"),
1423
+ }
1424
+
1425
+ # the ST9520C can support two heating zones, so: msg.len in (7, 14)?
1426
+ if msg._has_array:
1427
+ return [
1428
+ {
1429
+ SZ_ZONE_IDX: payload[i : i + 2],
1430
+ **_parser(payload[i + 2 : i + 14]),
1431
+ }
1432
+ for i in range(0, len(payload), 14)
1433
+ ]
1434
+
1435
+ return _parser(payload)
1436
+
1437
+
1438
+ # program_enabled, HVAC
1439
+ def parser_22b0(payload: str, msg: Message) -> dict[str, Any]:
1440
+ # Seen on Orcon: see 1470, 1F70, 22B0
1441
+
1442
+ # .W --- 37:171871 32:155617 --:------ 22B0 002 0005 # enable, calendar on
1443
+ # .I --- 32:155617 37:171871 --:------ 22B0 002 0005
1444
+
1445
+ # .W --- 37:171871 32:155617 --:------ 22B0 002 0006 # disable, calendar off
1446
+ # .I --- 32:155617 37:171871 --:------ 22B0 002 0006
1447
+
1448
+ return {
1449
+ "enabled": {"06": False, "05": True}.get(payload[2:4]),
1450
+ }
1451
+
1452
+
1453
+ # setpoint_bounds, TODO: max length = 24?
1454
+ def parser_22c9(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
1455
+ # .I --- 02:001107 --:------ 02:001107 22C9 024 00-0834-0A28-01-0108340A2801-0208340A2801-0308340A2801 # noqa: E501
1456
+ # .I --- 02:001107 --:------ 02:001107 22C9 006 04-0834-0A28-01
1457
+
1458
+ # .I --- 21:064743 --:------ 21:064743 22C9 006 00-07D0-0834-02
1459
+ # .W --- 21:064743 02:250708 --:------ 22C9 006 03-07D0-0834-02
1460
+ # .I --- 02:250708 21:064743 --:------ 22C9 008 03-07D0-7FFF-020203
1461
+
1462
+ # Notes on 008|suffix: only seen as I, only when no array, only as 7FFF(0101|0202)03$
1463
+
1464
+ def _parser(seqx: str) -> dict:
1465
+ assert seqx[10:] in ("01", "02"), f"is {seqx[10:]}, expecting 01 or 02"
1466
+
1467
+ return {
1468
+ SZ_MODE: {"01": "heat", "02": "cool"}[seqx[10:]], # TODO: or action?
1469
+ SZ_SETPOINT_BOUNDS: (hex_to_temp(seqx[2:6]), hex_to_temp(seqx[6:10])),
1470
+ } # lower, upper setpoints
1471
+
1472
+ if msg._has_array:
1473
+ return [
1474
+ {
1475
+ SZ_UFH_IDX: payload[i : i + 2],
1476
+ **_parser(payload[i : i + 12]),
1477
+ }
1478
+ for i in range(0, len(payload), 12)
1479
+ ]
1480
+
1481
+ assert msg.len != 8 or payload[10:] in ("010103", "020203"), _INFORM_DEV_MSG
1482
+
1483
+ return _parser(payload[:12])
1484
+
1485
+
1486
+ # unknown_22d0, UFH system mode (heat/cool)
1487
+ def parser_22d0(payload: str, msg: Message) -> dict[str, Any]:
1488
+ def _parser(seqx: str) -> dict:
1489
+ # assert seqx[2:4] in ("00", "03", "10", "13", "14"), _INFORM_DEV_MSG
1490
+ assert seqx[4:6] == "00", _INFORM_DEV_MSG
1491
+ return {
1492
+ "idx": seqx[:2],
1493
+ "_flags": hex_to_flag8(seqx[2:4]),
1494
+ "cool_mode": bool(int(seqx[2:4], 16) & 0x02),
1495
+ "heat_mode": bool(int(seqx[2:4], 16) & 0x04),
1496
+ "is_active": bool(int(seqx[2:4], 16) & 0x10),
1497
+ "_unknown": payload[4:],
1498
+ }
1499
+
1500
+ if len(payload) == 8:
1501
+ assert payload[6:] in ("00", "02", "0A"), _INFORM_DEV_MSG
1502
+ else:
1503
+ assert payload[4:] == "001E14030020", _INFORM_DEV_MSG
1504
+
1505
+ return _parser(payload)
1506
+
1507
+
1508
+ # desired boiler setpoint
1509
+ def parser_22d9(payload: str, msg: Message) -> PayDictT._22D9:
1510
+ return {SZ_SETPOINT: hex_to_temp(payload[2:6])}
1511
+
1512
+
1513
+ # WIP: unknown, HVAC
1514
+ def parser_22e0(payload: str, msg: Message) -> Mapping[str, float | None]:
1515
+ # RP --- 32:155617 18:005904 --:------ 22E0 004 00-34-A0-1E
1516
+ # RP --- 32:153258 18:005904 --:------ 22E0 004 00-64-A0-1E
1517
+ def _parser(seqx: str) -> float:
1518
+ assert int(seqx, 16) <= 200 or seqx == "E6" # only for 22E0, not 22E5/22E9
1519
+ return int(seqx, 16) / 200
1520
+
1521
+ try:
1522
+ return {
1523
+ f"percent_{i}": hex_to_percent(payload[i : i + 2])
1524
+ for i in range(2, len(payload), 2)
1525
+ }
1526
+ except ValueError:
1527
+ return {
1528
+ "percent_2": hex_to_percent(payload[2:4]),
1529
+ "percent_4": _parser(payload[4:6]),
1530
+ "percent_6": hex_to_percent(payload[6:8]),
1531
+ }
1532
+
1533
+
1534
+ # WIP: unknown, HVAC
1535
+ def parser_22e5(payload: str, msg: Message) -> Mapping[str, float | None]:
1536
+ # RP --- 32:153258 18:005904 --:------ 22E5 004 00-96-C8-14
1537
+ # RP --- 32:155617 18:005904 --:------ 22E5 004 00-72-C8-14
1538
+
1539
+ return parser_22e0(payload, msg)
1540
+
1541
+
1542
+ # WIP: unknown, HVAC
1543
+ def parser_22e9(payload: str, msg: Message) -> Mapping[str, float | str | None]:
1544
+ if payload[2:4] == "01":
1545
+ return {
1546
+ "unknown_4": payload[4:6],
1547
+ "unknown_6": payload[6:8],
1548
+ }
1549
+ return parser_22e0(payload, msg)
1550
+
1551
+
1552
+ # fan_speed (switch_mode), HVAC
1553
+ def parser_22f1(payload: str, msg: Message) -> dict[str, Any]:
1554
+ try:
1555
+ assert payload[0:2] in ("00", "63")
1556
+ assert not payload[4:] or int(payload[2:4], 16) <= int(payload[4:], 16), (
1557
+ "mode_idx > mode_max"
1558
+ )
1559
+ except AssertionError as err:
1560
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1561
+
1562
+ if msg._addrs[0] == NON_DEV_ADDR: # and payload[4:6] == "04":
1563
+ from .ramses import _22F1_MODE_ITHO as _22F1_FAN_MODE # TODO: only if 04
1564
+
1565
+ _22f1_mode_set: tuple[str, ...] = ("", "04")
1566
+ _22f1_scheme = "itho"
1567
+
1568
+ # elif msg._addrs[0] == NON_DEV_ADDR: # and payload[4:6] == "04":
1569
+ # _22F1_FAN_MODE = {
1570
+ # f"{x:02X}": f"speed_{x}" for x in range(int(payload[4:6], 16) + 1)
1571
+ # } | {"00": "off"}
1572
+
1573
+ # _22f1_mode_set = (payload[4:6], )
1574
+ # _22f1_scheme = "itho_2"
1575
+
1576
+ elif payload[4:6] == "0A":
1577
+ from .ramses import _22F1_MODE_NUAIRE as _22F1_FAN_MODE
1578
+
1579
+ _22f1_mode_set = ("", "0A")
1580
+ _22f1_scheme = "nuaire"
1581
+
1582
+ elif payload[4:6] == "06":
1583
+ from .ramses import _22F1_MODE_VASCO as _22F1_FAN_MODE
1584
+
1585
+ _22f1_mode_set = (
1586
+ "",
1587
+ "00",
1588
+ "06",
1589
+ ) # "00" seen incidentally on a ClimaRad 4-button remote: OFF?
1590
+ _22f1_scheme = "vasco"
1591
+
1592
+ else:
1593
+ from .ramses import _22F1_MODE_ORCON as _22F1_FAN_MODE
1594
+
1595
+ _22f1_mode_set = ("", "04", "07", "0B") # 0B?
1596
+ _22f1_scheme = "orcon"
1597
+
1598
+ try:
1599
+ assert payload[2:4] in _22F1_FAN_MODE, f"unknown fan_mode: {payload[2:4]}"
1600
+ assert payload[4:6] in _22f1_mode_set, f"unknown mode_set: {payload[4:6]}"
1601
+ except AssertionError as err:
1602
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1603
+
1604
+ return {
1605
+ SZ_FAN_MODE: _22F1_FAN_MODE.get(payload[2:4], f"unknown_{payload[2:4]}"),
1606
+ "_scheme": _22f1_scheme,
1607
+ "_mode_idx": f"{int(payload[2:4], 16) & 0x0F:02X}",
1608
+ "_mode_max": payload[4:6] or None,
1609
+ # "_payload": payload,
1610
+ }
1611
+
1612
+
1613
+ # WIP: unknown, HVAC (flow rate?)
1614
+ def parser_22f2(payload: str, msg: Message) -> list: # TODO: only dict
1615
+ # ClimeRad minibox uses 22F2 for speed feedback
1616
+
1617
+ def _parser(seqx: str) -> dict:
1618
+ assert seqx[:2] in ("00", "01"), f"is {seqx[:2]}, expecting 00/01"
1619
+
1620
+ return {
1621
+ "hvac_idx": seqx[:2],
1622
+ "measure": hex_to_temp(seqx[2:]),
1623
+ }
1624
+
1625
+ return [_parser(payload[i : i + 6]) for i in range(0, len(payload), 6)]
1626
+
1627
+
1628
+ # fan_boost, HVAC
1629
+ def parser_22f3(payload: str, msg: Message) -> dict[str, Any]:
1630
+ # NOTE: for boost timer for high
1631
+ try:
1632
+ assert msg.len <= 7 or payload[14:] == "0000", f"byte 7: {payload[14:]}"
1633
+ except AssertionError as err:
1634
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1635
+
1636
+ new_speed = { # from now, until timer expiry
1637
+ 0x00: "fan_boost", # # set fan off, or 'boost' mode?
1638
+ 0x01: "per_request", # # set fan as per payload[6:10]?
1639
+ 0x02: "per_vent_speed", # set fan as per current fan mode/speed?
1640
+ }.get(int(payload[2:4], 0x10) & 0x07) # 0b0000-0111
1641
+
1642
+ fallback_speed: str | None
1643
+ if msg.len == 7 and payload[9:10] == "06": # Vasco and ClimaRad REM
1644
+ fallback_speed = "per_vent_speed" # after timer expiry
1645
+ # set fan as per current fan mode/speed
1646
+ else:
1647
+ fallback_speed = { # after timer expiry
1648
+ 0x08: "fan_off", # # set fan off?
1649
+ 0x10: "per_request", # # set fan as per payload[6:10], or payload[10:]?
1650
+ 0x18: "per_vent_speed", # set fan as per current fan mode/speed?
1651
+ }.get(int(payload[2:4], 0x10) & 0x38) # 0b0011-1000
1652
+
1653
+ units = {
1654
+ 0x00: "minutes",
1655
+ 0x40: "hours",
1656
+ 0x80: "index", # TODO: days, day-of-week, day-of-month?
1657
+ }.get(int(payload[2:4], 0x10) & 0xC0) # 0b1100-0000
1658
+
1659
+ duration = int(payload[4:6], 16) * 60 if units == "hours" else int(payload[4:6], 16)
1660
+ result = {}
1661
+
1662
+ if msg.len >= 3:
1663
+ result = {
1664
+ "minutes" if units != "index" else "index": duration,
1665
+ "flags": hex_to_flag8(payload[2:4]),
1666
+ "_new_speed_mode": new_speed,
1667
+ "_fallback_speed_mode": fallback_speed,
1668
+ }
1669
+
1670
+ if msg.len >= 5 and payload[6:10] != "0000": # new speed?
1671
+ result["rate"] = parser_22f1(f"00{payload[6:10]}", msg).get("rate")
1672
+
1673
+ if msg.len >= 7: # fallback speed?
1674
+ result.update({"_unknown_5": payload[10:]})
1675
+
1676
+ return result
1677
+
1678
+
1679
+ # WIP: unknown, HVAC
1680
+ def parser_22f4(payload: str, msg: Message) -> dict[str, Any]:
1681
+ if msg.len == 13 and payload[14:] == "000000000000":
1682
+ # ClimaRad Ventura fan & remote
1683
+ _pl = payload[:4] + payload[12:14] if payload[10:12] == "00" else payload[8:14]
1684
+ else:
1685
+ _pl = payload[:6]
1686
+
1687
+ MODE_LOOKUP = {
1688
+ 0x00: "off",
1689
+ 0x20: "paused",
1690
+ 0x40: "auto",
1691
+ 0x60: "manual",
1692
+ }
1693
+ mode = int(_pl[2:4], 16) & 0x60
1694
+ assert mode in MODE_LOOKUP, mode
1695
+
1696
+ RATE_LOOKUP = {
1697
+ 0x00: "speed 0", # "off"?,
1698
+ 0x01: "speed 1", # "low", or trickle?
1699
+ 0x02: "speed 2", # "medium-low", or low?
1700
+ 0x03: "speed 3", # "medium",
1701
+ 0x04: "speed 4", # "medium-high", or high?
1702
+ 0x05: "boost", # "boost", aka purge?
1703
+ }
1704
+ rate = int(_pl[4:6], 16) & 0x03
1705
+ assert mode != 0x60 or rate in RATE_LOOKUP, rate
1706
+
1707
+ return {
1708
+ SZ_FAN_MODE: MODE_LOOKUP[mode],
1709
+ SZ_FAN_RATE: RATE_LOOKUP.get(rate),
1710
+ }
1711
+
1712
+
1713
+ # bypass_mode, HVAC
1714
+ def parser_22f7(payload: str, msg: Message) -> dict[str, Any]:
1715
+ result = {
1716
+ SZ_BYPASS_MODE: {"00": "off", "C8": "on", "FF": "auto"}.get(payload[2:4]),
1717
+ }
1718
+ if msg.verb != W_ or payload[4:] not in ("", "EF"):
1719
+ result[SZ_BYPASS_STATE] = {"00": "off", "C8": "on"}.get(payload[4:])
1720
+ result.update(**parse_bypass_position(payload[4:])) # type: ignore[arg-type]
1721
+
1722
+ return result
1723
+
1724
+
1725
+ # WIP: unknown_mode, HVAC
1726
+ def parser_22f8(payload: str, msg: Message) -> dict[str, Any]:
1727
+ # from: https://github.com/arjenhiemstra/ithowifi/blob/master/software/NRG_itho_wifi/src/IthoPacket.h
1728
+
1729
+ # message command bytes specific for AUTO RFT (536-0150)
1730
+ # ithoMessageAUTORFTAutoNightCommandBytes[] = {0x22, 0xF8, 0x03, 0x63, 0x02, 0x03};
1731
+ # .W --- 32:111111 37:111111 --:------ 22F8 003 630203
1732
+
1733
+ # message command bytes specific for DemandFlow remote (536-0146)
1734
+ # ithoMessageDFLowCommandBytes[] = {0x22, 0xF8, 0x03, 0x00, 0x01, 0x02};
1735
+ # ithoMessageDFHighCommandBytes[] = {0x22, 0xF8, 0x03, 0x00, 0x02, 0x02};
1736
+
1737
+ return {
1738
+ "value_02": payload[2:4],
1739
+ "value_04": payload[4:6],
1740
+ }
1741
+
1742
+
1743
+ # setpoint (of device/zones)
1744
+ def parser_2309(
1745
+ payload: str, msg: Message
1746
+ ) -> PayDictT._2309 | list[PayDictT._2309] | PayDictT.EMPTY:
1747
+ if msg._has_array:
1748
+ return [
1749
+ {
1750
+ SZ_ZONE_IDX: payload[i : i + 2],
1751
+ SZ_SETPOINT: hex_to_temp(payload[i + 2 : i + 6]),
1752
+ }
1753
+ for i in range(0, len(payload), 6)
1754
+ ]
1755
+
1756
+ # RQ --- 22:131874 01:063844 --:------ 2309 003 020708
1757
+ if msg.verb == RQ and msg.len == 1: # some RQs have a payload (why?)
1758
+ return {}
1759
+
1760
+ return {SZ_SETPOINT: hex_to_temp(payload[2:])}
1761
+
1762
+
1763
+ # zone_mode # TODO: messy
1764
+ def parser_2349(payload: str, msg: Message) -> PayDictT._2349 | PayDictT.EMPTY:
1765
+ # RQ --- 34:225071 30:258557 --:------ 2349 001 00
1766
+ # RP --- 30:258557 34:225071 --:------ 2349 013 007FFF00FFFFFFFFFFFFFFFFFF
1767
+ # RP --- 30:253184 34:010943 --:------ 2349 013 00064000FFFFFF00110E0507E5
1768
+ # .I --- 10:067219 --:------ 10:067219 2349 004 00000001
1769
+
1770
+ if msg.verb == RQ and msg.len <= 2: # some RQs have a payload (why?)
1771
+ return {}
1772
+
1773
+ assert msg.len in (7, 13), f"expected len 7,13, got {msg.len}"
1774
+
1775
+ assert payload[6:8] in ZON_MODE_MAP, f"unknown zone_mode: {payload[6:8]}"
1776
+ result: PayDictT._2349 = {
1777
+ SZ_MODE: ZON_MODE_MAP.get(payload[6:8]), # type: ignore[typeddict-item]
1778
+ SZ_SETPOINT: hex_to_temp(payload[2:6]),
1779
+ }
1780
+
1781
+ if msg.len >= 7: # has a dtm if mode == "04"
1782
+ if payload[8:14] == "FF" * 3: # 03/FFFFFF OK if W?
1783
+ assert payload[6:8] != ZON_MODE_MAP.COUNTDOWN, f"{payload[6:8]} (0x00)"
1784
+ else:
1785
+ assert payload[6:8] == ZON_MODE_MAP.COUNTDOWN, f"{payload[6:8]} (0x01)"
1786
+ result[SZ_DURATION] = int(payload[8:14], 16)
1787
+
1788
+ if msg.len >= 13:
1789
+ if payload[14:] == "FF" * 6:
1790
+ assert payload[6:8] in (
1791
+ ZON_MODE_MAP.FOLLOW,
1792
+ ZON_MODE_MAP.PERMANENT,
1793
+ ), f"{payload[6:8]} (0x02)"
1794
+ result[SZ_UNTIL] = None # TODO: remove?
1795
+ else:
1796
+ assert payload[6:8] != ZON_MODE_MAP.PERMANENT, f"{payload[6:8]} (0x03)"
1797
+ result[SZ_UNTIL] = hex_to_dtm(payload[14:26])
1798
+
1799
+ return result
1800
+
1801
+
1802
+ # unknown_2389, from 03:
1803
+ def parser_2389(payload: str, msg: Message) -> dict[str, Any]:
1804
+ return {
1805
+ "_unknown": hex_to_temp(payload[2:6]),
1806
+ }
1807
+
1808
+
1809
+ # unknown_2400, from OTB, FAN
1810
+ def parser_2400(payload: str, msg: Message) -> dict[str, Any]:
1811
+ # RP --- 32:155617 18:005904 --:------ 2400 045 00001111-1010929292921110101020110010000080100010100000009191111191910011119191111111111100 # Orcon FAN
1812
+ # RP --- 10:048122 18:006402 --:------ 2400 004 0000000F
1813
+ # assert payload == "0000000F", _INFORM_DEV_MSG
1814
+
1815
+ return {
1816
+ SZ_PAYLOAD: payload,
1817
+ }
1818
+
1819
+
1820
+ # unknown_2401, from OTB
1821
+ def parser_2401(payload: str, msg: Message) -> dict[str, Any]:
1822
+ try:
1823
+ assert payload[2:4] == "00", f"byte 1: {payload[2:4]}"
1824
+ assert int(payload[4:6], 16) & 0b11110000 == 0, (
1825
+ f"byte 2: {hex_to_flag8(payload[4:6])}"
1826
+ )
1827
+ assert int(payload[6:], 0x10) <= 200, f"byte 3: {payload[6:]}"
1828
+ except AssertionError as err:
1829
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1830
+
1831
+ return {
1832
+ "_flags_2": hex_to_flag8(payload[4:6]),
1833
+ **parse_valve_demand(payload[6:8]), # ~3150|FC
1834
+ "_value_2": int(payload[4:6], 0x10),
1835
+ }
1836
+
1837
+
1838
+ # unknown_2410, from OTB, FAN
1839
+ def parser_2410(payload: str, msg: Message) -> dict[str, Any]:
1840
+ # RP --- 10:048122 18:006402 --:------ 2410 020 00-00000000-00000000-00000001-00000001-00000C # OTB
1841
+ # RP --- 32:155617 18:005904 --:------ 2410 020 00-00003EE8-00000000-FFFFFFFF-00000000-1002A6 # Orcon Fan
1842
+
1843
+ def unstuff(seqx: str) -> tuple:
1844
+ val = int(seqx, 16)
1845
+ # if val & 0x40:
1846
+ # raise TypeError
1847
+ signed = bool(val & 0x80)
1848
+ length = (val >> 3 & 0x07) or 1
1849
+ d_type = {0b000: "a", 0b001: "b", 0b010: "c", 0b100: "d"}.get(
1850
+ val & 0x07, val & 0x07
1851
+ )
1852
+ return signed, length, d_type
1853
+
1854
+ try:
1855
+ assert payload[:6] == "00" * 3, _INFORM_DEV_MSG
1856
+ assert payload[10:18] == "00" * 4, _INFORM_DEV_MSG
1857
+ assert payload[18:26] in ("00000001", "FFFFFFFF"), _INFORM_DEV_MSG
1858
+ assert payload[26:34] in ("00000001", "00000000"), _INFORM_DEV_MSG
1859
+ except AssertionError as err:
1860
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1861
+
1862
+ return {
1863
+ "tail": payload[34:],
1864
+ "xxx_34": unstuff(payload[34:36]),
1865
+ "xxx_36": unstuff(payload[36:38]),
1866
+ "xxx_38": unstuff(payload[38:]),
1867
+ "cur_value": payload[2:10],
1868
+ "min_value": payload[10:18],
1869
+ "max_value": payload[18:26],
1870
+ "oth_value": payload[26:34],
1871
+ }
1872
+
1873
+
1874
+ # fan_params, HVAC
1875
+ def parser_2411(payload: str, msg: Message) -> dict[str, Any]:
1876
+ # There is a relationship between 0001 and 2411
1877
+ # RQ --- 37:171871 32:155617 --:------ 0001 005 0020000A04
1878
+ # RP --- 32:155617 37:171871 --:------ 0001 008 0020000A004E0B00 # 0A -> 2411|4E
1879
+ # RQ --- 37:171871 32:155617 --:------ 2411 003 00004E # 11th menu option (i.e. 0x0A)
1880
+ # RP --- 32:155617 37:171871 --:------ 2411 023 00004E460000000001000000000000000100000001A600
1881
+
1882
+ def counter(x: str) -> int:
1883
+ return int(x, 16)
1884
+
1885
+ def centile(x: str) -> float:
1886
+ return int(x, 16) / 10
1887
+
1888
+ _2411_DATA_TYPES = {
1889
+ "00": (2, counter), # 4E (0-1), 54 (15-60)
1890
+ "01": (2, centile), # 52 (0.0-25.0) (%)
1891
+ "0F": (2, hex_to_percent), # xx (0.0-1.0) (%)
1892
+ "10": (4, counter), # 31 (0-1800) (days)
1893
+ "92": (4, hex_to_temp), # 75 (0-30) (C)
1894
+ } # TODO: _2411_TYPES.get(payload[8:10], (8, no_op))
1895
+
1896
+ assert payload[4:6] in _2411_TABLE, (
1897
+ f"param {payload[4:6]} is unknown"
1898
+ ) # _INFORM_DEV_MSG
1899
+ description = _2411_TABLE.get(payload[4:6], "Unknown")
1900
+
1901
+ result = {
1902
+ "parameter": payload[4:6],
1903
+ "description": description,
1904
+ }
1905
+
1906
+ if msg.verb == RQ:
1907
+ return result
1908
+
1909
+ assert payload[8:10] in _2411_DATA_TYPES, (
1910
+ f"param {payload[4:6]} has unknown data_type: {payload[8:10]}"
1911
+ ) # _INFORM_DEV_MSG
1912
+ length, parser = _2411_DATA_TYPES.get(payload[8:10], (8, lambda x: x))
1913
+
1914
+ result |= {
1915
+ "value": parser(payload[10:18][-length:]), # type: ignore[operator]
1916
+ "_value_06": payload[6:10],
1917
+ }
1918
+
1919
+ if msg.len == 9:
1920
+ return result
1921
+
1922
+ return (
1923
+ result
1924
+ | {
1925
+ "min_value": parser(payload[18:26][-length:]), # type: ignore[operator]
1926
+ "max_value": parser(payload[26:34][-length:]), # type: ignore[operator]
1927
+ "precision": parser(payload[34:42][-length:]), # type: ignore[operator]
1928
+ "_value_42": payload[42:],
1929
+ }
1930
+ )
1931
+
1932
+
1933
+ # unknown_2420, from OTB
1934
+ def parser_2420(payload: str, msg: Message) -> dict[str, Any]:
1935
+ assert payload == "00000010" + "00" * 34, _INFORM_DEV_MSG
1936
+
1937
+ return {
1938
+ SZ_PAYLOAD: payload,
1939
+ }
1940
+
1941
+
1942
+ # _state (of cooling?), from BDR91T, hometronics CTL
1943
+ def parser_2d49(payload: str, msg: Message) -> PayDictT._2D49:
1944
+ assert payload[2:] in ("0000", "00FF", "C800", "C8FF"), _INFORM_DEV_MSG
1945
+
1946
+ return {
1947
+ "state": hex_to_bool(payload[2:4]),
1948
+ }
1949
+
1950
+
1951
+ # system_mode
1952
+ def parser_2e04(payload: str, msg: Message) -> PayDictT._2E04:
1953
+ # if msg.verb == W_:
1954
+
1955
+ # .I --— 01:020766 --:------ 01:020766 2E04 016 FFFFFFFFFFFFFF0007FFFFFFFFFFFF04 # Manual # noqa: E501
1956
+ # .I --— 01:020766 --:------ 01:020766 2E04 016 FFFFFFFFFFFFFF0000FFFFFFFFFFFF04 # Automatic/times # noqa: E501
1957
+
1958
+ if msg.len == 8: # evohome
1959
+ assert payload[:2] in SYS_MODE_MAP, f"Unknown system mode: {payload[:2]}"
1960
+
1961
+ elif msg.len == 16: # hometronics, lifestyle ID:
1962
+ assert 0 <= int(payload[:2], 16) <= 15 or payload[:2] == FF, payload[:2]
1963
+ assert payload[16:18] in (SYS_MODE_MAP.AUTO, SYS_MODE_MAP.CUSTOM), payload[
1964
+ 16:18
1965
+ ]
1966
+ assert payload[30:32] == SYS_MODE_MAP.DAY_OFF, payload[30:32]
1967
+ # assert False
1968
+
1969
+ else:
1970
+ # msg.len in (8, 16) # evohome 8, hometronics 16
1971
+ assert False, f"Packet length is {msg.len} (expecting 8, 16)"
1972
+
1973
+ result: PayDictT._2E04 = {SZ_SYSTEM_MODE: SYS_MODE_MAP[payload[:2]]}
1974
+ if payload[:2] not in (
1975
+ SYS_MODE_MAP.AUTO,
1976
+ SYS_MODE_MAP.HEAT_OFF,
1977
+ SYS_MODE_MAP.AUTO_WITH_RESET,
1978
+ ):
1979
+ result.update(
1980
+ {SZ_UNTIL: hex_to_dtm(payload[2:14]) if payload[14:16] != "00" else None}
1981
+ )
1982
+ return result # TODO: double-check the final "00"
1983
+
1984
+
1985
+ # presence_detect, HVAC sensor, or Timed boost for Vasco D60
1986
+ def parser_2e10(payload: str, msg: Message) -> dict[str, Any]:
1987
+ assert payload in ("0001", "000000", "000100"), _INFORM_DEV_MSG
1988
+ presence: int = int(payload[2:4])
1989
+ return {
1990
+ "presence_detected": bool(presence),
1991
+ "_unknown_4": payload[4:],
1992
+ }
1993
+
1994
+
1995
+ # current temperature (of device, zone/s)
1996
+ def parser_30c9(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
1997
+ if msg._has_array:
1998
+ return [
1999
+ {
2000
+ SZ_ZONE_IDX: payload[i : i + 2],
2001
+ SZ_TEMPERATURE: hex_to_temp(payload[i + 2 : i + 6]),
2002
+ }
2003
+ for i in range(0, len(payload), 6)
2004
+ ]
2005
+
2006
+ return {SZ_TEMPERATURE: hex_to_temp(payload[2:])}
2007
+
2008
+
2009
+ # ufc_demand, HVAC (Itho autotemp / spider)
2010
+ def parser_3110(payload: str, msg: Message) -> PayDictT._3110:
2011
+ # .I --- 02:250708 --:------ 02:250708 3110 004 0000C820 # cooling, 100%
2012
+ # .I --- 21:042656 --:------ 21:042656 3110 004 00000010 # heating, 0%
2013
+
2014
+ SZ_COOLING = "cooling"
2015
+ SZ_DISABLE = "disabled"
2016
+ SZ_HEATING = "heating"
2017
+ SZ_UNKNOWN = "unknown"
2018
+
2019
+ try:
2020
+ assert payload[2:4] == "00", f"byte 1: {payload[2:4]}" # ?circuit_idx?
2021
+ assert int(payload[4:6], 16) <= 200, f"byte 2: {payload[4:6]}"
2022
+ assert payload[6:] in ("00", "10", "20"), f"byte 3: {payload[6:]}"
2023
+ assert payload[6:] in ("10", "20") or payload[4:6] == "00", (
2024
+ f"byte 3: {payload[6:]}"
2025
+ )
2026
+ except AssertionError as err:
2027
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
2028
+
2029
+ mode = {
2030
+ 0x00: SZ_DISABLE,
2031
+ 0x10: SZ_HEATING,
2032
+ 0x20: SZ_COOLING,
2033
+ }.get(int(payload[6:8], 16) & 0x30, SZ_UNKNOWN)
2034
+
2035
+ if mode not in (SZ_COOLING, SZ_HEATING):
2036
+ return {SZ_MODE: mode}
2037
+
2038
+ return {SZ_MODE: mode, SZ_DEMAND: hex_to_percent(payload[4:6])}
2039
+
2040
+
2041
+ # unknown_3120, from STA, FAN
2042
+ def parser_3120(payload: str, msg: Message) -> dict[str, Any]:
2043
+ # .I --- 34:136285 --:------ 34:136285 3120 007 0070B0000000FF # every ~3:45:00!
2044
+ # RP --- 20:008749 18:142609 --:------ 3120 007 0070B000009CFF
2045
+ # .I --- 37:258565 --:------ 37:258565 3120 007 0080B0010003FF
2046
+
2047
+ try:
2048
+ assert payload[:2] == "00", f"byte 0: {payload[:2]}"
2049
+ assert payload[2:4] in ("00", "70", "80"), f"byte 1: {payload[2:4]}"
2050
+ assert payload[4:6] == "B0", f"byte 2: {payload[4:6]}"
2051
+ assert payload[6:8] in ("00", "01"), f"byte 3: {payload[6:8]}"
2052
+ assert payload[8:10] == "00", f"byte 4: {payload[8:10]}"
2053
+ assert payload[10:12] in ("00", "03", "0A", "9C"), f"byte 5: {payload[10:12]}"
2054
+ assert payload[12:] == "FF", f"byte 6: {payload[12:]}"
2055
+ except AssertionError as err:
2056
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
2057
+
2058
+ return {
2059
+ "unknown_0": payload[2:10],
2060
+ "unknown_5": payload[10:12],
2061
+ "unknown_2": payload[12:],
2062
+ }
2063
+
2064
+
2065
+ # WIP: unknown, HVAC
2066
+ def parser_313e(payload: str, msg: Message) -> dict[str, Any]:
2067
+ assert payload[:2] == "00"
2068
+ assert payload[12:] == "003C800000"
2069
+
2070
+ result = (
2071
+ msg.dtm - td(seconds=int(payload[10:12], 16), minutes=int(payload[2:10], 16))
2072
+ ).isoformat()
2073
+
2074
+ return {
2075
+ "zulu": result,
2076
+ "value_02": payload[2:10],
2077
+ "value_10": payload[10:12],
2078
+ "value_12": payload[12:],
2079
+ }
2080
+
2081
+
2082
+ # datetime
2083
+ def parser_313f(payload: str, msg: Message) -> PayDictT._313F: # TODO: look for TZ
2084
+ # 2020-03-28T03:59:21.315178 045 RP --- 01:158182 04:136513 --:------ 313F 009 00FC3500A41C0307E4
2085
+ # 2020-03-29T04:58:30.486343 045 RP --- 01:158182 04:136485 --:------ 313F 009 00FC8400C51D0307E4
2086
+ # 2022-09-20T20:50:32.800676 065 RP --- 01:182924 18:068640 --:------ 313F 009 00F9203234140907E6
2087
+ # 2020-05-31T11:37:50.351511 056 I --- --:------ --:------ 12:207082 313F 009 0038021ECB1F0507E4
2088
+
2089
+ # https://www.automatedhome.co.uk/vbulletin/showthread.php?5085-My-HGI80-equivalent-Domoticz-setup-without-HGI80&p=36422&viewfull=1#post36422
2090
+ # every day at ~4am TRV/RQ->CTL/RP, approx 5-10secs apart (CTL respond at any time)
2091
+
2092
+ assert msg.src.type != DEV_TYPE_MAP.CTL or payload[2:4] in (
2093
+ "F0",
2094
+ "F9",
2095
+ "FC",
2096
+ ), f"{payload[2:4]} unexpected for CTL" # DEX
2097
+ assert (
2098
+ msg.src.type not in (DEV_TYPE_MAP.DTS, DEV_TYPE_MAP.DT2) or payload[2:4] == "38"
2099
+ ), f"{payload[2:4]} unexpected for DTS" # DEX
2100
+ # assert (
2101
+ # msg.src.type != DEV_TYPE_MAP.FAN or payload[2:4] == "7C"
2102
+ # ), f"{payload[2:4]} unexpected for FAN" # DEX
2103
+ assert msg.src.type != DEV_TYPE_MAP.RFG or payload[2:4] == "60", (
2104
+ "{payload[2:4]} unexpected for RFG"
2105
+ ) # DEX
2106
+
2107
+ return {
2108
+ SZ_DATETIME: hex_to_dtm(payload[4:18]),
2109
+ SZ_IS_DST: True if bool(int(payload[4:6], 16) & 0x80) else None,
2110
+ "_unknown_0": payload[2:4],
2111
+ }
2112
+
2113
+
2114
+ # heat_demand (of device, FC domain) - valve status (%open)
2115
+ def parser_3150(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
2116
+ # event-driven, and periodically; FC domain is maximum of all zones
2117
+ # TODO: all have a valid domain will UFC/CTL respond to an RQ, for FC, for a zone?
2118
+
2119
+ # .I --- 04:136513 --:------ 01:158182 3150 002 01CA < often seen CA, artefact?
2120
+
2121
+ def complex_idx(seqx: str, msg: Message) -> dict[str, str]:
2122
+ # assert seqx[:2] == FC or (int(seqx[:2], 16) < MAX_ZONES) # <5, 8 for UFC
2123
+ idx_name = "ufx_idx" if msg.src.type == DEV_TYPE_MAP.UFC else SZ_ZONE_IDX # DEX
2124
+ return {SZ_DOMAIN_ID if seqx[:1] == "F" else idx_name: seqx[:2]}
2125
+
2126
+ if msg._has_array:
2127
+ return [
2128
+ {
2129
+ **complex_idx(payload[i : i + 2], msg),
2130
+ **parse_valve_demand(payload[i + 2 : i + 4]),
2131
+ }
2132
+ for i in range(0, len(payload), 4)
2133
+ ]
2134
+
2135
+ return parse_valve_demand(payload[2:]) # TODO: check UFC/FC is == CTL/FC
2136
+
2137
+
2138
+ # fan state (ventilation status), HVAC
2139
+ def parser_31d9(payload: str, msg: Message) -> dict[str, Any]:
2140
+ # NOTE: Itho and ClimaRad use 0x00-C8 for %, whilst Nuaire uses 0x00-64
2141
+ try:
2142
+ assert payload[4:6] == "FF" or int(payload[4:6], 16) <= 200, (
2143
+ f"byte 2: {payload[4:6]}"
2144
+ )
2145
+ except AssertionError as err:
2146
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
2147
+
2148
+ bitmap = int(payload[2:4], 16)
2149
+
2150
+ # NOTE: 31D9[4:6] is fan_speed (ClimaRad minibox, Itho) *or* fan_mode (Orcon, Vasco)
2151
+ result = {
2152
+ **parse_exhaust_fan_speed(payload[4:6]), # for itho
2153
+ SZ_FAN_MODE: payload[4:6], # orcon, vasco/climarad
2154
+ "passive": bool(bitmap & 0x02),
2155
+ "damper_only": bool(bitmap & 0x04), # i.e. valve only
2156
+ "filter_dirty": bool(bitmap & 0x20),
2157
+ "frost_cycle": bool(bitmap & 0x40),
2158
+ "has_fault": bool(bitmap & 0x80),
2159
+ "_flags": hex_to_flag8(payload[2:4]),
2160
+ }
2161
+
2162
+ # Fan Mode Lookup 1 for Vasco codes
2163
+ if msg.len == 3: # usu: I -->20: (no seq#)
2164
+ if (
2165
+ (payload[:4] == "0000" or payload[:4] == "0080") # Senza, meaning of 0x80?
2166
+ and msg._addrs[0] == msg._addrs[2]
2167
+ and msg._addrs[1] == NON_DEV_ADDR
2168
+ ):
2169
+ # _31D9_FAN_INFO for Vasco D60 HRU and ClimaRad Minibox, S-Fan, (REM: RQ only, msg.len==1)
2170
+ try:
2171
+ assert int(payload[4:6], 16) & 0xFF in _31D9_FAN_INFO_VASCO, (
2172
+ f"unknown 31D9 fan_mode lookup key: {payload[4:6]}"
2173
+ )
2174
+ except AssertionError as err:
2175
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
2176
+ fan_mode = _31D9_FAN_INFO_VASCO.get(
2177
+ int(payload[4:6], 16) & 0xFF, f"unknown_{payload[4:6]}"
2178
+ )
2179
+ result[SZ_FAN_MODE] = fan_mode # replace
2180
+ # if not replaced, 31D9 FAN_MODE is a 2 digit string HEX
2181
+ return result
2182
+
2183
+ try:
2184
+ assert payload[6:8] in ("00", "07", "0A", "FE"), f"byte 3: {payload[6:8]}"
2185
+ except AssertionError as err:
2186
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
2187
+
2188
+ result.update({"_unknown_3": payload[6:8]})
2189
+
2190
+ if msg.len == 4: # usu: I -->20: (no seq#)
2191
+ return result
2192
+
2193
+ try:
2194
+ assert payload[8:32] in ("00" * 12, "20" * 12), f"byte 4: {payload[8:32]}"
2195
+ assert payload[32:] in ("00", "04", "08"), f"byte 16: {payload[32:]}"
2196
+ except AssertionError as err:
2197
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
2198
+
2199
+ return {
2200
+ **result,
2201
+ "_unknown_4": payload[8:32],
2202
+ "unknown_16": payload[32:],
2203
+ }
2204
+
2205
+
2206
+ # ventilation state (extended), HVAC
2207
+ def parser_31da(payload: str, msg: Message) -> PayDictT._31DA:
2208
+ # see: https://github.com/python/typing/issues/1445
2209
+ return { # type: ignore[typeddict-unknown-key]
2210
+ **parse_exhaust_fan_speed(payload[38:40]), # maybe 31D9[4:6] for some?
2211
+ **parse_fan_info(payload[36:38]), # 22F3-ish
2212
+ #
2213
+ **parse_air_quality(payload[2:6]), # 12C8[2:6]
2214
+ **parse_co2_level(payload[6:10]), # 1298[2:6]
2215
+ **parse_indoor_humidity(payload[10:12]), # 12A0?
2216
+ **parse_outdoor_humidity(payload[12:14]),
2217
+ **parse_exhaust_temp(payload[14:18]), # to outside
2218
+ **parse_supply_temp(payload[18:22]), # to home
2219
+ **parse_indoor_temp(payload[22:26]), # in home
2220
+ **parse_outdoor_temp(payload[26:30]), # 1290?
2221
+ **parse_capabilities(payload[30:34]),
2222
+ **parse_bypass_position(payload[34:36]), # 22F7-ish
2223
+ **parse_supply_fan_speed(payload[40:42]),
2224
+ **parse_remaining_mins(payload[42:46]), # mins, ~22F3[2:6]
2225
+ **parse_post_heater(payload[46:48]),
2226
+ **parse_pre_heater(payload[48:50]),
2227
+ **parse_supply_flow(payload[50:54]), # NOTE: is supply, not exhaust
2228
+ **parse_exhaust_flow(payload[54:58]), # NOTE: order switched from others
2229
+ }
2230
+
2231
+ # From an Orcon 15RF Display
2232
+ # 1 Software version
2233
+ # 4 RH value in home (%) SZ_INDOOR_HUMIDITY
2234
+ # 5 RH value supply air (%) SZ_OUTDOOR_HUMIDITY
2235
+ # 6 Exhaust air temperature out (°C) SZ_EXHAUST_TEMPERATURE
2236
+ # 7 Supply air temperature to home (°C) SZ_SUPPLY_TEMPERATURE
2237
+ # 8 Temperature from home (°C) SZ_INDOOR_TEMPERATURE
2238
+ # 9 Temperature outside (°C) SZ_OUTDOOR_TEMPERATURE
2239
+ # 10 Bypass position SZ_BYPASS_POSITION
2240
+ # 11 Exhaust fan speed (%) SZ_EXHAUST_FAN_SPEED
2241
+ # 12 Fan supply speed (%) SZ_SUPPLY_FAN_SPEED
2242
+ # 13 Remaining after run time (min.) SZ_REMAINING_TIME - for humidity scenario
2243
+ # 14 Preheater control (MaxComfort) (%) SZ_PRE_HEAT
2244
+ # 16 Actual supply flow rate (m3/h) SZ_SUPPLY_FLOW (Orcon is m3/h, data is L/s)
2245
+ # 17 Current discharge flow rate (m3/h) SZ_EXHAUST_FLOW
2246
+
2247
+
2248
+ # vent_demand, HVAC
2249
+ def parser_31e0(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
2250
+ """Notes are.
2251
+
2252
+ van means “of”.
2253
+ - 0 = min. van min. potm would be:
2254
+ - 0 = minimum of minimum potentiometer
2255
+
2256
+ See: https://www.industrialcontrolsonline.com/honeywell-t991a
2257
+ - modulates air temperatures in ducts
2258
+
2259
+ case 0x31E0: ' 12768:
2260
+ {
2261
+ string str4;
2262
+ unchecked
2263
+ {
2264
+ result.Fan = Conversions.ToString((double)(int)data[checked(start + 1)] / 2.0);
2265
+ str4 = "";
2266
+ }
2267
+ str4 = (data[start + 2] & 0xF) switch
2268
+ {
2269
+ 0 => str4 + "0 = min. potm. ",
2270
+ 1 => str4 + "0 = min. van min. potm ",
2271
+ 2 => str4 + "0 = min. fan ",
2272
+ _ => "",
2273
+ };
2274
+ switch (data[start + 2] & 0xF0)
2275
+ {
2276
+ case 16:
2277
+ str4 += "100 = max. potm";
2278
+ break;
2279
+ case 32:
2280
+ str4 += "100 = max. van max. potm ";
2281
+ break;
2282
+ case 48:
2283
+ str4 += "100 = max. fan ";
2284
+ break;
2285
+ }
2286
+ result.Data = str4;
2287
+ break;
2288
+ }
2289
+ """
2290
+
2291
+ # .I --- 37:005302 32:132403 --:------ 31E0 008 00-0000-00 01-0064-00 # RF15 CO2 to Orcon HRC400 series SmartComfort Valve
2292
+
2293
+ # .I --- 29:146052 32:023459 --:------ 31E0 003 00-0000
2294
+ # .I --- 29:146052 32:023459 --:------ 31E0 003 00-00C8
2295
+
2296
+ # .I --- 32:168240 30:079129 --:------ 31E0 004 00-0000-FF
2297
+ # .I --- 32:168240 30:079129 --:------ 31E0 004 00-0000-FF
2298
+ # .I --- 32:166025 --:------ 30:079129 31E0 004 00-0000-00
2299
+
2300
+ # .I --- 32:168090 30:082155 --:------ 31E0 004 00-00C8-00
2301
+ # .I --- 37:258565 37:261128 --:------ 31E0 004 00-0001-00
2302
+
2303
+ def _parser(seqx: str) -> dict:
2304
+ assert seqx[6:] in ("", "00", "FF")
2305
+ return {
2306
+ # "hvac_idx": seqx[:2],
2307
+ "flags": seqx[2:4],
2308
+ "vent_demand": hex_to_percent(seqx[4:6]),
2309
+ "_unknown_3": payload[6:],
2310
+ }
2311
+
2312
+ if len(payload) > 8:
2313
+ return [_parser(payload[x : x + 8]) for x in range(0, len(payload), 8)]
2314
+ return _parser(payload)
2315
+
2316
+
2317
+ # supplied boiler water (flow) temp
2318
+ def parser_3200(payload: str, msg: Message) -> PayDictT._3200:
2319
+ return {SZ_TEMPERATURE: hex_to_temp(payload[2:])}
2320
+
2321
+
2322
+ # return (boiler) water temp
2323
+ def parser_3210(payload: str, msg: Message) -> PayDictT._3210:
2324
+ return {SZ_TEMPERATURE: hex_to_temp(payload[2:])}
2325
+
2326
+
2327
+ # opentherm_msg, from OTB (and some RND)
2328
+ def parser_3220(payload: str, msg: Message) -> dict[str, Any]:
2329
+ try:
2330
+ ot_type, ot_id, ot_value, ot_schema = decode_frame(payload[2:10])
2331
+ except AssertionError as err:
2332
+ raise AssertionError(f"OpenTherm: {err}") from err
2333
+ except ValueError as err:
2334
+ raise exc.PacketPayloadInvalid(f"OpenTherm: {err}") from err
2335
+
2336
+ # NOTE: Unknown-DataId isn't an invalid payload & is useful to train the OTB device
2337
+ if ot_schema is None and ot_type != OtMsgType.UNKNOWN_DATAID: # type: ignore[unreachable]
2338
+ raise exc.PacketPayloadInvalid(
2339
+ f"OpenTherm: Unknown data-id: 0x{ot_id:02X} ({ot_id})"
2340
+ )
2341
+
2342
+ result = {
2343
+ SZ_MSG_ID: ot_id,
2344
+ SZ_MSG_TYPE: str(ot_type),
2345
+ SZ_MSG_NAME: ot_value.pop(SZ_MSG_NAME, None),
2346
+ }
2347
+
2348
+ if msg.verb == RQ: # RQs have a context: msg_id (and a payload)
2349
+ assert (
2350
+ ot_type != OtMsgType.READ_DATA
2351
+ or payload[6:10] == "0000" # likely true for RAMSES
2352
+ ), f"OpenTherm: Invalid msg-type|data-value: {ot_type}|{payload[6:10]}"
2353
+
2354
+ if ot_type != OtMsgType.READ_DATA:
2355
+ assert ot_type in (
2356
+ OtMsgType.WRITE_DATA,
2357
+ OtMsgType.INVALID_DATA,
2358
+ ), f"OpenTherm: Invalid msg-type for RQ: {ot_type}"
2359
+
2360
+ result.update(ot_value) # TODO: find some of these packets to review
2361
+
2362
+ result[SZ_DESCRIPTION] = ot_schema.get(EN)
2363
+ return result
2364
+
2365
+ # if msg.verb != RP:
2366
+ # raise
2367
+
2368
+ _LIST = (OtMsgType.DATA_INVALID, OtMsgType.UNKNOWN_DATAID, OtMsgType.RESERVED)
2369
+ assert ot_type not in _LIST or payload[6:10] in (
2370
+ "0000",
2371
+ "FFFF",
2372
+ ), f"OpenTherm: Invalid msg-type|data-value: {ot_type}|{payload[6:10]}"
2373
+
2374
+ # HACK: These OT data id can pop in/out of 47AB, which is an invalid value
2375
+ if payload[6:] == "47AB" and ot_id in (0x12, 0x13, 0x19, 0x1A, 0x1B, 0x1C):
2376
+ ot_value[SZ_VALUE] = None
2377
+ # HACK: This OT data id can be 1980, which is an invalid value
2378
+ if payload[6:] == "1980" and ot_id: # CH pressure is 25.5 bar!
2379
+ ot_value[SZ_VALUE] = None
2380
+ # HACK: Done above, not in OT.decode_frame() as they isn't in the OT specification
2381
+
2382
+ if ot_type not in _LIST:
2383
+ assert ot_type in (
2384
+ OtMsgType.READ_ACK,
2385
+ OtMsgType.WRITE_ACK,
2386
+ ), f"OpenTherm: Invalid msg-type for RP: {ot_type}"
2387
+
2388
+ result.update(ot_value)
2389
+
2390
+ try: # These are checking flags in payload of data-id 0x00
2391
+ assert ot_id != 0 or (
2392
+ [result[SZ_VALUE][i] for i in (2, 3, 4, 5, 6, 7)] == [0] * 6
2393
+ # and [result[SZ_VALUE][i] for i in (1, )] == [1]
2394
+ ), result[SZ_VALUE]
2395
+
2396
+ assert ot_id != 0 or (
2397
+ [result[SZ_VALUE][8 + i] for i in (0, 4, 5, 6, 7)] == [0] * 5
2398
+ # and [result[SZ_VALUE][8 + i] for i in (1, 2, 3)] == [0] * 3
2399
+ ), result[SZ_VALUE]
2400
+
2401
+ except AssertionError:
2402
+ _LOGGER.warning(
2403
+ f"{msg!r} < {_INFORM_DEV_MSG}, with a description of your system"
2404
+ )
2405
+
2406
+ result[SZ_DESCRIPTION] = ot_schema.get(EN)
2407
+ return result
2408
+
2409
+
2410
+ # unknown_3221, from OTB, FAN
2411
+ def parser_3221(payload: str, msg: Message) -> dict[str, Any]:
2412
+ # RP --- 10:052644 18:198151 --:------ 3221 002 000F
2413
+ # RP --- 10:048122 18:006402 --:------ 3221 002 0000
2414
+ # RP --- 32:155617 18:005904 --:------ 3221 002 000A
2415
+
2416
+ assert int(payload[2:], 16) <= 0xC8, _INFORM_DEV_MSG
2417
+
2418
+ return {
2419
+ "_payload": payload,
2420
+ SZ_VALUE: int(payload[2:], 16),
2421
+ }
2422
+
2423
+
2424
+ # WIP: unknown, HVAC
2425
+ def parser_3222(payload: str, msg: Message) -> dict[str, Any]:
2426
+ assert payload[:2] == "00"
2427
+
2428
+ # e.g. RP|3222|00FE00 (payload = 3 bytes)
2429
+ if msg.len == 3:
2430
+ assert payload[4:] == "00" # aka length 0
2431
+
2432
+ return {
2433
+ "_value": f"0x{payload[2:4]}",
2434
+ }
2435
+
2436
+ # e.g. RP|3222|000604000F100E (payload > 3 bytes)
2437
+ return {
2438
+ "offset": f"0x{payload[2:4]}", # bytes
2439
+ "length": f"0x{payload[4:6]}", # bytes
2440
+ "_data": f"{'..' * int(payload[2:4])}{payload[6:]}",
2441
+ }
2442
+
2443
+
2444
+ # unknown_3223, from OTB
2445
+ def parser_3223(payload: str, msg: Message) -> dict[str, Any]:
2446
+ assert int(payload[2:], 16) <= 0xC8, _INFORM_DEV_MSG
2447
+
2448
+ return {
2449
+ "_payload": payload,
2450
+ SZ_VALUE: int(payload[2:], 16),
2451
+ }
2452
+
2453
+
2454
+ # actuator_sync (aka sync_tpi: TPI cycle sync)
2455
+ def parser_3b00(payload: str, msg: Message) -> PayDictT._3B00:
2456
+ # system timing master: the device that sends I/FCC8 pkt controls the heater relay
2457
+ """Decode a 3B00 packet (actuator_sync).
2458
+
2459
+ The heat relay regularly broadcasts a 3B00 at the end(?) of every TPI cycle, the
2460
+ frequency of which is determined by the (TPI) cycle rate in 1100.
2461
+
2462
+ The CTL subsequently broadcasts a 3B00 (i.e. at the start of every TPI cycle).
2463
+
2464
+ The OTB does not send these packets, but the CTL sends a regular broadcast anyway
2465
+ for the benefit of any zone actuators (e.g. zone valve zones).
2466
+ """
2467
+
2468
+ # 053 I --- 13:209679 --:------ 13:209679 3B00 002 00C8
2469
+ # 045 I --- 01:158182 --:------ 01:158182 3B00 002 FCC8
2470
+ # 052 I --- 13:209679 --:------ 13:209679 3B00 002 00C8
2471
+ # 045 I --- 01:158182 --:------ 01:158182 3B00 002 FCC8
2472
+
2473
+ # 063 I --- 01:078710 --:------ 01:078710 3B00 002 FCC8
2474
+ # 064 I --- 01:078710 --:------ 01:078710 3B00 002 FCC8
2475
+
2476
+ def complex_idx(payload: str, msg: Message) -> dict: # has complex idx
2477
+ if (
2478
+ msg.verb == I_
2479
+ and msg.src.type in (DEV_TYPE_MAP.CTL, DEV_TYPE_MAP.PRG)
2480
+ and msg.src is msg.dst
2481
+ ): # DEX
2482
+ assert payload[:2] == FC
2483
+ return {SZ_DOMAIN_ID: FC}
2484
+ assert payload[:2] == "00"
2485
+ return {}
2486
+
2487
+ assert msg.len == 2, msg.len
2488
+ assert payload[:2] == {
2489
+ DEV_TYPE_MAP.CTL: FC,
2490
+ DEV_TYPE_MAP.BDR: "00",
2491
+ DEV_TYPE_MAP.PRG: FC,
2492
+ }.get(msg.src.type, "00") # DEX
2493
+ assert payload[2:] == "C8", payload[2:] # Could it be a percentage?
2494
+
2495
+ return {
2496
+ **complex_idx(payload[:2], msg), # type: ignore[typeddict-item]
2497
+ "actuator_sync": hex_to_bool(payload[2:]),
2498
+ }
2499
+
2500
+
2501
+ # actuator_state
2502
+ def parser_3ef0(payload: str, msg: Message) -> PayDictT._3EF0 | PayDictT._JASPER:
2503
+ result: dict[str, Any]
2504
+
2505
+ if msg.src.type == DEV_TYPE_MAP.JIM: # Honeywell Jasper
2506
+ assert msg.len == 20, f"expecting len 20, got: {msg.len}"
2507
+ return {
2508
+ "ordinal": f"0x{payload[2:8]}",
2509
+ "blob": payload[8:],
2510
+ }
2511
+
2512
+ # TODO: These two should be picked up by the regex
2513
+ assert msg.len in (3, 6, 9), f"Invalid payload length: {msg.len}"
2514
+ # assert payload[:2] == "00", f"Invalid payload context: {payload[:2]}"
2515
+
2516
+ # NOTE: some [2:4] appear to intend 0x00-0x64 (high_res=False), instead of 0x00-0xC8
2517
+ # NOTE: for best compatibility, all will be switched to 0x00-0xC8 (high_res=True)
2518
+
2519
+ if msg.len == 3: # I|BDR|003 (the following are the only two payloads ever seen)
2520
+ # .I --- 13:042805 --:------ 13:042805 3EF0 003 0000FF
2521
+ # .I --- 13:023770 --:------ 13:023770 3EF0 003 00C8FF
2522
+ assert payload[2:4] in ("00", "C8"), f"byte 1: {payload[2:4]} (not 00/C8)"
2523
+ assert payload[4:6] == "FF", f"byte 2: {payload[4:6]} (not FF)"
2524
+ mod_level = hex_to_percent(payload[2:4], high_res=True)
2525
+
2526
+ else: # msg.len >= 6: # RP|OTB|006 (to RQ|CTL/HGI/RFG)
2527
+ # RP --- 10:004598 34:003611 --:------ 3EF0 006 0000100000FF
2528
+ # RP --- 10:004598 34:003611 --:------ 3EF0 006 0000110000FF
2529
+ # RP --- 10:138822 01:187666 --:------ 3EF0 006 0064100C00FF
2530
+ # RP --- 10:138822 01:187666 --:------ 3EF0 006 0064100200FF
2531
+ assert payload[4:6] in ("00", "10", "11"), f"byte 2: {payload[4:6]}"
2532
+ mod_level = hex_to_percent(payload[2:4], high_res=True) # 00-64/C8 (or FF)
2533
+
2534
+ result = {
2535
+ "modulation_level": mod_level, # 0008[2:4], 3EF1[10:12]
2536
+ "_flags_2": payload[4:6],
2537
+ }
2538
+
2539
+ if msg.len >= 6: # RP|OTB|006 (to RQ|CTL/HGI/RFG)
2540
+ # RP --- 10:138822 01:187666 --:------ 3EF0 006 000110FA00FF # ?corrupt
2541
+
2542
+ # for OTB (there's no reliable) modulation_level <-> flame_state)
2543
+
2544
+ result.update(
2545
+ {
2546
+ "_flags_3": hex_to_flag8(payload[6:8]),
2547
+ "ch_active": bool(int(payload[6:8], 0x10) & 1 << 1),
2548
+ "dhw_active": bool(int(payload[6:8], 0x10) & 1 << 2),
2549
+ "cool_active": bool(int(payload[6:8], 0x10) & 1 << 4),
2550
+ "flame_on": bool(int(payload[6:8], 0x10) & 1 << 3), # flame_on
2551
+ "_unknown_4": payload[8:10], # FF, 00, 01, 0A
2552
+ "_unknown_5": payload[10:12], # FF, 13, 1C, ?others
2553
+ } # TODO: change to flame_active?
2554
+ )
2555
+
2556
+ if msg.len >= 9: # I/RP|OTB|009 (R8820A only?)
2557
+ assert int(payload[12:14], 16) & 0b11111100 == 0, f"byte 6: {payload[12:14]}"
2558
+ assert int(payload[12:14], 16) & 0b00000010 == 2, f"byte 6: {payload[12:14]}"
2559
+ assert 10 <= int(payload[14:16], 16) <= 90, f"byte 7: {payload[14:16]}"
2560
+ assert int(payload[16:18], 16) in (0, 100), f"byte 8: {payload[18:]}"
2561
+
2562
+ result.update(
2563
+ {
2564
+ "_flags_6": hex_to_flag8(payload[12:14]),
2565
+ "ch_enabled": bool(int(payload[12:14], 0x10) & 1 << 0),
2566
+ "ch_setpoint": int(payload[14:16], 0x10),
2567
+ "max_rel_modulation": hex_to_percent(payload[16:18], high_res=True),
2568
+ }
2569
+ )
2570
+
2571
+ try: # Trying to decode flags...
2572
+ # assert payload[4:6] != "11" or (
2573
+ # payload[2:4] == "00"
2574
+ # ), f"bytes 1+2: {payload[2:6]}" # 97% is 00 when 11, but not always
2575
+
2576
+ assert payload[4:6] in ("00", "10", "11", "FF"), f"byte 2: {payload[4:6]}"
2577
+
2578
+ assert "_flags_3" not in result or (
2579
+ payload[6:8] == "FF" or int(payload[6:8], 0x10) & 0b10100000 == 0
2580
+ ), f"byte 3: {result['_flags_3']}"
2581
+ # only 10:040239 does 0b01000000, only Itho Autotemp does 0b00010000
2582
+
2583
+ assert "_unknown_4" not in result or (
2584
+ payload[8:10] in ("FF", "00", "01", "02", "04", "0A")
2585
+ ), f"byte 4: {payload[8:10]}"
2586
+ # only 10:040239 does 04
2587
+
2588
+ assert "_unknown_5" not in result or (
2589
+ payload[10:12] in ("00", "13", "1C", "FF")
2590
+ ), f"byte 5: {payload[10:12]}"
2591
+
2592
+ assert "_flags_6" not in result or (
2593
+ int(payload[12:14], 0x10) & 0b11111100 == 0
2594
+ ), f"byte 6: {result['_flags_6']}"
2595
+
2596
+ except AssertionError as err:
2597
+ _LOGGER.warning(
2598
+ f"{msg!r} < {_INFORM_DEV_MSG} ({err}), with a description of your system"
2599
+ )
2600
+ return result # type: ignore[return-value]
2601
+
2602
+
2603
+ # actuator_cycle
2604
+ def parser_3ef1(payload: str, msg: Message) -> PayDictT._3EF1 | PayDictT._JASPER:
2605
+ if msg.src.type == DEV_TYPE_MAP.JIM: # Honeywell Jasper, DEX
2606
+ assert msg.len == 18, f"expecting len 18, got: {msg.len}"
2607
+ return {
2608
+ "ordinal": f"0x{payload[2:8]}",
2609
+ "blob": payload[8:],
2610
+ }
2611
+
2612
+ if (
2613
+ msg.src.type == DEV_TYPE_MAP.JST
2614
+ ): # and msg.len == 12: # or (12, 20) Japser, DEX
2615
+ assert msg.len == 12, f"expecting len 12, got: {msg.len}"
2616
+ return {
2617
+ "ordinal": f"0x{payload[2:8]}",
2618
+ "blob": payload[8:],
2619
+ }
2620
+
2621
+ percent = hex_to_percent(payload[10:12])
2622
+
2623
+ if payload[12:] == "FF": # is BDR
2624
+ assert percent is None or percent in (0, 1), f"byte 5: {payload[10:12]}"
2625
+
2626
+ else: # is OTB
2627
+ # assert (
2628
+ # re.compile(r"^00[0-9A-F]{10}10").match(payload)
2629
+ # ), "doesn't match: " + r"^00[0-9A-F]{10}10"
2630
+ assert payload[2:6] == "7FFF", f"byte 1: {payload[2:6]}"
2631
+ assert payload[6:10] == "003C", f"byte 3: {payload[6:10]}" # 60 seconds
2632
+ assert percent is None or percent <= 1, f"byte 5: {payload[10:12]}"
2633
+
2634
+ cycle_countdown = None if payload[2:6] == "7FFF" else int(payload[2:6], 16)
2635
+ if cycle_countdown is not None:
2636
+ if cycle_countdown > 0x7FFF:
2637
+ cycle_countdown -= 0x10000
2638
+ assert cycle_countdown < 7200, f"byte 1: {payload[2:6]}" # 7200 seconds
2639
+
2640
+ actuator_countdown = None if payload[6:10] == "7FFF" else int(payload[6:10], 16)
2641
+ if actuator_countdown is not None:
2642
+ if actuator_countdown > 0x7FFF: # "87B3", "9DFA", "DCE1", "E638", "F8F7"
2643
+ # actuator_countdown = 0x10000 - actuator_countdown + cycle_countdown
2644
+ actuator_countdown = cycle_countdown # Needs work
2645
+ # assert actuator_countdown <= cycle_countdown, f"byte 3: {payload[6:10]}"
2646
+
2647
+ return {
2648
+ "modulation_level": percent, # 0008[2:4], 3EF0[2:4]
2649
+ "actuator_countdown": actuator_countdown,
2650
+ "cycle_countdown": cycle_countdown,
2651
+ "_unknown_0": payload[12:],
2652
+ }
2653
+
2654
+
2655
+ # timestamp, HVAC
2656
+ def parser_4401(payload: str, msg: Message) -> dict[str, Any]:
2657
+ if msg.verb == RP:
2658
+ return {}
2659
+
2660
+ # 2022-07-28T14:21:38.895354 095 W --- 37:010164 37:010151 --:------ 4401 020 10 7E-E99E90C8 00-E99E90C7-3BFF 7E-E99E90C8-000B
2661
+ # 2022-07-28T14:21:57.414447 076 RQ --- 20:225479 20:257336 --:------ 4401 020 10 2E-E99E90DB 00-00000000-0000 00-00000000-000B
2662
+ # 2022-07-28T14:21:57.625474 045 I --- 20:257336 20:225479 --:------ 4401 020 10 2E-E99E90DB 00-E99E90DA-F0FF BD-00000000-000A
2663
+ # 2022-07-28T14:22:02.932576 088 RQ --- 37:010188 20:257336 --:------ 4401 020 10 22-E99E90E0 00-00000000-0000 00-00000000-000B
2664
+ # 2022-07-28T14:22:03.053744 045 I --- 20:257336 37:010188 --:------ 4401 020 10 22-E99E90E0 00-E99E90E0-75FF BD-00000000-000A
2665
+ # 2022-07-28T14:22:20.516363 045 RQ --- 20:255710 20:257400 --:------ 4401 020 10 0B-E99E90F2 00-00000000-0000 00-00000000-000B
2666
+ # 2022-07-28T14:22:20.571640 085 I --- 20:255251 20:229597 --:------ 4401 020 10 39-E99E90F1 00-E99E90F1-5CFF 40-00000000-000A
2667
+ # 2022-07-28T14:22:20.648696 058 I --- 20:257400 20:255710 --:------ 4401 020 10 0B-E99E90F2 00-E99E90F1-D4FF DA-00000000-000B
2668
+
2669
+ # 2022-11-03T23:00:04.854479 088 RQ --- 20:256717 37:013150 --:------ 4401 020 10 00-00259261 00-00000000-0000 00-00000000-0063
2670
+ # 2022-11-03T23:00:05.102491 045 I --- 37:013150 20:256717 --:------ 4401 020 10 00-00259261 00-000C9E4C-1800 00-00000000-0063
2671
+ # 2022-11-03T23:00:17.820659 072 I --- 20:256112 20:255825 --:------ 4401 020 10 00-00F1EB91 00-00E8871B-B700 00-00000000-0063
2672
+ # 2022-11-03T23:01:25.495391 065 I --- 20:257732 20:257680 --:------ 4401 020 10 00-002E9C98 00-00107923-9E00 00-00000000-0063
2673
+ # 2022-11-03T23:01:33.753467 066 RQ --- 20:257732 20:256112 --:------ 4401 020 10 00-0010792C 00-00000000-0000 00-00000000-0063
2674
+ # 2022-11-03T23:01:33.997485 072 I --- 20:256112 20:257732 --:------ 4401 020 10 00-0010792C 00-00E88767-AD00 00-00000000-0063
2675
+ # 2022-11-03T23:01:52.391989 090 I --- 20:256717 20:255301 --:------ 4401 020 10 00-009870E1 00-002592CC-6300 00-00000000-0063
2676
+
2677
+ def hex_to_epoch(seqx: str) -> None | str: # seconds since 1-1-1970
2678
+ if seqx == "00" * 4:
2679
+ return None
2680
+ return str(
2681
+ dt.fromtimestamp(int(seqx, 16))
2682
+ ) # - int(payload[22:26], 16) * 15 * 60))
2683
+
2684
+ # 10 7E-E99E90C8 00-E99E90C7-3BFF 7E-E99E90C8-000B
2685
+ # hex(int(dt.fromisoformat("2022-07-28T14:21:38.895354").timestamp())).upper()
2686
+ # '0x62E20ED2'
2687
+
2688
+ assert payload[:2] == "10", payload[:2]
2689
+ assert payload[12:14] == "00", payload[12:14]
2690
+ assert payload[36:38] == "00", payload[36:38]
2691
+
2692
+ assert msg.verb != I_ or payload[24:26] in ("00", "7C", "FF"), payload[24:26]
2693
+ assert msg.verb != W_ or payload[24:26] in ("7C", "FF"), payload[24:26]
2694
+ assert msg.verb != RQ or payload[24:26] == "00", payload[24:26]
2695
+
2696
+ assert msg.verb != RQ or payload[14:22] == "00" * 4, payload[14:22]
2697
+ assert msg.verb != W_ or payload[28:36] != "00" * 4, payload[28:36]
2698
+
2699
+ assert payload[38:40] in ("08", "09", "0A", "0B", "63"), payload[38:40]
2700
+
2701
+ # assert payload[2:4] == payload[26:28], f"{payload[2:4]}, {payload[26:24]}"
2702
+
2703
+ return {
2704
+ "last_update_dst": payload[2:4],
2705
+ "time_dst": hex_to_epoch(payload[4:12]),
2706
+ "_unknown_12": payload[12:14], # usu.00
2707
+ "time_src": hex_to_epoch(payload[14:22]),
2708
+ "offset": payload[22:24], # *15 mins?
2709
+ "_unknown_24": payload[24:26],
2710
+ "last_update_src": payload[26:28],
2711
+ "time_dst_receive_src": hex_to_epoch(payload[28:36]),
2712
+ "_unknown_36": payload[36:38], # usu.00
2713
+ "hops_dst_src": payload[38:40],
2714
+ }
2715
+
2716
+
2717
+ # temperatures (see: 4e02) - Itho spider/autotemp
2718
+ def parser_4e01(payload: str, msg: Message) -> dict[str, Any]:
2719
+ # .I --- 02:248945 02:250708 --:------ 4E01 018 00-7FFF7FFF7FFF09077FFF7FFF7FFF7FFF-00 # 23.11, 8-group
2720
+ # .I --- 02:250984 02:250704 --:------ 4E01 018 00-7FFF7FFF7FFF7FFF08387FFF7FFF7FFF-00 # 21.04
2721
+
2722
+ num_groups = int((msg.len - 2) / 2) # e.g. (18 - 2) / 2
2723
+ assert num_groups * 2 == msg.len - 2, (
2724
+ _INFORM_DEV_MSG
2725
+ ) # num_groups: len 018 (8-group, 2+8*4), or 026 (12-group, 2+12*4)
2726
+
2727
+ x, y = 0, 2 + num_groups * 4
2728
+
2729
+ assert payload[x : x + 2] == "00", _INFORM_DEV_MSG
2730
+ assert payload[y : y + 2] == "00", _INFORM_DEV_MSG
2731
+
2732
+ return {
2733
+ "temperatures": [hex_to_temp(payload[i : i + 4]) for i in range(2, y, 4)],
2734
+ }
2735
+
2736
+
2737
+ # setpoint_bounds (see: 4e01) - Itho spider/autotemp
2738
+ def parser_4e02(
2739
+ payload: str, msg: Message
2740
+ ) -> dict[str, Any]: # sent a triplets, 1 min apart
2741
+ # .I --- 02:248945 02:250708 --:------ 4E02 034 00-7FFF7FFF7FFF07D07FFF7FFF7FFF7FFF-02-7FFF7FFF7FFF08347FFF7FFF7FFF7FFF # 20.00-21.00
2742
+ # .I --- 02:250984 02:250704 --:------ 4E02 034 00-7FFF7FFF7FFF076C7FFF7FFF7FFF7FFF-02-7FFF7FFF7FFF07D07FFF7FFF7FFF7FFF #
2743
+
2744
+ num_groups = int((msg.len - 2) / 4) # e.g. (34 - 2) / 4
2745
+ assert num_groups * 4 == msg.len - 2, (
2746
+ _INFORM_DEV_MSG
2747
+ ) # num_groups: len 034 (8-group, 2+8*4), or 050 (12-group, 2+12*4)
2748
+
2749
+ x, y = 0, 2 + num_groups * 4
2750
+
2751
+ assert payload[x : x + 2] == "00", _INFORM_DEV_MSG # expect no context
2752
+ assert payload[y : y + 2] in (
2753
+ "02",
2754
+ "03",
2755
+ "04",
2756
+ "05",
2757
+ ), _INFORM_DEV_MSG # mode: cool/heat?
2758
+
2759
+ setpoints = [
2760
+ (hex_to_temp(payload[x + i :][:4]), hex_to_temp(payload[y + i :][:4]))
2761
+ for i in range(2, y, 4)
2762
+ ] # lower, upper setpoints
2763
+
2764
+ return {
2765
+ SZ_MODE: {"02": "cool", "03": "cool+", "04": "heat", "05": "cool+"}[
2766
+ payload[y : y + 2]
2767
+ ],
2768
+ SZ_SETPOINT_BOUNDS: [s if s != (None, None) else None for s in setpoints],
2769
+ }
2770
+
2771
+
2772
+ # hvac_4e04
2773
+ def parser_4e04(payload: str, msg: Message) -> dict[str, Any]:
2774
+ MODE = {
2775
+ "00": "off",
2776
+ "01": "heat",
2777
+ "02": "cool",
2778
+ }
2779
+
2780
+ assert payload[2:4] in MODE, _INFORM_DEV_MSG
2781
+ assert int(payload[4:], 16) < 0x40 or payload[4:] in (
2782
+ "FB", # error code?
2783
+ "FC", # error code?
2784
+ "FD", # error code?
2785
+ "FE", # error code?
2786
+ "FF", # N/A?
2787
+ )
2788
+
2789
+ return {
2790
+ SZ_MODE: MODE.get(payload[2:4], "Unknown"),
2791
+ "_unknown_2": payload[4:],
2792
+ }
2793
+
2794
+
2795
+ # WIP: AT outdoor low - Itho spider/autotemp
2796
+ def parser_4e0d(payload: str, msg: Message) -> dict[str, Any]:
2797
+ # .I --- 02:250704 02:250984 --:------ 4E0D 002 0100 # Itho Autotemp: only(?) master -> slave
2798
+ # .I --- 02:250704 02:250984 --:------ 4E0D 002 0101 # why does it have a context?
2799
+
2800
+ return {
2801
+ "_payload": payload,
2802
+ }
2803
+
2804
+
2805
+ # AT fault circulation - Itho spider/autotemp
2806
+ def parser_4e14(payload: str, msg: Message) -> dict[str, Any]:
2807
+ """
2808
+ result = "AT fault circulation";
2809
+ result = (((payload[2:] & 0x01) != 0x01) ? " Fault state : no fault " : " Fault state : fault ")
2810
+ result = (((payload[2:] & 0x02) != 0x02) ? (text4 + "Circulation state : no fault ") : (text4 + " Circulation state : fault "))
2811
+ """
2812
+ return {}
2813
+
2814
+
2815
+ # wpu_state (hvac state) - Itho spider/autotemp
2816
+ def parser_4e15(payload: str, msg: Message) -> dict[str, Any]:
2817
+ # .I --- 21:034158 02:250676 --:------ 4E15 002 0000 # WPU "off" (maybe heating, but compressor off)
2818
+ # .I --- 21:064743 02:250708 --:------ 4E15 002 0001 # WPU cooling active
2819
+ # .I --- 21:057565 02:250677 --:------ 4E15 002 0002 # WPU heating, compressor active
2820
+ # .I --- 21:064743 02:250708 --:------ 4E15 002 0004 # WPU in "DHW mode" boiler active
2821
+ # .I --- 21:033160 02:250704 --:------ 4E15 002 0005 # 0x03, and 0x06 not seen in the wild
2822
+
2823
+ if int(payload[2:], 16) & 0xF0:
2824
+ pass
2825
+
2826
+ # If none of these, then is 'Off'
2827
+ SZ_COOLING = "is_cooling"
2828
+ SZ_DHW_ING = "is_dhw_ing"
2829
+ SZ_HEATING = "is_heating"
2830
+ # SZ_PUMPING = "is_pumping"
2831
+
2832
+ assert int(payload[2:], 16) & 0xF8 == 0x00, (
2833
+ _INFORM_DEV_MSG
2834
+ ) # check for unknown bit flags
2835
+ if int(payload[2:], 16) & 0x03 == 0x03: # is_cooling *and* is_heating (+/- DHW)
2836
+ raise TypeError # TODO: Use local exception & ?Move to higher layer
2837
+ assert int(payload[2:], 16) & 0x07 != 0x06, _INFORM_DEV_MSG # can't heat and DHW
2838
+
2839
+ return {
2840
+ "_flags": hex_to_flag8(payload[2:]),
2841
+ # SZ_PUMPING: bool(int(payload[2:], 16) & 0x08),
2842
+ SZ_DHW_ING: bool(int(payload[2:], 16) & 0x04),
2843
+ SZ_HEATING: bool(int(payload[2:], 16) & 0x02),
2844
+ SZ_COOLING: bool(int(payload[2:], 16) & 0x01),
2845
+ }
2846
+
2847
+
2848
+ # TODO: hvac_4e16 - Itho spider/autotemp
2849
+ def parser_4e16(payload: str, msg: Message) -> dict[str, Any]:
2850
+ # .I --- 02:250984 02:250704 --:------ 4E16 007 00000000000000 # Itho Autotemp: slave -> master
2851
+
2852
+ assert payload == "00000000000000", _INFORM_DEV_MSG
2853
+
2854
+ return {
2855
+ "_payload": payload,
2856
+ }
2857
+
2858
+
2859
+ # TODO: Fan characteristics - Itho
2860
+ def parser_4e20(payload: str, msg: Message) -> dict[str, Any]:
2861
+ """
2862
+ result = "Fan characteristics: "
2863
+ result += [C[ABC][210] hex_to_sint32[i:i+4] for i in range(2, 34, 4)]
2864
+ """
2865
+ return {}
2866
+
2867
+
2868
+ # TODO: Potentiometer control - Itho
2869
+ def parser_4e21(payload: str, msg: Message) -> dict[str, Any]:
2870
+ """
2871
+ result = "Potentiometer control: "
2872
+ result += "Rel min: " + hex_to_sint16(data[2:4]) # 16 bit, 2's complement
2873
+ result += "Min of rel min: " + hex_to_sint16(data[4:6])
2874
+ result += "Abs min: " + hex_to_sint16(data[6:8])
2875
+ result += "Rel max: " + hex_to_sint16(data[8:10])
2876
+ result += "Max rel: " + hex_to_sint16(data[10:12])
2877
+ result += "Abs max: " + hex_to_sint16(data[12:14]))
2878
+ """
2879
+ return {}
2880
+
2881
+
2882
+ # # faked puzzle pkt shouldn't be decorated
2883
+ def parser_7fff(payload: str, _: Message) -> dict[str, Any]:
2884
+ if payload[:2] != "00":
2885
+ _LOGGER.debug("Invalid/deprecated Puzzle packet")
2886
+ return {
2887
+ "msg_type": payload[:2],
2888
+ SZ_PAYLOAD: hex_to_str(payload[2:]),
2889
+ }
2890
+
2891
+ if payload[2:4] not in LOOKUP_PUZZ:
2892
+ _LOGGER.debug("Invalid/deprecated Puzzle packet")
2893
+ return {
2894
+ "msg_type": payload[2:4],
2895
+ "message": hex_to_str(payload[4:]),
2896
+ }
2897
+
2898
+ result: dict[str, None | str] = {}
2899
+ if int(payload[2:4]) >= int("20", 16):
2900
+ dtm = dt.fromtimestamp(int(payload[4:16], 16) / 1e7) # TZ-naive
2901
+ result["datetime"] = dtm.isoformat(timespec="milliseconds")
2902
+ elif payload[2:4] != "13":
2903
+ dtm = dt.fromtimestamp(int(payload[4:16], 16) / 1000) # TZ-naive
2904
+ result["datetime"] = dtm.isoformat(timespec="milliseconds")
2905
+
2906
+ msg_type = LOOKUP_PUZZ.get(payload[2:4], SZ_PAYLOAD)
2907
+
2908
+ if payload[2:4] == "11":
2909
+ mesg = hex_to_str(payload[16:])
2910
+ result[msg_type] = f"{mesg[:4]}|{mesg[4:6]}|{mesg[6:]}"
2911
+
2912
+ elif payload[2:4] == "13":
2913
+ result[msg_type] = hex_to_str(payload[4:])
2914
+
2915
+ elif payload[2:4] == "7F":
2916
+ result[msg_type] = payload[4:]
2917
+
2918
+ else:
2919
+ result[msg_type] = hex_to_str(payload[16:])
2920
+
2921
+ return {**result, "parser": f"v{VERSION}"}
2922
+
2923
+
2924
+ def parser_unknown(payload: str, msg: Message) -> dict[str, Any]:
2925
+ # TODO: it may be useful to generically search payloads for hex_ids, commands, etc.
2926
+
2927
+ # These are generic parsers
2928
+ if msg.len == 2 and payload[:2] == "00":
2929
+ return {
2930
+ "_payload": payload,
2931
+ "_value": {"00": False, "C8": True}.get(payload[2:], int(payload[2:], 16)),
2932
+ }
2933
+
2934
+ if msg.len == 3 and payload[:2] == "00":
2935
+ return {
2936
+ "_payload": payload,
2937
+ "_value": hex_to_temp(payload[2:]),
2938
+ }
2939
+
2940
+ raise NotImplementedError
2941
+
2942
+
2943
+ _PAYLOAD_PARSERS = {
2944
+ k[7:].upper(): v
2945
+ for k, v in locals().items()
2946
+ if callable(v) and k.startswith("parser_") and len(k) == 11
2947
+ }
2948
+
2949
+
2950
+ def parse_payload(msg: Message) -> dict | list[dict]:
2951
+ result: dict | list[dict]
2952
+
2953
+ result = _PAYLOAD_PARSERS.get(msg.code, parser_unknown)(msg._pkt.payload, msg)
2954
+ if isinstance(result, dict) and msg.seqn.isnumeric(): # e.g. 22F1/3
2955
+ result["seqx_num"] = msg.seqn
2956
+
2957
+ return result