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/device/heat.py CHANGED
@@ -1,27 +1,22 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- #
4
- """RAMSES RF - a RAMSES-II protocol decoder & analyser.
2
+ """RAMSES RF - devices from the CH/DHW (heat) domain."""
5
3
 
6
- Heating devices.
7
- """
8
4
  from __future__ import annotations
9
5
 
10
6
  import logging
11
- from typing import TYPE_CHECKING, Any, Callable
7
+ from collections.abc import Callable
8
+ from typing import TYPE_CHECKING, Any, Final
12
9
 
13
- from ..const import (
10
+ from ramses_rf import exceptions as exc
11
+ from ramses_rf.const import (
14
12
  DEV_ROLE_MAP,
15
- DEV_TYPE,
16
13
  DEV_TYPE_MAP,
17
14
  DOMAIN_TYPE_MAP,
18
15
  SZ_DEVICES,
19
16
  SZ_DOMAIN_ID,
20
17
  SZ_HEAT_DEMAND,
21
18
  SZ_PRESSURE,
22
- SZ_PRIORITY,
23
19
  SZ_RELAY_DEMAND,
24
- SZ_RETRIES,
25
20
  SZ_SETPOINT,
26
21
  SZ_TEMPERATURE,
27
22
  SZ_UFH_IDX,
@@ -30,92 +25,100 @@ from ..const import (
30
25
  SZ_ZONE_MASK,
31
26
  SZ_ZONE_TYPE,
32
27
  ZON_ROLE_MAP,
33
- __dev_mode__,
28
+ DevType,
34
29
  )
35
- from ..entity_base import Entity, Parent, class_by_attr
36
- from ..helpers import shrink
37
- from ..protocol.address import NON_DEV_ADDR
38
- from ..protocol.command import Command, Priority, _mk_cmd
39
- from ..protocol.const import SZ_BINDINGS
40
- from ..protocol.exceptions import InvalidPayloadError
41
- from ..protocol.opentherm import (
42
- MSG_ID,
43
- MSG_NAME,
44
- MSG_TYPE,
45
- PARAMS_MSG_IDS,
46
- SCHEMA_MSG_IDS,
47
- STATUS_MSG_IDS,
48
- VALUE,
30
+ from ramses_rf.device import Device
31
+ from ramses_rf.entity_base import Child, Entity, Parent, class_by_attr
32
+ from ramses_rf.helpers import shrink
33
+ from ramses_rf.schemas import SCH_TCS, SZ_ACTUATORS, SZ_CIRCUITS
34
+ from ramses_tx import NON_DEV_ADDR, Command, Priority
35
+ from ramses_tx.const import SZ_NUM_REPEATS, SZ_PRIORITY, MsgId
36
+ from ramses_tx.opentherm import (
37
+ PARAMS_DATA_IDS,
38
+ SCHEMA_DATA_IDS,
39
+ STATUS_DATA_IDS,
40
+ SZ_MSG_ID,
41
+ SZ_MSG_NAME,
42
+ SZ_MSG_TYPE,
43
+ SZ_VALUE,
49
44
  OtMsgType,
50
45
  )
51
- from ..protocol.ramses import CODES_OF_HEAT_DOMAIN_ONLY, CODES_ONLY_FROM_CTL
52
- from ..schemas import SCH_TCS, SZ_ACTUATORS, SZ_CIRCUITS
53
- from .base import BatteryState, Device, DeviceHeat, Fakeable
46
+ from ramses_tx.ramses import CODES_OF_HEAT_DOMAIN_ONLY, CODES_ONLY_FROM_CTL
47
+ from ramses_tx.typed_dicts import PayDictT
54
48
 
55
- # skipcq: PY-W2000
56
- from ..const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
57
- I_,
58
- RP,
59
- RQ,
60
- W_,
49
+ from .base import BatteryState, DeviceHeat, Fakeable
50
+
51
+ from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
61
52
  F9,
62
53
  FA,
63
54
  FC,
64
55
  FF,
56
+ )
57
+
58
+ from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
59
+ I_,
60
+ RP,
61
+ RQ,
62
+ W_,
65
63
  Code,
66
64
  )
67
65
 
66
+ from ramses_tx.const import (
67
+ SZ_BOILER_OUTPUT_TEMP,
68
+ SZ_BOILER_RETURN_TEMP,
69
+ SZ_BOILER_SETPOINT,
70
+ SZ_BURNER_FAILED_STARTS,
71
+ SZ_BURNER_HOURS,
72
+ SZ_BURNER_STARTS,
73
+ SZ_CH_ACTIVE,
74
+ SZ_CH_ENABLED,
75
+ SZ_CH_MAX_SETPOINT,
76
+ SZ_CH_PUMP_HOURS,
77
+ SZ_CH_PUMP_STARTS,
78
+ SZ_CH_SETPOINT,
79
+ SZ_CH_WATER_PRESSURE,
80
+ SZ_COOLING_ACTIVE,
81
+ SZ_COOLING_ENABLED,
82
+ SZ_DHW_ACTIVE,
83
+ SZ_DHW_BLOCKING,
84
+ SZ_DHW_BURNER_HOURS,
85
+ SZ_DHW_BURNER_STARTS,
86
+ SZ_DHW_ENABLED,
87
+ SZ_DHW_FLOW_RATE,
88
+ SZ_DHW_PUMP_HOURS,
89
+ SZ_DHW_PUMP_STARTS,
90
+ SZ_DHW_SETPOINT,
91
+ SZ_DHW_TEMP,
92
+ SZ_FAULT_PRESENT,
93
+ SZ_FLAME_ACTIVE,
94
+ SZ_FLAME_SIGNAL_LOW,
95
+ SZ_MAX_REL_MODULATION,
96
+ SZ_OEM_CODE,
97
+ SZ_OTC_ACTIVE,
98
+ SZ_OUTSIDE_TEMP,
99
+ SZ_REL_MODULATION_LEVEL,
100
+ SZ_SUMMER_MODE,
101
+ )
102
+
68
103
  if TYPE_CHECKING:
69
- from ..protocol import Address, Message
70
- from ..system import Zone
71
-
72
-
73
- SZ_BURNER_HOURS = "burner_hours"
74
- SZ_BURNER_STARTS = "burner_starts"
75
- SZ_BURNER_FAILED_STARTS = "burner_failed_starts"
76
- SZ_CH_PUMP_HOURS = "ch_pump_hours"
77
- SZ_CH_PUMP_STARTS = "ch_pump_starts"
78
- SZ_DHW_BURNER_HOURS = "dhw_burner_hours"
79
- SZ_DHW_BURNER_STARTS = "dhw_burner_starts"
80
- SZ_DHW_PUMP_HOURS = "dhw_pump_hours"
81
- SZ_DHW_PUMP_STARTS = "dhw_pump_starts"
82
- SZ_FLAME_SIGNAL_LOW = "flame_signal_low"
83
-
84
- SZ_BOILER_OUTPUT_TEMP = "boiler_output_temp"
85
- SZ_BOILER_RETURN_TEMP = "boiler_return_temp"
86
- SZ_BOILER_SETPOINT = "boiler_setpoint"
87
- SZ_CH_MAX_SETPOINT = "ch_max_setpoint"
88
- SZ_CH_SETPOINT = "ch_setpoint"
89
- SZ_CH_WATER_PRESSURE = "ch_water_pressure"
90
- SZ_DHW_FLOW_RATE = "dhw_flow_rate"
91
- SZ_DHW_SETPOINT = "dhw_setpoint"
92
- SZ_DHW_TEMP = "dhw_temp"
93
- SZ_MAX_REL_MODULATION = "max_rel_modularion"
94
- SZ_OEM_CODE = "oem_code"
95
- SZ_OUTSIDE_TEMP = "outside_temp"
96
- SZ_REL_MODULATION_LEVEL = "rel_modulation_level"
97
-
98
- SZ_CH_ACTIVE = "ch_active"
99
- SZ_CH_ENABLED = "ch_enabled"
100
- SZ_COOLING_ACTIVE = "cooling_active"
101
- SZ_COOLING_ENABLED = "cooling_enabled"
102
- SZ_DHW_ACTIVE = "dhw_active"
103
- SZ_DHW_BLOCKING = "dhw_blocking"
104
- SZ_DHW_ENABLED = "dhw_enabled"
105
- SZ_FAULT_PRESENT = "fault_present"
106
- SZ_FLAME_ACTIVE = "flame_active"
107
- SZ_SUMMER_MODE = "summer_mode"
108
- SZ_OTC_ACTIVE = "otc_active"
109
-
110
-
111
- DEV_MODE = __dev_mode__ # and False
104
+ from ramses_rf.system import Evohome, Zone
105
+ from ramses_tx import Address, Message, Packet
106
+ from ramses_tx.opentherm import OtDataId
107
+
108
+
109
+ QOS_LOW = {SZ_PRIORITY: Priority.LOW} # FIXME: deprecate QoS in kwargs
110
+ QOS_MID = {SZ_PRIORITY: Priority.HIGH} # FIXME: deprecate QoS in kwargs
111
+ QOS_MAX = {SZ_PRIORITY: Priority.HIGH, SZ_NUM_REPEATS: 3} # FIXME: deprecate QoS...
112
+
113
+ #
114
+ # NOTE: All debug flags should be False for deployment to end-users
115
+ _DBG_ENABLE_DEPRECATION: Final[bool] = False
116
+ _DBG_EXTRA_OTB_DISCOVERY: Final[bool] = False
112
117
 
113
118
  _LOGGER = logging.getLogger(__name__)
114
- if DEV_MODE:
115
- _LOGGER.setLevel(logging.DEBUG)
116
119
 
117
120
 
118
- class Actuator(Fakeable, DeviceHeat): # 3EF0, 3EF1 (for 10:/13:)
121
+ class Actuator(DeviceHeat): # 3EF0, 3EF1 (for 10:/13:)
119
122
  # .I --- 13:109598 --:------ 13:109598 3EF0 003 00C8FF # event-driven, 00/C8
120
123
  # RP --- 13:109598 18:002563 --:------ 0008 002 00C8 # 00/C8, as abobe
121
124
  # RP --- 13:109598 18:002563 --:------ 3EF1 007 0000BF-00BFC8FF # 00/C8, as above
@@ -127,10 +130,10 @@ class Actuator(Fakeable, DeviceHeat): # 3EF0, 3EF1 (for 10:/13:)
127
130
  # RP --- 10:138926 34:010253 --:------ 3EF0 006 002E11-0000FF # 10:s only RP
128
131
  # .I --- 13:209679 --:------ 13:209679 3EF0 003 00C8FF # 13:s only I
129
132
 
130
- ACTUATOR_CYCLE = "actuator_cycle"
131
- ACTUATOR_ENABLED = "actuator_enabled" # boolean
132
- ACTUATOR_STATE = "actuator_state"
133
- MODULATION_LEVEL = "modulation_level" # percentage (0.0-1.0)
133
+ ACTUATOR_CYCLE: Final = "actuator_cycle"
134
+ ACTUATOR_ENABLED: Final = "actuator_enabled" # boolean
135
+ ACTUATOR_STATE: Final = "actuator_state"
136
+ MODULATION_LEVEL: Final = "modulation_level" # percentage (0.0-1.0)
134
137
 
135
138
  def _handle_msg(self, msg: Message) -> None: # NOTE: active
136
139
  super()._handle_msg(msg)
@@ -138,22 +141,22 @@ class Actuator(Fakeable, DeviceHeat): # 3EF0, 3EF1 (for 10:/13:)
138
141
  if isinstance(self, OtbGateway):
139
142
  return
140
143
 
141
- if (
142
- msg.code == Code._3EF0
143
- and msg.verb == I_ # will be a 13:
144
- and not self._faked
145
- and not self._gwy.config.disable_discovery
146
- and not self._gwy.config.disable_sending
147
- ):
148
- # self._make_cmd(Code._0008, qos={SZ_PRIORITY: Priority.LOW, SZ_RETRIES: 1})
149
- self._make_cmd(Code._3EF1, qos={SZ_PRIORITY: Priority.LOW, SZ_RETRIES: 1})
144
+ if self._gwy.config.disable_discovery:
145
+ return
146
+
147
+ # TODO: why are we doing this here? Should simply use dscovery poller!
148
+ if msg.code == Code._3EF0 and msg.verb == I_ and not self.is_faked:
149
+ # lf._send_cmd(Command.get_relay_demand(self.id), qos=QOS_LOW)
150
+ self._send_cmd(
151
+ Command.from_attrs(RQ, self.id, Code._3EF1, "00"), **QOS_LOW
152
+ ) # actuator cycle
150
153
 
151
154
  @property
152
- def actuator_cycle(self) -> None | dict: # 3EF1
155
+ def actuator_cycle(self) -> dict | None: # 3EF1
153
156
  return self._msg_value(Code._3EF1)
154
157
 
155
158
  @property
156
- def actuator_state(self) -> None | dict: # 3EF0
159
+ def actuator_state(self) -> dict | None: # 3EF0
157
160
  return self._msg_value(Code._3EF0)
158
161
 
159
162
  @property
@@ -166,11 +169,10 @@ class Actuator(Fakeable, DeviceHeat): # 3EF0, 3EF1 (for 10:/13:)
166
169
 
167
170
 
168
171
  class HeatDemand(DeviceHeat): # 3150
169
-
170
- HEAT_DEMAND = SZ_HEAT_DEMAND # percentage valve open (0.0-1.0)
172
+ HEAT_DEMAND: Final = SZ_HEAT_DEMAND # percentage valve open (0.0-1.0)
171
173
 
172
174
  @property
173
- def heat_demand(self) -> None | float: # 3150
175
+ def heat_demand(self) -> float | None: # 3150
174
176
  return self._msg_value(Code._3150, key=self.HEAT_DEMAND)
175
177
 
176
178
  @property
@@ -182,11 +184,10 @@ class HeatDemand(DeviceHeat): # 3150
182
184
 
183
185
 
184
186
  class Setpoint(DeviceHeat): # 2309
185
-
186
- SETPOINT = SZ_SETPOINT # degrees Celsius
187
+ SETPOINT: Final = SZ_SETPOINT # degrees Celsius
187
188
 
188
189
  @property
189
- def setpoint(self) -> None | float: # 2309
190
+ def setpoint(self) -> float | None: # 2309
190
191
  return self._msg_value(Code._2309, key=self.SETPOINT)
191
192
 
192
193
  @property
@@ -197,35 +198,22 @@ class Setpoint(DeviceHeat): # 2309
197
198
  }
