ramses-rf 0.51.6__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_tx/command.py CHANGED
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env python3
2
2
  """RAMSES RF - a RAMSES-II protocol decoder & analyser.
3
3
 
4
- Construct a command (packet that is to be sent).
4
+ This module provides the `Command` class for constructing and managing RAMSES-II protocol
5
+ commands (packets) that are to be sent to HVAC devices. It includes methods for creating
6
+ commands to control various aspects of the heating system including zones, DHW, and fan controls.
7
+
5
8
  """
6
9
 
7
10
  from __future__ import annotations
8
11
 
9
12
  import logging
13
+ import math
10
14
  from collections.abc import Iterable
11
15
  from datetime import datetime as dt, timedelta as td
12
16
  from typing import TYPE_CHECKING, Any, TypeVar
@@ -54,7 +58,13 @@ from .helpers import (
54
58
  )
55
59
  from .opentherm import parity
56
60
  from .parsers import LOOKUP_PUZZ
57
- from .ramses import _2411_PARAMS_SCHEMA
61
+ from .ramses import (
62
+ _2411_PARAMS_SCHEMA,
63
+ SZ_DATA_TYPE,
64
+ SZ_MAX_VALUE,
65
+ SZ_MIN_VALUE,
66
+ SZ_PRECISION,
67
+ )
58
68
  from .version import VERSION
59
69
 
60
70
  from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
@@ -94,6 +104,8 @@ _ZoneIdxT = TypeVar("_ZoneIdxT", int, str)
94
104
  class Qos:
95
105
  """The QoS class - this is a mess - it is the first step in cleaning up QoS."""
96
106
 
107
+ # TODO: this needs work
108
+
97
109
  POLL_INTERVAL = 0.002
98
110
 
99
111
  TX_PRIORITY_DEFAULT = Priority.DEFAULT
@@ -150,6 +162,23 @@ class Qos:
150
162
 
151
163
 
152
164
  def _check_idx(zone_idx: int | str) -> str:
165
+ """Validate and normalize a zone index or DHW index.
166
+
167
+ This helper function validates that a zone index is within the valid range
168
+ and converts it to a consistent string format.
169
+
170
+ :param zone_idx: The zone index to validate. Can be:
171
+ - int: 0-15 for zones, 0xFA for DHW
172
+ - str: String representation of the index (hex or 'HW' for DHW)
173
+ :type zone_idx: int | str
174
+ :return: The normalized zone index as a 2-character hex string
175
+ :rtype: str
176
+ :raises CommandInvalid: If the zone index is invalid
177
+
178
+ .. note::
179
+ - For DHW (Domestic Hot Water), use 0xFA or 'HW'
180
+ - For zones, use 0-15 (or '00'-'0F' as hex strings)
181
+ """
153
182
  # if zone_idx is None:
154
183
  # return "00"
155
184
  if not isinstance(zone_idx, int | str):
@@ -168,9 +197,33 @@ def _normalise_mode(
168
197
  until: dt | str | None,
169
198
  duration: int | None,
170
199
  ) -> str:
171
- """Validate the mode and return it as a normalised 2-byte code.
172
-
173
- Used by set_dhw_mode (target=active) and set_zone_mode (target=setpoint).
200
+ """Validate and normalize a heating mode for zone or DHW control.
201
+
202
+ This helper function ensures the operating mode is valid and consistent
203
+ with the provided target and timing parameters.
204
+
205
+ :param mode: The operating mode. Can be:
206
+ - None: Auto-determined from other parameters
207
+ - int/str: Mode code (see ZON_MODE_MAP for valid values)
208
+ :type mode: int | str | None
209
+ :param target: The target value for the mode:
210
+ - For zone modes: The temperature setpoint
211
+ - For DHW modes: Active state (True/False)
212
+ :type target: bool | float | None
213
+ :param until: The end time for temporary modes
214
+ :type until: datetime | str | None
215
+ :param duration: The duration in minutes for countdown modes
216
+ :type duration: int | None
217
+ :return: Normalized 2-character hex mode string
218
+ :rtype: str
219
+ :raises CommandInvalid: If the parameters are inconsistent or invalid
220
+
221
+ .. note::
222
+ - If mode is None, it will be determined based on other parameters:
223
+ - If until is set: TEMPORARY mode
224
+ - If duration is set: COUNTDOWN mode
225
+ - Otherwise: PERMANENT mode
226
+ - The target parameter must be provided for all modes except FOLLOW
174
227
  """
175
228
 
176
229
  if mode is None and target is None:
@@ -210,9 +263,28 @@ def _normalise_until(
210
263
  until: dt | str | None,
211
264
  duration: int | None,
212
265
  ) -> tuple[Any, Any]:
213
- """Validate until and duration, and return a normalised xxx.
214
-
215
- Used by set_dhw_mode and set_zone_mode.
266
+ """Validate and normalize timing parameters for zone/DHW mode changes.
267
+
268
+ This helper function ensures that the timing parameters (until/duration)
269
+ are consistent with the specified mode.
270
+
271
+ :param mode: The operating mode (from ZON_MODE_MAP)
272
+ :type mode: int | str | None
273
+ :param _: Unused parameter (kept for compatibility with call signatures)
274
+ :type _: Any
275
+ :param until: The end time for temporary modes
276
+ :type until: datetime | str | None
277
+ :param duration: The duration in minutes for countdown modes
278
+ :type duration: int | None
279
+ :return: A tuple of (until, duration) with validated values
280
+ :rtype: tuple[Any, Any]
281
+ :raises CommandInvalid: If the timing parameters are inconsistent with the mode
282
+
283
+ .. note::
284
+ - For TEMPORARY mode: 'until' must be provided, 'duration' must be None
285
+ - For COUNTDOWN mode: 'duration' must be provided, 'until' must be None
286
+ - For other modes: Both 'until' and 'duration' must be None
287
+ - If mode is TEMPORARY and until is None, it will be changed to ADVANCED mode
216
288
  """
217
289
  if mode == ZON_MODE_MAP.TEMPORARY:
218
290
  if duration is not None:
@@ -432,45 +504,120 @@ class Command(Frame):
432
504
 
433
505
  @classmethod # constructor for RQ|0004
434
506
  def get_zone_name(cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT) -> Command:
435
- """Constructor to get the name of a zone (c.f. parser_0004)."""
507
+ """Get the name of a zone. (c.f. parser_0004)
508
+
509
+ This method constructs a command to request the name of a specific zone
510
+ from the controller.
436
511
 
512
+ :param ctl_id: The device ID of the controller
513
+ :type ctl_id: DeviceIdT | str
514
+ :param zone_idx: The index of the zone (00-31)
515
+ :type zone_idx: _ZoneIdxT
516
+ :return: A Command object for the RQ|0004 message
517
+ :rtype: Command
518
+
519
+ .. note::
520
+ The zone name is typically a user-assigned identifier for the zone,
521
+ such as "Living Room" or "Bedroom 1".
522
+ """
437
523
  return cls.from_attrs(RQ, ctl_id, Code._0004, f"{_check_idx(zone_idx)}00")
438
524
 
439
525
  @classmethod # constructor for W|0004
440
526
  def set_zone_name(
441
527
  cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT, name: str
442
528
  ) -> Command:
443
- """Constructor to set the name of a zone (c.f. parser_0004)."""
444
-
529
+ """Set the name of a zone. (c.f. parser_0004)
530
+
531
+ This method constructs a command to set the name of a specific zone
532
+ on the controller. The name will be truncated to 20 characters (40 hex digits).
533
+
534
+ :param ctl_id: The device ID of the controller
535
+ :type ctl_id: DeviceIdT | str
536
+ :param zone_idx: The index of the zone (00-31)
537
+ :type zone_idx: _ZoneIdxT
538
+ :param name: The new name for the zone (max 20 characters)
539
+ :type name: str
540
+ :return: A Command object for the W|0004 message
541
+ :rtype: Command
542
+
543
+ .. note::
544
+ The name will be converted to uppercase and non-ASCII characters
545
+ will be replaced with '?'. The name is limited to 20 characters.
546
+ """
445
547
  payload = f"{_check_idx(zone_idx)}00{hex_from_str(name)[:40]:0<40}"
446
548
  return cls.from_attrs(W_, ctl_id, Code._0004, payload)
447
549
 
448
550
  @classmethod # constructor for RQ|0006
449
551
  def get_schedule_version(cls, ctl_id: DeviceIdT | str) -> Command:
450
- """Constructor to get the current version (change counter) of the schedules.
451
-
452
- This number is increased whenever any zone's schedule is changed (incl. the DHW
453
- zone), and is used to avoid the relatively large expense of downloading a
454
- schedule, only to see that it hasn't changed.
552
+ """Get the current version (change counter) of the schedules.
553
+
554
+ This method retrieves a version number that is incremented whenever any zone's
555
+ schedule (including the DHW zone) is modified. This allows clients to efficiently
556
+ check if schedules have changed before downloading them.
557
+
558
+ :param ctl_id: The device ID of the controller
559
+ :type ctl_id: DeviceIdT | str
560
+ :return: A Command object for the RQ|0006 message
561
+ :rtype: Command
562
+
563
+ .. note::
564
+ The version number is a simple counter that increments with each schedule
565
+ change. It has no inherent meaning beyond indicating that a change has
566
+ occurred. The actual value should be compared with a previously stored
567
+ version to detect changes.
455
568
  """
456
-
457
569
  return cls.from_attrs(RQ, ctl_id, Code._0006, "00")
458
570
 
459
571
  @classmethod # constructor for RQ|0008
460
572
  def get_relay_demand(
461
573
  cls, dev_id: DeviceIdT | str, zone_idx: _ZoneIdxT | None = None
462
574
  ) -> Command:
463
- """Constructor to get the demand of a relay/zone (c.f. parser_0008)."""
464
-
575
+ """Get the current demand value for a relay or zone. (c.f. parser_0008)
576
+
577
+ This method constructs a command to request the current demand value for a
578
+ specific relay or zone. The demand value typically represents the requested
579
+ output level (0-100%) for the relay or zone.
580
+
581
+ :param dev_id: The device ID of the relay or controller
582
+ :type dev_id: DeviceIdT | str
583
+ :param zone_idx: The index of the zone (00-31), or None for the relay itself
584
+ :type zone_idx: _ZoneIdxT | None
585
+ :return: A Command object for the RQ|0008 message
586
+ :rtype: Command
587
+
588
+ .. note::
589
+ - If zone_idx is None, the command requests the relay's overall demand.
590
+ - If zone_idx is specified, the command requests the demand for that specific zone.
591
+ - The response will contain the current demand value as a percentage (0-100%).
592
+ """
465
593
  payload = "00" if zone_idx is None else _check_idx(zone_idx)
466
594
  return cls.from_attrs(RQ, dev_id, Code._0008, payload)
467
595
 
468
596
  @classmethod # constructor for RQ|000A
469
597
  def get_zone_config(cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT) -> Command:
470
- """Constructor to get the config of a zone (c.f. parser_000a)."""
471
-
598
+ """Get the configuration of a specific zone. (c.f. parser_000a)
599
+
600
+ This method constructs a command to request the configuration parameters
601
+ for a specific zone from the controller. The configuration includes
602
+ settings related to the zone's operation, such as temperature setpoints,
603
+ mode, and other zone-specific parameters.
604
+
605
+ :param ctl_id: The device ID of the controller
606
+ :type ctl_id: DeviceIdT | str
607
+ :param zone_idx: The index of the zone (00-31)
608
+ :type zone_idx: _ZoneIdxT
609
+ :return: A Command object for the RQ|000A message
610
+ :rtype: Command
611
+
612
+ .. note::
613
+ The response to this command will include various configuration parameters
614
+ for the specified zone, such as:
615
+ - Zone type (radiator, underfloor heating, etc.)
616
+ - Temperature setpoints
617
+ - Mode (heating/cooling)
618
+ - Other zone-specific settings
619
+ """
472
620
  zon_idx = _check_idx(zone_idx)
473
-
474
621
  return cls.from_attrs(RQ, ctl_id, Code._000A, zon_idx)
475
622
 
476
623
  @classmethod # constructor for W|000A
@@ -485,8 +632,35 @@ class Command(Frame):
485
632
  openwindow_function: bool = False,
486
633
  multiroom_mode: bool = False,
487
634
  ) -> Command:
488
- """Constructor to set the config of a zone (c.f. parser_000a)."""
489
-
635
+ """Set the configuration parameters for a specific zone. (c.f. parser_000a)
636
+
637
+ This method constructs a command to configure various parameters for a zone,
638
+ including temperature limits and operational modes.
639
+
640
+ :param ctl_id: The device ID of the controller
641
+ :type ctl_id: DeviceIdT | str
642
+ :param zone_idx: The index of the zone (00-31)
643
+ :type zone_idx: _ZoneIdxT
644
+ :param min_temp: Minimum allowed temperature for the zone (5-21°C)
645
+ :type min_temp: float
646
+ :param max_temp: Maximum allowed temperature for the zone (21-35°C)
647
+ :type max_temp: float
648
+ :param local_override: If True, allows local temperature override at the device
649
+ :type local_override: bool
650
+ :param openwindow_function: If True, enables open window detection function
651
+ :type openwindow_function: bool
652
+ :param multiroom_mode: If True, enables multi-room mode for this zone
653
+ :type multiroom_mode: bool
654
+ :return: A Command object for the W|000A message
655
+ :rtype: Command
656
+ :raises CommandInvalid: If any parameter is out of range or of incorrect type
657
+
658
+ .. note::
659
+ - The minimum temperature must be between 5°C and 21°C
660
+ - The maximum temperature must be between 21°C and 35°C
661
+ - The minimum temperature cannot be higher than the maximum temperature
662
+ - These settings affect how the zone behaves in different operating modes
663
+ """
490
664
  zon_idx = _check_idx(zone_idx)
491
665
 
492
666
  if not (5 <= min_temp <= 21):
@@ -514,8 +688,21 @@ class Command(Frame):
514
688
 
515
689
  @classmethod # constructor for RQ|0100
516
690
  def get_system_language(cls, ctl_id: DeviceIdT | str, **kwargs: Any) -> Command:
517
- """Constructor to get the language of a system (c.f. parser_0100)."""
691
+ """Get the configured language of the system. (c.f. parser_0100)
692
+
693
+ This method constructs a command to request the current language setting
694
+ from the system controller.
695
+
696
+ :param ctl_id: The device ID of the controller
697
+ :type ctl_id: DeviceIdT | str
698
+ :param kwargs: Additional keyword arguments (not used, for compatibility only)
699
+ :return: A Command object for the RQ|0100 message
700
+ :rtype: Command
518
701
 
