ramses-rf 0.22.40__py3-none-any.whl → 0.51.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +286 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +377 -513
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.1.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.1.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.1.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.1.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.1.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. {ramses_rf/protocol → ramses_tx}/parsers.py +1266 -1003
  47. ramses_tx/protocol.py +801 -0
  48. ramses_tx/protocol_fsm.py +672 -0
  49. ramses_tx/py.typed +0 -0
  50. {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
  51. {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
  52. ramses_tx/transport.py +1471 -0
  53. ramses_tx/typed_dicts.py +492 -0
  54. ramses_tx/typing.py +181 -0
  55. ramses_tx/version.py +4 -0
  56. ramses_rf/discovery.py +0 -398
  57. ramses_rf/protocol/__init__.py +0 -59
  58. ramses_rf/protocol/backports.py +0 -42
  59. ramses_rf/protocol/command.py +0 -1576
  60. ramses_rf/protocol/const.py +0 -697
  61. ramses_rf/protocol/exceptions.py +0 -111
  62. ramses_rf/protocol/helpers.py +0 -390
  63. ramses_rf/protocol/opentherm.py +0 -1170
  64. ramses_rf/protocol/packet.py +0 -235
  65. ramses_rf/protocol/protocol.py +0 -613
  66. ramses_rf/protocol/transport.py +0 -1011
  67. ramses_rf/protocol/version.py +0 -10
  68. ramses_rf/system/hvac.py +0 -82
  69. ramses_rf-0.22.40.dist-info/METADATA +0 -64
  70. ramses_rf-0.22.40.dist-info/RECORD +0 -42
  71. ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
@@ -1,1576 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- #
4
- """RAMSES RF - a RAMSES-II protocol decoder & analyser.
5
-
6
- Construct a command (packet that is to be sent).
7
- """
8
- from __future__ import annotations
9
-
10
- import asyncio
11
- import functools
12
- import json
13
- import logging
14
- from datetime import datetime as dt
15
- from datetime import timedelta as td
16
- from typing import ( # typeguard doesn't support PEP604 on 3.9.x
17
- Any,
18
- Iterable,
19
- Optional,
20
- TypeVar,
21
- Union,
22
- )
23
-
24
- from .address import HGI_DEV_ADDR, NON_DEV_ADDR, NUL_DEV_ADDR, Address, pkt_addrs
25
- from .const import (
26
- DEV_TYPE_MAP,
27
- DEVICE_ID_REGEX,
28
- SYS_MODE_MAP,
29
- SZ_BACKOFF,
30
- SZ_DAEMON,
31
- SZ_DHW_IDX,
32
- SZ_DISABLE_BACKOFF,
33
- SZ_DOMAIN_ID,
34
- SZ_FUNC,
35
- SZ_PRIORITY,
36
- SZ_QOS,
37
- SZ_RETRIES,
38
- SZ_TIMEOUT,
39
- SZ_ZONE_IDX,
40
- ZON_MODE_MAP,
41
- Priority,
42
- __dev_mode__,
43
- )
44
- from .exceptions import ExpiredCallbackError
45
- from .frame import Frame, _CodeT, _DeviceIdT, _HeaderT, _PayloadT, _VerbT, pkt_header
46
- from .helpers import (
47
- bool_from_hex,
48
- double_to_hex,
49
- dt_now,
50
- dtm_to_hex,
51
- str_to_hex,
52
- temp_to_hex,
53
- timestamp,
54
- typechecked,
55
- )
56
- from .opentherm import parity
57
- from .parsers import LOOKUP_PUZZ
58
- from .ramses import _2411_PARAMS_SCHEMA
59
- from .version import VERSION
60
-
61
- # skipcq: PY-W2000
62
- from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
63
- I_,
64
- RP,
65
- RQ,
66
- W_,
67
- F9,
68
- FA,
69
- FC,
70
- FF,
71
- Code,
72
- )
73
-
74
-
75
- COMMAND_FORMAT = "{:<2} {} {} {} {} {} {:03d} {}"
76
-
77
- TIMER_SHORT_SLEEP = 0.05
78
- TIMER_LONG_TIMEOUT = td(seconds=60)
79
-
80
-
81
- DEV_MODE = __dev_mode__ and False
82
-
83
- _LOGGER = logging.getLogger(__name__)
84
- if DEV_MODE:
85
- _LOGGER.setLevel(logging.DEBUG)
86
-
87
-
88
- _ZoneIdxT = TypeVar("_ZoneIdxT", int, str)
89
-
90
-
91
- class Qos:
92
- """The QoS class.
93
-
94
- This is a mess - it is the first step in cleaning up QoS.
95
- """
96
-
97
- POLL_INTERVAL = 0.002
98
-
99
- TX_PRIORITY_DEFAULT = Priority.DEFAULT
100
-
101
- # tx (from sent to gwy, to get back from gwy) seems to takes approx. 0.025s
102
- TX_RETRIES_DEFAULT = 2
103
- TX_RETRIES_MAX = 5
104
- TX_TIMEOUT_DEFAULT = td(seconds=0.2) # 0.20 OK, but too high?
105
-
106
- RX_TIMEOUT_DEFAULT = td(seconds=0.50) # 0.20 seems OK, 0.10 too low sometimes
107
-
108
- TX_BACKOFFS_MAX = 2 # i.e. tx_timeout 2 ** MAX_BACKOFF
109
-
110
- QOS_KEYS = (SZ_PRIORITY, SZ_RETRIES, SZ_TIMEOUT, SZ_BACKOFF)
111
- # priority, retries, rx_timeout, backoff
112
- DEFAULT_QOS = (Priority.DEFAULT, TX_RETRIES_DEFAULT, TX_TIMEOUT_DEFAULT, True)
113
- DEFAULT_QOS_TABLE = {
114
- f"{RQ}|{Code._0016}": (Priority.HIGH, 5, None, True),
115
- f"{RQ}|{Code._0006}": (Priority.HIGH, 5, None, True),
116
- f"{I_}|{Code._0404}": (Priority.HIGH, 3, td(seconds=0.30), True),
117
- f"{RQ}|{Code._0404}": (Priority.HIGH, 3, td(seconds=1.00), True),
118
- f"{W_}|{Code._0404}": (Priority.HIGH, 3, td(seconds=1.00), True),
119
- f"{RQ}|{Code._0418}": (Priority.LOW, 3, None, None),
120
- f"{RQ}|{Code._1F09}": (Priority.HIGH, 5, None, True),
121
- f"{I_}|{Code._1FC9}": (Priority.HIGH, 2, td(seconds=1), False),
122
- f"{RQ}|{Code._3220}": (Priority.DEFAULT, 1, td(seconds=1.2), False),
123
- f"{W_}|{Code._3220}": (Priority.HIGH, 3, td(seconds=1.2), False),
124
- } # The long timeout for the OTB is for total RTT to slave (boiler)
125
-
126
- def __init__(
127
- self,
128
- *,
129
- priority=None,
130
- retries=None,
131
- timeout=None,
132
- backoff=None,
133
- ) -> None:
134
-
135
- self.priority = self.DEFAULT_QOS[0] if priority is None else priority
136
- self.retry_limit = self.DEFAULT_QOS[1] if retries is None else retries
137
- self.tx_timeout = self.TX_TIMEOUT_DEFAULT
138
- self.rx_timeout = self.DEFAULT_QOS[2] if timeout is None else timeout
139
- self.disable_backoff = not (self.DEFAULT_QOS[3] if backoff is None else backoff)
140
-
141
- self.retry_limit = min(self.retry_limit, Qos.TX_RETRIES_MAX)
142
-
143
- @classmethod # constructor from verb|code pair
144
- def verb_code(cls, verb, code, **kwargs) -> Qos:
145
- """Constructor to create a QoS based upon the defaults for a verb|code pair."""
146
-
147
- default_qos = cls.DEFAULT_QOS_TABLE.get(f"{verb}|{code}", cls.DEFAULT_QOS)
148
- return cls(
149
- **{k: kwargs.get(k, default_qos[i]) for i, k in enumerate(cls.QOS_KEYS)}
150
- )
151
-
152
-
153
- def validate_api_params(*, has_zone: bool = None):
154
- """Decorator to protect the engine from any invalid command constructors.
155
-
156
- Additionally, validate/normalise some command arguments (e.g. 'HW' becomes 0xFA).
157
- NB: The zone_idx (domain_id) is converted to an integer, but payloads use strings
158
- such as f"{zone_idx}:02X".
159
- """
160
-
161
- def _wrapper(fcn, cls, *args, **kwargs):
162
- _LOGGER.debug(f"Calling: {fcn.__name__}({args}, {kwargs})")
163
- return fcn(cls, *args, **kwargs)
164
-
165
- def validate_zone_idx(zone_idx) -> int:
166
- # if zone_idx is None:
167
- # return "00" # TODO: I suspect a bad idea
168
- if isinstance(zone_idx, str):
169
- zone_idx = FA if zone_idx == "HW" else zone_idx
170
- zone_idx = zone_idx if isinstance(zone_idx, int) else int(zone_idx, 16)
171
- if 0 > zone_idx > 15 and zone_idx != 0xFA:
172
- raise ValueError("Invalid value for zone_idx")
173
- return zone_idx
174
-
175
- def device_decorator(fcn):
176
- @functools.wraps(fcn)
177
- def wrapper(cls, dst_id, *args, **kwargs):
178
-
179
- if SZ_ZONE_IDX in kwargs: # Cmd.get_relay_demand()
180
- kwargs[SZ_ZONE_IDX] = validate_zone_idx(kwargs[SZ_ZONE_IDX])
181
- if SZ_DOMAIN_ID in kwargs:
182
- kwargs[SZ_DOMAIN_ID] = validate_zone_idx(kwargs[SZ_DOMAIN_ID])
183
- if SZ_DHW_IDX in kwargs:
184
- kwargs[SZ_DHW_IDX] = validate_zone_idx(kwargs[SZ_DHW_IDX])
185
-
186
- return _wrapper(fcn, cls, dst_id, *args, **kwargs)
187
-
188
- return wrapper
189
-
190
- def zone_decorator(fcn):
191
- @functools.wraps(fcn)
192
- def wrapper(cls, ctl_id, zone_idx, *args, **kwargs):
193
-
194
- zone_idx = validate_zone_idx(zone_idx)
195
- if SZ_DOMAIN_ID in kwargs:
196
- kwargs[SZ_DOMAIN_ID] = validate_zone_idx(kwargs[SZ_DOMAIN_ID])
197
-
198
- return _wrapper(fcn, cls, ctl_id, zone_idx, *args, **kwargs)
199
-
200
- return wrapper
201
-
202
- return zone_decorator if has_zone else device_decorator
203
-
204
-
205
- def _normalise_mode(mode, target, until, duration) -> str:
206
- """Validate the zone_mode, and return a it as a normalised 2-byte code.
207
-
208
- Used by set_dhw_mode (target=active) and set_zone_mode (target=setpoint). May raise
209
- KeyError or ValueError.
210
- """
211
-
212
- if mode is None and target is None:
213
- raise ValueError("Invalid args: One of mode or setpoint/active cant be None")
214
- if until and duration:
215
- raise ValueError("Invalid args: At least one of until or duration must be None")
216
-
217
- if mode is None:
218
- if until:
219
- mode = ZON_MODE_MAP.TEMPORARY
220
- elif duration:
221
- mode = ZON_MODE_MAP.COUNTDOWN
222
- else:
223
- mode = ZON_MODE_MAP.PERMANENT # TODO: advanced_override?
224
- elif isinstance(mode, int):
225
- mode = f"{mode:02X}"
226
- if mode not in ZON_MODE_MAP:
227
- mode = ZON_MODE_MAP._hex(mode) # may raise KeyError
228
-
229
- if mode != ZON_MODE_MAP.FOLLOW and target is None:
230
- raise ValueError(
231
- f"Invalid args: For {ZON_MODE_MAP[mode]}, setpoint/active cant be None"
232
- )
233
-
234
- return mode
235
-
236
-
237
- def _normalise_until(mode, _, until, duration) -> tuple[Any, Any]:
238
- """Validate until and duration, and return a normalised xxx.
239
-
240
- Used by set_dhw_mode and set_zone_mode. May raise KeyError or ValueError.
241
- """
242
- # if until and duration:
243
- # raise ValueError("Invalid args: Only one of until or duration can be set")
244
-
245
- if mode == ZON_MODE_MAP.TEMPORARY:
246
- if duration is not None:
247
- raise ValueError(
248
- f"Invalid args: For {ZON_MODE_MAP[mode]}, duration must be None"
249
- )
250
- if until is None:
251
- mode = ZON_MODE_MAP.ADVANCED # or: until = dt.now() + td(hour=1)
252
-
253
- elif mode in ZON_MODE_MAP.COUNTDOWN:
254
- if duration is None:
255
- raise ValueError(
256
- f"Invalid args: For {ZON_MODE_MAP[mode]}, duration cant be None"
257
- )
258
- if until is not None:
259
- raise ValueError(
260
- f"Invalid args: For {ZON_MODE_MAP[mode]}, until must be None"
261
- )
262
-
263
- elif until is not None or duration is not None:
264
- raise ValueError(
265
- f"Invalid args: For {ZON_MODE_MAP[mode]},"
266
- " until and duration must both be None"
267
- )
268
-
269
- return until, duration
270
-
271
-
272
- def _qos_params(verb: _VerbT, code: _CodeT, qos: dict) -> Qos:
273
- """Class constructor wrapped to prevent cyclic reference."""
274
- return Qos.verb_code(verb, code, **qos)
275
-
276
-
277
- @functools.total_ordering
278
- class Command(Frame):
279
- """The Command class (packets to be transmitted).
280
-
281
- They have QoS and/or callbacks (but no RSSI).
282
- """
283
-
284
- def __init__(self, frame: str, qos: dict = None, callback: dict = None) -> None:
285
- """Create a command from a string (and its meta-attrs).
286
-
287
- Will raise InvalidPacketError if it is invalid.
288
- """
289
-
290
- super().__init__(frame) # may raise InvalidPacketError if it is invalid
291
-
292
- # used by app layer: callback (protocol.py: func, args, daemon, timeout)
293
- self._cbk = callback or {}
294
- # used by msg layer (for which cmd to send next, with _qos.priority)
295
- self._dtm = dt_now()
296
- # used by pkt layer: qos (transport.py: backoff, priority, retries, timeout)
297
- self._qos = _qos_params(self.verb, self.code, qos or {})
298
-
299
- self._rx_header: None | str = None
300
- self._source_entity = None
301
-
302
- self._validate(strict_checking=False)
303
-
304
- @classmethod # convenience constructor
305
- def from_attrs(
306
- cls,
307
- verb: _VerbT,
308
- dest_id,
309
- code: _CodeT,
310
- payload: _PayloadT,
311
- *,
312
- from_id=None,
313
- seqn=None,
314
- **kwargs,
315
- ):
316
- """Create a command from its attrs using a destination device_id."""
317
-
318
- from_id = from_id or HGI_DEV_ADDR.id
319
-
320
- if dest_id == from_id:
321
- addrs = (from_id, NON_DEV_ADDR.id, dest_id)
322
- else:
323
- addrs = (from_id, dest_id, NON_DEV_ADDR.id)
324
-
325
- return cls._from_attrs(
326
- verb,
327
- code,
328
- payload,
329
- addr0=addrs[0],
330
- addr1=addrs[1],
331
- addr2=addrs[2],
332
- seqn=seqn,
333
- **kwargs,
334
- )
335
-
336
- @classmethod # generic constructor
337
- def _from_attrs(
338
- cls,
339
- verb: _VerbT,
340
- code: _CodeT,
341
- payload: _PayloadT,
342
- *,
343
- addr0=None,
344
- addr1=None,
345
- addr2=None,
346
- seqn=None,
347
- **kwargs,
348
- ):
349
- """Create a command from its attrs using an address set."""
350
-
351
- verb = I_ if verb == "I" else W_ if verb == "W" else verb
352
-
353
- addr0 = addr0 or NON_DEV_ADDR.id
354
- addr1 = addr1 or NON_DEV_ADDR.id
355
- addr2 = addr2 or NON_DEV_ADDR.id
356
-
357
- _, _, *addrs = pkt_addrs(" ".join((addr0, addr1, addr2)))
358
- # print(pkt_addrs(" ".join((addr0, addr1, addr2))))
359
-
360
- if seqn in (None, "", "-", "--", "---"):
361
- seqn = "---"
362
- else:
363
- seqn = f"{int(seqn):03d}"
364
-
365
- len_ = f"{int(len(payload) / 2):03d}"
366
-
367
- frame = " ".join(
368
- (
369
- verb,
370
- seqn,
371
- *(a.id for a in addrs),
372
- code,
373
- len_,
374
- payload,
375
- )
376
- )
377
-
378
- return cls(frame, **kwargs)
379
-
380
- @classmethod # used by CLI for -x switch
381
- def from_cli(cls, cmd_str: str, **kwargs):
382
- """Create a command from a CLI string (the -x switch).
383
-
384
- Examples include (whitespace for readability):
385
- 'RQ 01:123456 1F09 00'
386
- 'RQ 01:123456 13:123456 3EF0 00'
387
- 'RQ 07:045960 01:054173 10A0 00137400031C'
388
- ' W 123 30:045960 -:- 32:054173 22F1 001374'
389
- """
390
-
391
- cmd = cmd_str.upper().split()
392
- if len(cmd) < 4:
393
- raise ValueError(
394
- f"Command string is not parseable: '{cmd_str}'"
395
- ", format is: verb [seqn] addr0 [addr1 [addr2]] code payload"
396
- )
397
-
398
- verb = cmd.pop(0)
399
- seqn = "---" if DEVICE_ID_REGEX.ANY.match(cmd[0]) else cmd.pop(0)
400
- payload = cmd.pop()[:48]
401
- code = cmd.pop()
402
-
403
- if not 0 < len(cmd) < 4:
404
- raise ValueError(f"Command is invalid: '{cmd_str}'")
405
- elif len(cmd) == 1 and verb == I_:
406
- # drs = (cmd[0], NON_DEV_ADDR.id, cmd[0])
407
- addrs = (NON_DEV_ADDR.id, NON_DEV_ADDR.id, cmd[0])
408
- elif len(cmd) == 1:
409
- addrs = (HGI_DEV_ADDR.id, cmd[0], NON_DEV_ADDR.id)
410
- elif len(cmd) == 2 and cmd[0] == cmd[1]:
411
- addrs = (cmd[0], NON_DEV_ADDR.id, cmd[1])
412
- elif len(cmd) == 2:
413
- addrs = (cmd[0], cmd[1], NON_DEV_ADDR.id)
414
- else:
415
- addrs = (cmd[0], cmd[1], cmd[2])
416
-
417
- return cls._from_attrs(
418
- verb,
419
- code,
420
- payload,
421
- **{f"addr{k}": v for k, v in enumerate(addrs)},
422
- seqn=seqn,
423
- **kwargs,
424
- )
425
-
426
- def __repr__(self) -> str:
427
- """Return an unambiguous string representation of this object."""
428
- hdr = f' # {self._hdr}{f" ({self._ctx})" if self._ctx else ""}'
429
- return f"... {self}{hdr}"
430
-
431
- def __str__(self) -> str:
432
- """Return an brief readable string representation of this object."""
433
- return super().__repr__()
434
-
435
- def __eq__(self, other: Any) -> bool:
436
- if not self._is_valid_operand(other):
437
- return NotImplemented
438
- return (self._qos.priority, self._dtm) == (other._qos.priority, other._dtm)
439
-
440
- def __lt__(self, other: Any) -> bool:
441
- if not self._is_valid_operand(other):
442
- return NotImplemented
443
- return (self._qos.priority, self._dtm) < (other._qos.priority, other._dtm)
444
-
445
- @staticmethod
446
- def _is_valid_operand(other: Any) -> bool:
447
- return (
448
- hasattr(other, "_dtm")
449
- and hasattr(other, "_qos")
450
- and hasattr(other._qos, "priority")
451
- )
452
-
453
- @property
454
- def tx_header(self) -> _HeaderT:
455
- """Return the QoS header of this (request) packet."""
456
-
457
- return self._hdr
458
-
459
- @property
460
- def rx_header(self) -> None | _HeaderT:
461
- """Return the QoS header of a corresponding response packet (if any)."""
462
-
463
- if self.tx_header and self._rx_header is None:
464
- self._rx_header = pkt_header(self, rx_header=True)
465
- return self._rx_header
466
-
467
- @classmethod # constructor for I|22F7
468
- @typechecked
469
- @validate_api_params()
470
- def set_bypass_position(
471
- cls,
472
- fan_id: _DeviceIdT,
473
- *,
474
- bypass_position: float = None,
475
- src_id: _DeviceIdT = None,
476
- **kwargs,
477
- ):
478
- """Constructor to set the position of the bypass valve (c.f. parser_22f7).
479
-
480
- bypass_position: a % from fully open (1.0) to fully closed (0.0).
481
- None is a sentinel value for auto.
482
-
483
- bypass_mode: is a proxy for bypass_position (they should be mutex)
484
- """
485
-
486
- # RQ --- 37:155617 32:155617 --:------ 22F7 002 0064 # offically: 00C8EF
487
- # RP --- 32:155617 37:155617 --:------ 22F7 003 00C8C8
488
-
489
- src_id = src_id or fan_id # TODO: src_id should be an arg?
490
-
491
- if (bypass_mode := kwargs.pop("bypass_mode", None)) and (
492
- bypass_position is not None
493
- ):
494
- raise ValueError(
495
- "bypass_mode and bypass_position are mutally exclusive, "
496
- "both cannot be provided, and neither is OK"
497
- )
498
- elif bypass_position is not None:
499
- pos = f"{int(bypass_position * 200):02X}"
500
- elif bypass_mode:
501
- pos = {"auto": "FF", "off": "00", "on": "C8"}[bypass_mode]
502
- else:
503
- pos = "FF" # auto
504
-
505
- return cls._from_attrs(
506
- W_, Code._22F7, f"00{pos}", addr0=src_id, addr1=fan_id, **kwargs
507
- ) # trailing EF not required
508
-
509
- @classmethod # constructor for W|2411
510
- @typechecked
511
- @validate_api_params()
512
- def set_fan_param(
513
- cls,
514
- fan_id: _DeviceIdT,
515
- param_id: str,
516
- value: str,
517
- *,
518
- src_id: _DeviceIdT = None,
519
- **kwargs,
520
- ):
521
- """Constructor to set a configurable fan parameter (c.f. parser_2411)."""
522
-
523
- src_id = src_id or fan_id # TODO: src_id should be an arg?
524
-
525
- if not _2411_PARAMS_SCHEMA.get(param_id): # TODO: not exlude unknowns?
526
- raise ValueError(f"Unknown parameter: {param_id}")
527
-
528
- payload = f"0000{param_id}0000{value:08X}" # TODO: needs work
529
-
530
- return cls._from_attrs(
531
- W_, Code._2411, payload, addr0=src_id, addr1=fan_id, **kwargs
532
- )
533
-
534
- @classmethod # constructor for I|22F1
535
- @typechecked
536
- @validate_api_params()
537
- def set_fan_mode(
538
- cls,
539
- fan_id: _DeviceIdT,
540
- fan_mode,
541
- *,
542
- seqn: int = None,
543
- src_id: _DeviceIdT = None,
544
- idx: str = "00", # could be e.g. "63"
545
- **kwargs,
546
- ):
547
- """Constructor to get the fan speed (and heater?) (c.f. parser_22f1).
548
-
549
- There are two types of this packet seen (with seqn, or with src_id):
550
- - I 018 --:------ --:------ 39:159057 22F1 003 000204
551
- - I --- 21:039407 28:126495 --:------ 22F1 003 000407
552
- """
553
-
554
- # Scheme 1: I 218 --:------ --:------ 39:159057
555
- # - are cast as a triplet, 0.1s apart?, with a seqn (000-255) and no src_id
556
- # - triplet has same seqn, increased monotonically mod 256 after every triplet
557
- # - only payloads seen: '(00|63)0[234]04', may accept '000.'
558
- # .I 218 --:------ --:------ 39:159057 22F1 003 000204 # low
559
-
560
- # Scheme 1a: I --- --:------ --:------ 21:038634 (less common)
561
- # - some systems that accept scheme 2 will accept this scheme
562
-
563
- # Scheme 2: I --- 21:038634 18:126620 --:------ (less common)
564
- # - are cast as a triplet, 0.085s apart, without a seqn (i.e. is ---)
565
- # - only payloads seen: '000.0[47A]', may accept '000.'
566
- # .I --- 21:038634 18:126620 --:------ 22F1 003 000507
567
-
568
- from .ramses import _22F1_MODE_ORCON
569
-
570
- _22F1_MODE_ORCON_MAP = {v: k for k, v in _22F1_MODE_ORCON.items()}
571
-
572
- if fan_mode is None:
573
- mode = "00"
574
- elif isinstance(fan_mode, int):
575
- mode = f"{fan_mode:02X}"
576
- else:
577
- mode = fan_mode
578
-
579
- if mode in _22F1_MODE_ORCON:
580
- payload = f"{idx}{mode}"
581
- elif mode in _22F1_MODE_ORCON_MAP:
582
- payload = f"{idx}{_22F1_MODE_ORCON_MAP[mode]}"
583
- else:
584
- raise TypeError(f"fan_mode is not valid: {fan_mode}")
585
-
586
- if src_id and seqn:
587
- raise TypeError(
588
- "seqn and src_id are mutally exclusive (you can have neither)"
589
- )
590
-
591
- if seqn:
592
- return cls._from_attrs(
593
- I_, Code._22F1, payload, addr2=fan_id, seqn=seqn, **kwargs
594
- )
595
- return cls._from_attrs(
596
- I_, Code._22F1, payload, addr0=src_id, addr1=fan_id, **kwargs
597
- )
598
-
599
- @classmethod # constructor for RQ|1F41
600
- @typechecked
601
- @validate_api_params() # TODO: has_dhw=True)
602
- def get_dhw_mode(cls, ctl_id: _DeviceIdT, **kwargs):
603
- """Constructor to get the mode of the DHW (c.f. parser_1f41)."""
604
-
605
- dhw_idx = f"{kwargs.pop(SZ_DHW_IDX, 0):02X}" # only 00 or 01 (rare)
606
- return cls.from_attrs(RQ, ctl_id, Code._1F41, dhw_idx, **kwargs)
607
-
608
- @classmethod # constructor for W|1F41
609
- @typechecked
610
- @validate_api_params() # TODO: has_dhw=True)
611
- def set_dhw_mode(
612
- cls,
613
- ctl_id: _DeviceIdT,
614
- *,
615
- mode: Union[None, int, str] = None,
616
- active: bool = None,
617
- until: Union[None, dt, str] = None,
618
- duration: int = None,
619
- **kwargs,
620
- ):
621
- """Constructor to set/reset the mode of the DHW (c.f. parser_1f41)."""
622
-
623
- dhw_idx = f"{kwargs.pop(SZ_DHW_IDX, 0):02X}" # only 00 or 01 (rare)
624
-
625
- mode = _normalise_mode(mode, active, until, duration)
626
-
627
- if mode == ZON_MODE_MAP.FOLLOW:
628
- active = None
629
- if active is not None and not isinstance(active, (bool, int)):
630
- raise TypeError(f"Invalid args: active={active}, but must be an bool")
631
-
632
- until, duration = _normalise_until(mode, active, until, duration)
633
-
634
- payload = "".join(
635
- (
636
- dhw_idx,
637
- "FF" if active is None else "01" if bool(active) else "00",
638
- mode,
639
- "FFFFFF" if duration is None else f"{duration:06X}",
640
- "" if until is None else dtm_to_hex(until),
641
- )
642
- )
643
-
644
- return cls.from_attrs(W_, ctl_id, Code._1F41, payload, **kwargs)
645
-
646
- @classmethod # constructor for RQ|10A0
647
- @typechecked
648
- @validate_api_params() # TODO: has_dhw=True)
649
- def get_dhw_params(cls, ctl_id: _DeviceIdT, **kwargs):
650
- """Constructor to get the params of the DHW (c.f. parser_10a0)."""
651
-
652
- dhw_idx = f"{kwargs.pop(SZ_DHW_IDX, 0):02X}" # only 00 or 01 (rare)
653
- return cls.from_attrs(RQ, ctl_id, Code._10A0, dhw_idx, **kwargs)
654
-
655
- @classmethod # constructor for W|10A0
656
- @typechecked
657
- @validate_api_params() # TODO: has_dhw=True)
658
- def set_dhw_params(
659
- cls,
660
- ctl_id: _DeviceIdT,
661
- *,
662
- setpoint: float = 50.0,
663
- overrun: int = 5,
664
- differential: float = 1,
665
- **kwargs,
666
- ):
667
- """Constructor to set the params of the DHW (c.f. parser_10a0)."""
668
- # Defaults for newer evohome colour:
669
- # Defaults for older evohome colour: ?? (30-85) C, ? (0-10) min, ? (1-10) C
670
- # Defaults for evohome monochrome:
671
-
672
- # 14:34:26.734 022 W --- 18:013393 01:145038 --:------ 10A0 006 000F6E050064
673
- # 14:34:26.751 073 I --- 01:145038 --:------ 01:145038 10A0 006 000F6E0003E8
674
- # 14:34:26.764 074 I --- 01:145038 18:013393 --:------ 10A0 006 000F6E0003E8
675
-
676
- dhw_idx = f"{kwargs.pop(SZ_DHW_IDX, 0):02X}" # only 00 or 01 (rare)
677
-
678
- setpoint = 50.0 if setpoint is None else setpoint
679
- overrun = 5 if overrun is None else overrun
680
- differential = 1.0 if differential is None else differential
681
-
682
- if not (30.0 <= setpoint <= 85.0):
683
- raise ValueError(f"Out of range, setpoint: {setpoint}")
684
- if not (0 <= overrun <= 10):
685
- raise ValueError(f"Out of range, overrun: {overrun}")
686
- if not (1 <= differential <= 10):
687
- raise ValueError(f"Out of range, differential: {differential}")
688
-
689
- payload = (
690
- f"{dhw_idx}{temp_to_hex(setpoint)}{overrun:02X}{temp_to_hex(differential)}"
691
- )
692
-
693
- return cls.from_attrs(W_, ctl_id, Code._10A0, payload, **kwargs)
694
-
695
- @classmethod # constructor for RQ|1260
696
- @typechecked
697
- @validate_api_params() # TODO: has_dhw=True)
698
- def get_dhw_temp(cls, ctl_id: _DeviceIdT, **kwargs):
699
- """Constructor to get the temperature of the DHW sensor (c.f. parser_10a0)."""
700
-
701
- dhw_idx = f"{kwargs.pop(SZ_DHW_IDX, 0):02X}" # only 00 or 01 (rare)
702
- return cls.from_attrs(RQ, ctl_id, Code._1260, dhw_idx, **kwargs)
703
-
704
- @classmethod # constructor for RQ|1030
705
- @typechecked
706
- @validate_api_params(has_zone=True)
707
- def get_mix_valve_params(cls, ctl_id: _DeviceIdT, zone_idx: _ZoneIdxT, **kwargs):
708
- """Constructor to get the mix valve params of a zone (c.f. parser_1030)."""
709
-
710
- return cls.from_attrs(
711
- RQ, ctl_id, Code._1030, f"{zone_idx:02X}00", **kwargs
712
- ) # TODO: needs 00?
713
-
714
- @classmethod # constructor for W|1030
715
- @typechecked
716
- @validate_api_params(has_zone=True)
717
- def set_mix_valve_params(
718
- cls,
719
- ctl_id: _DeviceIdT,
720
- zone_idx: _ZoneIdxT,
721
- *,
722
- max_flow_setpoint=55,
723
- min_flow_setpoint=15,
724
- valve_run_time=150,
725
- pump_run_time=15,
726
- **kwargs,
727
- ):
728
- """Constructor to set the mix valve params of a zone (c.f. parser_1030)."""
729
-
730
- boolean_cc = kwargs.pop("boolean_cc", 1)
731
- kwargs.get("unknown_20", None) # HVAC
732
- kwargs.get("unknown_21", None) # HVAC
733
-
734
- if not (0 <= max_flow_setpoint <= 99):
735
- raise ValueError(f"Out of range, max_flow_setpoint: {max_flow_setpoint}")
736
- if not (0 <= min_flow_setpoint <= 50):
737
- raise ValueError(f"Out of range, min_flow_setpoint: {min_flow_setpoint}")
738
- if not (0 <= valve_run_time <= 240):
739
- raise ValueError(f"Out of range, valve_run_time: {valve_run_time}")
740
- if not (0 <= pump_run_time <= 99):
741
- raise ValueError(f"Out of range, pump_run_time: {pump_run_time}")
742
-
743
- payload = "".join(
744
- (
745
- f"{zone_idx:02X}",
746
- f"C801{max_flow_setpoint:02X}",
747
- f"C901{min_flow_setpoint:02X}",
748
- f"CA01{valve_run_time:02X}",
749
- f"CB01{pump_run_time:02X}",
750
- f"CC01{boolean_cc:02X}",
751
- )
752
- )
753
-
754
- return cls.from_attrs(W_, ctl_id, Code._1030, payload, **kwargs)
755
-
756
- @classmethod # constructor for RQ|3220
757
- @typechecked
758
- @validate_api_params()
759
- def get_opentherm_data(cls, otb_id: _DeviceIdT, msg_id: Union[int, str], **kwargs):
760
- """Constructor to get (Read-Data) opentherm msg value (c.f. parser_3220)."""
761
-
762
- msg_id = msg_id if isinstance(msg_id, int) else int(msg_id, 16)
763
- payload = f"0080{msg_id:02X}0000" if parity(msg_id) else f"0000{msg_id:02X}0000"
764
- return cls.from_attrs(RQ, otb_id, Code._3220, payload, **kwargs)
765
-
766
- @classmethod # constructor for RQ|0008
767
- @typechecked
768
- @validate_api_params() # has_zone is optional
769
- def get_relay_demand(cls, dev_id: _DeviceIdT, zone_idx: _ZoneIdxT = None, **kwargs):
770
- """Constructor to get the demand of a relay/zone (c.f. parser_0008)."""
771
-
772
- payload = "00" if zone_idx is None else f"{zone_idx:02X}"
773
- return cls.from_attrs(RQ, dev_id, Code._0008, payload, **kwargs)
774
-
775
- @classmethod # constructor for RQ|0006
776
- @typechecked
777
- @validate_api_params()
778
- def get_schedule_version(cls, ctl_id: _DeviceIdT, **kwargs):
779
- """Constructor to get the current version (change counter) of the schedules.
780
-
781
- This number is increased whenever any zone's schedule is changed (incl. the DHW
782
- zone), and is used to avoid the relatively large expense of downloading a
783
- schedule, only to see that it hasn't changed.
784
- """
785
-
786
- return cls.from_attrs(RQ, ctl_id, Code._0006, "00", **kwargs)
787
-
788
- @classmethod # constructor for RQ|0404
789
- @typechecked
790
- @validate_api_params(has_zone=True)
791
- def get_schedule_fragment(
792
- cls,
793
- ctl_id: _DeviceIdT,
794
- zone_idx: _ZoneIdxT,
795
- frag_number: int,
796
- total_frags: Optional[int],
797
- **kwargs,
798
- ):
799
- """Constructor to get a schedule fragment (c.f. parser_0404).
800
-
801
- Usually a zone, but will be the DHW schedule if zone_idx == 0xFA, 'FA', or 'HW'.
802
- """
803
-
804
- if total_frags is None:
805
- total_frags = 0
806
-
807
- kwargs.pop("frag_length", None)
808
- frag_length = "00"
809
-
810
- # TODO: check the following rules
811
- if frag_number == 0:
812
- raise ValueError(f"frag_number={frag_number}, but it is 1-indexed")
813
- elif frag_number == 1 and total_frags != 0:
814
- raise ValueError(
815
- f"total_frags={total_frags}, but must be 0 when frag_number=1"
816
- )
817
- elif frag_number > total_frags and total_frags != 0:
818
- raise ValueError(
819
- f"frag_number={frag_number}, but must be <= total_frags={total_frags}"
820
- )
821
-
822
- header = "00230008" if zone_idx == 0xFA else f"{zone_idx:02X}200008"
823
-
824
- payload = f"{header}{frag_length}{frag_number:02X}{total_frags:02X}"
825
- return cls.from_attrs(RQ, ctl_id, Code._0404, payload, **kwargs)
826
-
827
- @classmethod # constructor for W|0404
828
- @typechecked
829
- @validate_api_params(has_zone=True)
830
- def set_schedule_fragment(
831
- cls,
832
- ctl_id: _DeviceIdT,
833
- zone_idx: _ZoneIdxT,
834
- frag_num: int,
835
- frag_cnt: int,
836
- fragment: str,
837
- **kwargs,
838
- ):
839
- """Constructor to set a zone schedule fragment (c.f. parser_0404).
840
-
841
- Usually a zone, but will be the DHW schedule if zone_idx == 0xFA, 'FA', or 'HW'.
842
- """
843
-
844
- # TODO: check the following rules
845
- if frag_num == 0:
846
- raise ValueError(f"frag_num={frag_num}, but it is 1-indexed")
847
- elif frag_num > frag_cnt:
848
- raise ValueError(f"frag_num={frag_num}, but must be <= frag_cnt={frag_cnt}")
849
-
850
- header = "00230008" if zone_idx == 0xFA else f"{zone_idx:02X}200008"
851
- frag_length = int(len(fragment) / 2)
852
-
853
- payload = f"{header}{frag_length:02X}{frag_num:02X}{frag_cnt:02X}{fragment}"
854
- return cls.from_attrs(W_, ctl_id, Code._0404, payload, **kwargs)
855
-
856
- @classmethod # constructor for RQ|0100
857
- @typechecked
858
- @validate_api_params()
859
- def get_system_language(cls, ctl_id: _DeviceIdT, **kwargs):
860
- """Constructor to get the language of a system (c.f. parser_0100)."""
861
-
862
- return cls.from_attrs(RQ, ctl_id, Code._0100, "00", **kwargs)
863
-
864
- @classmethod # constructor for RQ|0418
865
- @typechecked
866
- @validate_api_params()
867
- def get_system_log_entry(
868
- cls, ctl_id: _DeviceIdT, log_idx: Union[int, str], **kwargs
869
- ):
870
- """Constructor to get a log entry from a system (c.f. parser_0418)."""
871
-
872
- log_idx = log_idx if isinstance(log_idx, int) else int(log_idx, 16)
873
- return cls.from_attrs(RQ, ctl_id, Code._0418, f"{log_idx:06X}", **kwargs)
874
-
875
- @classmethod # constructor for RQ|2E04
876
- @typechecked
877
- @validate_api_params()
878
- def get_system_mode(cls, ctl_id: _DeviceIdT, **kwargs):
879
- """Constructor to get the mode of a system (c.f. parser_2e04)."""
880
-
881
- return cls.from_attrs(RQ, ctl_id, Code._2E04, FF, **kwargs)
882
-
883
- @classmethod # constructor for W|2E04
884
- @typechecked
885
- @validate_api_params()
886
- def set_system_mode(
887
- cls,
888
- ctl_id: _DeviceIdT,
889
- system_mode: Union[None, int, str],
890
- *,
891
- until: Union[None, dt, str] = None,
892
- **kwargs,
893
- ):
894
- """Constructor to set/reset the mode of a system (c.f. parser_2e04)."""
895
-
896
- if system_mode is None:
897
- system_mode = SYS_MODE_MAP.AUTO
898
- if isinstance(system_mode, int):
899
- system_mode = f"{system_mode:02X}"
900
- if system_mode not in SYS_MODE_MAP:
901
- system_mode = SYS_MODE_MAP._hex(system_mode) # may raise KeyError
902
-
903
- if until is not None and system_mode in (
904
- SYS_MODE_MAP.AUTO,
905
- SYS_MODE_MAP.AUTO_WITH_RESET,
906
- SYS_MODE_MAP.HEAT_OFF,
907
- ):
908
- raise ValueError(
909
- f"Invalid args: For system_mode={SYS_MODE_MAP[system_mode]},"
910
- " until must be None"
911
- )
912
-
913
- payload = "".join(
914
- (
915
- system_mode,
916
- dtm_to_hex(until),
917
- "00" if until is None else "01",
918
- )
919
- )
920
-
921
- return cls.from_attrs(W_, ctl_id, Code._2E04, payload, **kwargs)
922
-
923
- @classmethod # constructor for RQ|313F
924
- @typechecked
925
- @validate_api_params()
926
- def get_system_time(cls, ctl_id: _DeviceIdT, **kwargs):
927
- """Constructor to get the datetime of a system (c.f. parser_313f)."""
928
-
929
- return cls.from_attrs(RQ, ctl_id, Code._313F, "00", **kwargs)
930
-
931
- @classmethod # constructor for W|313F
932
- @typechecked
933
- @validate_api_params()
934
- def set_system_time(
935
- cls,
936
- ctl_id: _DeviceIdT,
937
- datetime: Union[dt, str],
938
- is_dst: Optional[bool] = False,
939
- **kwargs,
940
- ):
941
- """Constructor to set the datetime of a system (c.f. parser_313f)."""
942
- # .W --- 30:185469 01:037519 --:------ 313F 009 0060003A0C1B0107E5
943
-
944
- dt_str = dtm_to_hex(datetime, is_dst=is_dst, incl_seconds=True)
945
- return cls.from_attrs(W_, ctl_id, Code._313F, f"0060{dt_str}", **kwargs)
946
-
947
- @classmethod # constructor for RQ|1100
948
- @typechecked
949
- @validate_api_params()
950
- def get_tpi_params(cls, dev_id: _DeviceIdT, *, domain_id=None, **kwargs):
951
- """Constructor to get the TPI params of a system (c.f. parser_1100)."""
952
-
953
- if domain_id is None:
954
- domain_id = "00" if dev_id[:2] == DEV_TYPE_MAP.BDR else FC
955
- return cls.from_attrs(RQ, dev_id, Code._1100, domain_id, **kwargs)
956
-
957
- @classmethod # constructor for W|1100
958
- # @typechecked # TODO
959
- @validate_api_params()
960
- def set_tpi_params(
961
- cls,
962
- ctl_id: _DeviceIdT,
963
- domain_id: Optional[str],
964
- *,
965
- cycle_rate: int = 3, # TODO: check
966
- min_on_time: int = 5, # TODO: check
967
- min_off_time: int = 5, # TODO: check
968
- proportional_band_width: Optional[float] = None, # TODO: check
969
- **kwargs,
970
- ):
971
- """Constructor to set the TPI params of a system (c.f. parser_1100)."""
972
-
973
- if domain_id is None:
974
- domain_id = "00"
975
-
976
- # assert cycle_rate is None or cycle_rate in (3, 6, 9, 12), cycle_rate
977
- # assert min_on_time is None or 1 <= min_on_time <= 5, min_on_time
978
- # assert min_off_time is None or 1 <= min_off_time <= 5, min_off_time
979
- # assert (
980
- # proportional_band_width is None or 1.5 <= proportional_band_width <= 3.0
981
- # ), proportional_band_width
982
-
983
- payload = "".join(
984
- (
985
- f"{domain_id:02X}" if isinstance(domain_id, int) else domain_id,
986
- f"{cycle_rate * 4:02X}",
987
- f"{int(min_on_time * 4):02X}",
988
- f"{int(min_off_time * 4):02X}00", # or: ...FF",
989
- f"{temp_to_hex(proportional_band_width)}01",
990
- )
991
- )
992
-
993
- return cls.from_attrs(W_, ctl_id, Code._1100, payload, **kwargs)
994
-
995
- @classmethod # constructor for RQ|000A
996
- @typechecked
997
- @validate_api_params(has_zone=True)
998
- def get_zone_config(cls, ctl_id: _DeviceIdT, zone_idx: _ZoneIdxT, **kwargs):
999
- """Constructor to get the config of a zone (c.f. parser_000a)."""
1000
-
1001
- return cls.from_attrs(
1002
- RQ, ctl_id, Code._000A, f"{zone_idx:02X}00", **kwargs
1003
- ) # TODO: needs 00?
1004
-
1005
- @classmethod # constructor for W|000A
1006
- @typechecked
1007
- @validate_api_params(has_zone=True)
1008
- def set_zone_config(
1009
- cls,
1010
- ctl_id: _DeviceIdT,
1011
- zone_idx: _ZoneIdxT,
1012
- *,
1013
- min_temp: float = 5,
1014
- max_temp: float = 35,
1015
- local_override: bool = False,
1016
- openwindow_function: bool = False,
1017
- multiroom_mode: bool = False,
1018
- **kwargs,
1019
- ):
1020
- """Constructor to set the config of a zone (c.f. parser_000a)."""
1021
-
1022
- if not (5 <= min_temp <= 21):
1023
- raise ValueError(f"Out of range, min_temp: {min_temp}")
1024
- if not (21 <= max_temp <= 35):
1025
- raise ValueError(f"Out of range, max_temp: {max_temp}")
1026
- if not isinstance(local_override, bool):
1027
- raise ValueError(f"Invalid arg, local_override: {local_override}")
1028
- if not isinstance(openwindow_function, bool):
1029
- raise ValueError(f"Invalid arg, openwindow_function: {openwindow_function}")
1030
- if not isinstance(multiroom_mode, bool):
1031
- raise ValueError(f"Invalid arg, multiroom_mode: {multiroom_mode}")
1032
-
1033
- bitmap = 0 if local_override else 1
1034
- bitmap |= 0 if openwindow_function else 2
1035
- bitmap |= 0 if multiroom_mode else 16
1036
-
1037
- payload = "".join(
1038
- (
1039
- f"{zone_idx:02X}",
1040
- f"{bitmap:02X}",
1041
- temp_to_hex(min_temp),
1042
- temp_to_hex(max_temp),
1043
- )
1044
- )
1045
-
1046
- return cls.from_attrs(W_, ctl_id, Code._000A, payload, **kwargs)
1047
-
1048
- @classmethod # constructor for RQ|2349
1049
- @typechecked
1050
- @validate_api_params(has_zone=True)
1051
- def get_zone_mode(cls, ctl_id: _DeviceIdT, zone_idx: _ZoneIdxT, **kwargs):
1052
- """Constructor to get the mode of a zone (c.f. parser_2349)."""
1053
-
1054
- return cls.from_attrs(
1055
- RQ, ctl_id, Code._2349, f"{zone_idx:02X}00", **kwargs
1056
- ) # TODO: needs 00?
1057
-
1058
- @classmethod # constructor for W|2349
1059
- @typechecked
1060
- @validate_api_params(has_zone=True)
1061
- def set_zone_mode(
1062
- cls,
1063
- ctl_id: _DeviceIdT,
1064
- zone_idx: _ZoneIdxT,
1065
- *,
1066
- mode: Union[None, int, str] = None,
1067
- setpoint: float = None,
1068
- until: Union[None, dt, str] = None,
1069
- duration: int = None,
1070
- **kwargs,
1071
- ):
1072
- """Constructor to set/reset the mode of a zone (c.f. parser_2349).
1073
-
1074
- The setpoint has a resolution of 0.1 C. If a setpoint temperature is required,
1075
- but none is provided, evohome will use the maximum possible value.
1076
-
1077
- The until has a resolution of 1 min.
1078
-
1079
- Incompatible combinations:
1080
- - mode == Follow & setpoint not None (will silently ignore setpoint)
1081
- - mode == Temporary & until is None (will silently ignore ???)
1082
- - until and duration are mutually exclusive
1083
- """
1084
-
1085
- # .W --- 18:013393 01:145038 --:------ 2349 013 0004E201FFFFFF330B1A0607E4
1086
- # .W --- 22:017139 01:140959 --:------ 2349 007 0801F400FFFFFF
1087
-
1088
- mode = _normalise_mode(mode, setpoint, until, duration)
1089
-
1090
- if setpoint is not None and not isinstance(setpoint, (float, int)):
1091
- raise TypeError(f"Invalid args: setpoint={setpoint}, but must be a float")
1092
-
1093
- until, duration = _normalise_until(mode, setpoint, until, duration)
1094
-
1095
- payload = "".join(
1096
- (
1097
- f"{zone_idx:02X}",
1098
- temp_to_hex(setpoint), # None means max, if a temp is required
1099
- mode,
1100
- "FFFFFF" if duration is None else f"{duration:06X}",
1101
- "" if until is None else dtm_to_hex(until),
1102
- )
1103
- )
1104
-
1105
- return cls.from_attrs(W_, ctl_id, Code._2349, payload, **kwargs)
1106
-
1107
- @classmethod # constructor for RQ|0004
1108
- @typechecked
1109
- @validate_api_params(has_zone=True)
1110
- def get_zone_name(cls, ctl_id: _DeviceIdT, zone_idx: _ZoneIdxT, **kwargs):
1111
- """Constructor to get the name of a zone (c.f. parser_0004)."""
1112
-
1113
- return cls.from_attrs(
1114
- RQ, ctl_id, Code._0004, f"{zone_idx:02X}00", **kwargs
1115
- ) # TODO: needs 00?
1116
-
1117
- @classmethod # constructor for W|0004
1118
- @typechecked
1119
- @validate_api_params(has_zone=True)
1120
- def set_zone_name(
1121
- cls, ctl_id: _DeviceIdT, zone_idx: _ZoneIdxT, name: str, **kwargs
1122
- ):
1123
- """Constructor to set the name of a zone (c.f. parser_0004)."""
1124
-
1125
- payload = f"{zone_idx:02X}00{str_to_hex(name)[:40]:0<40}"
1126
- return cls.from_attrs(W_, ctl_id, Code._0004, payload, **kwargs)
1127
-
1128
- @classmethod # constructor for W|2309
1129
- @typechecked
1130
- @validate_api_params(has_zone=True)
1131
- def set_zone_setpoint(
1132
- cls, ctl_id: _DeviceIdT, zone_idx: _ZoneIdxT, setpoint: float, **kwargs
1133
- ):
1134
- """Constructor to set the setpoint of a zone (c.f. parser_2309)."""
1135
- # .W --- 34:092243 01:145038 --:------ 2309 003 0107D0
1136
-
1137
- payload = f"{zone_idx:02X}{temp_to_hex(setpoint)}"
1138
- return cls.from_attrs(W_, ctl_id, Code._2309, payload, **kwargs)
1139
-
1140
- @classmethod # constructor for RQ|30C9
1141
- @typechecked
1142
- @validate_api_params(has_zone=True)
1143
- def get_zone_temp(cls, ctl_id: _DeviceIdT, zone_idx: _ZoneIdxT, **kwargs):
1144
- """Constructor to get the current temperature of a zone (c.f. parser_30c9)."""
1145
-
1146
- return cls.from_attrs(RQ, ctl_id, Code._30C9, f"{zone_idx:02X}", **kwargs)
1147
-
1148
- @classmethod # constructor for RQ|12B0
1149
- @typechecked
1150
- @validate_api_params(has_zone=True)
1151
- def get_zone_window_state(cls, ctl_id: _DeviceIdT, zone_idx: _ZoneIdxT, **kwargs):
1152
- """Constructor to get the openwindow state of a zone (c.f. parser_12b0)."""
1153
-
1154
- return cls.from_attrs(RQ, ctl_id, Code._12B0, f"{zone_idx:02X}", **kwargs)
1155
-
1156
- @classmethod # constructor for RP|3EF1 (I|3EF1?) # TODO: trap corrupt values?
1157
- @typechecked
1158
- @validate_api_params()
1159
- def put_actuator_cycle(
1160
- cls,
1161
- src_id: _DeviceIdT,
1162
- dst_id: _DeviceIdT,
1163
- modulation_level: float,
1164
- actuator_countdown: int,
1165
- *,
1166
- cycle_countdown: int = None,
1167
- **kwargs,
1168
- ):
1169
- """Constructor to announce the internal state of an actuator (3EF1).
1170
-
1171
- This is for use by a faked BDR91A or similar.
1172
- """
1173
- # RP --- 13:049798 18:006402 --:------ 3EF1 007 00-0126-0126-00-FF
1174
-
1175
- if src_id[:2] != DEV_TYPE_MAP.BDR:
1176
- raise TypeError(
1177
- f"Faked device {src_id} has an unsupported device type: "
1178
- f"device_id should be like {DEV_TYPE_MAP.BDR}:xxxxxx"
1179
- )
1180
-
1181
- payload = "00"
1182
- payload += f"{cycle_countdown:04X}" if cycle_countdown is not None else "7FFF"
1183
- payload += f"{actuator_countdown:04X}"
1184
- payload += f"{int(modulation_level * 200):02X}FF" # percent_to_hex
1185
- return cls._from_attrs(
1186
- RP, Code._3EF1, payload, addr0=src_id, addr1=dst_id, **kwargs
1187
- )
1188
-
1189
- @classmethod # constructor for I|3EF0 # TODO: trap corrupt states?
1190
- @typechecked
1191
- @validate_api_params()
1192
- def put_actuator_state(cls, dev_id: _DeviceIdT, modulation_level: float, **kwargs):
1193
- """Constructor to announce the modulation level of an actuator (3EF0).
1194
-
1195
- This is for use by a faked BDR91A or similar.
1196
- """
1197
- # .I --- 13:049798 --:------ 13:049798 3EF0 003 00C8FF
1198
- # .I --- 13:106039 --:------ 13:106039 3EF0 003 0000FF
1199
-
1200
- if dev_id[:2] != DEV_TYPE_MAP.BDR:
1201
- raise TypeError(
1202
- f"Faked device {dev_id} has an unsupported device type: "
1203
- f"device_id should be like {DEV_TYPE_MAP.BDR}:xxxxxx"
1204
- )
1205
-
1206
- payload = (
1207
- "007FFF"
1208
- if modulation_level is None
1209
- else f"00{int(modulation_level * 200):02X}FF"
1210
- )
1211
- return cls._from_attrs(
1212
- I_, Code._3EF0, payload, addr0=dev_id, addr2=dev_id, **kwargs
1213
- )
1214
-
1215
- @classmethod # constructor for 1FC9 (rf_bind) 3-way handshake
1216
- @typechecked
1217
- def put_bind(
1218
- cls,
1219
- verb: _VerbT,
1220
- codes: None | _CodeT | Iterable[_CodeT],
1221
- src_id: _DeviceIdT,
1222
- *,
1223
- idx="00",
1224
- dst_id: None | _DeviceIdT = None,
1225
- **kwargs,
1226
- ):
1227
- """Constructor for RF bind commands (1FC9), for use by faked devices."""
1228
-
1229
- # .I --- 34:021943 --:------ 34:021943 1FC9 024 00-2309-8855B7 00-1FC9-8855B7
1230
- # .W --- 01:145038 34:021943 --:------ 1FC9 006 00-2309-06368E
1231
- # .I --- 34:021943 01:145038 --:------ 1FC9 006 00-2309-8855B7 # or, simply: 00
1232
-
1233
- hex_id = Address.convert_to_hex(src_id)
1234
- codes = [] if codes is None else codes # TODO: untidy
1235
- codes = ([codes] if isinstance(codes, _CodeT) else list(codes)) + [Code._1FC9]
1236
-
1237
- if dst_id is None and verb == I_:
1238
- payload = "".join(f"{idx}{c}{hex_id}" for c in codes)
1239
- addr2 = src_id
1240
-
1241
- # elif dst_id and verb == I_: # optional, ? must be 00 for HVAC, but not Heat
1242
- # payload = "00"
1243
- # addr2 = NON_DEV_ADDR.id
1244
-
1245
- elif dst_id and verb in (I_, W_):
1246
- payload = f"00{codes[0]}{hex_id}"
1247
- addr2 = NON_DEV_ADDR.id
1248
-
1249
- else:
1250
- raise ValueError("Invalid parameters")
1251
-
1252
- kwargs[SZ_QOS] = {SZ_PRIORITY: Priority.HIGH, SZ_RETRIES: 3}
1253
- return cls._from_attrs(
1254
- verb, Code._1FC9, payload, addr0=src_id, addr1=dst_id, addr2=addr2, **kwargs
1255
- )
1256
-
1257
- @classmethod # constructor for I|1260 # TODO: trap corrupt temps?
1258
- @typechecked
1259
- @validate_api_params()
1260
- def put_dhw_temp(cls, dev_id: _DeviceIdT, temperature: float, **kwargs):
1261
- """Constructor to announce the current temperature of an DHW sensor (1260).
1262
-
1263
- This is for use by a faked CS92A or similar.
1264
- """
1265
-
1266
- dhw_idx = f"{kwargs.pop(SZ_DHW_IDX, 0):02X}" # only 00 or 01 (rare)
1267
-
1268
- if dev_id[:2] != DEV_TYPE_MAP.DHW:
1269
- raise TypeError(
1270
- f"Faked device {dev_id} has an unsupported device type: "
1271
- f"device_id should be like {DEV_TYPE_MAP.DHW}:xxxxxx"
1272
- )
1273
-
1274
- payload = f"{dhw_idx}{temp_to_hex(temperature)}"
1275
- return cls._from_attrs(
1276
- I_, Code._1260, payload, addr0=dev_id, addr2=dev_id, **kwargs
1277
- )
1278
-
1279
- @classmethod # constructor for I|1290 # TODO: trap corrupt temps?
1280
- @typechecked
1281
- @validate_api_params()
1282
- def put_outdoor_temp(cls, dev_id: _DeviceIdT, temperature: float, **kwargs):
1283
- """Constructor to announce the current outdoor temperature (1290).
1284
-
1285
- This is for use by a faked HVAC sensor or similar.
1286
- """
1287
-
1288
- payload = f"00{temp_to_hex(temperature)}"
1289
- return cls._from_attrs(
1290
- I_, Code._1290, payload, addr0=dev_id, addr2=dev_id, **kwargs
1291
- )
1292
-
1293
- @classmethod # constructor for I|30C9 # TODO: trap corrupt temps?
1294
- @typechecked
1295
- @validate_api_params()
1296
- def put_sensor_temp(
1297
- cls, dev_id: _DeviceIdT, temperature: Union[None, float, int], **kwargs
1298
- ):
1299
- """Constructor to announce the current temperature of a thermostat (3C09).
1300
-
1301
- This is for use by a faked DTS92(E) or similar.
1302
- """
1303
- # .I --- 34:021943 --:------ 34:021943 30C9 003 000C0D
1304
-
1305
- if dev_id[:2] not in (
1306
- DEV_TYPE_MAP.TR0, # 00
1307
- DEV_TYPE_MAP.HCW, # 03
1308
- DEV_TYPE_MAP.TRV, # 04
1309
- DEV_TYPE_MAP.DTS, # 12
1310
- DEV_TYPE_MAP.DT2, # 22
1311
- DEV_TYPE_MAP.RND, # 34
1312
- ):
1313
- raise TypeError(
1314
- f"Faked device {dev_id} has an unsupported device type: "
1315
- f"device_id should be like {DEV_TYPE_MAP.HCW}:xxxxxx"
1316
- )
1317
-
1318
- payload = f"00{temp_to_hex(temperature)}"
1319
- return cls._from_attrs(
1320
- I_, Code._30C9, payload, addr0=dev_id, addr2=dev_id, **kwargs
1321
- )
1322
-
1323
- @classmethod # constructor for I|1298
1324
- @typechecked
1325
- @validate_api_params()
1326
- def put_co2_level(
1327
- cls, dev_id: _DeviceIdT, co2_level: Union[None, float, int], /, **kwargs
1328
- ):
1329
- """Constructor to announce the current co2 level of a sensor (1298)."""
1330
- # .I --- 37:039266 --:------ 37:039266 1298 003 000316
1331
-
1332
- payload = f"00{double_to_hex(co2_level)}"
1333
- return cls._from_attrs(
1334
- I_, Code._1298, payload, addr0=dev_id, addr2=dev_id, **kwargs
1335
- )
1336
-
1337
- @classmethod # constructor for I|12A0
1338
- @typechecked
1339
- @validate_api_params()
1340
- def put_indoor_humidity(
1341
- cls, dev_id: _DeviceIdT, indoor_humidity: Union[None, float, int], /, **kwargs
1342
- ):
1343
- """Constructor to announce the current humidity of a sensor (12A0)."""
1344
- # .I --- 37:039266 --:------ 37:039266 1298 003 000316
1345
-
1346
- payload = f"00{int(indoor_humidity * 100):02X}" # percent_to_hex
1347
- return cls._from_attrs(
1348
- I_, Code._12A0, payload, addr0=dev_id, addr2=dev_id, **kwargs
1349
- )
1350
-
1351
- @classmethod # constructor for I|2E10
1352
- @typechecked
1353
- @validate_api_params()
1354
- def put_presence_detected(
1355
- cls, dev_id: _DeviceIdT, presence_detected: Union[None, bool], /, **kwargs
1356
- ):
1357
- """Constructor to announce the current presence state of a sensor (2E10)."""
1358
- # .I --- ...
1359
-
1360
- payload = f"00{bool_from_hex(presence_detected)}"
1361
- return cls._from_attrs(
1362
- I_, Code._2E10, payload, addr0=dev_id, addr2=dev_id, **kwargs
1363
- )
1364
-
1365
- @classmethod # constructor for I|0002 # TODO: trap corrupt temps?
1366
- @typechecked
1367
- @validate_api_params()
1368
- def put_weather_temp(cls, dev_id: _DeviceIdT, temperature: float, **kwargs):
1369
- """Constructor to announce the current temperature of a weather sensor (0002).
1370
-
1371
- This is for use by a faked HB85 or similar.
1372
- """
1373
-
1374
- if dev_id[:2] != DEV_TYPE_MAP.OUT:
1375
- raise TypeError(
1376
- f"Faked device {dev_id} has an unsupported device type: "
1377
- f"device_id should be like {DEV_TYPE_MAP.OUT}:xxxxxx"
1378
- )
1379
-
1380
- payload = f"00{temp_to_hex(temperature)}01"
1381
- return cls._from_attrs(
1382
- I_, Code._0002, payload, addr0=dev_id, addr2=dev_id, **kwargs
1383
- )
1384
-
1385
- @classmethod # constructor for internal use only
1386
- @typechecked
1387
- def _puzzle(cls, msg_type: None | str = None, message: str = "", **kwargs):
1388
-
1389
- if msg_type is None:
1390
- msg_type = "12" if message else "10"
1391
-
1392
- assert msg_type in LOOKUP_PUZZ, f"Invalid/deprecated Puzzle type: {msg_type}"
1393
-
1394
- qos = kwargs.get(SZ_QOS, {})
1395
- qos[SZ_PRIORITY] = qos.get(SZ_PRIORITY, Priority.HIGHEST)
1396
- if msg_type == "10":
1397
- qos[SZ_DISABLE_BACKOFF] = qos.get(SZ_DISABLE_BACKOFF, True)
1398
- qos[SZ_RETRIES] = qos.get(SZ_RETRIES, 12)
1399
- kwargs[SZ_QOS] = qos
1400
-
1401
- payload = f"00{msg_type}"
1402
-
1403
- if msg_type != "13":
1404
- payload += f"{int(timestamp() * 1000):012X}"
1405
-
1406
- if msg_type == "10":
1407
- payload += str_to_hex(f"v{VERSION}")
1408
- elif msg_type == "11":
1409
- payload += str_to_hex(message[:4] + message[5:7] + message[8:])
1410
- else:
1411
- payload += str_to_hex(message)
1412
-
1413
- return cls.from_attrs(I_, NUL_DEV_ADDR.id, Code._PUZZ, payload[:48], **kwargs)
1414
-
1415
-
1416
- def _mk_cmd(
1417
- verb: _VerbT, code: _CodeT, payload: _PayloadT, dest_id, **kwargs
1418
- ) -> Command:
1419
- """A convenience function, to cope with a change to the Command class."""
1420
- return Command.from_attrs(verb, dest_id, code, payload, **kwargs)
1421
-
1422
-
1423
- # A convenience dict
1424
- CODE_API_MAP = {
1425
- f"{I_}|{Code._0002}": Command.put_weather_temp,
1426
- f"{RQ}|{Code._0004}": Command.get_zone_name,
1427
- f"{W_}|{Code._0004}": Command.set_zone_name,
1428
- f"{RQ}|{Code._0008}": Command.get_relay_demand,
1429
- f"{RQ}|{Code._000A}": Command.get_zone_config,
1430
- f"{W_}|{Code._000A}": Command.set_zone_config,
1431
- f"{RQ}|{Code._0100}": Command.get_system_language,
1432
- f"{RQ}|{Code._0404}": Command.get_schedule_fragment,
1433
- f"{W_}|{Code._0404}": Command.set_schedule_fragment,
1434
- f"{RQ}|{Code._0418}": Command.get_system_log_entry,
1435
- f"{RQ}|{Code._1030}": Command.get_mix_valve_params,
1436
- f"{W_}|{Code._1030}": Command.set_mix_valve_params,
1437
- f"{RQ}|{Code._10A0}": Command.get_dhw_params,
1438
- f"{W_}|{Code._10A0}": Command.set_dhw_params,
1439
- f"{RQ}|{Code._1100}": Command.get_tpi_params,
1440
- f"{W_}|{Code._1100}": Command.set_tpi_params,
1441
- f"{RQ}|{Code._1260}": Command.get_dhw_temp,
1442
- f"{I_}|{Code._1260}": Command.put_dhw_temp,
1443
- f"{I_}|{Code._1290}": Command.put_outdoor_temp,
1444
- f"{I_}|{Code._1298}": Command.put_co2_level,
1445
- f"{I_}|{Code._12A0}": Command.put_indoor_humidity,
1446
- f"{RQ}|{Code._12B0}": Command.get_zone_window_state,
1447
- f"{RQ}|{Code._1F41}": Command.get_dhw_mode,
1448
- f"{W_}|{Code._1F41}": Command.set_dhw_mode,
1449
- f"{I_}|{Code._22F1}": Command.set_fan_mode,
1450
- f"{W_}|{Code._22F7}": Command.set_bypass_position,
1451
- f"{RQ}|{Code._2349}": Command.get_zone_mode,
1452
- f"{W_}|{Code._2349}": Command.set_zone_mode,
1453
- f"{W_}|{Code._2411}": Command.set_fan_param,
1454
- f"{RQ}|{Code._2E04}": Command.get_system_mode,
1455
- f"{W_}|{Code._2E04}": Command.set_system_mode,
1456
- f"{I_}|{Code._2E10}": Command.put_presence_detected,
1457
- f"{I_}|{Code._30C9}": Command.put_sensor_temp,
1458
- f"{RQ}|{Code._30C9}": Command.get_zone_temp,
1459
- f"{RQ}|{Code._313F}": Command.get_system_time,
1460
- f"{W_}|{Code._313F}": Command.set_system_time,
1461
- f"{RQ}|{Code._3220}": Command.get_opentherm_data,
1462
- } # TODO: RQ|0404 (Zone & DHW)
1463
-
1464
-
1465
- class FaultLog: # 0418 # TODO: used a NamedTuple
1466
- """The fault log of a system."""
1467
-
1468
- def __init__(self, ctl, **kwargs) -> None:
1469
- _LOGGER.debug("FaultLog(ctl=%s).__init__()", ctl)
1470
-
1471
- self._loop = ctl._gwy._loop
1472
-
1473
- self.id = ctl.id
1474
- self.ctl = ctl
1475
- # self.tcs = ctl.tcs
1476
- self._gwy = ctl._gwy
1477
-
1478
- self._faultlog: dict = {}
1479
- self._faultlog_done: None | bool = None
1480
-
1481
- self._START = 0x00 # max 0x3E
1482
- self._limit = 0x06
1483
-
1484
- def __repr__(self) -> str:
1485
- return json.dumps(self._faultlog) if self._faultlog_done else "{}" # TODO:
1486
-
1487
- def __str__(self) -> str:
1488
- return f"{self.ctl} (fault log)"
1489
-
1490
- # @staticmethod
1491
- # def _is_valid_operand(other) -> bool:
1492
- # return hasattr(other, "verb") and hasattr(other, "_pkt")
1493
-
1494
- # def __eq__(self, other) -> bool:
1495
- # if not self._is_valid_operand(other):
1496
- # return NotImplemented
1497
- # return (self.verb, self._pkt.payload) == (other.verb, self._pkt.payload)
1498
-
1499
- async def get_faultlog(self, start=0, limit=6, force_refresh=None) -> None | dict:
1500
- """Get the fault log of a system."""
1501
- _LOGGER.debug("FaultLog(%s).get_faultlog()", self)
1502
-
1503
- if self._gwy.config.disable_sending:
1504
- raise RuntimeError("Sending is disabled")
1505
-
1506
- self._START = 0 if start is None else start
1507
- self._limit = 6 if limit is None else limit
1508
-
1509
- self._faultlog = {} # TODO: = namedtuple("Fault", "timestamp fault_state ...")
1510
- self._faultlog_done = None
1511
-
1512
- self._rq_log_entry(log_idx=self._START) # calls loop.create_task()
1513
-
1514
- time_start = dt.now()
1515
- while not self._faultlog_done:
1516
- await asyncio.sleep(TIMER_SHORT_SLEEP)
1517
- if dt.now() > time_start + TIMER_LONG_TIMEOUT * 2:
1518
- raise ExpiredCallbackError("failed to obtain log entry (long)")
1519
-
1520
- return self.faultlog
1521
-
1522
- def _rq_log_entry(self, log_idx=0):
1523
- """Request the next log entry."""
1524
- _LOGGER.debug("FaultLog(%s)._rq_log_entry(%s)", self, log_idx)
1525
-
1526
- def rq_callback(msg) -> None:
1527
- _LOGGER.debug("FaultLog(%s)._proc_log_entry(%s)", self.id, msg)
1528
-
1529
- if not msg:
1530
- self._faultlog_done = True
1531
- # raise ExpiredCallbackError("failed to obtain log entry (short)")
1532
- return
1533
-
1534
- log = dict(msg.payload)
1535
- log_idx = int(log.pop("log_idx", "00"), 16)
1536
- if not log: # null response (no payload)
1537
- # TODO: delete other callbacks rather than waiting for them to expire
1538
- self._faultlog_done = True
1539
- return
1540
-
1541
- self._faultlog[log_idx] = log # TODO: make a named tuple
1542
- if log_idx < self._limit:
1543
- self._rq_log_entry(log_idx + 1)
1544
- else:
1545
- self._faultlog_done = True
1546
-
1547
- # register callback for null response, which has no ctx (no frag_id),
1548
- # and so a different header
1549
- null_header = "|".join((RP, self.id, Code._0418))
1550
- if null_header not in self._gwy.msg_transport._callbacks:
1551
- self._gwy.msg_transport._callbacks[null_header] = {
1552
- SZ_FUNC: rq_callback,
1553
- SZ_DAEMON: True,
1554
- }
1555
-
1556
- rq_callback = {SZ_FUNC: rq_callback, SZ_TIMEOUT: 10}
1557
- self._gwy.send_cmd(
1558
- Command.get_system_log_entry(self.ctl.id, log_idx, callback=rq_callback)
1559
- )
1560
-
1561
- @property
1562
- def faultlog(self) -> None | dict:
1563
- """Return the fault log of a system."""
1564
- if not self._faultlog_done:
1565
- return None
1566
-
1567
- result = {
1568
- x: {k: v for k, v in y.items() if k[:1] != "_"}
1569
- for x, y in self._faultlog.items()
1570
- }
1571
-
1572
- return {k: list(v.values()) for k, v in result.items()}
1573
-
1574
- @property
1575
- def _faultlog_outdated(self) -> bool:
1576
- return bool(self._faultlog_done and len(self._faultlog))