198
199
 
199
200
 
200
- class Weather(Fakeable, DeviceHeat): # 0002
201
-
202
- TEMPERATURE = SZ_TEMPERATURE # degrees Celsius
203
-
204
- def _bind(self):
205
- #
206
- #
207
- #
208
-
209
- def callback(msg):
210
- pass
211
-
212
- super()._bind()
213
- self._bind_request(Code._0002, callback=callback)
201
+ class Weather(DeviceHeat): # 0002
202
+ TEMPERATURE: Final = SZ_TEMPERATURE # TODO: deprecate
214
203
 
215
204
  @property
216
- def temperature(self) -> None | float: # 0002
217
- return self._msg_value(Code._0002, key=self.TEMPERATURE)
205
+ def temperature(self) -> float | None: # 0002
206
+ return self._msg_value(Code._0002, key=SZ_TEMPERATURE)
218
207
 
219
- # @check_faking_enabled
220
208
  @temperature.setter
221
- def temperature(self, value) -> None: # 0002
222
- if not self._faked:
223
- raise RuntimeError(f"Faking is not enabled for {self}")
209
+ def temperature(self, value: float | None) -> None:
210
+ """Fake the outdoor temperature of the sensor."""
211
+
212
+ if not self.is_faked:
213
+ raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
214
+
224
215
  cmd = Command.put_outdoor_temp(self.id, value)
225
- # cmd = Command.put_zone_temp(
226
- # self._gwy.hgi.id if self == self._gwy.hgi._faked_thm else self.id, value
227
- # )
228
- self._send_cmd(cmd)
216
+ self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)
229
217
 
230
218
  @property
231
219
  def status(self) -> dict[str, Any]:
@@ -235,7 +223,11 @@ class Weather(Fakeable, DeviceHeat): # 0002
235
223
  }
236
224
 
237
225
 
238
- class RelayDemand(Fakeable, DeviceHeat): # 0008
226
+ class RelayDemand(DeviceHeat): # 0008
227
+ # .I --- 01:054173 --:------ 01:054173 1FC9 018 03-0008-04D39D FC-3B00-04D39D 03-1FC9-04D39D
228
+ # .W --- 13:123456 01:054173 --:------ 1FC9 006 00-3EF0-35E240
229
+ # .I --- 01:054173 13:123456 --:------ 1FC9 006 00-FFFF-04D39D
230
+
239
231
  # Some either 00/C8, others 00-C8
240
232
  # .I --- 01:145038 --:------ 01:145038 0008 002 0314 # ZON valve zone (ELE too?)
241
233
  # .I --- 01:145038 --:------ 01:145038 0008 002 F914 # HTG valve
@@ -245,72 +237,16 @@ class RelayDemand(Fakeable, DeviceHeat): # 0008
245
237
  # RP --- 13:109598 18:199952 --:------ 0008 002 0000
246
238
  # RP --- 13:109598 18:199952 --:------ 0008 002 00C8
247
239
 
248
- RELAY_DEMAND = SZ_RELAY_DEMAND # percentage (0.0-1.0)
240
+ RELAY_DEMAND: Final = SZ_RELAY_DEMAND # percentage (0.0-1.0)
249
241
 
250
242
  def _setup_discovery_cmds(self) -> None:
251
243
  super()._setup_discovery_cmds()
252
244
 
253
- if not self._faked: # discover_flag & Discover.STATUS and
245
+ if not self.is_faked: # discover_flag & Discover.STATUS and
254
246
  self._add_discovery_cmd(Command.get_relay_demand(self.id), 60 * 15)
255
247
 
256
- def _handle_msg(self, msg: Message) -> None: # NOTE: active
257
- if msg.src.id == self.id:
258
- super()._handle_msg(msg)
259
- return
260
-
261
- if (
262
- self._gwy.config.disable_sending
263
- or not self._faked
264
- or self._child_id is None
265
- or self._child_id
266
- not in (
267
- v for k, v in msg.payload.items() if k in (SZ_DOMAIN_ID, SZ_ZONE_IDX)
268
- )
269
- ):
270
- return
271
-
272
- if msg.code == Code._3EF0 and msg.verb == I_: # NOT RP
273
- # should't use for RP as RQ's might be polled quite often
274
- cmd = Command.get_relay_demand(self.id)
275
- self._send_cmd(cmd, qos={SZ_PRIORITY: Priority.LOW, SZ_RETRIES: 1})
276
-
277
- # elif msg.code == Code._0009: # can only be I, from a controller
278
- # elif msg.code == Code._3B00...:
279
-
280
- if not self._faked or msg.verb != RQ: # duplicated, above
281
- return
282
-
283
- # TODO: handle relay_failsafe, reply to RQs
284
- if msg.code == Code._0008 and msg.verb == RQ: # NOTE: WIP for FAKING
285
- # 076 I --- 01:054173 --:------ 01:054173 0008 002 037C
286
- mod_level = msg.payload[self.RELAY_DEMAND]
287
- if mod_level is not None:
288
- mod_level = 1.0 if mod_level > 0 else 0
289
-
290
- cmd = Command.put_actuator_state(self.id, mod_level)
291
- qos = {SZ_PRIORITY: Priority.HIGH, SZ_RETRIES: 3}
292
- [self._send_cmd(cmd, **qos) for _ in range(1)] # type: ignore[func-returns-value]
293
-
294
- elif msg.code == Code._3EF1 and msg.verb == RQ: # NOTE: WIP for FAKING
295
- mod_level = 1.0
296
-
297
- cmd = Command.put_actuator_cycle(self.id, msg.src.id, mod_level, 600, 600)
298
- qos = {SZ_PRIORITY: Priority.HIGH, SZ_RETRIES: 3}
299
- [self._send_cmd(cmd, **qos) for _ in range(1)] # type: ignore[func-returns-value]
300
-
301
- def _bind(self):
302
- # .I --- 01:054173 --:------ 01:054173 1FC9 018 03-0008-04D39D FC-3B00-04D39D 03-1FC9-04D39D
303
- # .W --- 13:123456 01:054173 --:------ 1FC9 006 00-3EF0-35E240
304
- # .I --- 01:054173 13:123456 --:------ 1FC9 006 00-FFFF-04D39D
305
-
306
- def callback(msg):
307
- pass
308
-
309
- super()._bind()
310
- self._bind_waiting(Code._3EF0, callback=callback)
311
-
312
248
  @property
313
- def relay_demand(self) -> None | float: # 0008
249
+ def relay_demand(self) -> float | None: # 0008
314
250
  return self._msg_value(Code._0008, key=self.RELAY_DEMAND)
315
251
 
316
252
  @property
@@ -321,32 +257,22 @@ class RelayDemand(Fakeable, DeviceHeat): # 0008
321
257
  }
322
258
 
323
259
 
324
- class DhwTemperature(Fakeable, DeviceHeat): # 1260
325
-
326
- TEMPERATURE = SZ_TEMPERATURE # degrees Celsius
327
-
328
- def _bind(self):
329
- #
330
- #
331
- #
332
-
333
- def callback(msg):
334
- self.set_parent(msg.src, child_id=FA, is_sensor=True)
335
-
336
- super()._bind()
337
- self._bind_request(Code._1260, callback=callback)
260
+ class DhwTemperature(DeviceHeat): # 1260
261
+ TEMPERATURE: Final = SZ_TEMPERATURE # TODO: deprecate
338
262
 
339
263
  @property
340
- def temperature(self) -> None | float: # 1260
341
- return self._msg_value(Code._1260, key=self.TEMPERATURE)
264
+ def temperature(self) -> float | None: # 1260
265
+ return self._msg_value(Code._1260, key=SZ_TEMPERATURE)
342
266
 
343
- # @check_faking_enabled
344
267
  @temperature.setter
345
- def temperature(self, value) -> None: # 1260
346
- if not self._faked:
347
- raise RuntimeError(f"Faking is not enabled for {self}")
348
- self._send_cmd(Command.put_dhw_temp(value))
349
- # lf._send_cmd(Command.get_dhw_temp(self.ctl.id, self.zone.idx))
268
+ def temperature(self, value: float | None) -> None:
269
+ """Fake the DHW temperature of the sensor."""
270
+
271
+ if not self.is_faked:
272
+ raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
273
+
274
+ cmd = Command.put_dhw_temp(self.id, value)
275
+ self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)
350
276
 
351
277
  @property
352
278
  def status(self) -> dict[str, Any]:
@@ -356,31 +282,23 @@ class DhwTemperature(Fakeable, DeviceHeat): # 1260
356
282
  }
357
283
 
358
284
 
359
- class Temperature(Fakeable, DeviceHeat): # 30C9
360
- def _bind(self):
361
- # .I --- 34:145039 --:------ 34:145039 1FC9 012 00-30C9-8A368F 00-1FC9-8A368F
362
- # .W --- 01:054173 34:145039 --:------ 1FC9 006 03-2309-04D39D # real CTL
363
- # .I --- 34:145039 01:054173 --:------ 1FC9 006 00-30C9-8A368F
364
-
365
- def callback(msg): # TODO: needs work
366
- """Use the accept pkt to determine the zone/domain id."""
367
- child_id = msg.payload[SZ_BINDINGS][0][0]
368
- self.set_parent(msg.src, child_id=child_id, is_sensor=True)
369
-
370
- super()._bind()
371
- self._bind_request(Code._30C9, callback=callback)
372
-
285
+ class Temperature(DeviceHeat): # 30C9
286
+ # .I --- 34:145039 --:------ 34:145039 1FC9 012 00-30C9-8A368F 00-1FC9-8A368F
287
+ # .W --- 01:054173 34:145039 --:------ 1FC9 006 03-2309-04D39D # real CTL
288
+ # .I --- 34:145039 01:054173 --:------ 1FC9 006 00-30C9-8A368F
373
289
  @property