702
+ .. note::
703
+ The response will contain a language code that corresponds to the
704
+ system's configured language setting.
705
+ """
519
706
  assert not kwargs, kwargs
520
707
  return cls.from_attrs(RQ, ctl_id, Code._0100, "00", **kwargs)
521
708
 
@@ -528,9 +715,29 @@ class Command(Frame):
528
715
  total_frags: int | None,
529
716
  **kwargs: Any,
530
717
  ) -> Command:
531
- """Constructor to get a schedule fragment (c.f. parser_0404).
532
-
533
- Usually a zone, but will be the DHW schedule if zone_idx == 0xFA, 'FA', or 'HW'.
718
+ """Get a specific fragment of a schedule. (c.f. parser_0404)
719
+
720
+ This method constructs a command to request a specific fragment of a schedule
721
+ from the controller. Schedules are typically broken into multiple fragments
722
+ for efficient transmission.
723
+
724
+ :param ctl_id: The device ID of the controller
725
+ :type ctl_id: DeviceIdT | str
726
+ :param zone_idx: The index of the zone (00-31), or 0xFA/'FA'/'HW' for DHW schedule
727
+ :type zone_idx: _ZoneIdxT
728
+ :param frag_number: The fragment number to retrieve (0-based)
729
+ :type frag_number: int
730
+ :param total_frags: Total number of fragments (optional)
731
+ :type total_frags: int | None
732
+ :param kwargs: Additional keyword arguments
733
+ :return: A Command object for the RQ|0404 message
734
+ :rtype: Command
735
+
736
+ .. note::
737
+ - For zone schedules, use a zone index between 00-31
738
+ - For DHW (Domestic Hot Water) schedule, use 0xFA, 'FA', or 'HW' as zone_idx
739
+ - The schedule is typically retrieved in multiple fragments to handle
740
+ the potentially large amount of data
534
741
  """
535
742
 
536
743
  assert not kwargs, kwargs
@@ -568,9 +775,31 @@ class Command(Frame):
568
775
  frag_cnt: int,
569
776
  fragment: str,
570
777
  ) -> Command:
