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.
Files changed (71) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +279 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +377 -513
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.2.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.2.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.2.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. {ramses_rf/protocol → ramses_tx}/parsers.py +1266 -1003
  47. ramses_tx/protocol.py +801 -0
  48. ramses_tx/protocol_fsm.py +672 -0
  49. ramses_tx/py.typed +0 -0
  50. {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
  51. {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
  52. ramses_tx/transport.py +1471 -0
  53. ramses_tx/typed_dicts.py +492 -0
  54. ramses_tx/typing.py +181 -0
  55. ramses_tx/version.py +4 -0
  56. ramses_rf/discovery.py +0 -398
  57. ramses_rf/protocol/__init__.py +0 -59
  58. ramses_rf/protocol/backports.py +0 -42
  59. ramses_rf/protocol/command.py +0 -1576
  60. ramses_rf/protocol/const.py +0 -697
  61. ramses_rf/protocol/exceptions.py +0 -111
  62. ramses_rf/protocol/helpers.py +0 -390
  63. ramses_rf/protocol/opentherm.py +0 -1170
  64. ramses_rf/protocol/packet.py +0 -235
  65. ramses_rf/protocol/protocol.py +0 -613
  66. ramses_rf/protocol/transport.py +0 -1011
  67. ramses_rf/protocol/version.py +0 -10
  68. ramses_rf/system/hvac.py +0 -82
  69. ramses_rf-0.22.40.dist-info/METADATA +0 -64
  70. ramses_rf-0.22.40.dist-info/RECORD +0 -42
  71. ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
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}