374
- def temperature(self) -> None | float: # degrees Celsius
290
+ def temperature(self) -> float | None: # 30C9
375
291
  return self._msg_value(Code._30C9, key=SZ_TEMPERATURE)
376
292
 
377
- # @check_faking_enabled
378
293
  @temperature.setter
379
- def temperature(self, value) -> None:
380
- if not self._faked:
381
- raise RuntimeError(f"Faking is not enabled for {self}")
382
- self._send_cmd(Command.put_sensor_temp(self.id, value))
383
- # lf._send_cmd(Command.get_zone_temp(self.ctl.id, self.zone.idx))
294
+ def temperature(self, value: float | None) -> None:
295
+ """Fake the indoor temperature of the sensor."""
296
+
297
+ if not self.is_faked:
298
+ raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
299
+
300
+ cmd = Command.put_sensor_temp(self.id, value)
301
+ self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)
384
302
 
385
303
  @property
386
304
  def status(self) -> dict[str, Any]:
@@ -393,15 +311,19 @@ class Temperature(Fakeable, DeviceHeat): # 30C9
393
311
  class RfgGateway(DeviceHeat): # RFG (30:)
394
312
  """The RFG100 base class."""
395
313
 
396
- _SLUG: str = DEV_TYPE.RFG
314
+ _SLUG = DevType.RFG
315
+ _STATE_ATTR = None
397
316
 
398
317
 
399
318
  class Controller(DeviceHeat): # CTL (01):
400
319
  """The Controller base class."""
401
320
 
402
- _SLUG: str = DEV_TYPE.CTL
321
+ HEAT_DEMAND: Final = SZ_HEAT_DEMAND
403
322
 
404
- def __init__(self, *args, **kwargs) -> None:
323
+ _SLUG = DevType.CTL
324
+ _STATE_ATTR = HEAT_DEMAND
325
+
326
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
405
327
  super().__init__(*args, **kwargs)
406
328
 
407
329
  # self.ctl = None
@@ -413,10 +335,12 @@ class Controller(DeviceHeat): # CTL (01):
413
335
 
414
336
  self.tcs._handle_msg(msg)
415
337
 
416
- def _make_tcs_controller(self, *, msg=None, **schema) -> None: # CH/DHW
338
+ def _make_tcs_controller(
339
+ self, *, msg: Message | None = None, **schema: Any
340
+ ) -> None: # CH/DHW
417
341
  """Attach a TCS (create/update as required) after passing it any msg."""
418
342
 
419
- def get_system(*, msg=None, **schema) -> Any: # System:
343
+ def get_system(*, msg: Message | None = None, **schema: Any) -> Evohome:
420
344
  """Return a TCS (temperature control system), create it if required.
421
345
 
422
346
  Use the schema to create/update it, then pass it any msg to handle.
@@ -425,7 +349,7 @@ class Controller(DeviceHeat): # CTL (01):
425
349
  If a TCS is created, attach it to this device (which should be a CTL).
426
350
  """
427
351
 
428
- from ..system import system_factory
352
+ from ramses_rf.system import system_factory
429
353
 
430
354
  schema = shrink(SCH_TCS(schema))
431
355
 
@@ -447,17 +371,21 @@ class Controller(DeviceHeat): # CTL (01):
447
371
  class Programmer(Controller): # PRG (23):
448
372
  """The Controller base class."""
449
373
 
450
- _SLUG: str = DEV_TYPE.PRG
374
+ _SLUG = DevType.PRG
451
375
 
452
376
 
453
377
  class UfhController(Parent, DeviceHeat): # UFC (02):
454
378
  """The UFC class, the HCE80 that controls the UFH zones."""
455
379
 
456
- _SLUG: str = DEV_TYPE.UFC
380
+ HEAT_DEMAND: Final = SZ_HEAT_DEMAND
457
381
 
458
- HEAT_DEMAND = SZ_HEAT_DEMAND
382
+ _SLUG = DevType.UFC
383
+ _STATE_ATTR = HEAT_DEMAND
459
384
 
460
- _STATE_ATTR = SZ_HEAT_DEMAND
385
+ _child_id = FA
386
+ _iz_controller = True
387
+
388
+ childs: list[UfhCircuit] # TODO: check (code so complex, not sure if this is true)
461
389
 
462
390
  # 12:27:24.398 067 I --- 02:000921 --:------ 01:191718 3150 002 0360
463
391
  # 12:27:24.546 068 I --- 02:000921 --:------ 01:191718 3150 002 065A
@@ -465,44 +393,38 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
465
393
  # 12:27:24.824 059 I --- 01:191718 --:------ 01:191718 3150 002 FC5C
466
394
  # 12:27:24.857 067 I --- 02:000921 --:------ 02:000921 3150 006 0060-015A-025C
467
395
 
468
- def __init__(self, *args, **kwargs) -> None:
396
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
469
397
  super().__init__(*args, **kwargs)
470
398
 
471
- self._child_id = FA # NOTE: domain_id, HACK: UFC
472
-
473
399
  self.circuit_by_id = {f"{i:02X}": {} for i in range(8)}
474
400
 
475
- self._setpoints: Message = None # type: ignore[assignment]
476
- self._heat_demand: Message = None # type: ignore[assignment]
477
- self._heat_demands: Message = None # type: ignore[assignment]
478
- self._relay_demand: Message = None # type: ignore[assignment]
479
- self._relay_demand_fa: Message = None # type: ignore[assignment]
480
-
481
- self._iz_controller = True
401
+ self._setpoints: Message | None = None
402
+ self._heat_demand: Message | None = None
403
+ self._heat_demands: Message | None = None
404
+ self._relay_demand: Message | None = None
405
+ self._relay_demand_fa: Message | None = None
482
406
 
483
407
  def _setup_discovery_cmds(self) -> None:
484
408
  super()._setup_discovery_cmds()
485
409
 
486
410
  # Only RPs are: 0001, 0005/000C, 10E0, 000A/2309 & 22D0
487
411
 
488
- self._add_discovery_cmd(
489
- _mk_cmd(RQ, Code._0005, f"00{DEV_ROLE_MAP.UFH}", self.id), 60 * 60 * 24
490
- )
412
+ cmd = Command.from_attrs(RQ, self.id, Code._0005, f"00{DEV_ROLE_MAP.UFH}")
413
+ self._add_discovery_cmd(cmd, 60 * 60 * 24)
414
+
491
415
  # TODO: this needs work
492
416
  # if discover_flag & Discover.PARAMS: # only 2309 has any potential?
493
417
  for ufc_idx in self.circuit_by_id:
494
- self._add_discovery_cmd(
495
- _mk_cmd(RQ, Code._000A, ufc_idx, self.id), 60 * 60 * 6
496
- )
497
- self._add_discovery_cmd(
498
- _mk_cmd(RQ, Code._2309, ufc_idx, self.id), 60 * 60 * 6
499
- )
418
+ cmd = Command.get_zone_config(self.id, ufc_idx)
419
+ self._add_discovery_cmd(cmd, 60 * 60 * 6)
420
+
421
+ cmd = Command.get_zone_setpoint(self.id, ufc_idx)
422
+ self._add_discovery_cmd(cmd, 60 * 60 * 6)
500
423
 
501
- for ufc_idx in range(8): # type: ignore[assignment]
424
+ for ufc_idx in range(8):
502
425
  payload = f"{ufc_idx:02X}{DEV_ROLE_MAP.UFH}"
503
- self._add_discovery_cmd(
504
- _mk_cmd(RQ, Code._000C, payload, self.id), 60 * 60 * 24
505
- )
426
+ cmd = Command.from_attrs(RQ, self.id, Code._000C, payload)
427
+ self._add_discovery_cmd(cmd, 60 * 60 * 24)
506
428
 
507
429
  def _handle_msg(self, msg: Message) -> None:
508
430
  super()._handle_msg(msg)
@@ -521,8 +443,12 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
521
443
  ufh_idx = f"{idx:02X}"
522
444
  if not flag:
523
445
  self.circuit_by_id[ufh_idx] = {SZ_ZONE_IDX: None}
524
- elif SZ_ZONE_IDX not in self.circuit_by_id[ufh_idx]:
525
- self._make_cmd(Code._000C, payload=f"{ufh_idx}{DEV_ROLE_MAP.UFH}")
446
+ # FIXME: this causing tests to fail when read-only protocol
447
+ # elif SZ_ZONE_IDX not in self.circuit_by_id[ufh_idx]:
448
+ # cmd = Command.from_attrs(
449
+ # RQ, self.ctl.id, Code._000C, f"{ufh_idx}{DEV_ROLE_MAP.UFH}"
450
+ # )
451
+ # self._send_cmd(cmd)
526
452
 
527
453
  elif msg.code == Code._0008: # relay_demand, TODO: use msg DB?
528
454
  if msg.payload.get(SZ_DOMAIN_ID) == FC:
@@ -545,7 +471,7 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
545
471
  # child_id=msg.payload[SZ_ZONE_IDX],
546
472
  )
547
473
 
548
- elif msg.code == Code._22C9: # ufh_setpoints
474
+ elif msg.code == Code._22C9: # setpoint_bounds
549
475
  # .I --- 02:017205 --:------ 02:017205 22C9 024 00076C0A280101076C0A28010...
550
476
  # .I --- 02:017205 --:------ 02:017205 22C9 006 04076C0A2801
551
477
  self._setpoints = msg
@@ -557,6 +483,7 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
557
483
  self._heat_demand = msg
558
484
  elif (
559
485
  (zone_idx := msg.payload.get(SZ_ZONE_IDX))
486
+ and isinstance(msg.dst, Device)
560
487
  and (tcs := msg.dst.tcs)
561
488
  and (zone := tcs.zone_by_idx.get(zone_idx))
562
489
  ):
@@ -567,7 +494,10 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
567
494
 
568
495
  # "0008|FA/FC", "22C9|array", "22D0|none", "3150|ZZ/array(/FC?)"
569
496
 