571
- """Constructor to set a zone schedule fragment (c.f. parser_0404).
572
-
573
- Usually a zone, but will be the DHW schedule if zone_idx == 0xFA, 'FA', or 'HW'.
778
+ """Set a specific fragment of a schedule. (c.f. parser_0404)
779
+
780
+ This method constructs a command to set a specific fragment of a schedule
781
+ on the controller. Schedules are typically set in multiple fragments
782
+ due to their potentially large size.
783
+
784
+ :param ctl_id: The device ID of the controller
785
+ :type ctl_id: DeviceIdT | str
786
+ :param zone_idx: The index of the zone (00-31), or 0xFA/'FA'/'HW' for DHW schedule
787
+ :type zone_idx: _ZoneIdxT
788
+ :param frag_num: The fragment number being set (1-based index)
789
+ :type frag_num: int
790
+ :param frag_cnt: Total number of fragments in the schedule
791
+ :type frag_cnt: int
792
+ :param fragment: The schedule fragment data as a hex string
793
+ :type fragment: str
794
+ :return: A Command object for the W|0404 message
795
+ :rtype: Command
796
+ :raises CommandInvalid: If fragment number is invalid or out of range
797
+
798
+ .. note::
799
+ - For zone schedules, use a zone index between 00-31
800
+ - For DHW (Domestic Hot Water) schedule, use 0xFA, 'FA', or 'HW' as zone_idx
801
+ - The first fragment (frag_num=1) typically contains schedule metadata
802
+ - Fragment numbers are 1-based (1 to frag_cnt)
574
803
  """
575
804
 
576
805
  zon_idx = _check_idx(zone_idx)
@@ -593,8 +822,23 @@ class Command(Frame):
593
822
  def get_system_log_entry(
594
823
  cls, ctl_id: DeviceIdT | str, log_idx: int | str
595
824
  ) -> Command:
596
- """Constructor to get a log entry from a system (c.f. parser_0418)."""
597
-
825
+ """Retrieve a specific log entry from the system log. (c.f. parser_0418)
826
+
827
+ This method constructs a command to request a specific log entry from the
828
+ system's event log. The log contains historical events and fault records.
829
+
830
+ :param ctl_id: The device ID of the controller
831
+ :type ctl_id: DeviceIdT | str
832
+ :param log_idx: The index of the log entry to retrieve (0-based)
833
+ :type log_idx: int | str (hex string)
834
+ :return: A Command object for the RQ|0418 message
835
+ :rtype: Command
836
+
837
+ .. note::
838
+ - The log index is 0-based, where 0 is the most recent entry
839
+ - The log typically contains system events, faults, and warnings
840
+ - The response will include details about the log entry
841
+ """
598
842
  log_idx = log_idx if isinstance(log_idx, int) else int(log_idx, 16)
599
843
  return cls.from_attrs(RQ, ctl_id, Code._0418, f"{log_idx:06X}")
600
844
 
@@ -611,8 +855,38 @@ class Command(Frame):
611
855
  timestamp: dt | str | None = None,
612
856
  **kwargs: Any,
613
857
  ) -> Command:
614
- """Constructor to get a log entry from a system (c.f. parser_0418)."""
615
-
858
+ """Create a log entry in the system log. (c.f. parser_0418)
859
+
860
+ This internal method constructs a command to create a log entry in the system's
861
+ event log. It's primarily used for testing purposes to simulate log entries.
862
+
863
+ :param ctl_id: The device ID of the controller
864
+ :type ctl_id: DeviceIdT | str
865
+ :param fault_state: The state of the fault (e.g., 'on', 'off', 'unknown')
866
+ :type fault_state: FaultState | str
867
+ :param fault_type: The type of fault being logged
868
+ :type fault_type: FaultType | str
869
+ :param device_class: The class of device associated with the fault
870
+ :type device_class: FaultDeviceClass | str
871
+ :param device_id: The ID of the device associated with the fault (optional)
872
+ :type device_id: DeviceIdT | str | None
873
+ :param domain_idx: The domain index (default: '00')
874
+ :type domain_idx: int | str
875
+ :param _log_idx: The log index (for internal use, optional)
876
+ :type _log_idx: int | str | None
877
+ :param timestamp: The timestamp of the log entry (default: current time)
878
+ :type timestamp: dt | str | None
879
+ :param kwargs: Additional keyword arguments
880
+ :return: A Command object for the I|0418 message
881
+ :rtype: Command
882
+ :raises AssertionError: If device_class is invalid
883
+
884
+ .. note::
885
+ - This is an internal method primarily used for testing
886
+ - The log entry will appear in the system's event log
887
+ - The fault_state and fault_type should match the expected enums
888
+ - If timestamp is not provided, the current time will be used
889
+ """
616
890
  if isinstance(device_class, FaultDeviceClass):
617
891
  device_class = {v: k for k, v in FAULT_DEVICE_CLASS.items()}[device_class]
618
892
  assert device_class in FAULT_DEVICE_CLASS
@@ -661,8 +935,25 @@ class Command(Frame):
661
935
  def get_mix_valve_params(
662
936
  cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT
663
937
  ) -> Command:
664
- """Constructor to get the mix valve params of a zone (c.f. parser_1030)."""
665
-
938
+ """Retrieve the mixing valve parameters for a specific zone. (c.f. parser_1030)
939
+
940
+ This method constructs a command to request the current mixing valve parameters
941
+ for a specific zone from the controller. These parameters control how the
942
+ mixing valve operates for the specified zone.
943
+
944
+ :param ctl_id: The device ID of the controller
945
+ :type ctl_id: DeviceIdT | str
946
+ :param zone_idx: The index of the zone (00-31)
947
+ :type zone_idx: _ZoneIdxT
948
+ :return: A Command object for the RQ|1030 message
949
+ :rtype: Command
950
+
951
+ .. note::
952
+ - The mixing valve controls the temperature of the water in the heating circuit
953
+ by mixing hot water from the boiler with cooler return water
954
+ - The parameters include settings like the minimum and maximum flow temperatures
955
+ and the proportional band for the valve control
956
+ """
666
957
  zon_idx = _check_idx(zone_idx)
667
958
 
668
959
  return cls.from_attrs(RQ, ctl_id, Code._1030, zon_idx)
@@ -679,8 +970,37 @@ class Command(Frame):
679
970
  pump_run_time: int = 15,
680
971
  **kwargs: Any,
681
972
  ) -> Command:
682
- """Constructor to set the mix valve params of a zone (c.f. parser_1030)."""
683
-
973
+ """Set the mixing valve parameters for a specific zone. (c.f. parser_1030)
974
+
975
+ This method constructs a command to configure the mixing valve parameters
976
+ for a specific zone. These parameters control how the mixing valve operates
977
+ to regulate the temperature of the water in the heating circuit.
978
+
979
+ :param ctl_id: The device ID of the controller
980
+ :type ctl_id: DeviceIdT | str
981
+ :param zone_idx: The index of the zone (00-31)
982
+ :type zone_idx: _ZoneIdxT
983
+ :param max_flow_setpoint: Maximum flow temperature setpoint in °C (0-99)
984
+ :type max_flow_setpoint: int
985
+ :param min_flow_setpoint: Minimum flow temperature setpoint in °C (0-50)
986
+ :type min_flow_setpoint: int
987
+ :param valve_run_time: Valve run time in seconds (0-240)
988
+ :type valve_run_time: int
989
+ :param pump_run_time: Pump overrun time in seconds after valve closes (0-99)
990
+ :type pump_run_time: int
991
+ :param kwargs: Additional keyword arguments (e.g., boolean_cc)
992
+ :return: A Command object for the W|1030 message
993
+ :rtype: Command
994
+ :raises CommandInvalid: If any parameter is out of valid range
995
+
996
+ .. note::
997
+ - The mixing valve controls the temperature by mixing hot water from the boiler
998
+ with cooler return water
999
+ - The pump overrun time allows the pump to continue running after the valve
1000
+ closes to dissipate residual heat
1001
+ - The valve run time determines how long the valve takes to move between
1002
+ fully open and fully closed positions
1003
+ """
684
1004
  boolean_cc = kwargs.pop("boolean_cc", 1)
685
1005
  assert not kwargs, kwargs
686
1006
 
@@ -714,10 +1034,27 @@ class Command(Frame):
714
1034
 
715
1035
  @classmethod # constructor for RQ|10A0
716
1036
  def get_dhw_params(cls, ctl_id: DeviceIdT | str, **kwargs: Any) -> Command:
717
- """Constructor to get the params of the DHW (c.f. parser_10a0)."""
718
-
1037
+ """Get the parameters of the Domestic Hot Water (DHW) system. (c.f. parser_10a0)
1038
+
1039
+ This method constructs a command to retrieve the current parameters
1040
+ of the DHW system, including setpoint, overrun, and differential settings.
1041
+
1042
+ :param ctl_id: The device ID of the controller
1043
+ :type ctl_id: DeviceIdT | str
1044
+ :param kwargs: Additional keyword arguments
1045
+ - dhw_idx: Index of the DHW circuit (0 or 1), defaults to 0
1046
+ - Other arguments will raise an exception
1047
+ :return: A Command object for the RQ|10A0 message
1048
+ :rtype: Command
1049
+ :raises AssertionError: If unexpected keyword arguments are provided
1050
+
1051
+ .. note::
1052
+ - Most systems only have one DHW circuit (index 0)
1053
+ - The response includes current setpoint, overrun, and differential values
1054
+ - The actual values are parsed by parser_10a0
1055
+ """
719
1056
  dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)
720
- assert not kwargs, kwargs
1057
+ assert not kwargs, f"Unexpected arguments: {kwargs}"
721
1058
 
722
1059
  return cls.from_attrs(RQ, ctl_id, Code._10A0, dhw_idx)
723
1060
 
@@ -731,7 +1068,33 @@ class Command(Frame):
731
1068
  differential: float | None = 1,
732
1069
  **kwargs: Any, # only expect "dhw_idx"
733
1070
  ) -> Command:
734
- """Constructor to set the params of the DHW (c.f. parser_10a0)."""
1071
+ """Set the parameters of the Domestic Hot Water (DHW) system. (c.f. parser_10a0)
1072
+
1073
+ This method constructs a command to configure the parameters of the DHW system,
1074
+ including temperature setpoint, pump overrun time, and temperature differential.
1075
+
1076
+ :param ctl_id: The device ID of the controller
1077
+ :type ctl_id: DeviceIdT | str
1078
+ :param setpoint: Target temperature for DHW in °C (30.0-85.0), defaults to 50.0
1079
+ :type setpoint: float | None
1080
+ :param overrun: Pump overrun time in minutes (0-10), defaults to 5
1081
+ :type overrun: int | None
1082
+ :param differential: Temperature differential in °C (1.0-10.0), defaults to 1.0
1083
+ :type differential: float | None
1084
+ :param kwargs: Additional keyword arguments
1085
+ - dhw_idx: Index of the DHW circuit (0 or 1), defaults to 0
1086
+ :return: A Command object for the W|10A0 message
1087
+ :rtype: Command
1088
+ :raises CommandInvalid: If any parameter is out of valid range
1089
+ :raises AssertionError: If unexpected keyword arguments are provided
1090
+
1091
+ .. note::
1092
+ - The setpoint is the target temperature for the hot water
1093
+ - Overrun keeps the pump running after heating stops to dissipate residual heat
1094
+ - Differential prevents rapid cycling by requiring this much temperature drop
1095
+ before reheating
1096
+ - Most systems only have one DHW circuit (index 0)
1097
+ """
735
1098
  # Defaults for newer evohome colour:
736
1099
  # Defaults for older evohome colour: ?? (30-85) C, ? (0-10) min, ? (1-10) C
737
1100
  # Defaults for evohome monochrome:
@@ -741,7 +1104,7 @@ class Command(Frame):
741
1104
  # 14:34:26.764 074 I --- 01:145038 18:013393 --:------ 10A0 006 000F6E0003E8
742
1105
 
743
1106
  dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)
744
- assert not kwargs, kwargs
1107
+ assert not kwargs, f"Unexpected arguments: {kwargs}"
745
1108
 
746
1109
  setpoint = 50.0 if setpoint is None else setpoint
747
1110
  overrun = 5 if overrun is None else overrun
@@ -762,8 +1125,24 @@ class Command(Frame):
762
1125
  def get_tpi_params(
763
1126
  cls, dev_id: DeviceIdT | str, *, domain_id: int | str | None = None
764
1127
  ) -> Command:
765
- """Constructor to get the TPI params of a system (c.f. parser_1100)."""
766
-
1128
+ """Get the Time Proportional and Integral (TPI) parameters of a system. (c.f. parser_1100)
1129
+
1130
+ This method constructs a command to retrieve the TPI parameters for a specific domain.
1131
+ TPI is a control algorithm used to maintain temperature by cycling the boiler on/off.
1132
+
1133
+ :param dev_id: The device ID of the controller or BDR91 relay
1134
+ :type dev_id: DeviceIdT | str
1135
+ :param domain_id: The domain ID to get parameters for, or None for default
1136
+ (00 for BDR devices, FC for controllers)
1137
+ :type domain_id: int | str | None
1138
+ :return: A Command object for the RQ|1100 message
1139
+ :rtype: Command
1140
+
1141
+ .. note::
1142
+ - TPI parameters control how the system maintains temperature by cycling the boiler
1143
+ - Different domains can have different TPI settings
1144
+ - The response will include cycle rate, minimum on/off times, and other parameters
1145
+ """
767
1146
  if domain_id is None:
768
1147
  domain_id = "00" if dev_id[:2] == DEV_TYPE_MAP.BDR else FC
769
1148
 
@@ -780,11 +1159,39 @@ class Command(Frame):
780
1159
  min_off_time: int = 5, # TODO: check
781
1160
  proportional_band_width: float | None = None, # TODO: check
782
1161
  ) -> Command:
783
- """Constructor to set the TPI params of a system (c.f. parser_1100)."""
784
-
1162
+ """Set the Time Proportional and Integral (TPI) parameters of a system. (c.f. parser_1100)
1163
+
1164
+ This method constructs a command to configure the TPI parameters for a specific domain.
1165
+ TPI is a control algorithm that maintains temperature by cycling the boiler on/off.
1166
+
1167
+ :param ctl_id: The device ID of the controller
1168
+ :type ctl_id: DeviceIdT | str
1169
+ :param domain_id: The domain ID to configure, or None for default domain (00)
1170
+ :type domain_id: int | str | None
1171
+ :param cycle_rate: Number of on/off cycles per hour (TODO: validate range, typically 3,6,9,12)
1172
+ :type cycle_rate: int
1173
+ :param min_on_time: Minimum time in minutes the boiler stays on (TODO: validate range, typically 1-5)
1174
+ :type min_on_time: int
1175
+ :param min_off_time: Minimum time in minutes the boiler stays off (TODO: validate range, typically 1-5)
1176
+ :type min_off_time: int
1177
+ :param proportional_band_width: Width of the proportional band in °C (TODO: validate range, typically 1.5-3.0)
1178
+ :type proportional_band_width: float | None
1179
+ :return: A Command object for the W|1100 message
1180
+ :rtype: Command
1181
+ :raises AssertionError: If any parameter is out of valid range
1182
+
1183
+ .. note::
1184
+ - TPI parameters control how the system maintains temperature by cycling the boiler
1185
+ - Different domains can have different TPI settings
1186
+ - The proportional band determines how much the temperature can vary before the
1187
+ boiler cycles on/off
1188
+ - The cycle rate affects how frequently the boiler cycles when maintaining temperature
1189
+ - Parameters are converted to appropriate hex values in the payload (e.g., minutes * 4)
1190
+ """
785
1191
  if domain_id is None:
786
1192
  domain_id = "00"
787
1193
 
1194
+ # TODO: Uncomment and fix these validations once ranges are confirmed
788
1195
  # assert cycle_rate is None or cycle_rate in (3, 6, 9, 12), cycle_rate
789
1196
  # assert min_on_time is None or 1 <= min_on_time <= 5, min_on_time
790
1197
  # assert min_off_time is None or 1 <= min_off_time <= 5, min_off_time
@@ -795,10 +1202,10 @@ class Command(Frame):
795
1202
  payload = "".join(
796
1203
  (
797
1204
  _check_idx(domain_id),
798
- f"{cycle_rate * 4:02X}",
799
- f"{int(min_on_time * 4):02X}",
800
- f"{int(min_off_time * 4):02X}00", # or: ...FF",
801
- f"{hex_from_temp(proportional_band_width)}01",
1205
+ f"{cycle_rate * 4:02X}", # Convert cycles/hour to internal format
1206
+ f"{int(min_on_time * 4):02X}", # Convert minutes to internal format
1207
+ f"{int(min_off_time * 4):02X}00", # Convert minutes to internal format (or: ...FF)
1208
+ f"{hex_from_temp(proportional_band_width)}01", # Convert temperature to hex
802
1209
  )
803
1210
  )
804
1211
 
@@ -806,10 +1213,27 @@ class Command(Frame):
806
1213
 
807
1214
  @classmethod # constructor for RQ|1260
808
1215
  def get_dhw_temp(cls, ctl_id: DeviceIdT | str, **kwargs: Any) -> Command:
809
- """Constructor to get the temperature of the DHW sensor (c.f. parser_10a0)."""
810
-
1216
+ """Get the current temperature from a Domestic Hot Water (DHW) sensor. (c.f. parser_10a0)
1217
+
1218
+ This method constructs a command to request the current temperature reading from
1219
+ a DHW temperature sensor. The sensor is typically located in the hot water tank.
1220
+
1221
+ :param ctl_id: The device ID of the controller
1222
+ :type ctl_id: DeviceIdT | str
1223
+ :param kwargs: Additional keyword arguments
1224
+ - dhw_idx: Index of the DHW sensor (0 or 1), defaults to 0
1225
+ - Other arguments will raise an exception
1226
+ :return: A Command object for the RQ|1260 message
1227
+ :rtype: Command
1228
+ :raises AssertionError: If unexpected keyword arguments are provided
1229
+
1230
+ .. note::
1231
+ - Most systems only have one DHW sensor (index 0)
1232
+ - The response will include the current temperature in degrees Celsius
1233
+ - The actual temperature is parsed by parser_10a0
1234
+ """
811
1235
  dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)
812
- assert not kwargs, kwargs
1236
+ assert not kwargs, f"Unexpected arguments: {kwargs}"
813
1237
 
814
1238
  return cls.from_attrs(RQ, ctl_id, Code._1260, dhw_idx)
815
1239
 
@@ -817,13 +1241,33 @@ class Command(Frame):
817
1241
  def put_dhw_temp(
818
1242
  cls, dev_id: DeviceIdT | str, temperature: float | None, **kwargs: Any
819
1243
  ) -> Command:
820
- """Constructor to announce the current temperature of an DHW sensor (1260).
821
-
822
- This is for use by a faked CS92A or similar.
1244
+ """Announce the current temperature of a Domestic Hot Water (DHW) sensor. (1260)
1245
+
1246
+ This method constructs a command to announce/simulate a temperature reading from
1247
+ a DHW temperature sensor. This is primarily intended for use with simulated or
1248
+ emulated devices like a faked CS92A sensor.
1249
+
1250
+ :param dev_id: The device ID of the DHW sensor (must start with DHW type code)
1251
+ :type dev_id: DeviceIdT | str
1252
+ :param temperature: The temperature to report in °C, or None for no reading
1253
+ :type temperature: float | None
1254
+ :param kwargs: Additional keyword arguments
1255
+ - dhw_idx: Index of the DHW sensor (0 or 1), defaults to 0
1256
+ - Other arguments will raise an exception
1257
+ :return: A Command object for the I|1260 message
1258
+ :rtype: Command
1259
+ :raises CommandInvalid: If the device type is not a DHW sensor
1260
+ :raises AssertionError: If unexpected keyword arguments are provided
1261
+
1262
+ .. note::
1263
+ - This is typically used for testing or simulation purposes
1264
+ - The temperature is converted to the appropriate hex format
1265
+ - The device ID must be a valid DHW sensor type (starts with DHW code)
1266
+ - Most systems only have one DHW sensor (index 0)
1267
+ - The message is sent as an I-type (unsolicited) message
823
1268
  """
824
-
825
1269
  dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)
826
- assert not kwargs, kwargs
1270
+ assert not kwargs, f"Unexpected arguments: {kwargs}"
827
1271
 
828
1272
  if dev_id[:2] != DEV_TYPE_MAP.DHW:
829
1273
  raise exc.CommandInvalid(
@@ -838,19 +1282,52 @@ class Command(Frame):
838
1282
  def put_outdoor_temp(
839
1283
  cls, dev_id: DeviceIdT | str, temperature: float | None
840
1284
  ) -> Command:
841
- """Constructor to announce the current outdoor temperature (1290).
842
-
843
- This is for use by a faked HVAC sensor or similar.
1285
+ """Announce the current outdoor temperature from a sensor. (1290)
1286
+
1287
+ This method constructs a command to announce/simulate an outdoor temperature reading.
1288
+ This is for use by a faked HVAC sensor, or similar.
1289
+
1290
+ :param dev_id: The device ID of the outdoor temperature sensor
1291
+ :type dev_id: DeviceIdT | str
1292
+ :param temperature: The temperature to report in °C, or None for no reading
1293
+ :type temperature: float | None
1294
+ :return: A Command object for the I|1290 message
1295
+ :rtype: Command
1296
+
1297
+ .. note::
1298
+ - This is typically used for testing or simulation purposes
1299
+ - The temperature is converted to the appropriate hex format
1300
+ - The message is sent as an I-type (unsolicited) message
1301
+ - The sensor index is hardcoded to 00 (most systems have only one outdoor sensor)
1302
+ - The device ID should match the expected format for an outdoor temperature sensor
844
1303
  """
845
-
846
1304
  payload = f"00{hex_from_temp(temperature)}"
847
1305
  return cls._from_attrs(I_, Code._1290, payload, addr0=dev_id, addr2=dev_id)
848
1306
 
849
1307
  @classmethod # constructor for I|1298
850
1308
  def put_co2_level(cls, dev_id: DeviceIdT | str, co2_level: float | None) -> Command:
851
- """Constructor to announce the current co2 level of a sensor (1298)."""
852
- # .I --- 37:039266 --:------ 37:039266 1298 003 000316
853
-
1309
+ """Announce the current CO₂ level from a sensor. (1298)
1310
+ .I --- 37:039266 --:------ 37:039266 1298 003 000316
1311
+
1312
+ This method constructs a command to announce/simulate a CO₂ level reading from
1313
+ an indoor air quality sensor. The message is typically sent by devices that
1314
+ monitor indoor air quality.
1315
+
1316
+ :param dev_id: The device ID of the CO₂ sensor
1317
+ :type dev_id: DeviceIdT | str
1318
+ :param co2_level: The CO₂ level to report in ppm (parts per million), or None for no reading
1319
+ :type co2_level: float | None
1320
+ :return: A Command object for the I|1298 message
1321
+ :rtype: Command
1322
+
1323
+ .. note::
1324
+ - This is typically used for testing or simulation purposes
1325
+ - The CO₂ level is converted to the appropriate hex format using double precision
1326
+ - The message is sent as an I-type (unsolicited) message
1327
+ - The sensor index is hardcoded to 00 (most systems have only one CO₂ sensor)
1328
+ - The device ID should match the expected format for a CO₂ sensor
1329
+ - Example message format: ``.I --- 37:039266 --:------ 37:039266 1298 003 000316``
1330
+ """
854
1331
  payload = f"00{hex_from_double(co2_level)}"
855
1332
  return cls._from_attrs(I_, Code._1298, payload, addr0=dev_id, addr2=dev_id)
856
1333
 
@@ -858,9 +1335,29 @@ class Command(Frame):
858
1335
  def put_indoor_humidity(
859
1336
  cls, dev_id: DeviceIdT | str, indoor_humidity: float | None
860
1337
  ) -> Command:
861
- """Constructor to announce the current humidity of a sensor or fan (12A0)."""
862
- # .I --- 37:039266 --:------ 37:039266 1298 003 000316
863
-
1338
+ """Announce the current indoor humidity from a sensor or fan. (12A0)
1339
+ .I --- 37:039266 --:------ 37:039266 1298 003 000316
1340
+
1341
+ This method constructs a command to announce/simulate an indoor humidity reading.
1342
+ The message is typically sent by devices that monitor indoor air quality,
1343
+ such as humidity sensors or ventilation systems with humidity sensing capabilities.
1344
+
1345
+ :param dev_id: The device ID of the humidity sensor or fan
1346
+ :type dev_id: DeviceIdT | str
1347
+ :param indoor_humidity: The relative humidity to report (0-100%), or None for no reading
1348
+ :type indoor_humidity: float | None
1349
+ :return: A Command object for the I|12A0 message
1350
+ :rtype: Command
1351
+
1352
+ .. note::
1353
+ - This is typically used for testing or simulation purposes
1354
+ - The humidity is converted to the appropriate hex format using standard precision
1355
+ - The message is sent as an I-type (unsolicited) message
1356
+ - The sensor index is hardcoded to 00 (most systems have only one humidity sensor)
1357
+ - The device ID should match the expected format for a humidity sensor or fan
1358
+ - The humidity value is expected to be in the range 0-100%
1359
+ - Example message format: ``.I --- 37:039266 --:------ 37:039266 12A0 003 0032`` (for 50%)
1360
+ """
864
1361
  payload = "00" + hex_from_percent(indoor_humidity, high_res=False)
865
1362
  return cls._from_attrs(I_, Code._12A0, payload, addr0=dev_id, addr2=dev_id)
866
1363
 
@@ -868,16 +1365,50 @@ class Command(Frame):
868
1365
  def get_zone_window_state(
869
1366
  cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT
870
1367
  ) -> Command:
871
- """Constructor to get the openwindow state of a zone (c.f. parser_12b0)."""
872
-
1368
+ """Request the open window state of a zone (c.f. parser 12B0).
1369
+
1370
+ This method constructs a command to query whether a particular zone has an open window.
1371
+ The response will indicate if the window in the specified zone is open or closed.
1372
+
1373
+ :param ctl_id: The device ID of the controller managing the zone
1374
+ :type ctl_id: DeviceIdT | str
1375
+ :param zone_idx: The zone index (0-based) to query
1376
+ :type zone_idx: _ZoneIdxT
1377
+ :return: A Command object for the RQ|12B0 message
1378
+ :rtype: Command
1379
+
1380
+ .. note::
1381
+ - The zone index is 0-based (0 = Zone 1, 1 = Zone 2, etc.)
1382
+ - The controller will respond with a message indicating the window state
1383
+ - This is typically used by thermostats to enable/disable heating when windows are open
1384
+ - The actual window state detection is usually done by a separate sensor
1385
+ """
873
1386
  return cls.from_attrs(RQ, ctl_id, Code._12B0, _check_idx(zone_idx))
874
1387
 
875
1388
  @classmethod # constructor for RQ|1F41
876
1389
  def get_dhw_mode(cls, ctl_id: DeviceIdT | str, **kwargs: Any) -> Command:
877
- """Constructor to get the mode of the DHW (c.f. parser_1f41)."""
878
-
1390
+ """Request the current mode of the Domestic Hot Water (DHW) system. (c.f. parser 1F41)
1391
+
1392
+ This method constructs a command to query the operating mode of the DHW system.
1393
+ The response will indicate whether the DHW is in automatic, manual, or other modes.
1394
+
1395
+ :param ctl_id: The device ID of the DHW controller
1396
+ :type ctl_id: DeviceIdT | str
1397
+ :param **kwargs: Additional parameters (currently only 'dhw_idx' is supported)
1398
+ :key dhw_idx: The DHW circuit index (0 or 1, defaults to 0 for single-DHW systems)
1399
+ :type dhw_idx: int, optional
1400
+ :return: A Command object for the RQ|1F41 message
1401
+ :rtype: Command
1402
+ :raises AssertionError: If unexpected keyword arguments are provided
1403
+
1404
+ .. note::
1405
+ - Most systems have a single DHW circuit (index 0)
1406
+ - The response will indicate the current DHW mode (e.g., auto, manual, off)
1407
+ - This is typically used by heating controllers to monitor DHW state
1408
+ - The actual mode values are defined in the response parser (parser_1f41)
1409
+ """
879
1410
  dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)
880
- assert not kwargs, kwargs
1411
+ assert not kwargs, f"Unexpected arguments: {kwargs}"
881
1412
 
882
1413
  return cls.from_attrs(RQ, ctl_id, Code._1F41, dhw_idx)
883
1414
 
@@ -892,10 +1423,38 @@ class Command(Frame):
892
1423
  duration: int | None = None, # never supplied by DhwZone.set_mode()
893
1424
  **kwargs: Any,
894
1425
  ) -> Command:
895
- """Constructor to set/reset the mode of the DHW (c.f. parser_1f41)."""
896
-
1426
+ """Set or reset the mode of the Domestic Hot Water (DHW) system. (c.f. parser 1F41)
1427
+
1428
+ This method constructs a command to change the operating mode of the DHW system.
1429
+ It can set the DHW to automatic, manual on/off, or scheduled modes with specific durations.
1430
+
1431
+ :param ctl_id: The device ID of the DHW controller
1432
+ :type ctl_id: DeviceIdT | str
1433
+ :param mode: The desired DHW mode (None, "auto", "heat", "off", or numeric values)
1434
+ :type mode: int | str | None
1435
+ :param active: If specified, sets the DHW on/off state (alternative to mode)
1436
+ :type active: bool | None
1437
+ :param until: End time for temporary mode (datetime or "YYYY-MM-DD HH:MM" string)
1438
+ :type until: datetime | str | None
1439
+ :param duration: Duration in seconds for temporary mode (alternative to 'until')
1440
+ :type duration: int | None
1441
+ :param **kwargs: Additional parameters (currently only 'dhw_idx' is supported)
1442
+ :key dhw_idx: The DHW circuit index (0 or 1, defaults to 0 for single-DHW systems)
1443
+ :type dhw_idx: int, optional
1444
+ :return: A Command object for the W|1F41 message
1445
+ :rtype: Command
1446
+ :raises AssertionError: If unexpected keyword arguments are provided
1447
+ :raises CommandInvalid: If invalid parameters are provided
1448
+
1449
+ .. note::
1450
+ - Mode takes precedence over 'active' if both are specified
1451
+ - When using 'active' with 'until' or 'duration', the mode will be temporary
1452
+ - Supported mode values are defined in ZON_MODE_MAP
1453
+ - Most systems have a single DHW circuit (index 0)
1454
+ - The actual mode values are defined in the response parser (parser_1f41)
1455
+ """
897
1456
  dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)
898
- assert not kwargs, kwargs
1457
+ assert not kwargs, f"Unexpected arguments: {kwargs}"
899
1458
 
900
1459
  mode = _normalise_mode(mode, active, until, duration)
901
1460
 
@@ -929,16 +1488,34 @@ class Command(Frame):
929
1488
  dst_id: DeviceIdT | str | None = None,
930
1489
  **kwargs: Any,
931
1490
  ) -> Command:
932
- """Constructor for RF bind commands (1FC9), for use by faked devices.
933
-
934
- Expected use-cases:
935
- FAN bound by CO2 (1298), HUM (12A0), PER (2E10), SWI (22F1, 22F3)
936
- CTL bound by DHW (1260), RND/THM (30C9)
937
-
938
- Many other bindings are much more complicated than the above, and may require
939
- bespoke constructors (e.g. TRV binding to a CTL).
1491
+ """Create an RF bind command (1FC9) for device binding operations.
1492
+
1493
+ This method constructs commands used in the 3-way handshake process for binding
1494
+ devices in the Ramses RF protocol. It's primarily used by faked/test devices.
1495
+
1496
+ :param verb: The verb for the command (I, RQ, RP, W, etc.)
1497
+ :type verb: VerbT
1498
+ :param src_id: Source device ID initiating the bind
1499
+ :type src_id: DeviceIdT | str
1500
+ :param codes: Single code or list of codes to bind
1501
+ :type codes: Code | Iterable[Code] | None
1502
+ :param dst_id: Optional destination device ID (defaults to broadcast)
1503
+ :type dst_id: DeviceIdT | str | None
1504
+ :param **kwargs: Additional parameters
1505
+ :key oem_code: OEM code for bind offers (only used with I-type messages)
1506
+ :type oem_code: str, optional
1507
+ :return: A Command object for the bind operation
1508
+ :rtype: Command
1509
+ :raises CommandInvalid: If invalid codes are provided for binding
1510
+
1511
+ .. note::
1512
+ - Common use cases include:
1513
+ - FAN binding to CO2 (1298), HUM (12A0), PER (2E10), or SWI (22F1, 22F3)
1514
+ - CTL binding to DHW (1260), RND/THM (30C9)
1515
+ - More complex bindings (e.g., TRV to CTL) may require custom constructors
1516
+ - The binding process typically involves a 3-way handshake
1517
+ - For I-type messages with no specific destination, this creates a bind offer
940
1518
  """
941
-
942
1519
  kodes: list[Code]
943
1520
 
944
1521
  if not codes: # None, "", or []
@@ -952,7 +1529,7 @@ class Command(Frame):
952
1529
 
953
1530
  if verb == I_ and dst_id in (None, src_id, ALL_DEV_ADDR.id):
954
1531
  oem_code = kwargs.pop("oem_code", None)
955
- assert not kwargs, kwargs
1532
+ assert not kwargs, f"Unexpected arguments: {kwargs}"
956
1533
  return cls._put_bind_offer(src_id, dst_id, kodes, oem_code=oem_code)
957
1534
 
958
1535
  elif verb == W_ and dst_id not in (None, src_id):
@@ -978,7 +1555,33 @@ class Command(Frame):
978
1555
  *,
979
1556
  oem_code: str | None = None,
980
1557
  ) -> Command:
