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