570
- def get_circuit(self, cct_idx, *, msg=None, **schema) -> Any:
497
+ # TODO: should be a private method
498
+ def get_circuit(
499
+ self, cct_idx: str, *, msg: Message | None = None, **schema: Any
500
+ ) -> Any:
571
501
  """Return a UFH circuit, create it if required.
572
502
 
573
503
  First, use the schema to create/update it, then pass it any msg to handle.
@@ -578,7 +508,7 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
578
508
 
579
509
  schema = {} # shrink(SCH_CCT(schema))
580
510
 
581
- cct = self.child_by_id.get(cct_idx)
511
+ cct: UfhCircuit = self.child_by_id.get(cct_idx)
582
512
  if not cct:
583
513
  cct = UfhCircuit(self, cct_idx)
584
514
  self.child_by_id[cct_idx] = cct
@@ -596,26 +526,26 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
596
526
  # return self.circuit_by_id
597
527
 
598
528
  @property
599
- def heat_demand(self) -> None | float: # 3150|FC (there is also 3150|FA)
529
+ def heat_demand(self) -> float | None: # 3150|FC (there is also 3150|FA)
600
530
  return self._msg_value_msg(self._heat_demand, key=self.HEAT_DEMAND)
601
531
 
602
532
  @property
603
- def heat_demands(self) -> None | dict: # 3150|ufh_idx array
533
+ def heat_demands(self) -> dict | None: # 3150|ufh_idx array
604
534
  # return self._heat_demands.payload if self._heat_demands else None
605
535
  return self._msg_value_msg(self._heat_demands)
606
536
 
607
537
  @property
608
- def relay_demand(self) -> None | dict: # 0008|FC
538
+ def relay_demand(self) -> dict | None: # 0008|FC
609
539
  return self._msg_value_msg(self._relay_demand, key=SZ_RELAY_DEMAND)
610
540
 
611
541
  @property
612
- def relay_demand_fa(self) -> None | dict: # 0008|FA
542
+ def relay_demand_fa(self) -> dict | None: # 0008|FA
613
543
  return self._msg_value_msg(self._relay_demand_fa, key=SZ_RELAY_DEMAND)
614
544
 
615
545
  @property
616
- def setpoints(self) -> None | dict: # 22C9|ufh_idx array
546
+ def setpoints(self) -> dict | None: # 22C9|ufh_idx array
617
547
  if self._setpoints is None:
618
- return
548
+ return None
619
549
 
620
550
  return {
621
551
  c[SZ_UFH_IDX]: {
@@ -648,17 +578,15 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
648
578
  }
649
579
 
650
580
 
651
- class DhwSensor(DhwTemperature, BatteryState): # DHW (07): 10A0, 1260
581
+ class DhwSensor(DhwTemperature, BatteryState, Fakeable): # DHW (07): 10A0, 1260
652
582
  """The DHW class, such as a CS92."""
653
583
 
654
- _SLUG: str = DEV_TYPE.DHW
584
+ DHW_PARAMS: Final = "dhw_params"
655
585
 
656
- DHW_PARAMS = "dhw_params"
657
- TEMPERATURE = SZ_TEMPERATURE
658
-
659
- _STATE_ATTR = SZ_TEMPERATURE
586
+ _SLUG: str = DevType.DHW
587
+ _STATE_ATTR = DhwTemperature.TEMPERATURE
660
588
 
661
- def __init__(self, *args, **kwargs) -> None:
589
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
662
590
  super().__init__(*args, **kwargs)
663
591
 
664
592
  self._child_id = FA # NOTE: domain_id
@@ -666,13 +594,20 @@ class DhwSensor(DhwTemperature, BatteryState): # DHW (07): 10A0, 1260
666
594
  def _handle_msg(self, msg: Message) -> None: # NOTE: active
667
595
  super()._handle_msg(msg)
668
596
 
669
- # The following is required, as CTLs don't send such every sync_cycle
670
- if msg.code == Code._1260 and self.ctl and not self._gwy.config.disable_sending:
597
+ if self._gwy.config.disable_discovery:
598
+ return
599
+
600
+ # TODO: why are we doing this here? Should simply use dscovery poller!
601
+ # The following is required, as CTLs don't send spontaneously
602
+ if msg.code == Code._1260 and self.ctl:
671
603
  # update the controller DHW temp
672
604
  self._send_cmd(Command.get_dhw_temp(self.ctl.id))
673
605
 
606
+ async def initiate_binding_process(self) -> Packet:
607
+ return await super()._initiate_binding_process(Code._1260)
608
+
674
609
  @property
675
- def dhw_params(self) -> None | dict: # 10A0
610
+ def dhw_params(self) -> PayDictT._10A0 | None:
676
611
  return self._msg_value(Code._10A0)
677
612
 
678
613
  @property
@@ -683,95 +618,111 @@ class DhwSensor(DhwTemperature, BatteryState): # DHW (07): 10A0, 1260
683
618
  }
684
619
 
685
620
 
686
- class OutSensor(Weather): # OUT: 17
621
+ class OutSensor(Weather, Fakeable): # OUT: 17
687
622
  """The OUT class (external sensor), such as a HB85/HB95."""
688
623
 
689
- _SLUG: str = DEV_TYPE.OUT
690
-
691
624
  # LUMINOSITY = "luminosity" # lux
692
625
  # WINDSPEED = "windspeed" # km/h
693
626
 
627
+ _SLUG = DevType.OUT
694
628
  _STATE_ATTR = SZ_TEMPERATURE
695
629
 
630
+ # async def initiate_binding_process(self) -> Packet:
631
+ # return await super()._initiate_binding_process(...)
696
632
 
633
+
634
+ def _to_msg_id(data_id: OtDataId) -> MsgId:
635
+ return f"{data_id:02X}"
636
+
637
+
638
+ # NOTE: config.use_native_ot should enforces sends, but not reads from _msgz DB
697
639
  class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
698
640
  """The OTB class, specifically an OpenTherm Bridge (R8810A Bridge)."""
699
641
 
700
642
  # see: https://www.opentherm.eu/request-details/?post_ids=2944
701
643
  # see: https://www.automatedhome.co.uk/vbulletin/showthread.php?6400-(New)-cool-mode-in-Evohome
702
644
 
703
- _SLUG: str = DEV_TYPE.OTB
704
-
645
+ _SLUG = DevType.OTB
705
646
  _STATE_ATTR = SZ_REL_MODULATION_LEVEL
706
647
 
707
- OT_TO_RAMSES = {
708
- "00": Code._3EF0, # master/slave status (actuator_state)
709
- "01": Code._22D9, # boiler_setpoint
710
- "0E": Code._3EF0, # max_rel_modulation_level (is a PARAM?)
711
- "11": Code._3EF0, # rel_modulation_level (actuator_state, also Code._3EF1)
712
- "12": Code._1300, # ch_water_pressure
713
- "13": Code._12F0, # dhw_flow_rate
714
- "19": Code._3200, # boiler_output_temp
715
- "1A": Code._1260, # dhw_temp
716
- "1B": Code._1290, # outside_temp
717
- "1C": Code._3210, # boiler_return_temp
718
- "38": Code._10A0, # dhw_setpoint (is a PARAM)
719
- "39": Code._1081, # ch_max_setpoint (is a PARAM)
648
+ OT_TO_RAMSES: dict[MsgId, Code] = { # TODO: move to opentherm.py
649
+ MsgId._00: Code._3EF0, # master/slave status (actuator_state)
650
+ MsgId._01: Code._22D9, # boiler_setpoint
651
+ MsgId._0E: Code._3EF0, # max_rel_modulation_level (is a PARAM?)
652
+ MsgId._11: Code._3EF0, # rel_modulation_level (actuator_state, also Code._3EF1)
653
+ MsgId._12: Code._1300, # ch_water_pressure
654
+ MsgId._13: Code._12F0, # dhw_flow_rate
655
+ MsgId._19: Code._3200, # boiler_output_temp
656
+ MsgId._1A: Code._1260, # dhw_temp
657
+ MsgId._1B: Code._1290, # outside_temp
658
+ MsgId._1C: Code._3210, # boiler_return_temp
659
+ MsgId._38: Code._10A0, # dhw_setpoint (is a PARAM)
660
+ MsgId._39: Code._1081, # ch_max_setpoint (is a PARAM)
720
661
  }
721
- RAMSES_TO_OT = {v: k for k, v in OT_TO_RAMSES.items() if v != Code._3EF0}
662
+ RAMSES_TO_OT: dict[Code, MsgId] = {
663
+ v: k for k, v in OT_TO_RAMSES.items() if v != Code._3EF0
664
+ } # also 10A0?
722
665
 
723
- def __init__(self, *args, **kwargs) -> None:
666
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
724
667
  super().__init__(*args, **kwargs)
725
668
 
726
669
  self._child_id = FC # NOTE: domain_id
727
670
 
728
- self._msgz[str(Code._3220)] = {RP: {}} # self._msgz[Code._3220][RP][msg_id]
671
+ self._msgz[Code._3220] = {RP: {}} # _msgz[Code._3220][RP][msg_id]
729
672
 
730
673
  # lf._use_ot = self._gwy.config.use_native_ot
731
- self._msgs_ot: dict[str, Message] = {}
674
+ self._msgs_ot: dict[MsgId, Message] = {}
732
675
  # lf._msgs_ot_ctl_polled = {}
733
676
 
734
677
  def _setup_discovery_cmds(self) -> None:
735
- def which_cmd(use_native_ot: str, msg_id: str) -> None | Command:
678
+ def which_cmd(use_native_ot: str, msg_id: MsgId) -> Command | None:
736
679
  """Create a OT cmd, or its RAMSES equivalent, depending."""
737
680
  # we know RQ|3220 is an option, question is: use that, or RAMSES or nothing?
738
681
  if use_native_ot in ("always", "prefer"):
739
682
  return Command.get_opentherm_data(self.id, msg_id)
740
683
  if msg_id in self.OT_TO_RAMSES: # is: in ("avoid", "never")
741
- return _mk_cmd(RQ, self.OT_TO_RAMSES[msg_id], "00", self.id)
684
+ return Command.from_attrs(RQ, self.id, self.OT_TO_RAMSES[msg_id], "00")
742
685
  if use_native_ot == "avoid":
743
686
  return Command.get_opentherm_data(self.id, msg_id)
744
687
  return None # use_native_ot == "never"
745
688
 
746
689
  super()._setup_discovery_cmds()
747
690
 
748
- # always send RQ|3EF0 and RQ|3220|00 (status), regardless of use_native_ot
749
- self._add_discovery_cmd(Command.get_opentherm_data(self.id, "00"), 60)
750
- self._add_discovery_cmd(_mk_cmd(RQ, Code._3EF0, "00", self.id), 60)
691
+ # always send at least one of RQ|3EF0 or RQ|3220|00 (status)
692
+ if self._gwy.config.use_native_ot != "never":
693
+ self._add_discovery_cmd(Command.get_opentherm_data(self.id, MsgId._00), 60)
751
694
 
752
- for msg_id in SCHEMA_MSG_IDS: # From OT v2.2: version numbers
753
- if cmd := which_cmd(self._gwy.config.use_native_ot, msg_id):
754
- self._add_discovery_cmd(cmd, 24 * 3600, delay=180)
695
+ if self._gwy.config.use_native_ot != "always":
696
+ self._add_discovery_cmd(
697
+ Command.from_attrs(RQ, self.id, Code._3EF0, "00"), 60
698
+ )
699
+ self._add_discovery_cmd( # NOTE: this code is a WIP
700
+ Command.from_attrs(RQ, self.id, Code._2401, "00"), 60
701
+ )
755
702
 
756
- for msg_id in PARAMS_MSG_IDS: # params or L/T state
757
- if cmd := which_cmd(self._gwy.config.use_native_ot, msg_id):
703
+ for data_id in SCHEMA_DATA_IDS: # From OT v2.2: version numbers
704
+ if cmd := which_cmd(self._gwy.config.use_native_ot, _to_msg_id(data_id)):
705
+ self._add_discovery_cmd(cmd, 6 * 3600, delay=180)
706
+
707
+ for data_id in PARAMS_DATA_IDS: # params or L/T state
708
+ if cmd := which_cmd(self._gwy.config.use_native_ot, _to_msg_id(data_id)):
758
709
  self._add_discovery_cmd(cmd, 3600, delay=90)
759
710
 
760
- for msg_id in STATUS_MSG_IDS: # except "00", see above
761
- if msg_id == "00":
711
+ for data_id in STATUS_DATA_IDS: # except "00", see above
712
+ if data_id == 0x00:
762
713
  continue
763
- if cmd := which_cmd(self._gwy.config.use_native_ot, msg_id):
714
+ if cmd := which_cmd(self._gwy.config.use_native_ot, _to_msg_id(data_id)):
764
715
  self._add_discovery_cmd(cmd, 300, delay=15)
765
716
 
766
- if DEV_MODE: # TODO: these are WIP, but do vary in payload
717
+ if _DBG_EXTRA_OTB_DISCOVERY: # TODO: these are WIP, but do vary in payload
767
718
  for code in (
768
719
  Code._2401, # WIP - modulation_level + flags?
769
720
  Code._3221, # R8810A/20A
770
721
  Code._3223, # R8810A/20A
771
722
  ):
772
- self._add_discovery_cmd(_mk_cmd(RQ, code, "00", self.id), 60)
723
+ self._add_discovery_cmd(Command.from_attrs(RQ, self.id, code, "00"), 60)
773
724
 
774
- if False and DEV_MODE: # TODO: these are WIP, appear FIXED in payload
725
+ if _DBG_EXTRA_OTB_DISCOVERY: # TODO: these are WIP, appear FIXED in payload
775
726
  for code in (
776
727
  Code._0150, # payload always "000000", R8820A only?
777
728
  Code._1098, # payload always "00C8", R8820A only?
@@ -781,89 +732,92 @@ class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
781
732
  Code._2410, # payload always "000000000000000000000000010000000100000C"
782
733
  Code._2420, # payload always "0000001000000...
783
734
  ): # TODO: to test against BDR91T
784
- cmd = _mk_cmd(RQ, code, "00", self.id)
785
- self._add_discovery_cmd(cmd, 300, delay=300)
735
+ self._add_discovery_cmd(
736
+ Command.from_attrs(RQ, self.id, code, "00"), 300
737
+ )
786
738
 
787
739
  def _handle_msg(self, msg: Message) -> None:
788
740
  super()._handle_msg(msg)
789
741
 
790
- (self._handle_3220 if msg.code == Code._3220 else self._handle_code)(msg)
742
+ if msg.verb not in (I_, RP):
743
+ return
744
+
745
+ if msg.code == Code._3220:
746
+ self._handle_3220(msg)
747
+ elif msg.code in self.RAMSES_TO_OT:
748
+ self._handle_code(msg)
791
749
 
792
750
  def _handle_3220(self, msg: Message) -> None:
793
- if msg.payload[MSG_TYPE] == OtMsgType.RESERVED: # workaround
751
+ """Handle 3220-based messages."""
752
+
753
+ # NOTE: Reserved msgs have null data, but that msg_id may later be OK!
754
+ if msg.payload[SZ_MSG_TYPE] == OtMsgType.RESERVED:
755
+ return
756
+
757
+ # NOTE: Some msgs have invalid data, but that msg_id may later be OK!
758
+ if msg.payload.get(SZ_VALUE) is None:
794
759
  return
795
760
 
796
- msg_id = f"{msg.payload[MSG_ID]:02X}"
761
+ # msg_id is int in msg payload/opentherm.py, but MsgId (str) is in this module
762
+ msg_id = _to_msg_id(msg.payload[SZ_MSG_ID])
797
763
  self._msgs_ot[msg_id] = msg
798
764
 
799
- if DEV_MODE: # here to follow state changes
800
- self._send_cmd(_mk_cmd(RQ, Code._2401, "00", self.id)) # oem code
801
- if msg_id != "73":
802
- self._send_cmd(Command.get_opentherm_data(self.id, "73")) # oem code
765
+ if not _DBG_ENABLE_DEPRECATION: # FIXME: data gaps
766
+ return
803
767
 
804
- # TODO: this is development code - will be rationalised, eventually
805
- if self._gwy.config.use_native_ot and (
806
- code := self.OT_TO_RAMSES.get(msg_id)
807
- ):
808
- self._send_cmd(_mk_cmd(RQ, code, "00", self.id))
768
+ reset = msg.payload[SZ_MSG_TYPE] not in (
769
+ OtMsgType.DATA_INVALID,
770
+ OtMsgType.UNKNOWN_DATAID,
771
+ OtMsgType.RESERVED, # but some are ?always reserved
772
+ )
773
+ self._deprecate_code_ctx(msg._pkt, ctx=msg_id, reset=reset)
809
774
 
810
- if msg._pkt.payload[6:] == "47AB" or msg._pkt.payload[4:] == "121980":
811
- self.deprecate_cmd(msg._pkt, ctx=msg_id)
775
+ def _handle_code(self, msg: Message) -> None:
776
+ """Handle non-3220-based messages."""
812
777
 
813
- else:
814
- # 18:50:32.524 ... RQ --- 18:013393 10:048122 --:------ 3220 005 0080730000
815
- # 18:50:32.547 ... RP --- 10:048122 18:013393 --:------ 3220 005 00B0730000 # -reserved-
816
- # 18:55:32.601 ... RQ --- 18:013393 10:048122 --:------ 3220 005 0080730000
817
- # 18:55:32.630 ... RP --- 10:048122 18:013393 --:------ 3220 005 00C07300CB # Read-Ack, 'value': 203
818
- reset = msg.payload[MSG_TYPE] not in (
819
- OtMsgType.DATA_INVALID,
820
- OtMsgType.UNKNOWN_DATAID,
821
- # OtMsgType.RESERVED, # some always reserved, others sometimes so
822
- )
823
- self.deprecate_cmd(msg._pkt, ctx=msg_id, reset=reset)
778
+ if msg.code == Code._3EF0 and msg.verb == I_:
779
+ # NOTE: this is development/discovery code # chasing flags
780
+ # self._send_cmd(
781
+ # Command.get_opentherm_data(self.id, MsgId._00), **QOS_MID
782
+ # ) # FIXME: deprecate QoS in kwargs
783
+ return
824
784
 
825
- def _handle_code(self, msg: Message) -> None:
826
- if msg.code == Code._3EF0 and msg.verb == I_: # chasing flags
827
- self._send_cmd(
828
- Command.get_opentherm_data(self.id, "00"),
829
- qos={SZ_PRIORITY: Priority.HIGH, SZ_RETRIES: 1},
830
- )
785
+ if msg.code in (Code._10A0, Code._3EF1):
831
786
  return
832
787
 
833
- if msg.code in (Code._10A0, Code._3EF1) or msg.len != 3:
788
+ if not _DBG_ENABLE_DEPRECATION: # FIXME: data gaps
834
789
  return
835
790
 
791
+ # TODO: can be temporarily 7FFF?
836
792
  if msg._pkt.payload[2:] == "7FFF" or (
837
793
  msg.code == Code._1300 and msg._pkt.payload[2:] == "09F6"
838
- ):
839
- self.deprecate_cmd(msg._pkt)
794
+ ): # latter is CH water pressure
795
+ self._deprecate_code_ctx(msg._pkt)
840
796
  else:
841
- self.deprecate_cmd(msg._pkt, reset=True)
797
+ self._deprecate_code_ctx(msg._pkt, reset=True)
842
798
 
843
- def _ot_msg_flag(self, msg_id, flag_idx) -> None | bool:
844
- if flags := self._ot_msg_value(msg_id):
845
- return bool(flags[flag_idx])
846
- return None
799
+ def _ot_msg_flag(self, msg_id: MsgId, flag_idx: int) -> bool | None:
800
+ flags: list = self._ot_msg_value(msg_id)
801
+ return bool(flags[flag_idx]) if flags else None
847
802
 
848
803
  @staticmethod
849
- def _ot_msg_name(msg) -> str:
804
+ def _ot_msg_name(msg: Message) -> str: # TODO: remove
850
805
  return (
851
- msg.payload[MSG_NAME]
852
- if isinstance(msg.payload[MSG_NAME], str)
853
- else f"{msg.payload[MSG_ID]:02X}"
806
+ msg.payload[SZ_MSG_NAME]
807
+ if isinstance(msg.payload[SZ_MSG_NAME], str)
808
+ else f"{msg.payload[SZ_MSG_ID]:02X}"
854
809
  )
855
810
 
856
- def _ot_msg_value(self, msg_id) -> None | int | float | list:
857
- if (
858
- self.is_pollable_cmd(Code._3220, ctx=msg_id)
859
- and self._msgs_ot.get(msg_id)
860
- and not self._msgs_ot[msg_id]._expired
861
- ):
862
- return self._msgs_ot[msg_id].payload.get(VALUE) # TODO: value_hb/_lb
811
+ def _ot_msg_value(self, msg_id: MsgId) -> int | float | list | None:
812
+ # data_id = int(msg_id, 16)
813
+ if (msg := self._msgs_ot.get(msg_id)) and not msg._expired:
814
+ # TODO: value_hb/_lb
815
+ return msg.payload.get(SZ_VALUE) # type: ignore[no-any-return]
816
+ return None
863
817
 
864
818
  def _result_by_callback(
865
- self, cbk_ot: None | Callable, cbk_ramses: None | Callable
866
- ) -> None | Any:
819
+ self, cbk_ot: Callable | None, cbk_ramses: Callable | None
820
+ ) -> Any | None:
867
821
  """Return a value using OpenTherm or RAMSES as per `config.use_native_ot`."""
868
822
 
869
823
  if self._gwy.config.use_native_ot == "always":
@@ -877,279 +831,297 @@ class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
877
831
  return cbk_ot() if cbk_ot else None
878
832
  return result_ramses # incl. use_native_ot == "never"
879
833
 
880
- def _result_by_lookup(self, code, *args, **kwargs) -> None | Any:
834
+ def _result_by_lookup(
835
+ self,
836
+ code: Code,
837
+ /,
838
+ *,
839
+ key: str,
840
+ ) -> Any | None:
881
841
  """Return a value using OpenTherm or RAMSES as per `config.use_native_ot`."""
882
842
  # assert code in self.RAMSES_TO_OT and kwargs.get("key"):
883
843
 
884
844
  if self._gwy.config.use_native_ot == "always":
885
845
  return self._ot_msg_value(self.RAMSES_TO_OT[code])
846
+
886
847
  if self._gwy.config.use_native_ot == "prefer":
887
848
  if (result_ot := self._ot_msg_value(self.RAMSES_TO_OT[code])) is not None:
888
849
  return result_ot
889
850
 
890
- result_ramses = self._msg_value(code, *args, **kwargs)
851
+ result_ramses = self._msg_value(code, key=key)
891
852
  if self._gwy.config.use_native_ot == "avoid" and result_ramses is None:
892
853
  return self._ot_msg_value(self.RAMSES_TO_OT[code])
854
+
893
855
  return result_ramses # incl. use_native_ot == "never"
894
856
 
895
857
  def _result_by_value(
896
- self, result_ot: None | Any, result_ramses: None | Any
897
- ) -> None | Any:
858
+ self, result_ot: Any | None, result_ramses: Any | None
859
+ ) -> Any | None:
898
860
  """Return a value using OpenTherm or RAMSES as per `config.use_native_ot`."""
861
+ #
899
862
 
900
863
  if self._gwy.config.use_native_ot == "always":
901
864
  return result_ot
865
+
902
866
  if self._gwy.config.use_native_ot == "prefer":
903
867
  if result_ot is not None:
904
868
  return result_ot
905
869
 
870
+ #
906
871
  elif self._gwy.config.use_native_ot == "avoid" and result_ramses is None:
907
872
  return result_ot
873
+
908
874
  return result_ramses # incl. use_native_ot == "never"
909
875
 
910
876
  @property # TODO
911
- def bit_2_4(self) -> None | bool: # 2401 - WIP
877
+ def bit_2_4(self) -> bool | None: # 2401 - WIP
912
878
  return self._msg_flag(Code._2401, "_flags_2", 4)
913
879
 
914
880
  @property # TODO
915
- def bit_2_5(self) -> None | bool: # 2401 - WIP
881
+ def bit_2_5(self) -> bool | None: # 2401 - WIP
916
882
  return self._msg_flag(Code._2401, "_flags_2", 5)
917
883
 
918
884
  @property # TODO
919
- def bit_2_6(self) -> None | bool: # 2401 - WIP
885
+ def bit_2_6(self) -> bool | None: # 2401 - WIP
920
886
  return self._msg_flag(Code._2401, "_flags_2", 6)
921
887
 
922
888
  @property # TODO
923
- def bit_2_7(self) -> None | bool: # 2401 - WIP
889
+ def bit_2_7(self) -> bool | None: # 2401 - WIP
924
890
  return self._msg_flag(Code._2401, "_flags_2", 7)
925
891
 
926
892
  @property # TODO
927
- def bit_3_7(self) -> None | bool: # 3EF0 (byte 3, only OTB)
893
+ def bit_3_7(self) -> bool | None: # 3EF0 (byte 3, only OTB)
928
894
  return self._msg_flag(Code._3EF0, "_flags_3", 7)
929
895
 
930
896
  @property # TODO
931
- def bit_6_6(self) -> None | bool: # 3EF0 ?dhw_enabled (byte 3, only R8820A?)
932
- return self._msg_flag(Code._3EF0, "_flags_3", 6)
897
+ def bit_6_6(self) -> bool | None: # 3EF0 ?dhw_enabled (byte 3, only R8820A?)
898
+ return self._msg_flag(Code._3EF0, "_flags_6", 6)
933
899
 
934
900
  @property # TODO
935
- def percent(self) -> None | float: # 2401 - WIP
936
- return self._msg_value(Code._2401, key="_percent_3")
901
+ def percent(self) -> float | None: # 2401 - WIP (~3150|FC)
902
+ return self._msg_value(Code._2401, key=SZ_HEAT_DEMAND)
937
903
 
938
904
  @property # TODO
939
- def value(self) -> None | int: # 2401 - WIP
905
+ def value(self) -> int | None: # 2401 - WIP
940
906
  return self._msg_value(Code._2401, key="_value_2")
941
907
 
942
908
  @property
943
- def boiler_output_temp(self) -> None | float: # 3220|19, or 3200
909
+ def boiler_output_temp(self) -> float | None: # 3220|19, or 3200
910
+ # _LOGGER.warning(
911
+ # "code=%s, 3220=%s, both=%s",
912
+ # self._msg_value(Code._3200, key=SZ_TEMPERATURE),
913
+ # self._ot_msg_value(str(self.RAMSES_TO_OT[Code._3200])),
914
+ # self._result_by_lookup(Code._3200, key=SZ_TEMPERATURE),
915
+ # )
916
+
944
917
  return self._result_by_lookup(Code._3200, key=SZ_TEMPERATURE)
945
918
 
946
919
  @property
947
- def boiler_return_temp(self) -> None | float: # 3220|1C, or 3210
920
+ def boiler_return_temp(self) -> float | None: # 3220|1C, or 3210
948
921
  return self._result_by_lookup(Code._3210, key=SZ_TEMPERATURE)
949
922
 
950
923
  @property
951
- def boiler_setpoint(self) -> None | float: # 3220|01, or 22D9
924
+ def boiler_setpoint(self) -> float | None: # 3220|01, or 22D9
952
925
  return self._result_by_lookup(Code._22D9, key=SZ_SETPOINT)
953
926
 
954
927
  @property
955
- def ch_max_setpoint(self) -> None | float: # 3220|39, or 1081
928
+ def ch_max_setpoint(self) -> float | None: # 3220|39, or 1081
956
929
  return self._result_by_lookup(Code._1081, key=SZ_SETPOINT)
957
930
 
958
- @property # TODO
959
- def ch_setpoint(self) -> None | float: # 3EF0 (byte 7, only R8820A?), TODO: no OT
931
+ @property # TODO: no OT equivalent
932
+ def ch_setpoint(self) -> float | None: # 3EF0 (byte 7, only R8820A?)
960
933
  return self._result_by_value(
961
934
  None, self._msg_value(Code._3EF0, key=SZ_CH_SETPOINT)
962
935
  )
963
936
 
964
937
  @property
965
- def ch_water_pressure(self) -> None | float: # 3220|12, or 1300
966
- result = self._result_by_lookup(Code._1300, key=SZ_PRESSURE)
967
- return None if result == 25.5 else result # HACK: to make more rigourous
938
+ def ch_water_pressure(self) -> float | None: # 3220|12, or 1300
939
+ return self._result_by_lookup(Code._1300, key=SZ_PRESSURE)
968
940
 
969
941
  @property
970
- def dhw_flow_rate(self) -> None | float: # 3220|13, or 12F0
942
+ def dhw_flow_rate(self) -> float | None: # 3220|13, or 12F0
971
943
  return self._result_by_lookup(Code._12F0, key=SZ_DHW_FLOW_RATE)
972
944
 
973
945
  @property
974
- def dhw_setpoint(self) -> None | float: # 3220|38, or 10A0
946
+ def dhw_setpoint(self) -> float | None: # 3220|38, or 10A0
975
947
  return self._result_by_lookup(Code._10A0, key=SZ_SETPOINT)
976
948
 
977
949
  @property
978
- def dhw_temp(self) -> None | float: # 3220|1A, or 1260
950
+ def dhw_temp(self) -> float | None: # 3220|1A, or 1260
979
951
  return self._result_by_lookup(Code._1260, key=SZ_TEMPERATURE)
980
952
 
981
- @property # TODO
982
- def max_rel_modulation(
983
- self,
984
- ) -> None | float: # 3220|0E, or 3EF0 (byte 8, only R8820A?)
985
- if self._gwy.config.use_native_ot == "prefer": # HACK
953
+ @property # TODO: no reliable OT equivalent?
954
+ def max_rel_modulation(self) -> float | None: # 3220|0E, or 3EF0 (byte 8)
955
+ if self._gwy.config.use_native_ot == "prefer": # HACK: there'll always be 3EF0
986
956
  return self._msg_value(Code._3EF0, key=SZ_MAX_REL_MODULATION)
987
957
  return self._result_by_value(
988
- self._ot_msg_value("0E"),
958
+ self._ot_msg_value(MsgId._0E), # NOTE: not reliable?
989
959
  self._msg_value(Code._3EF0, key=SZ_MAX_REL_MODULATION),
990
960
  )
991
961
 
992
962
  @property
993
- def oem_code(self) -> None | float: # 3220|73, no known RAMSES equivalent
994
- return self._ot_msg_value("73")
963
+ def oem_code(self) -> float | None: # 3220|73, no known RAMSES equivalent
964
+ return self._ot_msg_value(MsgId._73)
995
965
 
996
966
  @property
997
- def outside_temp(self) -> None | float: # 3220|1B, 1290
967
+ def outside_temp(self) -> float | None: # 3220|1B, 1290
998
968
  return self._result_by_lookup(Code._1290, key=SZ_TEMPERATURE)
999
969
 
1000
- @property # HACK
1001
- def rel_modulation_level(self) -> None | float: # 3220|11, or 3EF0/3EF1
1002
- if self._gwy.config.use_native_ot == "prefer": # HACK (there'll always be 3EF0)
970
+ @property # TODO: no reliable OT equivalent?
971
+ def rel_modulation_level(self) -> float | None: # 3220|11, or 3EF0/3EF1
972
+ if self._gwy.config.use_native_ot == "prefer": # HACK: there'll always be 3EF0
1003
973
  return self._msg_value((Code._3EF0, Code._3EF1), key=self.MODULATION_LEVEL)
1004
974
  return self._result_by_value(
1005
- self._ot_msg_value("11"),
975
+ self._ot_msg_value(MsgId._11), # NOTE: not reliable?
1006
976
  self._msg_value((Code._3EF0, Code._3EF1), key=self.MODULATION_LEVEL),
1007
977
  )
1008
978
 
1009
- @property # HACK
1010
- def ch_active(self) -> None | bool: # 3220|00, or 3EF0 (byte 3, only R8820A?)
1011
- if self._gwy.config.use_native_ot == "prefer": # HACK (there'll always be 3EF0)
979
+ @property # TODO: no reliable OT equivalent?
980
+ def ch_active(self) -> bool | None: # 3220|00, or 3EF0 (byte 3)
981
+ if self._gwy.config.use_native_ot == "prefer": # HACK: there'll always be 3EF0
1012
982
  return self._msg_value(Code._3EF0, key=SZ_CH_ACTIVE)
1013
983
  return self._result_by_value(
1014
- self._ot_msg_flag("00", 8 + 1),
984
+ self._ot_msg_flag(MsgId._00, 8 + 1), # NOTE: not reliable?
1015
985
  self._msg_value(Code._3EF0, key=SZ_CH_ACTIVE),
1016
986
  )
1017
987
 
1018
- @property # HACK
1019
- def ch_enabled(self) -> None | bool: # 3220|00, or 3EF0 (byte 6, only R8820A?)
1020
- if self._gwy.config.use_native_ot == "prefer": # HACK (there'll always be 3EF0)
988
+ @property # TODO: no reliable OT equivalent?
989
+ def ch_enabled(self) -> bool | None: # 3220|00, or 3EF0 (byte 6)
990
+ if self._gwy.config.use_native_ot == "prefer": # HACK: there'll always be 3EF0
1021
991
  return self._msg_value(Code._3EF0, key=SZ_CH_ENABLED)
1022
992
  return self._result_by_value(
1023
- self._ot_msg_flag("00", 0),
993
+ self._ot_msg_flag(MsgId._00, 0), # NOTE: not reliable?
1024
994
  self._msg_value(Code._3EF0, key=SZ_CH_ENABLED),
1025
995
  )
1026
996
 
1027
997
  @property
1028
- def cooling_active(self) -> None | bool: # 3220|00, TODO: no known RAMSES
1029
- return self._result_by_value(self._ot_msg_flag("00", 8 + 4), None)
998
+ def cooling_active(self) -> bool | None: # 3220|00, TODO: no known RAMSES
999
+ return self._result_by_value(self._ot_msg_flag(MsgId._00, 8 + 4), None)
1030
1000
 
1031
1001
  @property
1032
- def cooling_enabled(self) -> None | bool: # 3220|00, TODO: no known RAMSES
1033
- return self._result_by_value(self._ot_msg_flag("00", 2), None)
1002
+ def cooling_enabled(self) -> bool | None: # 3220|00, TODO: no known RAMSES
1003
+ return self._result_by_value(self._ot_msg_flag(MsgId._00, 2), None)
1034
1004
 
1035
- @property # HACK
1036
- def dhw_active(self) -> None | bool: # 3220|00, or 3EF0 (byte 3, only OTB)
1037
- if self._gwy.config.use_native_ot == "prefer": # HACK (there'll always be 3EF0)
1005
+ @property # TODO: no reliable OT equivalent?
1006
+ def dhw_active(self) -> bool | None: # 3220|00, or 3EF0 (byte 3)
1007
+ if self._gwy.config.use_native_ot == "prefer": # HACK: there'll always be 3EF0
1038
1008
  return self._msg_value(Code._3EF0, key=SZ_DHW_ACTIVE)
1039
1009
  return self._result_by_value(
1040
- self._ot_msg_flag("00", 8 + 2),
1010
+ self._ot_msg_flag(MsgId._00, 8 + 2), # NOTE: not reliable?
1041
1011
  self._msg_value(Code._3EF0, key=SZ_DHW_ACTIVE),
1042
1012
  )
1043
1013
 
1044
1014
  @property
1045
- def dhw_blocking(self) -> None | bool: # 3220|00, TODO: no known RAMSES
1046
- return self._result_by_value(self._ot_msg_flag("00", 6), None)
1015
+ def dhw_blocking(self) -> bool | None: # 3220|00, TODO: no known RAMSES
1016
+ return self._result_by_value(self._ot_msg_flag(MsgId._00, 6), None)
1047
1017
 
1048
1018
  @property
1049
- def dhw_enabled(self) -> None | bool: # 3220|00, TODO: no known RAMSES
1050
- return self._result_by_value(self._ot_msg_flag("00", 1), None)
1019
+ def dhw_enabled(self) -> bool | None: # 3220|00, TODO: no known RAMSES
1020
+ return self._result_by_value(self._ot_msg_flag(MsgId._00, 1), None)
1051
1021
 
1052
1022
  @property
1053
- def fault_present(self) -> None | bool: # 3220|00, TODO: no known RAMSES
1054
- return self._result_by_value(self._ot_msg_flag("00", 8), None)
1023
+ def fault_present(self) -> bool | None: # 3220|00, TODO: no known RAMSES
1024
+ return self._result_by_value(self._ot_msg_flag(MsgId._00, 8), None)
1055
1025
 
1056
- @property # HACK
1057
- def flame_active(self) -> None | bool: # 3220|00, or 3EF0 (byte 3, only OTB)
1058
- if self._gwy.config.use_native_ot == "prefer": # HACK (there'll always be 3EF0)
1059
- return self._msg_value(Code._3EF0, key=SZ_FLAME_ACTIVE)
1026
+ @property # TODO: no reliable OT equivalent?
1027
+ def flame_active(self) -> bool | None: # 3220|00, or 3EF0 (byte 3)
1028
+ if self._gwy.config.use_native_ot == "prefer": # HACK: there'll always be 3EF0
1029
+ return self._msg_value(Code._3EF0, key="flame_on")
1060
1030
  return self._result_by_value(
1061
- self._ot_msg_flag("00", 8 + 3),
1062
- self._msg_value(Code._3EF0, key=SZ_FLAME_ACTIVE),
1031
+ self._ot_msg_flag(MsgId._00, 8 + 3), # NOTE: not reliable?
1032
+ self._msg_value(Code._3EF0, key="flame_on"),
1063
1033
  )
1064
1034
 
1065
1035
  @property
1066
- def otc_active(self) -> None | bool: # 3220|00, TODO: no known RAMSES
1067
- return self._result_by_value(self._ot_msg_flag("00", 3), None)
1036
+ def otc_active(self) -> bool | None: # 3220|00, TODO: no known RAMSES
1037
+ return self._result_by_value(self._ot_msg_flag(MsgId._00, 3), None)
1068
1038
 
1069
1039
  @property
1070
- def summer_mode(self) -> None | bool: # 3220|00, TODO: no known RAMSES
1071
- return self._result_by_value(self._ot_msg_flag("00", 5), None)
1040
+ def summer_mode(self) -> bool | None: # 3220|00, TODO: no known RAMSES
1041
+ return self._result_by_value(self._ot_msg_flag(MsgId._00, 5), None)
1072
1042
 
1073
1043
  @property
1074
- def opentherm_schema(self) -> dict:
1075
- result = {
1044
+ def opentherm_schema(self) -> dict[str, Any]:
1045
+ result: dict[str, Any] = {
1076
1046
  self._ot_msg_name(v): v.payload
1077
1047
  for k, v in self._msgs_ot.items()
1078
- if self._supported_cmds_ctx.get(int(k, 16)) and int(k, 16) in SCHEMA_MSG_IDS
1048
+ if self._supported_cmds_ctx.get(k) and int(k, 16) in SCHEMA_DATA_IDS
1079
1049
  }
1080
1050
  return {
1081
- m: {k: v for k, v in p.items() if k.startswith(VALUE)}
1051
+ m: {k: v for k, v in p.items() if k.startswith(SZ_VALUE)}
1082
1052
  for m, p in result.items()
1083
1053
  }
1084
1054
 
1085
1055
  @property
1086
- def opentherm_counters(self) -> dict:
1087
- # for msg_id in ("71", "72", ...):
1056
+ def opentherm_counters(self) -> dict[str, Any]: # all are U16
1088
1057
  return {
1089
- SZ_BURNER_HOURS: self._ot_msg_value("78"),
1090
- SZ_BURNER_STARTS: self._ot_msg_value("74"),
1091
- SZ_BURNER_FAILED_STARTS: self._ot_msg_value("71"),
1092
- SZ_CH_PUMP_HOURS: self._ot_msg_value("79"),
1093
- SZ_CH_PUMP_STARTS: self._ot_msg_value("75"),
1094
- SZ_DHW_BURNER_HOURS: self._ot_msg_value("7B"),
1095
- SZ_DHW_BURNER_STARTS: self._ot_msg_value("77"),
1096
- SZ_DHW_PUMP_HOURS: self._ot_msg_value("7A"),
1097
- SZ_DHW_PUMP_STARTS: self._ot_msg_value("76"),
1098
- SZ_FLAME_SIGNAL_LOW: self._ot_msg_value("72"),
1099
- } # 0x73 is OEM diagnostic code...
1058
+ SZ_BURNER_HOURS: self._ot_msg_value(MsgId._78),
1059
+ SZ_BURNER_STARTS: self._ot_msg_value(MsgId._74),
1060
+ SZ_BURNER_FAILED_STARTS: self._ot_msg_value(MsgId._71),
1061
+ SZ_CH_PUMP_HOURS: self._ot_msg_value(MsgId._79),
1062
+ SZ_CH_PUMP_STARTS: self._ot_msg_value(MsgId._75),
1063
+ SZ_DHW_BURNER_HOURS: self._ot_msg_value(MsgId._7B),
1064
+ SZ_DHW_BURNER_STARTS: self._ot_msg_value(MsgId._77),
1065
+ SZ_DHW_PUMP_HOURS: self._ot_msg_value(MsgId._7A),
1066
+ SZ_DHW_PUMP_STARTS: self._ot_msg_value(MsgId._76),
1067
+ SZ_FLAME_SIGNAL_LOW: self._ot_msg_value(MsgId._72),
1068
+ } # 0x73 is not a counter: is OEM diagnostic code...
1100
1069
 
1101
1070
  @property
1102
- def opentherm_params(self) -> dict:
1071
+ def opentherm_params(self) -> dict[str, Any]: # F8_8, U8, {"hb": S8, "lb": S8}
1103
1072
  result = {
1104
1073
  self._ot_msg_name(v): v.payload
1105
1074
  for k, v in self._msgs_ot.items()
1106
- if self._supported_cmds_ctx.get(int(k, 16)) and int(k, 16) in PARAMS_MSG_IDS
1075
+ if self._supported_cmds_ctx.get(k) and int(k, 16) in PARAMS_DATA_IDS
1107
1076
  }
1108
1077
  return {
1109
- m: {k: v for k, v in p.items() if k.startswith(VALUE)}
1078
+ m: {k: v for k, v in p.items() if k.startswith(SZ_VALUE)}
1110
1079
  for m, p in result.items()
1111
1080
  }
1112
1081
 
1113
1082
  @property
1114
- def opentherm_status(self) -> dict:
1115
- return {
1116
- SZ_BOILER_OUTPUT_TEMP: self._ot_msg_value("19"),
1117
- SZ_BOILER_RETURN_TEMP: self._ot_msg_value("1C"),
1118
- SZ_BOILER_SETPOINT: self._ot_msg_value("01"),
1119
- SZ_CH_MAX_SETPOINT: self._ot_msg_value("39"),
1120
- SZ_CH_WATER_PRESSURE: self._ot_msg_value("12"),
1121
- SZ_DHW_FLOW_RATE: self._ot_msg_value("13"),
1122
- SZ_DHW_SETPOINT: self._ot_msg_value("38"),
1123
- SZ_DHW_TEMP: self._ot_msg_value("1A"),
1124
- SZ_OEM_CODE: self._ot_msg_value("73"),
1125
- SZ_OUTSIDE_TEMP: self._ot_msg_value("1B"),
1126
- SZ_REL_MODULATION_LEVEL: self._ot_msg_value("11"),
1083
+ def opentherm_status(self) -> dict[str, Any]: # F8_8, U16 (only OEM_CODE) or bool
1084
+ return { # most these are in: STATUS_DATA_IDS
1085
+ SZ_BOILER_OUTPUT_TEMP: self._ot_msg_value(MsgId._19),
1086
+ SZ_BOILER_RETURN_TEMP: self._ot_msg_value(MsgId._1C),
1087
+ SZ_BOILER_SETPOINT: self._ot_msg_value(MsgId._01),
1088
+ # SZ_CH_MAX_SETPOINT: self._ot_msg_value(MsgId._39), # in PARAMS_DATA_IDS
1089
+ SZ_CH_WATER_PRESSURE: self._ot_msg_value(MsgId._12),
1090
+ SZ_DHW_FLOW_RATE: self._ot_msg_value(MsgId._13),
1091
+ # SZ_DHW_SETPOINT: self._ot_msg_value(MsgId._38), # in PARAMS_DATA_IDS
1092
+ SZ_DHW_TEMP: self._ot_msg_value(MsgId._1A),
1093
+ SZ_OEM_CODE: self._ot_msg_value(MsgId._73),
1094
+ SZ_OUTSIDE_TEMP: self._ot_msg_value(MsgId._1B),
1095
+ SZ_REL_MODULATION_LEVEL: self._ot_msg_value(MsgId._11),
1096
+ #
1097
+ # SZ...: self._ot_msg_value(MsgId._05), # in STATUS_DATA_IDS
1098
+ # SZ...: self._ot_msg_value(MsgId._18), # in STATUS_DATA_IDS
1127
1099
  #
1128
- SZ_CH_ACTIVE: self._ot_msg_flag("00", 8 + 1),
1129
- SZ_CH_ENABLED: self._ot_msg_flag("00", 0),
1130
- SZ_COOLING_ACTIVE: self._ot_msg_flag("00", 8 + 4),
1131
- SZ_COOLING_ENABLED: self._ot_msg_flag("00", 2),
1132
- SZ_DHW_ACTIVE: self._ot_msg_flag("00", 8 + 2),
1133
- SZ_DHW_BLOCKING: self._ot_msg_flag("00", 6),
1134
- SZ_DHW_ENABLED: self._ot_msg_flag("00", 1),
1135
- SZ_FAULT_PRESENT: self._ot_msg_flag("00", 8),
1136
- SZ_FLAME_ACTIVE: self._ot_msg_flag("00", 8 + 3),
1137
- SZ_SUMMER_MODE: self._ot_msg_flag("00", 5),
1138
- SZ_OTC_ACTIVE: self._ot_msg_flag("00", 3),
1100
+ SZ_CH_ACTIVE: self._ot_msg_flag(MsgId._00, 8 + 1),
1101
+ SZ_CH_ENABLED: self._ot_msg_flag(MsgId._00, 0),
1102
+ SZ_COOLING_ACTIVE: self._ot_msg_flag(MsgId._00, 8 + 4),
1103
+ SZ_COOLING_ENABLED: self._ot_msg_flag(MsgId._00, 2),
1104
+ SZ_DHW_ACTIVE: self._ot_msg_flag(MsgId._00, 8 + 2),
1105
+ SZ_DHW_BLOCKING: self._ot_msg_flag(MsgId._00, 6),
1106
+ SZ_DHW_ENABLED: self._ot_msg_flag(MsgId._00, 1),
1107
+ SZ_FAULT_PRESENT: self._ot_msg_flag(MsgId._00, 8),
1108
+ SZ_FLAME_ACTIVE: self._ot_msg_flag(MsgId._00, 8 + 3),
1109
+ SZ_SUMMER_MODE: self._ot_msg_flag(MsgId._00, 5),
1110
+ SZ_OTC_ACTIVE: self._ot_msg_flag(MsgId._00, 3),
1139
1111
  }
1140
1112
 
1141
1113
  @property
1142
- def ramses_schema(self) -> dict:
1114
+ def ramses_schema(self) -> PayDictT.EMPTY:
1143
1115
  return {}
1144
1116
 
1145
1117
  @property
1146
- def ramses_params(self) -> dict:
1118
+ def ramses_params(self) -> dict[str, float | None]:
1147
1119
  return {
1148
1120
  SZ_MAX_REL_MODULATION: self.max_rel_modulation,
1149
1121
  }
1150
1122
 
1151
1123
  @property
1152
- def ramses_status(self) -> dict:
1124
+ def ramses_status(self) -> dict[str, Any]:
1153
1125
  return {
1154
1126
  SZ_BOILER_OUTPUT_TEMP: self._msg_value(Code._3200, key=SZ_TEMPERATURE),
1155
1127
  SZ_BOILER_RETURN_TEMP: self._msg_value(Code._3210, key=SZ_TEMPERATURE),
@@ -1230,11 +1202,10 @@ class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
1230
1202
  }
1231
1203
 
1232
1204
 
1233
- class Thermostat(BatteryState, Setpoint, Temperature): # THM (..):
1205
+ class Thermostat(BatteryState, Setpoint, Temperature, Fakeable): # THM (..):
1234
1206
  """The THM/STA class, such as a TR87RF."""
1235
1207
 
1236
- _SLUG: str = DEV_TYPE.THM
1237
-
1208
+ _SLUG = DevType.THM
1238
1209
  _STATE_ATTR = SZ_TEMPERATURE
1239
1210
 
1240
1211
  def _handle_msg(self, msg: Message) -> None:
@@ -1274,25 +1245,29 @@ class Thermostat(BatteryState, Setpoint, Temperature): # THM (..):
1274
1245
  elif self._iz_controller is False: # TODO: raise CorruptStateError
1275
1246
  _LOGGER.error(f"{msg!r} # IS_CONTROLLER (21): was FALSE, now True")
1276
1247
 
1248
+ async def initiate_binding_process(self) -> Packet:
1249
+ return await super()._initiate_binding_process(
1250
+ (Code._2309, Code._30C9, Code._0008)
1251
+ )
1252
+
1277
1253
 
1278
1254
  class BdrSwitch(Actuator, RelayDemand): # BDR (13):
1279
1255
  """The BDR class, such as a BDR91.
