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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +286 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +378 -514
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.1.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.1.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.1.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. ramses_tx/parsers.py +2957 -0
  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 -1561
  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/parsers.py +0 -2673
  66. ramses_rf/protocol/protocol.py +0 -613
  67. ramses_rf/protocol/transport.py +0 -1011
  68. ramses_rf/protocol/version.py +0 -10
  69. ramses_rf/system/hvac.py +0 -82
  70. ramses_rf-0.22.2.dist-info/METADATA +0 -64
  71. ramses_rf-0.22.2.dist-info/RECORD +0 -42
  72. ramses_rf-0.22.2.dist-info/top_level.txt +0 -1
ramses_rf/system/heat.py CHANGED
@@ -1,19 +1,16 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- #
4
2
  """RAMSES RF - The evohome-compatible system."""
3
+
5
4
  from __future__ import annotations
6
5
 
7
6
  import asyncio
8
7
  import logging
9
- from asyncio import Future
10
- from datetime import datetime as dt
11
- from datetime import timedelta as td
8
+ from datetime import datetime as dt, timedelta as td
12
9
  from threading import Lock
13
10
  from types import SimpleNamespace
14
- from typing import Any, Optional, Tuple, TypeVar
11
+ from typing import TYPE_CHECKING, Any, NoReturn, TypeVar
15
12
 