1558
+ """Create a bind offer message (I-type) for device binding.
1559
+
981
1560
  # TODO: should preserve order of codes, else tests may fail
1561
+
1562
+ This internal method constructs the initial bind offer message in the 3-way
1563
+ binding handshake. It's typically called by `put_bind()` and not used directly.
1564
+
1565
+ :param src_id: Source device ID making the offer
1566
+ :type src_id: DeviceIdT | str
1567
+ :param dst_id: Optional destination device ID (broadcast if None)
1568
+ :type dst_id: DeviceIdT | str | None
1569
+ :param codes: List of codes to include in the bind offer
1570
+ :type codes: list[Code]
1571
+ :param oem_code: Optional OEM-specific code for the binding
1572
+ :type oem_code: str | None
1573
+ :return: A Command object for the bind offer message
1574
+ :rtype: Command
1575
+ :raises CommandInvalid: If no valid codes are provided for the offer
1576
+
1577
+ .. note::
1578
+ - This creates an I-type (unsolicited) bind offer message
1579
+ - The message includes the source device's ID and the requested bind codes
1580
+ - OEM-specific bindings can include an additional OEM code
1581
+ - The actual binding codes are filtered to exclude 1FC9 and 10E0
1582
+ - The order of codes is preserved in the output message
1583
+ """
1584
+ # Filter out 1FC9 and 10E0 from the codes list
982
1585
  kodes = [c for c in codes if c not in (Code._1FC9, Code._10E0)]
983
1586
  if not kodes: # might be []
984
1587
  raise exc.CommandInvalid(f"Invalid codes for a bind offer: {codes}")
@@ -1003,7 +1606,31 @@ class Command(Frame):
1003
1606
  *,
1004
1607
  idx: str | None = "00",
1005
1608
  ) -> Command:
1006
- if not codes: # might be
1609
+ """Create a bind accept message (W-type) for device binding.
1610
+
1611
+ This internal method constructs the bind accept message in the 3-way binding
1612
+ handshake. It's typically called by `put_bind()` and is mainly used for testing.
1613
+
1614
+ :param src_id: Source device ID accepting the bind
1615
+ :type src_id: DeviceIdT | str
1616
+ :param dst_id: Destination device ID that sent the bind offer
1617
+ :type dst_id: DeviceIdT | str
1618
+ :param codes: List of codes to include in the bind accept
1619
+ :type codes: list[Code]
1620
+ :param idx: Optional index for the binding (defaults to "00")
1621
+ :type idx: str | None
1622
+ :return: A Command object for the bind accept message
1623
+ :rtype: Command
1624
+ :raises CommandInvalid: If no valid codes are provided for the accept
1625
+
1626
+ .. note::
1627
+ - This creates a W-type (write) bind accept message
1628
+ - The message includes the source device's ID and the accepted bind codes
1629
+ - The index parameter allows for multiple bindings between the same devices
1630
+ - Primarily used in test suites to simulate device binding
1631
+ - The actual binding codes should match those in the original offer
1632
+ """
1633
+ if not codes: # might be empty list
1007
1634
  raise exc.CommandInvalid(f"Invalid codes for a bind accept: {codes}")
1008
1635
 
1009
1636
  hex_id = Address.convert_to_hex(src_id) # type: ignore[arg-type]
@@ -1020,6 +1647,31 @@ class Command(Frame):
1020
1647
  *,
1021
1648
  idx: str | None = "00",
1022
1649
  ) -> Command:
1650
+ """Create a bind confirmation message (I-type) to complete device binding.
1651
+
1652
+ This internal method constructs the final confirmation message in the 3-way
1653
+ binding handshake. It's typically called by `put_bind()` to confirm that
1654
+ the binding process has been completed successfully.
1655
+
1656
+ :param src_id: Source device ID confirming the bind
1657
+ :type src_id: DeviceIdT | str
1658
+ :param dst_id: Destination device ID that needs confirmation
1659
+ :type dst_id: DeviceIdT | str
1660
+ :param codes: List of codes that were bound (only first code is used)
1661
+ :type codes: list[Code]
1662
+ :param idx: Optional index for the binding (defaults to "00")
1663
+ :type idx: str | None
1664
+ :return: A Command object for the bind confirmation message
1665
+ :rtype: Command
1666
+
1667
+ .. note::
1668
+ - This creates an I-type (unsolicited) bind confirmation message
1669
+ - The message includes the source device's ID and the first bound code
1670
+ - If no codes are provided, only the index is used as payload
1671
+ - The index is important (e.g., Nuaire 4-way switch uses "21")
1672
+ - This is the final step in the 3-way binding handshake
1673
+ - The binding is considered complete after this message is received
1674
+ """
1023
1675
  if not codes: # if not payload
1024
1676
  payload = idx or "00" # e.g. Nuaire 4-way switch uses 21!
1025
1677
  else:
@@ -1038,11 +1690,47 @@ class Command(Frame):
1038
1690
  src_id: DeviceIdT | str | None = None,
1039
1691
  idx: str = "00", # could be e.g. "63"
1040
1692
  ) -> Command:
1041
- """Constructor to set the fan speed (and heater?) (c.f. parser_22f1).
1042
-
1043
- There are two types of this packet seen (with seqn, or with src_id):
1044
- - I 018 --:------ --:------ 39:159057 22F1 003 000x04
1045
- - I --- 21:039407 28:126495 --:------ 22F1 003 000x07
1693
+ """Set the operating mode of a ventilation fan.
1694
+
1695
+ This method constructs a command to control the speed and operating mode of a
1696
+ ventilation fan. The command can be sent with either a sequence number or a
1697
+ source device ID, depending on the system configuration.
1698
+
1699
+ There are two types of this packet observed:
1700
+ - With sequence number: ``I 018 --:------ --:------ 39:159057 22F1 003 000x04``
1701
+ - With source ID: ``I --- 21:039407 28:126495 --:------ 22F1 003 000x07``
1702
+
1703
+ :param fan_id: The device ID of the target fan (e.g., '39:159057')
1704
+ :type fan_id: DeviceIdT | str
1705
+ :param fan_mode: The desired fan mode, which can be specified as:
1706
+ - Integer: 0-9 for different speed levels
1707
+ - String: Descriptive mode like 'auto', 'low', 'medium', 'high'
1708
+ - None: Default mode (typically auto)
1709
+ :type fan_mode: int | str | None
1710
+ :param seqn: Optional sequence number (0-255), mutually exclusive with src_id
1711
+ :type seqn: int | str | None
1712
+ :param src_id: Optional source device ID, mutually exclusive with seqn
1713
+ :type src_id: DeviceIdT | str | None
1714
+ :param idx: Index identifier, typically '00' but can be other values like '63'
1715
+ :type idx: str
1716
+ :return: A configured Command object ready to be sent to the device.
1717
+ :rtype: Command
1718
+ :raises CommandInvalid: If both seqn and src_id are provided, or if fan_mode is invalid.
1719
+
1720
+ .. note::
1721
+ This command is typically sent as part of a triplet with 0.1s intervals
1722
+ when using sequence numbers. The sequence number should increase
1723
+ monotonically modulo 256 after each triplet.
1724
+
1725
+ **Scheme 1 (with sequence number):**
1726
+ - Sent as a triplet, 0.1s apart
1727
+ - Uses a sequence number (000-255)
1728
+ - Example: ``I 218 --:------ --:------ 39:159057 22F1 003 000204`` (low speed)
1729
+
1730
+ **Scheme 2 (with source ID):**
1731
+ - Sent as a triplet, 0.085s apart
1732
+ - Uses source device ID instead of sequence number
1733
+ - Example: ``I --- 21:039407 28:126495 --:------ 22F1 003 000507``
1046
1734
  """
1047
1735
  # NOTE: WIP: rate can be int or str
1048
1736
 
@@ -1096,12 +1784,36 @@ class Command(Frame):
1096
1784
  src_id: DeviceIdT | str | None = None,
1097
1785
  **kwargs: Any,
1098
1786
  ) -> Command:
1099
- """Constructor to set the position of the bypass valve (c.f. parser_22f7).
1100
-
1101
- bypass_position: a % from fully open (1.0) to fully closed (0.0).
1102
- None is a sentinel value for auto.
1103
-
1104
- bypass_mode: is a proxy for bypass_position (they should be mutex)
1787
+ """Set the position or mode of a bypass valve in a ventilation system.
1788
+
1789
+ This method constructs a command to control the bypass valve position or mode
1790
+ for a ventilation system. The bypass valve regulates the flow of air between
1791
+ the supply and exhaust air streams, typically for heat recovery.
1792
+
1793
+ The method supports two ways to control the bypass:
1794
+ - Direct position control using `bypass_position` (0.0 to 1.0)
1795
+ - Predefined modes using `bypass_mode` ('auto', 'on', 'off')
1796
+
1797
+ :param fan_id: The device ID of the target fan/ventilation unit (e.g., '01:123456')
1798
+ :type fan_id: DeviceIdT | str
1799
+ :param bypass_position: The desired position as a float between 0.0 (fully closed)
1800
+ and 1.0 (fully open). If None, the system will use auto mode.
1801
+ :type bypass_position: float | None
1802
+ :param src_id: The source device ID sending the command. If None, defaults to fan_id.
1803
+ :type src_id: DeviceIdT | str | None
1804
+ :keyword bypass_mode: Alternative to bypass_position, accepts:
1805
+ - 'auto': Let the system control the bypass automatically
1806
+ - 'on': Force bypass fully open
1807
+ - 'off': Force bypass fully closed
1808
+ :type bypass_mode: str | None
1809
+ :return: A configured Command object ready to be sent to the device.
1810
+ :rtype: Command
1811
+ :raises CommandInvalid: If both bypass_position and bypass_mode are provided,
1812
+ or if an invalid bypass_mode is specified.
1813
+
1814
+ .. note::
1815
+ The bypass valve position affects heat recovery efficiency and indoor air quality.
1816
+ Use with caution as incorrect settings may impact system performance.
1105
1817
  """
1106
1818
 
1107
1819
  # RQ --- 37:155617 32:155617 --:------ 22F7 002 0064 # officially: 00C8EF
@@ -1130,23 +1842,86 @@ class Command(Frame):
1130
1842
 
1131
1843
  @classmethod # constructor for RQ|2309
1132
1844
  def get_zone_setpoint(cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT) -> Command:
1133
- """Constructor to get the setpoint of a zone (c.f. parser_2309)."""
1134
-
1845
+ """Get the current temperature setpoint for a specific zone.
1846
+
1847
+ This method constructs a command to request the current temperature setpoint
1848
+ for a specified zone from the controller. The response will contain the current
1849
+ target temperature for the zone.
1850
+
1851
+ :param ctl_id: The device ID of the controller (e.g., '01:123456')
1852
+ :type ctl_id: DeviceIdT | str
1853
+ :param zone_idx: The index of the zone (0-31 or '00'-'1F')
1854
+ :type zone_idx: _ZoneIdxT
1855
+ :return: A configured Command object that can be sent to the device.
1856
+ :rtype: Command
1857
+ :raises ValueError: If the zone index is out of valid range (0-31)
1858
+
1859
+ .. note::
1860
+ The zone index is 0-based, where:
1861
+ - 0 = Zone 1 (typically main living area)
1862
+ - 1 = Zone 2 (e.g., bedrooms)
1863
+ - And so on up to zone 32
1864
+
1865
+ The actual number of available zones depends on the controller configuration.
1866
+ Requesting a non-existent zone will typically result in no response.
1867
+ """
1135
1868
  return cls.from_attrs(W_, ctl_id, Code._2309, _check_idx(zone_idx))
1136
1869
 
1137
- @classmethod # constructor for W|2309 # TODO: check if setpoint can be None
1870
+ @classmethod # constructor for W|2309
1138
1871
  def set_zone_setpoint(
1139
1872
  cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT, setpoint: float
1140
1873
  ) -> Command:
1141
- """Constructor to set the setpoint of a zone (c.f. parser_2309)."""
1142
- # .W --- 34:092243 01:145038 --:------ 2309 003 0107D0
1143
-
1874
+ """Set the temperature setpoint for a specific zone.
1875
+
1876
+ This method constructs a command to set the target temperature for a specified
1877
+ zone. The setpoint is specified in degrees Celsius with a resolution of 0.1°C.
1878
+
1879
+ :param ctl_id: The device ID of the controller (e.g., '01:123456')
1880
+ :type ctl_id: DeviceIdT | str
1881
+ :param zone_idx: The index of the zone (0-31 or '00'-'1F')
1882
+ :type zone_idx: _ZoneIdxT
1883
+ :param setpoint: The desired temperature in °C (typically 5.0-35.0)
1884
+ :type setpoint: float
1885
+ :return: A configured Command object ready to be sent to the device.
1886
+ :rtype: Command
1887
+ :raises ValueError: If the setpoint is outside the valid range or if the
1888
+ zone index is invalid.
1889
+
1890
+ .. note::
1891
+ The controller will typically round the setpoint to the nearest 0.5°C.
1892
+ The actual temperature range may be further limited by:
1893
+ - System-wide minimum/maximum limits
1894
+ - Zone-specific overrides
1895
+ - Current operating mode (heating/cooling)
1896
+
1897
+ When setting a new setpoint, the system may take some time to acknowledge
1898
+ the change. Use `get_zone_setpoint` to verify the new setting.
1899
+
1900
+ Some systems may have additional restrictions on when setpoints can be
1901
+ modified, such as during specific operating modes or schedules.
1902
+ """
1903
+ # Example: .W --- 34:092243 01:145038 --:------ 2309 003 0107D0
1144
1904
  payload = f"{_check_idx(zone_idx)}{hex_from_temp(setpoint)}"
1145
1905
  return cls.from_attrs(W_, ctl_id, Code._2309, payload)
1146
1906
 
1147
1907
  @classmethod # constructor for RQ|2349
1148
1908
  def get_zone_mode(cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT) -> Command:
1149
- """Constructor to get the mode of a zone (c.f. parser_2349)."""
1909
+ """Get the current operating mode of a zone.
1910
+
1911
+ This method constructs a command to request the current operating mode
1912
+ and setpoint information for a specific zone from the controller.
1913
+
1914
+ :param ctl_id: The device ID of the controller (e.g., '01:123456')
1915
+ :type ctl_id: DeviceIdT | str
1916
+ :param zone_idx: The index of the zone (0-31 or '00'-'1F')
1917
+ :type zone_idx: _ZoneIdxT
1918
+ :return: A configured Command object that can be sent to the device.
1919
+ :rtype: Command
1920
+
1921
+ :Example:
1922
+ >>> # Get mode for zone 0
1923
+ >>> cmd = Command.get_zone_mode('01:123456', '00')
1924
+ """
1150
1925
 
1151
1926
  return cls.from_attrs(RQ, ctl_id, Code._2349, _check_idx(zone_idx))
1152
1927
 
@@ -1161,17 +1936,36 @@ class Command(Frame):
1161
1936
  until: dt | str | None = None,
1162
1937
  duration: int | None = None, # never supplied by Zone.set_mode()
1163
1938
  ) -> Command:
1164
- """Constructor to set/reset the mode of a zone (c.f. parser_2349).
1165
-
1166
- The setpoint has a resolution of 0.1 C. If a setpoint temperature is required,
1167
- but none is provided, evohome will use the maximum possible value.
1168
-
1169
- The until has a resolution of 1 min.
1170
-
1171
- Incompatible combinations:
1172
- - mode == Follow & setpoint not None (will silently ignore setpoint)
1173
- - mode == Temporary & until is None (will silently ignore ???)
1174
- - until and duration are mutually exclusive
1939
+ """Set or reset the operating mode of a zone.
1940
+
1941
+ This method constructs a command to configure the operating mode and setpoint
1942
+ for a specific zone. The command can set the zone to various modes including
1943
+ follow schedule, temporary override, or permanent override.
1944
+
1945
+ :param ctl_id: The device ID of the controller (e.g., '01:123456')
1946
+ :type ctl_id: DeviceIdT | str
1947
+ :param zone_idx: The index of the zone (0-31 or '00'-'1F')
1948
+ :type zone_idx: _ZoneIdxT
1949
+ :keyword mode: The desired operating mode. Can be an integer, string, or None.
1950
+ Common values include 'follow_schedule', 'temporary', 'permanent_override'.
1951
+ :type mode: int | str | None
1952
+ :keyword setpoint: The target temperature in °C (resolution 0.1°C). Required for
1953
+ some modes. If None, the system will use the maximum possible value.
1954
+ :type setpoint: float | None
1955
+ :keyword until: The end time for a temporary override. Required for 'temporary' mode.
1956
+ Can be a datetime object or ISO 8601 formatted string.
1957
+ :type until: datetime | str | None
1958
+ :keyword duration: Duration in minutes for the override. Mutually exclusive with 'until'.
1959
+ :type duration: int | None
1960
+ :return: A configured Command object ready to be sent to the device.
1961
+ :rtype: Command
1962
+ :raises CommandInvalid: If invalid arguments are provided.
1963
+
1964
+ .. note::
1965
+ Incompatible combinations:
1966
+ - mode == 'follow_schedule' & setpoint is not None (setpoint will be ignored)
1967
+ - mode == 'temporary' & until is None (until is required)
1968
+ - until and duration are mutually exclusive (use only one)
1175
1969
  """
1176
1970
 
1177
1971
  # .W --- 18:013393 01:145038 --:------ 2349 013 0004E201FFFFFF330B1A0607E4
@@ -1203,20 +1997,259 @@ class Command(Frame):
1203
1997
  cls,
1204
1998
  fan_id: DeviceIdT | str,
1205
1999
  param_id: str,
1206
- value: str,
2000
+ value: str | int | float | bool,
1207
2001
  *,
1208
2002
  src_id: DeviceIdT | str | None = None,
1209
2003
  ) -> Command:
1210
- """Constructor to set a configurable fan parameter (c.f. parser_2411)."""
2004
+ """Set a configuration parameter for a fan/ventilation device.
2005
+
2006
+ This method constructs a command to configure various parameters of a
2007
+ fan or ventilation device using the RAMSES-II protocol.
2008
+
2009
+ :param fan_id: The device ID of the fan/ventilation unit
2010
+ :type fan_id: DeviceIdT | str
2011
+ :param param_id: The parameter ID to set (e.g., 'bypass_position' or hex code '00')
2012
+ :type param_id: str
2013
+ :param value: The value to set for the parameter. Type depends on the parameter.
2014
+ :type value: str | int | float | bool
2015
+ :param src_id: Optional source device ID. If not provided, fan_id will be used.
2016
+ :type src_id: DeviceIdT | str | None
2017
+ :return: A configured Command object ready to be sent to the device.
2018
+ :rtype: Command
2019
+ :raises CommandInvalid: If the parameter ID is unknown or value is invalid.
2020
+
2021
+ .. note::
2022
+ The parameter ID must be a valid 2-character hexadecimal string (00-FF) that
2023
+ exists in the _2411_PARAMS_SCHEMA. The payload format follows the pattern:
2024
+ ^(00|01|15|16|17|21)00[0-9A-F]{6}[0-9A-F]{8}(([0-9A-F]{8}){3}[0-9A-F]{4})?$
2025
+ --- Ramses-II 2411 payload: 23 bytes, 46 hex digits ---
2026
+
2027
+ Raises:
2028
+ CommandInvalid: For invalid parameters or values
2029
+ """
2030
+ # Validate and normalize parameter ID
2031
+ try:
2032
+ param_id = param_id.strip().upper()
2033
+ if len(param_id) != 2:
2034
+ raise ValueError(
2035
+ "Parameter ID must be exactly 2 hexadecimal characters"
2036
+ )
2037
+ int(param_id, 16) # Validate hex
2038
+ except ValueError as err:
2039
+ raise exc.CommandInvalid(
2040
+ f"Invalid parameter ID: '{param_id}'. Must be a 2-digit hexadecimal value (00-FF)"
2041
+ ) from err
1211
2042
 
1212
- src_id = src_id or fan_id # TODO: src_id should be an arg?
2043
+ # Get parameter schema
2044
+ if (param_schema := _2411_PARAMS_SCHEMA.get(param_id)) is None:
2045
+ raise exc.CommandInvalid(
2046
+ f"Unknown parameter ID: '{param_id}'. This parameter is not defined in the device schema"
2047
+ )
2048
+
2049
+ # Get value constraints with defaults
2050
+ min_val = param_schema[SZ_MIN_VALUE]
2051
+ max_val = param_schema[SZ_MAX_VALUE]
2052
+ precision = param_schema.get(SZ_PRECISION, 1.0)
2053
+ data_type = param_schema.get(SZ_DATA_TYPE, "00")
2054
+
2055
+ try:
2056
+ # Check for special float values first
2057
+ if isinstance(value, float) and not math.isfinite(value):
2058
+ raise exc.CommandInvalid(
2059
+ f"Parameter {param_id}: Invalid value '{value}'. Must be a finite number"
2060
+ )
2061
+
2062
+ # Scaling
2063
+ if str(data_type) == "01": # %
2064
+ # Special handling for parameter 52 (Sensor sensitivity)
2065
+ value_scaled = int(round(float(value) / precision))
2066
+ min_val_scaled = int(round(float(min_val) / precision))
2067
+ max_val_scaled = int(round(float(max_val) / precision))
2068
+ precision_scaled = int(round(float(precision) * 10))
2069
+ trailer = "0032" # Trailer for percentage parameters
2070
+
2071
+ # For percentage values, validate input is in range
2072
+ if not min_val_scaled <= value_scaled <= max_val_scaled:
2073
+ raise exc.CommandInvalid(
2074
+ f"Parameter {param_id}: Value {value_scaled / 10}% is out of allowed range ({min_val_scaled / 10}% to {max_val_scaled / 10}%)"
2075
+ )
2076
+ elif str(data_type) == "0F": # %
2077
+ # For other percentage parameters, use the standard scaling
2078
+ value_scaled = int(round((float(value) / 100.0) / float(precision)))
2079
+ min_val_scaled = int(round(float(min_val) / float(precision)))
2080
+ max_val_scaled = int(round(float(max_val) / float(precision)))
2081
+ precision_scaled = int(round(float(precision) * 200))
2082
+ trailer = "0032" # Trailer for percentage parameters
2083
+
2084
+ # For percentage values, validate input is in range
2085
+ if not min_val_scaled <= value_scaled <= max_val_scaled:
2086
+ raise exc.CommandInvalid(
2087
+ f"Parameter {param_id}: Value {value_scaled / 2}% is out of allowed range ({min_val_scaled / 2}% to {max_val_scaled / 2}%)"
2088
+ )
2089
+ elif str(data_type) == "92": # °C
2090
+ # Scale temperature values by 100 (21.5°C -> 2150 = 0x0866)
2091
+ # Round to 0.1°C precision first, then scale
2092
+ value_rounded = (
2093
+ round(float(value) * 10) / 10
2094
+ ) # Round to 1 decimal place
2095
+ value_scaled = int(
2096
+ value_rounded * 100
2097
+ ) # Convert to integer (e.g., 21.5 -> 2150)
2098
+ min_val_scaled = int(float(min_val) * 100)
2099
+ max_val_scaled = int(float(max_val) * 100)
2100
+ precision_scaled = int(float(precision) * 100)
2101
+ trailer = (
2102
+ "0001" # always 4 hex not sure about the value, but seems to work.
2103
+ )
2104
+ # For temperature values, validate input is within allowed range
2105
+ if not min_val_scaled <= value_scaled <= max_val_scaled:
2106
+ raise exc.CommandInvalid(
2107
+ f"Parameter {param_id}: Temperature {value_scaled / 100:.1f}°C is out of allowed range ({min_val_scaled / 100:.1f}°C to {max_val_scaled / 100:.1f}°C)"
2108
+ )
2109
+ elif (str(data_type) == "00") or (
2110
+ str(data_type) == "10"
2111
+ ): # numeric (minutes, medium(0)/high(1) or days)
2112
+ value_scaled = int(float(value))
2113
+ min_val_scaled = int(float(min_val))
2114
+ max_val_scaled = int(float(max_val))
2115
+ precision = 1
2116
+ precision_scaled = int(precision)
2117
+ trailer = (
2118
+ "0001" # always 4 hex not sure about the value, but seems to work.
2119
+ )
2120
+ # For numeric values, validate input is between min and max
2121
+ if not min_val_scaled <= value_scaled <= max_val_scaled:
2122
+ unit = "minutes" if data_type == "00" else ""
2123
+ raise exc.CommandInvalid(
2124
+ f"Parameter {param_id}: Value {value_scaled}{' ' + unit if unit else ''} is out of allowed range ({min_val_scaled} to {max_val_scaled}{' ' + unit if unit else ''})"
2125
+ )
2126
+ else:
2127
+ # Validate value against min/max
2128
+ raise exc.CommandInvalid(
2129
+ f"Parameter {param_id}: Invalid data type '{data_type}'. Must be one of '00', '01', '0F', '10', or '92'"
2130
+ f"Invalid Data_type {data_type} for parameter {param_id}"
2131
+ )
2132
+
2133
+ # Assemble payload fields
2134
+ leading = "00" # always 2 hex
2135
+ param_id_hex = f"{int(param_id, 16):04X}" # 4 hex, upper, zero-padded
2136
+
2137
+ # data_type (6 hex): always from schema, zero-padded to 6 hex
2138
+ data_type_hex = f"00{data_type}"
2139
+ value_hex = f"{value_scaled:08X}"
2140
+ min_hex = f"{min_val_scaled:08X}"
2141
+ max_hex = f"{max_val_scaled:08X}"
2142
+ precision_hex = f"{precision_scaled:08X}"
2143
+
2144
+ _LOGGER.debug(
2145
+ f"set_fan_param: value={value}, min={min_val}, max={max_val}, precision={precision}"
2146
+ f"\n Scaled: value={value_scaled} (0x{value_hex}), min={min_val_scaled} (0x{min_hex}), "
2147
+ f"max={max_val_scaled} (0x{max_hex}), precision={precision_scaled} (0x{precision_hex})"
2148
+ )
2149
+
2150
+ # Final field order: 2+4+4+8+8+8+8+4 = 46 hex -> 23 bytes
2151
+ payload = (
2152
+ f"{leading}"
2153
+ f"{param_id_hex}"
2154
+ f"{data_type_hex}"
2155
+ f"{value_hex}"
2156
+ f"{min_hex}"
2157
+ f"{max_hex}"
2158
+ f"{precision_hex}"
2159
+ f"{trailer}"
2160
+ )
2161
+ payload = "".join(payload)
2162
+ _LOGGER.debug(
2163
+ f"set_fan_param: Final frame: {W_} --- {src_id} {fan_id} --:------ 2411 {len(payload):03d} {payload}"
2164
+ )
2165
+
2166
+ # Create the command with exactly 2 addresses: from_id and fan_id
2167
+ return cls._from_attrs(
2168
+ W_,
2169
+ Code._2411,
2170
+ payload,
2171
+ addr0=src_id,
2172
+ addr1=fan_id,
2173
+ addr2=NON_DEV_ADDR.id,
2174
+ )
2175
+
2176
+ except (ValueError, TypeError) as err:
2177
+ raise exc.CommandInvalid(f"Invalid value: {value}") from err
1213
2178
 
1214
- if not _2411_PARAMS_SCHEMA.get(param_id): # TODO: not exclude unknowns?
1215
- raise exc.CommandInvalid(f"Unknown parameter: {param_id}")
2179
+ @classmethod # constructor for RQ|2411
2180
+ def get_fan_param(
2181
+ cls,
2182
+ fan_id: DeviceIdT | str,
2183
+ param_id: str,
2184
+ *,
2185
+ src_id: DeviceIdT | str,
2186
+ ) -> Command:
2187
+ """Create a command to get a fan parameter value.
2188
+
2189
+ This method constructs a command to read a specific parameter from a fan device
2190
+ using the RAMSES-II 2411 command. The parameter ID must be a valid 2-character
2191
+ hexadecimal string (00-FF).
2192
+
2193
+ :param fan_id: The device ID of the target fan (e.g., '01:123456')
2194
+ :type fan_id: DeviceIdT | str
2195
+ :param param_id: The parameter ID to read (2-character hex string, e.g., '4E')
2196
+ :type param_id: str
2197
+ :param src_id: The source device ID that will send the command
2198
+ :type src_id: DeviceIdT | str
2199
+ :return: A Command object for the RQ|2411 message
2200
+ :rtype: Command
2201
+ :raises CommandInvalid: If the parameter ID is invalid (None, wrong type, wrong format)
2202
+
2203
+ .. note::
2204
+ For a complete working example, see the `test_get_fan_param.py` test file
2205
+ which demonstrates:
2206
+ - Setting up the gateway
2207
+ - Sending the command
2208
+ - Handling the response
2209
+ - Proper error handling
2210
+
2211
+ .. warning::
2212
+ The parameter ID must be a valid 2-character hexadecimal string (00-FF).
2213
+ The following will raise CommandInvalid:
2214
+ - None value
2215
+ - Non-string types
2216
+ - Leading/trailing whitespace
2217
+ - Incorrect length (not 2 characters)
2218
+ - Non-hexadecimal characters
2219
+ """
2220
+ if param_id is None:
2221
+ raise exc.CommandInvalid("Parameter ID cannot be None")
1216
2222
 
1217
- payload = f"0000{param_id}0000{value:08X}" # TODO: needs work
2223
+ if not isinstance(param_id, str):
2224
+ raise exc.CommandInvalid(
2225
+ f"Parameter ID must be a string, got {type(param_id).__name__}"
2226
+ )
1218
2227
 
1219
- return cls._from_attrs(W_, Code._2411, payload, addr0=src_id, addr1=fan_id)
2228
+ param_id_stripped = param_id.strip()
2229
+ if param_id != param_id_stripped:
2230
+ raise exc.CommandInvalid(
2231
+ f"Parameter ID cannot have leading or trailing whitespace: '{param_id}'"
2232
+ )
2233
+
2234
+ # validate the string format
2235
+ try:
2236
+ if len(param_id) != 2:
2237
+ raise ValueError("Invalid length")
2238
+ int(param_id, 16) # Will raise ValueError if not valid hex
2239
+ except ValueError as err:
2240
+ raise exc.CommandInvalid(
2241
+ f"Invalid parameter ID: '{param_id}'. Must be a 2-character hex string (00-FF)."
2242
+ ) from err
2243
+
2244
+ payload = f"0000{param_id.upper()}" # Convert to uppercase for consistency
2245
+ _LOGGER.debug(
2246
+ "Created get_fan_param command for %s from %s to %s",
2247
+ param_id,
2248
+ src_id,
2249
+ fan_id,
2250
+ )
2251
+
2252
+ return cls._from_attrs(RQ, Code._2411, payload, addr0=src_id, addr1=fan_id)
1220
2253
 
1221
2254
  @classmethod # constructor for RQ|2E04
1222
2255
  def get_system_mode(cls, ctl_id: DeviceIdT | str) -> Command:
@@ -1232,7 +2265,41 @@ class Command(Frame):
1232
2265
  *,
1233
2266
  until: dt | str | None = None,
1234
2267
  ) -> Command:
1235
- """Constructor to set/reset the mode of a system (c.f. parser_2e04)."""
2268
+ """Set or reset the operating mode of the HVAC system. (c.f. parser_2e04)
2269
+
2270
+ This method constructs a command to change the system-wide operating mode,
2271
+ such as switching between heating modes or setting a temporary override.
2272
+
2273
+ :param ctl_id: The device ID of the controller (e.g., '01:123456')
2274
+ :type ctl_id: DeviceIdT | str
2275
+ :param system_mode: The desired system mode. Can be specified as:
2276
+ - Integer: Numeric mode code (0-5)
2277
+ - String: Mode name (e.g., 'auto', 'heat_eco')
2278
+ - Hex string: Two-character hex code (e.g., '00' for auto)
2279
+ If None, defaults to 'auto' mode.
2280
+ :type system_mode: int | str | None
2281
+ :param until: Optional timestamp when the mode should revert.
2282
+ Required for temporary modes like 'eco' or 'advanced'.
2283
+ Not allowed for 'auto' or 'heat_off' modes.
2284
+ :type until: datetime | str | None
2285
+ :return: A configured Command object ready to be sent to the device.
2286
+ :rtype: Command
2287
+ :raises CommandInvalid: If the combination of mode and until is invalid.
2288
+ :raises KeyError: If an invalid mode is specified.
2289
+
2290
+ .. note::
2291
+ Available modes are defined in SYS_MODE_MAP and typically include:
2292
+ - 'auto': System follows the schedule (code '00')
2293
+ - 'heat_off': Heating disabled (code '04')
2294
+ - 'eco': Reduced temperature mode (code '01')
2295
+ - 'advanced': Custom temperature mode (code '02')
2296
+ - 'holiday': Away mode (code '03')
2297
+ - 'custom': Custom mode (code '05')
2298
+
2299
+ When using temporary modes (eco/advanced), the 'until' parameter
2300
+ must be provided. The system will automatically revert to the
2301
+ schedule when the time elapses.
2302
+ """
1236
2303
 
1237
2304
  if system_mode is None:
1238
2305
  system_mode = SYS_MODE_MAP.AUTO
@@ -1267,25 +2334,92 @@ class Command(Frame):
1267
2334
  def put_presence_detected(
1268
2335
  cls, dev_id: DeviceIdT | str, presence_detected: bool | None
1269
2336
  ) -> Command:
1270
- """Constructor to announce the current presence state of a sensor (2E10)."""
2337
+ """Announce the current presence detection state from a sensor. (c.f. parser_2e10)
1271
2338
  # .I --- ...
