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