ramses-rf 0.22.40__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 (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 +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 +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.1.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.1.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.40.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.40.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_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/device/hvac.py CHANGED
@@ -1,53 +1,52 @@
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 HVAC domain."""
5
3
 
6
- HVAC devices.
7
- """
8
4
  from __future__ import annotations
9
5
 
10
6
  import logging
11
- from typing import Any, Optional, TypeVar
7
+ from typing import Any, TypeVar
12
8
 
13
- from ..const import (
14
- DEV_TYPE,
15
- FAN_MODE,
9
+ from ramses_rf import exceptions as exc
10
+ from ramses_rf.const import (
16
11
  SZ_AIR_QUALITY,
17
- SZ_AIR_QUALITY_BASE,
12
+ SZ_AIR_QUALITY_BASIS,
18
13
  SZ_BOOST_TIMER,
14
+ SZ_BYPASS_MODE,
19
15
  SZ_BYPASS_POSITION,
16
+ SZ_BYPASS_STATE,
20
17
  SZ_CO2_LEVEL,
21
18
  SZ_EXHAUST_FAN_SPEED,
22
19
  SZ_EXHAUST_FLOW,
23
- SZ_EXHAUST_TEMPERATURE,
20
+ SZ_EXHAUST_TEMP,
24
21
  SZ_FAN_INFO,
22
+ SZ_FAN_MODE,
23
+ SZ_FAN_RATE,
25
24
  SZ_INDOOR_HUMIDITY,
26
- SZ_INDOOR_TEMPERATURE,
25
+ SZ_INDOOR_TEMP,
27
26
  SZ_OUTDOOR_HUMIDITY,
28
- SZ_OUTDOOR_TEMPERATURE,
27
+ SZ_OUTDOOR_TEMP,
29
28
  SZ_POST_HEAT,
30
29
  SZ_PRE_HEAT,
31
30
  SZ_PRESENCE_DETECTED,
32
- SZ_REMAINING_TIME,
33
- SZ_SPEED_CAP,
31
+ SZ_REMAINING_DAYS,
32
+ SZ_REMAINING_MINS,
33
+ SZ_REMAINING_PERCENT,
34
+ SZ_SPEED_CAPABILITIES,
34
35
  SZ_SUPPLY_FAN_SPEED,
35
36
  SZ_SUPPLY_FLOW,
36
- SZ_SUPPLY_TEMPERATURE,
37
+ SZ_SUPPLY_TEMP,
37
38
  SZ_TEMPERATURE,
38
- __dev_mode__,
39
+ DevType,
39
40
  )
40
- from ..entity_base import class_by_attr
41
- from ..helpers import shrink
42
- from ..protocol import Address, Message
43
- from ..protocol.command import Command
44
- from ..protocol.const import SZ_BINDINGS
45
- from ..protocol.ramses import CODES_OF_HVAC_DOMAIN_ONLY, HVAC_KLASS_BY_VC_PAIR
46
- from ..schemas import SCH_VCS, SZ_REMOTES, SZ_SENSORS
47
- from .base import BatteryState, Device, DeviceHvac, Fakeable
48
-
49
- # skipcq: PY-W2000
50
- from ..const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
41
+ from ramses_rf.entity_base import class_by_attr
42
+ from ramses_rf.helpers import shrink
43
+ from ramses_rf.schemas import SCH_VCS, SZ_REMOTES, SZ_SENSORS
44
+ from ramses_tx import Address, Command, Message, Packet, Priority
45
+ from ramses_tx.ramses import CODES_OF_HVAC_DOMAIN_ONLY, HVAC_KLASS_BY_VC_PAIR
46
+
47
+ from .base import BatteryState, DeviceHvac, Fakeable
48
+
49
+ from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
51
50
  I_,
52
51
  RP,
53
52
  RQ,
@@ -55,12 +54,19 @@ from ..const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
55
54
  Code,
56
55
  )
57
56
 
57
+ # TODO: Switch this module to utilise the (run-time) decorator design pattern...
58
+ # - https://refactoring.guru/design-patterns/decorator/python/example
59
+ # - will probably need setattr()?
60
+ # BaseComponents: FAN (HRU, PIV, EXT), SENsor (CO2, HUM, TEMp), SWItch (RF gateway?)
61
+ # - a device could be a combination of above (e.g. Spider Gateway)
62
+ # Track binding for SWI (HA service call) & SEN (HA trigger) to FAN/other
63
+
64
+ # Challenges:
65
+ # - may need two-tier system (HVAC -> FAN|SEN|SWI -> command class)
66
+ # - thus, Composite design pattern may be more appropriate
58
67
 
59
- DEV_MODE = __dev_mode__ # and False
60
68
 
61
69
  _LOGGER = logging.getLogger(__name__)
62
- if DEV_MODE:
63
- _LOGGER.setLevel(logging.DEBUG)
64
70
 
65
71
 
66
72
  _HvacRemoteBaseT = TypeVar("_HvacRemoteBaseT", bound="HvacRemoteBase")
@@ -75,33 +81,22 @@ class HvacSensorBase(DeviceHvac):
75
81
  pass
76
82
 
77
83
 
78
- class CarbonDioxide(Fakeable, HvacSensorBase): # 1298
84
+ class CarbonDioxide(HvacSensorBase): # 1298
79
85
  """The CO2 sensor (cardinal code is 1298)."""
80
86
 
81
- def _bind(self):
82
- # .I --- 29:181813 63:262142 --:------ 1FC9 030 00-31E0-76C635 01-31E0-76C635 00-1298-76C635 67-10E0-76C635 00-1FC9-76C635
83
- # .W --- 32:155617 29:181813 --:------ 1FC9 012 00-31D9-825FE1 00-31DA-825FE1 # The HRU
84
- # .I --- 29:181813 32:155617 --:------ 1FC9 001 00
85
-
86
- def callback(msg):
87
- """Use the accept pkt to determine the zone/domain id."""
88
- _ = msg.payload[SZ_BINDINGS][0][0]
89
- # self.set_parent(msg.src, child_id=child_id, is_sensor=True)
90
-
91
- super()._bind()
92
- self._bind_request((Code._1298, Code._31E0), callback=callback)
93
-
94
87
  @property
95
- def co2_level(self) -> None | float:
88
+ def co2_level(self) -> int | None: # 1298
96
89
  return self._msg_value(Code._1298, key=SZ_CO2_LEVEL)
97
90
 
98
- # @check_faking_enabled
99
91
  @co2_level.setter
100
- def co2_level(self, value) -> None:
101
- if not self._faked:
102
- raise RuntimeError(f"Faking is not enabled for {self}")
103
- self._send_cmd(Command.put_co2_level(self.id, value))
104
- # lf._send_cmd(Command.get_co2_level(...))
92
+ def co2_level(self, value: int | None) -> None:
93
+ """Fake the CO2 level of the sensor."""
94
+
95
+ if not self.is_faked:
96
+ raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
97
+
98
+ cmd = Command.put_co2_level(self.id, value)
99
+ self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)
105
100
 