1272
2339
 
2340
+ This method constructs an I-type (unsolicited) command to report the
2341
+ presence detection state from a presence sensor to the system.
2342
+
2343
+ :param dev_id: The device ID of the presence sensor (e.g., '01:123456')
2344
+ :type dev_id: DeviceIdT | str
2345
+ :param presence_detected: The current presence state:
2346
+ - True: Presence detected
2347
+ - False: No presence detected
2348
+ - None: Sensor state unknown/error
2349
+ :type presence_detected: bool | None
2350
+ :return: A configured Command object ready to be sent to the system.
2351
+ :rtype: Command
2352
+
2353
+ .. note::
2354
+ This is typically used by presence sensors to report their state
2355
+ to the HVAC system. The system may use this information for
2356
+ occupancy-based control strategies.
2357
+
2358
+ The command uses the 2E10 code, which is specifically designed
2359
+ for presence/occupancy reporting in the RAMSES-II protocol.
2360
+ """
1273
2361
  payload = f"00{hex_from_bool(presence_detected)}"
1274
2362
  return cls._from_attrs(I_, Code._2E10, payload, addr0=dev_id, addr2=dev_id)
1275
2363
 
1276
2364
  @classmethod # constructor for RQ|30C9
1277
2365
  def get_zone_temp(cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT) -> Command:
1278
- """Constructor to get the current temperature of a zone (c.f. parser_30c9)."""
1279
-
2366
+ """Request the current temperature reading for a specific zone. (c.f. parser_30c9)
2367
+
2368
+ This method constructs a command to request the current temperature
2369
+ from a zone's temperature sensor. The response will include the current
2370
+ temperature in degrees Celsius with 0.1°C resolution.
2371
+
2372
+ :param ctl_id: The device ID of the controller managing the zone (e.g., '01:123456')
2373
+ :type ctl_id: DeviceIdT | str
2374
+ :param zone_idx: The index of the zone to query. Can be specified as:
2375
+ - Integer (0-31)
2376
+ - Hex string ('00'-'1F')
2377
+ - String representation of integer ('0'-'31')
2378
+ :type zone_idx: _ZoneIdxT
2379
+ :return: A configured Command object that can be sent to the device.
2380
+ :rtype: Command
2381
+ :raises ValueError: If the zone index is out of valid range (0-31)
2382
+
2383
+ .. note::
2384
+ The zone index is 0-based. For example:
2385
+ - 0 = Zone 1 (typically main living area)
2386
+ - 1 = Zone 2 (e.g., bedrooms)
2387
+ - And so on up to zone 32
2388
+
2389
+ The actual number of available zones depends on the controller configuration.
2390
+ Requesting a non-existent zone will typically result in no response.
2391
+ """
1280
2392
  return cls.from_attrs(RQ, ctl_id, Code._30C9, _check_idx(zone_idx))
1281
2393
 
1282
2394
  @classmethod # constructor for I|30C9 # TODO: trap corrupt temps?
1283
2395
  def put_sensor_temp(
1284
2396
  cls, dev_id: DeviceIdT | str, temperature: float | None
1285
2397
  ) -> Command:
1286
- """Constructor to announce the current temperature of a thermostat (3C09).
1287
-
2398
+ """Announce the current temperature reading from a thermostat. (c.f. parser_30c9)
1288
2399
  This is for use by a faked DTS92(E) or similar.
2400
+
2401
+ This method constructs an I-type (unsolicited) command to report the current
2402
+ temperature from a thermostat or temperature sensor to the system. This is
2403
+ typically used to simulate a physical thermostat's temperature reporting.
2404
+
2405
+ :param dev_id: The device ID of the thermostat or sensor (e.g., '01:123456')
2406
+ :type dev_id: DeviceIdT | str
2407
+ :param temperature: The current temperature in degrees Celsius.
2408
+ Use None to indicate a sensor error or invalid reading.
2409
+ The valid range is typically 0-40°C, but this may vary by device.
2410
+ :type temperature: float | None
2411
+ :return: A configured Command object ready to be sent to the system.
2412
+ :rtype: Command
2413
+
2414
+ .. note::
2415
+ This is primarily used for testing or simulating thermostats like the DTS92(E).
2416
+ The temperature is transmitted with 0.1°C resolution.
2417
+
2418
+ The command uses the 30C9 code, which is used by thermostats to report
2419
+ their current temperature reading to the controller.
2420
+
2421
+ When temperature is None, it typically indicates a sensor fault or
2422
+ invalid reading, which the system may interpret as a maintenance alert.
1289
2423
  """
