ramses-rf 0.22.40__py3-none-any.whl → 0.51.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ramses_cli/__init__.py +18 -0
- ramses_cli/client.py +597 -0
- ramses_cli/debug.py +20 -0
- ramses_cli/discovery.py +405 -0
- ramses_cli/utils/cat_slow.py +17 -0
- ramses_cli/utils/convert.py +60 -0
- ramses_rf/__init__.py +31 -10
- ramses_rf/binding_fsm.py +787 -0
- ramses_rf/const.py +124 -105
- ramses_rf/database.py +297 -0
- ramses_rf/device/__init__.py +69 -39
- ramses_rf/device/base.py +187 -376
- ramses_rf/device/heat.py +540 -552
- ramses_rf/device/hvac.py +279 -171
- ramses_rf/dispatcher.py +153 -177
- ramses_rf/entity_base.py +478 -361
- ramses_rf/exceptions.py +82 -0
- ramses_rf/gateway.py +377 -513
- ramses_rf/helpers.py +57 -19
- ramses_rf/py.typed +0 -0
- ramses_rf/schemas.py +148 -194
- ramses_rf/system/__init__.py +16 -23
- ramses_rf/system/faultlog.py +363 -0
- ramses_rf/system/heat.py +295 -302
- ramses_rf/system/schedule.py +312 -198
- ramses_rf/system/zones.py +318 -238
- ramses_rf/version.py +2 -8
- ramses_rf-0.51.2.dist-info/METADATA +72 -0
- ramses_rf-0.51.2.dist-info/RECORD +55 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info}/WHEEL +1 -2
- ramses_rf-0.51.2.dist-info/entry_points.txt +2 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info/licenses}/LICENSE +1 -1
- ramses_tx/__init__.py +160 -0
- {ramses_rf/protocol → ramses_tx}/address.py +65 -59
- ramses_tx/command.py +1454 -0
- ramses_tx/const.py +903 -0
- ramses_tx/exceptions.py +92 -0
- {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
- {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
- ramses_tx/gateway.py +338 -0
- ramses_tx/helpers.py +883 -0
- {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
- {ramses_rf/protocol → ramses_tx}/message.py +155 -191
- ramses_tx/opentherm.py +1260 -0
- ramses_tx/packet.py +210 -0
- {ramses_rf/protocol → ramses_tx}/parsers.py +1266 -1003
- ramses_tx/protocol.py +801 -0
- ramses_tx/protocol_fsm.py +672 -0
- ramses_tx/py.typed +0 -0
- {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
- {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
- ramses_tx/transport.py +1471 -0
- ramses_tx/typed_dicts.py +492 -0
- ramses_tx/typing.py +181 -0
- ramses_tx/version.py +4 -0
- ramses_rf/discovery.py +0 -398
- ramses_rf/protocol/__init__.py +0 -59
- ramses_rf/protocol/backports.py +0 -42
- ramses_rf/protocol/command.py +0 -1576
- ramses_rf/protocol/const.py +0 -697
- ramses_rf/protocol/exceptions.py +0 -111
- ramses_rf/protocol/helpers.py +0 -390
- ramses_rf/protocol/opentherm.py +0 -1170
- ramses_rf/protocol/packet.py +0 -235
- ramses_rf/protocol/protocol.py +0 -613
- ramses_rf/protocol/transport.py +0 -1011
- ramses_rf/protocol/version.py +0 -10
- ramses_rf/system/hvac.py +0 -82
- ramses_rf-0.22.40.dist-info/METADATA +0 -64
- ramses_rf-0.22.40.dist-info/RECORD +0 -42
- ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
ramses_tx/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)
|