106
101
  @property
107
102
  def status(self) -> dict[str, Any]:
@@ -111,33 +106,22 @@ class CarbonDioxide(Fakeable, HvacSensorBase): # 1298
111
106
  }
112
107
 
113
108
 
114
- class IndoorHumidity(Fakeable, HvacSensorBase): # 12A0
109
+ class IndoorHumidity(HvacSensorBase): # 12A0
115
110
  """The relative humidity sensor (12A0)."""
116
111
 
117
- def _bind(self):
118
- # .I ---
119
- # .W ---
120
- # .I ---
121
-
122
- def callback(msg):
123
- """Use the accept pkt to determine the zone/domain id."""
124
- _ = msg.payload[SZ_BINDINGS][0][0]
125
- # self.set_parent(msg.src, child_id=child_id, is_sensor=True)
126
-
127
- super()._bind()
128
- self._bind_request((Code._12A0, Code._31E0), callback=callback)
129
-
130
112
  @property
131
- def indoor_humidity(self) -> None | float:
113
+ def indoor_humidity(self) -> float | None: # 12A0
132
114
  return self._msg_value(Code._12A0, key=SZ_INDOOR_HUMIDITY)
133
115
 
134
- # @check_faking_enabled
135
116
  @indoor_humidity.setter
136
- def indoor_humidity(self, value) -> None:
137
- if not self._faked:
138
- raise RuntimeError(f"Faking is not enabled for {self}")
139
- self._send_cmd(Command.put_indoor_humidity(self.id, value))
140
- # lf._send_cmd(Command.get_indoor_humidity(...))
117
+ def indoor_humidity(self, value: float | None) -> None:
118
+ """Fake the indoor humidity of the sensor."""
119
+
120
+ if not self.is_faked:
121
+ raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
122
+
123
+ cmd = Command.put_indoor_humidity(self.id, value)
124
+ self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)
141
125
 