1280
1256
 
1281
- BDR91s can be used in six disctinct modes, including:
1257
+ BDR91s can be used in six distinct modes, including:
1282
1258
  - x2 boiler controller (FC/TPI): either traditional, or newer heat pump-aware
1283
1259
  - x1 electric heat zones (0x/ELE)
1284
1260
  - x1 zone valve zones (0x/VAL)
1285
1261
  - x2 DHW thingys (F9/DHW, FA/DHW)
1286
1262
  """
1287
1263
 
1288
- _SLUG: str = DEV_TYPE.BDR
1289
-
1290
- ACTIVE = "active"
1291
- TPI_PARAMS = "tpi_params"
1264
+ ACTIVE: Final = "active"
1265
+ TPI_PARAMS: Final = "tpi_params"
1292
1266
 
1267
+ _SLUG = DevType.BDR
1293
1268
  _STATE_ATTR = "active"
1294
1269
 
1295
- # def __init__(self, *args, **kwargs) -> None:
1270
+ # def __init__(self, *args: Any, **kwargs: Any) -> None:
1296
1271
  # super().__init__(*args, **kwargs)
1297
1272
 
1298
1273
  # if kwargs.get(SZ_DOMAIN_ID) == FC: # TODO: F9/FA/FC, zone_idx
@@ -1316,41 +1291,42 @@ class BdrSwitch(Actuator, RelayDemand): # BDR (13):
1316
1291
 
1317
1292
  super()._setup_discovery_cmds()
1318
1293
 
1319
- if self._faked:
1294
+ if self.is_faked:
1320
1295
  return
1321
1296
 
1322
1297
  self._add_discovery_cmd(Command.get_tpi_params(self.id), 6 * 3600) # params
1323
1298
  self._add_discovery_cmd(
1324
- _mk_cmd(RQ, Code._3EF1, "00", self.id),
1299
+ Command.from_attrs(RQ, self.id, Code._3EF1, "00"),
1325
1300
  60 if self._child_id in (F9, FA, FC) else 300,
1326
1301
  ) # status
1327
1302
 
1328
1303
  @property
1329
- def active(self) -> None | bool: # 3EF0, 3EF1
1304
+ def active(self) -> bool | None: # 3EF0, 3EF1
1330
1305
  """Return the actuator's current state."""
