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_rf/entity_base.py
CHANGED
|
@@ -1,127 +1,276 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
"""RAMSES RF - a RAMSES-II protocol decoder & analyser.
|
|
2
|
+
"""RAMSES RF - Base class for all RAMSES-II objects: devices and constructs."""
|
|
5
3
|
|
|
6
|
-
Entity is the base of all RAMSES-II objects: devices and also system/zone constructs.
|
|
7
|
-
"""
|
|
8
4
|
from __future__ import annotations
|
|
9
5
|
|
|
10
6
|
import asyncio
|
|
7
|
+
import contextlib
|
|
11
8
|
import logging
|
|
12
9
|
import random
|
|
13
|
-
from
|
|
14
|
-
from datetime import datetime as dt
|
|
15
|
-
from datetime import timedelta as td
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from datetime import datetime as dt, timedelta as td
|
|
16
12
|
from inspect import getmembers, isclass
|
|
17
13
|
from sys import modules
|
|
18
|
-
from
|
|
14
|
+
from types import ModuleType
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
16
|
+
|
|
17
|
+
from ramses_rf.helpers import schedule_task
|
|
18
|
+
from ramses_tx import Priority, QosParams
|
|
19
|
+
from ramses_tx.address import ALL_DEVICE_ID
|
|
20
|
+
from ramses_tx.const import MsgId
|
|
21
|
+
from ramses_tx.opentherm import OPENTHERM_MESSAGES
|
|
22
|
+
from ramses_tx.ramses import CODES_SCHEMA
|
|
19
23
|
|
|
24
|
+
from . import exceptions as exc
|
|
20
25
|
from .const import (
|
|
21
26
|
DEV_TYPE_MAP,
|
|
22
27
|
SZ_ACTUATORS,
|
|
23
|
-
SZ_DEVICE_ID,
|
|
24
28
|
SZ_DOMAIN_ID,
|
|
25
29
|
SZ_NAME,
|
|
26
30
|
SZ_SENSOR,
|
|
27
|
-
SZ_UFH_IDX,
|
|
28
31
|
SZ_ZONE_IDX,
|
|
29
|
-
__dev_mode__,
|
|
30
32
|
)
|
|
31
|
-
from .protocol import CorruptStateError
|
|
32
|
-
from .protocol.frame import _CodeT, _DeviceIdT, _HeaderT, _VerbT
|
|
33
|
-
from .protocol.opentherm import OPENTHERM_MESSAGES
|
|
34
|
-
from .protocol.ramses import CODES_SCHEMA
|
|
35
|
-
from .protocol.transport import PacketProtocolPort
|
|
36
33
|
from .schemas import SZ_CIRCUITS
|
|
37
34
|
|
|
38
|
-
# skipcq: PY-W2000
|
|
39
35
|
from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
40
36
|
I_,
|
|
41
37
|
RP,
|
|
42
38
|
RQ,
|
|
43
39
|
W_,
|
|
40
|
+
Code,
|
|
41
|
+
VerbT,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
44
45
|
F9,
|
|
45
46
|
FA,
|
|
46
47
|
FC,
|
|
47
48
|
FF,
|
|
48
|
-
Code,
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
if TYPE_CHECKING:
|
|
52
|
-
from
|
|
52
|
+
from ramses_tx import Command, Message, Packet, VerbT
|
|
53
|
+
from ramses_tx.frame import HeaderT
|
|
54
|
+
from ramses_tx.opentherm import OtDataId
|
|
55
|
+
from ramses_tx.schemas import DeviceIdT, DevIndexT
|
|
56
|
+
|
|
57
|
+
from .device import (
|
|
58
|
+
BdrSwitch,
|
|
59
|
+
Controller,
|
|
60
|
+
DhwSensor,
|
|
61
|
+
OtbGateway,
|
|
62
|
+
TrvActuator,
|
|
63
|
+
UfhCircuit,
|
|
64
|
+
)
|
|
65
|
+
from .gateway import Gateway
|
|
66
|
+
from .system import Evohome
|
|
53
67
|
|
|
54
68
|
|
|
55
69
|
_QOS_TX_LIMIT = 12 # TODO: needs work
|
|
56
70
|
|
|
57
|
-
|
|
71
|
+
_SZ_LAST_PKT: Final = "last_msg"
|
|
72
|
+
_SZ_NEXT_DUE: Final = "next_due"
|
|
73
|
+
_SZ_TIMEOUT: Final = "timeout"
|
|
74
|
+
_SZ_FAILURES: Final = "failures"
|
|
75
|
+
_SZ_INTERVAL: Final = "interval"
|
|
76
|
+
_SZ_COMMAND: Final = "command"
|
|
58
77
|
|
|
59
|
-
#
|
|
78
|
+
#
|
|
79
|
+
# NOTE: All debug flags should be False for deployment to end-users
|
|
80
|
+
_DBG_ENABLE_DISCOVERY_BACKOFF: Final[bool] = False
|
|
60
81
|
|
|
61
82
|
_LOGGER = logging.getLogger(__name__)
|
|
62
|
-
if DEV_MODE:
|
|
63
|
-
_LOGGER.setLevel(logging.DEBUG)
|
|
64
83
|
|
|
65
84
|
|
|
66
|
-
def class_by_attr(name: str, attr: str) -> dict: # TODO: change to __module__
|
|
67
|
-
"""Return a mapping of a (unique) attr of classes in a module to that class.
|
|
85
|
+
def class_by_attr(name: str, attr: str) -> dict[str, Any]: # TODO: change to __module__
|
|
86
|
+
"""Return a mapping of a (unique) attr of classes in a module to that class."""
|
|
87
|
+
|
|
88
|
+
def predicate(m: ModuleType) -> bool:
|
|
89
|
+
return isclass(m) and m.__module__ == name and getattr(m, attr, None)
|
|
90
|
+
|
|
91
|
+
return {getattr(c[1], attr): c[1] for c in getmembers(modules[name], predicate)}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _Entity:
|
|
95
|
+
"""The ultimate base class for Devices/Zones/Systems.
|
|
68
96
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
{ZON_ROLE.RAD: RadZone, ZON_ROLE.UFH: UfhZone}
|
|
72
|
-
{"evohome": Evohome}
|
|
97
|
+
This class is mainly concerned with:
|
|
98
|
+
- if the entity can Rx packets (e.g. can the HGI send it an RQ)
|
|
73
99
|
"""
|
|
74
100
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
101
|
+
_SLUG: str = None # type: ignore[assignment]
|
|
102
|
+
|
|
103
|
+
def __init__(self, gwy: Gateway) -> None:
|
|
104
|
+
self._gwy = gwy
|
|
105
|
+
self.id: DeviceIdT = None # type: ignore[assignment]
|
|
106
|
+
|
|
107
|
+
self._qos_tx_count = 0 # the number of pkts Tx'd with no matching Rx
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str:
|
|
110
|
+
return f"{self.id} ({self._SLUG})"
|
|
111
|
+
|
|
112
|
+
# TODO: should be a private method
|
|
113
|
+
def deprecate_device(self, pkt: Packet, reset: bool = False) -> None:
|
|
114
|
+
"""If an entity is deprecated enough times, stop sending to it."""
|
|
115
|
+
|
|
116
|
+
if reset:
|
|
117
|
+
self._qos_tx_count = 0
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
self._qos_tx_count += 1
|
|
121
|
+
if self._qos_tx_count == _QOS_TX_LIMIT:
|
|
122
|
+
_LOGGER.warning(
|
|
123
|
+
f"{pkt} < Sending now deprecated for {self} "
|
|
124
|
+
"(consider adjusting device_id filters)"
|
|
125
|
+
) # TODO: take whitelist into account
|
|
126
|
+
|
|
127
|
+
def _handle_msg(self, msg: Message) -> None: # TODO: beware, this is a mess
|
|
128
|
+
"""Store a msg in _msgs[code] (only latest I/RP) and _msgz[code][verb][ctx]."""
|
|
129
|
+
|
|
130
|
+
raise NotImplementedError
|
|
131
|
+
|
|
132
|
+
# super()._handle_msg(msg) # store the message in the database
|
|
133
|
+
|
|
134
|
+
# if self._gwy.hgi and msg.src.id != self._gwy.hgi.id:
|
|
135
|
+
# self.deprecate_device(msg._pkt, reset=True)
|
|
136
|
+
|
|
137
|
+
# FIXME: this is a mess - to deprecate for async version?
|
|
138
|
+
def _send_cmd(self, cmd: Command, **kwargs: Any) -> asyncio.Task | None:
|
|
139
|
+
"""Send a Command & return the corresponding Task."""
|
|
140
|
+
|
|
141
|
+
# Don't poll this device if it is not responding
|
|
142
|
+
if self._qos_tx_count > _QOS_TX_LIMIT:
|
|
143
|
+
_LOGGER.info(f"{cmd} < Sending was deprecated for {self}")
|
|
144
|
+
return None # TODO: raise Exception (should be handled before now)
|
|
145
|
+
|
|
146
|
+
if [ # TODO: remove this
|
|
147
|
+
k for k in kwargs if k not in ("priority", "num_repeats")
|
|
148
|
+
]: # FIXME: deprecate QoS in kwargs, should be qos=QosParams(...)
|
|
149
|
+
raise RuntimeError("Deprecated kwargs: %s", kwargs)
|
|
150
|
+
|
|
151
|
+
# cmd._source_entity = self # TODO: is needed?
|
|
152
|
+
# _msgs.pop(cmd.code, None) # NOTE: Cause of DHW bug
|
|
153
|
+
return self._gwy.send_cmd(cmd, wait_for_reply=False, **kwargs)
|
|
154
|
+
|
|
155
|
+
# FIXME: this is a mess
|
|
156
|
+
async def _async_send_cmd(
|
|
157
|
+
self,
|
|
158
|
+
cmd: Command,
|
|
159
|
+
priority: Priority | None = None,
|
|
160
|
+
qos: QosParams | None = None, # FIXME: deprecate QoS in kwargs?
|
|
161
|
+
) -> Packet | None:
|
|
162
|
+
"""Send a Command & return the response Packet, or the echo Packet otherwise."""
|
|
163
|
+
|
|
164
|
+
# Don't poll this device if it is not responding
|
|
165
|
+
if self._qos_tx_count > _QOS_TX_LIMIT:
|
|
166
|
+
_LOGGER.warning(f"{cmd} < Sending was deprecated for {self}")
|
|
167
|
+
return None # FIXME: raise Exception (should be handled before now)
|
|
168
|
+
|
|
169
|
+
# cmd._source_entity = self # TODO: is needed?
|
|
170
|
+
return await self._gwy.async_send_cmd(
|
|
171
|
+
cmd,
|
|
172
|
+
max_retries=qos.max_retries if qos else None,
|
|
173
|
+
priority=priority,
|
|
174
|
+
timeout=qos.timeout if qos else None,
|
|
175
|
+
wait_for_reply=qos.wait_for_reply if qos else None,
|
|
80
176
|
)
|
|
81
|
-
}
|
|
82
177
|
|
|
83
178
|
|
|
84
|
-
class
|
|
179
|
+
class _MessageDB(_Entity):
|
|
85
180
|
"""Maintain/utilize an entity's state database."""
|
|
86
181
|
|
|
87
|
-
_gwy:
|
|
88
|
-
ctl:
|
|
89
|
-
tcs:
|
|
182
|
+
_gwy: Gateway
|
|
183
|
+
ctl: Controller
|
|
184
|
+
tcs: Evohome
|
|
185
|
+
|
|
186
|
+
# These attr used must be in this class, and the Entity base class
|
|
187
|
+
_z_id: DeviceIdT
|
|
188
|
+
_z_idx: DevIndexT | None # e.g. 03, HW, is None for CTL, TCS
|
|
189
|
+
|
|
190
|
+
def __init__(self, gwy: Gateway) -> None:
|
|
191
|
+
super().__init__(gwy)
|
|
90
192
|
|
|
91
|
-
|
|
92
|
-
self.
|
|
93
|
-
|
|
193
|
+
self._msgs_: dict[Code, Message] = {} # code, should be code/ctx? ?deprecate
|
|
194
|
+
self._msgz_: dict[
|
|
195
|
+
Code, dict[VerbT, dict[bool | str | None, Message]]
|
|
196
|
+
] = {} # code/verb/ctx, should be code/ctx/verb?
|
|
94
197
|
|
|
95
198
|
def _handle_msg(self, msg: Message) -> None: # TODO: beware, this is a mess
|
|
96
|
-
"""Store a msg in
|
|
199
|
+
"""Store a msg in the DBs."""
|
|
97
200
|
|
|
98
|
-
if
|
|
99
|
-
self.
|
|
201
|
+
if not (
|
|
202
|
+
msg.src.id == self.id[:9]
|
|
203
|
+
or (msg.dst.id == self.id[:9] and msg.verb != RQ)
|
|
204
|
+
or (msg.dst.id == ALL_DEVICE_ID and msg.code == Code._1FC9)
|
|
205
|
+
):
|
|
206
|
+
return # ZZZ: don't store these
|
|
100
207
|
|
|
101
|
-
if msg.
|
|
102
|
-
self.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
208
|
+
if msg.verb in (I_, RP):
|
|
209
|
+
self._msgs_[msg.code] = msg
|
|
210
|
+
|
|
211
|
+
if msg.code not in self._msgz_:
|
|
212
|
+
self._msgz_[msg.code] = {msg.verb: {msg._pkt._ctx: msg}}
|
|
213
|
+
elif msg.verb not in self._msgz_[msg.code]:
|
|
214
|
+
self._msgz_[msg.code][msg.verb] = {msg._pkt._ctx: msg}
|
|
215
|
+
else: # if not self._gwy._zzz:
|
|
216
|
+
self._msgz_[msg.code][msg.verb][msg._pkt._ctx] = msg
|
|
217
|
+
# elif (
|
|
218
|
+
# self._msgz[msg.code][msg.verb][msg._pkt._ctx] is not msg
|
|
219
|
+
# ): # MsgIdx ensures this
|
|
220
|
+
# assert False # TODO: remove
|
|
107
221
|
|
|
108
222
|
@property
|
|
109
|
-
def _msg_db(self) -> list: #
|
|
223
|
+
def _msg_db(self) -> list[Message]: # flattened version of _msgz[code][verb][indx]
|
|
110
224
|
"""Return a flattened version of _msgz[code][verb][index].
|
|
111
225
|
|
|
112
226
|
The idx is one of:
|
|
113
227
|
- a simple index (e.g. zone_idx, domain_id, aka child_id)
|
|
114
|
-
- a
|
|
228
|
+
- a compound ctx (e.g. 0005/000C/0418)
|
|
115
229
|
- True (an array of elements, each with its own idx),
|
|
116
230
|
- False (no idx, is usu. 00),
|
|
117
231
|
- None (not deteminable, rare)
|
|
118
232
|
"""
|
|
119
233
|
return [m for c in self._msgz.values() for v in c.values() for m in v.values()]
|
|
120
234
|
|
|
121
|
-
def
|
|
235
|
+
def _delete_msg(self, msg: Message) -> None: # FIXME: this is a mess
|
|
236
|
+
"""Remove the msg from all state databases."""
|
|
237
|
+
|
|
238
|
+
from .device import Device
|
|
239
|
+
|
|
240
|
+
obj: _MessageDB
|
|
241
|
+
|
|
242
|
+
if self._gwy._zzz:
|
|
243
|
+
self._gwy._zzz.rem(msg)
|
|
244
|
+
|
|
245
|
+
entities: list[_MessageDB] = []
|
|
246
|
+
if isinstance(msg.src, Device):
|
|
247
|
+
entities = [msg.src]
|
|
248
|
+
if getattr(msg.src, "tcs", None):
|
|
249
|
+
entities.append(msg.src.tcs)
|
|
250
|
+
if msg.src.tcs.dhw:
|
|
251
|
+
entities.append(msg.src.tcs.dhw)
|
|
252
|
+
entities.extend(msg.src.tcs.zones)
|
|
253
|
+
|
|
254
|
+
# remove the msg from all the state DBs
|
|
255
|
+
for obj in entities:
|
|
256
|
+
if msg in obj._msgs_.values():
|
|
257
|
+
del obj._msgs_[msg.code]
|
|
258
|
+
with contextlib.suppress(KeyError):
|
|
259
|
+
del obj._msgz_[msg.code][msg.verb][msg._pkt._ctx]
|
|
260
|
+
|
|
261
|
+
def _get_msg_by_hdr(self, hdr: HeaderT) -> Message | None:
|
|
122
262
|
"""Return a msg, if any, that matches a header."""
|
|
123
263
|
|
|
124
|
-
|
|
264
|
+
# if self._gwy._zzz:
|
|
265
|
+
# msgs = self._gwy._zzz.get(hdr=hdr)
|
|
266
|
+
# return msgs[0] if msgs else None
|
|
267
|
+
|
|
268
|
+
msg: Message
|
|
269
|
+
code: Code
|
|
270
|
+
verb: VerbT
|
|
271
|
+
|
|
272
|
+
# _ is device_id
|
|
273
|
+
code, verb, _, *args = hdr.split("|") # type: ignore[assignment]
|
|
125
274
|
|
|
126
275
|
try:
|
|
127
276
|
if args and (ctx := args[0]): # ctx may == True
|
|
@@ -140,26 +289,29 @@ class MessageDB:
|
|
|
140
289
|
|
|
141
290
|
return msg
|
|
142
291
|
|
|
143
|
-
def _msg_flag(self, code:
|
|
144
|
-
|
|
292
|
+
def _msg_flag(self, code: Code, key: str, idx: int) -> bool | None:
|
|
145
293
|
if flags := self._msg_value(code, key=key):
|
|
146
294
|
return bool(flags[idx])
|
|
147
295
|
return None
|
|
148
296
|
|
|
149
|
-
def _msg_value(
|
|
150
|
-
|
|
151
|
-
|
|
297
|
+
def _msg_value(
|
|
298
|
+
self, code: Code | Iterable[Code], *args: Any, **kwargs: Any
|
|
299
|
+
) -> dict | list | None:
|
|
300
|
+
if isinstance(code, str | tuple): # a code or a tuple of codes
|
|
152
301
|
return self._msg_value_code(code, *args, **kwargs)
|
|
153
302
|
# raise RuntimeError
|
|
154
303
|
return self._msg_value_msg(code, *args, **kwargs) # assume is a Message
|
|
155
304
|
|
|
156
305
|
def _msg_value_code(
|
|
157
|
-
self,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
306
|
+
self,
|
|
307
|
+
code: Code,
|
|
308
|
+
verb: VerbT | None = None,
|
|
309
|
+
key: str | None = None,
|
|
310
|
+
**kwargs: Any,
|
|
311
|
+
) -> dict | list | None:
|
|
312
|
+
assert not isinstance(code, tuple) or verb is None, (
|
|
313
|
+
f"Unsupported: using a tuple ({code}) with a verb ({verb})"
|
|
314
|
+
)
|
|
163
315
|
|
|
164
316
|
if verb:
|
|
165
317
|
try:
|
|
@@ -170,26 +322,32 @@ class MessageDB:
|
|
|
170
322
|
msg = max(msgs.values()) if msgs else None
|
|
171
323
|
elif isinstance(code, tuple):
|
|
172
324
|
msgs = [m for m in self._msgs.values() if m.code in code]
|
|
173
|
-
msg =
|
|
325
|
+
msg = (
|
|
326
|
+
max(msgs) if msgs else None
|
|
327
|
+
) # return highest value found in code:value pairs
|
|
174
328
|
else:
|
|
175
329
|
msg = self._msgs.get(code)
|
|
176
330
|
|
|
177
331
|
return self._msg_value_msg(msg, key=key, **kwargs)
|
|
178
332
|
|
|
179
333
|
def _msg_value_msg(
|
|
180
|
-
self,
|
|
181
|
-
|
|
182
|
-
|
|
334
|
+
self,
|
|
335
|
+
msg: Message | None,
|
|
336
|
+
key: str | None = None,
|
|
337
|
+
zone_idx: str | None = None,
|
|
338
|
+
domain_id: str | None = None,
|
|
339
|
+
) -> dict | list | None:
|
|
183
340
|
if msg is None:
|
|
184
341
|
return None
|
|
185
342
|
elif msg._expired:
|
|
186
|
-
self._gwy._loop.call_soon(_delete_msg, msg) # HA bugs without
|
|
343
|
+
self._gwy._loop.call_soon(self._delete_msg, msg) # HA bugs without defer
|
|
187
344
|
|
|
188
345
|
if msg.code == Code._1FC9: # NOTE: list of lists/tuples
|
|
189
346
|
return [x[1] for x in msg.payload]
|
|
190
347
|
|
|
191
|
-
idx:
|
|
192
|
-
val:
|
|
348
|
+
idx: str | None = None
|
|
349
|
+
val: str | None = None
|
|
350
|
+
|
|
193
351
|
if domain_id:
|
|
194
352
|
idx, val = SZ_DOMAIN_ID, domain_id
|
|
195
353
|
elif zone_idx:
|
|
@@ -207,9 +365,9 @@ class MessageDB:
|
|
|
207
365
|
# .I 101 --:------ --:------ 12:126457 2309 006 0107D0-0207D0 # is a CTL
|
|
208
366
|
msg_dict = msg.payload[0]
|
|
209
367
|
|
|
210
|
-
assert (
|
|
211
|
-
|
|
212
|
-
)
|
|
368
|
+
assert (not domain_id and not zone_idx) or (msg_dict.get(idx) == val), (
|
|
369
|
+
f"{msg_dict} < Coding error: key={idx}, val={val}"
|
|
370
|
+
)
|
|
213
371
|
|
|
214
372
|
if key:
|
|
215
373
|
return msg_dict.get(key)
|
|
@@ -219,30 +377,68 @@ class MessageDB:
|
|
|
219
377
|
if k not in ("dhw_idx", SZ_DOMAIN_ID, SZ_ZONE_IDX) and k[:1] != "_"
|
|
220
378
|
}
|
|
221
379
|
|
|
380
|
+
@property
|
|
381
|
+
def traits(self) -> dict:
|
|
382
|
+
"""Return the codes seen by the entity."""
|
|
383
|
+
|
|
384
|
+
codes = {
|
|
385
|
+
k: (CODES_SCHEMA[k][SZ_NAME] if k in CODES_SCHEMA else None)
|
|
386
|
+
for k in sorted(self._msgs)
|
|
387
|
+
if self._msgs[k].src is (self if hasattr(self, "addr") else self.ctl)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {"_sent": list(codes.keys())}
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def _msgs(self) -> dict[Code, Message]:
|
|
394
|
+
if not self._gwy._zzz:
|
|
395
|
+
return self._msgs_
|
|
396
|
+
|
|
397
|
+
sql = """
|
|
398
|
+
SELECT dtm from messages WHERE verb in (' I', 'RP') AND (src = ? OR dst = ?)
|
|
399
|
+
"""
|
|
400
|
+
return { # ? use context instead?
|
|
401
|
+
m.code: m for m in self._gwy._zzz.qry(sql, (self.id[:9], self.id[:9]))
|
|
402
|
+
} # e.g. 01:123456_HW
|
|
403
|
+
|
|
404
|
+
@property
|
|
405
|
+
def _msgz(self) -> dict[Code, dict[VerbT, dict[bool | str | None, Message]]]:
|
|
406
|
+
if not self._gwy._zzz:
|
|
407
|
+
return self._msgz_
|
|
408
|
+
|
|
409
|
+
msgs_1: dict[Code, dict[VerbT, dict[bool | str | None, Message]]] = {}
|
|
410
|
+
msg: Message
|
|
411
|
+
|
|
412
|
+
for msg in self._msgs.values():
|
|
413
|
+
if msg.code not in msgs_1:
|
|
414
|
+
msgs_1[msg.code] = {msg.verb: {msg._pkt._ctx: msg}}
|
|
415
|
+
elif msg.verb not in msgs_1[msg.code]:
|
|
416
|
+
msgs_1[msg.code][msg.verb] = {msg._pkt._ctx: msg}
|
|
417
|
+
else:
|
|
418
|
+
msgs_1[msg.code][msg.verb][msg._pkt._ctx] = msg
|
|
222
419
|
|
|
223
|
-
|
|
420
|
+
return msgs_1
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class _Discovery(_MessageDB):
|
|
224
424
|
MAX_CYCLE_SECS = 30
|
|
225
425
|
MIN_CYCLE_SECS = 3
|
|
226
426
|
|
|
227
|
-
def __init__(self, gwy
|
|
228
|
-
super().__init__(gwy
|
|
427
|
+
def __init__(self, gwy: Gateway) -> None:
|
|
428
|
+
super().__init__(gwy)
|
|
229
429
|
|
|
230
|
-
self._discovery_cmds: dict[
|
|
231
|
-
self._discovery_poller = None
|
|
430
|
+
self._discovery_cmds: dict[HeaderT, dict] = None # type: ignore[assignment]
|
|
431
|
+
self._discovery_poller: asyncio.Task | None = None
|
|
232
432
|
|
|
233
|
-
self._supported_cmds: dict[str,
|
|
234
|
-
self._supported_cmds_ctx: dict[str,
|
|
433
|
+
self._supported_cmds: dict[str, bool | None] = {}
|
|
434
|
+
self._supported_cmds_ctx: dict[str, bool | None] = {}
|
|
235
435
|
|
|
236
|
-
if not gwy.config.disable_discovery
|
|
237
|
-
|
|
238
|
-
): # TODO: here, or in get_xxx()?
|
|
239
|
-
# gwy._loop.call_soon_threadsafe(
|
|
240
|
-
# gwy._loop.call_later, random(0.5, 1.5), self.start_discovery_poller
|
|
241
|
-
# )
|
|
436
|
+
if not gwy.config.disable_discovery:
|
|
437
|
+
# self._start_discovery_poller() # Can't use derived classes dont exist yet
|
|
242
438
|
gwy._loop.call_soon(self._start_discovery_poller)
|
|
243
439
|
|
|
244
440
|
@property # TODO: needs tidy up
|
|
245
|
-
def discovery_cmds(self) -> dict:
|
|
441
|
+
def discovery_cmds(self) -> dict[HeaderT, dict]:
|
|
246
442
|
"""Return the pollable commands."""
|
|
247
443
|
if self._discovery_cmds is None:
|
|
248
444
|
self._discovery_cmds = {}
|
|
@@ -250,29 +446,39 @@ class Discovery(MessageDB):
|
|
|
250
446
|
return self._discovery_cmds
|
|
251
447
|
|
|
252
448
|
@property
|
|
253
|
-
def supported_cmds(self) -> dict:
|
|
449
|
+
def supported_cmds(self) -> dict[Code, Any]:
|
|
254
450
|
"""Return the current list of pollable command codes."""
|
|
255
451
|
return {
|
|
256
452
|
code: (CODES_SCHEMA[code][SZ_NAME] if code in CODES_SCHEMA else None)
|
|
257
453
|
for code in sorted(self._msgz)
|
|
258
|
-
if self._msgz[code].get(RP) and self.
|
|
454
|
+
if self._msgz[code].get(RP) and self._is_not_deprecated_cmd(code)
|
|
259
455
|
}
|
|
260
456
|
|
|
261
457
|
@property
|
|
262
|
-
def supported_cmds_ot(self) -> dict:
|
|
458
|
+
def supported_cmds_ot(self) -> dict[MsgId, Any]:
|
|
263
459
|
"""Return the current list of pollable OT msg_ids."""
|
|
460
|
+
|
|
461
|
+
def _to_data_id(msg_id: MsgId | str) -> OtDataId:
|
|
462
|
+
return int(msg_id, 16) # type: ignore[return-value]
|
|
463
|
+
|
|
464
|
+
def _to_msg_id(data_id: OtDataId | int) -> MsgId:
|
|
465
|
+
return f"{data_id:02X}" # type: ignore[return-value]
|
|
466
|
+
|
|
264
467
|
return {
|
|
265
|
-
msg_id: OPENTHERM_MESSAGES[
|
|
266
|
-
for msg_id in sorted(self._msgz[Code._3220].get(RP, []))
|
|
267
|
-
if
|
|
468
|
+
f"0x{msg_id}": OPENTHERM_MESSAGES[_to_data_id(msg_id)].get("en") # type: ignore[misc]
|
|
469
|
+
for msg_id in sorted(self._msgz[Code._3220].get(RP, [])) # type: ignore[type-var]
|
|
470
|
+
if (
|
|
471
|
+
self._is_not_deprecated_cmd(Code._3220, ctx=msg_id)
|
|
472
|
+
and _to_data_id(msg_id) in OPENTHERM_MESSAGES
|
|
473
|
+
)
|
|
268
474
|
}
|
|
269
475
|
|
|
270
|
-
def
|
|
476
|
+
def _is_not_deprecated_cmd(self, code: Code, ctx: str | None = None) -> bool:
|
|
271
477
|
"""Return True if the code|ctx pair is not deprecated."""
|
|
272
478
|
|
|
273
479
|
if ctx is None:
|
|
274
480
|
supported_cmds = self._supported_cmds
|
|
275
|
-
idx = code
|
|
481
|
+
idx = str(code)
|
|
276
482
|
else:
|
|
277
483
|
supported_cmds = self._supported_cmds_ctx
|
|
278
484
|
idx = f"{code}|{ctx}"
|
|
@@ -283,15 +489,18 @@ class Discovery(MessageDB):
|
|
|
283
489
|
raise NotImplementedError
|
|
284
490
|
|
|
285
491
|
def _add_discovery_cmd(
|
|
286
|
-
self,
|
|
492
|
+
self,
|
|
493
|
+
cmd: Command,
|
|
494
|
+
interval: float,
|
|
495
|
+
*,
|
|
496
|
+
delay: float = 0,
|
|
497
|
+
timeout: float | None = None,
|
|
287
498
|
) -> None:
|
|
288
499
|
"""Schedule a command to run periodically.
|
|
289
500
|
|
|
290
501
|
Both `timeout` and `delay` are in seconds.
|
|
291
502
|
"""
|
|
292
503
|
|
|
293
|
-
cmd._qos.retry_limit = 0 # disable QoS for these: equivalent functionality here
|
|
294
|
-
|
|
295
504
|
if cmd.rx_header is None: # TODO: raise TypeError
|
|
296
505
|
_LOGGER.warning(f"cmd({cmd}): invalid (null) header not added to discovery")
|
|
297
506
|
return
|
|
@@ -302,32 +511,34 @@ class Discovery(MessageDB):
|
|
|
302
511
|
|
|
303
512
|
if delay:
|
|
304
513
|
delay += random.uniform(0.05, 0.45)
|
|
305
|
-
timeout = (
|
|
306
|
-
timeout or (cmd._qos.retry_limit + 1) * cmd._qos.rx_timeout.total_seconds()
|
|
307
|
-
)
|
|
308
514
|
|
|
309
515
|
self.discovery_cmds[cmd.rx_header] = {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
516
|
+
_SZ_COMMAND: cmd,
|
|
517
|
+
_SZ_INTERVAL: td(seconds=max(interval, self.MAX_CYCLE_SECS)),
|
|
518
|
+
_SZ_LAST_PKT: None,
|
|
519
|
+
_SZ_NEXT_DUE: dt.now() + td(seconds=delay),
|
|
520
|
+
_SZ_TIMEOUT: timeout,
|
|
521
|
+
_SZ_FAILURES: 0,
|
|
316
522
|
}
|
|
317
523
|
|
|
318
524
|
def _start_discovery_poller(self) -> None:
|
|
319
|
-
if not
|
|
320
|
-
self._discovery_poller = self._gwy.add_task(self._poll_discovery_cmds)
|
|
321
|
-
self._discovery_poller.set_name(f"{self.id}_discovery_poller")
|
|
322
|
-
pass
|
|
525
|
+
"""Start the discovery poller (if it is not already running)."""
|
|
323
526
|
|
|
324
|
-
async def _stop_discovery_poller(self) -> None:
|
|
325
527
|
if self._discovery_poller and not self._discovery_poller.done():
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
self._discovery_poller = schedule_task(self._poll_discovery_cmds)
|
|
531
|
+
self._discovery_poller.set_name(f"{self.id}_discovery_poller")
|
|
532
|
+
self._gwy.add_task(self._discovery_poller)
|
|
533
|
+
|
|
534
|
+
async def _stop_discovery_poller(self) -> None:
|
|
535
|
+
"""Stop the discovery poller (only if it is running)."""
|
|
536
|
+
if not self._discovery_poller or self._discovery_poller.done():
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
self._discovery_poller.cancel()
|
|
540
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
541
|
+
await self._discovery_poller
|
|
331
542
|
|
|
332
543
|
async def _poll_discovery_cmds(self) -> None:
|
|
333
544
|
"""Send any outstanding commands that are past due.
|
|
@@ -337,14 +548,10 @@ class Discovery(MessageDB):
|
|
|
337
548
|
"""
|
|
338
549
|
|
|
339
550
|
while True:
|
|
340
|
-
if self._gwy.config.disable_discovery: # TODO: remove?
|
|
341
|
-
await asyncio.sleep(self.MIN_CYCLE_SECS)
|
|
342
|
-
continue
|
|
343
|
-
|
|
344
551
|
await self.discover()
|
|
345
552
|
|
|
346
553
|
if self.discovery_cmds:
|
|
347
|
-
next_due = min(t[
|
|
554
|
+
next_due = min(t[_SZ_NEXT_DUE] for t in self.discovery_cmds.values())
|
|
348
555
|
delay = max((next_due - dt.now()).total_seconds(), self.MIN_CYCLE_SECS)
|
|
349
556
|
else:
|
|
350
557
|
delay = self.MAX_CYCLE_SECS
|
|
@@ -352,7 +559,7 @@ class Discovery(MessageDB):
|
|
|
352
559
|
await asyncio.sleep(min(delay, self.MAX_CYCLE_SECS))
|
|
353
560
|
|
|
354
561
|
async def discover(self) -> None:
|
|
355
|
-
def find_latest_msg(hdr:
|
|
562
|
+
def find_latest_msg(hdr: HeaderT, task: dict) -> Message | None:
|
|
356
563
|
"""Return the latest message for a header from any source (not just RPs)."""
|
|
357
564
|
msgs: list[Message] = [
|
|
358
565
|
m
|
|
@@ -361,48 +568,60 @@ class Discovery(MessageDB):
|
|
|
361
568
|
]
|
|
362
569
|
|
|
363
570
|
try:
|
|
364
|
-
if task[
|
|
365
|
-
msgs += [self.tcs._msgz[task[
|
|
571
|
+
if task[_SZ_COMMAND].code in (Code._000A, Code._30C9):
|
|
572
|
+
msgs += [self.tcs._msgz[task[_SZ_COMMAND].code][I_][True]]
|
|
366
573
|
except KeyError:
|
|
367
574
|
pass
|
|
368
575
|
|
|
369
|
-
return (msgs
|
|
576
|
+
return max(msgs) if msgs else None
|
|
370
577
|
|
|
371
|
-
def backoff(hdr:
|
|
578
|
+
def backoff(hdr: HeaderT, failures: int) -> td:
|
|
372
579
|
"""Backoff the interval if there are/were any failures."""
|
|
373
580
|
|
|
581
|
+
if not _DBG_ENABLE_DISCOVERY_BACKOFF: # FIXME: data gaps
|
|
582
|
+
return self.discovery_cmds[hdr][_SZ_INTERVAL] # type: ignore[no-any-return]
|
|
583
|
+
|
|
374
584
|
if failures > 5:
|
|
375
585
|
secs = 60 * 60 * 6
|
|
376
|
-
_LOGGER.
|
|
586
|
+
_LOGGER.error(
|
|
587
|
+
f"No response for {hdr} ({failures}/5): throttling to 1/6h"
|
|
588
|
+
)
|
|
377
589
|
elif failures > 2:
|
|
378
|
-
_LOGGER.
|
|
590
|
+
_LOGGER.warning(
|
|
591
|
+
f"No response for {hdr} ({failures}/5): retrying in {self.MAX_CYCLE_SECS}s"
|
|
592
|
+
)
|
|
379
593
|
secs = self.MAX_CYCLE_SECS
|
|
380
594
|
else:
|
|
381
|
-
|
|
595
|
+
_LOGGER.info(
|
|
596
|
+
f"No response for {hdr} ({failures}/5): retrying in {self.MIN_CYCLE_SECS}s"
|
|
597
|
+
)
|
|
382
598
|
secs = self.MIN_CYCLE_SECS
|
|
383
599
|
|
|
384
600
|
return td(seconds=secs)
|
|
385
601
|
|
|
386
|
-
async def
|
|
387
|
-
|
|
602
|
+
async def send_disc_cmd(
|
|
603
|
+
hdr: HeaderT, task: dict, timeout: float = 15
|
|
604
|
+
) -> Packet | None: # TODO: use constant instead of 15
|
|
605
|
+
"""Send a scheduled command and wait for/return the response."""
|
|
388
606
|
|
|
389
607
|
try:
|
|
390
|
-
|
|
391
|
-
self._gwy.async_send_cmd(task[
|
|
392
|
-
timeout=
|
|
608
|
+
pkt: Packet | None = await asyncio.wait_for(
|
|
609
|
+
self._gwy.async_send_cmd(task[_SZ_COMMAND]),
|
|
610
|
+
timeout=timeout, # self.MAX_CYCLE_SECS?
|
|
393
611
|
)
|
|
394
612
|
|
|
395
|
-
|
|
396
|
-
_LOGGER.debug(f"{hdr}: {exc} (0x5A)")
|
|
613
|
+
# TODO: except: handle no QoS
|
|
397
614
|
|
|
398
|
-
except
|
|
399
|
-
_LOGGER.
|
|
615
|
+
except exc.ProtocolError as err: # InvalidStateError, SendTimeoutError
|
|
616
|
+
_LOGGER.warning(f"{self}: Failed to send discovery cmd: {hdr}: {err}")
|
|
400
617
|
|
|
401
|
-
except
|
|
402
|
-
_LOGGER.
|
|
618
|
+
except TimeoutError as err: # safety valve timeout
|
|
619
|
+
_LOGGER.warning(
|
|
620
|
+
f"{self}: Failed to send discovery cmd: {hdr} within {timeout} secs: {err}"
|
|
621
|
+
)
|
|
403
622
|
|
|
404
623
|
else:
|
|
405
|
-
return
|
|
624
|
+
return pkt
|
|
406
625
|
|
|
407
626
|
return None
|
|
408
627
|
|
|
@@ -410,53 +629,55 @@ class Discovery(MessageDB):
|
|
|
410
629
|
dt_now = dt.now()
|
|
411
630
|
|
|
412
631
|
if (msg := find_latest_msg(hdr, task)) and (
|
|
413
|
-
task[
|
|
632
|
+
task[_SZ_NEXT_DUE] < msg.dtm + task[_SZ_INTERVAL]
|
|
414
633
|
): # if a newer message is available, take it
|
|
415
|
-
task[
|
|
416
|
-
task[
|
|
417
|
-
task[
|
|
634
|
+
task[_SZ_FAILURES] = 0 # only if task[_SZ_LAST_PKT].verb == RP?
|
|
635
|
+
task[_SZ_LAST_PKT] = msg._pkt
|
|
636
|
+
task[_SZ_NEXT_DUE] = msg.dtm + task[_SZ_INTERVAL]
|
|
418
637
|
|
|
419
|
-
if task[
|
|
638
|
+
if task[_SZ_NEXT_DUE] > dt_now:
|
|
420
639
|
continue # if (most recent) last_msg is is not yet due...
|
|
421
640
|
|
|
422
641
|
# since we may do I/O, check if the code|msg_id is deprecated
|
|
423
|
-
task[
|
|
642
|
+
task[_SZ_NEXT_DUE] = dt_now + task[_SZ_INTERVAL] # might undeprecate later
|
|
424
643
|
|
|
425
|
-
if not self.
|
|
644
|
+
if not self._is_not_deprecated_cmd(task[_SZ_COMMAND].code):
|
|
426
645
|
continue
|
|
427
|
-
if not self.
|
|
428
|
-
task[
|
|
646
|
+
if not self._is_not_deprecated_cmd(
|
|
647
|
+
task[_SZ_COMMAND].code, ctx=task[_SZ_COMMAND].payload[4:6]
|
|
429
648
|
): # only for Code._3220
|
|
430
649
|
continue
|
|
431
650
|
|
|
432
651
|
# we'll have to do I/O...
|
|
433
|
-
task[
|
|
652
|
+
task[_SZ_NEXT_DUE] = dt_now + backoff(hdr, task[_SZ_FAILURES]) # JIC
|
|
434
653
|
|
|
435
|
-
if
|
|
436
|
-
task[
|
|
437
|
-
task[
|
|
438
|
-
task[
|
|
654
|
+
if pkt := await send_disc_cmd(hdr, task): # TODO: OK 4 some exceptions
|
|
655
|
+
task[_SZ_FAILURES] = 0 # only if task[_SZ_LAST_PKT].verb == RP?
|
|
656
|
+
task[_SZ_LAST_PKT] = pkt
|
|
657
|
+
task[_SZ_NEXT_DUE] = pkt.dtm + task[_SZ_INTERVAL]
|
|
439
658
|
else:
|
|
440
|
-
task[
|
|
441
|
-
task[
|
|
442
|
-
task[
|
|
659
|
+
task[_SZ_FAILURES] += 1
|
|
660
|
+
task[_SZ_LAST_PKT] = None
|
|
661
|
+
task[_SZ_NEXT_DUE] = dt_now + backoff(hdr, task[_SZ_FAILURES])
|
|
443
662
|
|
|
444
|
-
def
|
|
663
|
+
def _deprecate_code_ctx(
|
|
664
|
+
self, pkt: Packet, ctx: str = None, reset: bool = False
|
|
665
|
+
) -> None:
|
|
445
666
|
"""If a code|ctx is deprecated twice, stop polling for it."""
|
|
446
667
|
|
|
447
|
-
def deprecate(supported_dict, idx):
|
|
668
|
+
def deprecate(supported_dict: dict[str, bool | None], idx: str) -> None:
|
|
448
669
|
if idx not in supported_dict:
|
|
449
670
|
supported_dict[idx] = None
|
|
450
671
|
elif supported_dict[idx] is None:
|
|
451
|
-
_LOGGER.
|
|
672
|
+
_LOGGER.info(
|
|
452
673
|
f"{pkt} < Polling now deprecated for code|ctx={idx}: "
|
|
453
674
|
"it appears to be unsupported"
|
|
454
675
|
)
|
|
455
676
|
supported_dict[idx] = False
|
|
456
677
|
|
|
457
|
-
def reinstate(supported_dict, idx):
|
|
458
|
-
if self.
|
|
459
|
-
_LOGGER.
|
|
678
|
+
def reinstate(supported_dict: dict[str, bool | None], idx: str) -> None:
|
|
679
|
+
if self._is_not_deprecated_cmd(idx, None) is False:
|
|
680
|
+
_LOGGER.info(
|
|
460
681
|
f"{pkt} < Polling now reinstated for code|ctx={idx}: "
|
|
461
682
|
"it now appears supported"
|
|
462
683
|
)
|
|
@@ -464,107 +685,17 @@ class Discovery(MessageDB):
|
|
|
464
685
|
supported_dict.pop(idx)
|
|
465
686
|
|
|
466
687
|
if ctx is None:
|
|
467
|
-
|
|
468
|
-
idx = pkt.code
|
|
688
|
+
supported_cmds = self._supported_cmds
|
|
689
|
+
idx: str = pkt.code
|
|
469
690
|
else:
|
|
470
|
-
|
|
471
|
-
idx = f"{pkt.code}|{ctx}"
|
|
472
|
-
|
|
473
|
-
(reinstate if reset else deprecate)(supported_dict, idx)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
class Entity(Discovery):
|
|
477
|
-
"""The ultimate base class for Devices/Zones/Systems.
|
|
478
|
-
|
|
479
|
-
This class is mainly concerned with:
|
|
480
|
-
- if the entity can Rx packets (e.g. can the HGI send it an RQ)
|
|
481
|
-
"""
|
|
482
|
-
|
|
483
|
-
_SLUG: str = None # type: ignore[assignment]
|
|
484
|
-
|
|
485
|
-
def __init__(self, gwy) -> None:
|
|
486
|
-
super().__init__(gwy)
|
|
487
|
-
|
|
488
|
-
self._gwy = gwy
|
|
489
|
-
self.id: _DeviceIdT = None # type: ignore[assignment]
|
|
490
|
-
|
|
491
|
-
self._qos_tx_count = 0 # the number of pkts Tx'd with no matching Rx
|
|
492
|
-
|
|
493
|
-
def __repr__(self) -> str:
|
|
494
|
-
return f"{self.id} ({self._SLUG})"
|
|
495
|
-
|
|
496
|
-
def deprecate(self, pkt, reset=False) -> None:
|
|
497
|
-
"""If an entity is deprecated enough times, stop sending to it."""
|
|
498
|
-
|
|
499
|
-
if reset:
|
|
500
|
-
self._qos_tx_count = 0
|
|
501
|
-
return
|
|
502
|
-
|
|
503
|
-
self._qos_tx_count += 1
|
|
504
|
-
if self._qos_tx_count == _QOS_TX_LIMIT:
|
|
505
|
-
_LOGGER.warning(
|
|
506
|
-
f"{pkt} < Sending now deprecated for {self} "
|
|
507
|
-
"(consider adjusting device_id filters)"
|
|
508
|
-
) # TODO: take whitelist into account
|
|
509
|
-
|
|
510
|
-
def _handle_msg(self, msg: Message) -> None: # TODO: beware, this is a mess
|
|
511
|
-
"""Store a msg in _msgs[code] (only latest I/RP) and _msgz[code][verb][ctx]."""
|
|
512
|
-
|
|
513
|
-
super()._handle_msg(msg) # store the message in the database
|
|
514
|
-
|
|
515
|
-
if (
|
|
516
|
-
self._gwy.pkt_protocol is None
|
|
517
|
-
or msg.src.id != self._gwy.pkt_protocol._hgi80.get(SZ_DEVICE_ID)
|
|
518
|
-
):
|
|
519
|
-
self.deprecate(msg._pkt, reset=True)
|
|
520
|
-
|
|
521
|
-
def _make_cmd(self, code, dest_id, payload="00", verb=RQ, **kwargs) -> None:
|
|
522
|
-
self._send_cmd(self._gwy.create_cmd(verb, dest_id, code, payload, **kwargs))
|
|
523
|
-
|
|
524
|
-
def _send_cmd(self, cmd, **kwargs) -> None | Future:
|
|
525
|
-
if self._gwy.config.disable_sending:
|
|
526
|
-
_LOGGER.info(f"{cmd} < Sending is disabled")
|
|
527
|
-
return None
|
|
528
|
-
|
|
529
|
-
if self._qos_tx_count > _QOS_TX_LIMIT:
|
|
530
|
-
_LOGGER.info(f"{cmd} < Sending now deprecated for {self}")
|
|
531
|
-
return None
|
|
532
|
-
|
|
533
|
-
cmd._source_entity = self
|
|
534
|
-
# self._msgs.pop(cmd.code, None) # NOTE: Cause of DHW bug
|
|
535
|
-
return self._gwy.send_cmd(cmd) # BUG, should be: await async_send_cmd()
|
|
536
|
-
|
|
537
|
-
@property
|
|
538
|
-
def traits(self) -> dict:
|
|
539
|
-
"""Return the codes seen by the entity."""
|
|
540
|
-
|
|
541
|
-
codes = {
|
|
542
|
-
k: (CODES_SCHEMA[k][SZ_NAME] if k in CODES_SCHEMA else None)
|
|
543
|
-
for k in sorted(self._msgs)
|
|
544
|
-
if self._msgs[k].src is (self if hasattr(self, "addr") else self.ctl)
|
|
545
|
-
}
|
|
691
|
+
supported_cmds = self._supported_cmds_ctx
|
|
692
|
+
idx = f"{pkt.code}|{ctx}"
|
|
546
693
|
|
|
547
|
-
|
|
694
|
+
(reinstate if reset else deprecate)(supported_cmds, idx)
|
|
548
695
|
|
|
549
696
|
|
|
550
|
-
|
|
551
|
-
"""
|
|
552
|
-
|
|
553
|
-
entities = [msg.src]
|
|
554
|
-
if getattr(msg.src, "tcs", None):
|
|
555
|
-
entities.append(msg.src.tcs)
|
|
556
|
-
if msg.src.tcs.dhw:
|
|
557
|
-
entities.append(msg.src.tcs.dhw)
|
|
558
|
-
entities.extend(msg.src.tcs.zones)
|
|
559
|
-
|
|
560
|
-
# remove the msg from all the state DBs
|
|
561
|
-
for obj in entities:
|
|
562
|
-
if msg in obj._msgs.values():
|
|
563
|
-
del obj._msgs[msg.code]
|
|
564
|
-
try:
|
|
565
|
-
del obj._msgz[msg.code][msg.verb][msg._pkt._ctx]
|
|
566
|
-
except KeyError:
|
|
567
|
-
pass
|
|
697
|
+
class Entity(_Discovery):
|
|
698
|
+
"""The base class for Devices/Zones/Systems."""
|
|
568
699
|
|
|
569
700
|
|
|
570
701
|
class Parent(Entity): # A System, Zone, DhwZone or a UfhController
|
|
@@ -580,16 +711,17 @@ class Parent(Entity): # A System, Zone, DhwZone or a UfhController
|
|
|
580
711
|
There is a `set_parent` method, but no `set_child` method.
|
|
581
712
|
"""
|
|
582
713
|
|
|
583
|
-
actuator_by_id: dict[
|
|
584
|
-
actuators: list[
|
|
714
|
+
actuator_by_id: dict[DeviceIdT, BdrSwitch | UfhCircuit | TrvActuator]
|
|
715
|
+
actuators: list[BdrSwitch | UfhCircuit | TrvActuator]
|
|
585
716
|
|
|
586
717
|
circuit_by_id: dict[str, Any]
|
|
587
718
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
719
|
+
_app_cntrl: BdrSwitch | OtbGateway | None
|
|
720
|
+
_dhw_sensor: DhwSensor | None
|
|
721
|
+
_dhw_valve: BdrSwitch | None
|
|
722
|
+
_htg_valve: BdrSwitch | None
|
|
591
723
|
|
|
592
|
-
def __init__(self, *args, child_id: str = None, **kwargs) -> None:
|
|
724
|
+
def __init__(self, *args: Any, child_id: str = None, **kwargs: Any) -> None:
|
|
593
725
|
super().__init__(*args, **kwargs)
|
|
594
726
|
|
|
595
727
|
self._child_id: str = child_id # type: ignore[assignment]
|
|
@@ -598,27 +730,24 @@ class Parent(Entity): # A System, Zone, DhwZone or a UfhController
|
|
|
598
730
|
self.child_by_id: dict[str, Child] = {}
|
|
599
731
|
self.childs: list[Child] = []
|
|
600
732
|
|
|
601
|
-
def _handle_msg(self, msg: Message) -> None:
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
for cct_idx in circuit_idxs:
|
|
609
|
-
self.get_circuit(cct_idx, msg=msg)
|
|
733
|
+
# def _handle_msg(self, msg: Message) -> None:
|
|
734
|
+
# def eavesdrop_ufh_circuits():
|
|
735
|
+
# if msg.code == Code._22C9:
|
|
736
|
+
# # .I --- 02:044446 --:------ 02:044446 22C9 024 00-076C0A28-01 01-06720A28-01 02-06A40A28-01 03-06A40A2-801 # NOTE: fragments
|
|
737
|
+
# # .I --- 02:044446 --:------ 02:044446 22C9 006 04-07D00A28-01 # [{'ufh_idx': '04',...
|
|
738
|
+
# circuit_idxs = [c[SZ_UFH_IDX] for c in msg.payload]
|
|
610
739
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
# raise CorruptStateError
|
|
740
|
+
# for cct_idx in circuit_idxs:
|
|
741
|
+
# self.get_circuit(cct_idx, msg=msg)
|
|
614
742
|
|
|
615
|
-
|
|
743
|
+
# # BUG: this will fail with > 4 circuits, as uses two pkts for this msg
|
|
744
|
+
# # if [c for c in self.child_by_id if c not in circuit_idxs]:
|
|
745
|
+
# # raise CorruptStateError
|
|
616
746
|
|
|
617
|
-
|
|
618
|
-
return
|
|
747
|
+
# super()._handle_msg(msg)
|
|
619
748
|
|
|
620
|
-
|
|
621
|
-
|
|
749
|
+
# if self._gwy.config.enable_eavesdrop:
|
|
750
|
+
# eavesdrop_ufh_circuits()
|
|
622
751
|
|
|
623
752
|
@property
|
|
624
753
|
def zone_idx(self) -> str:
|
|
@@ -629,8 +758,8 @@ class Parent(Entity): # A System, Zone, DhwZone or a UfhController
|
|
|
629
758
|
"""
|
|
630
759
|
return self._child_id
|
|
631
760
|
|
|
632
|
-
@zone_idx.setter
|
|
633
|
-
def zone_idx(self, value) -> None:
|
|
761
|
+
@zone_idx.setter # TODO: should be a private setter
|
|
762
|
+
def zone_idx(self, value: str) -> None:
|
|
634
763
|
"""Set the domain id, after validating it."""
|
|
635
764
|
self._child_id = value
|
|
636
765
|
|
|
@@ -658,14 +787,14 @@ class Parent(Entity): # A System, Zone, DhwZone or a UfhController
|
|
|
658
787
|
|
|
659
788
|
if hasattr(self, "childs") and child not in self.childs: # Any parent
|
|
660
789
|
assert isinstance(
|
|
661
|
-
self,
|
|
790
|
+
self, System | Zone | DhwZone | UfhController
|
|
662
791
|
) # TODO: remove me
|
|
663
792
|
|
|
664
793
|
if is_sensor and child_id == FA: # DHW zone (sensor)
|
|
665
794
|
assert isinstance(self, DhwZone) # TODO: remove me
|
|
666
795
|
assert isinstance(child, DhwSensor)
|
|
667
796
|
if self._dhw_sensor and self._dhw_sensor is not child:
|
|
668
|
-
raise
|
|
797
|
+
raise exc.SystemSchemaInconsistent(
|
|
669
798
|
f"{self} changed dhw_sensor (from {self._dhw_sensor} to {child})"
|
|
670
799
|
)
|
|
671
800
|
self._dhw_sensor = child
|
|
@@ -673,15 +802,14 @@ class Parent(Entity): # A System, Zone, DhwZone or a UfhController
|
|
|
673
802
|
elif is_sensor and hasattr(self, SZ_SENSOR): # HTG zone
|
|
674
803
|
assert isinstance(self, Zone) # TODO: remove me
|
|
675
804
|
if self.sensor and self.sensor is not child:
|
|
676
|
-
raise
|
|
805
|
+
raise exc.SystemSchemaInconsistent(
|
|
677
806
|
f"{self} changed zone sensor (from {self.sensor} to {child})"
|
|
678
807
|
)
|
|
679
808
|
self._sensor = child
|
|
680
809
|
|
|
681
810
|
elif is_sensor:
|
|
682
811
|
raise TypeError(
|
|
683
|
-
f"not a valid combination for {self}: "
|
|
684
|
-
f"{child}|{child_id}|{is_sensor}"
|
|
812
|
+
f"not a valid combination for {self}: {child}|{child_id}|{is_sensor}"
|
|
685
813
|
)
|
|
686
814
|
|
|
687
815
|
elif hasattr(self, SZ_CIRCUITS): # UFH circuit
|
|
@@ -691,18 +819,16 @@ class Parent(Entity): # A System, Zone, DhwZone or a UfhController
|
|
|
691
819
|
|
|
692
820
|
elif hasattr(self, SZ_ACTUATORS): # HTG zone
|
|
693
821
|
assert isinstance(self, Zone) # TODO: remove me
|
|
694
|
-
assert isinstance(child,
|
|
695
|
-
"what" if True else "why"
|
|
696
|
-
)
|
|
822
|
+
assert isinstance(child, BdrSwitch | UfhCircuit | TrvActuator)
|
|
697
823
|
if child not in self.actuators:
|
|
698
824
|
self.actuators.append(child)
|
|
699
|
-
self.actuator_by_id[child.id] = child
|
|
825
|
+
self.actuator_by_id[child.id] = child # type: ignore[assignment,index]
|
|
700
826
|
|
|
701
827
|
elif child_id == F9: # DHW zone (HTG valve)
|
|
702
828
|
assert isinstance(self, DhwZone) # TODO: remove me
|
|
703
829
|
assert isinstance(child, BdrSwitch)
|
|
704
830
|
if self._htg_valve and self._htg_valve is not child:
|
|
705
|
-
raise
|
|
831
|
+
raise exc.SystemSchemaInconsistent(
|
|
706
832
|
f"{self} changed htg_valve (from {self._htg_valve} to {child})"
|
|
707
833
|
)
|
|
708
834
|
self._htg_valve = child
|
|
@@ -711,43 +837,33 @@ class Parent(Entity): # A System, Zone, DhwZone or a UfhController
|
|
|
711
837
|
assert isinstance(self, DhwZone) # TODO: remove me
|
|
712
838
|
assert isinstance(child, BdrSwitch)
|
|
713
839
|
if self._dhw_valve and self._dhw_valve is not child:
|
|
714
|
-
raise
|
|
840
|
+
raise exc.SystemSchemaInconsistent(
|
|
715
841
|
f"{self} changed dhw_valve (from {self._dhw_valve} to {child})"
|
|
716
842
|
)
|
|
717
843
|
self._dhw_valve = child
|
|
718
844
|
|
|
719
845
|
elif child_id == FC: # Appliance Controller
|
|
720
846
|
assert isinstance(self, System) # TODO: remove me
|
|
721
|
-
assert isinstance(child,
|
|
847
|
+
assert isinstance(child, BdrSwitch | OtbGateway)
|
|
722
848
|
if self._app_cntrl and self._app_cntrl is not child:
|
|
723
|
-
raise
|
|
849
|
+
raise exc.SystemSchemaInconsistent(
|
|
724
850
|
f"{self} changed app_cntrl (from {self._app_cntrl} to {child})"
|
|
725
851
|
)
|
|
726
852
|
self._app_cntrl = child
|
|
727
853
|
|
|
728
854
|
elif child_id == FF: # System
|
|
729
855
|
assert isinstance(self, System) # TODO: remove me?
|
|
730
|
-
assert isinstance(child,
|
|
856
|
+
assert isinstance(child, UfhController | OutSensor)
|
|
731
857
|
pass
|
|
732
858
|
|
|
733
859
|
else:
|
|
734
860
|
raise TypeError(
|
|
735
|
-
f"not a valid combination for {self}: "
|
|
736
|
-
f"{child}|{child_id}|{is_sensor}"
|
|
861
|
+
f"not a valid combination for {self}: {child}|{child_id}|{is_sensor}"
|
|
737
862
|
)
|
|
738
863
|
|
|
739
864
|
self.childs.append(child)
|
|
740
865
|
self.child_by_id[child.id] = child
|
|
741
866
|
|
|
742
|
-
if DEV_MODE:
|
|
743
|
-
_LOGGER.warning(
|
|
744
|
-
"parent.set_child(), Parent: %s_%s, %s: %s",
|
|
745
|
-
self.id,
|
|
746
|
-
child_id,
|
|
747
|
-
"Sensor" if is_sensor else "Device",
|
|
748
|
-
child,
|
|
749
|
-
)
|
|
750
|
-
|
|
751
867
|
|
|
752
868
|
class Child(Entity): # A Zone, Device or a UfhCircuit
|
|
753
869
|
"""A Device can be the Child of a Parent (a System, a heating Zone, or a DHW Zone).
|
|
@@ -762,39 +878,43 @@ class Child(Entity): # A Zone, Device or a UfhCircuit
|
|
|
762
878
|
"""
|
|
763
879
|
|
|
764
880
|
def __init__(
|
|
765
|
-
self,
|
|
881
|
+
self,
|
|
882
|
+
*args: Any,
|
|
883
|
+
parent: Parent = None,
|
|
884
|
+
is_sensor: bool | None = None,
|
|
885
|
+
**kwargs: Any,
|
|
766
886
|
) -> None:
|
|
767
887
|
super().__init__(*args, **kwargs)
|
|
768
888
|
|
|
769
|
-
self._parent = parent
|
|
770
|
-
self._is_sensor = is_sensor
|
|
889
|
+
self._parent = parent
|
|
890
|
+
self._is_sensor = is_sensor
|
|
771
891
|
|
|
772
|
-
self._child_id:
|
|
892
|
+
self._child_id: str | None = None # TODO: should be: str?
|
|
773
893
|
|
|
774
894
|
def _handle_msg(self, msg: Message) -> None:
|
|
775
|
-
from .device import Controller, UfhController
|
|
895
|
+
from .device import Controller, Device, UfhController
|
|
776
896
|
|
|
777
|
-
def eavesdrop_parent_zone():
|
|
897
|
+
def eavesdrop_parent_zone() -> None:
|
|
778
898
|
if isinstance(msg.src, UfhController):
|
|
779
899
|
return
|
|
780
900
|
|
|
781
901
|
if SZ_ZONE_IDX not in msg.payload:
|
|
782
902
|
return
|
|
783
903
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
self.
|
|
904
|
+
if isinstance(self, Device): # FIXME: a mess...
|
|
905
|
+
# the following is a mess - may just be better off deprecating it
|
|
906
|
+
if self.type in DEV_TYPE_MAP.HEAT_ZONE_ACTUATORS:
|
|
907
|
+
self.set_parent(msg.dst, child_id=msg.payload[SZ_ZONE_IDX])
|
|
787
908
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
909
|
+
elif self.type in DEV_TYPE_MAP.THM_DEVICES:
|
|
910
|
+
self.set_parent(
|
|
911
|
+
msg.dst, child_id=msg.payload[SZ_ZONE_IDX], is_sensor=True
|
|
912
|
+
)
|
|
792
913
|
|
|
793
914
|
super()._handle_msg(msg)
|
|
794
915
|
|
|
795
916
|
if not self._gwy.config.enable_eavesdrop or (
|
|
796
|
-
msg.src is msg.dst
|
|
797
|
-
or not isinstance(msg.dst, (Controller,)) # UfhController))
|
|
917
|
+
msg.src is msg.dst or not isinstance(msg.dst, Controller) # UfhController))
|
|
798
918
|
):
|
|
799
919
|
return
|
|
800
920
|
|
|
@@ -802,8 +922,8 @@ class Child(Entity): # A Zone, Device or a UfhCircuit
|
|
|
802
922
|
eavesdrop_parent_zone()
|
|
803
923
|
|
|
804
924
|
def _get_parent(
|
|
805
|
-
self, parent: Parent, *, child_id: str = None, is_sensor: bool = None
|
|
806
|
-
) -> tuple[Parent,
|
|
925
|
+
self, parent: Parent, *, child_id: str = None, is_sensor: bool | None = None
|
|
926
|
+
) -> tuple[Parent, str | None]:
|
|
807
927
|
"""Get the device's parent, after validating it."""
|
|
808
928
|
|
|
809
929
|
# NOTE: here to prevent circular references
|
|
@@ -818,21 +938,21 @@ class Child(Entity): # A Zone, Device or a UfhCircuit
|
|
|
818
938
|
UfhCircuit,
|
|
819
939
|
UfhController,
|
|
820
940
|
)
|
|
821
|
-
from .system import DhwZone, System, Zone
|
|
941
|
+
from .system import DhwZone, Evohome, System, Zone
|
|
822
942
|
|
|
823
943
|
if isinstance(self, UfhController):
|
|
824
944
|
child_id = FF
|
|
825
945
|
|
|
826
|
-
if isinstance(parent, Controller): # A controller
|
|
827
|
-
parent
|
|
946
|
+
if isinstance(parent, Controller): # A controller can't be a Parent
|
|
947
|
+
parent = parent.tcs
|
|
828
948
|
|
|
829
|
-
if isinstance(parent,
|
|
949
|
+
if isinstance(parent, Evohome) and child_id:
|
|
830
950
|
if child_id in (F9, FA):
|
|
831
|
-
parent
|
|
951
|
+
parent = parent.get_dhw_zone()
|
|
832
952
|
# elif child_id == FC:
|
|
833
953
|
# pass
|
|
834
954
|
elif int(child_id, 16) < parent._max_zones:
|
|
835
|
-
parent
|
|
955
|
+
parent = parent.get_htg_zone(child_id)
|
|
836
956
|
|
|
837
957
|
elif isinstance(parent, Zone) and not child_id:
|
|
838
958
|
child_id = child_id or parent.idx
|
|
@@ -842,7 +962,7 @@ class Child(Entity): # A Zone, Device or a UfhCircuit
|
|
|
842
962
|
|
|
843
963
|
elif isinstance(parent, UfhController) and not child_id:
|
|
844
964
|
raise TypeError(
|
|
845
|
-
f"{self}:
|
|
965
|
+
f"{self}: can't set child_id to: {child_id} "
|
|
846
966
|
f"(for Circuits, it must be a circuit_idx)"
|
|
847
967
|
)
|
|
848
968
|
|
|
@@ -850,14 +970,14 @@ class Child(Entity): # A Zone, Device or a UfhCircuit
|
|
|
850
970
|
# child_id = parent._child_id # or, for zones: parent.idx
|
|
851
971
|
|
|
852
972
|
if self._parent and self._parent != parent:
|
|
853
|
-
raise
|
|
854
|
-
f"{self}
|
|
973
|
+
raise exc.SystemSchemaInconsistent(
|
|
974
|
+
f"{self} can't change parent "
|
|
855
975
|
f"({self._parent}_{self._child_id} to {parent}_{child_id})"
|
|
856
976
|
)
|
|
857
977
|
|
|
858
978
|
# if self._child_id is not None and self._child_id != child_id:
|
|
859
979
|
# raise CorruptStateError(
|
|
860
|
-
# f"{self}
|
|
980
|
+
# f"{self} can't set domain to: {child_id}, "
|
|
861
981
|
# f"({self._parent}_{self._child_id} to {parent}_{child_id})"
|
|
862
982
|
# )
|
|
863
983
|
|
|
@@ -902,34 +1022,35 @@ class Child(Entity): # A Zone, Device or a UfhCircuit
|
|
|
902
1022
|
if isinstance(parent, Zone):
|
|
903
1023
|
if child_id != parent.idx:
|
|
904
1024
|
raise TypeError(
|
|
905
|
-
f"{self}:
|
|
1025
|
+
f"{self}: can't set child_id to: {child_id} "
|
|
906
1026
|
f"(it must match its parent's zone idx, {parent.idx})"
|
|
907
1027
|
)
|
|
908
1028
|
|
|
909
1029
|
elif isinstance(parent, DhwZone): # usu. FA (HW), could be F9
|
|
910
1030
|
if child_id not in (F9, FA): # may not be known if eavesdrop'd
|
|
911
1031
|
raise TypeError(
|
|
912
|
-
f"{self}:
|
|
1032
|
+
f"{self}: can't set child_id to: {child_id} "
|
|
913
1033
|
f"(for DHW, it must be F9 or FA)"
|
|
914
1034
|
)
|
|
915
1035
|
|
|
916
1036
|
elif isinstance(parent, System): # usu. FC
|
|
917
1037
|
if child_id not in (FC, FF): # was: not in (F9, FA, FC, "HW"):
|
|
918
1038
|
raise TypeError(
|
|
919
|
-
f"{self}:
|
|
1039
|
+
f"{self}: can't set child_id to: {child_id} "
|
|
920
1040
|
f"(for TCS, it must be FC)"
|
|
921
1041
|
)
|
|
922
1042
|
|
|
923
1043
|
elif not isinstance(parent, UfhController): # is like CTL/TCS combined
|
|
924
1044
|
raise TypeError(
|
|
925
|
-
f"{self}:
|
|
1045
|
+
f"{self}: can't set Parent to: {parent} "
|
|
926
1046
|
f"(it must be System, DHW, Zone, or UfhController)"
|
|
927
1047
|
)
|
|
928
1048
|
|
|
929
1049
|
return parent, child_id
|
|
930
1050
|
|
|
1051
|
+
# TODO: should be a private method
|
|
931
1052
|
def set_parent(
|
|
932
|
-
self, parent: Parent, *, child_id: str = None, is_sensor: bool = None
|
|
1053
|
+
self, parent: Parent | None, *, child_id: str = None, is_sensor: bool = None
|
|
933
1054
|
) -> Parent:
|
|
934
1055
|
"""Set the device's parent, after validating it.
|
|
935
1056
|
|
|
@@ -943,7 +1064,10 @@ class Child(Entity): # A Zone, Device or a UfhCircuit
|
|
|
943
1064
|
controller, or an UFH controller.
|
|
944
1065
|
"""
|
|
945
1066
|
|
|
946
|
-
from .device import
|
|
1067
|
+
from .device import ( # NOTE: here to prevent circular references
|
|
1068
|
+
Controller,
|
|
1069
|
+
UfhController,
|
|
1070
|
+
)
|
|
947
1071
|
|
|
948
1072
|
parent, child_id = self._get_parent(
|
|
949
1073
|
parent, child_id=child_id, is_sensor=is_sensor
|
|
@@ -952,8 +1076,8 @@ class Child(Entity): # A Zone, Device or a UfhCircuit
|
|
|
952
1076
|
|
|
953
1077
|
if self.ctl and self.ctl is not ctl:
|
|
954
1078
|
# NOTE: assume a device is bound to only one CTL (usu. best practice)
|
|
955
|
-
raise
|
|
956
|
-
f"{self}
|
|
1079
|
+
raise exc.SystemSchemaInconsistent(
|
|
1080
|
+
f"{self} can't change controller: {self.ctl} to {ctl} "
|
|
957
1081
|
"(or perhaps the device has multiple controllers?"
|
|
958
1082
|
)
|
|
959
1083
|
|
|
@@ -964,16 +1088,9 @@ class Child(Entity): # A Zone, Device or a UfhCircuit
|
|
|
964
1088
|
self._child_id = child_id
|
|
965
1089
|
self._parent = parent
|
|
966
1090
|
|
|
967
|
-
|
|
968
|
-
self.tcs = ctl.tcs
|
|
1091
|
+
assert isinstance(ctl, Controller) # mypy hint
|
|
969
1092
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
"child.set_parent(), Parent: %s_%s, %s: %s",
|
|
973
|
-
parent.id,
|
|
974
|
-
child_id,
|
|
975
|
-
"Sensor" if is_sensor else "Device",
|
|
976
|
-
self,
|
|
977
|
-
)
|
|
1093
|
+
self.ctl: Controller = ctl
|
|
1094
|
+
self.tcs: Evohome = ctl.tcs
|
|
978
1095
|
|
|
979
1096
|
return parent
|