1290
2424
  # .I --- 34:021943 --:------ 34:021943 30C9 003 000C0D
1291
2425
 
@@ -1350,7 +2484,62 @@ class Command(Frame):
1350
2484
  exhaust_flow: float | None,
1351
2485
  **kwargs: Any, # option: air_quality_basis: str | None,
1352
2486
  ) -> Command:
1353
- """Constructor to announce hvac fan (state, temps, flows, humidity etc.) of a HRU (31DA)."""
2487
+ """Construct an I|31DA command for HVAC fan status updates.
2488
+
2489
+ This method creates an unsolicited status update command for HVAC fan systems,
2490
+ reporting various sensor readings and system states.
2491
+
2492
+ :param dev_id: The device ID of the HVAC controller
2493
+ :type dev_id: DeviceIdT | str
2494
+ :param hvac_id: The ID of the HVAC unit
2495
+ :type hvac_id: str
2496
+ :param bypass_position: Current bypass damper position (0.0-1.0)
2497
+ :type bypass_position: float | None
2498
+ :param air_quality: Current air quality reading
2499
+ :type air_quality: int | None
2500
+ :param co2_level: Current CO₂ level in ppm
2501
+ :type co2_level: int | None
2502
+ :param indoor_humidity: Current indoor relative humidity (0.0-1.0)
2503
+ :type indoor_humidity: float | None
2504
+ :param outdoor_humidity: Current outdoor relative humidity (0.0-1.0)
2505
+ :type outdoor_humidity: float | None
2506
+ :param exhaust_temp: Current exhaust air temperature in °C
2507
+ :type exhaust_temp: float | None
2508
+ :param supply_temp: Current supply air temperature in °C
2509
+ :type supply_temp: float | None
2510
+ :param indoor_temp: Current indoor temperature in °C
2511
+ :type indoor_temp: float | None
2512
+ :param outdoor_temp: Current outdoor temperature in °C
2513
+ :type outdoor_temp: float | None
2514
+ :param speed_capabilities: List of supported fan speed settings
2515
+ :type speed_capabilities: list[str]
2516
+ :param fan_info: Current fan mode/status information
2517
+ :type fan_info: str
2518
+ :param _unknown_fan_info_flags: Internal flags (reserved for future use)
2519
+ :type _unknown_fan_info_flags: list[int]
2520
+ :param exhaust_fan_speed: Current exhaust fan speed (0.0-1.0)
2521
+ :type exhaust_fan_speed: float | None
2522
+ :param supply_fan_speed: Current supply fan speed (0.0-1.0)
2523
+ :type supply_fan_speed: float | None
2524
+ :param remaining_mins: Remaining time in current mode (minutes)
2525
+ :type remaining_mins: int | None
2526
+ :param post_heat: Post-heat status/level
2527
+ :type post_heat: int | None
2528
+ :param pre_heat: Pre-heat status/level
2529
+ :type pre_heat: int | None
2530
+ :param supply_flow: Current supply air flow rate (if available)
2531
+ :type supply_flow: float | None
2532
+ :param exhaust_flow: Current exhaust air flow rate (if available)
2533
+ :type exhaust_flow: float | None
2534
+ :param **kwargs: Additional parameters (reserved for future use)
2535
+ :return: A configured Command object for the HVAC fan status update
2536
+ :rtype: Command
2537
+
2538
+ .. note::
2539
+ This command is typically sent periodically by the HVAC controller to report
2540
+ current system status. All parameters are optional, but providing complete
2541
+ information will result in more accurate system monitoring and control.
2542
+ """
1354
2543
  # 00 EF00 7FFF 34 33 0898 0898 088A 0882 F800 00 15 14 14 0000 EF EF 05F5 0613:
1355
2544
  # {"hvac_id": '00', 'bypass_position': 0.000, 'air_quality': None,
1356
2545
  # 'co2_level': None, 'indoor_humidity': 0.52, 'outdoor_humidity': 0.51,
@@ -1429,8 +2618,39 @@ class Command(Frame):
1429
2618
 
1430
2619
  @classmethod # constructor for RQ|3220
1431
2620
  def get_opentherm_data(cls, otb_id: DeviceIdT | str, msg_id: int | str) -> Command:
1432
- """Constructor to get (Read-Data) opentherm msg value (c.f. parser_3220)."""
1433
-
2621
+ """Request OpenTherm protocol data from a device. (c.f. parser_3220)
2622
+
2623
+ This method constructs a command to request data from an OpenTherm compatible
2624
+ device using the OpenTherm protocol. It sends a Read-Data request for a
2625
+ specific data ID to the target device.
2626
+
2627
+ :param otb_id: The device ID of the OpenTherm bridge/controller
2628
+ :type otb_id: DeviceIdT | str
2629
+ :param msg_id: The OpenTherm message ID to request. Can be specified as:
2630
+ - Integer (e.g., 0 for Status)
2631
+ - Hex string (e.g., '00' for Status)
2632
+ See OpenTherm specification for valid message IDs.
2633
+ :type msg_id: int | str
2634
+ :return: A configured Command object ready to be sent to the device.
2635
+ :rtype: Command
2636
+
2637
+ .. note::
2638
+ The OpenTherm protocol is used for communication between heating systems
2639
+ and thermostats. Common message IDs include:
2640
+ - 0x00: Status (0x00)
2641
+ - 0x01: Control setpoint (0x01)
2642
+ - 0x11: Relative modulation level (0x11)
2643
+ - 0x12: CH water pressure (0x12)
2644
+ - 0x19: Boiler water temperature (0x19)
2645
+ - 0x1A: DHW temperature (0x1A)
2646
+ - 0x71: DHW setpoint (0x71)
2647
+
2648
+ The response will contain the requested data in the OpenTherm format,
2649
+ which includes status flags and the data value.
2650
+
2651
+ The command automatically handles the parity bit required by the
2652
+ OpenTherm protocol.
2653
+ """
1434
2654
  msg_id = msg_id if isinstance(msg_id, int) else int(msg_id, 16)
1435
2655
  payload = f"0080{msg_id:02X}0000" if parity(msg_id) else f"0000{msg_id:02X}0000"
1436
2656
  return cls.from_attrs(RQ, otb_id, Code._3220, payload)
@@ -1439,9 +2659,32 @@ class Command(Frame):
1439
2659
  def put_actuator_state(
1440
2660
  cls, dev_id: DeviceIdT | str, modulation_level: float
1441
2661
  ) -> Command:
1442
- """Constructor to announce the modulation level of an actuator (3EF0).
1443
-
2662
+ """Announce the current modulation level of a heating actuator. (c.f. parser_3ef0)
1444
2663
  This is for use by a faked BDR91A or similar.
2664
+
2665
+ This method constructs an I-type (unsolicited) command to report the current
2666
+ modulation level of a heating actuator, such as a BDR91A relay. The modulation
2667
+ level represents the current output state of the actuator as a percentage.
2668
+
2669
+ :param dev_id: The device ID of the actuator (e.g., '13:123456').
2670
+ Must be a device type compatible with BDR91A.
2671
+ :type dev_id: DeviceIdT | str
2672
+ :param modulation_level: The current modulation level as a float between 0.0 and 1.0.
2673
+ - 0.0: Actuator is fully off
2674
+ - 1.0: Actuator is fully on
2675
+ - Values in between represent partial modulation (if supported)
2676
+ - None: Indicates an error or unknown state
2677
+ :type modulation_level: float | None
2678
+ :return: A configured Command object ready to be sent to the system.
2679
+ :rtype: Command
2680
+ :raises CommandInvalid: If the device ID is not a valid BDR-type device.
2681
+
2682
+ .. note::
2683
+ This is primarily used for testing or simulating BDR91A relay modules.
2684
+ The modulation level is converted to a percentage (0-100%) with 0.5% resolution.
2685
+
2686
+ The command uses the 3EF0 code, which is specifically designed for
2687
+ reporting actuator states in the RAMSES-II protocol.
1445
2688
  """
1446
2689
  # .I --- 13:049798 --:------ 13:049798 3EF0 003 00C8FF
1447
2690
  # .I --- 13:106039 --:------ 13:106039 3EF0 003 0000FF
@@ -1469,9 +2712,40 @@ class Command(Frame):
1469
2712
  *,
1470
2713
  cycle_countdown: int | None = None,
1471
2714
  ) -> Command:
1472
- """Constructor to announce the internal state of an actuator (3EF1).
1473
-
2715
+ """Announce the internal cycling state of a heating actuator. (c.f. parser_3ef1)
1474
2716
  This is for use by a faked BDR91A or similar.
2717
+
2718
+ This method constructs an RP-type (request/response) command to report the
2719
+ internal cycling state of a heating actuator, such as a BDR91A relay. It provides
2720
+ detailed timing information about the actuator's modulation cycle.
2721
+
2722
+ :param src_id: The device ID of the actuator sending the report (e.g., '13:123456').
2723
+ Must be a device type compatible with BDR91A.
2724
+ :type src_id: DeviceIdT | str
2725
+ :param dst_id: The device ID of the intended recipient of this report.
2726
+ :type dst_id: DeviceIdT | str
2727
+ :param modulation_level: The current modulation level as a float between 0.0 and 1.0.
2728
+ - 0.0: Actuator is fully off
2729
+ - 1.0: Actuator is fully on
2730
+ - Values in between represent partial modulation (if supported)
2731
+ :type modulation_level: float
2732
+ :param actuator_countdown: Time in seconds until the next actuator cycle state change.
2733
+ This is used for PWM (Pulse Width Modulation) control.
2734
+ :type actuator_countdown: int
2735
+ :param cycle_countdown: Optional time in seconds until the next complete cycle.
2736
+ If None, indicates the cycle is not currently active.
2737
+ :type cycle_countdown: int | None
2738
+ :return: A configured Command object ready to be sent to the system.
2739
+ :rtype: Command
2740
+ :raises CommandInvalid: If the source device ID is not a valid BDR-type device.
2741
+
2742
+ .. note::
2743
+ This is primarily used for testing or simulating BDR91A relay modules.
2744
+ The method automatically handles the conversion of timing values to the
2745
+ appropriate hexadecimal format required by the RAMSES-II protocol.
2746
+
2747
+ The command uses the 3EF1 code, which is specifically designed for
2748
+ reporting detailed actuator cycling information.
1475
2749
  """
1476
2750
  # RP --- 13:049798 18:006402 --:------ 3EF1 007 00-0126-0126-00-FF
1477
2751
 
@@ -1490,6 +2764,43 @@ class Command(Frame):
1490
2764
 
1491
2765
  @classmethod # constructor for internal use only
1492
2766
  def _puzzle(cls, msg_type: str | None = None, message: str = "") -> Command:
2767
+ """Construct a puzzle command used for device discovery and version reporting.
2768
+
2769
+ This internal method creates a special 'puzzle' command used during device
2770
+ discovery and version reporting. The command format varies based on the
2771
+ message type and content.
2772
+
2773
+ :param msg_type: The type of puzzle message to create. If None, it will be
2774
+ automatically determined based on the presence of a message:
2775
+ - '10': Version request (empty message)
2776
+ - '12': Version response (with message)
2777
+ Other valid types include '11' and '13' for specific message formats,
2778
+ and '20' and above for timestamp-based messages.
2779
+ :type msg_type: str | None
2780
+ :param message: The message content to include in the puzzle.
2781
+ Format depends on msg_type:
2782
+ - For type '10': Should be empty (version request)
2783
+ - For type '11': Should be a 10-character string (MAC address)
2784
+ - For type '12': Version string (e.g., 'v0.20.0')
2785
+ - For other types: Arbitrary message content
2786
+ :type message: str
2787
+ :return: A configured Command object with the puzzle message.
2788
+ :rtype: Command
2789
+ :raises AssertionError: If msg_type is not in LOOKUP_PUZZ.
2790
+
2791
+ .. note::
2792
+ This is an internal method used by the RAMSES-II protocol for device
2793
+ discovery and version reporting. The message format varies:
2794
+
2795
+ - Type '10': Version request (empty message)
2796
+ - Type '11': MAC address report (special format)
2797
+ - Type '12': Version response (includes version string)
2798
+ - Type '13': Basic message (no timestamp)
2799
+ - Type '20+': Timestamped message (high precision)
2800
+
2801
+ The method automatically handles timestamp generation and message
2802
+ formatting based on the message type.
2803
+ """
1493
2804
  if msg_type is None:
1494
2805
  msg_type = "12" if message else "10"
1495
2806