1331
1306
  result = self._msg_value((Code._3EF0, Code._3EF1), key=self.MODULATION_LEVEL)
1332
1307
  return None if result is None else bool(result)
1333
1308
 
1334
1309
  @property
1335
- def role(self) -> None | str:
1310
+ def role(self) -> str | None:
1336
1311
  """Return the role of the BDR91A (there are six possibilities)."""
1337
1312
 
1338
1313
  # TODO: use self._parent?
1339
1314
  if self._child_id in DOMAIN_TYPE_MAP:
1340
1315
  return DOMAIN_TYPE_MAP[self._child_id]
1341
- elif self._parent:
1342
- return self._parent.heating_type # TODO: only applies to zones
1316
+ elif self._parent and isinstance(self._parent, Zone):
1317
+ # TODO: remove need for isinstance
1318
+ return self._parent.heating_type
1343
1319
 
1344
- # if Code._3B00 in self._msgs and self._msgs[Code._3B00].verb == I_:
1320
+ # if Code._3B00 in _msgs and _msgs[Code._3B00].verb == I_:
1345
1321
  # self._is_tpi = True
1346
- # if Code._1FC9 in self._msgs and self._msgs[Code._1FC9].verb == RP:
1347
- # if Code._3B00 in self._msgs[Code._1FC9].raw_payload:
1322
+ # if Code._1FC9 in _msgs and _msgs[Code._1FC9].verb == RP:
1323
+ # if Code._3B00 in _msgs[Code._1FC9].raw_payload:
1348
1324
  # self._is_tpi = True