142
126
  @property
143
127
  def status(self) -> dict[str, Any]:
@@ -147,33 +131,26 @@ class IndoorHumidity(Fakeable, HvacSensorBase): # 12A0
147
131
  }
148
132
 
149
133
 
150
- class PresenceDetect(Fakeable, HvacSensorBase): # 2E10
134
+ class PresenceDetect(HvacSensorBase): # 2E10
151
135
  """The presence sensor (2E10/31E0)."""
152
136
 
153
- def _bind(self):
154
- # .I --- 37:154011 --:------ 37:154011 1FC9 030 00-31E0-96599B 00-1298-96599B 00-2E10-96599B 01-10E0-96599B 00-1FC9-96599B # CO2, idx|10E0 == 01
155
- # .W --- 28:126620 37:154011 --:------ 1FC9 012 00-31D9-49EE9C 00-31DA-49EE9C # FAN, BRDG-02A55
156
- # .I --- 37:154011 28:126620 --:------ 1FC9 001 00 # CO2, incl. integrated control, PIR
157
-
158
- def callback(msg):
159
- """Use the accept pkt to determine the zone/domain id."""
160
- _ = msg.payload[SZ_BINDINGS][0][0]
161
- # self.set_parent(msg.src, child_id=child_id, is_sensor=True)
162
-
163
- super()._bind()
164
- self._bind_request((Code._2E10, Code._31E0), callback=callback)
137
+ # .I --- 37:154011 --:------ 37:154011 1FC9 030 00-31E0-96599B 00-1298-96599B 00-2E10-96599B 01-10E0-96599B 00-1FC9-96599B # CO2, idx|10E0 == 01
138
+ # .W --- 28:126620 37:154011 --:------ 1FC9 012 00-31D9-49EE9C 00-31DA-49EE9C # FAN, BRDG-02A55
139
+ # .I --- 37:154011 28:126620 --:------ 1FC9 001 00 # CO2, incl. integrated control, PIR
165
140
 
166
141
  @property
167
- def presence_detected(self) -> None | float:
142
+ def presence_detected(self) -> bool | None:
168
143
  return self._msg_value(Code._2E10, key=SZ_PRESENCE_DETECTED)
169
144
 
170
- # @check_faking_enabled
171
145
  @presence_detected.setter
172
- def presence_detected(self, value) -> None:
173
- if not self._faked:
174
- raise RuntimeError(f"Faking is not enabled for {self}")
175
- self._send_cmd(Command.put_presence_detected(self.id, value))
176
- # lf._send_cmd(Command.get_presence_detected(...))
146
+ def presence_detected(self, value: bool | None) -> None:
147
+ """Fake the presence state of the sensor."""
148
+
149
+ if not self.is_faked:
150
+ raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
151
+
152
+ cmd = Command.put_presence_detected(self.id, value)
153
+ self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)
177
154
 
178
155
  @property
179
156
  def status(self) -> dict[str, Any]:
@@ -194,16 +171,24 @@ class FilterChange(DeviceHvac): # FAN: 10D0
194
171
  )
195
172
 
196
173
  @property
197
- def filter_remaining(self) -> Optional[int]:
198
- return self._msg_value(Code._10D0, key="days_remaining")
174
+ def filter_remaining(self) -> int | None:
175
+ _val = self._msg_value(Code._10D0, key=SZ_REMAINING_DAYS)
176
+ assert isinstance(_val, (int | type(None)))
177
+ return _val
178
+
179
+ @property
180
+ def filter_remaining_percent(self) -> float | None:
181
+ _val = self._msg_value(Code._10D0, key=SZ_REMAINING_PERCENT)
182
+ assert isinstance(_val, (float | type(None)))
183
+ return _val
199
184
 
200
185
 
201
186
  class RfsGateway(DeviceHvac): # RFS: (spIDer gateway)
202
187
  """The spIDer gateway base class."""
203
188
 
204
- _SLUG: str = DEV_TYPE.RFS
189
+ _SLUG: str = DevType.RFS
205
190
 
206
- def __init__(self, *args, **kwargs) -> None:
191
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
207
192
  super().__init__(*args, **kwargs)
