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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +279 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +377 -513
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.2.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.2.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.2.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. {ramses_rf/protocol → ramses_tx}/parsers.py +1266 -1003
  47. ramses_tx/protocol.py +801 -0
  48. ramses_tx/protocol_fsm.py +672 -0
  49. ramses_tx/py.typed +0 -0
  50. {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
  51. {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
  52. ramses_tx/transport.py +1471 -0
  53. ramses_tx/typed_dicts.py +492 -0
  54. ramses_tx/typing.py +181 -0
  55. ramses_tx/version.py +4 -0
  56. ramses_rf/discovery.py +0 -398
  57. ramses_rf/protocol/__init__.py +0 -59
  58. ramses_rf/protocol/backports.py +0 -42
  59. ramses_rf/protocol/command.py +0 -1576
  60. ramses_rf/protocol/const.py +0 -697
  61. ramses_rf/protocol/exceptions.py +0 -111
  62. ramses_rf/protocol/helpers.py +0 -390
  63. ramses_rf/protocol/opentherm.py +0 -1170
  64. ramses_rf/protocol/packet.py +0 -235
  65. ramses_rf/protocol/protocol.py +0 -613
  66. ramses_rf/protocol/transport.py +0 -1011
  67. ramses_rf/protocol/version.py +0 -10
  68. ramses_rf/system/hvac.py +0 -82
  69. ramses_rf-0.22.40.dist-info/METADATA +0 -64
  70. ramses_rf-0.22.40.dist-info/RECORD +0 -42
  71. ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
ramses_rf/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,173 @@ 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. The most recent packet is returned.
382
+ :return: bypass position as percentage: 0.0 (closed) or 1.0 (open), on error: "x_faulted"
383
+ """
384
+ # if both packets exist and both have the key, returns the most recent
385
+ return self._msg_value([Code._22F7, Code._31DA], key=SZ_BYPASS_POSITION)
386
+
387
+ @property
388
+ def bypass_state(self) -> str | None:
389
+ """
390
+ Orcon, others?
391
+ :return: bypass position as on/off
392
+ """
393
+ return self._msg_value(Code._22F7, key=SZ_BYPASS_STATE)
374
394
 
375
395
  @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)
396
+ def co2_level(self) -> int | None:
397
+ return self._msg_value(Code._31DA, key=SZ_CO2_LEVEL)
378
398
 
379
399
  @property
380
- def exhaust_flow(self) -> None | float:
400
+ def exhaust_fan_speed(
401
+ self,
402
+ ) -> float | None:
403
+ """
404
+ Some fans (Vasco, Itho) use Code._31D9 for speed + mode,
405
+ Orcon sends SZ_EXHAUST_FAN_SPEED in 31DA. See parser for details.
406
+ :return: speed as percentage
407
+ """
408
+ speed: float = -1
409
+ for code in [c for c in (Code._31D9, Code._31DA) if c in self._msgs]:
410
+ if v := self._msgs[code].payload.get(SZ_EXHAUST_FAN_SPEED):
411
+ # if both packets exist and both have the key, use the highest value
412
+ if v is not None:
413
+ speed = max(v, speed)
414
+ if speed >= 0:
415
+ return speed
416
+ return None
417
+
418
+ @property
419
+ def exhaust_flow(self) -> float | None:
381
420
  return self._msg_value(Code._31DA, key=SZ_EXHAUST_FLOW)
382
421
 
383
422
  @property
384
- def exhaust_temperature(self) -> None | float:
385
- return self._msg_value(Code._31DA, key=SZ_EXHAUST_TEMPERATURE)
423
+ def exhaust_temp(self) -> float | None:
424
+ return self._msg_value(Code._31DA, key=SZ_EXHAUST_TEMP)
386
425
 
387
426
  @property
388
- def fan_info(self) -> None | str:
389
- return self._msg_value(Code._31DA, key=SZ_FAN_INFO)
427
+ def fan_rate(self) -> str | None:
428
+ """
429
+ Lookup fan mode description from _22F4 message payload, e.g. "low", "medium", "boost".
430
+ For manufacturers Orcon, Vasco, ClimaRad.
431
+
432
+ :return: int or str describing rate of fan
433
+ """
434
+ return self._msg_value(Code._22F4, key=SZ_FAN_RATE)
390
435
 
391
436
  @property
392
- def indoor_humidity(self) -> None | float:
393
- return self._msg_value(Code._31DA, key=SZ_INDOOR_HUMIDITY)
437
+ def fan_mode(self) -> str | None:
438
+ """
439
+ Lookup fan mode description from _22F4 message payload, e.g. "auto", "manual", "off".
440
+ For manufacturers Orcon, Vasco, ClimaRad.
441
+
442
+ :return: a string describing mode
443
+ """
444
+ return self._msg_value(Code._22F4, key=SZ_FAN_MODE)
394
445
 
395
446
  @property
396
- def indoor_temperature(self) -> None | float:
397
- return self._msg_value(Code._31DA, key=SZ_INDOOR_TEMPERATURE)
447
+ def fan_info(self) -> str | None:
448
+ """
449
+ Extract fan info description from _31D9 or _31DA message payload, e.g. "speed 2, medium".
450
+ By its name, the result is automatically displayed in HA Climate UI.
451
+ Some manufacturers (Orcon, Vasco) include the fan mode (auto, manual), others don't (Itho).
452
+
453
+ :return: a string describing mode, speed
454
+ """
455
+ if Code._31D9 in self._msgs:
456
+ # Itho, Vasco D60 and ClimaRad (MiniBox fan) send mode/speed in _31D9
457
+ v: str
458
+ for k, v in self._msgs[Code._31D9].payload.items():
459
+ if k == SZ_FAN_MODE and len(v) > 2: # prevent non-lookups to pass
460
+ return v
461
+ # continue to 31DA
462
+ return str(self._msg_value(Code._31DA, key=SZ_FAN_INFO)) # Itho lookup
398
463
 
399
464
  @property
400
- def outdoor_humidity(self) -> None | float:
465
+ def indoor_humidity(self) -> float | None:
466
+ """
467
+ Extract humidity value from _12A0 or _31DA JSON message payload
468
+
469
+ :return: percentage <= 1.0
470
+ """
471
+ if Code._12A0 in self._msgs and isinstance(
472
+ self._msgs[Code._12A0].payload, list
473
+ ): # FAN Ventura sends RH/temps as a list; element [0] contains indoor_hum
474
+ if v := self._msgs[Code._12A0].payload[0].get(SZ_INDOOR_HUMIDITY):
475
+ assert isinstance(v, (float | type(None)))
476
+ return v
477
+ return self._msg_value([Code._12A0, Code._31DA], key=SZ_INDOOR_HUMIDITY)
478
+
479
+ @property
480
+ def indoor_temp(self) -> float | None:
481
+ return self._msg_value(Code._31DA, key=SZ_INDOOR_TEMP)
482
+
483
+ @property
484
+ def outdoor_humidity(self) -> float | None:
485
+ if Code._12A0 in self._msgs and isinstance(
486
+ self._msgs[Code._12A0].payload, list
487
+ ): # FAN Ventura sends RH/temps as a list; element [1] contains outdoor_hum
488
+ if v := self._msgs[Code._12A0].payload[1].get(SZ_OUTDOOR_HUMIDITY):
489
+ assert isinstance(v, (float | type(None)))
490
+ return v
401
491
  return self._msg_value(Code._31DA, key=SZ_OUTDOOR_HUMIDITY)
402
492
 
403
493
  @property
404
- def outdoor_temperature(self) -> None | float:
405
- return self._msg_value(Code._31DA, key=SZ_OUTDOOR_TEMPERATURE)
494
+ def outdoor_temp(self) -> float | None:
495
+ return self._msg_value(Code._31DA, key=SZ_OUTDOOR_TEMP)
406
496
 
407
497
  @property
408
- def post_heat(self) -> Optional[int]:
498
+ def post_heat(self) -> int | None:
409
499
  return self._msg_value(Code._31DA, key=SZ_POST_HEAT)
410
500
 
411
501
  @property
412
- def pre_heat(self) -> Optional[int]:
502
+ def pre_heat(self) -> int | None:
413
503
  return self._msg_value(Code._31DA, key=SZ_PRE_HEAT)
414
504
 
415
505
  @property
416
- def remaining_time(self) -> Optional[int]:
417
- return self._msg_value(Code._31DA, key=SZ_REMAINING_TIME)
506
+ def remaining_mins(self) -> int | None:
507
+ return self._msg_value(Code._31DA, key=SZ_REMAINING_MINS)
418
508
 
419
509
  @property
420
- def speed_cap(self) -> Optional[int]:
421
- return self._msg_value(Code._31DA, key=SZ_SPEED_CAP)
510
+ def speed_cap(self) -> int | None:
511
+ return self._msg_value(Code._31DA, key=SZ_SPEED_CAPABILITIES)
422
512
 
423
513
  @property
424
- def supply_fan_speed(self) -> None | float:
514
+ def supply_fan_speed(self) -> float | None:
425
515
  return self._msg_value(Code._31DA, key=SZ_SUPPLY_FAN_SPEED)
426
516
 
427
517
  @property
428
- def supply_flow(self) -> None | float:
518
+ def supply_flow(self) -> float | None:
429
519
  return self._msg_value(Code._31DA, key=SZ_SUPPLY_FLOW)
430
520
 
431
521
  @property
432
- def supply_temperature(self) -> None | float:
433
- return self._msg_value(Code._31DA, key=SZ_SUPPLY_TEMPERATURE)
522
+ def supply_temp(self) -> float | None:
523
+ if Code._12A0 in self._msgs and isinstance(
524
+ self._msgs[Code._12A0].payload, list
525
+ ): # FAN Ventura sends RH/temps as a list;
526
+ # pass element [0] in place of supply_temp, which is always None in VenturaV1x 31DA
527
+ if v := self._msgs[Code._12A0].payload[1].get(SZ_TEMPERATURE):
528
+ assert isinstance(v, (float | type(None)))
529
+ return v
530
+ return self._msg_value(Code._31DA, key=SZ_SUPPLY_TEMP)
434
531
 
435
532
  @property
436
533
  def status(self) -> dict[str, Any]:
@@ -439,13 +536,23 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
439
536
  SZ_EXHAUST_FAN_SPEED: self.exhaust_fan_speed,
440
537
  **{
441
538
  k: v
442
- for code in (Code._31D9, Code._31DA)
539
+ for code in [c for c in (Code._31D9, Code._31DA) if c in self._msgs]
443
540
  for k, v in self._msgs[code].payload.items()
444
- if code in self._msgs
445
541
  if k != SZ_EXHAUST_FAN_SPEED
446
542
  },
447
543
  }
448
544
 
545
+ @property
546
+ def temperature(self) -> float | None: # Celsius
547
+ if Code._12A0 in self._msgs and isinstance(
548
+ self._msgs[Code._12A0].payload, list
549
+ ): # FAN Ventura sends RH/temps as a list; use element [1]
550
+ if v := self._msgs[Code._12A0].payload[0].get(SZ_TEMPERATURE):
551
+ assert isinstance(v, (float | type(None)))
552
+ return v
553
+ # ClimaRad minibox FAN sends (indoor) temp in 12A0
554
+ return self._msg_value(Code._12A0, key=SZ_TEMPERATURE)
555
+
449
556
 
450
557
  # class HvacFanHru(HvacVentilator):
451
558
  # """A Heat recovery unit (aka: HRU, WTW)."""
@@ -458,12 +565,13 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
458
565
  # _SLUG: str = DEV_TYPE.PIV
459
566
 
460
567
 
461
- HVAC_CLASS_BY_SLUG = class_by_attr(__name__, "_SLUG") # e.g. HUM: HvacHumiditySensor
568
+ # e.g. {"HUM": HvacHumiditySensor}
569
+ HVAC_CLASS_BY_SLUG: dict[str, type[DeviceHvac]] = class_by_attr(__name__, "_SLUG")
462
570
 
463
571
 
464
572
  def class_dev_hvac(
465
- dev_addr: Address, *, msg: Message = None, eavesdrop: bool = False
466
- ) -> type[Device]:
573
+ dev_addr: Address, *, msg: Message | None = None, eavesdrop: bool = False
574
+ ) -> type[DeviceHvac]:
467
575
  """Return a device class, but only if the device must be from the HVAC group.
468
576
 
469
577
  May return a base clase, DeviceHvac, which will need promotion.