ramses-rf 0.22.40__py3-none-any.whl → 0.51.2__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 +279 -171
- ramses_rf/dispatcher.py +153 -177
- ramses_rf/entity_base.py +478 -361
- ramses_rf/exceptions.py +82 -0
- ramses_rf/gateway.py +377 -513
- 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.2.dist-info/METADATA +72 -0
- ramses_rf-0.51.2.dist-info/RECORD +55 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info}/WHEEL +1 -2
- ramses_rf-0.51.2.dist-info/entry_points.txt +2 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.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_rf/protocol → ramses_tx}/parsers.py +1266 -1003
- 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 -1576
- 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/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.40.dist-info/METADATA +0 -64
- ramses_rf-0.22.40.dist-info/RECORD +0 -42
- ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
ramses_tx/helpers.py
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""RAMSES RF - Protocol/Transport layer - Helper functions."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ctypes
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Iterable, Mapping
|
|
10
|
+
from datetime import date, datetime as dt
|
|
11
|
+
from typing import TYPE_CHECKING, Final, Literal, TypeAlias
|
|
12
|
+
|
|
13
|
+
from .address import hex_id_to_dev_id
|
|
14
|
+
from .const import (
|
|
15
|
+
FAULT_DEVICE_CLASS,
|
|
16
|
+
FAULT_STATE,
|
|
17
|
+
FAULT_TYPE,
|
|
18
|
+
SZ_AIR_QUALITY,
|
|
19
|
+
SZ_AIR_QUALITY_BASIS,
|
|
20
|
+
SZ_BYPASS_POSITION,
|
|
21
|
+
SZ_CO2_LEVEL,
|
|
22
|
+
SZ_DEVICE_CLASS,
|
|
23
|
+
SZ_DEVICE_ID,
|
|
24
|
+
SZ_DEWPOINT_TEMP,
|
|
25
|
+
SZ_DOMAIN_IDX,
|
|
26
|
+
SZ_EXHAUST_FAN_SPEED,
|
|
27
|
+
SZ_EXHAUST_FLOW,
|
|
28
|
+
SZ_EXHAUST_TEMP,
|
|
29
|
+
SZ_FAN_INFO,
|
|
30
|
+
SZ_FAULT_STATE,
|
|
31
|
+
SZ_FAULT_TYPE,
|
|
32
|
+
SZ_HEAT_DEMAND,
|
|
33
|
+
SZ_INDOOR_HUMIDITY,
|
|
34
|
+
SZ_INDOOR_TEMP,
|
|
35
|
+
SZ_LOG_IDX,
|
|
36
|
+
SZ_OUTDOOR_HUMIDITY,
|
|
37
|
+
SZ_OUTDOOR_TEMP,
|
|
38
|
+
SZ_POST_HEAT,
|
|
39
|
+
SZ_PRE_HEAT,
|
|
40
|
+
SZ_REL_HUMIDITY,
|
|
41
|
+
SZ_REMAINING_MINS,
|
|
42
|
+
SZ_SPEED_CAPABILITIES,
|
|
43
|
+
SZ_SUPPLY_FAN_SPEED,
|
|
44
|
+
SZ_SUPPLY_FLOW,
|
|
45
|
+
SZ_SUPPLY_TEMP,
|
|
46
|
+
SZ_TEMPERATURE,
|
|
47
|
+
SZ_TIMESTAMP,
|
|
48
|
+
FaultDeviceClass,
|
|
49
|
+
FaultState,
|
|
50
|
+
FaultType,
|
|
51
|
+
)
|
|
52
|
+
from .ramses import _31DA_FAN_INFO
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from .typed_dicts import PayDictT
|
|
56
|
+
|
|
57
|
+
# Sensor faults
|
|
58
|
+
SZ_UNRELIABLE: Final = "unreliable"
|
|
59
|
+
SZ_TOO_HIGH: Final = "out_of_range_high"
|
|
60
|
+
SZ_TOO_LOW: Final = "out_of_range_low"
|
|
61
|
+
# Actuator, Valve/damper faults
|
|
62
|
+
SZ_STUCK_VALVE: Final = "stuck_valve" # Damper/Valve jammed
|
|
63
|
+
SZ_STUCK_ACTUATOR: Final = "stuck_actuator" # Actuator jammed
|
|
64
|
+
# Common (to both) faults
|
|
65
|
+
SZ_OPEN_CIRCUIT: Final = "open_circuit"
|
|
66
|
+
SZ_SHORT_CIRCUIT: Final = "short_circuit"
|
|
67
|
+
SZ_UNAVAILABLE: Final = "unavailable"
|
|
68
|
+
SZ_OTHER_FAULT: Final = "other_fault" # Non-specific fault
|
|
69
|
+
|
|
70
|
+
DEVICE_FAULT_CODES = {
|
|
71
|
+
0x0: SZ_OPEN_CIRCUIT, # NOTE: open, short
|
|
72
|
+
0x1: SZ_SHORT_CIRCUIT,
|
|
73
|
+
0x2: SZ_UNAVAILABLE,
|
|
74
|
+
0xD: SZ_STUCK_VALVE,
|
|
75
|
+
0xE: SZ_STUCK_ACTUATOR,
|
|
76
|
+
0xF: SZ_OTHER_FAULT,
|
|
77
|
+
}
|
|
78
|
+
SENSOR_FAULT_CODES = {
|
|
79
|
+
0x0: SZ_SHORT_CIRCUIT, # NOTE: short, open
|
|
80
|
+
0x1: SZ_OPEN_CIRCUIT,
|
|
81
|
+
0x2: SZ_UNAVAILABLE,
|
|
82
|
+
0x3: SZ_TOO_HIGH,
|
|
83
|
+
0x4: SZ_TOO_LOW,
|
|
84
|
+
0x5: SZ_UNRELIABLE,
|
|
85
|
+
# 0xF: SZ_OTHER_FAULT, # No evidence is explicitly part of the specification
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# TODO: consider returning from helpers as TypeGuard[HexByte]
|
|
90
|
+
# fmt: off
|
|
91
|
+
HexByteAlt = Literal[
|
|
92
|
+
'00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '0A', '0B', '0C', '0D', '0E', '0F',
|
|
93
|
+
'10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '1A', '1B', '1C', '1D', '1E', '1F',
|
|
94
|
+
'20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '2A', '2B', '2C', '2D', '2E', '2F',
|
|
95
|
+
'30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '3A', '3B', '3C', '3D', '3E', '3F',
|
|
96
|
+
'40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '4A', '4B', '4C', '4D', '4E', '4F',
|
|
97
|
+
'50', '51', '52', '53', '54', '55', '56', '57', '58', '59', '5A', '5B', '5C', '5D', '5E', '5F',
|
|
98
|
+
'60', '61', '62', '63', '64', '65', '66', '67', '68', '69', '6A', '6B', '6C', '6D', '6E', '6F',
|
|
99
|
+
'70', '71', '72', '73', '74', '75', '76', '77', '78', '79', '7A', '7B', '7C', '7D', '7E', '7F',
|
|
100
|
+
'80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '8A', '8B', '8C', '8D', '8E', '8F',
|
|
101
|
+
'90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '9A', '9B', '9C', '9D', '9E', '9F',
|
|
102
|
+
'A0', 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9', 'AA', 'AB', 'AC', 'AD', 'AE', 'AF',
|
|
103
|
+
'B0', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B9', 'BA', 'BB', 'BC', 'BD', 'BE', 'BF',
|
|
104
|
+
'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9', 'CA', 'CB', 'CC', 'CD', 'CE', 'CF',
|
|
105
|
+
'D0', 'D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8', 'D9', 'DA', 'DB', 'DC', 'DD', 'DE', 'DF',
|
|
106
|
+
'E0', 'E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'E9', 'EA', 'EB', 'EC', 'ED', 'EE', 'EF',
|
|
107
|
+
'F0', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'FA', 'FB', 'FC', 'FD', 'FE', 'FF'
|
|
108
|
+
]
|
|
109
|
+
# fmt: on
|
|
110
|
+
|
|
111
|
+
HexByte: TypeAlias = str
|
|
112
|
+
HexStr2: TypeAlias = str # two characters, one byte
|
|
113
|
+
HexStr4: TypeAlias = str
|
|
114
|
+
HexStr8: TypeAlias = str
|
|
115
|
+
HexStr12: TypeAlias = str
|
|
116
|
+
HexStr14: TypeAlias = str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
ReturnValueDictT: TypeAlias = Mapping[str, float | str | None]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class _FILE_TIME(ctypes.Structure):
|
|
123
|
+
"""Data structure for GetSystemTimePreciseAsFileTime()."""
|
|
124
|
+
|
|
125
|
+
_fields_ = [("dwLowDateTime", ctypes.c_uint), ("dwHighDateTime", ctypes.c_uint)]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
file_time = _FILE_TIME()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def timestamp() -> float:
|
|
132
|
+
"""Return the number of seconds since the Unix epoch.
|
|
133
|
+
|
|
134
|
+
Return an accurate value, even for Windows-based systems.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
# see: https://www.python.org/dev/peps/pep-0564/
|
|
138
|
+
if sys.platform != "win32": # since 1970-01-01T00:00:00Z, time.gmtime(0)
|
|
139
|
+
return time.time_ns() / 1e9
|
|
140
|
+
|
|
141
|
+
# otherwise, is since 1601-01-01T00:00:00Z
|
|
142
|
+
ctypes.windll.kernel32.GetSystemTimePreciseAsFileTime(ctypes.byref(file_time)) # type: ignore[unreachable]
|
|
143
|
+
_time = (file_time.dwLowDateTime + (file_time.dwHighDateTime << 32)) / 1e7
|
|
144
|
+
return _time - 134774 * 24 * 60 * 60
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def dt_now() -> dt:
|
|
148
|
+
"""Return the current datetime as a local/naive datetime object.
|
|
149
|
+
|
|
150
|
+
This is slower, but potentially more accurate, than dt.now(), and is used mainly for
|
|
151
|
+
packet timestamps.
|
|
152
|
+
"""
|
|
153
|
+
if sys.platform == "win32":
|
|
154
|
+
return dt.fromtimestamp(timestamp())
|
|
155
|
+
return dt.now()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def dt_str() -> str:
|
|
159
|
+
"""Return the current datetime as an isoformat string."""
|
|
160
|
+
return dt_now().isoformat(timespec="microseconds")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
####################################################################################################
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def hex_to_bool(value: HexStr2) -> bool | None: # either False, True or None
|
|
167
|
+
"""Convert a 2-char hex string into a boolean."""
|
|
168
|
+
if not isinstance(value, str) or len(value) != 2:
|
|
169
|
+
raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")
|
|
170
|
+
if value == "FF":
|
|
171
|
+
return None
|
|
172
|
+
return {"00": False, "C8": True}[value]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def hex_from_bool(value: bool | None) -> HexStr2: # either 00, C8 or FF
|
|
176
|
+
"""Convert a boolean into a 2-char hex string."""
|
|
177
|
+
if value is None:
|
|
178
|
+
return "FF"
|
|
179
|
+
if not isinstance(value, bool):
|
|
180
|
+
raise ValueError(f"Invalid value: {value}, is not bool")
|
|
181
|
+
return {False: "00", True: "C8"}[value]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def hex_to_date(value: HexStr8) -> str | None: # YY-MM-DD
|
|
185
|
+
"""Convert am 8-char hex string into a date, format YY-MM-DD."""
|
|
186
|
+
if not isinstance(value, str) or len(value) != 8:
|
|
187
|
+
raise ValueError(f"Invalid value: {value}, is not an 8-char hex string")
|
|
188
|
+
if value == "FFFFFFFF":
|
|
189
|
+
return None
|
|
190
|
+
return dt(
|
|
191
|
+
year=int(value[4:8], 16),
|
|
192
|
+
month=int(value[2:4], 16),
|
|
193
|
+
day=int(value[:2], 16) & 0b11111, # 1st 3 bits: DayOfWeek
|
|
194
|
+
).strftime("%Y-%m-%d")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# FIXME: factor=1 should return an int
|
|
198
|
+
def hex_to_double(value: HexStr4, factor: int = 1) -> float | None:
|
|
199
|
+
"""Convert a 4-char hex string into a double."""
|
|
200
|
+
if not isinstance(value, str) or len(value) != 4:
|
|
201
|
+
raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")
|
|
202
|
+
if value == "7FFF":
|
|
203
|
+
return None
|
|
204
|
+
return int(value, 16) / factor
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def hex_from_double(value: float | None, factor: int = 1) -> HexStr4:
|
|
208
|
+
"""Convert a double into 4-char hex string."""
|
|
209
|
+
if value is None:
|
|
210
|
+
return "7FFF"
|
|
211
|
+
if not isinstance(value, float | int):
|
|
212
|
+
raise ValueError(f"Invalid value: {value}, is not a double (a float/int)")
|
|
213
|
+
return f"{int(value * factor):04X}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def hex_to_dtm(value: HexStr12 | HexStr14) -> str | None: # from parsers
|
|
217
|
+
"""Convert a 12/14-char hex string to an isoformat datetime (naive, local)."""
|
|
218
|
+
# 00141B0A07E3 (...HH:MM:00) for system_mode, zone_mode (schedules?)
|
|
219
|
+
# 0400041C0A07E3 (...HH:MM:SS) for sync_datetime
|
|
220
|
+
|
|
221
|
+
if not isinstance(value, str) or len(value) not in (12, 14):
|
|
222
|
+
raise ValueError(f"Invalid value: {value}, is not a 12/14-char hex string")
|
|
223
|
+
if value[-12:] == "FF" * 6:
|
|
224
|
+
return None
|
|
225
|
+
if len(value) == 12:
|
|
226
|
+
value = f"00{value}"
|
|
227
|
+
return dt(
|
|
228
|
+
year=int(value[10:14], 16),
|
|
229
|
+
month=int(value[8:10], 16),
|
|
230
|
+
day=int(value[6:8], 16),
|
|
231
|
+
hour=int(value[4:6], 16) & 0b11111, # 1st 3 bits: DayOfWeek
|
|
232
|
+
minute=int(value[2:4], 16),
|
|
233
|
+
second=int(value[:2], 16) & 0b1111111, # 1st bit: used for DST
|
|
234
|
+
).isoformat(timespec="seconds")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def hex_from_dtm(
|
|
238
|
+
dtm: date | dt | str | None, is_dst: bool = False, incl_seconds: bool = False
|
|
239
|
+
) -> HexStr12 | HexStr14:
|
|
240
|
+
"""Convert a datetime (isoformat str, or naive dtm) to a 12/14-char hex str."""
|
|
241
|
+
|
|
242
|
+
def _dtm_to_hex(year, mon, mday, hour, min, sec, *args: int) -> str: # type: ignore[no-untyped-def]
|
|
243
|
+
return f"{sec:02X}{min:02X}{hour:02X}{mday:02X}{mon:02X}{year:04X}"
|
|
244
|
+
|
|
245
|
+
if dtm is None:
|
|
246
|
+
return "FF" * (7 if incl_seconds else 6)
|
|
247
|
+
if isinstance(dtm, str):
|
|
248
|
+
dtm = dt.fromisoformat(dtm)
|
|
249
|
+
dtm_str = _dtm_to_hex(*dtm.timetuple()) # TODO: add DST for tm_isdst
|
|
250
|
+
if is_dst:
|
|
251
|
+
dtm_str = f"{int(dtm_str[:2], 16) | 0x80:02X}" + dtm_str[2:]
|
|
252
|
+
return dtm_str if incl_seconds else dtm_str[2:]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def hex_to_dts(value: HexStr12) -> str | None:
|
|
256
|
+
"""YY-MM-DD HH:MM:SS."""
|
|
257
|
+
if not isinstance(value, str) or len(value) != 12:
|
|
258
|
+
raise ValueError(f"Invalid value: {value}, is not a 12-char hex string")
|
|
259
|
+
if value == "00000000007F":
|
|
260
|
+
return None
|
|
261
|
+
_seqx = int(value, 16)
|
|
262
|
+
return dt(
|
|
263
|
+
year=(_seqx & 0b1111111 << 24) >> 24,
|
|
264
|
+
month=(_seqx & 0b1111 << 36) >> 36,
|
|
265
|
+
day=(_seqx & 0b11111 << 31) >> 31,
|
|
266
|
+
hour=(_seqx & 0b11111 << 19) >> 19,
|
|
267
|
+
minute=(_seqx & 0b111111 << 13) >> 13,
|
|
268
|
+
second=(_seqx & 0b111111 << 7) >> 7,
|
|
269
|
+
).strftime("%y-%m-%dT%H:%M:%S")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def hex_from_dts(dtm: dt | str | None) -> HexStr12: # TODO: WIP
|
|
273
|
+
"""Convert a datetime (isoformat str, or dtm) to a packed 12-char hex str."""
|
|
274
|
+
"""YY-MM-DD HH:MM:SS."""
|
|
275
|
+
if dtm is None:
|
|
276
|
+
return "00000000007F"
|
|
277
|
+
if isinstance(dtm, str):
|
|
278
|
+
try:
|
|
279
|
+
dtm = dt.strptime(dtm, "%y-%m-%dT%H:%M:%S")
|
|
280
|
+
except ValueError:
|
|
281
|
+
dtm = dt.fromisoformat(dtm) # type: ignore[arg-type]
|
|
282
|
+
|
|
283
|
+
(tm_year, tm_mon, tm_mday, tm_hour, tm_min, tm_sec, *_) = dtm.timetuple()
|
|
284
|
+
result = sum(
|
|
285
|
+
(
|
|
286
|
+
tm_year % 100 << 24,
|
|
287
|
+
tm_mon << 36,
|
|
288
|
+
tm_mday << 31,
|
|
289
|
+
tm_hour << 19,
|
|
290
|
+
tm_min << 13,
|
|
291
|
+
tm_sec << 7,
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
return f"{result:012X}"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def hex_to_flag8(byte: HexByte, lsb: bool = False) -> list[int]: # TODO: use tuple
|
|
298
|
+
"""Split a hex str (a byte) into a list of 8 bits, MSB as first bit by default.
|
|
299
|
+
|
|
300
|
+
If lsb==True, then the LSB is first.
|
|
301
|
+
The `lsb` boolean is used so that flag[0] is `zone_idx["00"]`, etc.
|
|
302
|
+
"""
|
|
303
|
+
if not isinstance(byte, str) or len(byte) != 2:
|
|
304
|
+
raise ValueError(f"Invalid value: '{byte}', is not a 2-char hex string")
|
|
305
|
+
if lsb: # make LSB is first bit
|
|
306
|
+
return list((int(byte, 16) & (1 << x)) >> x for x in range(8))
|
|
307
|
+
return list((int(byte, 16) & (1 << x)) >> x for x in reversed(range(8)))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def hex_from_flag8(flags: Iterable[int], lsb: bool = False) -> HexByte:
|
|
311
|
+
"""Convert list of 8 bits, MSB bit 1 by default, to a two-char ASCII hex string.
|
|
312
|
+
|
|
313
|
+
The `lsb` boolean is used so that flag[0] is `zone_idx["00"]`, etc.
|
|
314
|
+
"""
|
|
315
|
+
if not isinstance(flags, list) or len(flags) != 8:
|
|
316
|
+
raise ValueError(f"Invalid value: '{flags}', is not a list of 8 bits")
|
|
317
|
+
if lsb: # LSB is first bit
|
|
318
|
+
return f"{sum(x << idx for idx, x in enumerate(flags)):02X}"
|
|
319
|
+
return f"{sum(x << idx for idx, x in enumerate(reversed(flags))):02X}"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# TODO: add a wrapper for EF, & 0xF0
|
|
323
|
+
def hex_to_percent(
|
|
324
|
+
value: HexStr2, high_res: bool = True
|
|
325
|
+
) -> float | None: # c.f. valve_demand
|
|
326
|
+
"""Convert a 2-char hex string into a percentage.
|
|
327
|
+
|
|
328
|
+
The range is 0-100%, with resolution of 0.5% (high_res, 00-C8) or 1% (00-64).
|
|
329
|
+
"""
|
|
330
|
+
if not isinstance(value, str) or len(value) != 2:
|
|
331
|
+
raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")
|
|
332
|
+
if value == "EF": # TODO: when EF, when 7F?
|
|
333
|
+
return None # TODO: raise NotImplementedError
|
|
334
|
+
if (raw_result := int(value, 16)) & 0xF0 == 0xF0:
|
|
335
|
+
return None # TODO: raise errors
|
|
336
|
+
result = float(raw_result) / (200 if high_res else 100)
|
|
337
|
+
if result > 1.0: # move to outer wrapper
|
|
338
|
+
raise ValueError(f"Invalid result: {result} (0x{value}) is > 1")
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def hex_from_percent(value: float | None, high_res: bool = True) -> HexStr2:
|
|
343
|
+
"""Convert a percentage into a 2-char hex string.
|
|
344
|
+
|
|
345
|
+
The range is 0-100%, with resolution of 0.5% (high_res, 00-C8) or 1% (00-64).
|
|
346
|
+
"""
|
|
347
|
+
if value is None:
|
|
348
|
+
return "EF"
|
|
349
|
+
if not isinstance(value, float | int) or not 0 <= value <= 1:
|
|
350
|
+
raise ValueError(f"Invalid value: {value}, is not a percentage")
|
|
351
|
+
result = int(value * (200 if high_res else 100))
|
|
352
|
+
return f"{result:02X}"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def hex_to_str(value: str) -> str: # printable ASCII characters
|
|
356
|
+
"""Return a string of printable ASCII characters."""
|
|
357
|
+
# result = bytearray.fromhex(value).split(b"\x7F")[0] # TODO: needs checking
|
|
358
|
+
if not isinstance(value, str):
|
|
359
|
+
raise ValueError(f"Invalid value: {value}, is not a string")
|
|
360
|
+
result = bytearray([x for x in bytearray.fromhex(value) if 31 < x < 127])
|
|
361
|
+
return result.decode("ascii").strip() if result else ""
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def hex_from_str(value: str) -> str:
|
|
365
|
+
"""Convert a string to a variable-length ASCII hex string."""
|
|
366
|
+
if not isinstance(value, str):
|
|
367
|
+
raise ValueError(f"Invalid value: {value}, is not a string")
|
|
368
|
+
return "".join(f"{ord(x):02X}" for x in value) # or: value.encode().hex()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def hex_to_temp(value: HexStr4) -> bool | float | None: # TODO: remove bool
|
|
372
|
+
"""Convert a 2's complement 4-byte hex string to a float."""
|
|
373
|
+
if not isinstance(value, str) or len(value) != 4:
|
|
374
|
+
raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")
|
|
375
|
+
if value == "31FF": # means: N/A (== 127.99, 2s complement), signed?
|
|
376
|
+
return None
|
|
377
|
+
if value == "7EFF": # possibly only for setpoints? unsigned?
|
|
378
|
+
return False
|
|
379
|
+
if value == "7FFF": # also: FFFF?, means: N/A (== 327.67)
|
|
380
|
+
return None
|
|
381
|
+
temp: float = int(value, 16)
|
|
382
|
+
temp = (temp if temp < 2**15 else temp - 2**16) / 100
|
|
383
|
+
if temp < -273.15:
|
|
384
|
+
raise ValueError(f"Invalid value: {temp} (0x{value}) is < -273.15")
|
|
385
|
+
return temp
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def hex_from_temp(value: bool | float | None) -> HexStr4:
|
|
389
|
+
"""Convert a float to a 2's complement 4-byte hex string."""
|
|
390
|
+
if value is None:
|
|
391
|
+
return "7FFF" # or: "31FF"?
|
|
392
|
+
if value is False:
|
|
393
|
+
return "7EFF"
|
|
394
|
+
if not isinstance(value, float | int):
|
|
395
|
+
raise TypeError(f"Invalid temp: {value} is not a float")
|
|
396
|
+
# if not -(2**7) <= value < 2**7: # TODO: tighten range
|
|
397
|
+
# raise ValueError(f"Invalid temp: {value} is out of range")
|
|
398
|
+
temp = int(value * 100)
|
|
399
|
+
return f"{temp if temp >= 0 else temp + 2**16:04X}"
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
########################################################################################
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def parse_fault_log_entry(
|
|
406
|
+
payload: str,
|
|
407
|
+
) -> PayDictT.FAULT_LOG_ENTRY | PayDictT.FAULT_LOG_ENTRY_NULL:
|
|
408
|
+
"""Return the fault log entry."""
|
|
409
|
+
|
|
410
|
+
assert len(payload) == 44
|
|
411
|
+
|
|
412
|
+
# NOTE: the log_idx will increment as the entry moves down the log, hence '_log_idx'
|
|
413
|
+
|
|
414
|
+
# these are only useful for I_, not RP
|
|
415
|
+
if (timestamp := hex_to_dts(payload[18:30])) is None:
|
|
416
|
+
return {f"_{SZ_LOG_IDX}": payload[4:6]} # type: ignore[misc,return-value]
|
|
417
|
+
|
|
418
|
+
result: PayDictT.FAULT_LOG_ENTRY = {
|
|
419
|
+
f"_{SZ_LOG_IDX}": payload[4:6], # type: ignore[misc]
|
|
420
|
+
SZ_TIMESTAMP: timestamp,
|
|
421
|
+
SZ_FAULT_STATE: FAULT_STATE.get(payload[2:4], FaultState.UNKNOWN),
|
|
422
|
+
SZ_FAULT_TYPE: FAULT_TYPE.get(payload[8:10], FaultType.UNKNOWN),
|
|
423
|
+
SZ_DOMAIN_IDX: payload[10:12],
|
|
424
|
+
SZ_DEVICE_CLASS: FAULT_DEVICE_CLASS.get(
|
|
425
|
+
payload[12:14], FaultDeviceClass.UNKNOWN
|
|
426
|
+
),
|
|
427
|
+
SZ_DEVICE_ID: hex_id_to_dev_id(payload[38:]),
|
|
428
|
+
"_unknown_3": payload[6:8], # B0 ?priority
|
|
429
|
+
"_unknown_7": payload[14:18], # 0000
|
|
430
|
+
"_unknown_15": payload[30:38], # FFFF7000/1/2
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return result
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _faulted_common(param_name: str, value: str) -> dict[str, str]:
|
|
437
|
+
return {f"{param_name}_fault": f"invalid_{value}"}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _faulted_sensor(param_name: str, value: str) -> dict[str, str]:
|
|
441
|
+
# assert value[:1] in ("8", "F"), value
|
|
442
|
+
code = int(value[:2], 16) & 0xF
|
|
443
|
+
fault = SENSOR_FAULT_CODES.get(code, f"invalid_{value}")
|
|
444
|
+
return {f"{param_name}_fault": fault}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _faulted_device(param_name: str, value: str) -> dict[str, str]:
|
|
448
|
+
assert value[:1] in ("8", "F"), value
|
|
449
|
+
code = int(value[:2], 16) & 0xF
|
|
450
|
+
fault: str = DEVICE_FAULT_CODES.get(code, f"invalid_{value}")
|
|
451
|
+
return {f"{param_name}_fault": fault}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# TODO: refactor as per 31DA parsers
|
|
455
|
+
def parse_valve_demand(
|
|
456
|
+
value: HexStr2,
|
|
457
|
+
) -> dict[str, float] | dict[str, str] | dict[str, None]:
|
|
458
|
+
"""Convert a 2-char hex string into a percentage.
|
|
459
|
+
|
|
460
|
+
The range is 0-100%, with resolution of 0.5% (high_res) or 1%.
|
|
461
|
+
""" # for a damper (restricts flow), or a valve (permits flow)
|
|
462
|
+
|
|
463
|
+
# TODO: remove this...
|
|
464
|
+
if not isinstance(value, str) or len(value) != 2:
|
|
465
|
+
raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")
|
|
466
|
+
|
|
467
|
+
if value == "EF":
|
|
468
|
+
return {SZ_HEAT_DEMAND: None} # Not Implemented
|
|
469
|
+
|
|
470
|
+
if int(value, 16) & 0xF0 == 0xF0:
|
|
471
|
+
return _faulted_device(SZ_HEAT_DEMAND, value)
|
|
472
|
+
|
|
473
|
+
result = int(value, 16) / 200 # c.f. hex_to_percentage
|
|
474
|
+
if result == 1.01: # HACK - does it mean maximum?
|
|
475
|
+
result = 1.0
|
|
476
|
+
elif result > 1.0:
|
|
477
|
+
raise ValueError(f"Invalid result: {result} (0x{value}) is > 1")
|
|
478
|
+
|
|
479
|
+
return {SZ_HEAT_DEMAND: result}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# 31DA[2:6] and 12C8[2:6]
|
|
483
|
+
def parse_air_quality(value: HexStr4) -> PayDictT.AIR_QUALITY:
|
|
484
|
+
"""Return the air quality (%): poor (0.0) to excellent (1.0).
|
|
485
|
+
|
|
486
|
+
The basis of the air quality level should be one of: VOC, CO2 or relative humidity.
|
|
487
|
+
If air_quality is EF, air_quality_basis should be 00.
|
|
488
|
+
|
|
489
|
+
The sensor value is None if there is no sensor present (is not an error).
|
|
490
|
+
The dict does not include the key if there is a sensor fault.
|
|
491
|
+
""" # VOC: Volatile organic compounds
|
|
492
|
+
|
|
493
|
+
# TODO: remove this as API used only internally...
|
|
494
|
+
if not isinstance(value, str) or len(value) != 4:
|
|
495
|
+
raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")
|
|
496
|
+
|
|
497
|
+
assert value[:2] != "EF" or value[2:] == "00", value # TODO: raise exception
|
|
498
|
+
if value == "EF00": # Not implemented
|
|
499
|
+
return {SZ_AIR_QUALITY: None}
|
|
500
|
+
|
|
501
|
+
if int(value[:2], 16) & 0xF0 == 0xF0:
|
|
502
|
+
return _faulted_sensor(SZ_AIR_QUALITY, value) # type: ignore[return-value]
|
|
503
|
+
|
|
504
|
+
level = int(value[:2], 16) / 200 # was: hex_to_percent(value[:2])
|
|
505
|
+
assert level <= 1.0, value[:2] # TODO: raise exception
|
|
506
|
+
|
|
507
|
+
assert value[2:] in ("10", "20", "40"), value[2:] # TODO: remove assert
|
|
508
|
+
basis = {
|
|
509
|
+
"10": "voc", # volatile compounds
|
|
510
|
+
"20": "co2", # carbon dioxide
|
|
511
|
+
"40": "rel_humidity", # relative humidity
|
|
512
|
+
}.get(value[2:], f"unknown_{value[2:]}") # TODO: remove get/unknown
|
|
513
|
+
|
|
514
|
+
return {SZ_AIR_QUALITY: level, SZ_AIR_QUALITY_BASIS: basis}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# 31DA[6:10] and 1298[2:6]
|
|
518
|
+
def parse_co2_level(value: HexStr4) -> PayDictT.CO2_LEVEL:
|
|
519
|
+
"""Return the co2 level (ppm).
|
|
520
|
+
|
|
521
|
+
The sensor value is None if there is no sensor present (is not an error).
|
|
522
|
+
The dict does not include the key if there is a sensor fault.
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
# TODO: remove this...
|
|
526
|
+
if not isinstance(value, str) or len(value) != 4:
|
|
527
|
+
raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")
|
|
528
|
+
|
|
529
|
+
if value == "7FFF": # Not implemented
|
|
530
|
+
return {SZ_CO2_LEVEL: None}
|
|
531
|
+
|
|
532
|
+
level = int(value, 16) # was: hex_to_double(value) # is it 2's complement?
|
|
533
|
+
|
|
534
|
+
if int(value[:2], 16) & 0x80 or level >= 0x8000:
|
|
535
|
+
return _faulted_sensor(SZ_CO2_LEVEL, value) # type: ignore[return-value]
|
|
536
|
+
|
|
537
|
+
# assert int(value[:2], 16) <= 0x8000, value
|
|
538
|
+
return {SZ_CO2_LEVEL: level}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def parse_humidity_element(value: str, index: str) -> PayDictT._12A0:
|
|
542
|
+
"""Return the relative humidity (%) and 2 temperatures
|
|
543
|
+
|
|
544
|
+
The result may include current temperature ('C) and include dewpoint temperature ('C).
|
|
545
|
+
"""
|
|
546
|
+
if index == "01":
|
|
547
|
+
return _parse_hvac_humidity(SZ_REL_HUMIDITY, value[:2], value[2:6], value[6:10]) # type: ignore[return-value]
|
|
548
|
+
if index == "02":
|
|
549
|
+
return _parse_hvac_humidity(
|
|
550
|
+
SZ_OUTDOOR_HUMIDITY, value[:2], value[2:6], value[6:10]
|
|
551
|
+
) # type: ignore[return-value]
|
|
552
|
+
return _parse_hvac_humidity(SZ_INDOOR_HUMIDITY, value[:2], value[2:6], value[6:10]) # type: ignore[return-value]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# 31DA[10:12] and 12A0[2:12]
|
|
556
|
+
def parse_indoor_humidity(value: str) -> PayDictT.INDOOR_HUMIDITY:
|
|
557
|
+
"""Return the relative indoor humidity (%).
|
|
558
|
+
|
|
559
|
+
The result may include current temperature ('C), and dewpoint temperature ('C).
|
|
560
|
+
"""
|
|
561
|
+
return _parse_hvac_humidity(SZ_INDOOR_HUMIDITY, value[:2], value[2:6], value[6:10]) # type: ignore[return-value]
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# 31DA[12:14] and 1280[2:12]
|
|
565
|
+
def parse_outdoor_humidity(value: str) -> PayDictT.OUTDOOR_HUMIDITY:
|
|
566
|
+
"""Return the relative outdoor humidity (%).
|
|
567
|
+
|
|
568
|
+
The result may include current temperature ('C), and dewpoint temperature ('C).
|
|
569
|
+
"""
|
|
570
|
+
return _parse_hvac_humidity(SZ_OUTDOOR_HUMIDITY, value[:2], value[2:6], value[6:10]) # type: ignore[return-value]
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _parse_hvac_humidity(
|
|
574
|
+
param_name: str, value: HexStr2, temp: HexStr4, dewpoint: HexStr4
|
|
575
|
+
) -> ReturnValueDictT:
|
|
576
|
+
"""Return the relative humidity, etc. (called by sensor parsers).
|
|
577
|
+
|
|
578
|
+
The sensor value is None if there is no sensor present (is not an error).
|
|
579
|
+
The dict does not include the key if there is a sensor fault.
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
# TODO: remove this...
|
|
583
|
+
if not isinstance(value, str) or len(value) != 2:
|
|
584
|
+
raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")
|
|
585
|
+
if not isinstance(temp, str) or len(temp) not in (0, 4):
|
|
586
|
+
raise ValueError(f"Invalid temp: {temp}, is not a 4-char hex string")
|
|
587
|
+
if not isinstance(dewpoint, str) or len(dewpoint) not in (0, 4):
|
|
588
|
+
raise ValueError(f"Invalid dewpoint: {dewpoint}, is not a 4-char hex string")
|
|
589
|
+
|
|
590
|
+
if value == "EF": # Not implemented
|
|
591
|
+
return {param_name: None}
|
|
592
|
+
|
|
593
|
+
if int(value, 16) & 0xF0 == 0xF0:
|
|
594
|
+
return _faulted_sensor(param_name, value)
|
|
595
|
+
|
|
596
|
+
percentage = int(value, 16) / 100 # TODO: confirm not 200
|
|
597
|
+
assert percentage <= 1.0, value # TODO: raise exception if > 1.0?
|
|
598
|
+
|
|
599
|
+
result: dict[str, float | str | None] = {
|
|
600
|
+
param_name: percentage
|
|
601
|
+
} # was: percent_from_hex(value, high_res=False)
|
|
602
|
+
if temp:
|
|
603
|
+
result |= {SZ_TEMPERATURE: hex_to_temp(temp)}
|
|
604
|
+
if dewpoint:
|
|
605
|
+
result |= {SZ_DEWPOINT_TEMP: hex_to_temp(dewpoint)}
|
|
606
|
+
return result
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# 31DA[14:18]
|
|
610
|
+
def parse_exhaust_temp(value: HexStr4) -> PayDictT.EXHAUST_TEMP:
|
|
611
|
+
"""Return the exhaust temperature ('C)."""
|
|
612
|
+
return _parse_hvac_temp(SZ_EXHAUST_TEMP, value) # type: ignore[return-value]
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# 31DA[18:22]
|
|
616
|
+
def parse_supply_temp(value: HexStr4) -> PayDictT.SUPPLY_TEMP:
|
|
617
|
+
"""Return the supply temperature ('C)."""
|
|
618
|
+
return _parse_hvac_temp(SZ_SUPPLY_TEMP, value) # type: ignore[return-value]
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# 31DA[22:26]
|
|
622
|
+
def parse_indoor_temp(value: HexStr4) -> PayDictT.INDOOR_TEMP:
|
|
623
|
+
"""Return the indoor temperature ('C)."""
|
|
624
|
+
return _parse_hvac_temp(SZ_INDOOR_TEMP, value) # type: ignore[return-value]
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# 31DA[26:30] & 1290[2:6]?
|
|
628
|
+
def parse_outdoor_temp(value: HexStr4) -> PayDictT.OUTDOOR_TEMP:
|
|
629
|
+
"""Return the outdoor temperature ('C)."""
|
|
630
|
+
return _parse_hvac_temp(SZ_OUTDOOR_TEMP, value) # type: ignore[return-value]
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _parse_hvac_temp(param_name: str, value: HexStr4) -> Mapping[str, float | None]:
|
|
634
|
+
"""Return the temperature ('C) (called by sensor parsers).
|
|
635
|
+
|
|
636
|
+
The sensor value is None if there is no sensor present (is not an error).
|
|
637
|
+
The dict does not include the key if there is a sensor fault.
|
|
638
|
+
"""
|
|
639
|
+
|
|
640
|
+
# TODO: remove this...
|
|
641
|
+
if not isinstance(value, str) or len(value) != 4:
|
|
642
|
+
raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")
|
|
643
|
+
|
|
644
|
+
if value == "7FFF": # Not implemented
|
|
645
|
+
return {param_name: None}
|
|
646
|
+
if value == "31FF": # Other
|
|
647
|
+
return {param_name: None}
|
|
648
|
+
|
|
649
|
+
if int(value[:2], 16) & 0xF0 == 0x80: # or temperature < -273.15:
|
|
650
|
+
return _faulted_sensor(param_name, value) # type: ignore[return-value]
|
|
651
|
+
|
|
652
|
+
temp: float = int(value, 16)
|
|
653
|
+
temp = (temp if temp < 2**15 else temp - 2**16) / 100
|
|
654
|
+
if temp <= -273: # TODO: < 273.15?
|
|
655
|
+
return _faulted_sensor(param_name, value) # type: ignore[return-value]
|
|
656
|
+
|
|
657
|
+
return {param_name: temp}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# 31DA[30:34]
|
|
661
|
+
def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
|
|
662
|
+
"""Return the speed capabilities (a bitmask).
|
|
663
|
+
|
|
664
|
+
The sensor value is None if there is no sensor present (is not an error).
|
|
665
|
+
The dict does not include the key if there is a sensor fault.
|
|
666
|
+
"""
|
|
667
|
+
|
|
668
|
+
# TODO: remove this...
|
|
669
|
+
if not isinstance(value, str) or len(value) != 4:
|
|
670
|
+
raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")
|
|
671
|
+
|
|
672
|
+
if value == "7FFF": # TODO: Not implemented???
|
|
673
|
+
return {SZ_SPEED_CAPABILITIES: None}
|
|
674
|
+
|
|
675
|
+
ABILITIES = {
|
|
676
|
+
15: "off",
|
|
677
|
+
14: "low_med_high", # 3,2,1 = high,med,low?
|
|
678
|
+
13: "timer",
|
|
679
|
+
12: "boost",
|
|
680
|
+
11: "auto",
|
|
681
|
+
10: "speed_4",
|
|
682
|
+
9: "speed_5",
|
|
683
|
+
8: "speed_6",
|
|
684
|
+
7: "speed_7",
|
|
685
|
+
6: "speed_8",
|
|
686
|
+
5: "speed_9",
|
|
687
|
+
4: "speed_10",
|
|
688
|
+
3: "auto_night",
|
|
689
|
+
2: "reserved",
|
|
690
|
+
1: "post_heater",
|
|
691
|
+
0: "pre_heater",
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
# assert value in ("0002", "4000", "4808", "F000", "F001", "F800", "F808"), value
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
SZ_SPEED_CAPABILITIES: [
|
|
698
|
+
v for k, v in ABILITIES.items() if int(value, 16) & 2**k
|
|
699
|
+
]
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
# 31DA[34:36]
|
|
704
|
+
def parse_bypass_position(value: HexStr2) -> PayDictT.BYPASS_POSITION:
|
|
705
|
+
"""Return the bypass position (%), usually fully open or closed (0%, no bypass).
|
|
706
|
+
|
|
707
|
+
The sensor value is None if there is no sensor present (is not an error).
|
|
708
|
+
The dict does not include the key if there is a sensor fault.
|
|
709
|
+
"""
|
|
710
|
+
|
|
711
|
+
# TODO: remove this...
|
|
712
|
+
if not isinstance(value, str) or len(value) != 2:
|
|
713
|
+
raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")
|
|
714
|
+
|
|
715
|
+
if value == "EF": # Not implemented
|
|
716
|
+
return {SZ_BYPASS_POSITION: None}
|
|
717
|
+
|
|
718
|
+
if int(value[:2], 16) & 0xF0 == 0xF0:
|
|
719
|
+
return _faulted_device(SZ_BYPASS_POSITION, value) # type: ignore[return-value]
|
|
720
|
+
|
|
721
|
+
bypass_pos = int(value, 16) / 200 # was: hex_to_percent(value)
|
|
722
|
+
assert bypass_pos <= 1.0, value
|
|
723
|
+
|
|
724
|
+
return {SZ_BYPASS_POSITION: bypass_pos}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
# 31DA[36:38] # TODO: WIP (3 more bits), also 22F3 and 22F4?
|
|
728
|
+
def parse_fan_info(value: HexStr2) -> PayDictT.FAN_INFO:
|
|
729
|
+
"""Return the fan state (lookup table for current speed and mode).
|
|
730
|
+
|
|
731
|
+
The sensor value is None if there is no sensor present (is not an error).
|
|
732
|
+
The dict does not include the key if there is a sensor fault.
|
|
733
|
+
"""
|
|
734
|
+
|
|
735
|
+
# TODO: remove this...
|
|
736
|
+
if not isinstance(value, str) or len(value) != 2:
|
|
737
|
+
raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")
|
|
738
|
+
|
|
739
|
+
# if value == "EF": # TODO: Not implemented???
|
|
740
|
+
# return {SZ_FAN_INFO: None}
|
|
741
|
+
|
|
742
|
+
assert int(value, 16) & 0xE0 in (
|
|
743
|
+
0x00,
|
|
744
|
+
0x20,
|
|
745
|
+
0x40,
|
|
746
|
+
0x60,
|
|
747
|
+
0x80,
|
|
748
|
+
), f"invalid fan_info: {int(value, 16) & 0xE0}"
|
|
749
|
+
|
|
750
|
+
flags = list((int(value, 16) & (1 << x)) >> x for x in range(7, 4, -1))
|
|
751
|
+
|
|
752
|
+
return {
|
|
753
|
+
SZ_FAN_INFO: _31DA_FAN_INFO[
|
|
754
|
+
int(value, 16) & 0x1F
|
|
755
|
+
], # lookup description from code
|
|
756
|
+
"_unknown_fan_info_flags": flags,
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
# 31DA[38:40]
|
|
761
|
+
def parse_exhaust_fan_speed(value: HexStr2) -> PayDictT.EXHAUST_FAN_SPEED:
|
|
762
|
+
"""Return the exhaust fan speed (% of max speed)."""
|
|
763
|
+
return _parse_fan_speed(SZ_EXHAUST_FAN_SPEED, value) # type: ignore[return-value]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# 31DA[40:42]
|
|
767
|
+
def parse_supply_fan_speed(value: HexStr2) -> PayDictT.SUPPLY_FAN_SPEED:
|
|
768
|
+
"""Return the supply fan speed (% of max speed)."""
|
|
769
|
+
return _parse_fan_speed(SZ_SUPPLY_FAN_SPEED, value) # type: ignore[return-value]
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _parse_fan_speed(param_name: str, value: HexStr2) -> Mapping[str, float | None]:
|
|
773
|
+
"""Return the fan speed (called by sensor parsers).
|
|
774
|
+
|
|
775
|
+
The sensor value is None if there is no sensor present (is not an error).
|
|
776
|
+
The dict does not include the key if there is a sensor fault.
|
|
777
|
+
"""
|
|
778
|
+
|
|
779
|
+
# TODO: remove this...
|
|
780
|
+
if not isinstance(value, str) or len(value) != 2:
|
|
781
|
+
raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")
|
|
782
|
+
|
|
783
|
+
if value == "FF": # Not implemented (is definitely FF, not EF!)
|
|
784
|
+
return {param_name: None}
|
|
785
|
+
|
|
786
|
+
percentage = int(value, 16) / 200 # was: hex_to_percent(value)
|
|
787
|
+
if percentage > 1.0:
|
|
788
|
+
return _faulted_common(param_name, value) # type: ignore[return-value]
|
|
789
|
+
|
|
790
|
+
return {param_name: percentage}
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
# 31DA[42:46] & 22F3[2:6] # TODO: make 22F3-friendly
|
|
794
|
+
def parse_remaining_mins(value: HexStr4) -> PayDictT.REMAINING_MINUTES:
|
|
795
|
+
"""Return the remaining time for temporary modes (whole minutes).
|
|
796
|
+
|
|
797
|
+
The sensor value is None if there is no sensor present (is not an error).
|
|
798
|
+
The dict does not include the key if there is a sensor fault.
|
|
799
|
+
"""
|
|
800
|
+
|
|
801
|
+
# TODO: remove this...
|
|
802
|
+
if not isinstance(value, str) or len(value) != 4:
|
|
803
|
+
raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")
|
|
804
|
+
|
|
805
|
+
if value == "0000":
|
|
806
|
+
return {SZ_REMAINING_MINS: 0}
|
|
807
|
+
if value == "3FFF":
|
|
808
|
+
return {SZ_REMAINING_MINS: None}
|
|
809
|
+
|
|
810
|
+
minutes = int(value, 16) # was: hex_to_double(value)
|
|
811
|
+
assert minutes > 0, value # TODO: raise assert
|
|
812
|
+
|
|
813
|
+
return {SZ_REMAINING_MINS: minutes} # usu. 0-60 mins
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
# 31DA[46:48]
|
|
817
|
+
def parse_post_heater(value: HexStr2) -> PayDictT.POST_HEATER:
|
|
818
|
+
"""Return the post-heater state (% of max heat)."""
|
|
819
|
+
return _parse_fan_heater(SZ_POST_HEAT, value) # type: ignore[return-value]
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
# 31DA[48:50]
|
|
823
|
+
def parse_pre_heater(value: HexStr2) -> PayDictT.PRE_HEATER:
|
|
824
|
+
"""Return the pre-heater state (% of max heat)."""
|
|
825
|
+
return _parse_fan_heater(SZ_PRE_HEAT, value) # type: ignore[return-value]
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _parse_fan_heater(param_name: str, value: HexStr2) -> Mapping[str, float | None]:
|
|
829
|
+
"""Return the heater state (called by sensor parsers).
|
|
830
|
+
|
|
831
|
+
The sensor value is None if there is no sensor present (is not an error).
|
|
832
|
+
The dict does not include the key if there is a sensor fault.
|
|
833
|
+
"""
|
|
834
|
+
|
|
835
|
+
# TODO: remove this...
|
|
836
|
+
if not isinstance(value, str) or len(value) != 2:
|
|
837
|
+
raise ValueError(f"Invalid value: {value}, is not a 2-char hex string")
|
|
838
|
+
|
|
839
|
+
if value == "EF": # Not implemented
|
|
840
|
+
return {param_name: None}
|
|
841
|
+
|
|
842
|
+
if int(value, 16) & 0xF0 == 0xF0:
|
|
843
|
+
return _faulted_sensor(param_name, value) # type: ignore[return-value]
|
|
844
|
+
|
|
845
|
+
percentage = int(value, 16) / 200 # Siber DF EVO 2 is /200, not /100 (?Others)
|
|
846
|
+
assert percentage <= 1.0, value # TODO: raise exception if > 1.0?
|
|
847
|
+
|
|
848
|
+
return {param_name: percentage} # was: percent_from_hex(value, high_res=False)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
# 31DA[50:54]
|
|
852
|
+
def parse_supply_flow(value: HexStr4) -> PayDictT.SUPPLY_FLOW:
|
|
853
|
+
"""Return the supply flow rate in m^3/hr (Orcon) ?or L/sec (?Itho)."""
|
|
854
|
+
return _parse_fan_flow(SZ_SUPPLY_FLOW, value) # type: ignore[return-value]
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
# 31DA[54:58]
|
|
858
|
+
def parse_exhaust_flow(value: HexStr4) -> PayDictT.EXHAUST_FLOW:
|
|
859
|
+
"""Return the exhaust flow rate in m^3/hr (Orcon) ?or L/sec (?Itho)"""
|
|
860
|
+
return _parse_fan_flow(SZ_EXHAUST_FLOW, value) # type: ignore[return-value]
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _parse_fan_flow(param_name: str, value: HexStr4) -> Mapping[str, float | None]:
|
|
864
|
+
"""Return the air flow rate (called by sensor parsers).
|
|
865
|
+
|
|
866
|
+
The sensor value is None if there is no sensor present (is not an error).
|
|
867
|
+
The dict does not include the key if there is a sensor fault.
|
|
868
|
+
"""
|
|
869
|
+
|
|
870
|
+
# TODO: remove this...
|
|
871
|
+
if not isinstance(value, str) or len(value) != 4:
|
|
872
|
+
raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")
|
|
873
|
+
|
|
874
|
+
if value == "7FFF": # Not implemented
|
|
875
|
+
return {param_name: None}
|
|
876
|
+
|
|
877
|
+
if int(value[:2], 16) & 0x80:
|
|
878
|
+
return _faulted_sensor(param_name, value) # type: ignore[return-value]
|
|
879
|
+
|
|
880
|
+
flow = int(value, 16) / 100 # was: hex_to_double(value, factor=100)
|
|
881
|
+
assert flow >= 0, value # TODO: raise exception if < 0?
|
|
882
|
+
|
|
883
|
+
return {param_name: flow}
|