208
193
 
209
194
  self.ctl = None
@@ -211,20 +196,20 @@ class RfsGateway(DeviceHvac): # RFS: (spIDer gateway)
211
196
  self.tcs = None
212
197
 
213
198
 
214
- class HvacHumiditySensor(BatteryState, IndoorHumidity): # HUM: I/12A0
199
+ class HvacHumiditySensor(BatteryState, IndoorHumidity, Fakeable): # HUM: I/12A0
215
200
  """The class for a humidity sensor.
216
201
 
217
202
  The cardinal code is 12A0.
218
203
  """
219
204
 
220
- _SLUG: str = DEV_TYPE.HUM
205
+ _SLUG: str = DevType.HUM
221
206
 
222
207
  @property
223
- def temperature(self) -> None | float: # Celsius
208
+ def temperature(self) -> float | None: # Celsius
224
209
  return self._msg_value(Code._12A0, key=SZ_TEMPERATURE)
225
210
 
226
211
  @property
227
- def dewpoint_temp(self) -> None | float: # Celsius
212
+ def dewpoint_temp(self) -> float | None: # Celsius
228
213
  return self._msg_value(Code._12A0, key="dewpoint_temp")
229
214
 
230
215
  @property
@@ -236,13 +221,22 @@ class HvacHumiditySensor(BatteryState, IndoorHumidity): # HUM: I/12A0
236
221
  }
237
222
 
238
223
 
239
- class HvacCarbonDioxideSensor(CarbonDioxide): # CO2: I/1298
224
+ class HvacCarbonDioxideSensor(CarbonDioxide, Fakeable): # CO2: I/1298
240
225
  """The class for a CO2 sensor.
241
226
 
242
227
  The cardinal code is 1298.
243
228
  """
244
229
 
245
- _SLUG: str = DEV_TYPE.CO2
230
+ _SLUG: str = DevType.CO2
231
+
232
+ # .I --- 29:181813 63:262142 --:------ 1FC9 030 00-31E0-76C635 01-31E0-76C635 00-1298-76C635 67-10E0-76C635 00-1FC9-76C635
233
+ # .W --- 32:155617 29:181813 --:------ 1FC9 012 00-31D9-825FE1 00-31DA-825FE1 # The HRU
234
+ # .I --- 29:181813 32:155617 --:------ 1FC9 001 00
235
+
236
+ async def initiate_binding_process(self) -> Packet:
237
+ return await super()._initiate_binding_process(
238
+ (Code._31E0, Code._1298, Code._2E10)
239
+ )
246
240
 
247
241
 
248
242
  class HvacRemote(BatteryState, Fakeable, HvacRemoteBase): # REM: I/22F[138]
@@ -251,48 +245,48 @@ class HvacRemote(BatteryState, Fakeable, HvacRemoteBase): # REM: I/22F[138]
251
245
  The cardinal codes are 22F1, 22F3 (also 22F8?).