16
- from ..const import (
13
+ from ramses_rf.const import (
17
14
  SYS_MODE_MAP,
18
15
  SZ_ACTUATORS,
19
16
  SZ_CHANGE_COUNTER,
@@ -30,32 +27,18 @@ from ..const import (
30
27
  SZ_ZONE_MASK,
31
28
  SZ_ZONE_TYPE,
32
29
  SZ_ZONES,
33
- __dev_mode__,
34
30
  )
35
- from ..device import (
31
+ from ramses_rf.device import (
36
32
  BdrSwitch,
37
33
  Controller,
38
34
  Device,
39
- DeviceHeat,
40
35
  OtbGateway,
41
36
  Temperature,
42
37
  UfhController,
43
38
  )
44
- from ..entity_base import Entity, Parent, class_by_attr
45
- from ..helpers import shrink
46
- from ..protocol import (
47
- DEV_ROLE_MAP,
48
- DEV_TYPE_MAP,
49
- ZON_ROLE_MAP,
50
- Address,
51
- Command,
52
- ExpiredCallbackError,
53
- Message,
54
- Priority,
55
- )
56
- from ..protocol.command import FaultLog, _mk_cmd
57
- from ..protocol.const import SZ_PRIORITY, SZ_RETRIES
58
- from ..schemas import (
39
+ from ramses_rf.entity_base import Entity, Parent, class_by_attr
40
+ from ramses_rf.helpers import shrink
41
+ from ramses_rf.schemas import (
59
42
  DEFAULT_MAX_ZONES,
60
43
  SCH_TCS,
61
44
  SCH_TCS_DHW,
@@ -68,33 +51,54 @@ from ..schemas import (
68
51
  SZ_SYSTEM,
69
52
  SZ_UFH_SYSTEM,
70
53
  )
71
- from .zones import DhwZone, Zone
54
+ from ramses_tx import (
55
+ DEV_ROLE_MAP,
56
+ DEV_TYPE_MAP,
57
+ ZON_ROLE_MAP,
58
+ Command,
59
+ DeviceIdT,
60
+ Message,
61
+ Priority,
62
+ )
63
+ from ramses_tx.typed_dicts import PayDictT
64
+
65
+ from .faultlog import FaultLog
66
+ from .zones import zone_factory
67
+
68
+ if TYPE_CHECKING:
69
+ from ramses_tx import Address, Packet
70
+
71
+ from .faultlog import FaultIdxT, FaultLogEntry
72
+ from .zones import DhwZone, Zone
73
+
72
74
 
73
75
  # TODO: refactor packet routing (filter *before* routing)
74
76
 
75
77
 
76
- # skipcq: PY-W2000
77
- from ..protocol import ( # noqa: F401, isort: skip, pylint: disable=unused-import
78
- I_,
79
- RP,
80
- RQ,
81
- W_,
78
+ from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
82
79
  F9,
83
80
  FA,
84
81
  FC,
85
82
  FF,
86
- Code,
87
83
  )
88
84
 
85
+ from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
86
+ I_,
87
+ RP,
88
+ RQ,
89
+ W_,
90
+ Code,
91
+ )
89
92
 
90
- DEV_MODE = __dev_mode__
91
93
 
92
94
  _LOGGER = logging.getLogger(__name__)
93
- if DEV_MODE:
94
- _LOGGER.setLevel(logging.DEBUG)
95
95
 
96
96
 
97
- _SystemT = TypeVar("_SystemT", bound="System")
97
+ _SystemT = TypeVar("_SystemT", bound="Evohome")
98
+
99
+ _StoredHwT = TypeVar("_StoredHwT", bound="StoredHw")
100
+ _LogbookT = TypeVar("_LogbookT", bound="Logbook")
101
+ _MultiZoneT = TypeVar("_MultiZoneT", bound="MultiZone")
98
102
 
99
103
 
100
104
  SYS_KLASS = SimpleNamespace(
@@ -109,7 +113,10 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
109
113
 
110
114
  _SLUG: str = None # type: ignore[assignment]
111
115
 
112
- def __init__(self, ctl) -> None:
116
+ # TODO: check (code so complex, not sure if this is true)
117
+ childs: list[Device] # type: ignore[assignment]
118
+
119
+ def __init__(self, ctl: Controller) -> None:
113
120
  _LOGGER.debug("Creating a TCS for CTL: %s (%s)", ctl.id, self.__class__)
114
121
 
115
122
  if ctl.id in ctl._gwy.system_by_id:
@@ -119,45 +126,18 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
119
126
 
120
127
  super().__init__(ctl._gwy)
121
128
 
122
- self.id: str = ctl.id
123
-
124
- self.ctl = ctl
125
- self.tcs = self
126
- self._child_id = FF # NOTE: domain_id
129
+ # FIXME: ZZZ entities must know their parent device ID and their own idx
130
+ self._z_id = ctl.id # the responsible device is the controller
131
+ self._z_idx = None # ? True (sentinel value to pick up arrays?)
127
132
 
128
- self._app_cntrl: DeviceHeat = None # schema attr
129
- self._heat_demand = None # state attr
133
+ self.id: DeviceIdT = ctl.id
130
134
 
131
- def _update_schema(self, **schema):
132
- """Update a CH/DHW system with new schema attrs.
133
-
134
- Raise an exception if the new schema is not a superset of the existing schema.
135
- """
136
-
137
- schema = shrink(SCH_TCS(schema))
138
-
139
- if schema.get(SZ_SYSTEM) and (
140
- dev_id := schema[SZ_SYSTEM].get(SZ_APPLIANCE_CONTROL)
141
- ):
142
- self._app_cntrl = self._gwy.get_device(dev_id, parent=self, child_id=FC)
143
-
144
- if _schema := (schema.get(SZ_DHW_SYSTEM)):
145
- self.get_dhw_zone(**_schema) # self._dhw = ...
146
-
147
- if _schema := (schema.get(SZ_ZONES)):
148
- [self.get_htg_zone(idx, **s) for idx, s in _schema.items()]
135
+ self.ctl: Controller = ctl
136
+ self.tcs: Evohome = self # type: ignore[assignment]
137
+ self._child_id = FF # NOTE: domain_id
149
138
 
150
- @classmethod
151
- def create_from_schema(cls, ctl: Device, **schema):
152
- """Create a CH/DHW system for a CTL and set its schema attrs.
153
-
154
- The appropriate System class should have been determined by a factory.
155
- Schema attrs include: class (klass) & others.
156
- """
157
-
158
- tcs = cls(ctl)
159
- tcs._update_schema(**schema)
160
- return tcs
139
+ self._app_cntrl: BdrSwitch | OtbGateway | None = None
140
+ self._heat_demand = None
161
141
 
162
142
  def __repr__(self) -> str:
163
143
  return f"{self.ctl.id} ({self._SLUG})"
@@ -170,14 +150,16 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
170
150
  f"00{DEV_ROLE_MAP.HTG}", # hotwater_valve
171
151
  f"01{DEV_ROLE_MAP.HTG}", # heating_valve
172
152
  ):
173
- self._add_discovery_cmd(
174
- _mk_cmd(RQ, Code._000C, payload, self.ctl.id), 60 * 60 * 24, delay=0
175
- )
153
+ cmd = Command.from_attrs(RQ, self.ctl.id, Code._000C, payload)
154
+ self._add_discovery_cmd(cmd, 60 * 60 * 24, delay=0)
176
155
 
177
- self._add_discovery_cmd(Command.get_tpi_params(self.id), 60 * 60 * 6, delay=5)
156
+ cmd = Command.get_tpi_params(self.id)
157
+ self._add_discovery_cmd(cmd, 60 * 60 * 6, delay=5)
178
158
 
179
159
  def _handle_msg(self, msg: Message) -> None:
180
- def eavesdrop_appliance_control(this, *, prev=None) -> None:
160
+ def eavesdrop_appliance_control(
161
+ this: Message, *, prev: Message | None = None
162
+ ) -> None:
181
163
  """Discover the heat relay (10: or 13:) for this system.
182
164
 
183
165
  There's' 3 ways to find a controller's heat relay (in order of reliability):
@@ -186,7 +168,7 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
186
168
  2b. The 3EF0 RQ (no RP) *to a 13:* (3x/60min)
187
169
  3. The 3B00 I/I exchange between a CTL & a 13: (TPI cycle rate, usu. 6x/hr)
188
170
 
189
- Data from the CTL is considered 'authorative'. The 1FC9 RQ/RP exchange
171
+ Data from the CTL is considered 'authoritative'. The 1FC9 RQ/RP exchange
190
172
  to/from a CTL is too rare to be useful.
191
173
  """
192
174
 
@@ -210,84 +192,67 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
210
192
  if (
211
193
  this.code in (Code._22D9, Code._3220) and this.verb == RQ
212
194
  ): # TODO: RPs too?
213
- if this.src is self.ctl and isinstance(this.dst, OtbGateway):
214
- app_cntrl = this.dst
195
+ # dst could be an Address...
196
+ if this.src is self.ctl and isinstance(this.dst, OtbGateway): # type: ignore[unreachable]
197
+ app_cntrl = this.dst # type: ignore[unreachable]
215
198
 
216
199
  elif this.code == Code._3EF0 and this.verb == RQ:
200
+ # dst could be an Address...
217
201
  if this.src is self.ctl and isinstance(
218
- this.dst, (BdrSwitch, OtbGateway)
202
+ this.dst, # type: ignore[unreachable]
203
+ BdrSwitch | OtbGateway,
219
204
  ):
220
- app_cntrl = this.dst
205
+ app_cntrl = this.dst # type: ignore[unreachable]
221
206
 
222
207
  elif this.code == Code._3B00 and this.verb == I_ and prev is not None:
223
- if this.src is self.ctl and isinstance(prev.src, BdrSwitch):
224
- if prev.code == this.code and prev.verb == this.verb:
208
+ if this.src is self.ctl and isinstance(prev.src, BdrSwitch): # type: ignore[unreachable]
209
+ if prev.code == this.code and prev.verb == this.verb: # type: ignore[unreachable]
225
210
  app_cntrl = prev.src
226
211
 
227
212
  if app_cntrl is not None:
228
- app_cntrl.set_parent(self, child_id=FC) # sets self._app_cntrl
213
+ app_cntrl.set_parent(self, child_id=FC) # type: ignore[unreachable]
229
214
 
230
- assert msg.src is self.ctl, f"msg inappropriately routed to {self}"
215
+ # # assert msg.src is self.ctl, f"msg inappropriately routed to {self}"
231
216
 
232
217
  super()._handle_msg(msg)
233
218
 
234
- if (
235
- msg.code == Code._000C
236
- and msg.payload[SZ_ZONE_TYPE] == DEV_ROLE_MAP.APP
237
- and (msg.payload[SZ_DEVICES])
238
- ):
239
- self._gwy.get_device(
240
- msg.payload[SZ_DEVICES][0], parent=self, child_id=FC
241
- ) # sets self._app_cntrl
219
+ if msg.code == Code._000C:
220
+ if msg.payload[SZ_ZONE_TYPE] == DEV_ROLE_MAP.APP and msg.payload.get(
221
+ SZ_DEVICES
222
+ ):
223
+ self._gwy.get_device(
224
+ msg.payload[SZ_DEVICES][0], parent=self, child_id=FC
225
+ ) # sets self._app_cntrl
242
226
  return
243
227
 
244
- if msg.code == Code._0008:
245
- if (domain_id := msg.payload.get(SZ_DOMAIN_ID)) and msg.verb in (I_, RP):
246
- self._relay_demands[domain_id] = msg
247
- if domain_id == F9:
248
- device = self.dhw.heating_valve if self.dhw else None
249
- elif domain_id == "xFA": # TODO, FIXME
250
- device = self.dhw.hotwater_valve if self.dhw else None
251
- elif domain_id == FC:
252
- device = self.appliance_control
253
- else:
254
- device = None
255
-
256
- if False and device is not None: # TODO: FIXME
257
- qos = {SZ_PRIORITY: Priority.LOW, SZ_RETRIES: 2}
258
- for code in (Code._0008, Code._3EF1):
259
- device._make_cmd(code, qos)
260
-
261
- elif msg.code == Code._3150:
228
+ if msg.code == Code._3150:
262
229
  if msg.payload.get(SZ_DOMAIN_ID) == FC and msg.verb in (I_, RP):
263
230
  self._heat_demand = msg.payload
264
231
 
265
232
  if self._gwy.config.enable_eavesdrop and not self.appliance_control:
266
233
  eavesdrop_appliance_control(msg)
267
234
 
268
- def _make_cmd(self, code, payload="00", **kwargs) -> None: # skipcq: PYL-W0221
269
- super()._make_cmd(code, self.ctl.id, payload=payload, **kwargs)
270
-
271
235
  @property
272
- def appliance_control(self) -> Device:
236
+ def appliance_control(self) -> BdrSwitch | OtbGateway | None:
273
237
  """The TCS relay, aka 'appliance control' (BDR or OTB)."""
274
238
  if self._app_cntrl:
275
239
  return self._app_cntrl
276
240
  app_cntrl = [d for d in self.childs if d._child_id == FC]
277
- return app_cntrl[0] if len(app_cntrl) == 1 else None # HACK for 10:
241
+ return app_cntrl[0] if len(app_cntrl) == 1 else None # type: ignore[return-value]
278
242
 
279
243
  @property
280
- def tpi_params(self) -> Optional[dict]: # 1100
281
- return self._msg_value(Code._1100)
244
+ def tpi_params(self) -> PayDictT._1100 | None: # 1100
245
+ return self._msg_value(Code._1100) # type: ignore[return-value]
282
246
 
283
247
  @property
284
- def heat_demand(self) -> None | float: # 3150/FC
285
- return self._msg_value(Code._3150, domain_id=FC, key=SZ_HEAT_DEMAND)
248
+ def heat_demand(self) -> float | None: # 3150/FC
249
+ return self._msg_value(Code._3150, domain_id=FC, key=SZ_HEAT_DEMAND) # type: ignore[return-value]
286
250
 
287
251
  @property
288
- def is_calling_for_heat(self) -> None | bool:
289
- """Return True is the system is currently calling for heat."""
290
- return self._app_cntrl and self._app_cntrl.actuator_state
252
+ def is_calling_for_heat(self) -> NoReturn:
253
+ raise NotImplementedError(
254
+ f"{self}: is_calling_for_heat attr is deprecated, use bool(heat_demand)"
255
+ )
291
256
 
292
257
  @property
293
258
  def schema(self) -> dict[str, Any]:
@@ -338,6 +303,12 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
338
303
  if zones:
339
304
  result[SZ_ZONES] = zones
340
305
 
306
+ result |= {
307
+ k: v
308
+ for k, v in schema.items()
309
+ if k in ("orphans",) and v # add UFH?
310
+ }
311
+
341
312
  return result # TODO: check against vol schema
342
313
 
343
314
  @property
@@ -355,30 +326,31 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
355
326
  status: dict[str, Any] = {SZ_SYSTEM: {}}
356
327
  status[SZ_SYSTEM]["heat_demand"] = self.heat_demand
357
328
 
358
- status[SZ_DEVICES] = {d.id: d.status for d in sorted(self.childs)}
329
+ status[SZ_DEVICES] = {
330
+ d.id: d.status for d in sorted(self.childs, key=lambda x: x.id)
331
+ }
359
332
 
360
333
  return status
361
334
 
362
335
 
363
336
  class MultiZone(SystemBase): # 0005 (+/- 000C?)
364
- def __init__(self, *args, **kwargs) -> None:
337
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
365
338
  super().__init__(*args, **kwargs)
366
339
 
367
- self.zones = []
368
- self.zone_by_idx = {}
369
- self._max_zones = getattr(self._gwy.config, SZ_MAX_ZONES, DEFAULT_MAX_ZONES)
340
+ self.zones: list[Zone] = []
341
+ self.zone_by_idx: dict[str, Zone] = {} # should not include HW
342
+ self._max_zones: int = getattr(
343
+ self._gwy.config, SZ_MAX_ZONES, DEFAULT_MAX_ZONES
344
+ )
370
345
 
371
- self._prev_30c9 = None # used to eavesdrop zone sensors
346
+ self._prev_30c9: Message | None = None # used to eavesdrop zone sensors
372
347
 
373
348
  def _setup_discovery_cmds(self) -> None:
374
349
  super()._setup_discovery_cmds()
375
350
 
376
351
  for zone_type in list(ZON_ROLE_MAP.HEAT_ZONES) + [ZON_ROLE_MAP.SEN]:
377
- self._add_discovery_cmd(
378
- _mk_cmd(RQ, Code._0005, f"00{zone_type}", self.id),
379
- 60 * 60 * 24,
380
- delay=0,
381
- )
352
+ cmd = Command.from_attrs(RQ, self.id, Code._0005, f"00{zone_type}")
353
+ self._add_discovery_cmd(cmd, 60 * 60 * 24, delay=0)
382
354
 
383
355
  def _handle_msg(self, msg: Message) -> None:
384
356
  """Process any relevant message.
@@ -386,7 +358,7 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
386
358
  If `zone_idx` in payload, route any messages to the corresponding zone.
387
359
  """
388
360
 
389
- def eavesdrop_zones(this, *, prev=None) -> None:
361
+ def eavesdrop_zones(this: Message, *, prev: Message | None = None) -> None:
390
362
  [
391
363
  self.get_htg_zone(v)
392
364
  for d in msg.payload
@@ -394,29 +366,31 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
394
366
  if k == SZ_ZONE_IDX
395
367
  ]
396
368
 
397
- def eavesdrop_zone_sensors(this, *, prev=None) -> None:
369
+ def eavesdrop_zone_sensors(
370
+ this: Message, *, prev: Message | None = None
371
+ ) -> None:
398
372
  """Determine each zone's sensor by matching zone/sensor temperatures."""
399
373
 
400
- def _testable_zones(changed_zones) -> dict:
374
+ def _testable_zones(changed_zones: dict[str, float]) -> dict[float, str]:
401
375
  return {
402
- t: z
403
- for z, t in changed_zones.items()
404
- if self.zone_by_idx[z].sensor is None
405
- and t not in [t2 for z2, t2 in changed_zones.items() if z2 != z]
376
+ t1: i1
377
+ for i1, t1 in changed_zones.items()
378
+ if self.zone_by_idx[i1].sensor is None
379
+ and t1 not in [t2 for i2, t2 in changed_zones.items() if i2 != i1]
406
380
  }
407
381
 
408
382
  self._prev_30c9, prev = this, self._prev_30c9
409
383
  if prev is None:
410
- return
384
+ return # type: ignore[unreachable]
411
385
 
412
386
  # TODO: use msgz/I, not RP
413
- secs = self._msg_value(Code._1F09, key="remaining_seconds")
387
+ secs: int = self._msg_value(Code._1F09, key="remaining_seconds") # type: ignore[assignment]
414
388
  if secs is None or this.dtm > prev.dtm + td(seconds=secs + 5):
415
389
  return # can only compare against 30C9 pkt from the last cycle
416
390
 
417
391
  # _LOGGER.warning("System state (before): %s", self.schema)
418
392
 
419
- changed_zones = {
393
+ changed_zones: dict[str, float] = {
420
394
  z[SZ_ZONE_IDX]: z[SZ_TEMPERATURE]
421
395
  for z in this.payload
422
396
  if z not in prev.payload and z[SZ_TEMPERATURE] is not None
@@ -469,12 +443,11 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
469
443
 
470
444
  # _LOGGER.warning("System state (finally): %s", self.schema)
471
445
 
472
- def handle_msg_by_zone_idx(zone_idx: str, msg):
446
+ def handle_msg_by_zone_idx(zone_idx: str, msg: Message) -> None:
473
447
  if zone := self.zone_by_idx.get(zone_idx):
474
448
  zone._handle_msg(msg)
475
449
  # elif self._gwy.config.enable_eavesdrop:
476
450
  # self.get_htg_zone(zone_idx)._handle_msg(msg)
477
- pass
478
451
 
479
452
  super()._handle_msg(msg)
480
453
 
@@ -532,7 +505,8 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
532
505
  elif isinstance(msg.payload, list) and len(msg.payload):
533
506
  # TODO: elif msg.payload.get(SZ_DOMAIN_ID) == FA: # DHW
534
507
  if isinstance(msg.payload[0], dict): # e.g. 1FC9 is a list of lists:
535
- [handle_msg_by_zone_idx(z.get(SZ_ZONE_IDX), msg) for z in msg.payload]
508
+ for z_dict in msg.payload:
509
+ handle_msg_by_zone_idx(z_dict.get(SZ_ZONE_IDX), msg)
536
510
 
537
511
  # If some zones still don't have a sensor, maybe eavesdrop?
538
512
  if ( # TODO: edge case: 1 zone with CTL as SEN
@@ -543,7 +517,10 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
543
517
  ):
544
518
  eavesdrop_zone_sensors(msg)
545
519
 
546
- def get_htg_zone(self, zone_idx, *, msg=None, **schema) -> Zone:
520
+ # TODO: should be a private method
521
+ def get_htg_zone(
522
+ self, zone_idx: str, *, msg: Message | None = None, **schema: Any
523
+ ) -> Zone:
547
524
  """Return a heating zone, create it if required.
548
525
 
549
526
  First, use the schema to create/update it, then pass it any msg to handle.
@@ -552,13 +529,11 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
552
529
  If a zone is created, attach it to this TCS.
553
530
  """
554
531
 
555
- from .zones import zone_factory
556
-
557
532
  schema = shrink(SCH_TCS_ZONES_ZON(schema))
558
533
 
559
- zon = self.zone_by_idx.get(zone_idx)
560
- if not zon:
561
- zon = zone_factory(self, zone_idx, msg=msg, **schema)
534
+ zon: Zone = self.zone_by_idx.get(zone_idx) # type: ignore[assignment]
535
+ if zon is None:
536
+ zon = zone_factory(self, zone_idx, msg=msg, **schema) # type: ignore[unreachable]
562
537
  self.zone_by_idx[zon.idx] = zon
563
538
  self.zones.append(zon)
564
539
 
@@ -592,31 +567,36 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
592
567
 
593
568
 
594
569
  class ScheduleSync(SystemBase): # 0006 (+/- 0404?)
595
- def __init__(self, *args, **kwargs) -> None:
570
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
596
571
  super().__init__(*args, **kwargs)
597
572
 
598
573
  self._msg_0006: Message = None # type: ignore[assignment]
599
574
 
600
- self.zone_lock = Lock() # used to stop concurrent get_schedules
601
- self.zone_lock_idx = None
575
+ # used to stop concurrent get_schedules
576
+ self.zone_lock = Lock() # FIXME: threading lock, or asyncio lock?
577
+ self.zone_lock_idx: str | None = None
602
578
 
603
579
  def _setup_discovery_cmds(self) -> None:
604
580
  super()._setup_discovery_cmds()
605
581
 
606
- self._add_discovery_cmd(_mk_cmd(RQ, Code._0006, "00", self.id), 60 * 5, delay=5)
582
+ cmd = Command.get_schedule_version(self.id)
583
+ self._add_discovery_cmd(cmd, 60 * 5, delay=5)
607
584
 
608
585
  def _handle_msg(self, msg: Message) -> None: # NOTE: active
609
- """Periodically retreive the latest global change counter."""
586
+ """Periodically retrieve the latest global change counter."""
587
+
610
588
  super()._handle_msg(msg)
611
589
 
612
590
  if msg.code == Code._0006:
613
591
  self._msg_0006 = msg
614
592
 
615
- async def _schedule_version(self, *, force_io: bool = False) -> Tuple[int, bool]:
593
+ async def _schedule_version(self, *, force_io: bool = False) -> tuple[int, bool]:
616
594
  """Return the global schedule version number, and an indication if I/O was done.
617
595
 
618
596
  If `force_io`, then RQ the latest change counter from the TCS rather than
619
597
  rely upon a recent (cached) value.
598
+
599
+ Cached values are only used if less than 3 minutes old.
620
600
  """
621
601
 
622
602
  # RQ --- 30:185469 01:037519 --:------ 0006 001 00
@@ -632,31 +612,26 @@ class ScheduleSync(SystemBase): # 0006 (+/- 0404?)
632
612
  False,
633
613
  ) # global_ver, did_io
634
614
 
635
- self._msg_0006 = await self._gwy.async_send_cmd(
636
- Command.get_schedule_version(self.ctl.id)
615
+ cmd = Command.get_schedule_version(self.ctl.id)
616
+ pkt = await self._gwy.async_send_cmd(
617
+ cmd, wait_for_reply=True, priority=Priority.HIGH
637
618
  )
638
-
639
- assert isinstance( # TODO: remove me
640
- self._msg_0006, Message
641
- ), f"_schedule_version(): {self._msg_0006} (is not a message)"
619
+ if pkt:
620
+ self._msg_0006 = Message(pkt)
642
621
 
643
622
  return self._msg_0006.payload[SZ_CHANGE_COUNTER], True # global_ver, did_io
644
623
 
645
624
  def _refresh_schedules(self) -> None:
646
- if self._gwy.config.disable_sending:
647
- raise RuntimeError("Sending is disabled")
625
+ zone: Zone
648
626
 
649
- # schedules based upon 'active' (not most recent) 0006 pkt
650
627
  for zone in getattr(self, SZ_ZONES, []):
651
628
  self._gwy._loop.create_task(zone.get_schedule(force_io=True))
652
- if dhw := getattr(self, "dhw", None):
653
- self._gwy._loop.create_task(dhw.get_schedule(force_io=True))
654
-
655
- async def _obtain_lock(self, zone_idx) -> None:
629
+ if isinstance(self, StoredHw) and self.dhw:
630
+ self._gwy._loop.create_task(self.dhw.get_schedule(force_io=True))
656
631
 
632
+ async def _obtain_lock(self, zone_idx: str) -> None:
657
633
  timeout_dtm = dt.now() + td(minutes=3)
658
634
  while dt.now() < timeout_dtm:
659
-
660
635
  self.zone_lock.acquire()
661
636
  if self.zone_lock_idx is None:
662
637
  self.zone_lock_idx = zone_idx
@@ -672,14 +647,13 @@ class ScheduleSync(SystemBase): # 0006 (+/- 0404?)
672
647
  )
673
648
 
674
649
  def _release_lock(self) -> None:
675
-
676
650
  self.zone_lock.acquire()
677
651
  self.zone_lock_idx = None
678
652
  self.zone_lock.release()
679
653
 
680
654
  @property
681
- def schedule_version(self) -> None | int:
682
- return self._msg_value(Code._0006, key=SZ_CHANGE_COUNTER)
655
+ def schedule_version(self) -> int | None:
656
+ return self._msg_value(Code._0006, key=SZ_CHANGE_COUNTER) # type: ignore[return-value]
683
657
 
684
658
  @property
685
659
  def status(self) -> dict[str, Any]:
@@ -693,13 +667,12 @@ class Language(SystemBase): # 0100
693
667
  def _setup_discovery_cmds(self) -> None:
694
668
  super()._setup_discovery_cmds()
695
669
 
696
- self._add_discovery_cmd(
697
- Command.get_system_language(self.id), 60 * 60 * 24, delay=60 * 15
698
- )
670
+ cmd = Command.get_system_language(self.id)
671
+ self._add_discovery_cmd(cmd, 60 * 60 * 24, delay=60 * 15)
699
672
 
700
673
  @property
701
- def language(self) -> None | str:
702
- return self._msg_value(Code._0100, key=SZ_LANGUAGE)
674
+ def language(self) -> str | None:
675
+ return self._msg_value(Code._0100, key=SZ_LANGUAGE) # type: ignore[return-value]
703
676
 
704
677
  @property
705
678
  def params(self) -> dict[str, Any]:
@@ -709,7 +682,7 @@ class Language(SystemBase): # 0100
709
682
 
710
683
 
711
684
  class Logbook(SystemBase): # 0418
712
- def __init__(self, *args, **kwargs) -> None:
685
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
713
686
  super().__init__(*args, **kwargs)
714
687
 
715
688
  self._prev_event: Message = None # type: ignore[assignment]
@@ -718,93 +691,63 @@ class Logbook(SystemBase): # 0418
718
691
  self._prev_fault: Message = None # type: ignore[assignment]
719
692
  self._this_fault: Message = None # type: ignore[assignment]
720
693
 
721
- # FaultLog(self.ctl)
722
- self._faultlog: FaultLog = None # type: ignore[assignment]
723
- self._faultlog_outdated: bool = True
694
+ self._faultlog: FaultLog = FaultLog(self)
724
695
 
725
696
  def _setup_discovery_cmds(self) -> None:
726
697
  super()._setup_discovery_cmds()
727
698
 
728
- self._add_discovery_cmd(
729
- Command.get_system_log_entry(self.ctl.id, 0), 60 * 5, delay=5
730
- )
731
- # self._gwy.add_task(self._loop.create_task(self.get_faultlog()))
699
+ cmd = Command.get_system_log_entry(self.id, 0)
700
+ self._add_discovery_cmd(cmd, 60 * 5, delay=5)
701
+ # self._gwy.add_task(
702
+ # self._gwy._loop.create_task(self.get_faultlog())
703
+ # )
732
704
 
733
705
  def _handle_msg(self, msg: Message) -> None: # NOTE: active
734
706
  super()._handle_msg(msg)
735
707
 
736
- if msg.code != Code._0418:
737
- return
738
-
739
- if msg.payload["log_idx"] == "00":
740
- if not self._this_event or (
741
- msg.payload["log_entry"] != self._this_event.payload["log_entry"]
742
- ):
743
- self._this_event, self._prev_event = msg, self._this_event
744
- # TODO: self._faultlog_outdated = msg.verb == I_ or self._prev_event and (
745
- # msg.payload["log_entry"] != self._prev_event.payload["log_entry"]
746
- # )
747
-
748
- if msg.payload["log_entry"] and msg.payload["log_entry"][1] == "fault":
749
- if not self._this_fault or (
750
- msg.payload["log_entry"] != self._this_fault.payload["log_entry"]
751
- ):
752
- self._this_fault, self._prev_fault = msg, self._this_fault
753
-
754
- # if msg.payload["log_entry"][1] == "restore" and not self._this_fault:
755
- # self._send_cmd(Command.get_system_log_entry(self.ctl.id, 1))
756
-
757
- # TODO: if self._faultlog_outdated:
758
- # if not self._gwy.config.disable_sending:
759
- # self._loop.create_task(self.get_faultlog(force_io=True))
708
+ if msg.code == Code._0418: # and msg.verb in (I_, RP):
709
+ self._faultlog.handle_msg(msg)
760
710
 
761
711
  async def get_faultlog(
762
- self, *, start=None, limit=None, force_io=None
763
- ) -> None | dict:
764
- if self._gwy.config.disable_sending:
765
- raise RuntimeError("Sending is disabled")
766
-
767
- try:
768
- return await self._faultlog.get_faultlog(
769
- start=start, limit=limit, force_io=force_io
770
- )
771
- except (ExpiredCallbackError, RuntimeError):
772
- return None
773
-
774
- # @property
775
- # def faultlog_outdated(self) -> bool:
776
- # return self._this_event.verb == I_ or self._prev_event and (
777
- # self._this_event.payload != self._prev_event.payload
778
- # )
779
-
780
- # @property
781
- # def faultlog(self) -> dict:
782
- # return self._faultlog.faultlog
712
+ self,
713
+ /,
714
+ *,
715
+ start: int = 0,
716
+ limit: int | None = None,
717
+ force_refresh: bool = False,
718
+ ) -> dict[FaultIdxT, FaultLogEntry] | None:
719
+ return await self._faultlog.get_faultlog(
720
+ start=start, limit=limit, force_refresh=force_refresh
721
+ )
783
722
 
784
723
  @property
785
- def active_fault(self) -> Optional[tuple]:
786
- """Return the most recently logged event, but only if it is a fault."""
787
- if self.latest_fault != self.latest_event:
724
+ def active_faults(self) -> tuple[str, ...] | None:
725
+ """Return the most recently logged faults that are not restored."""
726
+ if self._faultlog.active_faults is None:
788
727
  return None
789
- return self.latest_fault
728
+ return tuple(str(f) for f in self._faultlog.active_faults)
790
729
 
791
730
  @property
792
- def latest_event(self) -> Optional[tuple]:
731
+ def latest_event(self) -> str | None:
793
732
  """Return the most recently logged event (fault or restore), if any."""
794
- return self._this_event and self._this_event.payload["log_entry"]
733
+ if not self._faultlog.latest_event:
734
+ return None
735
+ return str(self._faultlog.latest_event)
795
736
 
796
737
  @property
797
- def latest_fault(self) -> Optional[tuple]:
738
+ def latest_fault(self) -> str | None:
798
739
  """Return the most recently logged fault, if any."""
799
- return self._this_fault and self._this_fault.payload["log_entry"]
740
+ if not self._faultlog.latest_fault:
741
+ return None
742
+ return str(self._faultlog.latest_fault)
800
743
 
801
744
  @property
802
745
  def status(self) -> dict[str, Any]:
803
746
  return {
804
747
  **super().status,
748
+ "active_faults": self.active_faults,
805
749
  "latest_event": self.latest_event,
806
- "active_fault": self.active_fault,
807
- # "faultlog": self.faultlog,
750
+ "latest_fault": self.latest_fault,
808
751
  }
809
752
 
810
753
 
@@ -813,7 +756,7 @@ class StoredHw(SystemBase): # 10A0, 1260, 1F41
813
756
  MAX_SETPOINT = 85.0
814
757
  DEFAULT_SETPOINT = 50.0
815
758
 
816
- def __init__(self, *args, **kwargs) -> None:
759
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
817
760
  super().__init__(*args, **kwargs)
818
761
  self._dhw: DhwZone = None # type: ignore[assignment]
819
762
 
@@ -825,12 +768,10 @@ class StoredHw(SystemBase): # 10A0, 1260, 1F41
825
768
  # f"00{DEV_ROLE_MAP.HTG}", # hotwater_valve
826
769
  # f"01{DEV_ROLE_MAP.HTG}", # heating_valve
827
770
  ):
828
- self._add_discovery_cmd(
829
- _mk_cmd(RQ, Code._000C, payload, self.id), 60 * 60 * 24, delay=0
830
- )
771
+ cmd = Command.from_attrs(RQ, self.id, Code._000C, payload)
772
+ self._add_discovery_cmd(cmd, 60 * 60 * 24, delay=0)
831
773
 
832
774
  def _handle_msg(self, msg: Message) -> None:
833
-
834
775
  super()._handle_msg(msg)
835
776
 
836
777
  if (
@@ -858,7 +799,8 @@ class StoredHw(SystemBase): # 10A0, 1260, 1F41
858
799
  # Route all messages to their zones, incl. 000C, 0404, others
859
800
  self.get_dhw_zone(msg=msg)
860
801
 
861
- def get_dhw_zone(self, *, msg=None, **schema) -> DhwZone:
802
+ # TODO: should be a private method
803
+ def get_dhw_zone(self, *, msg: Message | None = None, **schema: Any) -> DhwZone:
862
804
  """Return a DHW zone, create it if required.
863
805
 
864
806
  First, use the schema to create/update it, then pass it any msg to handle.
@@ -867,12 +809,10 @@ class StoredHw(SystemBase): # 10A0, 1260, 1F41
867
809
  If a DHW zone is created, attach it to this TCS.
868
810
  """
869
811
 
870
- from .zones import zone_factory
871
-
872
812
  schema = shrink(SCH_TCS_DHW(schema))
873
813
 
874
814
  if not self._dhw:
875
- self._dhw = zone_factory(self, "HW", msg=msg, **schema)
815
+ self._dhw = zone_factory(self, "HW", msg=msg, **schema) # type: ignore[assignment]
876
816
 
877
817
  elif schema:
878
818
  self._dhw._update_schema(**schema)
@@ -882,19 +822,19 @@ class StoredHw(SystemBase): # 10A0, 1260, 1F41
882
822
  return self._dhw
883
823
 
884
824
  @property
885
- def dhw(self) -> DhwZone:
825
+ def dhw(self) -> DhwZone | None:
886
826
  return self._dhw
887
827
 
888
828
  @property
889
- def dhw_sensor(self) -> None | Device:
829
+ def dhw_sensor(self) -> Device | None:
890
830
  return self._dhw.sensor if self._dhw else None
891
831
 
892
832
  @property
893
- def hotwater_valve(self) -> None | Device:
833
+ def hotwater_valve(self) -> Device | None:
894
834
  return self._dhw.hotwater_valve if self._dhw else None
895
835
 
896
836
  @property
897
- def heating_valve(self) -> None | Device:
837
+ def heating_valve(self) -> Device | None:
898
838
  return self._dhw.heating_valve if self._dhw else None
899
839
 
900
840
  @property
@@ -923,23 +863,31 @@ class SysMode(SystemBase): # 2E04
923
863
  def _setup_discovery_cmds(self) -> None:
924
864
  super()._setup_discovery_cmds()
925
865
 
926
- self._add_discovery_cmd(Command.get_system_mode(self.id), 60 * 5, delay=5)
866
+ cmd = Command.get_system_mode(self.id)
867
+ self._add_discovery_cmd(cmd, 60 * 5, delay=5)
927
868
 
928
869
  @property
929
- def system_mode(self) -> Optional[dict]: # 2E04
930
- return self._msg_value(Code._2E04)
870
+ def system_mode(self) -> dict[str, Any] | None: # 2E04
871
+ return self._msg_value(Code._2E04) # type: ignore[return-value]
931
872
 
932
- def set_mode(self, system_mode, *, until=None) -> Future:
933
- """Set a system mode for a specified duration, or indefinitely."""
934
- return self._send_cmd(
935
- Command.set_system_mode(self.id, system_mode, until=until)
936
- )
873
+ def set_mode(
874
+ self, system_mode: int | str | None, *, until: dt | str | None = None
875
+ ) -> asyncio.Task[Packet]:
876
+ """
877
+ Set a system mode for a specified duration, or indefinitely.
878
+
879
+ :param system_mode: 2-digit item from SYS_MODE_MAP, positional
880
+ :param until: optional: end of set period
881
+ :return:
882
+ """
883
+ cmd = Command.set_system_mode(self.id, system_mode, until=until)
884
+ return self._gwy.send_cmd(cmd, priority=Priority.HIGH, wait_for_reply=True)
937
885
 
938
- def set_auto(self) -> Future:
886
+ def set_auto(self) -> asyncio.Task[Packet]:
939
887
  """Revert system to Auto, set non-PermanentOverride zones to FollowSchedule."""
940
888
  return self.set_mode(SYS_MODE_MAP.AUTO)
941
889
 
942
- def reset_mode(self) -> Future:
890
+ def reset_mode(self) -> asyncio.Task[Packet]:
943
891
  """Revert system to Auto, force *all* zones to FollowSchedule."""
944
892
  return self.set_mode(SYS_MODE_MAP.AUTO_WITH_RESET)
945
893
 
@@ -954,26 +902,33 @@ class Datetime(SystemBase): # 313F
954
902
  def _setup_discovery_cmds(self) -> None:
955
903
  super()._setup_discovery_cmds()
956
904
 
957
- self._add_discovery_cmd(Command.get_system_time(self.id), 60 * 60, delay=0)
905
+ cmd = Command.get_system_time(self.id)
906
+ self._add_discovery_cmd(cmd, 60 * 60, delay=0)
958
907
 
959
908
  def _handle_msg(self, msg: Message) -> None:
960
909
  super()._handle_msg(msg)
961
910
 
962
- if msg.code == Code._313F and msg.verb in (I_, RP) and self._gwy.pkt_transport:
911
+ # FIXME: refactoring protocol stack
912
+ if msg.code == Code._313F and msg.verb in (I_, RP) and self._gwy._transport:
963
913
  diff = abs(dt.fromisoformat(msg.payload[SZ_DATETIME]) - self._gwy._dt_now())
964
914
  if diff > td(minutes=5):
965
915
  _LOGGER.warning(f"{msg!r} < excessive datetime difference: {diff}")
966
916
 
967
- async def get_datetime(self) -> Optional[dt]:
968
- msg = await self._gwy.async_send_cmd(Command.get_system_time(self.id))
917
+ async def get_datetime(self) -> dt | None:
918
+ cmd = Command.get_system_time(self.id)
919
+ pkt = await self._gwy.async_send_cmd(cmd, wait_for_reply=True)
920
+ msg = Message._from_pkt(pkt)
969
921
  return dt.fromisoformat(msg.payload[SZ_DATETIME])
970
922
 
971
- async def set_datetime(self, dtm: dt) -> Optional[Message]:
972
- return await self._gwy.async_send_cmd(Command.set_system_time(self.id, dtm))
923
+ async def set_datetime(self, dtm: dt) -> Packet:
924
+ """Set the date and time of the system."""
925
+
926
+ cmd = Command.set_system_time(self.id, dtm)
927
+ return await self._gwy.async_send_cmd(cmd, priority=Priority.HIGH)
973
928
 
974
929
 
975
930
  class UfHeating(SystemBase):
976
- def _ufh_ctls(self):
931
+ def _ufh_ctls(self) -> list[UfhController]:
977
932
  return sorted([d for d in self.childs if isinstance(d, UfhController)])
978
933
 
979
934
  @property
@@ -999,21 +954,59 @@ class UfHeating(SystemBase):
999
954
 
1000
955
 
1001
956
  class System(StoredHw, Datetime, Logbook, SystemBase):
1002
- """The Controller class."""
957
+ """The Temperature Control System class."""
1003
958
 
1004
- _SLUG: str = SYS_KLASS.PRG
959
+ _SLUG: str = SYS_KLASS.SYS
1005
960
 
1006
- def __init__(self, ctl, **kwargs) -> None:
961
+ def __init__(self, ctl: Controller, **kwargs: Any) -> None:
1007
962
  super().__init__(ctl, **kwargs)
1008
963
 
1009
964
  self._heat_demands: dict[str, Any] = {}
1010
965
  self._relay_demands: dict[str, Any] = {}
1011
966
  self._relay_failsafes: dict[str, Any] = {}
1012
967
 
968
+ def _update_schema(self, **schema: Any) -> None:
969
+ """Update a CH/DHW system with new schema attrs.
970
+
971
+ Raise an exception if the new schema is not a superset of the existing schema.
972
+ """
973
+
974
+ _schema: dict[str, Any]
975
+ schema = shrink(SCH_TCS(schema))
976
+
977
+ if schema.get(SZ_SYSTEM) and (
978
+ dev_id := schema[SZ_SYSTEM].get(SZ_APPLIANCE_CONTROL)
979
+ ):
980
+ self._app_cntrl = self._gwy.get_device(dev_id, parent=self, child_id=FC) # type: ignore[assignment]
981
+
982
+ if _schema := (schema.get(SZ_DHW_SYSTEM)): # type: ignore[assignment]
983
+ self.get_dhw_zone(**_schema) # self._dhw = ...
984
+
985
+ if not isinstance(self, MultiZone):
986
+ return
987
+
988
+ if _schema := (schema.get(SZ_ZONES)): # type: ignore[assignment]
989
+ [self.get_htg_zone(idx, **s) for idx, s in _schema.items()]
990
+
991
+ @classmethod
992
+ def create_from_schema(cls, ctl: Controller, **schema: Any) -> System:
993
+ """Create a CH/DHW system for a CTL and set its schema attrs.
994
+
995
+ The appropriate System class should have been determined by a factory.
996
+ Schema attrs include: class (klass) & others.
997
+ """
998
+
999
+ tcs = cls(ctl)
1000
+ tcs._update_schema(**schema)
1001
+ return tcs
1002
+
1013
1003
  def _handle_msg(self, msg: Message) -> None:
1014
1004
  super()._handle_msg(msg)
1015
1005
 
1016
- if SZ_DOMAIN_ID in msg.payload:
1006
+ if not isinstance(msg.payload, dict):
1007
+ return
1008
+
1009
+ if (idx := msg.payload.get(SZ_DOMAIN_ID)) and msg.verb in (I_, RP):
1017
1010
  idx = msg.payload[SZ_DOMAIN_ID]
1018
1011
  if msg.code == Code._0008:
1019
1012
  self._relay_demands[idx] = msg
@@ -1032,24 +1025,24 @@ class System(StoredHw, Datetime, Logbook, SystemBase):
1032
1025
  assert False, f"Unexpected code with a domain_id: {msg.code}"
1033
1026
 
1034
1027
  @property
1035
- def heat_demands(self) -> Optional[dict]: # 3150
1028
+ def heat_demands(self) -> dict[str, Any] | None: # 3150
1036
1029
  # FC: 00-C8 (no F9, FA), TODO: deprecate as FC only?
1037
1030
  if not self._heat_demands:
1038
1031
  return None
1039
1032
  return {k: v.payload["heat_demand"] for k, v in self._heat_demands.items()}
1040
1033
 
1041
1034
  @property
1042
- def relay_demands(self) -> Optional[dict]: # 0008
1035
+ def relay_demands(self) -> dict[str, Any] | None: # 0008
1043
1036
  # FC: 00-C8, F9: 00-C8, FA: 00 or C8 only (01: all 3, 02: FC/FA only)
1044
1037
  if not self._relay_demands:
1045
1038
  return None
1046
1039
  return {k: v.payload["relay_demand"] for k, v in self._relay_demands.items()}
1047
1040
 
1048
1041
  @property
1049
- def relay_failsafes(self) -> Optional[dict]: # 0009
1042
+ def relay_failsafes(self) -> dict[str, Any] | None: # 0009
1050
1043
  if not self._relay_failsafes:
1051
1044
  return None
1052
- return {} # TODO: failsafe_enabled
1045
+ return {} # FIXME: failsafe_enabled
1053
1046
 
1054
1047
  @property
1055
1048
  def status(self) -> dict[str, Any]:
@@ -1066,28 +1059,25 @@ class System(StoredHw, Datetime, Logbook, SystemBase):
1066
1059
 
1067
1060
 
1068
1061
  class Evohome(ScheduleSync, Language, SysMode, MultiZone, UfHeating, System):
1069
- """The Evohome system - some controllers are evohome-compatible."""
1062
+ _SLUG: str = SYS_KLASS.TCS # evohome
1070
1063
 
1071
1064
  # older evohome don't have zone_type=ELE
1072
1065
 
1073
- _SLUG: str = SYS_KLASS.TCS
1074
-
1075
1066
 
1076
1067
  class Chronotherm(Evohome):
1077
-
1078
1068
  _SLUG: str = SYS_KLASS.SYS
1079
1069
 
1080
1070
 
1081
1071
  class Hometronics(System):
1072
+ _SLUG: str = SYS_KLASS.SYS
1073
+
1082
1074
  # These are only ever been seen from a Hometronics controller
1083
1075
  # .I --- 01:023389 --:------ 01:023389 2D49 003 00C800
1084
1076
  # .I --- 01:023389 --:------ 01:023389 2D49 003 01C800
1085
1077
  # .I --- 01:023389 --:------ 01:023389 2D49 003 880000
1086
1078
  # .I --- 01:023389 --:------ 01:023389 2D49 003 FD0000
1087
1079
 
1088
- # Hometronic does not react to W/2349 but rather requies W/2309
1089
-
1090
- _SLUG: str = SYS_KLASS.SYS
1080
+ # Hometronic does not react to W/2349 but rather requires W/2309
1091
1081
 
1092
1082
  #
1093
1083
  # def _setup_discovery_cmds(self) -> None:
@@ -1101,39 +1091,42 @@ class Hometronics(System):
1101
1091
 
1102
1092
 
1103
1093
  class Programmer(Evohome):
1104
-
1105
1094
  _SLUG: str = SYS_KLASS.PRG
1106
1095
 
1107
1096
 
1108
1097
  class Sundial(Evohome):
1109
-
1110
1098
  _SLUG: str = SYS_KLASS.SYS
1111
1099
 
1112
1100
 
1113
- SYS_CLASS_BY_SLUG = class_by_attr(__name__, "_SLUG") # e.g. "evohome": Evohome
1101
+ # e.g. {"evohome": Evohome}
1102
+ SYS_CLASS_BY_SLUG: dict[str, type[System]] = class_by_attr(__name__, "_SLUG")
1114
1103
 
1115
1104
 
1116
- def system_factory(ctl, *, msg: Message = None, **schema) -> _SystemT:
1105
+ def system_factory(
1106
+ ctl: Controller, *, msg: Message | None = None, **schema: Any
1107
+ ) -> System:
1117
1108
  """Return the system class for a given controller/schema (defaults to evohome)."""
1118
1109
 
1119
1110
  def best_tcs_class(
1120
1111
  ctl_addr: Address,
1121
1112
  *,
1122
- msg: Message = None,
1113
+ msg: Message | None = None,
1123
1114
  eavesdrop: bool = False,
1124
- **schema,
1125
- ) -> type[_SystemT]:
1115
+ **schema: Any,
1116
+ ) -> type[System]:
1126
1117
  """Return the system class for a given CTL/schema (defaults to evohome)."""
1127
1118
 
1128
- # a specified system class always takes precidence (even if it is wrong)...
1129
- if cls := SYS_CLASS_BY_SLUG.get(schema.get(SZ_CLASS)):
1119
+ klass: str = schema.get(SZ_CLASS) # type: ignore[assignment]
1120
+
1121
+ # a specified system class always takes precedence (even if it is wrong)...
1122
+ if klass and (cls := SYS_CLASS_BY_SLUG.get(klass)):
1130
1123
  _LOGGER.debug(
1131
1124
  f"Using an explicitly-defined system class for: {ctl_addr} ({cls._SLUG})"
1132
1125
  )
1133
1126
  return cls
1134
1127
 
1135
1128
  # otherwise, use the default system class...
1136
- _LOGGER.debug(f"Using a generic system class for: {ctl_addr} ({Evohome._SLUG})")
1129
+ _LOGGER.debug(f"Using a generic system class for: {ctl_addr} ({Device._SLUG})")
1137
1130
  return Evohome
1138
1131
 
1139
1132
  return best_tcs_class(