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