252
246
  """
253
247
 
254
- _SLUG: str = DEV_TYPE.REM
248
+ _SLUG: str = DevType.REM
255
249
 
256
- def _bind(self):
257
- # .I --- 37:155617 --:------ 37:155617 1FC9 024 0022F1965FE10022F3965FE16710E0965FE1001FC9965FE1
258
- # .W --- 32:155617 37:155617 --:------ 1FC9 012 0031D9825FE10031DA825FE1
250
+ async def initiate_binding_process(self) -> Packet:
251
+ # .I --- 37:155617 --:------ 37:155617 1FC9 024 00-22F1-965FE1 00-22F3-965FE1 67-10E09-65FE1 00-1FC9-965FE1
252
+ # .W --- 32:155617 37:155617 --:------ 1FC9 012 00-31D9-825FE1 00-31DA-825FE1
259
253
  # .I --- 37:155617 32:155617 --:------ 1FC9 001 00
260
254
 
261
- def callback(msg): # TODO: set_parent()
262
- """Use the accept pkt to determine the zone/domain id."""
263
- _ = msg.payload[SZ_BINDINGS][0][0]
264
- # self.set_parent(msg.src, child_id=child_id, is_sensor=True)
265
-
266
- super()._bind()
267
- self._bind_request((Code._22F1, Code._22F3), callback=callback)
255
+ return await super()._initiate_binding_process(
256
+ Code._22F1 if self._scheme == "nuaire" else (Code._22F1, Code._22F3)
257
+ )
268
258
 
269
259
  @property
270
- def fan_rate(self) -> None | str:
260
+ def fan_rate(self) -> str | None: # 22F1
261
+ # NOTE: WIP: rate can be int or str
271
262
  return self._msg_value(Code._22F1, key="rate")
272
263
 
273
- # @check_faking_enabled
274
264
  @fan_rate.setter
275
- def fan_rate(self, rate) -> None: # I/22F1
276
- if not self._faked:
277
- raise RuntimeError(f"Faking is not enabled for {self}")
278
- for _ in range(3):
279
- self._send_cmd(
280
- Command.set_fan_mode(self.id, int(4 * rate), 4, src_id=self.id)
281
- ) # TODO: needs checking
265
+ def fan_rate(self, value: int) -> None: # NOTE: value can be int or str, not None
266
+ """Fake a fan rate from a remote (to a FAN, is a WIP)."""
267
+
268
+ if not self.is_faked: # NOTE: some remotes are stateless (i.e. except seqn)
269
+ raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
270
+
271
+ # TODO: num_repeats=2, or wait_for_reply=True ?
272
+
273
+ # NOTE: this is not completely understood (i.e. diffs between vendor schemes)
274
+ cmd = Command.set_fan_mode(self.id, int(4 * value), src_id=self.id)
275
+ self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)
282
276
 
283
277
  @property
284
- def fan_mode(self) -> None | str:
285
- return self._msg_value(Code._22F1, key=FAN_MODE)
278
+ def fan_mode(self) -> str | None:
279
+ return self._msg_value(Code._22F1, key=SZ_FAN_MODE)
286
280
 
287
281
  @property
288
- def boost_timer(self) -> Optional[int]:
282
+ def boost_timer(self) -> int | None:
289
283
  return self._msg_value(Code._22F3, key=SZ_BOOST_TIMER)
290
284
 
291
285
  @property
292
286
  def status(self) -> dict[str, Any]:
293
287
  return {
294
288
  **super().status,
295
- FAN_MODE: self.fan_mode,
289
+ SZ_FAN_MODE: self.fan_mode,
296
290
  SZ_BOOST_TIMER: self.boost_timer,
297
291
  }
298
292
 
@@ -300,23 +294,33 @@ class HvacRemote(BatteryState, Fakeable, HvacRemoteBase): # REM: I/22F[138]
300
294
  class HvacDisplayRemote(HvacRemote): # DIS
301
295
  """The DIS (display switch)."""
302
296
 
303
- _SLUG: str = DEV_TYPE.DIS
297
+ _SLUG: str = DevType.DIS
298
+
299
+ # async def initiate_binding_process(self) -> Packet:
300
+ # return await super()._initiate_binding_process(
301
+ # (Code._31E0, Code._1298, Code._2E10)
302
+ # )
304
303
 
305
304
 
306
305
  class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
307
306
  """The FAN (ventilation) class.
308
307
 
309
- The cardinal code are 31D9, 31DA. Signature is RP/31DA.
308
+ The cardinal codes are 31D9, 31DA. Signature is RP/31DA.
310
309
  """
311
310
 
312
311
  # Itho Daalderop (NL)
313
312
  # Heatrae Sadia (UK)
314
313
  # Nuaire (UK), e.g. DRI-ECO-PIV
315
314
  # Orcon/Ventiline
315
+ # ClimaRad (NL)
316
+ # Vasco (B)
316
317
 
317
- _SLUG: str = DEV_TYPE.FAN
318
+ _SLUG: str = DevType.FAN
318
319
 
319
- def _update_schema(self, **schema):
320
+ def _handle_msg(self, *args: Any, **kwargs: Any) -> None:
321
+ return super()._handle_msg(*args, **kwargs)
322
+
323
+ def _update_schema(self, **schema: Any) -> None:
320
324
  """Update a FAN with new schema attrs.
321
325
 
322
326
  Raise an exception if the new schema is not a superset of the existing schema.
@@ -324,11 +328,11 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
324
328
 
325
329
  schema = shrink(SCH_VCS(schema))
326
330
 
327
- for dev_id in schema.get(SZ_REMOTES, {}).keys():
328
- self._gwy.get_device(self._gwy, dev_id)
331
+ for dev_id in schema.get(SZ_REMOTES, {}):
332
+ self._gwy.get_device(dev_id)
329
333
 
