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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +279 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +377 -513
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.2.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.2.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.2.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. {ramses_rf/protocol → ramses_tx}/parsers.py +1266 -1003
  47. ramses_tx/protocol.py +801 -0
  48. ramses_tx/protocol_fsm.py +672 -0
  49. ramses_tx/py.typed +0 -0
  50. {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
  51. {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
  52. ramses_tx/transport.py +1471 -0
  53. ramses_tx/typed_dicts.py +492 -0
  54. ramses_tx/typing.py +181 -0
  55. ramses_tx/version.py +4 -0
  56. ramses_rf/discovery.py +0 -398
  57. ramses_rf/protocol/__init__.py +0 -59
  58. ramses_rf/protocol/backports.py +0 -42
  59. ramses_rf/protocol/command.py +0 -1576
  60. ramses_rf/protocol/const.py +0 -697
  61. ramses_rf/protocol/exceptions.py +0 -111
  62. ramses_rf/protocol/helpers.py +0 -390
  63. ramses_rf/protocol/opentherm.py +0 -1170
  64. ramses_rf/protocol/packet.py +0 -235
  65. ramses_rf/protocol/protocol.py +0 -613
  66. ramses_rf/protocol/transport.py +0 -1011
  67. ramses_rf/protocol/version.py +0 -10
  68. ramses_rf/system/hvac.py +0 -82
  69. ramses_rf-0.22.40.dist-info/METADATA +0 -64
  70. ramses_rf-0.22.40.dist-info/RECORD +0 -42
  71. ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
ramses_rf/entity_base.py CHANGED
@@ -1,127 +1,276 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
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 asyncio import Future
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 typing import TYPE_CHECKING, Any
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 .protocol import Command, Message, Packet
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
- DEV_MODE = __dev_mode__ and False
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
- # USE_JMESPATH = False
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
- For example:
70
- {DEV_TYPE.OTB: OtbGateway, DEV_TYPE.CTL: Controller}
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
- return {
76
- getattr(c[1], attr): c[1]
77
- for c in getmembers(
78
- modules[name],
79
- lambda m: isclass(m) and m.__module__ == name and getattr(m, attr, None), # type: ignore[arg-type, return-value]
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 MessageDB:
179
+ class _MessageDB(_Entity):
85
180
  """Maintain/utilize an entity's state database."""
86
181
 
87
- _gwy: Any # HACK
88
- ctl: Any # HACK
89
- tcs: Any # HACK
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
- def __init__(self, gwy) -> None:
92
- self._msgs: dict[_CodeT, Message] = {} # code, should be code/ctx? ?deprecate
93
- self._msgz: dict[_CodeT, Any] = {} # code/verb/ctx, should be code/ctx/verb?
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 _msgs[code] (only latest I/RP) and _msgz[code][verb][ctx]."""
199
+ """Store a msg in the DBs."""
97
200
 
98
- if msg.verb in (I_, RP):
99
- self._msgs[msg.code] = msg
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.code not in self._msgz:
102
- self._msgz[msg.code] = {msg.verb: {msg._pkt._ctx: msg}}
103
- elif msg.verb not in self._msgz[msg.code]:
104
- self._msgz[msg.code][msg.verb] = {msg._pkt._ctx: msg}
105
- else:
106
- self._msgz[msg.code][msg.verb][msg._pkt._ctx] = msg
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: # a flattened version of _msgz[code][verb][indx]
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 compund ctx (e.g. 0005/000C/0418)
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 _get_msg_by_hdr(self, hdr: _HeaderT) -> None | Message:
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
- code, verb, _, *args = hdr.split("|") # _ is device_id
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: _CodeT, key, idx) -> None | bool:
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(self, code: _CodeT, *args, **kwargs):
150
-
151
- if isinstance(code, (str, tuple)): # a code or a tuple of codes
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, code: _CodeT, verb: _VerbT = None, key=None, **kwargs
158
- ) -> None | dict | list:
159
-
160
- assert (
161
- not isinstance(code, tuple) or verb is None
162
- ), f"Unsupported: using a tuple ({code}) with a verb ({verb})"
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 = max(msgs) if msgs else None
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, msg: None | Message, key=None, zone_idx: str = None, domain_id: str = None
181
- ) -> None | dict | list:
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 deferred call
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: None | str = None
192
- val: None | str = None
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
- not domain_id and not zone_idx or msg_dict.get(idx) == val
212
- ), f"{msg_dict} < Coding error: key={idx}, val={val}"
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
- class Discovery(MessageDB):
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, *args, **kwargs) -> None:
228
- super().__init__(gwy, *args, **kwargs)
427
+ def __init__(self, gwy: Gateway) -> None:
428
+ super().__init__(gwy)
229
429
 
230
- self._discovery_cmds: dict[_HeaderT, dict] = None # type: ignore[assignment]
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, None | bool] = {}
234
- self._supported_cmds_ctx: dict[str, None | bool] = {}
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 and isinstance(
237
- gwy.pkt_protocol, PacketProtocolPort
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.is_pollable_cmd(code)
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[int(msg_id, 16)].get("var")
266
- for msg_id in sorted(self._msgz[Code._3220].get(RP, []))
267
- if self.is_pollable_cmd(Code._3220, ctx=msg_id)
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 is_pollable_cmd(self, code, ctx=None) -> bool:
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, cmd: Command, interval, *, timeout: float = None, delay: float = 0
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
- "command": cmd,
311
- "interval": td(seconds=max(interval, self.MAX_CYCLE_SECS)),
312
- "last_msg": None,
313
- "next_due": dt.now() + td(seconds=delay),
314
- "timeout": timeout,
315
- "failures": 0,
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 self._discovery_poller or self._discovery_poller.done():
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
- self._discovery_poller.cancel()
327
- try:
328
- await self._discovery_poller
329
- except asyncio.CancelledError:
330
- pass
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["next_due"] for t in self.discovery_cmds.values())
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: _HeaderT, task: dict) -> None | Message:
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["command"].code in (Code._000A, Code._30C9):
365
- msgs += [self.tcs._msgz[task["command"].code][I_][True]]
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 and max(msgs)) or None
576
+ return max(msgs) if msgs else None
370
577
 
371
- def backoff(hdr: _HeaderT, failures: int) -> td:
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.warning(f"No response for task({hdr}): throttling to 1/6h")
586
+ _LOGGER.error(
587
+ f"No response for {hdr} ({failures}/5): throttling to 1/6h"
588
+ )
377
589
  elif failures > 2:
378
- _LOGGER.debug(f"No response for task({hdr}): retrying in 30s")
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
- # _LOGGER.info(f"No response for task({hdr}): retrying in 3s")
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 send_disc_task(hdr: _HeaderT, task: dict) -> None | Message:
387
- """Send a scheduled command and wait for/return the reponse."""
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
- result = await asyncio.wait_for(
391
- self._gwy.async_send_cmd(task["command"]),
392
- timeout=60, # self.MAX_CYCLE_SECS?
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
- except asyncio.TimeoutError as exc: # safety valve timeout
396
- _LOGGER.debug(f"{hdr}: {exc} (0x5A)")
613
+ # TODO: except: handle no QoS
397
614
 
398
- except TimeoutError as exc: # TODO: deprecate non-responsive code/device
399
- _LOGGER.debug(f"{hdr}: {exc} (0x5B)")
615
+ except exc.ProtocolError as err: # InvalidStateError, SendTimeoutError
616
+ _LOGGER.warning(f"{self}: Failed to send discovery cmd: {hdr}: {err}")
400
617
 
401
- except Exception as exc:
402
- _LOGGER.error(exc)
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 result
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["next_due"] < msg.dtm + task["interval"]
632
+ task[_SZ_NEXT_DUE] < msg.dtm + task[_SZ_INTERVAL]
414
633
  ): # if a newer message is available, take it
415
- task["failures"] = 0 # only if task["last_msg"].verb == RP?
416
- task["last_msg"] = msg
417
- task["next_due"] = msg.dtm + task["interval"]
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["next_due"] > dt_now:
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["next_due"] = dt_now + task["interval"] # might undeprecate later
642
+ task[_SZ_NEXT_DUE] = dt_now + task[_SZ_INTERVAL] # might undeprecate later
424
643
 
425
- if not self.is_pollable_cmd(task["command"].code):
644
+ if not self._is_not_deprecated_cmd(task[_SZ_COMMAND].code):
426
645
  continue
427
- if not self.is_pollable_cmd(
428
- task["command"].code, ctx=task["command"].payload[4:6]
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["next_due"] = dt_now + backoff(hdr, task["failures"]) # JIC
652
+ task[_SZ_NEXT_DUE] = dt_now + backoff(hdr, task[_SZ_FAILURES]) # JIC
434
653
 
435
- if msg := await send_disc_task(hdr, task): # TODO: OK 4 some exceptions
436
- task["failures"] = 0 # only if task["last_msg"].verb == RP?
437
- task["last_msg"] = msg
438
- task["next_due"] = msg.dtm + task["interval"]
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["failures"] += 1
441
- task["last_msg"] = None
442
- task["next_due"] = dt_now + backoff(hdr, task["failures"])
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 deprecate_cmd(self, pkt: Packet, ctx: str = None, reset: bool = False) -> None:
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.warning(
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.is_pollable_cmd(idx, None) is False:
459
- _LOGGER.warning(
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
- supported_dict = self._supported_cmds
468
- idx = pkt.code
688
+ supported_cmds = self._supported_cmds
689
+ idx: str = pkt.code
469
690
  else:
470
- supported_dict = self._supported_cmds_ctx
471
- idx = f"{pkt.code}|{ctx}" # msg._pkt._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
- return {"_sent": list(codes.keys())}
694
+ (reinstate if reset else deprecate)(supported_cmds, idx)
548
695
 
549
696
 
550
- def _delete_msg(msg: Message) -> None:
551
- """Remove the msg from all state databases."""
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[_DeviceIdT, Entity]
584
- actuators: list[Entity]
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
- _dhw_sensor: Entity
589
- _dhw_valve: Entity
590
- _htg_valve: Entity
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
- def eavesdrop_ufh_circuits():
603
- if msg.code == Code._22C9:
604
- # .I --- 02:044446 --:------ 02:044446 22C9 024 00-076C0A28-01 01-06720A28-01 02-06A40A28-01 03-06A40A2-801 # NOTE: fragments
605
- # .I --- 02:044446 --:------ 02:044446 22C9 006 04-07D00A28-01 # [{'ufh_idx': '04',...
606
- circuit_idxs = [c[SZ_UFH_IDX] for c in msg.payload]
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
- # BUG: this will fail with > 4 circuits, as uses two pkts for this msg
612
- # if [c for c in self.child_by_id if c not in circuit_idxs]:
613
- # raise CorruptStateError
740
+ # for cct_idx in circuit_idxs:
741
+ # self.get_circuit(cct_idx, msg=msg)
614
742
 
615
- super()._handle_msg(msg)
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
- if not self._gwy.config.enable_eavesdrop:
618
- return
747
+ # super()._handle_msg(msg)
619
748
 
620
- # if True:
621
- eavesdrop_ufh_circuits()
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, (System, Zone, DhwZone, UfhController)
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 CorruptStateError(
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 CorruptStateError(
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, (BdrSwitch, UfhCircuit, TrvActuator)), (
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 CorruptStateError(
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 CorruptStateError(
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, (BdrSwitch, OtbGateway))
847
+ assert isinstance(child, BdrSwitch | OtbGateway)
722
848
  if self._app_cntrl and self._app_cntrl is not child:
723
- raise CorruptStateError(
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, (UfhController, OutSensor))
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, *args, parent: Parent = None, is_sensor: bool = None, **kwargs
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 # type: ignore[assignment]
770
- self._is_sensor = is_sensor # type: ignore[assignment]
889
+ self._parent = parent
890
+ self._is_sensor = is_sensor
771
891
 
772
- self._child_id: None | str = None # TODO: should be: str?
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
- # the follwing is a mess - may just be better off deprecating it
785
- if self.type in DEV_TYPE_MAP.HEAT_ZONE_ACTUATORS:
786
- self.set_parent(msg.dst, child_id=msg.payload[SZ_ZONE_IDX])
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
- elif self.type in DEV_TYPE_MAP.THM_DEVICES:
789
- self.set_parent(
790
- msg.dst, child_id=msg.payload[SZ_ZONE_IDX], is_sensor=True
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, None | str]:
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 cant be a Parent
827
- parent: System = parent.tcs # type: ignore[assignment, no-redef]
946
+ if isinstance(parent, Controller): # A controller can't be a Parent
947
+ parent = parent.tcs
828
948
 
829
- if isinstance(parent, System) and child_id:
949
+ if isinstance(parent, Evohome) and child_id:
830
950
  if child_id in (F9, FA):
831
- parent: DhwZone = parent.get_dhw_zone() # type: ignore[no-redef]
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: Zone = parent.get_htg_zone(child_id) # type: ignore[no-redef, attr-defined]
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}: cant set child_id to: {child_id} "
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 CorruptStateError(
854
- f"{self} cant change parent "
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} cant set domain to: {child_id}, "
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}: cant set child_id to: {child_id} "
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}: cant set child_id to: {child_id} "
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}: cant set child_id to: {child_id} "
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}: cant set Parent to: {parent} "
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 UfhController # NOTE: here to prevent circular references
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 CorruptStateError(
956
- f"{self} cant change controller: {self.ctl} to {ctl} "
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
- self.ctl = ctl
968
- self.tcs = ctl.tcs
1091
+ assert isinstance(ctl, Controller) # mypy hint
969
1092
 
970
- if DEV_MODE:
971
- _LOGGER.warning(
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