ramses-rf 0.51.7__py3-none-any.whl → 0.51.8__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.
ramses_rf/device/hvac.py CHANGED
@@ -4,6 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import logging
7
+ from collections.abc import Callable
7
8
  from typing import Any, TypeVar
8
9
 
9
10
  from ramses_rf import exceptions as exc
@@ -41,8 +42,6 @@ from ramses_rf.const import (
41
42
  DevType,
42
43
  )
43
44
  from ramses_rf.entity_base import class_by_attr
44
- from ramses_rf.helpers import shrink
45
- from ramses_rf.schemas import SCH_VCS, SZ_REMOTES, SZ_SENSORS
46
45
  from ramses_tx import Address, Command, Message, Packet, Priority
47
46
  from ramses_tx.ramses import CODES_OF_HVAC_DOMAIN_ONLY, HVAC_KLASS_BY_VC_PAIR
48
47
 
@@ -76,10 +75,22 @@ _HvacSensorBaseT = TypeVar("_HvacSensorBaseT", bound="HvacSensorBase")
76
75
 
77
76
 
78
77
  class HvacRemoteBase(DeviceHvac):
78
+ """Base class for HVAC remote control devices.
79
+
80
+ This class serves as a base for all remote control devices in the HVAC domain.
81
+ It provides common functionality and interfaces for remote control operations.
82
+ """
83
+
79
84
  pass
80
85
 
81
86
 
82
87
  class HvacSensorBase(DeviceHvac):
88
+ """Base class for HVAC sensor devices.
89
+
90
+ This class serves as a base for all sensor devices in the HVAC domain.
91
+ It provides common functionality for sensor data collection and processing.
92
+ """
93
+
83
94
  pass
84
95
 
85
96
 
@@ -87,12 +98,22 @@ class CarbonDioxide(HvacSensorBase): # 1298
87
98
  """The CO2 sensor (cardinal code is 1298)."""
88
99
 
89
100
  @property
90
- def co2_level(self) -> int | None: # 1298
101
+ def co2_level(self) -> int | None:
102
+ """Get the CO2 level in ppm.
103
+
104
+ :return: The CO2 level in parts per million (ppm), or None if not available
105
+ :rtype: int | None
106
+ """
91
107
  return self._msg_value(Code._1298, key=SZ_CO2_LEVEL)
92
108
 
93
109
  @co2_level.setter
94
110
  def co2_level(self, value: int | None) -> None:
95
- """Fake the CO2 level of the sensor."""
111
+ """Set a fake CO2 level for the sensor.
112
+
113
+ :param value: The CO2 level in ppm to set, or None to clear the fake value
114
+ :type value: int | None
115
+ :raises TypeError: If the sensor is not in faked mode
116
+ """
96
117
 
97
118
  if not self.is_faked:
98
119
  raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
@@ -102,6 +123,11 @@ class CarbonDioxide(HvacSensorBase): # 1298
102
123
 
103
124
  @property
104
125
  def status(self) -> dict[str, Any]:
126
+ """Return the status of the CO2 sensor.
127
+
128
+ :return: A dictionary containing the sensor's status including CO2 level
129
+ :rtype: dict[str, Any]
130
+ """
105
131
  return {
106
132
  **super().status,
107
133
  SZ_CO2_LEVEL: self.co2_level,
@@ -112,12 +138,22 @@ class IndoorHumidity(HvacSensorBase): # 12A0
112
138
  """The relative humidity sensor (12A0)."""
113
139
 
114
140
  @property
115
- def indoor_humidity(self) -> float | None: # 12A0
141
+ def indoor_humidity(self) -> float | None:
142
+ """Get the indoor relative humidity.
143
+
144
+ :return: The indoor relative humidity as a percentage (0-100), or None if not available
145
+ :rtype: float | None
146
+ """
116
147
  return self._msg_value(Code._12A0, key=SZ_INDOOR_HUMIDITY)
117
148
 
118
149
  @indoor_humidity.setter
119
150
  def indoor_humidity(self, value: float | None) -> None:
120
- """Fake the indoor humidity of the sensor."""
151
+ """Set a fake indoor humidity value for the sensor.
152
+
153
+ :param value: The humidity percentage to set (0-100), or None to clear the fake value
154
+ :type value: float | None
155
+ :raises TypeError: If the sensor is not in faked mode
156
+ """
121
157
 
122
158
  if not self.is_faked:
123
159
  raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
@@ -127,6 +163,11 @@ class IndoorHumidity(HvacSensorBase): # 12A0
127
163
 
128
164
  @property
129
165
  def status(self) -> dict[str, Any]:
166
+ """Return the status of the indoor humidity sensor.
167
+
168
+ :return: A dictionary containing the sensor's status including humidity level
169
+ :rtype: dict[str, Any]
170
+ """
130
171
  return {
131
172
  **super().status,
132
173
  SZ_INDOOR_HUMIDITY: self.indoor_humidity,
@@ -142,11 +183,21 @@ class PresenceDetect(HvacSensorBase): # 2E10
142
183
 
143
184
  @property
144
185
  def presence_detected(self) -> bool | None:
186
+ """Get the presence detection status.
187
+
188
+ :return: True if presence is detected, False if not, None if status is unknown
189
+ :rtype: bool | None
190
+ """
145
191
  return self._msg_value(Code._2E10, key=SZ_PRESENCE_DETECTED)
146
192
 
147
193
  @presence_detected.setter
148
194
  def presence_detected(self, value: bool | None) -> None:
149
- """Fake the presence state of the sensor."""
195
+ """Set a fake presence detection state for the sensor.
196
+
197
+ :param value: The presence state to set (True/False), or None to clear the fake value
198
+ :type value: bool | None
199
+ :raises TypeError: If the sensor is not in faked mode
200
+ """
150
201
 
151
202
  if not self.is_faked:
152
203
  raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
@@ -156,6 +207,11 @@ class PresenceDetect(HvacSensorBase): # 2E10
156
207
 
157
208
  @property
158
209
  def status(self) -> dict[str, Any]:
210
+ """Return the status of the presence sensor.
211
+
212
+ :return: A dictionary containing the sensor's status including presence detection state
213
+ :rtype: dict[str, Any]
214
+ """
159
215
  return {
160
216
  **super().status,
161
217
  SZ_PRESENCE_DETECTED: self.presence_detected,
@@ -166,6 +222,7 @@ class FilterChange(DeviceHvac): # FAN: 10D0
166
222
  """The filter state sensor (10D0)."""
167
223
 
168
224
  def _setup_discovery_cmds(self) -> None:
225
+ """Set up the discovery commands for the filter change sensor."""
169
226
  super()._setup_discovery_cmds()
170
227
 
171
228
  self._add_discovery_cmd(
@@ -174,12 +231,22 @@ class FilterChange(DeviceHvac): # FAN: 10D0
174
231
 
175
232
  @property
176
233
  def filter_remaining(self) -> int | None:
234
+ """Return the remaining days until filter change is needed.
235
+
236
+ :return: Number of days remaining until filter change, or None if not available
237
+ :rtype: int | None
238
+ """
177
239
  _val = self._msg_value(Code._10D0, key=SZ_REMAINING_DAYS)
178
240
  assert isinstance(_val, (int | type(None)))
179
241
  return _val
180
242
 
181
243
  @property
182
244
  def filter_remaining_percent(self) -> float | None:
245
+ """Return the remaining filter life as a percentage.
246
+
247
+ :return: Percentage of filter life remaining (0-100), or None if not available
248
+ :rtype: float | None
249
+ """
183
250
  _val = self._msg_value(Code._10D0, key=SZ_REMAINING_PERCENT)
184
251
  assert isinstance(_val, (float | type(None)))
185
252
  return _val
@@ -191,6 +258,11 @@ class RfsGateway(DeviceHvac): # RFS: (spIDer gateway)
191
258
  _SLUG: str = DevType.RFS
192
259
 
193
260
  def __init__(self, *args: Any, **kwargs: Any) -> None:
261
+ """Initialize the RFS gateway.
262
+
263
+ :param args: Positional arguments passed to the parent class
264
+ :param kwargs: Keyword arguments passed to the parent class
265
+ """
194
266
  super().__init__(*args, **kwargs)
195
267
 
196
268
  self.ctl = None
@@ -207,15 +279,30 @@ class HvacHumiditySensor(BatteryState, IndoorHumidity, Fakeable): # HUM: I/12A0
207
279
  _SLUG: str = DevType.HUM
208
280
 
209
281
  @property
210
- def temperature(self) -> float | None: # Celsius
282
+ def temperature(self) -> float | None:
283
+ """Return the current temperature in Celsius.
284
+
285
+ :return: The temperature in degrees Celsius, or None if not available
286
+ :rtype: float | None
287
+ """
211
288
  return self._msg_value(Code._12A0, key=SZ_TEMPERATURE)
212
289
 
213
290
  @property
214
- def dewpoint_temp(self) -> float | None: # Celsius
291
+ def dewpoint_temp(self) -> float | None:
292
+ """Return the dewpoint temperature in Celsius.
293
+
294
+ :return: The dewpoint temperature in degrees Celsius, or None if not available
295
+ :rtype: float | None
296
+ """
215
297
  return self._msg_value(Code._12A0, key="dewpoint_temp")
216
298
 
217
299
  @property
218
300
  def status(self) -> dict[str, Any]:
301
+ """Return the status of the humidity sensor.
302
+
303
+ :return: A dictionary containing the sensor's status including temperature and humidity
304
+ :rtype: dict[str, Any]
305
+ """
219
306
  return {
220
307
  **super().status,
221
308
  SZ_TEMPERATURE: self.temperature,
@@ -236,6 +323,12 @@ class HvacCarbonDioxideSensor(CarbonDioxide, Fakeable): # CO2: I/1298
236
323
  # .I --- 29:181813 32:155617 --:------ 1FC9 001 00
237
324
 
238
325
  async def initiate_binding_process(self) -> Packet:
326
+ """Initiate the binding process for the CO2 sensor.
327
+
328
+ :return: The packet sent to initiate binding
329
+ :rtype: Packet
330
+ :raises exc.BindingError: If binding fails
331
+ """
239
332
  return await super()._initiate_binding_process(
240
333
  (Code._31E0, Code._1298, Code._2E10)
241
334
  )
@@ -259,13 +352,24 @@ class HvacRemote(BatteryState, Fakeable, HvacRemoteBase): # REM: I/22F[138]
259
352
  )
260
353
 
261
354
  @property
262
- def fan_rate(self) -> str | None: # 22F1
263
- # NOTE: WIP: rate can be int or str
355
+ def fan_rate(self) -> str | None:
356
+ """Get the current fan rate setting.
357
+
358
+ :return: The fan rate as a string, or None if not available
359
+ :rtype: str | None
360
+ :note: This is a work in progress - rate can be either int or str
361
+ """
264
362
  return self._msg_value(Code._22F1, key="rate")
265
363
 
266
364
  @fan_rate.setter
267
- def fan_rate(self, value: int) -> None: # NOTE: value can be int or str, not None
268
- """Fake a fan rate from a remote (to a FAN, is a WIP)."""
365
+ def fan_rate(self, value: int) -> None:
366
+ """Set a fake fan rate for the remote control.
367
+
368
+ :param value: The fan rate to set (can be int or str, but not None)
369
+ :type value: int
370
+ :raises TypeError: If the remote is not in faked mode
371
+ :note: This is a work in progress
372
+ """
269
373
 
270
374
  if not self.is_faked: # NOTE: some remotes are stateless (i.e. except seqn)
271
375
  raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
@@ -278,10 +382,20 @@ class HvacRemote(BatteryState, Fakeable, HvacRemoteBase): # REM: I/22F[138]
278
382
 
279
383
  @property
280
384
  def fan_mode(self) -> str | None:
385
+ """Return the current fan mode.
386
+
387
+ :return: The fan mode as a string, or None if not available
388
+ :rtype: str | None
389
+ """
281
390
  return self._msg_value(Code._22F1, key=SZ_FAN_MODE)
282
391
 
283
392
  @property
284
393
  def boost_timer(self) -> int | None:
394
+ """Return the remaining boost timer in minutes.
395
+
396
+ :return: The remaining boost time in minutes, or None if boost is not active
397
+ :rtype: int | None
398
+ """
285
399
  return self._msg_value(Code._22F3, key=SZ_BOOST_TIMER)
286
400
 
287
401
  @property
@@ -304,10 +418,15 @@ class HvacDisplayRemote(HvacRemote): # DIS
304
418
  # )
305
419
 
306
420
 
307
- class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
421
+ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
308
422
  """The FAN (ventilation) class.
309
423
 
310
424
  The cardinal codes are 31D9, 31DA. Signature is RP/31DA.
425
+
426
+ Also handles 2411 parameter messages for configuration.
427
+ Since 2411 is not supported by all vendors, discovery is used to determine if it is supported.
428
+ Since more than 1 different parameters can be sent on 2411 messages,
429
+ we will process these in the dedicated _handle_2411_message method.
311
430
  """
312
431
 
313
432
  # Itho Daalderop (NL)
@@ -319,24 +438,201 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
319
438
 
320
439
  _SLUG: str = DevType.FAN
321
440
 
322
- def _handle_msg(self, *args: Any, **kwargs: Any) -> None:
323
- return super()._handle_msg(*args, **kwargs)
441
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
442
+ """Initialize the HvacVentilator.
443
+
444
+ :param args: Positional arguments passed to the parent class
445
+ :param kwargs: Keyword arguments passed to the parent class
446
+ """
447
+ super().__init__(*args, **kwargs)
448
+ self._supports_2411 = False # Flag for 2411 parameter support
449
+ self._initialized_callback = None # Called when device is fully initialized
450
+ self._param_update_callback = None # Called when 2411 parameters are updated
451
+ self._hgi: Any | None = None # Will be set when HGI is available
452
+ self._bound_devices: dict[str, str] = {} # Track bound devices (e.g., REM/DIS)
453
+
454
+ def set_initialized_callback(self, callback: Callable[[], None] | None) -> None:
455
+ """Set a callback to be executed when the next message (any) is received.
456
+
457
+ The callback will be used exactly once to indicate that the device is fully functional.
458
+ In ramses_cc, 2411 entities are created - on the fly - only for devices that support them.
459
+
460
+ :param callback: A callable that takes no arguments and returns None.
461
+ If None, any existing callback will be cleared.
462
+ :type callback: Callable[[], None] | None
463
+ :raises ValueError: If the callback is not callable and not None
464
+ """
465
+ if callback is not None and not callable(callback):
466
+ raise ValueError("Callback must be callable or None")
324
467
 
325
- def _update_schema(self, **schema: Any) -> None:
326
- """Update a FAN with new schema attrs.
468
+ self._initialized_callback = callback
469
+ if callback is not None:
470
+ _LOGGER.debug("Initialization callback set for %s", self.id)
327
471
 
328
- Raise an exception if the new schema is not a superset of the existing schema.
472
+ def _handle_initialized_callback(self) -> None:
473
+ """Handle the initialization callback.
474
+
475
+ This method is called when the device has been fully initialized and
476
+ is ready to process commands. It triggers any registered initialization
477
+ callbacks and performs necessary setup for 2411 parameter support.
478
+ """
479
+ if self._initialized_callback is not None and self.supports_2411:
480
+ _LOGGER.debug("2411-Device initialized: %s", self.id)
481
+ if callable(self._initialized_callback):
482
+ try:
483
+ self._initialized_callback()
484
+ except Exception as ex:
485
+ _LOGGER.warning("Error in initialized_callback: %s", ex)
486
+ finally:
487
+ # Clear the callback so it's only called once
488
+ self._initialized_callback = None
489
+
490
+ def set_param_update_callback(
491
+ self, callback: Callable[[str, Any], None] | None
492
+ ) -> None:
493
+ """Set a callback to be called when 2411 parameters are updated.
494
+
495
+ This method registers a callback function that will be invoked whenever
496
+ a 2411 parameter is updated. The callback receives the parameter ID and
497
+ its new value as arguments.
498
+
499
+ Since 2411 parameters are configuration entities, we are not polling for them
500
+ and we update them immediately after receiving a 2411 message. We don't wait for them,
501
+ we only process when we see a 2411 response for our device. The request may have come
502
+ from another REM or DIS, but we will update to that as well.
503
+
504
+ :param callback: A callable that will be invoked with (param_id, value) when a
505
+ 2411 parameter is updated, or None to clear the current callback
506
+ :type callback: Callable[[str, Any], None] | None
507
+ """
508
+ self._param_update_callback = callback
509
+
510
+ def _handle_param_update(self, param_id: str, value: Any) -> None:
511
+ """Handle a parameter update and notify listeners.
512
+
513
+ This method processes parameter updates and notifies any registered
514
+ callbacks of the change. It ensures thread safety and handles any
515
+ exceptions that may occur during callback execution.
516
+
517
+ :param param_id: The ID of the parameter that was updated
518
+ :type param_id: str
519
+ :param value: The new value of the parameter
520
+ :type value: Any
521
+ """
522
+ if callable(self._param_update_callback):
523
+ try:
524
+ self._param_update_callback(param_id, value)
525
+ except Exception as ex:
526
+ _LOGGER.warning("Error in param_update_callback: %s", ex)
527
+
528
+ @property
529
+ def supports_2411(self) -> bool:
530
+ """Return whether this device supports 2411 parameters.
531
+
532
+ :return: True if the device supports 2411 parameters, False otherwise
533
+ :rtype: bool
329
534
  """
535
+ return self._supports_2411
536
+
537
+ @property
538
+ def hgi(self) -> Any | None:
539
+ """Return the HGI (Home Gateway Interface) device if available.
330
540
 
331
- schema = shrink(SCH_VCS(schema))
541
+ The HGI device provides additional functionality for certain operations.
542
+
543
+ :return: The HGI device instance, or None if not available
544
+ :rtype: Any | None
545
+ """
546
+ if self._hgi is None and self._gwy and hasattr(self._gwy, "hgi"):
547
+ self._hgi = self._gwy.hgi
548
+ return self._hgi
332
549
 
333
- for dev_id in schema.get(SZ_REMOTES, {}):
334
- self._gwy.get_device(dev_id)
550
+ def _handle_2411_message(self, msg: Message) -> None:
551
+ """Handle incoming 2411 parameter messages.
552
+
553
+ This method processes 2411 parameter update messages, updates the device's
554
+ message store, and triggers any registered parameter update callbacks.
555
+ It handles parameter value normalization and validation.
556
+
557
+ :param msg: The incoming 2411 message
558
+ :type msg: Message
559
+ """
560
+ if not hasattr(msg, "payload") or not isinstance(msg.payload, dict):
561
+ _LOGGER.debug("Invalid 2411 message format: %s", msg)
562
+ return
563
+
564
+ param_id = msg.payload.get("parameter")
565
+ param_value = msg.payload.get("value")
566
+
567
+ if not param_id or param_value is None:
568
+ _LOGGER.debug("Missing parameter ID or value in 2411 message: %s", msg)
569
+ return
570
+
571
+ # Create a composite key for this parameter using the normalized ID
572
+ key = f"{Code._2411}_{param_id}"
573
+
574
+ # Store the message in the device's message store
575
+ old_value = self._msgs.get(Code._2411)
576
+ # Use direct assignment for Code._2411 key
577
+ self._msgs[Code._2411] = msg
578
+ # For the composite key, we need to bypass type checking
579
+ self._msgs[key] = msg # type: ignore[index]
580
+
581
+ _LOGGER.debug(
582
+ "Updated 2411 parameter %s = %s (was: %s) for %s",
583
+ param_id,
584
+ param_value,
585
+ old_value.payload if old_value else None,
586
+ self.id,
587
+ )
335
588
 
336
- for dev_id in schema.get(SZ_SENSORS, {}):
337
- self._gwy.get_device(dev_id)
589
+ # Mark that we support 2411 parameters
590
+ if not self._supports_2411:
591
+ self._supports_2411 = True
592
+ _LOGGER.debug("Device %s supports 2411 parameters", self.id)
593
+
594
+ # Round parameter 75 values to 1 decimal place
595
+ if param_id == "75" and isinstance(param_value, int | float):
596
+ param_value = round(float(param_value), 1)
597
+
598
+ # call the 2411 parameter update callback
599
+ self._handle_param_update(param_id, param_value)
600
+
601
+ def _handle_msg(self, msg: Message) -> None:
602
+ """Handle a message from this device.
603
+
604
+ This method processes incoming messages for the device, with special
605
+ handling for 2411 parameter messages. It updates the device state and
606
+ triggers any necessary callbacks.
607
+
608
+ After handling the messages, it calls the initialized callback if set to notify that
609
+ the device was fully initialized.
610
+
611
+ :param msg: The incoming message to process
612
+ :type msg: Message
613
+ """
614
+ super()._handle_msg(msg)
615
+
616
+ # Handle 2411 parameter messages
617
+ if msg.code == Code._2411:
618
+ _LOGGER.debug(
619
+ "Received 2411 message from %s: verb=%s, payload=%s, src=%s, dst=%s",
620
+ self.id,
621
+ msg.verb,
622
+ msg.payload,
623
+ msg.src,
624
+ msg.dst,
625
+ )
626
+ self._handle_2411_message(msg)
627
+
628
+ self._handle_initialized_callback()
338
629
 
339
630
  def _setup_discovery_cmds(self) -> None:
631
+ """Set up discovery commands for the RFS gateway.
632
+
633
+ This method initializes the discovery commands needed to identify and
634
+ communicate with the RFS gateway device.
635
+ """
340
636
  super()._setup_discovery_cmds()
341
637
 
342
638
  # RP --- 32:155617 18:005904 --:------ 22F1 003 000207
@@ -344,14 +640,25 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
344
640
  Command.from_attrs(RQ, self.id, Code._22F1, "00"), 60 * 60 * 24, delay=15
345
641
  ) # to learn scheme: orcon/itho/other (04/07/0?)
346
642
 
643
+ # Add a single discovery command for all parameters (3F likely to be supported if any)
644
+ # The handler will process the response and update the appropriate parameter and
645
+ # also set the supports_2411 flag
646
+ _LOGGER.debug("Adding single discovery command for all 2411 parameters")
647
+ self._add_discovery_cmd(
648
+ Command.from_attrs(RQ, self.id, Code._2411, "00003F"),
649
+ interval=60 * 60 * 24, # Check daily
650
+ delay=40, # Initial delay before first discovery
651
+ )
652
+
653
+ # Standard discovery commands for other codes
347
654
  for code in (
348
- Code._2210,
349
- Code._22E0,
350
- Code._22E5,
351
- Code._22E9,
352
- Code._22F2,
353
- Code._22F4,
354
- Code._22F8,
655
+ Code._2210, # Air quality
656
+ Code._22E0, # Bypass position
657
+ Code._22E5, # Remaining minutes
658
+ Code._22E9, # Speed cap
659
+ Code._22F2, # Post heat
660
+ Code._22F4, # Pre heat
661
+ Code._22F8, # Air quality base
355
662
  ):
356
663
  self._add_discovery_cmd(
357
664
  Command.from_attrs(RQ, self.id, code, "00"), 60 * 30, delay=15
@@ -362,12 +669,171 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
362
669
  Command.from_attrs(RQ, self.id, code, "00"), 60 * 30, delay=30
363
670
  )
364
671
 
672
+ def add_bound_device(self, device_id: str, device_type: str) -> None:
673
+ """Add a bound device to this FAN.
674
+
675
+ This method registers a REM or DIS device as bound to this FAN device.
676
+ Bound devices are required for certain operations like setting parameters.
677
+
678
+ A bound device is needed to be able to send 2411 parameter Set messages,
679
+ or the device will not accept and respond to them.
680
+ In HomeAssistant, ramses_cc, you can set a bound device in the device configuration.
681
+
682
+ System schema and known devices example:
683
+ "32:153289":
684
+ bound: "37:168270"
685
+ class: FAN
686
+ "37:168270":
687
+ class: REM
688
+ faked: true
689
+
690
+ :param device_id: The unique identifier of the device to bind
691
+ :type device_id: str
692
+ :param device_type: The type of device (must be 'REM' or 'DIS')
693
+ :type device_type: str
694
+ :raises ValueError: If the device type is not 'REM' or 'DIS'
695
+ """
696
+ if device_type not in (DevType.REM, DevType.DIS):
697
+ _LOGGER.warning(
698
+ "Cannot bind device %s of type %s to FAN %s: must be REM or DIS",
699
+ device_id,
700
+ device_type,
701
+ self.id,
702
+ )
703
+ return
704
+
705
+ self._bound_devices[device_id] = device_type
706
+ _LOGGER.info("Bound %s device %s to FAN %s", device_type, device_id, self.id)
707
+
708
+ def remove_bound_device(self, device_id: str) -> None:
709
+ """Remove a bound device from this FAN.
710
+
711
+ This method unregisters a previously bound device from this FAN.
712
+
713
+ :param device_id: The unique identifier of the device to unbind
714
+ :type device_id: str
715
+ """
716
+ if device_id in self._bound_devices:
717
+ device_type = self._bound_devices.pop(device_id)
718
+ _LOGGER.info(
719
+ "Removed bound %s device %s from FAN %s",
720
+ device_type,
721
+ device_id,
722
+ self.id,
723
+ )
724
+
725
+ def get_bound_rem(self) -> str | None:
726
+ """Get the first bound REM/DIS device ID for this FAN.
727
+
728
+ This method retrieves the device ID of the first bound REM or DIS device.
729
+ Bound devices are required for certain operations like setting parameters.
730
+
731
+ :return: The device ID of the first bound REM or DIS device, or None if none found
732
+ :rtype: str | None
733
+ """
734
+ if not self._bound_devices:
735
+ _LOGGER.debug("No bound devices found for FAN %s", self.id)
736
+ return None
737
+
738
+ # Find first REM or DIS device
739
+ for device_id, device_type in self._bound_devices.items():
740
+ if device_type in (DevType.REM, DevType.DIS):
741
+ _LOGGER.debug(
742
+ "Found bound %s device %s for FAN %s",
743
+ device_type,
744
+ device_id,
745
+ self.id,
746
+ )
747
+ return device_id
748
+
749
+ _LOGGER.debug("No bound REM or DIS devices found for FAN %s", self.id)
750
+ return None
751
+
752
+ def get_fan_param(self, param_id: str) -> Any | None:
753
+ """Retrieve a fan parameter value from the device's message store.
754
+
755
+ This method attempts to fetch a specific parameter value for a FAN device from the
756
+ stored messages. It first looks for the parameter using a composite key (e.g., '2411_3F')
757
+ and falls back to checking the general 2411 message if needed.
758
+
759
+ :param param_id: The parameter ID to retrieve.
760
+ :type param_id: str
761
+ :return: The parameter value if found, None otherwise
762
+ :rtype: Any | None
763
+ """
764
+ # Ensure param_id is uppercase and strip leading zeros for consistency
765
+ param_id = (
766
+ str(param_id).upper().lstrip("0") or "0"
767
+ ) # Handle case where param_id is "0"
768
+ # we need some extra workarounds to please mypy
769
+ # Create a composite key for this parameter using the normalized ID
770
+ key = f"{Code._2411}_{param_id}"
771
+
772
+ # Get the message using the composite key first, fall back to just the code
773
+ msg = None
774
+
775
+ # First try to get the specific parameter message
776
+ try:
777
+ # Try to access the message directly using the key
778
+ msg = self._msgs[key] # type: ignore[index]
779
+ except (KeyError, TypeError):
780
+ # If that fails, try to find the message by iterating through the dictionary
781
+ msg = next((v for k, v in self._msgs.items() if str(k) == key), None)
782
+
783
+ # If not found, try to get the general 2411 message
784
+ if msg is None:
785
+ msg = self._msgs.get(Code._2411)
786
+
787
+ if not msg or not hasattr(msg, "payload"):
788
+ if not self.supports_2411:
789
+ _LOGGER.debug(
790
+ "Cannot get parameter %s from %s: 2411 parameters not supported",
791
+ param_id,
792
+ self.id,
793
+ )
794
+ else:
795
+ _LOGGER.debug(
796
+ "No payload found for parameter %s on %s", param_id, self.id
797
+ )
798
+ return None
799
+
800
+ # If we have a message but not the specific parameter, try to get it from the payload
801
+ if param_id and hasattr(msg.payload, "get"):
802
+ value = msg.payload.get("value")
803
+ if value is not None:
804
+ return value
805
+
806
+ # If we get here, the parameter wasn't found in the message
807
+ if not self.supports_2411:
808
+ _LOGGER.debug(
809
+ "Parameter %s not found for %s: 2411 parameters not supported",
810
+ param_id,
811
+ self.id,
812
+ )
813
+ else:
814
+ _LOGGER.debug("Parameter %s not found in payload for %s", param_id, self.id)
815
+
816
+ return None
817
+
365
818
  @property
366
819
  def air_quality(self) -> float | None:
820
+ """Return the current air quality measurement.
821
+
822
+ :return: The air quality measurement as a float, or None if not available
823
+ :rtype: float | None
824
+ """
367
825
  return self._msg_value(Code._31DA, key=SZ_AIR_QUALITY)
368
826
 
369
827
  @property
370
828
  def air_quality_base(self) -> float | None:
829
+ """Return the base air quality measurement.
830
+
831
+ This represents the baseline or raw air quality measurement before any
832
+ processing or normalization.
833
+
834
+ :return: The base air quality measurement, or None if not available
835
+ :rtype: float | None
836
+ """
371
837
  return self._msg_value(Code._31DA, key=SZ_AIR_QUALITY_BASIS)
372
838
 
373
839
  @property
@@ -396,6 +862,11 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
396
862
 
397
863
  @property
398
864
  def co2_level(self) -> int | None:
865
+ """Return the CO2 level in parts per million (ppm).
866
+
867
+ :return: The CO2 level in ppm, or None if not available
868
+ :rtype: int | None
869
+ """
399
870
  return self._msg_value(Code._31DA, key=SZ_CO2_LEVEL)
400
871
 
401
872
  @property
@@ -419,10 +890,20 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
419
890
 
420
891
  @property
421
892
  def exhaust_flow(self) -> float | None:
893
+ """Return the current exhaust air flow rate.
894
+
895
+ :return: The exhaust air flow rate in m³/h, or None if not available
896
+ :rtype: float | None
897
+ """
422
898
  return self._msg_value(Code._31DA, key=SZ_EXHAUST_FLOW)
423
899
 
424
900
  @property
425
901
  def exhaust_temp(self) -> float | None:
902
+ """Return the current exhaust air temperature.
903
+
904
+ :return: The exhaust air temperature in degrees Celsius, or None if not available
905
+ :rtype: float | None
906
+ """
426
907
  return self._msg_value(Code._31DA, key=SZ_EXHAUST_TEMP)
427
908
 
428
909
  @property
@@ -481,10 +962,22 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
481
962
 
482
963
  @property
483
964
  def indoor_temp(self) -> float | None:
965
+ """Return the current indoor temperature.
966
+
967
+ :return: The indoor temperature in degrees Celsius, or None if not available
968
+ :rtype: float | None
969
+ """
484
970
  return self._msg_value(Code._31DA, key=SZ_INDOOR_TEMP)
485
971
 
486
972
  @property
487
973
  def outdoor_humidity(self) -> float | None:
974
+ """Return the outdoor relative humidity.
975
+
976
+ Handles special case for Ventura devices that send humidity data in 12A0 messages.
977
+
978
+ :return: The outdoor relative humidity as a percentage (0-100), or None if not available
979
+ :rtype: float | None
980
+ """
488
981
  if Code._12A0 in self._msgs and isinstance(
489
982
  self._msgs[Code._12A0].payload, list
490
983
  ): # FAN Ventura sends RH/temps as a list; element [1] contains outdoor_hum
@@ -495,22 +988,47 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
495
988
 
496
989
  @property
497
990
  def outdoor_temp(self) -> float | None:
991
+ """Return the outdoor temperature in Celsius.
992
+
993
+ :return: The outdoor temperature in degrees Celsius, or None if not available
994
+ :rtype: float | None
995
+ """
498
996
  return self._msg_value(Code._31DA, key=SZ_OUTDOOR_TEMP)
499
997
 
500
998
  @property
501
999
  def post_heat(self) -> int | None:
1000
+ """Return the post-heat status.
1001
+
1002
+ :return: The post-heat status as an integer, or None if not available
1003
+ :rtype: int | None
1004
+ """
502
1005
  return self._msg_value(Code._31DA, key=SZ_POST_HEAT)
503
1006
 
504
1007
  @property
505
1008
  def pre_heat(self) -> int | None:
1009
+ """Return the pre-heat status.
1010
+
1011
+ :return: The pre-heat status as an integer, or None if not available
1012
+ :rtype: int | None
1013
+ """
506
1014
  return self._msg_value(Code._31DA, key=SZ_PRE_HEAT)
507
1015
 
508
1016
  @property
509
1017
  def remaining_mins(self) -> int | None:
1018
+ """Return the remaining minutes for the current operation.
1019
+
1020
+ :return: The remaining minutes as an integer, or None if not available
1021
+ :rtype: int | None
1022
+ """
510
1023
  return self._msg_value(Code._31DA, key=SZ_REMAINING_MINS)
511
1024
 
512
1025
  @property
513
1026
  def request_fan_speed(self) -> float | None:
1027
+ """Return the requested fan speed.
1028
+
1029
+ :return: The requested fan speed as a percentage, or None if not available
1030
+ :rtype: float | None
1031
+ """
514
1032
  return self._msg_value(Code._2210, key=SZ_REQ_SPEED)
515
1033
 
516
1034
  @property
@@ -523,18 +1041,40 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
523
1041
 
524
1042
  @property
525
1043
  def speed_cap(self) -> int | None:
1044
+ """Return the speed capabilities of the fan.
1045
+
1046
+ :return: The speed capabilities as an integer, or None if not available
1047
+ :rtype: int | None
1048
+ """
526
1049
  return self._msg_value(Code._31DA, key=SZ_SPEED_CAPABILITIES)
527
1050
 
528
1051
  @property
529
1052
  def supply_fan_speed(self) -> float | None:
1053
+ """Return the supply fan speed.
1054
+
1055
+ :return: The supply fan speed as a percentage, or None if not available
1056
+ :rtype: float | None
1057
+ """
530
1058
  return self._msg_value(Code._31DA, key=SZ_SUPPLY_FAN_SPEED)
531
1059
 
532
1060
  @property
533
1061
  def supply_flow(self) -> float | None:
1062
+ """Return the supply air flow rate.
1063
+
1064
+ :return: The supply air flow rate in m³/h, or None if not available
1065
+ :rtype: float | None
1066
+ """
534
1067
  return self._msg_value(Code._31DA, key=SZ_SUPPLY_FLOW)
535
1068
 
536
1069
  @property
537
1070
  def supply_temp(self) -> float | None:
1071
+ """Return the supply air temperature.
1072
+
1073
+ Handles special case for Ventura devices that send temperature data in 12A0 messages.
1074
+
1075
+ :return: The supply air temperature in Celsius, or None if not available
1076
+ :rtype: float | None
1077
+ """
538
1078
  if Code._12A0 in self._msgs and isinstance(
539
1079
  self._msgs[Code._12A0].payload, list
540
1080
  ): # FAN Ventura sends RH/temps as a list;
@@ -559,6 +1099,13 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
559
1099
 
560
1100
  @property
561
1101
  def temperature(self) -> float | None: # Celsius
1102
+ """Return the current temperature in Celsius.
1103
+
1104
+ Handles special cases.
1105
+
1106
+ :return: The temperature in degrees Celsius, or None if not available
1107
+ :rtype: float | None
1108
+ """
562
1109
  if Code._12A0 in self._msgs and isinstance(
563
1110
  self._msgs[Code._12A0].payload, list
564
1111
  ): # FAN Ventura sends RH/temps as a list; use element [1]
@@ -655,7 +1202,7 @@ _REMOTES = {
655
1202
  """
656
1203
  # CVE/HRU remote (536-0124) [RFT W: 3 modes, timer]
657
1204
  "away": (Code._22F1, 00, 01|04"), # how to invoke?
658
- "low": (Code._22F1, 00, 02|04"),
1205
+ "low": (Code._22F1, 00, 02|04"), # aka eco
659
1206
  "medium": (Code._22F1, 00, 03|04"), # aka auto (with sensors) - is that only for 63?
660
1207
  "high": (Code._22F1, 00, 04|04"), # aka full
661
1208