330
- for dev_id in schema.get(SZ_SENSORS, {}).keys():
331
- self._gwy.get_device(self._gwy, dev_id)
334
+ for dev_id in schema.get(SZ_SENSORS, {}):
335
+ self._gwy.get_device(dev_id)
332
336
 
333
337
  def _setup_discovery_cmds(self) -> None:
334
338
  super()._setup_discovery_cmds()
@@ -357,80 +361,186 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
357
361
  )
358
362
 
359
363
  @property
360
- def air_quality(self) -> None | float:
364
+ def air_quality(self) -> float | None:
361
365
  return self._msg_value(Code._31DA, key=SZ_AIR_QUALITY)
362
366
 
363
367
  @property
364
- def air_quality_base(self) -> None | float:
365
- return self._msg_value(Code._31DA, key=SZ_AIR_QUALITY_BASE)
368
+ def air_quality_base(self) -> float | None:
369
+ return self._msg_value(Code._31DA, key=SZ_AIR_QUALITY_BASIS)
366
370
 
367
371
  @property
368
- def bypass_position(self) -> Optional[int]:
369
- return self._msg_value(Code._31DA, key=SZ_BYPASS_POSITION)
372
+ def bypass_mode(self) -> str | None:
373
+ """
374
+ :return: bypass mode as on|off|auto
375
+ """
376
+ return self._msg_value(Code._22F7, key=SZ_BYPASS_MODE)
370
377
 
371
378
  @property
372
- def co2_level(self) -> Optional[int]:
373
- return self._msg_value(Code._31DA, key=SZ_CO2_LEVEL)
379
+ def bypass_position(self) -> float | str | None:
380
+ """
381
+ Position info is found in 22F7 and in 31DA. Both are picked up, ignoring None.
382
+ :return: bypass position as percentage: 0.0 (closed) or 1.0 (open), on error: "x_faulted"
383
+ """
384
+ for code in [c for c in (Code._22F7, Code._31DA) if c in self._msgs]:
385
+ if v := self._msgs[code].payload.get(SZ_BYPASS_POSITION):
386
+ if v is not None: # skip none (to fetch other code)
387
+ assert isinstance(v, (float | str))
388
+ return v
389
+ # if both packets exist and both have the key, return the most recent
390
+ return None
391
+
392
+ @property
393
+ def bypass_state(self) -> str | None:
394
+ """
395
+ Orcon, others?
396
+ :return: bypass position as on/off
397
+ """
398
+ return self._msg_value(Code._22F7, key=SZ_BYPASS_STATE)
374
399
 
375
400
  @property
376
- def exhaust_fan_speed(self) -> None | float: # was from: (Code._31D9, Code._31DA)
377
- return self._msg_value(Code._31DA, key=SZ_EXHAUST_FAN_SPEED)
401
+ def co2_level(self) -> int | None:
402
+ return self._msg_value(Code._31DA, key=SZ_CO2_LEVEL)
378
403
 
379
404
  @property
380
- def exhaust_flow(self) -> None | float:
405
+ def exhaust_fan_speed(
406
+ self,
407
+ ) -> float | None:
408
+ """
409
+ Some fans (Vasco, Itho) use Code._31D9 for speed + mode,
410
+ Orcon sends SZ_EXHAUST_FAN_SPEED in 31DA. See parser for details.
411
+ :return: speed as percentage
412
+ """
413
+ speed: float = -1
414
+ for code in [c for c in (Code._31D9, Code._31DA) if c in self._msgs]:
415
+ if v := self._msgs[code].payload.get(SZ_EXHAUST_FAN_SPEED):
416
+ # if both packets exist and both have the key, use the highest value
417
+ if v is not None:
418
+ speed = max(v, speed)
419
+ if speed >= 0:
420
+ return speed
421
+ return None
422
+
423
+ @property
424
+ def exhaust_flow(self) -> float | None:
381
425
  return self._msg_value(Code._31DA, key=SZ_EXHAUST_FLOW)
382
426
 
383
427
  @property
384
- def exhaust_temperature(self) -> None | float:
385
- return self._msg_value(Code._31DA, key=SZ_EXHAUST_TEMPERATURE)
428
+ def exhaust_temp(self) -> float | None:
429
+ if Code._12A0 in self._msgs and isinstance(
430
+ self._msgs[Code._12A0].payload, list
431
+ ): # FAN Ventura sends RH/temps as a list, use element [2] for exhaust temp
432
+ if v := self._msgs[Code._12A0].payload[2].get(SZ_TEMPERATURE):
433
+ assert isinstance(v, (float | type(None)))
434
+ return v
435
+ return None
436
+ return self._msg_value(Code._31DA, key=SZ_EXHAUST_TEMP)
386
437
 