1349
1325
 
1350
1326
  return None
1351
1327
 
1352
1328
  @property
1353
- def tpi_params(self) -> None | dict: # 1100
1329
+ def tpi_params(self) -> PayDictT._10A0 | None:
1354
1330
  return self._msg_value(Code._1100)
1355
1331
 
1356
1332
  @property
@@ -1378,21 +1354,20 @@ class BdrSwitch(Actuator, RelayDemand): # BDR (13):
1378
1354
  class TrvActuator(BatteryState, HeatDemand, Setpoint, Temperature): # TRV (04):
1379
1355
  """The TRV class, such as a HR92."""
1380
1356
 
1381
- _SLUG: str = DEV_TYPE.TRV
1382
-
1383
- WINDOW_OPEN = SZ_WINDOW_OPEN # boolean
1357
+ WINDOW_OPEN: Final = SZ_WINDOW_OPEN
1384
1358
 
1359
+ _SLUG = DevType.TRV
1385
1360
  _STATE_ATTR = SZ_HEAT_DEMAND
1386
1361
 
1387
1362
  @property
1388
- def heat_demand(self) -> None | float: # 3150
1363
+ def heat_demand(self) -> float | None: # 3150
1389
1364
  if (heat_demand := super().heat_demand) is None:
1390
1365
  if self._msg_value(Code._3150) is None and self.setpoint is False:
1391
1366
  return 0 # instead of None (no 3150s sent when setpoint is False)
1392
1367
  return heat_demand
1393
1368
 
1394
1369
  @property
1395
- def window_open(self) -> None | bool: # 12B0
1370
+ def window_open(self) -> bool | None: # 12B0
1396
1371
  return self._msg_value(Code._12B0, key=self.WINDOW_OPEN)
1397
1372
 
1398
1373
  @property
@@ -1404,14 +1379,16 @@ class TrvActuator(BatteryState, HeatDemand, Setpoint, Temperature): # TRV (04):
1404
1379
 
1405
1380
 
1406
1381
  class JimDevice(Actuator): # BDR (08):
1407
- _SLUG: str = DEV_TYPE.JIM
1382
+ _SLUG: str = DevType.JIM
1383
+ _STATE_ATTR = None
1408
1384
 
1409
1385
 
1410
1386
  class JstDevice(RelayDemand): # BDR (31):
1411
- _SLUG: str = DEV_TYPE.JST
1387
+ _SLUG: str = DevType.JST
1388
+ _STATE_ATTR = None
1412
1389
 
1413
1390
 
1414
- class UfhCircuit(Entity):
1391
+ class UfhCircuit(Child, Entity): # FIXME
1415
1392
  """The UFH circuit class (UFC:circuit is much like CTL/TCS:zone).
1416
1393
 
1417
1394
  NOTE: for circuits, there's a difference between :
@@ -1419,82 +1396,93 @@ class UfhCircuit(Entity):
1419
1396
  - `self.tcs.ctl`: the Evohome controller
1420
1397
  """
1421
1398
 
1422
- _SLUG: str = None # type: ignore[assignment]
1399
+ _SLUG: str = None
1400
+ _STATE_ATTR = None
1423
1401
 
1424
- def __init__(self, ufc, ufh_idx: str) -> None:
1402
+ def __init__(self, ufc: UfhController, ufh_idx: str) -> None:
1425
1403
  super().__init__(ufc._gwy)
1426
1404
 
1405
+ # FIXME: ZZZ entities must know their parent device ID and their own idx
1406
+ self._z_id = ufc.id
1407
+ self._z_idx = ufh_idx
1408
+
1427
1409
  self.id: str = f"{ufc.id}_{ufh_idx}"
1428
1410
 
1429
1411
  self.ufc: UfhController = ufc
1430
1412
  self._child_id = ufh_idx
1431
1413
 
1432
1414
  # TODO: _ctl should be: .ufc? .ctl?
1433
- self._ctl: Controller = None # type: ignore[assignment]
1434
- self._zone: None | Zone = None
1415
+ self._ctl: Controller = None
1416
+ self._zone: Zone | None = None
1435
1417
 
1436
1418
  # def __str__(self) -> str:
1437
1419
  # return f"{self.id} ({self._zone and self._zone._child_id})"
1438
1420
 
1421
+ def _update_schema(self, **kwargs: Any) -> None:
1422
+ raise NotImplementedError
1423
+
1439
1424
  def _handle_msg(self, msg: Message) -> None:
1440
1425
  super()._handle_msg(msg)
1441
1426
 
1442
- # FIXME:
1443
- if msg.code == Code._000C and msg.payload[SZ_DEVICES]: # zone_devices
1427
+ if msg.code != Code._000C or not msg.payload[SZ_DEVICES]: # zone_devices
1428
+ return
1444
1429
 
1445
- if not (dev_ids := msg.payload[SZ_DEVICES]):
1446
- return
1447
- if len(dev_ids) != 1:
1448
- raise InvalidPayloadError("No devices")
1430
+ # FIXME: is messy
1431
+ if not (dev_ids := msg.payload[SZ_DEVICES]):
1432
+ return
1433
+ if len(dev_ids) != 1:
1434
+ raise exc.PacketPayloadInvalid("No devices")
1449
1435
 
1450
- # ctl = self._gwy.device_by_id.get(dev_ids[0])
1451
- ctl = self._gwy.get_device(dev_ids[0])
1452
- if not ctl or (self._ctl and self._ctl is not ctl):
1453
- raise InvalidPayloadError("No CTL")
1454
- self._ctl = ctl
1436
+ # ctl = self._gwy.device_by_id.get(dev_ids[0])
1437
+ ctl: Controller = self._gwy.get_device(dev_ids[0])
1438
+ if not ctl or (self._ctl and self._ctl is not ctl):
1439
+ raise exc.PacketPayloadInvalid("No CTL")
1440
+ self._ctl = ctl
1455
1441
 
1456
- ctl._make_tcs_controller()
1457
- # self.set_parent(ctl.tcs)
1442
+ ctl._make_tcs_controller()
1443
+ # self.set_parent(ctl.tcs)
1458
1444
 
1459
- zon = ctl.tcs.get_htg_zone(msg.payload[SZ_ZONE_IDX])
1460
- if not zon:
1461
- raise InvalidPayloadError("No Zone")
1462
- if self._zone and self._zone is not zon:
1463
- raise InvalidPayloadError("Wrong Zone")
1464
- self._zone = zon
1445
+ zon = ctl.tcs.get_htg_zone(msg.payload[SZ_ZONE_IDX])
1446
+ if not zon:
1447
+ raise exc.PacketPayloadInvalid("No Zone")
1448
+ if self._zone and self._zone is not zon:
1449
+ raise exc.PacketPayloadInvalid("Wrong Zone")
1450
+ self._zone = zon
1465
1451
 
1466
- if self not in self._zone.actuators:
1467
- schema = {SZ_ACTUATORS: [self.ufc.id], SZ_CIRCUITS: [self.id]}
1468
- self._zone._update_schema(**schema)
1452
+ if self.ufc not in self._zone.actuators:
1453
+ schema = {SZ_ACTUATORS: [self.ufc.id], SZ_CIRCUITS: [self.id]}
1454
+ self._zone._update_schema(**schema)
1469
1455
 
1470
1456
  @property
1471
1457
  def ufx_idx(self) -> str:
1472
1458
  return self._child_id
1473
1459
 
1474
1460
  @property
1475
- def zone_idx(self) -> None | str:
1461
+ def zone_idx(self) -> str | None:
1476
1462
  if self._zone:
1477
1463
  return self._zone._child_id
1464
+ return None
1478
1465
 
1479
1466
 
1480
- HEAT_CLASS_BY_SLUG = class_by_attr(__name__, "_SLUG") # e.g. CTL: Controller
1467
+ # e.g. {"CTL": Controller}
1468
+ HEAT_CLASS_BY_SLUG: dict[str, type[DeviceHeat]] = class_by_attr(__name__, "_SLUG")
1481
1469
 
1482
1470
  _HEAT_VC_PAIR_BY_CLASS = {
1483
- DEV_TYPE.DHW: ((I_, Code._1260),),
1484
- DEV_TYPE.OTB: ((I_, Code._3220), (RP, Code._3220)),
1471
+ DevType.DHW: ((I_, Code._1260),),
1472
+ DevType.OTB: ((I_, Code._3220), (RP, Code._3220)),
1485
1473
  }
1486
1474
 
1487
1475
 
1488
1476
  def class_dev_heat(
1489
- dev_addr: Address, *, msg: Message = None, eavesdrop: bool = False
1490
- ) -> type[Device]:
1477
+ dev_addr: Address, *, msg: Message | None = None, eavesdrop: bool = False
1478
+ ) -> type[DeviceHeat]:
1491
1479
  """Return a device class, but only if the device must be from the CH/DHW group.
1492
1480
 
1493
1481
  May return a device class, DeviceHeat (which will need promotion).
1494
1482
  """
1495
1483
 
1496
1484
  if dev_addr.type in DEV_TYPE_MAP.THM_DEVICES:
1497
- return HEAT_CLASS_BY_SLUG[DEV_TYPE.THM]
1485
+ return HEAT_CLASS_BY_SLUG[DevType.THM]
1498
1486
 
1499
1487
  try:
1500
1488
  slug = DEV_TYPE_MAP.slug(dev_addr.type)