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
@@ -1,2673 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- #
4
- """RAMSES RF - payload processors."""
5
- from __future__ import annotations
6
-
7
- import logging
8
- import re
9
- from datetime import datetime as dt
10
- from datetime import timedelta as td
11
- from typing import Callable
12
-
13
- from .address import NON_DEV_ADDR, hex_id_to_dev_id
14
- from .const import (
15
- DEV_ROLE,
16
- DEV_ROLE_MAP,
17
- DEV_TYPE_MAP,
18
- FAULT_DEVICE_CLASS,
19
- FAULT_STATE,
20
- FAULT_TYPE,
21
- SYS_MODE_MAP,
22
- SZ_ACTUATOR,
23
- SZ_AIR_QUALITY,
24
- SZ_AIR_QUALITY_BASE,
25
- SZ_BINDINGS,
26
- SZ_BYPASS_POSITION,
27
- SZ_CHANGE_COUNTER,
28
- SZ_CO2_LEVEL,
29
- SZ_DATETIME,
30
- SZ_DEVICE_CLASS,
31
- SZ_DEVICE_ID,
32
- SZ_DEVICE_ROLE,
33
- SZ_DEVICES,
34
- SZ_DOMAIN_ID,
35
- SZ_DURATION,
36
- SZ_EXHAUST_FAN_SPEED,
37
- SZ_EXHAUST_FLOW,
38
- SZ_EXHAUST_TEMPERATURE,
39
- SZ_FAN_INFO,
40
- SZ_FAN_MODE,
41
- SZ_FRAG_LENGTH,
42
- SZ_FRAG_NUMBER,
43
- SZ_FRAGMENT,
44
- SZ_INDOOR_HUMIDITY,
45
- SZ_INDOOR_TEMPERATURE,
46
- SZ_IS_DST,
47
- SZ_LANGUAGE,
48
- SZ_MODE,
49
- SZ_NAME,
50
- SZ_OUTDOOR_HUMIDITY,
51
- SZ_OUTDOOR_TEMPERATURE,
52
- SZ_PAYLOAD,
53
- SZ_POST_HEAT,
54
- SZ_PRE_HEAT,
55
- SZ_PRESSURE,
56
- SZ_RELAY_DEMAND,
57
- SZ_REMAINING_TIME,
58
- SZ_SETPOINT,
59
- SZ_SPEED_CAP,
60
- SZ_SUPPLY_FAN_SPEED,
61
- SZ_SUPPLY_FLOW,
62
- SZ_SUPPLY_TEMPERATURE,
63
- SZ_SYSTEM_MODE,
64
- SZ_TEMPERATURE,
65
- SZ_TOTAL_FRAGS,
66
- SZ_UFH_IDX,
67
- SZ_UNKNOWN,
68
- SZ_UNTIL,
69
- SZ_VALUE,
70
- SZ_WINDOW_OPEN,
71
- SZ_ZONE_CLASS,
72
- SZ_ZONE_IDX,
73
- SZ_ZONE_MASK,
74
- SZ_ZONE_TYPE,
75
- ZON_MODE_MAP,
76
- ZON_ROLE_MAP,
77
- __dev_mode__,
78
- )
79
- from .exceptions import InvalidPayloadError
80
- from .fingerprints import check_signature
81
- from .helpers import (
82
- bool_from_hex,
83
- date_from_hex,
84
- double_from_hex,
85
- dtm_from_hex,
86
- dts_from_hex,
87
- flag8_from_hex,
88
- percent_from_hex,
89
- str_from_hex,
90
- temp_from_hex,
91
- valve_demand,
92
- )
93
- from .opentherm import EN, MSG_DESC, MSG_ID, MSG_NAME, MSG_TYPE, OtMsgType, decode_frame
94
- from .ramses import _31DA_FAN_INFO, _2411_PARAMS_SCHEMA
95
- from .version import VERSION
96
-
97
- # Kudos & many thanks to:
98
- # - Evsdd: 0404 (wow!)
99
- # - Ierlandfan: 3150, 31D9, 31DA, others
100
- # - ReneKlootwijk: 3EF0
101
- # - brucemiranda: 3EF0, others
102
- # - janvken: 10D0, 1470, 1F70, 22B0, 2411, several others
103
-
104
-
105
- # skipcq: PY-W2000
106
- from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
107
- I_,
108
- RP,
109
- RQ,
110
- W_,
111
- F6,
112
- F8,
113
- F9,
114
- FA,
115
- FB,
116
- FC,
117
- FF,
118
- )
119
-
120
- _2411_TABLE = {k: v["description"] for k, v in _2411_PARAMS_SCHEMA.items()}
121
-
122
- _INFORM_DEV_MSG = "Support the development of ramses_rf by reporting this packet"
123
-
124
- LOOKUP_PUZZ = {
125
- "10": "engine", # . # version str, e.g. v0.14.0
126
- "11": "impersonating", # pkt header, e.g. 30C9| I|03:123001 (15 characters, packed)
127
- "12": "message", # . # message only, max len is 16 ascii characters
128
- "13": "message", # . # message only, but without a timestamp, max len 22 chars
129
- "7F": "null", # . # packet is null / was nullified: payload to be ignored
130
- } # "00" is reserved
131
-
132
- DEV_MODE = __dev_mode__ and False
133
-
134
- _LOGGER = _PKT_LOGGER = logging.getLogger(__name__)
135
- if DEV_MODE:
136
- _LOGGER.setLevel(logging.DEBUG)
137
-
138
-
139
- def parser_decorator(fnc) -> Callable:
140
- def wrapper(payload, msg, **kwargs):
141
- result = fnc(payload, msg, **kwargs)
142
- if isinstance(result, dict) and msg.seqn.isnumeric(): # 22F1/3
143
- result["seqx_num"] = msg.seqn
144
- return result
145
-
146
- return wrapper
147
-
148
-
149
- @parser_decorator # rf_unknown
150
- def parser_0001(payload, msg) -> dict:
151
- # When in test mode, a 12: will send a W every 6 seconds, *on?* the second:
152
- # 12:39:56.099 061 W --- 12:010740 --:------ 12:010740 0001 005 0000000501
153
- # 12:40:02.098 061 W --- 12:010740 --:------ 12:010740 0001 005 0000000501
154
- # 12:40:08.099 058 W --- 12:010740 --:------ 12:010740 0001 005 0000000501
155
-
156
- # sent by a THM every 5s when is signal strength test mode (0505, except 1st pkt)
157
- # 13:48:38.518 080 W --- 12:010740 --:------ 12:010740 0001 005 0000000501
158
- # 13:48:45.518 074 W --- 12:010740 --:------ 12:010740 0001 005 0000000505
159
- # 13:48:50.518 077 W --- 12:010740 --:------ 12:010740 0001 005 0000000505
160
-
161
- # sent by a CTL before a rf_check
162
- # 15:12:47.769 053 W --- 01:145038 --:------ 01:145038 0001 005 FC00000505
163
- # 15:12:47.869 053 RQ --- 01:145038 13:237335 --:------ 0016 002 00FF
164
- # 15:12:47.880 053 RP --- 13:237335 01:145038 --:------ 0016 002 0017
165
-
166
- # 12:30:18.083 047 W --- 01:145038 --:------ 01:145038 0001 005 0800000505
167
- # 12:30:23.084 049 W --- 01:145038 --:------ 01:145038 0001 005 0800000505
168
-
169
- # 15:03:33.187 054 W --- 01:145038 --:------ 01:145038 0001 005 FC00000505
170
- # 15:03:38.188 063 W --- 01:145038 --:------ 01:145038 0001 005 FC00000505
171
- # 15:03:43.188 064 W --- 01:145038 --:------ 01:145038 0001 005 FC00000505
172
- # 15:13:19.757 053 W --- 01:145038 --:------ 01:145038 0001 005 FF00000505
173
- # 15:13:24.758 054 W --- 01:145038 --:------ 01:145038 0001 005 FF00000505
174
- # 15:13:29.758 068 W --- 01:145038 --:------ 01:145038 0001 005 FF00000505
175
- # 15:13:34.759 063 W --- 01:145038 --:------ 01:145038 0001 005 FF00000505
176
-
177
- # loopback (not Tx'd) by a HGI80 whenever its button is pressed
178
- # 00:22:41.540 --- I --- --:------ --:------ --:------ 0001 005 00FFFF02FF
179
- # 00:22:41.757 --- I --- --:------ --:------ --:------ 0001 005 00FFFF0200
180
- # 00:22:43.320 --- I --- --:------ --:------ --:------ 0001 005 00FFFF02FF
181
- # 00:22:43.415 --- I --- --:------ --:------ --:------ 0001 005 00FFFF0200
182
-
183
- # From a CM927:
184
- # W/--:/--:/12:/00-0000-0501 = Test transmit
185
- # W/--:/--:/12:/00-0000-0505 = Field strength
186
-
187
- if payload[2:6] in ("2000", "8000", "A000"):
188
- mode = "hvac"
189
- elif payload[2:6] in ("0000", "FFFF"):
190
- mode = "heat"
191
- else:
192
- mode = "heat"
193
-
194
- if mode == "hvac":
195
- assert payload[:2] == "00", payload[:2]
196
- # assert payload[2:4] in ("20", "80", "A0"), payload[2:4]
197
- # assert payload[4:6] == "00", payload[4:6]
198
- assert payload[8:10] in ("00", "04", "10", "20", "FF"), payload[8:10]
199
-
200
- result = {"payload": payload, "slot_num": payload[6:8]}
201
- if msg.len >= 6:
202
- result.update({"param_num": payload[10:12]})
203
- if msg.len >= 7:
204
- result.update({"next_slot_num": payload[12:14]})
205
- if msg.len >= 8:
206
- result.update(
207
- {
208
- "boolean_14": None
209
- if payload[14:16] == "FF"
210
- else bool(int(payload[14:16]))
211
- }
212
- )
213
- return result
214
-
215
- assert payload[:2] in ("00",), payload[:2]
216
- assert payload[2:6] in ("0000", "FFFF"), payload[2:6]
217
- assert payload[8:10] in ("00", "02", "05"), payload[8:10]
218
-
219
- return {
220
- SZ_PAYLOAD: "-".join((payload[:2], payload[2:6], payload[6:8], payload[8:])),
221
- }
222
-
223
-
224
- @parser_decorator # outdoor_sensor (outdoor_weather / outdoor_temperature)
225
- def parser_0002(payload, msg) -> dict:
226
- # seen with: 03:125829, 03:196221, 03:196196, 03:052382, 03:201498, 03:201565:
227
- # .I 000 03:201565 --:------ 03:201565 0002 004 03020105 # no zone_idx, domain_id
228
-
229
- # is it CODE_IDX_COMPLEX:
230
- # - 02...... for outside temp?
231
- # - 03...... for other stuff?
232
-
233
- if msg.src.type == DEV_TYPE_MAP.HCW: # payload[2:] == DEV_TYPE_MAP.HCW, DEX
234
- assert payload == "03020105"
235
- return {f"_{SZ_UNKNOWN}": payload}
236
-
237
- # if payload[6:] == "02": # msg.src.type == DEV_TYPE_MAP.OUT:
238
- return {
239
- SZ_TEMPERATURE: temp_from_hex(payload[2:6]),
240
- f"_{SZ_UNKNOWN}": payload[6:],
241
- }
242
-
243
-
244
- @parser_decorator # zone_name
245
- def parser_0004(payload, msg) -> dict:
246
- # RQ payload is zz00; limited to 12 chars in evohome UI? if "7F"*20: not a zone
247
-
248
- return {} if payload[4:] == "7F" * 20 else {SZ_NAME: str_from_hex(payload[4:])}
249
-
250
-
251
- @parser_decorator # system_zones (add/del a zone?)
252
- def parser_0005(payload, msg) -> dict | list[dict]: # TODO: needs a cleanup
253
- # .I --- 01:145038 --:------ 01:145038 0005 004 00000100
254
- # RP --- 02:017205 18:073736 --:------ 0005 004 0009001F
255
- # .I --- 34:064023 --:------ 34:064023 0005 012 000A0000-000F0000-00100000
256
-
257
- def _parser(seqx) -> dict:
258
- if msg.src.type == DEV_TYPE_MAP.UFC: # DEX, or use: seqx[2:4] == ...
259
- zone_mask = flag8_from_hex(seqx[6:8], lsb=True)
260
- elif msg.len == 3: # ATC928G1000 - 1st gen monochrome model, max 8 zones
261
- zone_mask = flag8_from_hex(seqx[4:6], lsb=True)
262
- else:
263
- zone_mask = flag8_from_hex(seqx[4:6], lsb=True) + flag8_from_hex(
264
- seqx[6:8], lsb=True
265
- )
266
- zone_class = ZON_ROLE_MAP.get(seqx[2:4], DEV_ROLE_MAP[seqx[2:4]])
267
- return {
268
- SZ_ZONE_TYPE: seqx[2:4], # TODO: ?remove & keep zone_class?
269
- SZ_ZONE_MASK: zone_mask,
270
- SZ_ZONE_CLASS: zone_class, # TODO: ?remove & keep zone_type?
271
- }
272
-
273
- if msg.verb == RQ: # RQs have a context: zone_type
274
- return {SZ_ZONE_TYPE: payload[2:4], SZ_ZONE_CLASS: DEV_ROLE_MAP[payload[2:4]]}
275
-
276
- if msg._has_array:
277
- assert (
278
- msg.verb == I_ and msg.src.type == DEV_TYPE_MAP.RND
279
- ), f"{msg!r} # expecting I/{DEV_TYPE_MAP.RND}:" # DEX
280
- return [_parser(payload[i : i + 8]) for i in range(0, len(payload), 8)]
281
-
282
- return _parser(payload)
283
-
284
-
285
- @parser_decorator # schedule_sync (any changes?)
286
- def parser_0006(payload, msg) -> dict:
287
- """Return the total number of changes to the schedules, including the DHW schedule.
288
-
289
- An RQ is sent every ~60s by a RFG100, an increase will prompt it to send a run of
290
- RQ/0404s (it seems to assume only the zones may have changed?).
291
- """
292
- # 16:10:34.288 053 RQ --- 30:071715 01:145038 --:------ 0006 001 00
293
- # 16:10:34.291 053 RP --- 01:145038 30:071715 --:------ 0006 004 00050008
294
-
295
- if payload[2:] == "FFFFFF": # RP to an invalid RQ
296
- return {}
297
-
298
- assert payload[2:4] == "05"
299
-
300
- return {
301
- SZ_CHANGE_COUNTER: int(payload[4:], 16),
302
- "_header": payload[:4],
303
- }
304
-
305
-
306
- @parser_decorator # relay_demand (domain/zone/device)
307
- def parser_0008(payload, msg) -> dict:
308
- # https://www.domoticaforum.eu/viewtopic.php?f=7&t=5806&start=105#p73681
309
- # e.g. Electric Heat Zone
310
-
311
- # .I --- 01:145038 --:------ 01:145038 0008 002 0314
312
- # .I --- 01:145038 --:------ 01:145038 0008 002 F914
313
- # .I --- 01:054173 --:------ 01:054173 0008 002 FA00
314
- # .I --- 01:145038 --:------ 01:145038 0008 002 FC14
315
-
316
- # RP --- 13:109598 18:199952 --:------ 0008 002 0000
317
- # RP --- 13:109598 18:199952 --:------ 0008 002 00C8
318
-
319
- if msg.src.type == DEV_TYPE_MAP.JST and msg.len == 13: # Honeywell Japser, DEX
320
- assert msg.len == 13, "expecting length 13"
321
- return {
322
- "ordinal": f"0x{payload[2:8]}",
323
- "blob": payload[8:],
324
- }
325
-
326
- return {SZ_RELAY_DEMAND: percent_from_hex(payload[2:4])} # 3EF0[2:4], 3EF1[10:12]
327
-
328
-
329
- @parser_decorator # relay_failsafe
330
- def parser_0009(payload, msg) -> dict | list:
331
- """The relay failsafe mode.
332
-
333
- The failsafe mode defines the relay behaviour if the RF communication is lost (e.g.
334
- when a room thermostat stops communicating due to discharged batteries):
335
- False (disabled) - if RF comms are lost, relay will be held in OFF position
336
- True (enabled) - if RF comms are lost, relay will cycle at 20% ON, 80% OFF
337
-
338
- This setting may need to be enabled to ensure prost protect mode.
339
- """
340
- # can get: 003 or 006, e.g.: FC01FF-F901FF or FC00FF-F900FF
341
- # .I --- 23:100224 --:------ 23:100224 0009 003 0100FF # 2-zone ST9520C
342
- # .I --- 10:040239 01:223036 --:------ 0009 003 000000
343
-
344
- def _parser(seqx) -> dict:
345
- assert seqx[:2] in (F9, FC) or int(seqx[:2], 16) < 16
346
- return {
347
- SZ_DOMAIN_ID if seqx[:1] == "F" else SZ_ZONE_IDX: seqx[:2],
348
- "failsafe_enabled": {"00": False, "01": True}.get(seqx[2:4]),
349
- f"{SZ_UNKNOWN}_0": seqx[4:],
350
- }
351
-
352
- if msg._has_array:
353
- return [_parser(payload[i : i + 6]) for i in range(0, len(payload), 6)]
354
-
355
- return {
356
- "failsafe_enabled": {"00": False, "01": True}.get(payload[2:4]),
357
- f"{SZ_UNKNOWN}_0": payload[4:],
358
- }
359
-
360
-
361
- @parser_decorator # zone_params (zone_config)
362
- def parser_000a(payload, msg) -> dict | list:
363
- # RQ --- 34:044203 01:158182 --:------ 000A 001 08
364
- # RP --- 01:158182 34:044203 --:------ 000A 006 081001F409C4
365
- # RQ --- 22:017139 01:140959 --:------ 000A 006 080001F40DAC
366
- # RP --- 01:140959 22:017139 --:------ 000A 006 081001F40DAC
367
-
368
- def _parser(seqx) -> dict: # null_rp: "007FFF7FFF"
369
- bitmap = int(seqx[2:4], 16)
370
- return {
371
- "min_temp": temp_from_hex(seqx[4:8]),
372
- "max_temp": temp_from_hex(seqx[8:]),
373
- "local_override": not bool(bitmap & 1),
374
- "openwindow_function": not bool(bitmap & 2),
375
- "multiroom_mode": not bool(bitmap & 16),
376
- f"_{SZ_UNKNOWN}_bitmap": f"0b{bitmap:08b}", # TODO: try W with this
377
- } # cannot determine zone_type from this information
378
-
379
- if msg._has_array: # NOTE: these arrays can span 2 pkts!
380
- return [
381
- {
382
- SZ_ZONE_IDX: payload[i : i + 2],
383
- **_parser(payload[i : i + 12]),
384
- }
385
- for i in range(0, len(payload), 12)
386
- ]
387
-
388
- if msg.verb == RQ and msg.len <= 2: # some RQs have a payload (why?)
389
- return {}
390
-
391
- assert msg.len == 6, f"{msg!r} # expecting length 006"
392
- return _parser(payload)
393
-
394
-
395
- @parser_decorator # zone_devices
396
- def parser_000c(payload, msg) -> dict:
397
- # .I --- 34:092243 --:------ 34:092243 000C 018 00-0A-7F-FFFFFF 00-0F-7F-FFFFFF 00-10-7F-FFFFFF # noqa: E501
398
- # RP --- 01:145038 18:013393 --:------ 000C 006 00-00-00-10DAFD
399
- # RP --- 01:145038 18:013393 --:------ 000C 012 01-00-00-10DAF5 01-00-00-10DAFB
400
-
401
- def complex_idx(seqx, msg) -> dict: # complex index
402
- """domain_id, zone_idx, or ufx_idx|zone_idx."""
403
-
404
- # TODO: 000C to a UFC should be ufh_ifx, not zone_idx
405
- if msg.src.type == DEV_TYPE_MAP.UFC: # DEX
406
- assert int(seqx, 16) < 8, f"invalid ufh_idx: '{seqx}' (0x00)"
407
- return {
408
- SZ_UFH_IDX: seqx,
409
- SZ_ZONE_IDX: None if payload[4:6] == "7F" else payload[4:6],
410
- }
411
-
412
- if payload[2:4] in (DEV_ROLE_MAP.DHW, DEV_ROLE_MAP.HTG):
413
- assert (
414
- int(seqx, 16) < 1 if payload[2:4] == DEV_ROLE_MAP.DHW else 2
415
- ), f"invalid _idx: '{seqx}' (0x01)"
416
- return {SZ_DOMAIN_ID: FA if payload[:2] == "00" else F9}
417
-
418
- if payload[2:4] == DEV_ROLE_MAP.APP:
419
- assert int(seqx, 16) < 1, f"invalid _idx: '{seqx}' (0x02)"
420
- return {SZ_DOMAIN_ID: FC}
421
-
422
- assert int(seqx, 16) < 16, f"invalid zone_idx: '{seqx}' (0x03)"
423
- return {SZ_ZONE_IDX: seqx}
424
-
425
- def _parser(seqx) -> dict: # TODO: assumption that all id/idx are same is wrong!
426
- assert (
427
- seqx[:2] == payload[:2]
428
- ), f"idx != {payload[:2]} (seqx = {seqx}), short={is_short_000C(payload)}"
429
- assert int(seqx[:2], 16) < 16
430
- assert seqx[4:6] == "7F" or seqx[6:] != "F" * 6, f"Bad device_id: {seqx[6:]}"
431
- return {hex_id_to_dev_id(seqx[6:12]): seqx[4:6]}
432
-
433
- def is_short_000C(payload) -> bool:
434
- """Return True if it is a short 000C (element length is 5, not 6)."""
435
-
436
- if (pkt_len := len(payload)) != 72:
437
- return pkt_len % 12 != 0
438
-
439
- # 0608-001099C3 0608-001099C5 0608-001099BF 0608-001099BE 0608-001099BD 0608-001099BC # len(element) = 6
440
- # 0508-00109901 0800-10990208 0010-99030800 1099-04080010 9905-08001099 0608-00109907 # len(element) = 5
441
- elif all(payload[i : i + 4] == payload[:4] for i in range(12, pkt_len, 12)):
442
- return False # len(element) = 6 (12)
443
-
444
- # 06 08-001099C3 06-08001099 C5-06080010 99-BF060800 10-99BE0608 00-1099BD06 08-001099BC # len(element) = 6
445
- # 05 08-00109901 08-00109902 08-00109903 08-00109904 08-00109905 08-00109906 08-00109907 # len(element) = 5
446
- elif all(payload[i : i + 2] == payload[2:4] for i in range(12, pkt_len, 10)):
447
- return True # len(element) = 5 (10)
448
-
449
- raise InvalidPayloadError("Unable to determine element length") # return None
450
-
451
- if payload[2:4] == DEV_ROLE_MAP.HTG and payload[:2] == "01":
452
- dev_role = DEV_ROLE_MAP[DEV_ROLE.HT1]
453
- else:
454
- dev_role = DEV_ROLE_MAP[payload[2:4]]
455
-
456
- result = {
457
- SZ_ZONE_TYPE: payload[2:4],
458
- **complex_idx(payload[:2], msg),
459
- SZ_DEVICE_ROLE: dev_role,
460
- }
461
- if msg.verb == RQ: # RQs have a context: index, zone_type, payload is iitt
462
- return result
463
-
464
- # NOTE: Both these are valid! So collision when len = 036!
465
- # RP --- 01:239474 18:198929 --:------ 000C 012 06-00-00119A99 06-00-00119B21
466
- # RP --- 01:069616 18:205592 --:------ 000C 011 01-00-00121B54 00-00121B52
467
- # RP --- 01:239700 18:009874 --:------ 000C 018 07-08-001099C3 07-08-001099C5 07-08-001099BF
468
- # RP --- 01:059885 18:010642 --:------ 000C 016 00-00-0011EDAA 00-0011ED92 00-0011EDA0
469
-
470
- devs = (
471
- [_parser(payload[:2] + payload[i : i + 10]) for i in range(2, len(payload), 10)]
472
- if is_short_000C(payload)
473
- else [_parser(payload[i : i + 12]) for i in range(0, len(payload), 12)]
474
- )
475
-
476
- return {
477
- **result,
478
- SZ_DEVICES: [k for d in devs for k, v in d.items() if v != "7F"],
479
- }
480
-
481
-
482
- @parser_decorator # unknown_000e, from STA
483
- def parser_000e(payload, msg) -> dict:
484
-
485
- assert payload in ("000000", "000014"), _INFORM_DEV_MSG
486
-
487
- return {
488
- SZ_PAYLOAD: payload,
489
- }
490
-
491
-
492
- @parser_decorator # rf_check
493
- def parser_0016(payload, msg) -> dict:
494
- # TODO: does 0016 include parent_idx?, but RQ/07:/0000?
495
- # RQ --- 22:060293 01:078710 --:------ 0016 002 0200
496
- # RP --- 01:078710 22:060293 --:------ 0016 002 021E
497
- # RQ --- 12:010740 01:145038 --:------ 0016 002 0800
498
- # RP --- 01:145038 12:010740 --:------ 0016 002 081E
499
- # RQ --- 07:031785 01:063844 --:------ 0016 002 0000
500
- # RP --- 01:063844 07:031785 --:------ 0016 002 002A
501
-
502
- if msg.verb == RQ: # and msg.len == 1: # TODO: some RQs have a payload
503
- return {}
504
-
505
- rf_value = int(payload[2:4], 16)
506
- return {
507
- "rf_strength": min(int(rf_value / 5) + 1, 5),
508
- "rf_value": rf_value,
509
- }
510
-
511
-
512
- @parser_decorator # language (of device/system)
513
- def parser_0100(payload, msg) -> dict:
514
-
515
- if msg.verb == RQ and msg.len == 1: # some RQs have a payload
516
- return {}
517
-
518
- return {
519
- SZ_LANGUAGE: str_from_hex(payload[2:6]),
520
- f"_{SZ_UNKNOWN}_0": payload[6:],
521
- }
522
-
523
-
524
- @parser_decorator # unknown_0150, from OTB
525
- def parser_0150(payload, msg) -> dict:
526
-
527
- assert payload == "000000", _INFORM_DEV_MSG
528
-
529
- return {
530
- SZ_PAYLOAD: payload,
531
- }
532
-
533
-
534
- @parser_decorator # unknown_01d0, from a HR91 (when its buttons are pushed)
535
- def parser_01d0(payload, msg) -> dict:
536
- # 23:57:28.869 045 W --- 04:000722 01:158182 --:------ 01D0 002 0003
537
- # 23:57:28.931 045 I --- 01:158182 04:000722 --:------ 01D0 002 0003
538
- # 23:57:31.581 048 W --- 04:000722 01:158182 --:------ 01E9 002 0003
539
- # 23:57:31.643 045 I --- 01:158182 04:000722 --:------ 01E9 002 0000
540
- # 23:57:31.749 050 W --- 04:000722 01:158182 --:------ 01D0 002 0000
541
- # 23:57:31.811 045 I --- 01:158182 04:000722 --:------ 01D0 002 0000
542
-
543
- assert payload[2:] in ("00", "03"), _INFORM_DEV_MSG
544
- return {
545
- f"{SZ_UNKNOWN}_0": payload[2:],
546
- }
547
-
548
-
549
- @parser_decorator # unknown_01e9, from a HR91 (when its buttons are pushed)
550
- def parser_01e9(payload, msg) -> dict:
551
- # 23:57:31.581348 048 W --- 04:000722 01:158182 --:------ 01E9 002 0003
552
- # 23:57:31.643188 045 I --- 01:158182 04:000722 --:------ 01E9 002 0000
553
-
554
- assert payload[2:] in ("00", "03"), _INFORM_DEV_MSG
555
- return {
556
- f"{SZ_UNKNOWN}_0": payload[2:],
557
- }
558
-
559
-
560
- @parser_decorator # zone_schedule (fragment)
561
- def parser_0404(payload, msg) -> dict:
562
- # Retreival of Zone schedule (NB: 200008)
563
- # RQ --- 30:185469 01:037519 --:------ 0404 007 00-200008-00-0100
564
- # RP --- 01:037519 30:185469 --:------ 0404 048 00-200008-29-0103-6E2...
565
- # RQ --- 30:185469 01:037519 --:------ 0404 007 00-200008-00-0203
566
- # RP --- 01:037519 30:185469 --:------ 0404 048 00-200008-29-0203-4FD...
567
- # RQ --- 30:185469 01:037519 --:------ 0404 007 00-200008-00-0303
568
- # RP --- 01:037519 30:185469 --:------ 0404 038 00-200008-1F-0303-C10...
569
-
570
- # Retreival of DHW schedule (NB: 230008)
571
- # RQ --- 30:185469 01:037519 --:------ 0404 007 00-230008-00-0100
572
- # RP --- 01:037519 30:185469 --:------ 0404 048 00-230008-29-0103-618...
573
- # RQ --- 30:185469 01:037519 --:------ 0404 007 00-230008-00-0203
574
- # RP --- 01:037519 30:185469 --:------ 0404 048 00-230008-29-0203-ED6...
575
- # RQ --- 30:185469 01:037519 --:------ 0404 007 00-230008-00-0303
576
- # RP --- 01:037519 30:185469 --:------ 0404 014 00-230008-07-0303-13F...
577
-
578
- # Write a Zone schedule...
579
- # .W --- 30:042165 01:076010 --:------ 0404 048 08-200808-29-0104-688...
580
- # .I --- 01:076010 30:042165 --:------ 0404 007 08-200808-29-0104
581
- # .W --- 30:042165 01:076010 --:------ 0404 048 08-200808-29-0204-007...
582
- # .I --- 01:076010 30:042165 --:------ 0404 007 08-200808-29-0204
583
- # .W --- 30:042165 01:076010 --:------ 0404 048 08-200808-29-0304-8DD...
584
- # .I --- 01:076010 30:042165 --:------ 0404 007 08-200808-29-0304
585
- # .W --- 30:042165 01:076010 --:------ 0404 048 08-200808-11-0404-970...
586
- # .I --- 01:076010 30:042165 --:------ 0404 007 08-200808-11-0400
587
-
588
- # RP --- 01:145038 18:013393 --:------ 0404 007 00-230008-00-01FF # no schedule
589
-
590
- assert payload[4:6] in ("00", payload[:2]), _INFORM_DEV_MSG
591
-
592
- if int(payload[8:10], 16) * 2 != (frag_length := len(payload[14:])) and (
593
- msg.verb != I_ or frag_length != 0
594
- ):
595
- raise InvalidPayloadError(f"Incorrect fragment length: 0x{payload[8:10]}")
596
-
597
- if msg.verb == RQ: # have a ctx: idx|frag_idx
598
- return {
599
- SZ_FRAG_NUMBER: int(payload[10:12], 16),
600
- SZ_TOTAL_FRAGS: None if payload[12:14] == "00" else int(payload[12:14], 16),
601
- }
602
-
603
- if msg.verb == I_: # have a ctx: idx|frag_idx
604
- return {
605
- SZ_FRAG_NUMBER: int(payload[10:12], 16),
606
- SZ_TOTAL_FRAGS: int(payload[12:14], 16),
607
- SZ_FRAG_LENGTH: None if payload[8:10] == "00" else int(payload[8:10], 16),
608
- }
609
-
610
- if payload[12:14] == FF:
611
- return {
612
- SZ_FRAG_NUMBER: int(payload[10:12], 16),
613
- SZ_TOTAL_FRAGS: None,
614
- }
615
-
616
- return {
617
- SZ_FRAG_NUMBER: int(payload[10:12], 16),
618
- SZ_TOTAL_FRAGS: int(payload[12:14], 16),
619
- SZ_FRAG_LENGTH: None if payload[8:10] == "FF" else int(payload[8:10], 16),
620
- SZ_FRAGMENT: payload[14:],
621
- }
622
-
623
-
624
- @parser_decorator # system_fault
625
- def parser_0418(payload, msg) -> dict:
626
- # RP --- 01:145038 18:013393 --:------ 0418 022 000000B006F604000000711607697FFFFF7000348A86 # COMMS FAULT, CHANGEOVER
627
- # RP --- 01:145038 18:013393 --:------ 0418 022 000000B0000000000000000000007FFFFF7000000000 # noqa: E501
628
- # RP --- 01:145038 18:013393 --:------ 0418 022 000036B0010000000000108000007FFFFF7000000000 # noqa: E501
629
- # RP --- 01:145038 18:013393 --:------ 0418 022 000000B00401010000008694A3CC7FFFFF70000ECC8A # noqa: E501
630
- # .I --- 01:037519 --:------ 01:037519 0418 022 000000B0050000000000239581877FFFFF7000000001 # Evotouch Battery Error # noqa: E501
631
- # RP --- 01:037519 18:140805 --:------ 0418 022 004024B0060006000000CB94A112FFFFFF70007AD47D # noqa: E501
632
- # 0 0 1 1 3 3
633
- # 2 8 2 8 0 8
634
-
635
- # assert int(payload[4:6], 16) < 64, f"Unexpected log_idx: 0x{payload[4:6]}"
636
-
637
- if dts_from_hex(payload[18:30]) is None: # a null log entry
638
- return {"log_entry": None}
639
-
640
- try:
641
- assert payload[2:4] in FAULT_STATE, f"fault state: {payload[2:4]}"
642
- assert payload[8:10] in FAULT_TYPE, f"fault type: {payload[8:10]}"
643
- assert payload[12:14] in FAULT_DEVICE_CLASS, f"device class: {payload[12:14]}"
644
- # 1C: 'Comms fault, Actuator': seen with boiler relays
645
- assert int(payload[10:12], 16) < 16 or (
646
- payload[10:12] in ("1C", F6, F9, FA, FC)
647
- ), f"domain id: {payload[10:12]}"
648
- except AssertionError as exc:
649
- _LOGGER.warning(
650
- f"{msg!r} < {_INFORM_DEV_MSG} ({exc}), with a photo of your fault log"
651
- )
652
-
653
- result = {
654
- "timestamp": dts_from_hex(payload[18:30]),
655
- "state": FAULT_STATE.get(payload[2:4], payload[2:4]),
656
- "type": FAULT_TYPE.get(payload[8:10], payload[8:10]),
657
- SZ_DEVICE_CLASS: FAULT_DEVICE_CLASS.get(payload[12:14], payload[12:14]),
658
- }
659
-
660
- if payload[10:12] == FC and result[SZ_DEVICE_CLASS] == SZ_ACTUATOR:
661
- result[SZ_DEVICE_CLASS] = DEV_ROLE_MAP[DEV_ROLE.APP] # actual evohome UI
662
- elif payload[10:12] == FA and result[SZ_DEVICE_CLASS] == SZ_ACTUATOR:
663
- result[SZ_DEVICE_CLASS] = DEV_ROLE_MAP[DEV_ROLE.HTG] # speculative
664
- elif payload[10:12] == F9 and result[SZ_DEVICE_CLASS] == SZ_ACTUATOR:
665
- result[SZ_DEVICE_CLASS] = DEV_ROLE_MAP[DEV_ROLE.HT1] # speculative
666
-
667
- if payload[12:14] != "00": # TODO: Controller
668
- key_name = SZ_ZONE_IDX if int(payload[10:12], 16) < 16 else SZ_DOMAIN_ID
669
- result.update({key_name: payload[10:12]})
670
-
671
- if payload[38:] == "000002": # "00:000002 for Unknown?
672
- result.update({SZ_DEVICE_ID: None})
673
- elif payload[38:] not in ("000000", "000001"): # "00:000001 for Controller?
674
- result.update({SZ_DEVICE_ID: hex_id_to_dev_id(payload[38:])})
675
-
676
- result.update(
677
- {
678
- f"_{SZ_UNKNOWN}_3": payload[6:8], # B0 ?priority
679
- f"_{SZ_UNKNOWN}_7": payload[14:18], # 0000
680
- f"_{SZ_UNKNOWN}_15": payload[30:38], # FFFF7000/1/2
681
- }
682
- )
683
-
684
- return {"log_entry": [v for k, v in result.items() if k != "log_idx"]}
685
-
686
-
687
- @parser_decorator # unknown_042f, from STA, VMS
688
- def parser_042f(payload, msg) -> dict:
689
- # .I --- 34:064023 --:------ 34:064023 042F 008 00-0000-0023-0023-F5
690
- # .I --- 34:064023 --:------ 34:064023 042F 008 00-0000-0024-0024-F5
691
- # .I --- 34:064023 --:------ 34:064023 042F 008 00-0000-0025-0025-F5
692
- # .I --- 34:064023 --:------ 34:064023 042F 008 00-0000-0026-0026-F5
693
- # .I --- 34:092243 --:------ 34:092243 042F 008 00-0001-0021-0022-01
694
- # .I 34:011469 --:------ 34:011469 042F 008 00-0001-0003-0004-BC
695
-
696
- # .I --- 32:168090 --:------ 32:168090 042F 009 00-0000100F00105050
697
- # .I --- 32:166025 --:------ 32:166025 042F 009 00-050E0B0C00111470
698
-
699
- return {
700
- "counter_1": f"0x{payload[2:6]}",
701
- "counter_3": f"0x{payload[6:10]}",
702
- "counter_5": f"0x{payload[10:14]}",
703
- f"{SZ_UNKNOWN}_7": f"0x{payload[14:]}",
704
- }
705
-
706
-
707
- @parser_decorator # TODO: unknown_0b04, from THM (only when its a CTL?)
708
- def parser_0b04(payload, msg) -> dict:
709
- # .I --- --:------ --:------ 12:207082 0B04 002 00C8 # batch of 3, every 24h
710
-
711
- return {
712
- f"{SZ_UNKNOWN}_1": payload[2:],
713
- }
714
-
715
-
716
- @parser_decorator # mixvalve_config (zone), FAN
717
- def parser_1030(payload, msg) -> dict:
718
- # .I --- 01:145038 --:------ 01:145038 1030 016 0A-C80137-C9010F-CA0196-CB0100-CC0101
719
- # .I --- --:------ --:------ 12:144017 1030 016 01-C80137-C9010F-CA0196-CB010F-CC0101
720
- # RP --- 32:155617 18:005904 --:------ 1030 007 00-200100-21011F
721
-
722
- def _parser(seqx) -> dict:
723
- assert seqx[2:4] == "01", seqx[2:4]
724
-
725
- param_name = {
726
- "20": "unknown_20", # HVAC
727
- "21": "unknown_21", # HVAC
728
- "C8": "max_flow_setpoint", # 55 (0-99) C
729
- "C9": "min_flow_setpoint", # 15 (0-50) C
730
- "CA": "valve_run_time", # 150 (0-240) sec, aka actuator_run_time
731
- "CB": "pump_run_time", # 15 (0-99) sec
732
- "CC": "boolean_cc", # ?boolean?
733
- }[seqx[:2]]
734
-
735
- return {param_name: int(seqx[4:], 16)}
736
-
737
- assert (msg.len - 1) / 3 in (2, 5), msg.len
738
- # assert payload[30:] in ("00", "01"), payload[30:]
739
-
740
- params = [_parser(payload[i : i + 6]) for i in range(2, len(payload), 6)]
741
- return {k: v for x in params for k, v in x.items()}
742
-
743
-
744
- @parser_decorator # device_battery (battery_state)
745
- def parser_1060(payload, msg) -> dict:
746
- """Return the battery state.
747
-
748
- Some devices (04:) will also report battery level.
749
- """
750
- # 06:48:23.948 049 I --- 12:010740 --:------ 12:010740 1060 003 00FF01
751
- # 16:18:43.515 051 I --- 12:010740 --:------ 12:010740 1060 003 00FF00
752
- # 16:14:44.180 054 I --- 04:056057 --:------ 04:056057 1060 003 002800
753
- # 17:34:35.460 087 I --- 04:189076 --:------ 01:145038 1060 003 026401
754
-
755
- assert msg.len == 3, msg.len
756
- assert payload[4:6] in ("00", "01")
757
-
758
- return {
759
- "battery_low": payload[4:] == "00",
760
- "battery_level": percent_from_hex(payload[2:4]),
761
- }
762
-
763
-
764
- @parser_decorator # max_ch_setpoint (supply high limit)
765
- def parser_1081(payload, msg) -> dict:
766
- return {SZ_SETPOINT: temp_from_hex(payload[2:])}
767
-
768
-
769
- @parser_decorator # unknown_1090 (non-Evohome, e.g. ST9520C)
770
- def parser_1090(payload, msg) -> dict:
771
- # 14:08:05.176 095 RP --- 23:100224 22:219457 --:------ 1090 005 007FFF01F4
772
- # 18:08:05.809 095 RP --- 23:100224 22:219457 --:------ 1090 005 007FFF01F4
773
-
774
- # this is an educated guess
775
- assert msg.len == 5, _INFORM_DEV_MSG
776
- assert int(payload[:2], 16) < 2, _INFORM_DEV_MSG
777
-
778
- return {
779
- f"{SZ_TEMPERATURE}_0": temp_from_hex(payload[2:6]),
780
- f"{SZ_TEMPERATURE}_1": temp_from_hex(payload[6:10]),
781
- }
782
-
783
-
784
- @parser_decorator # unknown_1098, from OTB
785
- def parser_1098(payload, msg) -> dict:
786
-
787
- assert payload == "00C8", _INFORM_DEV_MSG
788
-
789
- return {
790
- f"_{SZ_PAYLOAD}": payload,
791
- f"_{SZ_VALUE}": {"00": False, "C8": True}.get(
792
- payload[2:], percent_from_hex(payload[2:])
793
- ),
794
- }
795
-
796
-
797
- @parser_decorator # dhw (cylinder) params # FIXME: a bit messy
798
- def parser_10a0(payload, msg) -> dict:
799
- # RQ --- 07:045960 01:145038 --:------ 10A0 006 00-1087-00-03E4 # RQ/RP, every 24h
800
- # RP --- 01:145038 07:045960 --:------ 10A0 006 00-109A-00-03E8
801
- # RP --- 10:048122 18:006402 --:------ 10A0 003 00-1B58
802
-
803
- # these may not be reliable...
804
- # RQ --- 01:136410 10:067219 --:------ 10A0 002 0000
805
- # RQ --- 07:017494 01:078710 --:------ 10A0 006 00-1566-00-03E4
806
-
807
- # RQ --- 07:045960 01:145038 --:------ 10A0 006 00-31FF-00-31FF # null
808
- # RQ --- 07:045960 01:145038 --:------ 10A0 006 00-1770-00-03E8
809
- # RQ --- 07:045960 01:145038 --:------ 10A0 006 00-1374-00-03E4
810
- # RQ --- 07:030741 01:102458 --:------ 10A0 006 00-181F-00-03E4
811
- # RQ --- 07:036831 23:100224 --:------ 10A0 006 01-1566-00-03E4 # non-evohome
812
-
813
- # these from a RFG...
814
- # RQ --- 30:185469 01:037519 --:------ 0005 002 000E
815
- # RP --- 01:037519 30:185469 --:------ 0005 004 000E0300 # two DHW valves
816
- # RQ --- 30:185469 01:037519 --:------ 10A0 001 01 (01 )
817
-
818
- if msg.verb == RQ and msg.len == 1: # some RQs have a payload (why?)
819
- # 045 RQ --- 07:045960 01:145038 --:------ 10A0 006 0013740003E4
820
- # 037 RQ --- 18:013393 01:145038 --:------ 10A0 001 00
821
- # 054 RP --- 01:145038 18:013393 --:------ 10A0 006 0013880003E8
822
- return {}
823
-
824
- assert msg.len in (1, 3, 6), msg.len # OTB uses 3, evohome uses 6
825
- assert payload[:2] in ("00", "01"), payload[:2] # can be two DHW valves/system
826
-
827
- result = {}
828
- if msg.len >= 2:
829
- setpoint = temp_from_hex(payload[2:6]) # 255 for OTB? iff no DHW?
830
- result = {SZ_SETPOINT: None if setpoint == 255 else setpoint} # 30.0-85.0 C
831
- if msg.len >= 4:
832
- result["overrun"] = int(payload[6:8], 16) # 0-10 minutes
833
- if msg.len >= 6:
834
- result["differential"] = temp_from_hex(payload[8:12]) # 1.0-10.0 C
835
-
836
- return result
837
-
838
-
839
- @parser_decorator # unknown_10b0, from OTB
840
- def parser_10b0(payload, msg) -> dict:
841
-
842
- assert payload == "0000", _INFORM_DEV_MSG
843
-
844
- return {
845
- f"_{SZ_PAYLOAD}": payload,
846
- f"_{SZ_VALUE}": {"00": False, "C8": True}.get(
847
- payload[2:], percent_from_hex(payload[2:])
848
- ),
849
- }
850
-
851
-
852
- @parser_decorator # filter_change, HVAC
853
- def parser_10d0(payload, msg) -> dict:
854
-
855
- # 2022-07-03T22:52:34.571579 045 W --- 37:171871 32:155617 --:------ 10D0 002 00FF
856
- # 2022-07-03T22:52:34.596526 066 I --- 32:155617 37:171871 --:------ 10D0 006 0047B44F0000
857
- # then...
858
- # 2022-07-03T23:14:23.854089 000 RQ --- 37:155617 32:155617 --:------ 10D0 002 0000
859
- # 2022-07-03T23:14:23.876088 084 RP --- 32:155617 37:155617 --:------ 10D0 006 00B4B4C80000
860
-
861
- # 00-FF resets the counter, 00-47-B4-4F-0000 is the value (71 180 79).
862
- # Default is 180 180 200. The returned value is the amount of days (180),
863
- # total amount of days till change (180), percentage (200)
864
-
865
- if msg.verb == W_:
866
- result = {"reset_counter": payload[2:4] == "FF"}
867
- else:
868
- result = {"days_remaining": int(payload[2:4], 16)}
869
-
870
- if msg.len >= 3:
871
- result.update({"days_lifetime": int(payload[4:6], 16)})
872
- if msg.len >= 4:
873
- result.update({"percent_remaining": percent_from_hex(payload[6:8])})
874
-
875
- return result
876
-
877
-
878
- @parser_decorator # device_info
879
- def parser_10e0(payload, msg) -> dict:
880
- if payload == "00": # some HVAC devices wil RP|10E0|00
881
- return {}
882
-
883
- assert msg.len in (19, 28, 29, 30, 36, 38), msg.len # >= 19, msg.len
884
-
885
- payload = re.sub("(00)*$", "", payload) # remove trailing 00s
886
- assert len(payload) >= 18 * 2
887
-
888
- # if DEV_MODE: # TODO
889
- try: # DEX
890
- check_signature(msg.src.type, payload[2:20])
891
- except ValueError as exc:
892
- _LOGGER.warning(
893
- f"{msg!r} < {_INFORM_DEV_MSG}, with the make/model of device: {msg.src} ({exc})"
894
- )
895
-
896
- description, _, unknown = payload[36:].partition("00")
897
- assert msg.verb == RP or not unknown, f"{unknown}"
898
-
899
- result = {
900
- "date_2": date_from_hex(payload[20:28]) or "0000-00-00", # manufactured?
901
- "date_1": date_from_hex(payload[28:36]) or "0000-00-00", # firmware?
902
- # "manufacturer_group": payload[2:6], # 0001/0002
903
- "manufacturer_sub_id": payload[6:8],
904
- "product_id": payload[8:10], # if CH/DHW: matches device_type (sometimes)
905
- # "software_ver_id": payload[10:12],
906
- # "list_ver_id": payload[12:14], # if FF/01 is CH/DHW, then 01/FF
907
- "oem_code": payload[14:16], # 00/FF is CH/DHW, 01/6x is HVAC
908
- # # "additional_ver_a": payload[16:18],
909
- # # "additional_ver_b": payload[18:20],
910
- "description": bytearray.fromhex(description).decode(),
911
- }
912
- if msg.verb == RP and unknown: # TODO: why only OTBs do this?
913
- result[f"_{SZ_UNKNOWN}"] = unknown
914
- return result
915
-
916
-
917
- @parser_decorator # device_id
918
- def parser_10e1(payload, msg) -> dict:
919
- return {SZ_DEVICE_ID: hex_id_to_dev_id(payload[2:])}
920
-
921
-
922
- @parser_decorator # unknown_10e2 - HVAC
923
- def parser_10e2(payload, msg) -> dict:
924
- # .I --- --:------ --:------ 20:231151 10E2 003 00AD74 # every 2 minutes
925
-
926
- assert payload[:2] == "00", _INFORM_DEV_MSG
927
- assert len(payload) == 6, _INFORM_DEV_MSG
928
-
929
- return {
930
- "counter": int(payload[2:], 16),
931
- }
932
-
933
-
934
- @parser_decorator # tpi_params (domain/zone/device) # FIXME: a bit messy
935
- def parser_1100(payload, msg) -> dict:
936
- def complex_idx(seqx) -> dict:
937
- return {SZ_DOMAIN_ID: seqx} if seqx[:1] == "F" else {} # only FC
938
-
939
- if msg.src.type == DEV_TYPE_MAP.JIM: # Honeywell Japser, DEX
940
- assert msg.len == 19, msg.len
941
- return {
942
- "ordinal": f"0x{payload[2:8]}",
943
- "blob": payload[8:],
944
- }
945
-
946
- if msg.verb == RQ and msg.len == 1: # some RQs have a payload (why?)
947
- return complex_idx(payload[:2])
948
-
949
- assert int(payload[2:4], 16) / 4 in range(1, 13), payload[2:4]
950
- assert int(payload[4:6], 16) / 4 in range(1, 31), payload[4:6]
951
- assert int(payload[6:8], 16) / 4 in range(0, 16), payload[6:8]
952
-
953
- # for: TPI // heatpump
954
- # - cycle_rate: 6 (3, 6, 9, 12) // ?? (1-9)
955
- # - min_on_time: 1 (1-5) // ?? (1, 5, 10,...30)
956
- # - min_off_time: 1 (1-?) // ?? (0, 5, 10, 15)
957
-
958
- def _parser(seqx) -> dict:
959
- return {
960
- "cycle_rate": int(int(payload[2:4], 16) / 4), # cycles/hour
961
- "min_on_time": int(payload[4:6], 16) / 4, # min
962
- "min_off_time": int(payload[6:8], 16) / 4, # min
963
- f"_{SZ_UNKNOWN}_0": payload[8:10], # always 00, FF?
964
- }
965
-
966
- result = _parser(payload)
967
-
968
- if msg.len > 5:
969
- assert (
970
- payload[10:14] == "7FFF" or 1.5 <= temp_from_hex(payload[10:14]) <= 3.0
971
- ), f"unexpected value for PBW: {payload[10:14]}"
972
-
973
- result.update(
974
- {
975
- "proportional_band_width": temp_from_hex(payload[10:14]),
976
- f"_{SZ_UNKNOWN}_1": payload[14:], # always 01?
977
- }
978
- )
979
-
980
- return {
981
- **complex_idx(payload[:2]),
982
- **result,
983
- }
984
-
985
-
986
- @parser_decorator # unknown_11f0, from heatpump relay
987
- def parser_11f0(payload, msg) -> dict:
988
-
989
- assert payload == "000009000000000000", _INFORM_DEV_MSG
990
-
991
- return {
992
- SZ_PAYLOAD: payload,
993
- }
994
-
995
-
996
- @parser_decorator # dhw cylinder temperature
997
- def parser_1260(payload, msg) -> dict:
998
- return {SZ_TEMPERATURE: temp_from_hex(payload[2:])}
999
-
1000
-
1001
- @parser_decorator # outdoor humidity
1002
- def parser_1280(payload, msg) -> dict:
1003
- # educated guess - this packet never seen in the wild
1004
-
1005
- rh = percent_from_hex(payload[2:4], high_res=False)
1006
- if msg.len == 2:
1007
- return {SZ_OUTDOOR_HUMIDITY: rh}
1008
-
1009
- return {
1010
- SZ_OUTDOOR_HUMIDITY: rh,
1011
- SZ_TEMPERATURE: temp_from_hex(payload[4:8]),
1012
- "dewpoint_temp": temp_from_hex(payload[8:12]),
1013
- }
1014
-
1015
-
1016
- @parser_decorator # outdoor temperature
1017
- def parser_1290(payload, msg) -> dict:
1018
- # evohome responds to an RQ
1019
- return {SZ_TEMPERATURE: temp_from_hex(payload[2:])}
1020
-
1021
-
1022
- @parser_decorator # co2_level
1023
- def parser_1298(payload, msg) -> dict:
1024
- # .I --- 37:258565 --:------ 37:258565 1298 003 0007D0
1025
- FAULT_CODES_CO2 = {
1026
- "80": "sensor short circuit",
1027
- "81": "sensor open",
1028
- "83": "sensor value too high",
1029
- "84": "sensor value too low",
1030
- "85": "sensor unreliable",
1031
- }
1032
- if fault := FAULT_CODES_CO2.get(payload[:2]):
1033
- return {"sensor_fault": fault}
1034
-
1035
- return {SZ_CO2_LEVEL: double_from_hex(payload[2:])}
1036
-
1037
-
1038
- @parser_decorator # indoor_humidity
1039
- def parser_12a0(payload, msg) -> dict:
1040
-
1041
- FAULT_CODES_RHUM = {
1042
- "EF": "sensor not available",
1043
- "F0": "sensor short circuit",
1044
- "F1": "sensor open",
1045
- "F2": "sensor not available",
1046
- "F3": "sensor value too high",
1047
- "F4": "sensor value too low",
1048
- "F5": "sensor unreliable",
1049
- } # relative humidity sensor
1050
-
1051
- assert payload[2:4] in FAULT_CODES_RHUM or int(payload[2:4], 16) <= 100
1052
- if fault := FAULT_CODES_RHUM.get(payload[2:4]):
1053
- return {"sensor_fault": fault}
1054
-
1055
- # FAULT_CODES_TEMP = {
1056
- # "7F": "sensor not available",
1057
- # "80": "sensor short circuit",
1058
- # "81": "sensor open",
1059
- # "82": "sensor not available",
1060
- # "83": "sensor value too high",
1061
- # "84": "sensor value too low",
1062
- # "85": "sensor unreliable",
1063
- # }
1064
- # assert payload[4:6] in FAULT_CODES_TEMP or ...
1065
- # if (fault := FAULT_CODES_TEMP.get(payload[2:4])):
1066
- # return {"sensor_fault": fault}
1067
-
1068
- rh = percent_from_hex(payload[2:4], high_res=False)
1069
- if msg.len == 2:
1070
- return {SZ_INDOOR_HUMIDITY: rh}
1071
-
1072
- return {
1073
- SZ_INDOOR_HUMIDITY: rh,
1074
- SZ_TEMPERATURE: temp_from_hex(payload[4:8]),
1075
- "dewpoint_temp": temp_from_hex(payload[8:12]),
1076
- }
1077
-
1078
-
1079
- @parser_decorator # window_state (of a device/zone)
1080
- def parser_12b0(payload, msg) -> dict:
1081
- assert payload[2:] in ("0000", "C800", "FFFF"), payload[2:] # "FFFF" means N/A
1082
-
1083
- return {
1084
- SZ_WINDOW_OPEN: bool_from_hex(payload[2:4]),
1085
- }
1086
-
1087
-
1088
- @parser_decorator # displayed temperature (on a TR87RF bound to a RFG100)
1089
- def parser_12c0(payload, msg) -> dict:
1090
-
1091
- if payload[2:4] == "80":
1092
- temp = None
1093
- elif payload[4:] == "00": # units are 1.0 F
1094
- temp = int(payload[2:4], 16)
1095
- else: # if payload[4:] == "01": # units are 0.5 C
1096
- temp = int(payload[2:4], 16) / 2
1097
-
1098
- return {
1099
- SZ_TEMPERATURE: temp,
1100
- "units": {"00": "Fahrenheit", "01": "Celsius"}[payload[4:6]],
1101
- f"_{SZ_UNKNOWN}_6": payload[6:],
1102
- }
1103
-
1104
-
1105
- @parser_decorator # air_quality, HVAC
1106
- def parser_12c8(payload, msg) -> dict:
1107
- # 04:50:01.616 080 I --- 37:261128 --:------ 37:261128 31DA 029 00A740-05133AEF7FFF7FFF7FFF7FFFF808EF1805000000EFEF7FFF7FFF # noqa: E501
1108
- # 04:50:01.717 078 I --- 37:261128 --:------ 37:261128 12C8 003 00A740
1109
- # 04:50:31.443 078 I --- 37:261128 --:------ 37:261128 31DA 029 007A40-05993AEF7FFF7FFF7FFF7FFFF808EF1807000000EFEF7FFF7FFF # noqa: E501
1110
- # 04:50:31.544 078 I --- 37:261128 --:------ 37:261128 12C8 003 007A40
1111
- # 04:51:40.262 079 I --- 37:261128 --:------ 37:261128 31DA 029 009540-054B3AEF7FFF7FFF7FFF7FFFF808EF180E000000EFEF7FFF7FFF # noqa: E501
1112
- # 04:51:41.192 078 I --- 37:261128 --:------ 37:261128 12C8 003 009540
1113
-
1114
- return {
1115
- SZ_AIR_QUALITY: percent_from_hex(payload[2:4]), # 31DA[2:4]
1116
- SZ_AIR_QUALITY_BASE: int(payload[4:6], 16), # 31DA[4:6]
1117
- }
1118
-
1119
-
1120
- @parser_decorator # dhw_flow_rate
1121
- def parser_12f0(payload, msg) -> dict:
1122
- return {"dhw_flow_rate": temp_from_hex(payload[2:])}
1123
-
1124
-
1125
- @parser_decorator # ch_pressure
1126
- def parser_1300(payload, msg) -> dict:
1127
- return {SZ_PRESSURE: temp_from_hex(payload[2:])} # is 2's complement still
1128
-
1129
-
1130
- @parser_decorator # programme_scheme, HVAC
1131
- def parser_1470(payload, msg) -> dict:
1132
- # Seen on Orcon: see 1470, 1F70, 22B0
1133
-
1134
- SCHEDULE_SCHEME = {
1135
- "9": "one_per_week",
1136
- "A": "two_per_week", # week_day, week_end
1137
- "B": "one_each_day", # seven_per_week (default?)
1138
- }
1139
-
1140
- assert payload[8:10] == "80", _INFORM_DEV_MSG
1141
- assert msg.verb == W_ or payload[4:8] == "0E60", _INFORM_DEV_MSG
1142
- assert msg.verb == W_ or payload[10:] == "2A0108", _INFORM_DEV_MSG
1143
- assert msg.verb != W_ or payload[4:] == "000080000000", _INFORM_DEV_MSG
1144
-
1145
- # schedule...
1146
- # [2:3] - 1, every/all days, 1&6, weekdays/weekends, 1-7, each individual day
1147
- # [3:4] - # setpoints/day (default 3)
1148
- assert payload[2:3] in SCHEDULE_SCHEME and (
1149
- payload[3:4] in ("2", "3", "4", "5", "6")
1150
- ), _INFORM_DEV_MSG
1151
-
1152
- return {
1153
- "scheme": SCHEDULE_SCHEME.get(payload[2:3], f"unknown_{payload[2:3]}"),
1154
- "daily_setpoints": payload[3:4],
1155
- f"_{SZ_VALUE}_4": payload[4:8],
1156
- f"_{SZ_VALUE}_8": payload[8:10],
1157
- f"_{SZ_VALUE}_10": payload[10:],
1158
- }
1159
-
1160
-
1161
- @parser_decorator # system_sync
1162
- def parser_1f09(payload, msg) -> dict:
1163
- # 22:51:19.287 067 I --- --:------ --:------ 12:193204 1F09 003 010A69
1164
- # 22:51:19.318 068 I --- --:------ --:------ 12:193204 2309 003 010866
1165
- # 22:51:19.321 067 I --- --:------ --:------ 12:193204 30C9 003 0108C3
1166
-
1167
- assert msg.len == 3, f"length is {msg.len}, expecting 3"
1168
- assert payload[:2] in ("00", "01", F8, FF) # W/F8
1169
-
1170
- seconds = int(payload[2:6], 16) / 10
1171
- next_sync = msg.dtm + td(seconds=seconds)
1172
-
1173
- return {
1174
- "remaining_seconds": seconds,
1175
- "_next_sync": dt.strftime(next_sync, "%H:%M:%S"),
1176
- }
1177
-
1178
-
1179
- @parser_decorator # dhw_mode
1180
- def parser_1f41(payload, msg) -> dict:
1181
- # 053 RP --- 01:145038 18:013393 --:------ 1F41 006 00FF00FFFFFF # no stored DHW
1182
- assert payload[4:6] in ZON_MODE_MAP, f"{payload[4:6]} (0xjj)"
1183
- assert (
1184
- payload[4:6] == ZON_MODE_MAP.TEMPORARY or msg.len == 6
1185
- ), f"{msg!r}: expected length 6"
1186
- assert (
1187
- payload[4:6] != ZON_MODE_MAP.TEMPORARY or msg.len == 12
1188
- ), f"{msg!r}: expected length 12"
1189
- assert (
1190
- payload[6:12] == "FFFFFF"
1191
- ), f"{msg!r}: expected FFFFFF instead of '{payload[6:12]}'"
1192
-
1193
- result = {SZ_MODE: ZON_MODE_MAP.get(payload[4:6])}
1194
- if payload[2:4] != "FF":
1195
- result["active"] = {"00": False, "01": True, "FF": None}[payload[2:4]]
1196
- # if payload[4:6] == ZON_MODE_MAP.COUNTDOWN:
1197
- # result[SZ_UNTIL] = dtm_from_hex(payload[6:12])
1198
- if payload[4:6] == ZON_MODE_MAP.TEMPORARY:
1199
- result[SZ_UNTIL] = dtm_from_hex(payload[12:24])
1200
-
1201
- return result
1202
-
1203
-
1204
- @parser_decorator # programme_config, HVAC
1205
- def parser_1f70(payload, msg) -> dict:
1206
- # Seen on Orcon: see 1470, 1F70, 22B0
1207
-
1208
- try:
1209
- assert payload[:2] == "00", f"expected 00, not {payload[:2]}"
1210
- assert payload[2:4] in ("00", "01"), f"expected (00|01), not {payload[2:4]}"
1211
- assert payload[4:8] == "0800", f"expected 0800, not {payload[4:8]}"
1212
- assert payload[10:14] == "0000", f"expected 0000, not {payload[10:14]}"
1213
- assert msg.verb in (RQ, W_) or payload[14:16] == "15"
1214
- assert msg.verb in (I_, RP) or payload[14:16] == "00"
1215
- assert msg.verb == RQ or payload[22:24] == "60"
1216
- assert msg.verb != RQ or payload[22:24] == "00"
1217
- assert msg.verb == RQ or payload[24:26] in ("E4", "E5", "E6"), _INFORM_DEV_MSG
1218
- assert msg.verb == RP or payload[26:] == "000000"
1219
- assert msg.verb != RP or payload[26:] == "008000"
1220
-
1221
- except AssertionError as exc:
1222
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({exc})")
1223
-
1224
- # assert int(payload[16:18], 16) < 7, _INFORM_DEV_MSG
1225
-
1226
- return {
1227
- "day_idx": payload[16:18], # depends upon 1470[3:4]?
1228
- "setpoint_idx": payload[8:10], # needs to be mod 1470[3:4]?
1229
- "start_time": f"{int(payload[18:20], 16):02d}:{int(payload[20:22], 16):02d}",
1230
- "fan_speed_wip": payload[24:26], # # E4/E5/E6 / 00(RQ)
1231
- f"_{SZ_VALUE}_02": payload[2:4], # # 00/01 / 00(RQ)
1232
- f"_{SZ_VALUE}_04": payload[4:8], # # 0800
1233
- f"_{SZ_VALUE}_10": payload[10:14], # 0000
1234
- f"_{SZ_VALUE}_14": payload[14:16], # 15(RP,I) / 00(RQ,W)
1235
- f"_{SZ_VALUE}_22": payload[22:24], # 60 / 00(RQ)
1236
- f"_{SZ_VALUE}_26": payload[26:], # # 008000(RP) / 000000(I/RQ/W)
1237
- }
1238
-
1239
-
1240
- @parser_decorator # rf_bind
1241
- def parser_1fc9(payload, msg) -> list:
1242
- # .I --- 01:145038 --:------ 01:145038 1FC9 012 F6-2D49-06368E F6-1FC9-06368E
1243
-
1244
- # .I is missing?
1245
- # .W --- 10:048122 01:145038 --:------ 1FC9 006 003EF028BBFA
1246
- # .I --- 01:145038 10:048122 --:------ 1FC9 006 00FFFF06368E
1247
-
1248
- # .I --- 07:045960 --:------ 07:045960 1FC9 012 0012601CB388001FC91CB388
1249
- # .W --- 01:145038 07:045960 --:------ 1FC9 006 0010A006368E
1250
- # .I --- 07:045960 01:145038 --:------ 1FC9 006 0012601CB388
1251
-
1252
- # .I --- 01:145038 --:------ 01:145038 1FC9 018 FA000806368EFC3B0006368EFA1FC906368E
1253
- # .W --- 13:081807 01:145038 --:------ 1FC9 006 003EF0353F8F
1254
- # .I --- 01:145038 13:081807 --:------ 1FC9 006 00FFFF06368E
1255
-
1256
- # this is an array of codes
1257
- # 049 I --- 01:145038 --:------ 01:145038 1FC9 018 07-0008-06368E FC-3B00-06368E 07-1FC9-06368E # noqa: E501
1258
- # 047 I --- 01:145038 --:------ 01:145038 1FC9 018 FA-0008-06368E FC-3B00-06368E FA-1FC9-06368E # noqa: E501
1259
- # 065 I --- 01:145038 --:------ 01:145038 1FC9 024 FC-0008-06368E FC-3150-06368E FB-3150-06368E FC-1FC9-06368E # noqa: E501
1260
-
1261
- # HW valve binding:
1262
- # 063 I --- 01:145038 --:------ 01:145038 1FC9 018 FA-0008-06368E FC-3B00-06368E FA-1FC9-06368E # noqa: E501
1263
- # CH valve binding:
1264
- # 071 I --- 01:145038 --:------ 01:145038 1FC9 018 F9-0008-06368E FC-3B00-06368E F9-1FC9-06368E # noqa: E501
1265
- # ZoneValve zone binding
1266
- # 045 W --- 13:106039 01:145038 --:------ 1FC9 012 00-3EF0-359E37 00-3B00-359E37
1267
- # DHW binding..
1268
- # 045 W --- 13:163733 01:145038 --:------ 1FC9 012 00-3EF0-367F95 00-3B00-367F95
1269
-
1270
- # 049 I --- 01:145038 --:------ 01:145038 1FC9 018 F9-0008-06368E FC-3B00-06368E F9-1FC9-06368E # noqa: E501
1271
-
1272
- # the new (heatpump-aware) BDR91:
1273
- # 045 RP --- 13:035462 18:013393 --:------ 1FC9 018 00-3EF0-348A86 00-11F0-348A86 90-7FE1-DD6ABD # noqa: E501
1274
-
1275
- def _parser(seqx) -> list:
1276
- if seqx[:2] not in ("90",):
1277
- assert seqx[6:] == payload[6:12] # all with same controller
1278
- if seqx[:2] not in (
1279
- "63",
1280
- "67",
1281
- "6C",
1282
- "90",
1283
- F6,
1284
- F9,
1285
- FA,
1286
- FB,
1287
- FC,
1288
- FF,
1289
- ): # or: not in DOMAIN_TYPE_MAP: ??
1290
- assert int(seqx[:2], 16) < 16
1291
- return [seqx[:2], seqx[2:6], hex_id_to_dev_id(seqx[6:])]
1292
-
1293
- if msg.verb == I_ and msg.src is msg.dst:
1294
- bind_step = "offer"
1295
- elif msg.verb == W_ and msg.src is not msg.dst:
1296
- bind_step = "accept"
1297
- elif msg.verb == I_:
1298
- bind_step = "confirm" # payload could be "00"
1299
- else:
1300
- bind_step = None # unknown
1301
-
1302
- if payload == "00":
1303
- return {"phase": bind_step, SZ_BINDINGS: []}
1304
-
1305
- assert msg.len >= 6 and msg.len % 6 == 0, msg.len # assuming not RQ
1306
- assert msg.verb in (I_, W_, RP), msg.verb # devices will respond to a RQ!
1307
- # assert (
1308
- # msg.src.id == hex_id_to_dev_id(payload[6:12])
1309
- # ), f"{payload[6:12]} ({hex_id_to_dev_id(payload[6:12])})" # NOTE: use_regex
1310
- bindings = [
1311
- _parser(payload[i : i + 12])
1312
- for i in range(0, len(payload), 12)
1313
- # if payload[i : i + 2] != "90" # TODO: WIP, what is 90?
1314
- ]
1315
- return {"phase": bind_step, SZ_BINDINGS: bindings}
1316
-
1317
-
1318
- @parser_decorator # unknown_1fca, HVAC?
1319
- def parser_1fca(payload, msg) -> list:
1320
- # .W --- 30:248208 34:021943 --:------ 1FCA 009 00-01FF-7BC990-FFFFFF # sent x2
1321
-
1322
- return {
1323
- f"_{SZ_UNKNOWN}_0": payload[:2],
1324
- f"_{SZ_UNKNOWN}_1": payload[2:6],
1325
- "device_id_0": hex_id_to_dev_id(payload[6:12]),
1326
- "device_id_1": hex_id_to_dev_id(payload[12:]),
1327
- }
1328
-
1329
-
1330
- @parser_decorator # unknown_1fd0, from OTB
1331
- def parser_1fd0(payload, msg) -> dict:
1332
-
1333
- assert payload == "0000000000000000", _INFORM_DEV_MSG
1334
-
1335
- return {
1336
- SZ_PAYLOAD: payload,
1337
- }
1338
-
1339
-
1340
- @parser_decorator # opentherm_sync, otb_sync
1341
- def parser_1fd4(payload, msg) -> dict:
1342
- return {"ticker": int(payload[2:], 16)}
1343
-
1344
-
1345
- @parser_decorator # WIP: unknown, HVAC
1346
- def parser_2210(payload, msg) -> dict:
1347
- # RP --- 32:153258 18:005904 --:------ 2210 042 00FF 00FFFFFF0000000000FFFFFFFFFF 00FFFFFF0000000000FFFFFFFFFF FFFFFF000000000000000800
1348
- # RP --- 32:153258 18:005904 --:------ 2210 042 00FF 00FFFF960000000003FFFFFFFFFF 00FFFF960000000003FFFFFFFFFF FFFFFF000000000000000800
1349
-
1350
- assert payload in (
1351
- "00FF" + "00FFFFFF0000000000FFFFFFFFFF" * 2 + "FFFFFF000000000000000800",
1352
- "00FF" + "00FFFF960000000003FFFFFFFFFF" * 2 + "FFFFFF000000000000000800",
1353
- ), _INFORM_DEV_MSG
1354
-
1355
- return {}
1356
-
1357
-
1358
- @parser_decorator # now_next_setpoint - Programmer/Hometronics
1359
- def parser_2249(payload, msg) -> dict:
1360
- # see: https://github.com/jrosser/honeymon/blob/master/decoder.cpp#L357-L370
1361
- # .I --- 23:100224 --:------ 23:100224 2249 007 00-7EFF-7EFF-FFFF
1362
-
1363
- def _parser(seqx) -> dict:
1364
- minutes = int(seqx[10:], 16)
1365
- next_setpoint = msg.dtm + td(minutes=minutes)
1366
- return {
1367
- "setpoint_now": temp_from_hex(seqx[2:6]),
1368
- "setpoint_next": temp_from_hex(seqx[6:10]),
1369
- "minutes_remaining": minutes,
1370
- "_next_setpoint": dt.strftime(next_setpoint, "%H:%M:%S"),
1371
- }
1372
-
1373
- # the ST9520C can support two heating zones, so: msg.len in (7, 14)?
1374
- if msg._has_array:
1375
- return [
1376
- {
1377
- SZ_ZONE_IDX: payload[i : i + 2],
1378
- **_parser(payload[i + 2 : i + 14]),
1379
- }
1380
- for i in range(0, len(payload), 14)
1381
- ]
1382
-
1383
- return _parser(payload)
1384
-
1385
-
1386
- @parser_decorator # program_enabled, HVAC
1387
- def parser_22b0(payload, msg) -> dict:
1388
- # Seen on Orcon: see 1470, 1F70, 22B0
1389
-
1390
- # .W --- 37:171871 32:155617 --:------ 22B0 002 0005 # enable
1391
- # .I --- 32:155617 37:171871 --:------ 22B0 002 0005
1392
-
1393
- # .W --- 37:171871 32:155617 --:------ 22B0 002 0006 # disable
1394
- # .I --- 32:155617 37:171871 --:------ 22B0 002 0006
1395
-
1396
- return {
1397
- "enabled": {"06": False, "05": True}.get(payload[2:4]),
1398
- }
1399
-
1400
-
1401
- @parser_decorator # ufh_setpoint, TODO: max length = 24?
1402
- def parser_22c9(payload, msg) -> list:
1403
- # .I --- 02:001107 --:------ 02:001107 22C9 024 00-0834-0A28-01-0108340A2801-0208340A2801-0308340A2801 # noqa: E501
1404
- # .I --- 02:001107 --:------ 02:001107 22C9 006 04-0834-0A28-01
1405
-
1406
- # .I --- 21:064743 --:------ 21:064743 22C9 006 00-07D0-0834-02
1407
- # .W --- 21:064743 02:250708 --:------ 22C9 006 03-07D0-0834-02
1408
- # .I --- 02:250708 21:064743 --:------ 22C9 008 03-07D0-7FFF-02-02-03
1409
-
1410
- def _parser(seqx) -> dict:
1411
- assert seqx[10:] in ("01", "02"), f"is {seqx[10:]}, expecting 01/02"
1412
-
1413
- return {
1414
- "temp_low": temp_from_hex(seqx[2:6]),
1415
- "temp_high": temp_from_hex(seqx[6:10]),
1416
- f"_{SZ_UNKNOWN}_0": seqx[10:],
1417
- }
1418
-
1419
- if msg._has_array:
1420
- return [
1421
- {
1422
- SZ_UFH_IDX: payload[i : i + 2],
1423
- **_parser(payload[i : i + 12]),
1424
- }
1425
- for i in range(0, len(payload), 12)
1426
- ]
1427
-
1428
- return _parser(payload[:12]) # TODO: [12:]
1429
-
1430
-
1431
- @parser_decorator # unknown_22d0, HVAC system switch?
1432
- def parser_22d0(payload, msg) -> dict:
1433
-
1434
- # When closing H/C contact (or enabling cooling mode with buttons when H/C contact is closed) on HCE80 it sends following packet:
1435
- # .I — 02:044994 --:------ 02:044994 22D0 004 0010000A < AssertionError…
1436
-
1437
- # When H/C contact is opened, it send the following packet (although this packet is not transmitted, when cooling mode is disabled with buttons):
1438
- # .I — 02:044994 --:------ 02:044994 22D0 004 0000000A < AssertionError…
1439
-
1440
- # .I --- 02:001107 --:------ 02:001107 22D0 004 00000002 # an UFC
1441
-
1442
- # .W --- 21:064743 02:250708 --:------ 22D0 008 0314001E-14030020
1443
- # .I --- 02:250708 21:064743 --:------ 22D0 004 03130000
1444
- # .I --- 02:250708 --:------ 02:250708 22D0 004 00130000 # sends 3x, 1s apart
1445
-
1446
- assert payload in (
1447
- "00000002",
1448
- "00130000",
1449
- "03130000",
1450
- "0314001E14030020",
1451
- ), _INFORM_DEV_MSG
1452
-
1453
- return {
1454
- "idx": payload[:2],
1455
- SZ_PAYLOAD: payload[2:],
1456
- }
1457
-
1458
-
1459
- @parser_decorator # desired boiler setpoint
1460
- def parser_22d9(payload, msg) -> dict:
1461
- return {SZ_SETPOINT: temp_from_hex(payload[2:6])}
1462
-
1463
-
1464
- @parser_decorator # WIP: unknown, HVAC
1465
- def parser_22e0(payload, msg) -> dict:
1466
- # RP --- 32:155617 18:005904 --:------ 22E0 004 00-34-A0-1E
1467
- # RP --- 32:153258 18:005904 --:------ 22E0 004 00-64-A0-1E
1468
- def _parser(seqx) -> dict:
1469
- assert int(seqx, 16) <= 200 or seqx == "E6" # only for 22E0, not 22E5/22E9
1470
- return int(seqx, 16) / 200
1471
-
1472
- try:
1473
- return {
1474
- f"percent_{i}": percent_from_hex(payload[i : i + 2])
1475
- for i in range(2, len(payload), 2)
1476
- }
1477
- except ValueError:
1478
- return {
1479
- "percent_2": percent_from_hex(payload[2:4]),
1480
- "percent_4": _parser(payload[4:6]),
1481
- "percent_6": percent_from_hex(payload[6:8]),
1482
- }
1483
-
1484
-
1485
- @parser_decorator # WIP: unknown, HVAC
1486
- def parser_22e5(payload, msg) -> dict:
1487
- # RP --- 32:153258 18:005904 --:------ 22E5 004 00-96-C8-14
1488
- # RP --- 32:155617 18:005904 --:------ 22E5 004 00-72-C8-14
1489
-
1490
- return parser_22e0(payload, msg)
1491
-
1492
-
1493
- @parser_decorator # WIP: unknown, HVAC
1494
- def parser_22e9(payload, msg) -> dict:
1495
- # RP --- 32:153258 18:005904 --:------ 22E9 004 00C8C814
1496
- # RP --- 32:155617 18:005904 --:------ 22E9 004 008CC814
1497
-
1498
- return parser_22e0(payload, msg)
1499
-
1500
-
1501
- @parser_decorator # fan_speed (switch_mode), HVAC
1502
- def parser_22f1(payload, msg) -> dict:
1503
- # Orcon wireless remote 15RF
1504
- # .I --- 37:171871 32:155617 --:------ 22F1 003 000007 # Absent mode // Afwezig (absence mode, aka: weg/away) - low & doesn't respond to sensors
1505
- # .I --- 37:171871 32:155617 --:------ 22F1 003 000107 # Mode 1: Low // Stand 1 (position low)
1506
- # .I --- 37:171871 32:155617 --:------ 22F1 003 000207 # Mode 2: Med // Stand 2 (position med)
1507
- # .I --- 37:171871 32:155617 --:------ 22F1 003 000307 # Mode 3: High // Stand 3 (position high)
1508
- # .I --- 37:171871 32:155617 --:------ 22F1 003 000407 # Auto
1509
- # .I --- 37:171871 32:155617 --:------ 22F1 003 000507 # Auto
1510
- # .I --- 37:171871 32:155617 --:------ 22F1 003 000607 # Party/boost
1511
- # .I --- 37:171871 32:155617 --:------ 22F1 003 000707 # Off
1512
-
1513
- # .I 015 --:------ --:------ 39:159057 22F1 003 000004 # TBA: off/standby?
1514
- # .I 015 --:------ --:------ 39:159057 22F1 003 000104 # TBA: trickle/min-speed?
1515
- # .I 015 --:------ --:------ 39:159057 22F1 003 000204 # low
1516
- # .I 016 --:------ --:------ 39:159057 22F1 003 000304 # medium
1517
- # .I 017 --:------ --:------ 39:159057 22F1 003 000404 # high (aka boost if timer)
1518
-
1519
- # Scheme x: 0|x standby/off, 1|x min, 2+|x rate as % of max (Itho?)
1520
- # Scheme 4: 0|4 standby/off, 1|4 auto, 2|4 low, 3|4 med, 4|4 high/boost
1521
- # Scheme 7: only seen 000[2345]07 -- ? off, auto, rate x/4, +3 others?
1522
- # Scheme A: only seen 000[239A]0A -- Normal, Boost (purge), HeaterOff & HeaterAuto
1523
-
1524
- try:
1525
- assert payload[0:2] in ("00", "63")
1526
- assert not payload[4:] or int(payload[2:4], 16) <= int(
1527
- payload[4:], 16
1528
- ), "mode_idx > mode_max"
1529
- except AssertionError as exc:
1530
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({exc})")
1531
-
1532
- if msg._addrs[0] == NON_DEV_ADDR: # and payload[4:6] == "04":
1533
- from .ramses import _22F1_MODE_ITHO as _22F1_FAN_MODE # TODO: only if 04
1534
-
1535
- _22f1_mode_set = ("", "04")
1536
- _22f1_scheme = "itho"
1537
-
1538
- # elif msg._addrs[0] == NON_DEV_ADDR: # and payload[4:6] == "04":
1539
- # _22F1_FAN_MODE = {
1540
- # f"{x:02X}": f"speed_{x}" for x in range(int(payload[4:6], 16) + 1)
1541
- # } | {"00": "off"}
1542
-
1543
- # _22f1_mode_set = (payload[4:6], )
1544
- # _22f1_scheme = "itho_2"
1545
-
1546
- elif payload[4:6] == "0A":
1547
- from .ramses import _22F1_MODE_NUAIRE as _22F1_FAN_MODE
1548
-
1549
- _22f1_mode_set = ("", "0A")
1550
- _22f1_scheme = "nuaire"
1551
-
1552
- else:
1553
- from .ramses import _22F1_MODE_ORCON as _22F1_FAN_MODE
1554
-
1555
- _22f1_mode_set = ("", "04", "07", "0B") # 0B?
1556
- _22f1_scheme = "orcon"
1557
-
1558
- try:
1559
- assert payload[2:4] in _22F1_FAN_MODE, f"unknown fan_mode: {payload[2:4]}"
1560
- assert payload[4:6] in _22f1_mode_set, f"unknown mode_set: {payload[4:6]}"
1561
- except AssertionError as exc:
1562
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({exc})")
1563
-
1564
- return {
1565
- SZ_FAN_MODE: _22F1_FAN_MODE.get(payload[2:4], f"unknown_{payload[2:4]}"),
1566
- "_scheme": _22f1_scheme,
1567
- "_mode_idx": f"{int(payload[2:4], 16) & 0x0F:02X}",
1568
- "_mode_max": payload[4:6] or None,
1569
- # "_payload": payload,
1570
- }
1571
-
1572
-
1573
- @parser_decorator # WIP: unknown, HVAC (flow rate?)
1574
- def parser_22f2(payload, msg) -> dict:
1575
- # RP --- 32:155617 18:005904 --:------ 22F2 006 00-019B 01-0201
1576
- # RP --- 32:155617 18:005904 --:------ 22F2 006 00-0174 01-0208
1577
- # RP --- 32:155617 18:005904 --:------ 22F2 006 00-01E5 01-0201
1578
-
1579
- def _parser(seqx) -> dict:
1580
- assert seqx[:2] in ("00", "01"), f"is {seqx[:2]}, expecting 00/01"
1581
-
1582
- return {
1583
- "hvac_idx": seqx[:2],
1584
- "measure": temp_from_hex(seqx[2:]),
1585
- }
1586
-
1587
- return [_parser(payload[i : i + 6]) for i in range(0, len(payload), 6)]
1588
-
1589
-
1590
- @parser_decorator # fan_boost, HVAC
1591
- def parser_22f3(payload, msg) -> dict:
1592
- # .I 019 --:------ --:------ 39:159057 22F3 003 00000A # 10 mins
1593
- # .I 022 --:------ --:------ 39:159057 22F3 003 000014 # 20 mins
1594
- # .I 026 --:------ --:------ 39:159057 22F3 003 00001E # 30 mins
1595
- # .I --- 29:151550 29:237552 --:------ 22F3 007 00023C-0304-0000 # 60 mins
1596
- # .I --- 29:162374 29:237552 --:------ 22F3 007 00020F-0304-0000 # 15 mins
1597
- # .I --- 29:162374 29:237552 --:------ 22F3 007 00020F-0304-0000 # 15 mins
1598
-
1599
- # NOTE: for boost timer for high
1600
- try:
1601
- # assert payload[2:4] in ("00", "02", "12", "x52"), f"byte 1: {flag8(payload[2:4])}"
1602
- assert msg.len <= 7 or payload[14:] == "0000", f"byte 7: {payload[14:]}"
1603
- except AssertionError as exc:
1604
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({exc})")
1605
-
1606
- new_speed = { # from now, until timer expiry
1607
- 0x00: "fan_boost", # # set fan off, or 'boost' mode?
1608
- 0x01: "per_request", # # set fan as per payload[6:10]?
1609
- 0x02: "per_vent_speed", # set fan as per current fan mode/speed?
1610
- }.get(
1611
- int(payload[2:4], 0x10) & 0x07
1612
- ) # 0b0000-0111
1613
-
1614
- fallback_speed = { # after timer expiry
1615
- 0x08: "fan_off", # # set fan off?
1616
- 0x10: "per_request", # # set fan as per payload[6:10], or payload[10:]?
1617
- 0x18: "per_vent_speed", # set fan as per current fan mode/speed?
1618
- }.get(
1619
- int(payload[2:4], 0x10) & 0x38
1620
- ) # 0b0011-1000
1621
-
1622
- units = {
1623
- 0x00: "minutes",
1624
- 0x40: "hours",
1625
- 0x80: "index", # TODO: days, day-of-week, day-of-month?
1626
- }.get(
1627
- int(payload[2:4], 0x10) & 0xC0
1628
- ) # 0b1100-0000
1629
-
1630
- duration = int(payload[4:6], 16) * 60 if units == "hours" else int(payload[4:6], 16)
1631
-
1632
- if msg.len >= 3:
1633
- result = {
1634
- "minutes" if units != "index" else "index": duration,
1635
- "flags": flag8_from_hex(payload[2:4]),
1636
- "_new_speed_mode": new_speed,
1637
- "_fallback_speed_mode": fallback_speed,
1638
- }
1639
-
1640
- if msg.len >= 5 and payload[6:10] != "0000": # new speed?
1641
- result["rate"] = parser_22f1(f"00{payload[6:10]}", msg).get("rate")
1642
-
1643
- if msg.len >= 7: # fallback speed?
1644
- result.update({f"_{SZ_UNKNOWN}_5": payload[10:]})
1645
-
1646
- return result
1647
-
1648
-
1649
- @parser_decorator # WIP: unknown, HVAC
1650
- def parser_22f4(payload, msg) -> dict:
1651
- # RP --- 32:155617 18:005904 --:------ 22F4 013 00-60E6-00000000000000-200000
1652
- # RP --- 32:153258 18:005904 --:------ 22F4 013 00-60DD-00000000000000-200000
1653
- # RP --- 32:155617 18:005904 --:------ 22F4 013 00-40B0-00000000000000-200000
1654
-
1655
- assert payload[:2] == "00"
1656
- assert payload[6:] == "00000000000000200000"
1657
-
1658
- return {
1659
- "value_02": payload[2:4],
1660
- "value_04": payload[4:6],
1661
- }
1662
-
1663
-
1664
- @parser_decorator # bypass_mode, HVAC
1665
- def parser_22f7(payload, msg) -> dict:
1666
- # RQ --- 37:171871 32:155617 --:------ 22F7 001 00
1667
- # RP --- 32:155617 37:171871 --:------ 22F7 003 00FF00 # alse: 000000, 00C8C8
1668
-
1669
- # .W --- 37:171871 32:155617 --:------ 22F7 003 0000EF # bypass off
1670
- # .I --- 32:155617 37:171871 --:------ 22F7 003 000000
1671
- # .W --- 37:171871 32:155617 --:------ 22F7 003 00C8EF # bypass on
1672
- # .I --- 32:155617 37:171871 --:------ 22F7 003 00C800
1673
- # .W --- 37:171871 32:155617 --:------ 22F7 003 00FFEF # bypass auto
1674
- # .I --- 32:155617 37:171871 --:------ 22F7 003 00FFC8
1675
-
1676
- result = {
1677
- "bypass_mode": {"00": "off", "C8": "on", "FF": "auto"}.get(payload[2:4]),
1678
- }
1679
- if msg.verb != W_ or payload[4:] not in ("", "EF"):
1680
- result["bypass_state"] = {"00": "off", "C8": "on"}.get(payload[4:])
1681
-
1682
- return result
1683
-
1684
-
1685
- @parser_decorator # setpoint (of device/zones)
1686
- def parser_2309(payload, msg) -> dict | list:
1687
-
1688
- if msg._has_array:
1689
- return [
1690
- {
1691
- SZ_ZONE_IDX: payload[i : i + 2],
1692
- SZ_SETPOINT: temp_from_hex(payload[i + 2 : i + 6]),
1693
- }
1694
- for i in range(0, len(payload), 6)
1695
- ]
1696
-
1697
- # RQ --- 22:131874 01:063844 --:------ 2309 003 020708
1698
- if msg.verb == RQ and msg.len == 1: # some RQs have a payload (why?)
1699
- return {}
1700
-
1701
- return {SZ_SETPOINT: temp_from_hex(payload[2:])}
1702
-
1703
-
1704
- @parser_decorator # zone_mode # TODO: messy
1705
- def parser_2349(payload, msg) -> dict:
1706
- # RQ --- 34:225071 30:258557 --:------ 2349 001 00
1707
- # RP --- 30:258557 34:225071 --:------ 2349 013 007FFF00FFFFFFFFFFFFFFFFFF
1708
- # RP --- 30:253184 34:010943 --:------ 2349 013 00064000FFFFFF00110E0507E5
1709
- # .I --- 10:067219 --:------ 10:067219 2349 004 00000001
1710
-
1711
- if msg.verb == RQ and msg.len <= 2: # some RQs have a payload (why?)
1712
- return {}
1713
-
1714
- assert msg.len in (7, 13), f"expected len 7,13, got {msg.len}"
1715
-
1716
- assert payload[6:8] in ZON_MODE_MAP, f"{SZ_UNKNOWN} zone_mode: {payload[6:8]}"
1717
- result = {
1718
- SZ_MODE: ZON_MODE_MAP.get(payload[6:8]),
1719
- SZ_SETPOINT: temp_from_hex(payload[2:6]),
1720
- }
1721
-
1722
- if msg.len >= 7: # has a dtm if mode == "04"
1723
- if payload[8:14] == "FF" * 3: # 03/FFFFFF OK if W?
1724
- assert payload[6:8] != ZON_MODE_MAP.COUNTDOWN, f"{payload[6:8]} (0x00)"
1725
- else:
1726
- assert payload[6:8] == ZON_MODE_MAP.COUNTDOWN, f"{payload[6:8]} (0x01)"
1727
- result[SZ_DURATION] = int(payload[8:14], 16)
1728
-
1729
- if msg.len >= 13:
1730
- if payload[14:] == "FF" * 6:
1731
- assert payload[6:8] in (
1732
- ZON_MODE_MAP.FOLLOW,
1733
- ZON_MODE_MAP.PERMANENT,
1734
- ), f"{payload[6:8]} (0x02)"
1735
- result[SZ_UNTIL] = None # TODO: remove?
1736
- else:
1737
- assert payload[6:8] != ZON_MODE_MAP.PERMANENT, f"{payload[6:8]} (0x03)"
1738
- result[SZ_UNTIL] = dtm_from_hex(payload[14:26])
1739
-
1740
- return result
1741
-
1742
-
1743
- @parser_decorator # unknown_2389, from 03:
1744
- def parser_2389(payload, msg) -> dict:
1745
-
1746
- return {
1747
- f"_{SZ_UNKNOWN}": temp_from_hex(payload[2:6]),
1748
- }
1749
-
1750
-
1751
- @parser_decorator # unknown_2400, from OTB, FAN
1752
- def parser_2400(payload, msg) -> dict:
1753
- # RP --- 32:155617 18:005904 --:------ 2400 045 00001111-1010929292921110101020110010000080100010100000009191111191910011119191111111111100 # Orcon FAN
1754
- # RP --- 10:048122 18:006402 --:------ 2400 004 0000000F
1755
- # assert payload == "0000000F", _INFORM_DEV_MSG
1756
-
1757
- return {
1758
- SZ_PAYLOAD: payload,
1759
- }
1760
-
1761
-
1762
- @parser_decorator # unknown_2401, from OTB
1763
- def parser_2401(payload, msg) -> dict:
1764
-
1765
- try:
1766
- assert payload[2:4] == "00", f"byte 1: {payload[2:4]}"
1767
- assert (
1768
- int(payload[4:6], 16) & 0b11110000 == 0
1769
- ), f"byte 2: {flag8_from_hex(payload[4:6])}"
1770
- assert int(payload[6:], 0x10) <= 200, f"byte 3: {payload[6:]}"
1771
- except AssertionError as exc:
1772
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({exc})")
1773
-
1774
- return {
1775
- SZ_PAYLOAD: payload,
1776
- "_value_2": int(payload[4:6], 0x10),
1777
- "_flags_2": flag8_from_hex(payload[4:6]),
1778
- "_percent_3": percent_from_hex(payload[6:]),
1779
- }
1780
-
1781
-
1782
- @parser_decorator # unknown_2410, from OTB, FAN
1783
- def parser_2410(payload, msg) -> dict:
1784
- # RP --- 10:048122 18:006402 --:------ 2410 020 00-00000000-00000000-00000001-00000001-00000C # OTB
1785
- # RP --- 32:155617 18:005904 --:------ 2410 020 00-00003EE8-00000000-FFFFFFFF-00000000-1002A6 # Orcon Fan
1786
-
1787
- def unstuff(seqx: str) -> tuple:
1788
- val = int(seqx, 16)
1789
- # if val & 0x40:
1790
- # raise TypeError
1791
- signed = bool(val & 0x80)
1792
- length = (val >> 3 & 0x07) or 1
1793
- d_type = {0b000: "a", 0b001: "b", 0b010: "c", 0b100: "d"}.get(
1794
- val & 0x07, val & 0x07
1795
- )
1796
- return signed, length, d_type
1797
-
1798
- assert payload[:6] == "00" * 3, _INFORM_DEV_MSG
1799
- assert payload[10:18] == "00" * 4, _INFORM_DEV_MSG
1800
- assert payload[18:26] in ("00000001", "FFFFFFFF"), _INFORM_DEV_MSG
1801
- assert payload[26:34] in ("00000001", "00000000"), _INFORM_DEV_MSG
1802
-
1803
- return {
1804
- "tail": payload[34:],
1805
- "xxx_34": unstuff(payload[34:36]),
1806
- "xxx_36": unstuff(payload[36:38]),
1807
- "xxx_38": unstuff(payload[38:]),
1808
- "cur_value": payload[2:10],
1809
- "min_value": payload[10:18],
1810
- "max_value": payload[18:26],
1811
- "oth_value": payload[26:34],
1812
- }
1813
-
1814
-
1815
- @parser_decorator # fan_params, HVAC
1816
- def parser_2411(payload, msg) -> dict:
1817
- # There is a relationship between 0001 and 2411
1818
- # RQ --- 37:171871 32:155617 --:------ 0001 005 0020000A04
1819
- # RP --- 32:155617 37:171871 --:------ 0001 008 0020000A004E0B00 # 0A -> 2411|4E
1820
- # RQ --- 37:171871 32:155617 --:------ 2411 003 00004E # 11th menu option (i.e. 0x0A)
1821
- # RP --- 32:155617 37:171871 --:------ 2411 023 00004E460000000001000000000000000100000001A600
1822
-
1823
- def counter(x) -> int:
1824
- return int(x, 16)
1825
-
1826
- def centile(x) -> float:
1827
- return int(x, 16) / 10
1828
-
1829
- _2411_DATA_TYPES = {
1830
- "00": (2, counter), # 4E (0-1), 54 (15-60)
1831
- "01": (2, centile), # 52 (0.0-25.0) (%)
1832
- "0F": (2, percent_from_hex), # xx (0.0-1.0) (%)
1833
- "10": (4, counter), # 31 (0-1800) (days)
1834
- "92": (4, temp_from_hex), # 75 (0-30) (C)
1835
- } # TODO: _2411_TYPES.get(payload[8:10], (8, no_op))
1836
-
1837
- assert (
1838
- payload[4:6] in _2411_TABLE
1839
- ), f"param {payload[4:6]} is unknown" # _INFORM_DEV_MSG
1840
- description = _2411_TABLE.get(payload[4:6], "Unknown")
1841
-
1842
- result = {
1843
- "parameter": payload[4:6],
1844
- "description": description,
1845
- }
1846
-
1847
- if msg.verb == RQ:
1848
- return result
1849
-
1850
- assert (
1851
- payload[8:10] in _2411_DATA_TYPES
1852
- ), f"param {payload[4:6]} has unknown data_type: {payload[8:10]}" # _INFORM_DEV_MSG
1853
- length, parser = _2411_DATA_TYPES.get(payload[8:10], (8, lambda x: x))
1854
-
1855
- result |= {
1856
- "value": parser(payload[10:18][-length:]),
1857
- f"_{SZ_VALUE}_06": payload[6:10],
1858
- }
1859
-
1860
- if msg.len == 9:
1861
- return result
1862
-
1863
- return result | {
1864
- "min_value": parser(payload[18:26][-length:]),
1865
- "max_value": parser(payload[26:34][-length:]),
1866
- "precision": parser(payload[34:42][-length:]),
1867
- f"_{SZ_VALUE}_42": payload[42:],
1868
- }
1869
-
1870
-
1871
- @parser_decorator # unknown_2420, from OTB
1872
- def parser_2420(payload, msg) -> dict:
1873
-
1874
- assert payload == "00000010" + "00" * 34, _INFORM_DEV_MSG
1875
-
1876
- return {
1877
- SZ_PAYLOAD: payload,
1878
- }
1879
-
1880
-
1881
- @parser_decorator # _state (of cooling?), from BDR91T, hometronics CTL
1882
- def parser_2d49(payload, msg) -> dict:
1883
-
1884
- assert payload[2:] in ("0000", "00FF", "C800", "C8FF"), _INFORM_DEV_MSG
1885
-
1886
- return {
1887
- "state": bool_from_hex(payload[2:4]),
1888
- }
1889
-
1890
-
1891
- @parser_decorator # system_mode
1892
- def parser_2e04(payload, msg) -> dict:
1893
- # if msg.verb == W_:
1894
-
1895
- # .I --— 01:020766 --:------ 01:020766 2E04 016 FFFFFFFFFFFFFF0007FFFFFFFFFFFF04 # Manual # noqa: E501
1896
- # .I --— 01:020766 --:------ 01:020766 2E04 016 FFFFFFFFFFFFFF0000FFFFFFFFFFFF04 # Automatic/times # noqa: E501
1897
-
1898
- if msg.len == 8: # evohome
1899
- assert payload[:2] in SYS_MODE_MAP, f"Unknown system mode: {payload[:2]}"
1900
-
1901
- elif msg.len == 16: # hometronics, lifestyle ID:
1902
- assert 0 <= int(payload[:2], 16) <= 15 or payload[:2] == FF, payload[:2]
1903
- assert payload[16:18] in (SYS_MODE_MAP.AUTO, SYS_MODE_MAP.CUSTOM), payload[
1904
- 16:18
1905
- ]
1906
- assert payload[30:32] == SYS_MODE_MAP.DAY_OFF, payload[30:32]
1907
- # assert False
1908
-
1909
- else:
1910
- # msg.len in (8, 16) # evohome 8, hometronics 16
1911
- assert False, f"Packet length is {msg.len} (expecting 8, 16)"
1912
-
1913
- result = {SZ_SYSTEM_MODE: SYS_MODE_MAP[payload[:2]]}
1914
- if payload[:2] not in (
1915
- SYS_MODE_MAP.AUTO,
1916
- SYS_MODE_MAP.HEAT_OFF,
1917
- SYS_MODE_MAP.AUTO_WITH_RESET,
1918
- ):
1919
- result.update(
1920
- {SZ_UNTIL: dtm_from_hex(payload[2:14]) if payload[14:16] != "00" else None}
1921
- )
1922
- return result # TODO: double-check the final "00"
1923
-
1924
-
1925
- @parser_decorator # presence_detect, HVAC sensor
1926
- def parser_2e10(payload, msg) -> dict:
1927
-
1928
- assert payload in ("0001", "000100"), _INFORM_DEV_MSG
1929
-
1930
- return {
1931
- "presence_detected": bool(payload[2:4]),
1932
- f"_{SZ_UNKNOWN}_4": payload[4:],
1933
- }
1934
-
1935
-
1936
- @parser_decorator # current temperature (of device, zone/s)
1937
- def parser_30c9(payload, msg) -> dict:
1938
-
1939
- if msg._has_array:
1940
- return [
1941
- {
1942
- SZ_ZONE_IDX: payload[i : i + 2],
1943
- SZ_TEMPERATURE: temp_from_hex(payload[i + 2 : i + 6]),
1944
- }
1945
- for i in range(0, len(payload), 6)
1946
- ]
1947
-
1948
- return {SZ_TEMPERATURE: temp_from_hex(payload[2:])}
1949
-
1950
-
1951
- @parser_decorator # unknown_3110, HVAC
1952
- def parser_3110(payload, msg) -> dict:
1953
- # .I --- 02:250708 --:------ 02:250708 3110 004 0000C820
1954
- # .I --- 21:042656 --:------ 21:042656 3110 004 00000020
1955
-
1956
- try:
1957
- assert payload[2:4] == "00", f"byte 1: {payload[2:4]}"
1958
- assert int(payload[4:6], 16) <= 200, f"byte 2: {payload[4:6]}"
1959
- assert payload[6:] in ("00", "10", "20"), f"byte 3: {payload[6:]}"
1960
- except AssertionError as exc:
1961
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({exc})")
1962
-
1963
- return {
1964
- f"_{SZ_UNKNOWN}_1": payload[2:4],
1965
- "_percent_2": percent_from_hex(payload[4:6]),
1966
- "_value_3": payload[6:],
1967
- }
1968
-
1969
-
1970
- @parser_decorator # unknown_3120, from STA, FAN
1971
- def parser_3120(payload, msg) -> dict:
1972
- # .I --- 34:136285 --:------ 34:136285 3120 007 0070B0000000FF # every ~3:45:00!
1973
- # RP --- 20:008749 18:142609 --:------ 3120 007 0070B000009CFF
1974
- # .I --- 37:258565 --:------ 37:258565 3120 007 0080B0010003FF
1975
-
1976
- try:
1977
- assert payload[:2] == "00", f"byte 0: {payload[:2]}"
1978
- assert payload[2:4] in ("00", "70", "80"), f"byte 1: {payload[2:4]}"
1979
- assert payload[4:6] == "B0", f"byte 2: {payload[4:6]}"
1980
- assert payload[6:8] in ("00", "01"), f"byte 3: {payload[6:8]}"
1981
- assert payload[8:10] == "00", f"byte 4: {payload[8:10]}"
1982
- assert payload[10:12] in ("00", "03", "0A", "9C"), f"byte 5: {payload[10:12]}"
1983
- assert payload[12:] == "FF", f"byte 6: {payload[12:]}"
1984
- except AssertionError as exc:
1985
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({exc})")
1986
-
1987
- return {
1988
- f"{SZ_UNKNOWN}_0": payload[2:10],
1989
- f"{SZ_UNKNOWN}_5": payload[10:12],
1990
- f"{SZ_UNKNOWN}_2": payload[12:],
1991
- }
1992
-
1993
-
1994
- @parser_decorator # WIP: unknown, HVAC
1995
- def parser_313e(payload, msg) -> dict:
1996
- # 11:00:59.412 RP --- 32:153258 18:005904 --:------ 313E 011 00-0000007937-003C80-0000
1997
- # 11:02:23.961 RP --- 32:153258 18:005904 --:------ 313E 011 00-0000007B14-003C80-0000
1998
- # 11:03:32.193 RP --- 32:153258 18:005904 --:------ 313E 011 00-0000007C1C-003C80-0000
1999
-
2000
- assert payload[:2] == "00"
2001
- assert payload[12:] == "003C800000"
2002
-
2003
- return {
2004
- "value_02": payload[2:12],
2005
- "value_12": payload[12:18],
2006
- "value_18": payload[18:],
2007
- }
2008
-
2009
-
2010
- @parser_decorator # datetime
2011
- def parser_313f(payload, msg) -> dict: # TODO: look for TZ
2012
- # 2020-03-28T03:59:21.315178 045 RP --- 01:158182 04:136513 --:------ 313F 009 00FC3500A41C0307E4 # noqa: E501
2013
- # 2020-03-29T04:58:30.486343 045 RP --- 01:158182 04:136485 --:------ 313F 009 00FC8400C51D0307E4 # noqa: E501
2014
- # 2022-09-20T20:50:32.800676 065 RP --- 01:182924 18:068640 --:------ 313F 009 00F9203234140907E6
2015
- # 2020-05-31T11:37:50.351511 056 I --- --:------ --:------ 12:207082 313F 009 0038021ECB1F0507E4 # noqa: E501
2016
-
2017
- # https://www.automatedhome.co.uk/vbulletin/showthread.php?5085-My-HGI80-equivalent-Domoticz-setup-without-HGI80&p=36422&viewfull=1#post36422
2018
- # every day at ~4am TRV/RQ->CTL/RP, approx 5-10secs apart (CTL respond at any time)
2019
-
2020
- assert msg.src.type != DEV_TYPE_MAP.CTL or payload[2:4] in (
2021
- "F0",
2022
- "F9",
2023
- "FC",
2024
- ), f"{payload[2:4]} unexpected for CTL" # DEX
2025
- assert (
2026
- msg.src.type not in (DEV_TYPE_MAP.DTS, DEV_TYPE_MAP.DT2) or payload[2:4] == "38"
2027
- ), f"{payload[2:4]} unexpected for DTS" # DEX
2028
- # assert (
2029
- # msg.src.type != DEV_TYPE_MAP.FAN or payload[2:4] == "7C"
2030
- # ), f"{payload[2:4]} unexpected for FAN" # DEX
2031
- assert (
2032
- msg.src.type != DEV_TYPE_MAP.RFG or payload[2:4] == "60"
2033
- ), "{payload[2:4]} unexpected for RFG" # DEX
2034
-
2035
- return {
2036
- SZ_DATETIME: dtm_from_hex(payload[4:18]),
2037
- SZ_IS_DST: True if bool(int(payload[4:6], 16) & 0x80) else None,
2038
- f"_{SZ_UNKNOWN}_0": payload[2:4],
2039
- }
2040
-
2041
-
2042
- @parser_decorator # heat_demand (of device, FC domain) - valve status (%open)
2043
- def parser_3150(payload, msg) -> dict | list:
2044
- # event-driven, and periodically; FC domain is maximum of all zones
2045
- # TODO: all have a valid domain will UFC/CTL respond to an RQ, for FC, for a zone?
2046
-
2047
- # .I --- 04:136513 --:------ 01:158182 3150 002 01CA < often seen CA, artefact?
2048
-
2049
- def complex_idx(seqx, msg) -> dict:
2050
- # assert seqx[:2] == FC or (int(seqx[:2], 16) < MAX_ZONES) # <5, 8 for UFC
2051
- idx_name = "ufx_idx" if msg.src.type == DEV_TYPE_MAP.UFC else SZ_ZONE_IDX # DEX
2052
- return {SZ_DOMAIN_ID if seqx[:1] == "F" else idx_name: seqx[:2]}
2053
-
2054
- if msg._has_array:
2055
- return [
2056
- {
2057
- **complex_idx(payload[i : i + 2], msg),
2058
- **valve_demand(payload[i + 2 : i + 4]),
2059
- }
2060
- for i in range(0, len(payload), 4)
2061
- ]
2062
-
2063
- return valve_demand(payload[2:]) # TODO: check UFC/FC is == CTL/FC
2064
-
2065
-
2066
- @parser_decorator # fan state (basic), HVAC
2067
- def parser_31d9(payload, msg) -> dict:
2068
- # NOTE: I have a suspicion that Itho use 0x00-C8 for %, whilst Nuaire use 0x00-64
2069
- try:
2070
- assert (
2071
- payload[4:6] == "FF" or int(payload[4:6], 16) <= 200
2072
- ), f"byte 2: {payload[4:6]}"
2073
- except AssertionError as exc:
2074
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({exc})")
2075
-
2076
- bitmap = int(payload[2:4], 16)
2077
-
2078
- # NOTE: 31D9[4:6] is fan_rate (itho?) *or* fan_mode (orcon?)
2079
- result = {
2080
- SZ_EXHAUST_FAN_SPEED: percent_from_hex(payload[4:6], high_res=True), # itho
2081
- SZ_FAN_MODE: payload[4:6], # orcon
2082
- "passive": bool(bitmap & 0x02),
2083
- "damper_only": bool(bitmap & 0x04),
2084
- "filter_dirty": bool(bitmap & 0x20),
2085
- "frost_cycle": bool(bitmap & 0x40),
2086
- "has_fault": bool(bitmap & 0x80),
2087
- "_flags": flag8_from_hex(payload[2:4]),
2088
- }
2089
-
2090
- if msg.len == 3: # usu: I -->20: (no seq#)
2091
- return result
2092
-
2093
- try:
2094
- assert payload[6:8] in ("00", "07", "0A", "FE"), f"byte 3: {payload[6:8]}"
2095
- except AssertionError as exc:
2096
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({exc})")
2097
-
2098
- result.update({f"_{SZ_UNKNOWN}_3": payload[6:8]})
2099
-
2100
- if msg.len == 4: # usu: I -->20: (no seq#)
2101
- return result
2102
-
2103
- try:
2104
- assert payload[8:32] in ("00" * 12, "20" * 12), f"byte 4: {payload[8:32]}"
2105
- assert payload[32:] in ("00", "04", "08"), f"byte 16: {payload[32:]}"
2106
- except AssertionError as exc:
2107
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({exc})")
2108
-
2109
- return {
2110
- **result,
2111
- f"_{SZ_UNKNOWN}_4": payload[8:32],
2112
- f"{SZ_UNKNOWN}_16": payload[32:],
2113
- }
2114
-
2115
-
2116
- @parser_decorator # ventilation state (extended), HVAC
2117
- def parser_31da(payload, msg) -> dict:
2118
-
2119
- try:
2120
- # assert (
2121
- # int(payload[2:4], 16) <= 200
2122
- # or int(payload[2:4], 16) & 0xF0 == 0xF0
2123
- # or payload[2:4] == "EF"
2124
- # ), f"[2:4] {payload[2:4]}"
2125
- assert payload[4:6] in ("00", "40"), payload[4:6]
2126
- # assert payload[6:10] in ("07D0", "7FFF"), payload[6:10]
2127
- assert payload[10:12] == "EF" or int(payload[10:12], 16) <= 100, payload[10:12]
2128
- assert (
2129
- payload[12:14] == "EF" or int(payload[12:14], 16) <= 100
2130
- ), f"[12:14] {payload[10:12]}"
2131
- # assert payload[30:34] in ("0002", "F000", "F800", "F808", "7FFF"), payload[30:34]
2132
- # assert payload[34:36] == "EF", payload[34:36]
2133
- assert (
2134
- payload[36:38] == "EF" or int(payload[36:38], 16) & 0x1F <= 0x19
2135
- ), f"invalid _31DA_FAN_INFO: {payload[36:38]}"
2136
- assert int(payload[38:40], 16) <= 200 or payload[38:40] in (
2137
- "EF",
2138
- "FF",
2139
- ), payload[38:40]
2140
- # assert payload[40:42] in ("00", "EF", "FF"), payload[40:42]
2141
- assert payload[46:48] in ("00", "EF"), f"[46:48] {payload[46:48]}"
2142
- # assert payload[48:50] == "EF", payload[48:50]
2143
- except AssertionError as exc:
2144
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({exc})")
2145
-
2146
- # From an Orcon 15RF Display
2147
- # 1 Software version
2148
- # 4 RH value in home (%) SZ_INDOOR_HUMIDITY
2149
- # 5 RH value supply air (%) SZ_OUTDOOR_HUMIDITY
2150
- # 6 Exhaust air temperature out (°C) SZ_EXHAUST_TEMPERATURE
2151
- # 7 Supply air temperature to home (°C) SZ_SUPPLY_TEMPERATURE
2152
- # 8 Temperature from home (°C) SZ_INDOOR_TEMPERATURE
2153
- # 9 Temperature outside (°C) SZ_OUTDOOR_TEMPERATURE
2154
- # 10 Bypass position SZ_BYPASS_POSITION
2155
- # 11 Exhaust fan speed (%) SZ_EXHAUST_FAN_SPEED
2156
- # 12 Fan supply speed (%) SZ_SUPPLY_FAN_SPEED
2157
- # 13 Remaining after run time (humidity scenario) (min.) SZ_REMAINING_TIME
2158
- # 14 Preheater control (MaxComfort) (%) SZ_PRE_HEAT
2159
- # 16 Actual supply flow rate (m3/h) SZ_SUPPLY_FLOW (Orcon is m3/h, data is L/s)
2160
- # 17 Current discharge flow rate (m3/h) SZ_EXHAUST_FLOW
2161
-
2162
- return {
2163
- SZ_EXHAUST_FAN_SPEED: percent_from_hex(
2164
- payload[38:40]
2165
- ), # maybe 31D9[4:6] for some?
2166
- SZ_FAN_INFO: _31DA_FAN_INFO[int(payload[36:38], 16) & 0x1F], # 22F3-ish
2167
- SZ_REMAINING_TIME: double_from_hex(payload[42:46]), # mins, 22F3[2:6]
2168
- #
2169
- SZ_AIR_QUALITY: percent_from_hex(payload[2:4]), # 12C8[2:4]
2170
- SZ_AIR_QUALITY_BASE: int(payload[4:6], 16), # 12C8[4:6]
2171
- SZ_CO2_LEVEL: double_from_hex(payload[6:10]), # ppm, 1298[2:6]
2172
- SZ_INDOOR_HUMIDITY: percent_from_hex(payload[10:12], high_res=False), # 12A0?
2173
- SZ_OUTDOOR_HUMIDITY: percent_from_hex(payload[12:14], high_res=False),
2174
- SZ_EXHAUST_TEMPERATURE: temp_from_hex(payload[14:18]),
2175
- SZ_SUPPLY_TEMPERATURE: temp_from_hex(payload[18:22]),
2176
- SZ_INDOOR_TEMPERATURE: temp_from_hex(payload[22:26]),
2177
- SZ_OUTDOOR_TEMPERATURE: temp_from_hex(payload[26:30]), # 1290?
2178
- SZ_SPEED_CAP: int(payload[30:34], 16),
2179
- SZ_BYPASS_POSITION: percent_from_hex(payload[34:36]),
2180
- SZ_SUPPLY_FAN_SPEED: percent_from_hex(payload[40:42]),
2181
- SZ_POST_HEAT: percent_from_hex(payload[46:48], high_res=False),
2182
- SZ_PRE_HEAT: percent_from_hex(payload[48:50], high_res=False),
2183
- SZ_SUPPLY_FLOW: double_from_hex(payload[50:54], factor=100), # L/sec
2184
- SZ_EXHAUST_FLOW: double_from_hex(payload[54:58], factor=100), # L/sec
2185
- }
2186
-
2187
-
2188
- @parser_decorator # vent_demand, HVAC
2189
- def parser_31e0(payload, msg) -> dict:
2190
- """Notes are.
2191
-
2192
- van means “of”.
2193
- - 0 = min. van min. potm would be:
2194
- - 0 = minimum of minimum potentiometer
2195
-
2196
- See: https://www.industrialcontrolsonline.com/honeywell-t991a
2197
- - modulates air temperatures in ducts
2198
-
2199
- case 0x31E0: ' 12768:
2200
- {
2201
- string str4;
2202
- unchecked
2203
- {
2204
- result.Fan = Conversions.ToString((double)(int)data[checked(start + 1)] / 2.0);
2205
- str4 = "";
2206
- }
2207
- str4 = (data[start + 2] & 0xF) switch
2208
- {
2209
- 0 => str4 + "0 = min. potm. ",
2210
- 1 => str4 + "0 = min. van min. potm ",
2211
- 2 => str4 + "0 = min. fan ",
2212
- _ => "",
2213
- };
2214
- switch (data[start + 2] & 0xF0)
2215
- {
2216
- case 16:
2217
- str4 += "100 = max. potm";
2218
- break;
2219
- case 32:
2220
- str4 += "100 = max. van max. potm ";
2221
- break;
2222
- case 48:
2223
- str4 += "100 = max. fan ";
2224
- break;
2225
- }
2226
- result.Data = str4;
2227
- break;
2228
- }
2229
- """
2230
-
2231
- # .I --- 37:005302 32:132403 --:------ 31E0 008 00-0000-00 01-0064-00 # RF15 CO2 to Orcon HRC400 series SmartComfort Valve
2232
-
2233
- # .I --- 29:146052 32:023459 --:------ 31E0 003 00-0000
2234
- # .I --- 29:146052 32:023459 --:------ 31E0 003 00-00C8
2235
-
2236
- # .I --- 32:168240 30:079129 --:------ 31E0 004 00-0000-FF
2237
- # .I --- 32:168240 30:079129 --:------ 31E0 004 00-0000-FF
2238
- # .I --- 32:166025 --:------ 30:079129 31E0 004 00-0000-00
2239
-
2240
- # .I --- 32:168090 30:082155 --:------ 31E0 004 00-00C8-00
2241
- # .I --- 37:258565 37:261128 --:------ 31E0 004 00-0001-00
2242
-
2243
- def _parser(seqx) -> dict:
2244
- assert seqx[6:] in ("", "00", "FF")
2245
- return {
2246
- # "hvac_idx": seqx[:2],
2247
- "flags": seqx[2:4],
2248
- "vent_demand": percent_from_hex(seqx[4:6]),
2249
- f"_{SZ_UNKNOWN}_3": payload[6:],
2250
- }
2251
-
2252
- if len(payload) > 8:
2253
- return [_parser(payload[x : x + 8]) for x in range(0, len(payload), 8)]
2254
- return _parser(payload)
2255
-
2256
-
2257
- @parser_decorator # supplied boiler water (flow) temp
2258
- def parser_3200(payload, msg) -> dict:
2259
- return {SZ_TEMPERATURE: temp_from_hex(payload[2:])}
2260
-
2261
-
2262
- @parser_decorator # return (boiler) water temp
2263
- def parser_3210(payload, msg) -> dict:
2264
- return {SZ_TEMPERATURE: temp_from_hex(payload[2:])}
2265
-
2266
-
2267
- @parser_decorator # opentherm_msg, from OTB
2268
- def parser_3220(payload, msg) -> dict:
2269
-
2270
- try:
2271
- ot_type, ot_id, ot_value, ot_schema = decode_frame(payload[2:10])
2272
- except AssertionError as exc:
2273
- raise AssertionError(f"OpenTherm: {exc}") from exc
2274
- except ValueError as exc:
2275
- raise InvalidPayloadError(f"OpenTherm: {exc}") from exc
2276
-
2277
- # NOTE: Unknown-DataId isn't an invalid payload & is useful to train the OTB device
2278
- if ot_schema is None and ot_type != OtMsgType.UNKNOWN_DATAID:
2279
- raise InvalidPayloadError(f"OpenTherm: Unknown data-id: {ot_id}")
2280
-
2281
- result = {
2282
- MSG_ID: ot_id,
2283
- MSG_TYPE: ot_type,
2284
- MSG_NAME: ot_value.pop(MSG_NAME, None),
2285
- }
2286
-
2287
- if msg.verb == RQ: # RQs have a context: msg_id (and a payload)
2288
- assert (
2289
- ot_type != OtMsgType.READ_DATA
2290
- or payload[6:10] == "0000" # likely true for RAMSES
2291
- ), f"OpenTherm: Invalid msg-type|data-value: {ot_type}|{payload[6:10]}"
2292
-
2293
- if ot_type != OtMsgType.READ_DATA:
2294
- assert ot_type in (
2295
- OtMsgType.WRITE_DATA,
2296
- OtMsgType.INVALID_DATA,
2297
- ), f"OpenTherm: Invalid msg-type for RQ: {ot_type}"
2298
-
2299
- result.update(ot_value) # TODO: find some of these packets to review
2300
-
2301
- else: # if msg.verb == RP:
2302
- _LIST = (OtMsgType.DATA_INVALID, OtMsgType.UNKNOWN_DATAID, OtMsgType.RESERVED)
2303
- assert ot_type not in _LIST or payload[6:10] in (
2304
- "0000",
2305
- "FFFF",
2306
- ), f"OpenTherm: Invalid msg-type|data-value: {ot_type}|{payload[6:10]}"
2307
-
2308
- if ot_type not in _LIST:
2309
- assert ot_type in (
2310
- OtMsgType.READ_ACK,
2311
- OtMsgType.WRITE_ACK,
2312
- ), f"OpenTherm: Invalid msg-type for RP: {ot_type}"
2313
-
2314
- result.update(ot_value)
2315
-
2316
- try:
2317
- assert ot_id != 0 or (
2318
- [result[SZ_VALUE][i] for i in (2, 3, 4, 5, 6, 7)] == [0] * 6
2319
- ), result[SZ_VALUE]
2320
-
2321
- assert ot_id != 0 or (
2322
- [result[SZ_VALUE][8 + i] for i in (0, 4, 5, 6, 7)] == [0] * 5
2323
- ), result[SZ_VALUE]
2324
- except AssertionError:
2325
- _LOGGER.warning(
2326
- f"{msg!r} < {_INFORM_DEV_MSG}, with a description of your system"
2327
- )
2328
-
2329
- result[MSG_DESC] = ot_schema.get(EN)
2330
- return result
2331
-
2332
-
2333
- @parser_decorator # unknown_3221, from OTB, FAN
2334
- def parser_3221(payload, msg) -> dict:
2335
-
2336
- # RP --- 10:052644 18:198151 --:------ 3221 002 000F
2337
- # RP --- 10:048122 18:006402 --:------ 3221 002 0000
2338
- # RP --- 32:155617 18:005904 --:------ 3221 002 000A
2339
-
2340
- assert int(payload[2:], 16) <= 0xC8, _INFORM_DEV_MSG
2341
-
2342
- return {
2343
- f"_{SZ_PAYLOAD}": payload,
2344
- SZ_VALUE: int(payload[2:], 16),
2345
- }
2346
-
2347
-
2348
- @parser_decorator # WIP: unknown, HVAC
2349
- def parser_3222(payload, msg) -> dict:
2350
- # 06:30:14.322 RP --- 32:155617 18:005904 --:------ 3222 004 00-00-01-00
2351
- # 00:09:26.263 RP --- 32:155617 18:005904 --:------ 3222 005 00-00-02-0009
2352
- # 02:42:27.090 RP --- 32:155617 18:005904 --:------ 3222 007 00-06-04- 000F100E
2353
- # 22:06:45.771 RP --- 32:155617 18:005904 --:------ 3222 011 00-02-08- 0009000F000F100E
2354
- # 13:30:26.792 RP --- 32:155617 18:005904 --:------ 3222 012 00-01-09- 090009000F000F100E
2355
- # 06:29:40.767 RP --- 32:155617 18:005904 --:------ 3222 013 00-00-0A-00090009000F000F100E
2356
-
2357
- assert payload[:2] == "00"
2358
-
2359
- if msg.len == 3:
2360
- assert payload[4:] == "00"
2361
- return {"percentage": percent_from_hex(payload[2:4])}
2362
-
2363
- return {
2364
- "start": payload[2:4],
2365
- "length": payload[4:6],
2366
- "data": f"{'..' * int(payload[2:4])}{payload[6:]}",
2367
- }
2368
-
2369
-
2370
- @parser_decorator # unknown_3223, from OTB
2371
- def parser_3223(payload, msg) -> dict:
2372
-
2373
- assert int(payload[2:], 16) <= 0xC8, _INFORM_DEV_MSG
2374
-
2375
- return {
2376
- f"_{SZ_PAYLOAD}": payload,
2377
- SZ_VALUE: int(payload[2:], 16),
2378
- }
2379
-
2380
-
2381
- @parser_decorator # actuator_sync (aka sync_tpi: TPI cycle sync)
2382
- def parser_3b00(payload, msg) -> dict:
2383
- # system timing master: the device that sends I/FCC8 pkt controls the heater relay
2384
- """Decode a 3B00 packet (actuator_sync).
2385
-
2386
- The heat relay regularly broadcasts a 3B00 at the end(?) of every TPI cycle, the
2387
- frequency of which is determined by the (TPI) cycle rate in 1100.
2388
-
2389
- The CTL subsequently broadcasts a 3B00 (i.e. at the start of every TPI cycle).
2390
-
2391
- The OTB does not send these packets, but the CTL sends a regular broadcast anyway
2392
- for the benefit of any zone actuators (e.g. zone valve zones).
2393
- """
2394
-
2395
- # 053 I --- 13:209679 --:------ 13:209679 3B00 002 00C8
2396
- # 045 I --- 01:158182 --:------ 01:158182 3B00 002 FCC8
2397
- # 052 I --- 13:209679 --:------ 13:209679 3B00 002 00C8
2398
- # 045 I --- 01:158182 --:------ 01:158182 3B00 002 FCC8
2399
-
2400
- # 063 I --- 01:078710 --:------ 01:078710 3B00 002 FCC8
2401
- # 064 I --- 01:078710 --:------ 01:078710 3B00 002 FCC8
2402
-
2403
- def complex_idx(payload, msg) -> dict: # has complex idx
2404
- if (
2405
- msg.verb == I_
2406
- and msg.src.type in (DEV_TYPE_MAP.CTL, DEV_TYPE_MAP.PRG)
2407
- and msg.src is msg.dst
2408
- ): # DEX
2409
- assert payload[:2] == FC
2410
- return {SZ_DOMAIN_ID: FC}
2411
- assert payload[:2] == "00"
2412
- return {}
2413
-
2414
- assert msg.len == 2, msg.len
2415
- assert payload[:2] == {
2416
- DEV_TYPE_MAP.CTL: FC,
2417
- DEV_TYPE_MAP.BDR: "00",
2418
- DEV_TYPE_MAP.PRG: FC,
2419
- }.get(
2420
- msg.src.type, "00"
2421
- ) # DEX
2422
- assert payload[2:] == "C8", payload[2:] # Could it be a percentage?
2423
-
2424
- return {
2425
- **complex_idx(payload[:2], msg),
2426
- "actuator_sync": bool_from_hex(payload[2:]),
2427
- }
2428
-
2429
-
2430
- @parser_decorator # actuator_state
2431
- def parser_3ef0(payload, msg) -> dict:
2432
-
2433
- if msg.src.type == DEV_TYPE_MAP.JIM: # Honeywell Jasper
2434
- assert msg.len == 20, f"expecting len 20, got: {msg.len}"
2435
- return {
2436
- "ordinal": f"0x{payload[2:8]}",
2437
- "blob": payload[8:],
2438
- }
2439
-
2440
- # TODO: These two should be picked up by the regex
2441
- assert msg.len in (3, 6, 9), f"Invalid payload length: {msg.len}"
2442
- assert payload[:2] == "00", f"Invalid payload context: {payload[:2]}"
2443
-
2444
- if msg.len == 3: # I|BDR|003 (the following are the only two payloads ever seen)
2445
- # .I --- 13:042805 --:------ 13:042805 3EF0 003 0000FF
2446
- # .I --- 13:023770 --:------ 13:023770 3EF0 003 00C8FF
2447
- assert payload[2:4] in ("00", "C8"), f"byte 1: {payload[2:4]} (not 00/C8)"
2448
- assert payload[4:6] == "FF", f"byte 2: {payload[4:6]} (not FF)"
2449
- mod_level = percent_from_hex(payload[2:4]) # , high_res=True)
2450
-
2451
- else: # msg.len >= 6: # RP|OTB|006 (to RQ|CTL/HGI/RFG)
2452
- # RP --- 10:004598 34:003611 --:------ 3EF0 006 0000100000FF
2453
- # RP --- 10:004598 34:003611 --:------ 3EF0 006 0000110000FF
2454
- # RP --- 10:138822 01:187666 --:------ 3EF0 006 0064100C00FF
2455
- # RP --- 10:138822 01:187666 --:------ 3EF0 006 0064100200FF
2456
- assert payload[4:6] in ("10", "11"), f"byte 2: {payload[4:6]}" # maybe 00 too?
2457
- mod_level = percent_from_hex(payload[2:4], high_res=False) # 00-64 (or FF)
2458
-
2459
- result = {
2460
- "modulation_level": mod_level, # 0008[2:4], 3EF1[10:12]
2461
- "_flags_2": payload[4:6],
2462
- }
2463
-
2464
- if msg.len >= 6: # RP|OTB|006 (to RQ|CTL/HGI/RFG)
2465
- # RP --- 10:138822 01:187666 --:------ 3EF0 006 000110FA00FF # ?corrupt
2466
-
2467
- # for OTB (there's no reliable) modulation_level <-> flame_state)
2468
-
2469
- result.update(
2470
- {
2471
- "_flags_3": flag8_from_hex(payload[6:8]),
2472
- "ch_active": bool(int(payload[6:8], 0x10) & 1 << 1),
2473
- "dhw_active": bool(int(payload[6:8], 0x10) & 1 << 2),
2474
- "flame_active": bool(int(payload[6:8], 0x10) & 1 << 3), # flame_on
2475
- "_unknown_4": payload[8:10], # FF, 00, 01, 0A
2476
- "_unknown_5": payload[10:12], # FF, 1C, ?others
2477
- }
2478
- )
2479
-
2480
- if msg.len >= 9: # I/RP|OTB|009 (R8820A only?)
2481
- assert int(payload[12:14], 16) & 0b11111100 == 0, f"byte 6: {payload[12:14]}"
2482
- assert int(payload[12:14], 16) & 0b00000010 == 2, f"byte 6: {payload[12:14]}"
2483
- assert 10 <= int(payload[14:16], 16) <= 90, f"byte 7: {payload[14:16]}"
2484
- assert int(payload[16:18], 16) in (0, 100), f"byte 8: {payload[18:]}"
2485
-
2486
- result.update(
2487
- {
2488
- "_flags_6": flag8_from_hex(payload[12:14]),
2489
- "ch_enabled": bool(int(payload[12:14], 0x10) & 1 << 0),
2490
- "ch_setpoint": int(payload[14:16], 0x10),
2491
- "max_rel_modulation": percent_from_hex(payload[16:18], high_res=False),
2492
- }
2493
- )
2494
-
2495
- try: # Trying to decode flags...
2496
- # assert payload[4:6] != "11" or (
2497
- # payload[2:4] == "00"
2498
- # ), f"bytes 1+2: {payload[2:6]}" # 97% is 00 when 11, but not always
2499
-
2500
- assert payload[4:6] in ("FF", "10", "11"), f"byte 2: {payload[4:6]}"
2501
-
2502
- assert "_flags_3" not in result or (
2503
- payload[6:8] == "FF" or int(payload[6:8], 0x10) & 0b10110000 == 0
2504
- ), f'byte 3: {result["_flags_3"]}'
2505
- # only 01:10:040239 does 0b01000000
2506
-
2507
- assert "_unknown_4" not in result or (
2508
- payload[8:10] in ("FF", "00", "01", "04", "0A")
2509
- ), f"byte 4: {payload[8:10]}"
2510
- # only 10:040239 does 04
2511
-
2512
- assert "_unknown_5" not in result or (
2513
- payload[10:12] in ("00", "1C", "FF")
2514
- ), f"byte 5: {payload[10:12]}"
2515
-
2516
- assert "_flags_6" not in result or (
2517
- int(payload[12:14], 0x10) & 0b11111100 == 0
2518
- ), f'byte 6: {result["_flags_6"]}'
2519
-
2520
- except AssertionError as exc:
2521
- _LOGGER.warning(
2522
- f"{msg!r} < {_INFORM_DEV_MSG} ({exc}), with a description of your system"
2523
- )
2524
-
2525
- return result
2526
-
2527
-
2528
- @parser_decorator # actuator_cycle
2529
- def parser_3ef1(payload, msg) -> dict:
2530
-
2531
- if msg.src.type == DEV_TYPE_MAP.JIM: # Honeywell Jasper, DEX
2532
- assert msg.len == 18, f"expecting len 18, got: {msg.len}"
2533
- return {
2534
- "ordinal": f"0x{payload[2:8]}",
2535
- "blob": payload[8:],
2536
- }
2537
-
2538
- if (
2539
- msg.src.type == DEV_TYPE_MAP.JST
2540
- ): # and msg.len == 12: # or (12, 20) Japser, DEX
2541
- assert msg.len == 12, f"expecting len 12, got: {msg.len}"
2542
- return {
2543
- "ordinal": f"0x{payload[2:8]}",
2544
- "blob": payload[8:],
2545
- }
2546
-
2547
- if payload[12:] == "FF": # is BDR
2548
- # assert (
2549
- # re.compile(r"^00[0-9A-F]{10}FF").match(payload)
2550
- # ), "doesn't match: " + r"^00[0-9A-F]{10}FF"
2551
- assert int(payload[2:6], 16) <= 7200, f"byte 1: {payload[2:6]}"
2552
- # assert payload[6:10] in ("87B3", "9DFA", "DCE1", "E638", "F8F7") or (
2553
- # int(payload[6:10], 16) <= 7200
2554
- # ), f"byte 3: {payload[6:10]}"
2555
- assert percent_from_hex(payload[10:12]) in (0, 1), f"byte 5: {payload[10:12]}"
2556
-
2557
- else: # is OTB
2558
- # assert (
2559
- # re.compile(r"^00[0-9A-F]{10}10").match(payload)
2560
- # ), "doesn't match: " + r"^00[0-9A-F]{10}10"
2561
- assert payload[2:6] == "7FFF", f"byte 1: {payload[2:6]}"
2562
- assert payload[6:10] == "003C", f"byte 3: {payload[6:10]}" # 60 seconds
2563
- assert percent_from_hex(payload[10:12]) <= 1, f"byte 5: {payload[10:12]}"
2564
-
2565
- cycle_countdown = None if payload[2:6] == "7FFF" else int(payload[2:6], 16)
2566
-
2567
- return {
2568
- "modulation_level": percent_from_hex(payload[10:12]), # 0008[2:4], 3EF0[2:4]
2569
- "actuator_countdown": int(payload[6:10], 16),
2570
- "cycle_countdown": cycle_countdown,
2571
- f"_{SZ_UNKNOWN}_0": payload[12:],
2572
- }
2573
-
2574
-
2575
- @parser_decorator # timestamp, HVAC
2576
- def parser_4401(payload, msg) -> dict:
2577
-
2578
- if msg.verb == RP:
2579
- return {}
2580
-
2581
- # assert payload[:4] == "1000", _INFORM_DEV_MSG
2582
- # assert payload[24:] == "0000000000000063", _INFORM_DEV_MSG
2583
-
2584
- return {
2585
- "epoch_02": f"0x{payload[4:12]}",
2586
- "epoch_07": f"0x{payload[14:22]}",
2587
- "xxxxx_13": f"0x{payload[22:24]}",
2588
- "epoch_13": f"0x{payload[26:34]}",
2589
- } # epoch are in seconds
2590
-
2591
-
2592
- @parser_decorator # hvac_4e01
2593
- def parser_4e01(payload, msg) -> dict:
2594
- return {f"val_{x}": temp_from_hex(payload[x : x + 4]) for x in range(2, 34, 4)}
2595
-
2596
-
2597
- @parser_decorator # hvac_4e02
2598
- def parser_4e02(payload, msg) -> dict:
2599
-
2600
- return (
2601
- {f"val_{x}": temp_from_hex(payload[x : x + 4]) for x in range(2, 34, 4)}
2602
- | {"val_34": payload[34:36]}
2603
- | {f"val_{x}": temp_from_hex(payload[x : x + 4]) for x in range(36, 68, 4)}
2604
- )
2605
-
2606
-
2607
- # @parser_decorator # faked puzzle pkt shouldn't be decorated
2608
- def parser_7fff(payload, msg) -> dict:
2609
-
2610
- if payload[:2] != "00":
2611
- _LOGGER.debug("Invalid/deprecated Puzzle packet")
2612
- return {
2613
- "msg_type": payload[:2],
2614
- SZ_PAYLOAD: str_from_hex(payload[2:]),
2615
- }
2616
-
2617
- if payload[2:4] not in LOOKUP_PUZZ:
2618
- _LOGGER.debug("Invalid/deprecated Puzzle packet")
2619
- return {
2620
- "msg_type": payload[2:4],
2621
- "message": str_from_hex(payload[4:]),
2622
- }
2623
-
2624
- result: dict[str, None | str] = {}
2625
- if payload[2:4] != "13":
2626
- dtm = dt.fromtimestamp(int(payload[4:16], 16) / 1000) # TZ-naive
2627
- result["datetime"] = dtm.isoformat(timespec="milliseconds")
2628
-
2629
- msg_type = LOOKUP_PUZZ.get(payload[2:4], SZ_PAYLOAD)
2630
-
2631
- if payload[2:4] == "11":
2632
- msg = str_from_hex(payload[16:])
2633
- result[msg_type] = f"{msg[:4]}|{msg[4:6]}|{msg[6:]}"
2634
-
2635
- elif payload[2:4] == "13":
2636
- result[msg_type] = str_from_hex(payload[4:])
2637
-
2638
- elif payload[2:4] == "7F":
2639
- result[msg_type] = payload[4:]
2640
-
2641
- else:
2642
- result[msg_type] = str_from_hex(payload[16:])
2643
-
2644
- return {**result, "parser": f"v{VERSION}"}
2645
-
2646
-
2647
- @parser_decorator
2648
- def parser_unknown(payload, msg) -> dict:
2649
- # TODO: it may be useful to generically search payloads for hex_ids, commands, etc.
2650
-
2651
- # These are generic parsers
2652
- if msg.len == 2 and payload[:2] == "00":
2653
- return {
2654
- f"_{SZ_PAYLOAD}": payload,
2655
- f"_{SZ_VALUE}": {"00": False, "C8": True}.get(
2656
- payload[2:], int(payload[2:], 16)
2657
- ),
2658
- }
2659
-
2660
- if msg.len == 3 and payload[:2] == "00":
2661
- return {
2662
- f"_{SZ_PAYLOAD}": payload,
2663
- f"_{SZ_VALUE}": temp_from_hex(payload[2:]),
2664
- }
2665
-
2666
- raise NotImplementedError
2667
-
2668
-
2669
- PAYLOAD_PARSERS = {
2670
- k[7:].upper(): v
2671
- for k, v in locals().items()
2672
- if callable(v) and k.startswith("parser_") and len(k) == 11
2673
- }