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/system/zones.py CHANGED
@@ -1,18 +1,16 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- #
4
2
  """RAMSES RF - The evohome-compatible zones."""
3
+
5
4
  from __future__ import annotations
6
5
 
6
+ import asyncio
7
7
  import logging
8
8
  import math
9
- from asyncio import Future
10
- from datetime import datetime as dt
11
- from datetime import timedelta as td
12
- from typing import Any, TypeVar
9
+ from datetime import datetime as dt, timedelta as td
10
+ from typing import TYPE_CHECKING, Any, TypeVar
13
11
 
14
- from ..const import (
15
- DEV_ROLE,
12
+ from ramses_rf import exceptions as exc
13
+ from ramses_rf.const import (
16
14
  DEV_ROLE_MAP,
17
15
  DEV_TYPE_MAP,
18
16
  SZ_DOMAIN_ID,
@@ -26,11 +24,11 @@ from ..const import (
26
24
  SZ_ZONE_IDX,
27
25
  SZ_ZONE_TYPE,
28
26
  ZON_MODE_MAP,
29
- ZON_ROLE,
30
27
  ZON_ROLE_MAP,
31
- __dev_mode__,
28
+ DevRole,
29
+ ZoneRole,
32
30
  )
33
- from ..device import (
31
+ from ramses_rf.device import (
34
32
  BdrSwitch,
35
33
  Controller,
36
34
  Device,
@@ -38,12 +36,9 @@ from ..device import (
38
36
  TrvActuator,
39
37
  UfhController,
40
38
  )
41
- from ..entity_base import Child, Entity, Parent, class_by_attr
42
- from ..helpers import shrink
43
- from ..protocol import Address, Command, CorruptStateError, Message
44
- from ..protocol.command import _mk_cmd
45
- from ..protocol.const import SZ_PAYLOAD
46
- from ..schemas import (
39
+ from ramses_rf.entity_base import Child, Entity, Parent, class_by_attr
40
+ from ramses_rf.helpers import shrink
41
+ from ramses_rf.schemas import (
47
42
  SCH_TCS_DHW,
48
43
  SCH_TCS_ZONES_ZON,
49
44
  SZ_ACTUATORS,
@@ -53,98 +48,114 @@ from ..schemas import (
53
48
  SZ_HTG_VALVE,
54
49
  SZ_SENSOR,
55
50
  )
56
- from .schedule import Schedule
51
+ from ramses_tx import Address, Command, Message, Priority
52
+
53
+ from .schedule import InnerScheduleT, OuterScheduleT, Schedule
54
+
55
+ if TYPE_CHECKING:
56
+ from ramses_tx import Packet
57
+ from ramses_tx.schemas import DeviceIdT, DevIndexT
58
+ from ramses_tx.typed_dicts import PayDictT
59
+
60
+ from .heat import Evohome, _MultiZoneT, _StoredHwT
61
+
57
62
 
58
63
  # Kudos & many thanks to:
59
64
  # - @dbmandrake: valve_position -> heat_demand transform
60
65
 
61
- # TODO: add optional eavesdrop of zone_type
62
66
 
67
+ from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
68
+ F9,
69
+ FA,
70
+ FC,
71
+ FF,
72
+ )
63
73
 
64
- # skipcq: PY-W2000
65
- from ..protocol import ( # noqa: F401, isort: skip, pylint: disable=unused-import
74
+ from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
66
75
  I_,
67
76
  RP,
68
77
  RQ,
69
78
  W_,
70
- F9,
71
- FA,
72
- FC,
73
- FF,
74
79
  Code,
75
80
  )
76
81
 
77
-
78
- DEV_MODE = __dev_mode__
79
-
80
82
  _LOGGER = logging.getLogger(__name__)
81
- if DEV_MODE:
82
- _LOGGER.setLevel(logging.DEBUG)
83
-
84
-
85
- _ZoneT = TypeVar("_ZoneT", bound="ZoneBase")
86
83
 
87
84
 
88
85
  class ZoneBase(Child, Parent, Entity):
89
86
  """The Zone/DHW base class."""
90
87
 
91
- _SLUG: str = None # type: ignore[assignment]
92
- _ROLE_ACTUATORS: str = None # type: ignore[assignment]
93
- _ROLE_SENSORS: str = None # type: ignore[assignment]
88
+ _SLUG: str = None
89
+
90
+ _ROLE_ACTUATORS: str = None
91
+ _ROLE_SENSORS: str = None
94
92
 
95
- def __init__(self, tcs, zone_idx: str) -> None:
93
+ def __init__(self, tcs: _MultiZoneT | _StoredHwT, zone_idx: str) -> None:
96
94
  super().__init__(tcs._gwy)
97
95
 
96
+ # FIXME: ZZZ entities must know their parent device ID and their own idx
97
+ self._z_id = tcs.id # the responsible device is the controller
98
+ self._z_idx: DevIndexT = zone_idx # the zone idx (ctx), 00-0B (or 0F), HW (FA)
99
+
98
100
  self.id: str = f"{tcs.id}_{zone_idx}"
99
101
 
100
- self.tcs = tcs
102
+ self.tcs: Evohome = tcs
101
103
  self.ctl: Controller = tcs.ctl
102
- self._child_id = zone_idx
104
+ self._child_id: str = zone_idx
103
105
 
104
106
  self._name = None # param attr
105
107
 
108
+ # Should be a private method
106
109
  @classmethod
107
- def create_from_schema(cls, tcs, zone_idx: str, **schema):
110
+ def create_from_schema(
111
+ cls, tcs: _MultiZoneT, zone_idx: str, **schema: Any
112
+ ) -> ZoneBase:
108
113
  """Create a CH/DHW zone for a TCS and set its schema attrs.
109
114
 
110
115
  The appropriate Zone class should have been determined by a factory.
111
116
  Can be a heating zone (of a klass), or the DHW subsystem (idx must be 'HW').
112
117
  """
113
118
 
114
- zon = cls(tcs, zone_idx)
119
+ zon = cls(tcs, zone_idx) # type: ignore[arg-type]
115
120
  zon._update_schema(**schema)
116
121
  return zon
117
122
 
118
- def _update_schema(self, **schema):
123
+ def _update_schema(self, **schema: Any) -> None:
119
124
  raise NotImplementedError
120
125
 
121
126
  def __repr__(self) -> str:
122
127
  return f"{self.id} ({self._SLUG})"
123
128
 
124
- def __lt__(self, other) -> bool:
129
+ def __lt__(self, other: object) -> bool:
125
130
  if not isinstance(other, ZoneBase):
126
131
  return NotImplemented
127
132
  return self.idx < other.idx
128
133
 
129
- def _make_cmd(self, code, **kwargs) -> None: # skipcq: PYL-W0221
130
- payload = kwargs.pop(SZ_PAYLOAD, f"{self.idx}00")
131
- super()._make_cmd(code, self.ctl.id, payload=payload, **kwargs)
134
+ @property
135
+ def idx(self) -> str:
136
+ return self._child_id
132
137
 
133
138
  @property
134
- def heating_type(self) -> str:
135
- """Return the type of the zone/DHW (e.g. electric_zone, stored_dhw)."""
136
- return ZON_ROLE_MAP[self._SLUG]
139
+ def schema(self) -> dict[str, Any]:
140
+ """Return the schema (can't change without destroying/re-creating entity)."""
141
+ return {}
137
142
 
138
143
  @property
139
- def idx(self) -> str:
140
- return self._child_id
144
+ def params(self) -> dict[str, Any]:
145
+ """Return configuration (can be changed by user)."""
146
+ return {}
147
+
148
+ @property
149
+ def status(self) -> dict[str, Any]:
150
+ """Return the current state."""
151
+ return {}
141
152
 
142
153
 
143
- class ZoneSchedule: # 0404
144
- def __init__(self, *args, **kwargs) -> None:
154
+ class ZoneSchedule(ZoneBase): # 0404
155
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
145
156
  super().__init__(*args, **kwargs)
146
157
 
147
- self._schedule = Schedule(self) # ? add to discovery
158
+ self._schedule = Schedule(self) # type: ignore[arg-type]
148
159
 
149
160
  def _handle_msg(self, msg: Message) -> None:
150
161
  super()._handle_msg(msg)
@@ -152,21 +163,24 @@ class ZoneSchedule: # 0404
152
163
  if msg.code in (Code._0006, Code._0404):
153
164
  self._schedule._handle_msg(msg)
154
165
 
155
- async def get_schedule(self, *, force_io=None) -> None | dict:
166
+ async def get_schedule(self, *, force_io: bool = False) -> InnerScheduleT | None:
156
167
  await self._schedule.get_schedule(force_io=force_io)
157
168
  return self.schedule
158
169
 
159
- async def set_schedule(self, schedule) -> None | dict:
160
- await self._schedule.set_schedule(schedule)
170
+ async def set_schedule(self, schedule: OuterScheduleT) -> InnerScheduleT | None:
171
+ await self._schedule.set_schedule(schedule) # type: ignore[arg-type]
161
172
  return self.schedule
162
173
 
163
174
  @property
164
- def schedule(self) -> None | dict:
165
- """Return the latest retreived schedule (not guaranteed to be up to date)."""
175
+ def schedule(self) -> InnerScheduleT | None:
176
+ """Return the latest retrieved schedule (not guaranteed to be up to date)."""
177
+ # inner: [{"day_of_week": 0, "switchpoints": [...], {"day_of_week": 1, ...
178
+ # outer: {"zone_idx": "01", "schedule": <inner>
179
+
166
180
  return self._schedule.schedule
167
181
 
168
182
  @property
169
- def schedule_version(self) -> None | int:
183
+ def schedule_version(self) -> int | None: # TODO: make int
170
184
  """Return the version number associated with the latest retrieved schedule."""
171
185
  return self._schedule.version
172
186
 
@@ -178,12 +192,12 @@ class ZoneSchedule: # 0404
178
192
  }
179
193
 
180
194
 
181
- class DhwZone(ZoneSchedule, ZoneBase): # CS92A # TODO: add Schedule
195
+ class DhwZone(ZoneSchedule): # CS92A
182
196
  """The DHW class."""
183
197
 
184
- _SLUG: str = ZON_ROLE.DHW
198
+ _SLUG: str = ZoneRole.DHW
185
199
 
186
- def __init__(self, tcs, zone_idx: str = "HW") -> None:
200
+ def __init__(self, tcs: _StoredHwT, zone_idx: str = "HW") -> None:
187
201
  _LOGGER.debug("Creating a DHW for TCS: %s_HW (%s)", tcs.id, self.__class__)
188
202
 
189
203
  if tcs.dhw:
@@ -193,20 +207,21 @@ class DhwZone(ZoneSchedule, ZoneBase): # CS92A # TODO: add Schedule
193
207
 
194
208
  super().__init__(tcs, "HW")
195
209
 
196
- self._dhw_sensor: DhwSensor = None # type: ignore[assignment]
197
- self._dhw_valve: BdrSwitch = None # type: ignore[assignment]
198
- self._htg_valve: BdrSwitch = None # type: ignore[assignment]
210
+ # DhwZones have a sensor, but actuators are optional, depending on schema
211
+ self._dhw_sensor: DhwSensor | None = None
212
+ self._dhw_valve: BdrSwitch | None = None
213
+ self._htg_valve: BdrSwitch | None = None
199
214
 
200
215
  def _setup_discovery_cmds(self) -> None:
201
216
  # super()._setup_discovery_cmds()
202
217
 
203
218
  for payload in (
204
- f"00{DEV_ROLE_MAP.DHW}",
205
- f"00{DEV_ROLE_MAP.HTG}",
206
- f"01{DEV_ROLE_MAP.HTG}",
219
+ f"00{DEV_ROLE_MAP.DHW}", # sensor
220
+ f"00{DEV_ROLE_MAP.HTG}", # hotwater_valve
221
+ f"01{DEV_ROLE_MAP.HTG}", # heating_valve
207
222
  ):
208
223
  self._add_discovery_cmd(
209
- _mk_cmd(RQ, Code._000C, payload, self.ctl.id), 60 * 60 * 24
224
+ Command.from_attrs(RQ, self.ctl.id, Code._000C, payload), 60 * 60 * 24
210
225
  )
211
226
 
212
227
  self._add_discovery_cmd(Command.get_dhw_params(self.ctl.id), 60 * 60 * 6)
@@ -215,40 +230,40 @@ class DhwZone(ZoneSchedule, ZoneBase): # CS92A # TODO: add Schedule
215
230
  self._add_discovery_cmd(Command.get_dhw_temp(self.ctl.id), 60 * 15)
216
231
 
217
232
  def _handle_msg(self, msg: Message) -> None:
218
- def eavesdrop_dhw_sensor(this, *, prev=None) -> None:
219
- """Eavesdrop packets, or pairs of packets, to maintain the system state.
233
+ # def eavesdrop_dhw_sensor(this: Message, *, prev: Message | None = None) -> None:
234
+ # """Eavesdrop packets, or pairs of packets, to maintain the system state.
220
235
 
221
- There are only 2 ways to to find a controller's DHW sensor:
222
- 1. The 10A0 RQ/RP *from/to a 07:* (1x/4h) - reliable
223
- 2. Use sensor temp matching - non-deterministic
236
+ # There are only 2 ways to to find a controller's DHW sensor:
237
+ # 1. The 10A0 RQ/RP *from/to a 07:* (1x/4h) - reliable
238
+ # 2. Use sensor temp matching - non-deterministic
224
239
 
225
- Data from the CTL is considered more authorative. The RQ is initiated by the
226
- DHW, so is not authorative. The I/1260 is not to/from a controller, so is
227
- not useful.
228
- """
240
+ # Data from the CTL is considered more authoritative. The RQ is initiated by the
241
+ # DHW, so is not authoritative. The I/1260 is not to/from a controller, so is
242
+ # not useful.
243
+ # """
229
244
 
230
- # 10A0: RQ/07/01, RP/01/07: can get both parent controller & DHW sensor
231
- # 047 RQ --- 07:030741 01:102458 --:------ 10A0 006 00181F0003E4
232
- # 062 RP --- 01:102458 07:030741 --:------ 10A0 006 0018380003E8
245
+ # # 10A0: RQ/07/01, RP/01/07: can get both parent controller & DHW sensor
246
+ # # 047 RQ --- 07:030741 01:102458 --:------ 10A0 006 00181F0003E4
247
+ # # 062 RP --- 01:102458 07:030741 --:------ 10A0 006 0018380003E8
233
248
 
234
- # 1260: I/07: can't get which parent controller - would need to match temps
235
- # 045 I --- 07:045960 --:------ 07:045960 1260 003 000911
249
+ # # 1260: I/07: can't get which parent controller - would need to match temps
250
+ # # 045 I --- 07:045960 --:------ 07:045960 1260 003 000911
236
251
 
237
- # 1F41: I/01: get parent controller, but not DHW sensor
238
- # 045 I --- 01:145038 --:------ 01:145038 1F41 012 000004FFFFFF1E060E0507E4
239
- # 045 I --- 01:145038 --:------ 01:145038 1F41 006 000002FFFFFF
252
+ # # 1F41: I/01: get parent controller, but not DHW sensor
253
+ # # 045 I --- 01:145038 --:------ 01:145038 1F41 012 000004FFFFFF1E060E0507E4
254
+ # # 045 I --- 01:145038 --:------ 01:145038 1F41 006 000002FFFFFF
240
255
 
241
- assert self._gwy.config.enable_eavesdrop, "Coding error"
256
+ # assert self._gwy.config.enable_eavesdrop, "Coding error"
242
257
 
243
- if all(
244
- (
245
- this.code == Code._10A0,
246
- this.verb == RP,
247
- this.src is self.ctl,
248
- isinstance(this.dst, DhwSensor),
249
- )
250
- ):
251
- self._get_dhw(sensor=this.dst)
258
+ # if all(
259
+ # (
260
+ # this.code == Code._10A0,
261
+ # this.verb == RP,
262
+ # this.src is self.ctl,
263
+ # isinstance(this.dst, DhwSensor),
264
+ # )
265
+ # ):
266
+ # self._get_dhw(sensor=this.dst)
252
267
 
253
268
  assert (
254
269
  msg.src is self.ctl
@@ -280,7 +295,7 @@ class DhwZone(ZoneSchedule, ZoneBase): # CS92A # TODO: add Schedule
280
295
  # if self._gwy.config.enable_eavesdrop and not self.dhw_sensor:
281
296
  # eavesdrop_dhw_sensor(msg)
282
297
 
283
- def _update_schema(self, **schema):
298
+ def _update_schema(self, **schema: Any) -> None:
284
299
  """Update a DHW zone with new schema attrs.
285
300
 
286
301
  Raise an exception if the new schema is not a superset of the existing schema.
@@ -290,38 +305,44 @@ class DhwZone(ZoneSchedule, ZoneBase): # CS92A # TODO: add Schedule
290
305
  """Set the heating valve relay for this DHW zone (13: only)."""
291
306
  """Set the hotwater valve relay for this DHW zone (13: only).
292
307
 
293
- Check and _VerbT the DHW sensor (07:) of this system/CTL (if there is one).
308
+ Check and ??? the DHW sensor (07:) of this system/CTL (if there is one).
294
309
 
295
310
  There is only 1 way to eavesdrop a controller's DHW sensor:
296
311
  1. The 10A0 RQ/RP *from/to a 07:* (1x/4h)
297
312
 
298
- The RQ is initiated by the DHW, so is not authorative (the CTL will RP any RQ).
313
+ The RQ is initiated by the DHW, so is not authoritative (the CTL will RP any RQ).
299
314
  The I/1260 is not to/from a controller, so is not useful.
300
- """ # noqa: D402
315
+ """
301
316
 
302
317
  schema = shrink(SCH_TCS_DHW(schema))
303
318
 
304
319
  if dev_id := schema.get(SZ_SENSOR):
305
- self._dhw_sensor = self._gwy.get_device(
320
+ dhw_sensor = self._gwy.get_device(
306
321
  dev_id, parent=self, child_id=FA, is_sensor=True
307
322
  )
323
+ assert isinstance(dhw_sensor, DhwSensor) # mypy
324
+ self._dhw_sensor = dhw_sensor
308
325
 
309
- if dev_id := schema.get(DEV_ROLE_MAP[DEV_ROLE.HTG]):
310
- self._dhw_valve = self._gwy.get_device(dev_id, parent=self, child_id=FA)
326
+ if dev_id := schema.get(DEV_ROLE_MAP[DevRole.HTG]):
327
+ dhw_valve = self._gwy.get_device(dev_id, parent=self, child_id=FA)
328
+ assert isinstance(dhw_valve, BdrSwitch) # mypy
329
+ self._dhw_valve = dhw_valve
311
330
 
312
- if dev_id := schema.get(DEV_ROLE_MAP[DEV_ROLE.HT1]):
313
- self._htg_valve = self._gwy.get_device(dev_id, parent=self, child_id=F9)
331
+ if dev_id := schema.get(DEV_ROLE_MAP[DevRole.HT1]):
332
+ htg_valve = self._gwy.get_device(dev_id, parent=self, child_id=F9)
333
+ assert isinstance(htg_valve, BdrSwitch) # mypy
334
+ self._htg_valve = htg_valve
314
335
 
315
336
  @property
316
- def sensor(self) -> DhwSensor: # self._dhw_sensor
337
+ def sensor(self) -> DhwSensor | None: # self._dhw_sensor
317
338
  return self._dhw_sensor
318
339
 
319
340
  @property
320
- def hotwater_valve(self) -> BdrSwitch: # self._dhw_valve
341
+ def hotwater_valve(self) -> BdrSwitch | None: # self._dhw_valve
321
342
  return self._dhw_valve
322
343
 
323
344
  @property
324
- def heating_valve(self) -> BdrSwitch: # self._htg_valve
345
+ def heating_valve(self) -> BdrSwitch | None: # self._htg_valve
325
346
  return self._htg_valve
326
347
 
327
348
  @property
@@ -329,44 +350,50 @@ class DhwZone(ZoneSchedule, ZoneBase): # CS92A # TODO: add Schedule
329
350
  return "Stored HW"
330
351
 
331
352
  @property
332
- def config(self) -> None | dict: # 10A0
333
- return self._msg_value(Code._10A0)
353
+ def config(self) -> dict[str, Any] | None: # 10A0
354
+ return self._msg_value(Code._10A0) # type: ignore[return-value]
334
355
 
335
356
  @property
336
- def mode(self) -> None | dict: # 1F41
337
- return self._msg_value(Code._1F41)
357
+ def mode(self) -> dict[str, Any] | None: # 1F41
358
+ return self._msg_value(Code._1F41) # type: ignore[return-value]
338
359
 
339
360
  @property
340
- def setpoint(self) -> None | float: # 10A0
341
- return self._msg_value(Code._10A0, key=SZ_SETPOINT)
361
+ def setpoint(self) -> float | None: # 10A0
362
+ return self._msg_value(Code._10A0, key=SZ_SETPOINT) # type: ignore[return-value]
342
363
 
343
- @setpoint.setter
344
- def setpoint(self, value) -> None: # 10A0
364
+ @setpoint.setter # TODO: can value be None?
365
+ def setpoint(self, value: float) -> None: # 10A0
345
366
  self.set_config(setpoint=value)
346
367
 
347
368
  @property
348
- def temperature(self) -> None | float: # 1260
349
- return self._msg_value(Code._1260, key=SZ_TEMPERATURE)
369
+ def temperature(self) -> float | None: # 1260
370
+ return self._msg_value(Code._1260, key=SZ_TEMPERATURE) # type: ignore[return-value]
350
371
 
351
372
  @property
352
- def heat_demand(self) -> None | float: # 3150
353
- return self._msg_value(Code._3150, key=SZ_HEAT_DEMAND)
373
+ def heat_demand(self) -> float | None: # 3150
374
+ return self._msg_value(Code._3150, key=SZ_HEAT_DEMAND) # type: ignore[return-value]
354
375
 
355
376
  @property
356
- def relay_demand(self) -> None | float: # 0008
357
- return self._msg_value(Code._0008, key=SZ_RELAY_DEMAND)
377
+ def relay_demand(self) -> float | None: # 0008
378
+ return self._msg_value(Code._0008, key=SZ_RELAY_DEMAND) # type: ignore[return-value]
358
379
 
359
380
  @property # only seen with FC, but seems should pair with 0008?
360
- def relay_failsafe(self) -> None | float: # 0009
361
- return self._msg_value(Code._0009, key=SZ_RELAY_FAILSAFE)
381
+ def relay_failsafe(self) -> float | None: # 0009
382
+ return self._msg_value(Code._0009, key=SZ_RELAY_FAILSAFE) # type: ignore[return-value]
362
383
 
363
- def set_mode(self, *, mode=None, active=None, until=None) -> Future:
384
+ def set_mode(
385
+ self,
386
+ *,
387
+ mode: int | str | None = None,
388
+ active: bool | None = None,
389
+ until: dt | str | None = None,
390
+ ) -> asyncio.Task[Packet]:
364
391
  """Set the DHW mode (mode, active, until)."""
365
- return self._send_cmd(
366
- Command.set_dhw_mode(self.ctl.id, mode=mode, active=active, until=until)
367
- )
368
392
 
369
- def set_boost_mode(self) -> Future:
393
+ cmd = Command.set_dhw_mode(self.ctl.id, mode=mode, active=active, until=until)
394
+ return self._gwy.send_cmd(cmd, priority=Priority.HIGH, wait_for_reply=True)
395
+
396
+ def set_boost_mode(self) -> asyncio.Task[Packet]:
370
397
  """Enable DHW for an hour, despite any schedule."""
371
398
  return self.set_mode(
372
399
  mode=ZON_MODE_MAP.TEMPORARY,
@@ -374,12 +401,19 @@ class DhwZone(ZoneSchedule, ZoneBase): # CS92A # TODO: add Schedule
374
401
  until=dt.now() + td(hours=1),
375
402
  )
376
403
 
377
- def reset_mode(self) -> Future: # 1F41
404
+ def reset_mode(self) -> asyncio.Task[Packet]: # 1F41
378
405
  """Revert the DHW to following its schedule."""
379
406
  return self.set_mode(mode=ZON_MODE_MAP.FOLLOW)
380
407
 
381
- def set_config(self, *, setpoint=None, overrun=None, differential=None) -> Future:
408
+ def set_config(
409
+ self,
410
+ *,
411
+ setpoint: float | None = None,
412
+ overrun: int | None = None,
413
+ differential: float | None = None,
414
+ ) -> asyncio.Task[Packet]:
382
415
  """Set the DHW parameters (setpoint, overrun, differential)."""
416
+
383
417
  # dhw_params = self._msg_value(Code._10A0)
384
418
  # if setpoint is None:
385
419
  # setpoint = dhw_params[SZ_SETPOINT]
@@ -388,16 +422,15 @@ class DhwZone(ZoneSchedule, ZoneBase): # CS92A # TODO: add Schedule
388
422
  # if differential is None:
389
423
  # setpoint = dhw_params["differential"]
390
424
 
391
- return self._send_cmd(
392
- Command.set_dhw_params(
393
- self.ctl.id,
394
- setpoint=setpoint,
395
- overrun=overrun,
396
- differential=differential,
397
- )
425
+ cmd = Command.set_dhw_params(
426
+ self.ctl.id,
427
+ setpoint=setpoint,
428
+ overrun=overrun,
429
+ differential=differential,
398
430
  )
431
+ return self._gwy.send_cmd(cmd, priority=Priority.HIGH)
399
432
 
400
- def reset_config(self) -> Future: # 10A0
433
+ def reset_config(self) -> asyncio.Task[Packet]: # 10A0
401
434
  """Reset the DHW parameters to their default values."""
402
435
  return self.set_config(setpoint=50, overrun=5, differential=1)
403
436
 
@@ -421,13 +454,13 @@ class DhwZone(ZoneSchedule, ZoneBase): # CS92A # TODO: add Schedule
421
454
  return {a: getattr(self, a) for a in (SZ_TEMPERATURE, SZ_HEAT_DEMAND)}
422
455
 
423
456
 
424
- class Zone(ZoneSchedule, ZoneBase):
457
+ class Zone(ZoneSchedule):
425
458
  """The Zone class for all zone types (but not DHW)."""
426
459
 
427
- _SLUG: str = None # type: ignore[assignment]
460
+ _SLUG: str = None
428
461
  _ROLE_ACTUATORS: str = DEV_ROLE_MAP.ACT
429
462
 
430
- def __init__(self, tcs, zone_idx: str) -> None:
463
+ def __init__(self, tcs: _MultiZoneT, zone_idx: str) -> None:
431
464
  """Create a heating zone.
432
465
 
433
466
  The type of zone may not be known at instantiation. Even when it is known, zones
@@ -445,11 +478,11 @@ class Zone(ZoneSchedule, ZoneBase):
445
478
 
446
479
  super().__init__(tcs, zone_idx)
447
480
 
448
- self._sensor = None # schema attr
449
- self.actuators = [] # schema attr
450
- self.actuator_by_id = {} # schema attr
481
+ self._sensor: Device | None = None
482
+ self.actuators: list[Device] = []
483
+ self.actuator_by_id: dict[DeviceIdT, Device] = {}
451
484
 
452
- def _update_schema(self, *, append_actuators=True, **schema):
485
+ def _update_schema(self, **schema: Any) -> None:
453
486
  """Update a heating zone with new schema attrs.
454
487
 
455
488
  Raise an exception if the new schema is not a superset of the existing schema.
@@ -473,9 +506,9 @@ class Zone(ZoneSchedule, ZoneBase):
473
506
  if klass == self._SLUG:
474
507
  return
475
508
 
476
- if klass == ZON_ROLE.VAL and self._SLUG not in (
509
+ if klass == ZoneRole.VAL and self._SLUG not in (
477
510
  None,
478
- ZON_ROLE.ELE,
511
+ ZoneRole.ELE,
479
512
  ):
480
513
  raise ValueError(f"Not a compatible zone class for {self}: {zone_type}")
481
514
 
@@ -483,15 +516,16 @@ class Zone(ZoneSchedule, ZoneBase):
483
516
  raise ValueError(f"Not a known zone class (for {self}): {zone_type}")
484
517
 
485
518
  if self._SLUG is not None:
486
- raise CorruptStateError(
519
+ raise exc.SystemSchemaInconsistent(
487
520
  f"{self} changed zone class: from {self._SLUG} to {klass}"
488
521
  )
489
522
 
490
523
  self.__class__ = ZONE_CLASS_BY_SLUG[klass]
491
524
  _LOGGER.debug("Promoted a Zone: %s (%s)", self.id, self.__class__)
492
525
 
493
- # TODO: broken fixme
494
- # self._gwy._loop.call_soon(self._setup_discovery_cmds) # TODO: check this
526
+ self._setup_discovery_cmds()
527
+
528
+ dev_id: DeviceIdT
495
529
 
496
530
  # if schema.get(SZ_CLASS) == ZON_ROLE_MAP[ZON_ROLE.ACT]:
497
531
  # schema.pop(SZ_CLASS)
@@ -510,11 +544,10 @@ class Zone(ZoneSchedule, ZoneBase):
510
544
  # super()._setup_discovery_cmds()
511
545
 
512
546
  for dev_role in (self._ROLE_ACTUATORS, DEV_ROLE_MAP.SEN):
513
- self._add_discovery_cmd(
514
- _mk_cmd(RQ, Code._000C, f"{self.idx}{dev_role}", self.ctl.id),
515
- 60 * 60 * 24,
516
- delay=0.5,
547
+ cmd = Command.from_attrs(
548
+ RQ, self.ctl.id, Code._000C, f"{self.idx}{dev_role}"
517
549
  )
550
+ self._add_discovery_cmd(cmd, 60 * 60 * 24, delay=0.5)
518
551
 
519
552
  self._add_discovery_cmd(
520
553
  Command.get_zone_config(self.ctl.id, self.idx), 60 * 60 * 6, delay=30
@@ -534,26 +567,35 @@ class Zone(ZoneSchedule, ZoneBase):
534
567
  ) # longer dt as low yield (factory duration is 30 min): prefer eavesdropping
535
568
 
536
569
  def _add_discovery_cmd(
537
- self, cmd, interval, *, delay: float = 0, timeout: float = None
538
- ):
539
- """Schedule a command to run periodically."""
570
+ self,
571
+ cmd: Command,
572
+ interval: float,
573
+ *,
574
+ delay: float = 0,
575
+ timeout: float | None = None,
576
+ ) -> None:
577
+ """Schedule a command to run periodically.
578
+
579
+ Both `timeout` and `delay` are in seconds.
580
+ """
540
581
  super()._add_discovery_cmd(cmd, interval, delay=delay, timeout=timeout)
541
582
 
542
583
  if cmd.code != Code._000C: # or cmd._ctx == f"{self.idx}{ZON_ROLE_MAP.SEN}":
543
584
  return
544
585
 
545
586
  if [t for t in self._discovery_cmds if t[-2:] in ZON_ROLE_MAP.HEAT_ZONES] and (
546
- self._discovery_cmds.pop(f"{self.idx}{ZON_ROLE_MAP.ACT}", None)
587
+ self._discovery_cmds.pop(f"{self.idx}{ZON_ROLE_MAP.ACT}", [])
547
588
  ):
548
589
  _LOGGER.warning(f"cmd({cmd}): inferior header removed from discovery")
549
590
 
550
- if self._discovery_cmds.get(f"{self.idx}{ZON_ROLE_MAP.VAL}") and (
551
- self._discovery_cmds[f"{self.idx}{ZON_ROLE_MAP.ELE}"]
591
+ if (
592
+ self._discovery_cmds.get(f"{self.idx}{ZON_ROLE_MAP.VAL}")
593
+ and (self._discovery_cmds[f"{self.idx}{ZON_ROLE_MAP.ELE}"])
552
594
  ):
553
595
  _LOGGER.warning(f"cmd({cmd}): inferior header removed from discovery")
554
596
 
555
597
  def _handle_msg(self, msg: Message) -> None:
556
- def eavesdrop_zone_type(this, *, prev=None) -> None:
598
+ def eavesdrop_zone_type(this: Message, *, prev: Message | None = None) -> None:
557
599
  """TODO.
558
600
 
559
601
  There are three ways to determine the type of a zone:
@@ -565,30 +607,30 @@ class Zone(ZoneSchedule, ZoneBase):
565
607
  if this.code in (Code._0008, Code._0009):
566
608
  assert self._SLUG in (
567
609
  None,
568
- ZON_ROLE.ELE,
569
- ZON_ROLE.VAL,
570
- ZON_ROLE.MIX,
610
+ ZoneRole.ELE,
611
+ ZoneRole.VAL,
612
+ ZoneRole.MIX,
571
613
  ), self._SLUG
572
614
 
573
615
  if self._SLUG is None:
574
616
  # this might eventually be: ZON_ROLE.VAL
575
- self._update_schema(**{SZ_CLASS: ZON_ROLE_MAP[ZON_ROLE.ELE]})
617
+ self._update_schema(**{SZ_CLASS: ZON_ROLE_MAP[ZoneRole.ELE]})
576
618
 
577
619
  elif this.code == Code._3150: # TODO: and this.verb in (I_, RP)?
578
620
  # MIX/ELE don't 3150
579
621
  assert self._SLUG in (
580
622
  None,
581
- ZON_ROLE.RAD,
582
- ZON_ROLE.UFH,
583
- ZON_ROLE.VAL,
623
+ ZoneRole.RAD,
624
+ ZoneRole.UFH,
625
+ ZoneRole.VAL,
584
626
  ), self._SLUG
585
627
 
586
628
  if isinstance(this.src, TrvActuator):
587
- self._update_schema(**{SZ_CLASS: ZON_ROLE_MAP[ZON_ROLE.RAD]})
629
+ self._update_schema(**{SZ_CLASS: ZON_ROLE_MAP[ZoneRole.RAD]})
588
630
  elif isinstance(this.src, BdrSwitch):
589
- self._update_schema(**{SZ_CLASS: ZON_ROLE_MAP[ZON_ROLE.VAL]})
631
+ self._update_schema(**{SZ_CLASS: ZON_ROLE_MAP[ZoneRole.VAL]})
590
632
  elif isinstance(this.src, UfhController):
591
- self._update_schema(**{SZ_CLASS: ZON_ROLE_MAP[ZON_ROLE.UFH]})
633
+ self._update_schema(**{SZ_CLASS: ZON_ROLE_MAP[ZoneRole.UFH]})
592
634
 
593
635
  assert (
594
636
  msg.src is self.ctl or msg.src.type == DEV_TYPE_MAP.UFC
@@ -625,64 +667,76 @@ class Zone(ZoneSchedule, ZoneBase):
625
667
  )
626
668
 
627
669
  # TODO: testing this concept, hoping to learn device_id of UFC
628
- if msg.payload[SZ_ZONE_TYPE] == DEV_ROLE_MAP.UFH:
629
- self._make_cmd(Code._000C, payload=f"{self.idx}{DEV_ROLE_MAP.UFH}")
670
+ # if msg.payload[SZ_ZONE_TYPE] == DEV_ROLE_MAP.UFH:
671
+ # cmd = Command.from_attrs(
672
+ # RQ, self.ctl.id, Code._000C, f"{self.idx}{DEV_ROLE_MAP.UFH}"
673
+ # )
674
+ # self._send_cmd(cmd)
630
675
 
631
676
  # If zone still doesn't have a zone class, maybe eavesdrop?
632
677
  if self._gwy.config.enable_eavesdrop and self._SLUG in (
633
678
  None,
634
- ZON_ROLE.ELE,
679
+ ZoneRole.ELE,
635
680
  ):
636
681
  eavesdrop_zone_type(msg)
637
682
 
638
- def _msg_value(self, *args, **kwargs):
683
+ def _msg_value(self, *args: Any, **kwargs: Any) -> Any:
639
684
  return super()._msg_value(*args, **kwargs, zone_idx=self.idx)
640
685
 
641
686
  @property
642
- def sensor(self) -> None | Device:
687
+ def sensor(self) -> Device | None:
643
688
  return self._sensor
644
689
 
645
690
  @property
646
- def heating_type(self) -> None | str:
647
- if self._SLUG is not None: # isinstance(self, ???)
648
- return ZON_ROLE_MAP[self._SLUG]
691
+ def heating_type(self) -> str | None:
692
+ """Return the type of the zone/DHW (e.g. electric_zone, stored_dhw)."""
693
+
694
+ if self._SLUG is None: # isinstance(self, ???)
695
+ return None
696
+ return ZON_ROLE_MAP[self._SLUG] # type: ignore[no-any-return]
649
697
 
650
698
  @property
651
- def name(self) -> None | str: # 0004
699
+ def name(self) -> str | None: # 0004
652
700
  """Return the name of the zone."""
653
- return self._msg_value(Code._0004, key=SZ_NAME)
701
+
702
+ if self._gwy._zzz:
703
+ msgs = self._gwy._zzz.get(code=Code._0004, src=self._z_id, ctx=self._z_idx)
704
+ return msgs[0].payload.get(SZ_NAME) if msgs else None
705
+
706
+ return self._msg_value(Code._0004, key=SZ_NAME) # type: ignore[no-any-return]
654
707
 
655
708
  @name.setter
656
- def name(self, value) -> None:
657
- """Set the name of the zone."""
658
- self._send_cmd(Command.set_zone_name(self.ctl.id, self.idx, value))
709
+ def name(self, value: str) -> None:
710
+ raise NotImplementedError("The setter has been deprecated, use: .set_name()")
659
711
 
660
712
  @property
661
- def config(self) -> None | dict: # 000A
662
- return self._msg_value(Code._000A)
713
+ def config(self) -> dict[str, Any] | None: # 000A
714
+ return self._msg_value(Code._000A) # type: ignore[no-any-return]
663
715
 
664
716
  @property
665
- def mode(self) -> None | dict: # 2349
666
- return self._msg_value(Code._2349)
717
+ def mode(self) -> dict[str, Any] | None: # 2349
718
+ return self._msg_value(Code._2349) # type: ignore[no-any-return]
667
719
 
668
720
  @property
669
- def setpoint(self) -> None | float: # 2309 (2349 is a superset of 2309)
670
- return self._msg_value((Code._2309, Code._2349), key=SZ_SETPOINT)
721
+ def setpoint(self) -> float | None: # 2309 (2349 is a superset of 2309)
722
+ return self._msg_value((Code._2309, Code._2349), key=SZ_SETPOINT) # type: ignore[no-any-return]
671
723
 
672
- @setpoint.setter
673
- def setpoint(self, value) -> None: # 000A/2309
724
+ @setpoint.setter # TODO: can value be None?
725
+ def setpoint(self, value: float) -> None: # 000A/2309
674
726
  """Set the target temperature, until the next scheduled setpoint."""
727
+
675
728
  if value is None:
676
- self.reset_mode()
677
- else:
678
- self._send_cmd(Command.set_zone_setpoint(self.ctl.id, self.idx, value))
729
+ return self.reset_mode()
730
+
731
+ cmd = Command.set_zone_setpoint(self.ctl.id, self.idx, value)
732
+ self._gwy.send_cmd(cmd, priority=Priority.HIGH)
679
733
 
680
734
  @property
681
- def temperature(self) -> None | float: # 30C9
682
- return self._msg_value(Code._30C9, key=SZ_TEMPERATURE)
735
+ def temperature(self) -> float | None: # 30C9
736
+ return self._msg_value(Code._30C9, key=SZ_TEMPERATURE) # type: ignore[no-any-return]
683
737
 
684
738
  @property
685
- def heat_demand(self) -> None | float: # 3150
739
+ def heat_demand(self) -> float | None: # 3150
686
740
  """Return the zone's heat demand, estimated from its devices' heat demand."""
687
741
  demands = [
688
742
  d.heat_demand
@@ -692,28 +746,29 @@ class Zone(ZoneSchedule, ZoneBase):
692
746
  return _transform(max(demands + [0])) if demands else None
693
747
 
694
748
  @property
695
- def window_open(self) -> None | bool: # 12B0
749
+ def window_open(self) -> bool | None: # 12B0
696
750
  """Return an estimate of the zone's current window_open state."""
697
- return self._msg_value(Code._12B0, key=SZ_WINDOW_OPEN)
751
+ return self._msg_value(Code._12B0, key=SZ_WINDOW_OPEN) # type: ignore[no-any-return]
698
752
 
699
- def _get_temp(self) -> Future:
753
+ def _get_temp(self) -> asyncio.Task[Packet] | None:
700
754
  """Get the zone's latest temp from the Controller."""
701
755
  return self._send_cmd(Command.get_zone_temp(self.ctl.id, self.idx))
702
756
 
703
- def reset_config(self) -> Future: # 000A
757
+ def reset_config(self) -> asyncio.Task[Packet]: # 000A
704
758
  """Reset the zone's parameters to their default values."""
705
759
  return self.set_config()
706
760
 
707
761
  def set_config(
708
762
  self,
709
763
  *,
710
- min_temp=5,
711
- max_temp=35,
764
+ min_temp: float = 5,
765
+ max_temp: float = 35,
712
766
  local_override: bool = False,
713
767
  openwindow_function: bool = False,
714
768
  multiroom_mode: bool = False,
715
- ) -> Future:
769
+ ) -> asyncio.Task[Packet]:
716
770
  """Set the zone's parameters (min_temp, max_temp, etc.)."""
771
+
717
772
  cmd = Command.set_zone_config(
718
773
  self.ctl.id,
719
774
  self.idx,
@@ -723,29 +778,41 @@ class Zone(ZoneSchedule, ZoneBase):
723
778
  openwindow_function=openwindow_function,
724
779
  multiroom_mode=multiroom_mode,
725
780
  )
726
- return self._send_cmd(cmd)
781
+ return self._gwy.send_cmd(cmd, priority=Priority.HIGH)
727
782
 
728
- def reset_mode(self) -> Future: # 2349
783
+ def reset_mode(self) -> asyncio.Task[Packet]: # 2349
729
784
  """Revert the zone to following its schedule."""
730
785
  return self.set_mode(mode=ZON_MODE_MAP.FOLLOW)
731
786
 
732
- def set_frost_mode(self) -> Future: # 2349
787
+ def set_frost_mode(self) -> asyncio.Task[Packet]: # 2349
733
788
  """Set the zone to the lowest possible setpoint, indefinitely."""
734
789
  return self.set_mode(mode=ZON_MODE_MAP.PERMANENT, setpoint=5) # TODO
735
790
 
736
- def set_mode(self, *, mode=None, setpoint=None, until=None) -> Future: # 2309/2349
791
+ def set_mode(
792
+ self,
793
+ *,
794
+ mode: str | None = None,
795
+ setpoint: float | None = None,
796
+ until: dt | str | None = None,
797
+ ) -> asyncio.Task[Packet]: # 2309/2349
737
798
  """Override the zone's setpoint for a specified duration, or indefinitely."""
738
- if mode is None and until is None: # Hometronics doesn't support 2349
739
- cmd = Command.set_zone_setpoint(self.ctl.id, self.idx, setpoint)
740
- else:
799
+
800
+ if mode is not None or until is not None: # Hometronics doesn't support 2349
741
801
  cmd = Command.set_zone_mode(
742
802
  self.ctl.id, self.idx, mode=mode, setpoint=setpoint, until=until
743
803
  )
744
- return self._send_cmd(cmd)
804
+ elif setpoint is not None: # unsure if Hometronics supports setpoint of None
805
+ cmd = Command.set_zone_setpoint(self.ctl.id, self.idx, setpoint)
806
+ else:
807
+ raise ValueError("Invalid mode/setpoint")
808
+
809
+ return self._gwy.send_cmd(cmd, priority=Priority.HIGH)
745
810
 
746
- def set_name(self, name) -> Future:
811
+ def set_name(self, name: str) -> asyncio.Task[Packet]:
747
812
  """Set the zone's name."""
748
- return self._send_cmd(Command.set_zone_name(self.ctl.id, self.idx, name))
813
+
814
+ cmd = Command.set_zone_name(self.ctl.id, self.idx, name)
815
+ return self._gwy.send_cmd(cmd, priority=Priority.HIGH)
749
816
 
750
817
  @property
751
818
  def schema(self) -> dict[str, Any]:
@@ -776,7 +843,7 @@ class EleZone(Zone): # BDR91A/T # TODO: 0008/0009/3150
776
843
 
777
844
  # def __init__(self,... # NOTE: since zones are promotable, we can't use this here
778
845
 
779
- _SLUG: str = ZON_ROLE.ELE
846
+ _SLUG: str = ZoneRole.ELE
780
847
  _ROLE_ACTUATORS: str = DEV_ROLE_MAP.ELE
781
848
 
782
849
  def _handle_msg(self, msg: Message) -> None:
@@ -790,15 +857,13 @@ class EleZone(Zone): # BDR91A/T # TODO: 0008/0009/3150
790
857
  raise TypeError("WHAT 2")
791
858
 
792
859
  @property
793
- def heat_demand(self) -> None | float:
860
+ def heat_demand(self) -> float | None:
794
861
  """Return 0 as the zone's heat demand, as electric zones don't call for heat."""
795
862
  return 0
796
863
 
797
864
  @property
798
- def relay_demand(self) -> None | float: # 0008 (NOTE: CTLs wont RP|0008)
799
- # if Code._0008 in self._msgs:
800
- # return self._msgs[Code._0008].payload[SZ_RELAY_DEMAND]
801
- return self._msg_value(Code._0008, key=SZ_RELAY_DEMAND)
865
+ def relay_demand(self) -> float | None: # 0008 (NOTE: CTLs won't RP|0008)
866
+ return self._msg_value(Code._0008, key=SZ_RELAY_DEMAND) # type: ignore[no-any-return]
802
867
 
803
868
  @property
804
869
  def status(self) -> dict[str, Any]:
@@ -816,7 +881,7 @@ class MixZone(Zone): # HM80 # TODO: 0008/0009/3150
816
881
 
817
882
  # def __init__(self,... # NOTE: since zones are promotable, we can't use this here
818
883
 
819
- _SLUG: str = ZON_ROLE.MIX
884
+ _SLUG: str = ZoneRole.MIX
820
885
  _ROLE_ACTUATORS: str = DEV_ROLE_MAP.MIX
821
886
 
822
887
  def _setup_discovery_cmds(self) -> None:
@@ -827,8 +892,8 @@ class MixZone(Zone): # HM80 # TODO: 0008/0009/3150
827
892
  )
828
893
 
829
894
  @property
830
- def mix_config(self) -> dict: # 1030
831
- return self._msg_value(Code._1030)
895
+ def mix_config(self) -> PayDictT._1030:
896
+ return self._msg_value(Code._1030) # type: ignore[no-any-return]
832
897
 
833
898
  @property
834
899
  def params(self) -> dict[str, Any]:
@@ -843,7 +908,7 @@ class RadZone(Zone): # HR92/HR80
843
908
 
844
909
  # def __init__(self,... # NOTE: since zones are promotable, we can't use this here
845
910
 
846
- _SLUG: str = ZON_ROLE.RAD
911
+ _SLUG: str = ZoneRole.RAD
847
912
  _ROLE_ACTUATORS: str = DEV_ROLE_MAP.RAD
848
913
 
849
914
 
@@ -852,11 +917,11 @@ class UfhZone(Zone): # HCC80/HCE80 # TODO: needs checking
852
917
 
853
918
  # def __init__(self,... # NOTE: since zones are promotable, we can't use this here
854
919
 
855
- _SLUG: str = ZON_ROLE.UFH
920
+ _SLUG: str = ZoneRole.UFH
856
921
  _ROLE_ACTUATORS: str = DEV_ROLE_MAP.UFH
857
922
 
858
923
  @property
859
- def heat_demand(self) -> None | float: # 3150
924
+ def heat_demand(self) -> float | None: # 3150
860
925
  """Return the zone's heat demand, estimated from its devices' heat demand."""
861
926
  if (demand := self._msg_value(Code._3150, key=SZ_HEAT_DEMAND)) is not None:
862
927
  return _transform(demand)
@@ -868,11 +933,11 @@ class ValZone(EleZone): # BDR91A/T
868
933
 
869
934
  # def __init__(self,... # NOTE: since zones are promotable, we can't use this here
870
935
 
871
- _SLUG: str = ZON_ROLE.VAL
936
+ _SLUG: str = ZoneRole.VAL
872
937
  _ROLE_ACTUATORS: str = DEV_ROLE_MAP.VAL
873
938
 
874
939
  @property
875
- def heat_demand(self) -> None | float: # 0008 (NOTE: not 3150)
940
+ def heat_demand(self) -> float | None: # 0008 (NOTE: not 3150)
876
941
  """Return the zone's heat demand, using relay demand as a proxy."""
877
942
  return self.relay_demand
878
943
 
@@ -887,10 +952,19 @@ def _transform(valve_pos: float) -> float:
887
952
  return math.floor((valve_pos - t1) * t1 / (t2 - t1) + t0 + 0.5) / 100
888
953
 
889
954
 
890
- ZONE_CLASS_BY_SLUG = class_by_attr(__name__, "_SLUG") # ZON_ROLE.RAD: RadZone
955
+ # e.g. {"RAD": RadZone}
956
+ ZONE_CLASS_BY_SLUG: dict[str, type[DhwZone] | type[Zone]] = class_by_attr(
957
+ __name__, "_SLUG"
958
+ )
891
959
 
892
960
 
893
- def zone_factory(tcs, idx: str, *, msg: Message = None, **schema) -> _ZoneT:
961
+ def zone_factory(
962
+ tcs: _StoredHwT | _MultiZoneT,
963
+ idx: str,
964
+ *,
965
+ msg: Message | None = None,
966
+ **schema: Any,
967
+ ) -> DhwZone | Zone:
894
968
  """Return the zone class for a given zone_idx/klass (Zone or DhwZone).
895
969
 
896
970
  Some zones are promotable to a compatible sub class (e.g. ELE->VAL).
@@ -900,15 +974,15 @@ def zone_factory(tcs, idx: str, *, msg: Message = None, **schema) -> _ZoneT:
900
974
  ctl_addr: Address,
901
975
  idx: str,
902
976
  *,
903
- msg: Message = None,
977
+ msg: Message | None = None,
904
978
  eavesdrop: bool = False,
905
- **schema,
906
- ) -> type[_ZoneT]:
979
+ **schema: Any,
980
+ ) -> type[DhwZone] | type[Zone]:
907
981
  """Return the initial zone class for a given zone_idx/klass (Zone or DhwZone)."""
908
982
 
909
983
  # NOTE: for now, zones are always promoted after instantiation
910
984
 
911
- # # a specified zone class always takes precidence (even if it is wrong)...
985
+ # # a specified zone class always takes precedence (even if it is wrong)...
912
986
  # if cls := ZONE_CLASS_BY_SLUG.get(schema.get(SZ_CLASS)):
913
987
  # _LOGGER.debug(
914
988
  # f"Using an explicitly-defined zone class for: {ctl_addr}_{idx} ({cls})"
@@ -937,10 +1011,16 @@ def zone_factory(tcs, idx: str, *, msg: Message = None, **schema) -> _ZoneT:
937
1011
  )
938
1012
  return Zone
939
1013
 
940
- return best_zon_class(
1014
+ zon: DhwZone | Zone = best_zon_class( # type: ignore[type-var]
941
1015
  tcs.ctl.addr,
942
1016
  idx,
943
1017
  msg=msg,
944
1018
  eavesdrop=tcs._gwy.config.enable_eavesdrop,
945
1019
  **schema,
946
1020
  ).create_from_schema(tcs, idx, **schema)
1021
+
1022
+ # assert isinstance(zon, DhwZone | Zone) # mypy
1023
+ return zon
1024
+
1025
+
1026
+ _ZoneT = TypeVar("_ZoneT", bound="ZoneBase")