387
438
  @property
388
- def fan_info(self) -> None | str:
389
- return self._msg_value(Code._31DA, key=SZ_FAN_INFO)
439
+ def fan_rate(self) -> str | None:
440
+ """
441
+ Lookup fan mode description from _22F4 message payload, e.g. "low", "medium", "boost".
442
+ For manufacturers Orcon, Vasco, ClimaRad.
443
+
444
+ :return: int or str describing rate of fan
445
+ """
446
+ return self._msg_value(Code._22F4, key=SZ_FAN_RATE)
390
447
 
391
448
  @property
392
- def indoor_humidity(self) -> None | float:
393
- return self._msg_value(Code._31DA, key=SZ_INDOOR_HUMIDITY)
449
+ def fan_mode(self) -> str | None:
450
+ """
451
+ Lookup fan mode description from _22F4 message payload, e.g. "auto", "manual", "off".
452
+ For manufacturers Orcon, Vasco, ClimaRad.
453
+
454
+ :return: a string describing mode
455
+ """
456
+ return self._msg_value(Code._22F4, key=SZ_FAN_MODE)
394
457
 
395
458
  @property
396
- def indoor_temperature(self) -> None | float:
397
- return self._msg_value(Code._31DA, key=SZ_INDOOR_TEMPERATURE)
459
+ def fan_info(self) -> str | None:
460
+ """
461
+ Extract fan info description from _31D9 or _31DA message payload, e.g. "speed 2, medium".
462
+ By its name, the result is automatically displayed in HA Climate UI.
463
+ Some manufacturers (Orcon, Vasco) include the fan mode (auto, manual), others don't (Itho).
464
+
465
+ :return: a string describing mode, speed
466
+ """
467
+ if Code._31D9 in self._msgs:
468
+ # Itho, Vasco D60 and ClimaRad (MiniBox fan) send mode/speed in _31D9
469
+ v: str
470
+ for k, v in self._msgs[Code._31D9].payload.items():
471
+ if k == SZ_FAN_MODE and len(v) > 2: # prevent non-lookups to pass
472
+ return v
473
+ # continue to 31DA
474
+ return str(self._msg_value(Code._31DA, key=SZ_FAN_INFO)) # Itho lookup
398
475
 
399
476
  @property
400
- def outdoor_humidity(self) -> None | float:
477
+ def indoor_humidity(self) -> float | None:
478
+ """
479
+ Extract humidity value from _12A0 or _31DA JSON message payload
480
+
481
+ :return: percentage <= 1.0
482
+ """
483
+ if Code._12A0 in self._msgs and isinstance(
484
+ self._msgs[Code._12A0].payload, list
485
+ ): # FAN Ventura sends a list, use element [0]
486
+ if v := self._msgs[Code._12A0].payload[0].get(SZ_INDOOR_HUMIDITY):
487
+ assert isinstance(v, (float | type(None)))
488
+ return v
489
+ return None # prevent AttributeError: 'list' object has no attribute 'get'
490
+ for code in [c for c in (Code._12A0, Code._31DA) if c in self._msgs]:
491
+ if v := self._msgs[code].payload.get(SZ_INDOOR_HUMIDITY):
492
+ if v is not None: # skip none (to check the other code)
493
+ assert isinstance(v, float)
494
+ return v
495
+ return None
496
+
497
+ @property
498
+ def indoor_temp(self) -> float | None:
499
+ if Code._12A0 in self._msgs and isinstance(
500
+ self._msgs[Code._12A0].payload, list
501
+ ): # FAN Ventura sends RH/temps as a list; element [0] is indoor_temp
502
+ if v := self._msgs[Code._12A0].payload[0].get(SZ_TEMPERATURE):
503
+ assert isinstance(v, (float | type(None)))
504
+ return v
505
+ else:
506
+ return self._msg_value(Code._31DA, key=SZ_INDOOR_TEMP)
507
+ return None
508
+
509
+ @property
510
+ def outdoor_humidity(self) -> float | None:
401
511
  return self._msg_value(Code._31DA, key=SZ_OUTDOOR_HUMIDITY)
402
512
 
403
513
  @property
404
- def outdoor_temperature(self) -> None | float:
405
- return self._msg_value(Code._31DA, key=SZ_OUTDOOR_TEMPERATURE)
514
+ def outdoor_temp(self) -> float | None:
515
+ return self._msg_value(Code._31DA, key=SZ_OUTDOOR_TEMP)
406
516
 
407
517
  @property
408
- def post_heat(self) -> Optional[int]:
518
+ def post_heat(self) -> int | None:
409
519
  return self._msg_value(Code._31DA, key=SZ_POST_HEAT)
410
520
 
411
521
  @property
412
- def pre_heat(self) -> Optional[int]:
522
+ def pre_heat(self) -> int | None:
413
523
  return self._msg_value(Code._31DA, key=SZ_PRE_HEAT)
414
524
 
415
525
  @property
416
- def remaining_time(self) -> Optional[int]:
417
- return self._msg_value(Code._31DA, key=SZ_REMAINING_TIME)
526
+ def remaining_mins(self) -> int | None:
527
+ return self._msg_value(Code._31DA, key=SZ_REMAINING_MINS)
418
528
 
419
529
  @property
420
- def speed_cap(self) -> Optional[int]:
421
- return self._msg_value(Code._31DA, key=SZ_SPEED_CAP)
530
+ def speed_cap(self) -> int | None:
531
+ return self._msg_value(Code._31DA, key=SZ_SPEED_CAPABILITIES)
422
532
 
423
533
  @property
424
- def supply_fan_speed(self) -> None | float:
534
+ def supply_fan_speed(self) -> float | None:
425
535
  return self._msg_value(Code._31DA, key=SZ_SUPPLY_FAN_SPEED)
426
536
 
427
537
  @property
428
- def supply_flow(self) -> None | float:
538
+ def supply_flow(self) -> float | None:
429
539
  return self._msg_value(Code._31DA, key=SZ_SUPPLY_FLOW)
430
540
 
431
541
  @property
432
- def supply_temperature(self) -> None | float:
433
- return self._msg_value(Code._31DA, key=SZ_SUPPLY_TEMPERATURE)
542
+ def supply_temp(self) -> float | None:
543
+ return self._msg_value(Code._31DA, key=SZ_SUPPLY_TEMP)
434
544
 
435
545
  @property
436
546
  def status(self) -> dict[str, Any]:
@@ -439,13 +549,17 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
439
549
  SZ_EXHAUST_FAN_SPEED: self.exhaust_fan_speed,
440
550
  **{
441
551
  k: v
442
- for code in (Code._31D9, Code._31DA)
552
+ for code in [c for c in (Code._31D9, Code._31DA) if c in self._msgs]
443
553
  for k, v in self._msgs[code].payload.items()
444
- if code in self._msgs
445
554
  if k != SZ_EXHAUST_FAN_SPEED
446
555
  },
447
556
  }
448
557
 
558
+ @property
559
+ def temperature(self) -> float | None: # Celsius
560
+ # ClimaRad minibox FAN sends (indoor) temp in 12A0
561
+ return self._msg_value(Code._12A0, key=SZ_TEMPERATURE)
562
+
449
563
 
450
564
  # class HvacFanHru(HvacVentilator):
451
565
  # """A Heat recovery unit (aka: HRU, WTW)."""
@@ -458,12 +572,13 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
458
572
  # _SLUG: str = DEV_TYPE.PIV
459
573
 
460
574
 
461
- HVAC_CLASS_BY_SLUG = class_by_attr(__name__, "_SLUG") # e.g. HUM: HvacHumiditySensor
575
+ # e.g. {"HUM": HvacHumiditySensor}
576
+ HVAC_CLASS_BY_SLUG: dict[str, type[DeviceHvac]] = class_by_attr(__name__, "_SLUG")
462
577
 
463
578
 
464
579
  def class_dev_hvac(
465
- dev_addr: Address, *, msg: Message = None, eavesdrop: bool = False
466
- ) -> type[Device]:
580
+ dev_addr: Address, *, msg: Message | None = None, eavesdrop: bool = False
581
+ ) -> type[DeviceHvac]:
467
582
  """Return a device class, but only if the device must be from the HVAC group.
468
583
 
469
584
  May return a base clase, DeviceHvac, which will need promotion.