ramses-rf 0.52.4__py3-none-any.whl → 0.53.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ramses_rf/device/base.py CHANGED
@@ -87,9 +87,10 @@ class DeviceBase(Entity):
87
87
  return self.id < other.id # type: ignore[no-any-return]
88
88
 
89
89
  def _update_traits(self, **traits: Any) -> None:
90
- """Update a device with new schema attrs.
90
+ """Update a device with new schema attributes.
91
91
 
92
- Raise an exception if the new schema is not a superset of the existing schema.
92
+ :param traits: The traits to apply (e.g., alias, class, faked)
93
+ :raises TypeError: If the device is not fakeable but 'faked' is set.
93
94
  """
94
95
 
95
96
  traits = shrink(SCH_TRAITS(traits))
@@ -342,7 +343,17 @@ class Fakeable(DeviceBase):
342
343
  idx: IndexT = "00",
343
344
  require_ratify: bool = False,
344
345
  ) -> tuple[Packet, Packet, Packet, Packet | None]:
345
- """Listen for a binding and return the Offer, or raise an exception."""
346
+ """Listen for a binding and return the Offer packets.
347
+
348
+ :param accept_codes: The codes allowed for this binding
349
+ :type accept_codes: Iterable[Code]
350
+ :param idx: The index to bind to, defaults to "00"
351
+ :type idx: IndexT
352
+ :param require_ratify: Whether a ratification step is required, defaults to False
353
+ :type require_ratify: bool
354
+ :return: A tuple of the four binding transaction packets
355
+ :rtype: tuple[Packet, Packet, Packet, Packet | None]
356
+ """
346
357
 
347
358
  if not self._bind_context:
348
359
  raise TypeError(f"{self}: Faking not enabled")
ramses_rf/device/heat.py CHANGED
@@ -669,7 +669,7 @@ class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
669
669
 
670
670
  # TODO(eb): cleanup
671
671
  if self._gwy.msg_db:
672
- self._add_record(address=self.addr, code=Code._3220, verb="RP")
672
+ self._add_record(id=self.id, code=Code._3220, verb="RP")
673
673
  # adds a "sim" RP opentherm_msg to the SQLite MessageIndex with code _3220
674
674
  # causes exc when fetching ALL, when no "real" msg was added to _msgs_. We skip those.
675
675
  else:
ramses_rf/device/hvac.py CHANGED
@@ -921,27 +921,30 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
921
921
 
922
922
  :return: string describing fan mode, speed
923
923
  """
924
- if self._gwy.msg_db:
925
- # Use SQLite query on MessageIndex. res_rate/res_mode not exposed yet
926
- sql = f"""
927
- SELECT code from messages WHERE verb in (' I', 'RP')
928
- AND (src = ? OR dst = ?)
929
- AND (plk LIKE '%{SZ_FAN_MODE}%')
930
- """
931
- res_mode: list = self._msg_qry(sql)
932
- # SQLite query on MessageIndex
933
- _LOGGER.debug(f"{res_mode} # FAN_MODE FETCHED from MessageIndex")
934
-
935
- sql = f"""
936
- SELECT code from messages WHERE verb in (' I', 'RP')
937
- AND (src = ? OR dst = ?)
938
- AND (plk LIKE '%{SZ_FAN_RATE}%')
939
- """
940
- res_rate: list = self._msg_qry(sql)
941
- # SQLite query on MessageIndex
942
- _LOGGER.debug(
943
- f"{res_rate} # FAN_RATE FETCHED from MessageIndex"
944
- ) # DEBUG always empty?
924
+ # if self._gwy.msg_db:
925
+ # Use SQLite query on MessageIndex. res_rate/res_mode not exposed yet
926
+ # working fine in 0.52.4, no need to specify code, only payload key
927
+ # sql = f"""
928
+ # SELECT code from messages WHERE verb in (' I', 'RP')
929
+ # AND (src = ? OR dst = ?)
930
+ # AND (plk LIKE '%{SZ_FAN_MODE}%')
931
+ # """
932
+ # res_mode: list = self._msg_qry(sql)
933
+ # # SQLite query on MessageIndex
934
+ # _LOGGER.debug(
935
+ # f"# Fetched FAN_MODE for {self.id} from MessageIndex: {res_mode}"
936
+ # )
937
+
938
+ # sql = f"""
939
+ # SELECT code from messages WHERE verb in (' I', 'RP')
940
+ # AND (src = ? OR dst = ?)
941
+ # AND (plk LIKE '%{SZ_FAN_RATE}%')
942
+ # """
943
+ # res_rate: list = self._msg_qry(sql)
944
+ # # SQLite query on MessageIndex
945
+ # _LOGGER.debug(
946
+ # f"# Fetched FAN_RATE for {self.id} from MessageIndex: {res_rate}"
947
+ # )
945
948
 
946
949
  if Code._31D9 in self._msgs:
947
950
  # was a dict by Code
ramses_rf/entity_base.py CHANGED
@@ -15,7 +15,7 @@ from types import ModuleType
15
15
  from typing import TYPE_CHECKING, Any, Final
16
16
 
17
17
  from ramses_rf.helpers import schedule_task
18
- from ramses_tx import Address, Priority, QosParams
18
+ from ramses_tx import Priority, QosParams
19
19
  from ramses_tx.address import ALL_DEVICE_ID
20
20
  from ramses_tx.const import MsgId
21
21
  from ramses_tx.opentherm import OPENTHERM_MESSAGES
@@ -247,7 +247,7 @@ class _MessageDB(_Entity):
247
247
 
248
248
  if self._gwy.msg_db: # central SQLite MessageIndex
249
249
  _LOGGER.debug(
250
- "For %s (_z_id %s) add msg %s, src %s, dst %s to msg_db.",
250
+ "For %s (_z_id %s) add to msg_db: %s, src %s, dst %s",
251
251
  self.id,
252
252
  self._z_id,
253
253
  msg,
@@ -324,12 +324,12 @@ class _MessageDB(_Entity):
324
324
  ]
325
325
 
326
326
  def _add_record(
327
- self, address: Address, code: Code | None = None, verb: str = " I"
327
+ self, id: DeviceIdT, code: Code | None = None, verb: str = " I"
328
328
  ) -> None:
329
329
  """Add a (dummy) record to the central SQLite MessageIndex."""
330
330
  # used by heat.py init
331
331
  if self._gwy.msg_db:
332
- self._gwy.msg_db.add_record(str(address), code=str(code), verb=verb)
332
+ self._gwy.msg_db.add_record(id, code=str(code), verb=verb)
333
333
  # else:
334
334
  # _LOGGER.warning("Missing MessageIndex")
335
335
  # raise NotImplementedError
@@ -1029,7 +1029,7 @@ class _Discovery(_MessageDB):
1029
1029
  sql = """
1030
1030
  SELECT dtm from messages WHERE
1031
1031
  code = ?
1032
- AND verb = ' I'
1032
+ AND verb in (' I', 'RP')
1033
1033
  AND ctx = 'True'
1034
1034
  AND (src = ? OR dst = ?)
1035
1035
  """
@@ -1045,10 +1045,12 @@ class _Discovery(_MessageDB):
1045
1045
  msgs += res[0] # expect 1 Message in returned tuple
1046
1046
  else:
1047
1047
  _LOGGER.debug(
1048
- f"No msg found for hdr {hdr}, tesk code {task[_SZ_COMMAND].code}"
1048
+ f"No msg found for hdr {hdr}, task code {task[_SZ_COMMAND].code}"
1049
1049
  )
1050
1050
  else: # TODO(eb) remove next Q1 2026
1051
- msgs += [self.tcs._msgz[task[_SZ_COMMAND].code][I_][True]]
1051
+ # CRITICAL FIX: self.tcs might be None during early discovery
1052
+ if self.tcs:
1053
+ msgs += [self.tcs._msgz[task[_SZ_COMMAND].code][I_][True]]
1052
1054
  # raise NotImplementedError
1053
1055
  except KeyError:
1054
1056
  pass
ramses_rf/gateway.py CHANGED
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import asyncio
13
13
  import logging
14
+ from collections.abc import Awaitable, Callable
14
15
  from types import SimpleNamespace
15
16
  from typing import TYPE_CHECKING, Any
16
17
 
@@ -83,7 +84,12 @@ _LOGGER = logging.getLogger(__name__)
83
84
 
84
85
 
85
86
  class Gateway(Engine):
86
- """The gateway class."""
87
+ """The gateway class.
88
+
89
+ This class serves as the primary interface for the RAMSES RF network. It manages
90
+ the serial connection (via ``Engine``), device discovery, schema maintenance,
91
+ and message dispatching.
92
+ """
87
93
 
88
94
  def __init__(
89
95
  self,
@@ -94,8 +100,30 @@ class Gateway(Engine):
94
100
  block_list: DeviceListT | None = None,
95
101
  known_list: DeviceListT | None = None,
96
102
  loop: asyncio.AbstractEventLoop | None = None,
103
+ transport_constructor: Callable[..., Awaitable[RamsesTransportT]] | None = None,
97
104
  **kwargs: Any,
98
105
  ) -> None:
106
+ """Initialize the Gateway instance.
107
+
108
+ :param port_name: The serial port name (e.g., '/dev/ttyUSB0') or None if using a file.
109
+ :type port_name: str | None
110
+ :param input_file: Path to a packet log file for playback, defaults to None.
111
+ :type input_file: str | None, optional
112
+ :param port_config: Configuration dictionary for the serial port, defaults to None.
113
+ :type port_config: PortConfigT | None, optional
114
+ :param packet_log: Configuration for packet logging, defaults to None.
115
+ :type packet_log: PktLogConfigT | None, optional
116
+ :param block_list: A list of device IDs to block/ignore, defaults to None.
117
+ :type block_list: DeviceListT | None, optional
118
+ :param known_list: A list of known device IDs and their traits, defaults to None.
119
+ :type known_list: DeviceListT | None, optional
120
+ :param loop: The asyncio event loop to use, defaults to None.
121
+ :type loop: asyncio.AbstractEventLoop | None, optional
122
+ :param transport_constructor: A factory for creating the transport layer, defaults to None.
123
+ :type transport_constructor: Callable[..., Awaitable[RamsesTransportT]] | None, optional
124
+ :param kwargs: Additional configuration parameters passed to the engine and schema.
125
+ :type kwargs: Any
126
+ """
99
127
  if kwargs.pop("debug_mode", None):
100
128
  _LOGGER.setLevel(logging.DEBUG)
101
129
 
@@ -110,6 +138,7 @@ class Gateway(Engine):
110
138
  block_list=block_list,
111
139
  known_list=known_list,
112
140
  loop=loop,
141
+ transport_constructor=transport_constructor,
113
142
  **SCH_ENGINE_CONFIG(config),
114
143
  )
115
144
 
@@ -132,13 +161,23 @@ class Gateway(Engine):
132
161
  self.msg_db: MessageIndex | None = None
133
162
 
134
163
  def __repr__(self) -> str:
164
+ """Return a string representation of the Gateway.
165
+
166
+ :returns: A string describing the gateway's input source (port or file).
167
+ :rtype: str
168
+ """
135
169
  if not self.ser_name:
136
170
  return f"Gateway(input_file={self._input_file})"
137
171
  return f"Gateway(port_name={self.ser_name}, port_config={self._port_config})"
138
172
 
139
173
  @property
140
174
  def hgi(self) -> HgiGateway | None:
141
- """Return the active HGI80-compatible gateway device, if known."""
175
+ """Return the active HGI80-compatible gateway device, if known.
176
+
177
+ :returns: The gateway device instance or None if the transport is not set up
178
+ or the HGI ID is not found.
179
+ :rtype: HgiGateway | None
180
+ """
142
181
  if not self._transport:
143
182
  return None
144
183
  if device_id := self._transport.get_extra_info(SZ_ACTIVE_HGI):
@@ -152,10 +191,21 @@ class Gateway(Engine):
152
191
  start_discovery: bool = True,
153
192
  cached_packets: dict[str, str] | None = None,
154
193
  ) -> None:
155
- """Start the Gateway and Initiate discovery as required."""
194
+ """Start the Gateway and Initiate discovery as required.
195
+
196
+ This method initializes packet logging, the SQLite index, loads the schema,
197
+ and optionally restores state from cached packets before starting the transport.
198
+
199
+ :param start_discovery: Whether to initiate the discovery process after start, defaults to True.
200
+ :type start_discovery: bool, optional
201
+ :param cached_packets: A dictionary of packet strings used to restore state, defaults to None.
202
+ :type cached_packets: dict[str, str] | None, optional
203
+ :returns: None
204
+ :rtype: None
205
+ """
156
206
 
157
207
  def initiate_discovery(dev_list: list[Device], sys_list: list[Evohome]) -> None:
158
- _LOGGER.debug("ENGINE: Initiating/enabling discovery...")
208
+ _LOGGER.debug("Engine: Initiating/enabling discovery...")
159
209
 
160
210
  # [d._start_discovery_poller() for d in devs]
161
211
  for device in dev_list:
@@ -201,10 +251,21 @@ class Gateway(Engine):
201
251
  initiate_discovery(self.devices, self.systems)
202
252
 
203
253
  def create_sqlite_message_index(self) -> None:
254
+ """Initialize the SQLite MessageIndex.
255
+
256
+ :returns: None
257
+ :rtype: None
258
+ """
204
259
  self.msg_db = MessageIndex() # start the index
205
260
 
206
261
  async def stop(self) -> None:
207
- """Stop the Gateway and tidy up."""
262
+ """Stop the Gateway and tidy up.
263
+
264
+ Stops the message database and the underlying engine/transport.
265
+
266
+ :returns: None
267
+ :rtype: None
268
+ """
208
269
 
209
270
  if self.msg_db:
210
271
  self.msg_db.stop()
@@ -214,6 +275,12 @@ class Gateway(Engine):
214
275
  """Pause the (unpaused) gateway (disables sending/discovery).
215
276
 
216
277
  There is the option to save other objects, as `args`.
278
+
279
+ :param args: Additional objects/state to save during the pause.
280
+ :type args: Any
281
+ :returns: None
282
+ :rtype: None
283
+ :raises RuntimeError: If the engine fails to pause.
217
284
  """
218
285
  _LOGGER.debug("Gateway: Pausing engine...")
219
286
 
@@ -229,6 +296,9 @@ class Gateway(Engine):
229
296
  """Resume the (paused) gateway (enables sending/discovery, if applicable).
230
297
 
231
298
  Will restore other objects, as `args`.
299
+
300
+ :returns: A tuple of arguments saved during the pause.
301
+ :rtype: tuple[Any]
232
302
  """
233
303
  args: tuple[Any]
234
304
 
@@ -241,7 +311,13 @@ class Gateway(Engine):
241
311
  def get_state(
242
312
  self, include_expired: bool = False
243
313
  ) -> tuple[dict[str, Any], dict[str, str]]:
244
- """Return the current schema & state (may include expired packets)."""
314
+ """Return the current schema & state (may include expired packets).
315
+
316
+ :param include_expired: If True, include expired packets in the state, defaults to False.
317
+ :type include_expired: bool, optional
318
+ :returns: A tuple containing the schema dictionary and the packet log dictionary.
319
+ :rtype: tuple[dict[str, Any], dict[str, str]]
320
+ """
245
321
 
246
322
  self._pause()
247
323
 
@@ -287,10 +363,21 @@ class Gateway(Engine):
287
363
  async def _restore_cached_packets(
288
364
  self, packets: dict[str, str], _clear_state: bool = False
289
365
  ) -> None:
290
- """Restore cached packets (may include expired packets)."""
366
+ """Restore cached packets (may include expired packets).
367
+
368
+ This process uses a temporary transport to replay the packet history
369
+ into the message handler.
370
+
371
+ :param packets: A dictionary of packet strings.
372
+ :type packets: dict[str, str]
373
+ :param _clear_state: If True, reset internal state before restoration (for testing), defaults to False.
374
+ :type _clear_state: bool, optional
375
+ :returns: None
376
+ :rtype: None
377
+ """
291
378
 
292
379
  def clear_state() -> None:
293
- _LOGGER.info("GATEWAY: Clearing existing schema/state...")
380
+ _LOGGER.info("Gateway: Clearing existing schema/state...")
294
381
 
295
382
  # self._schema = {}
296
383
 
@@ -303,7 +390,7 @@ class Gateway(Engine):
303
390
 
304
391
  tmp_transport: RamsesTransportT # mypy hint
305
392
 
306
- _LOGGER.debug("GATEWAY: Restoring a cached packet log...")
393
+ _LOGGER.debug("Gateway: Restoring a cached packet log...")
307
394
  self._pause()
308
395
 
309
396
  if _clear_state: # only intended for test suite use
@@ -339,11 +426,18 @@ class Gateway(Engine):
339
426
 
340
427
  await tmp_transport.get_extra_info(SZ_READER_TASK)
341
428
 
342
- _LOGGER.debug("GATEWAY: Restored, resuming")
429
+ _LOGGER.debug("Gateway: Restored, resuming")
343
430
  self._resume()
344
431
 
345
432
  def _add_device(self, dev: Device) -> None: # TODO: also: _add_system()
346
- """Add a device to the gateway (called by devices during instantiation)."""
433
+ """Add a device to the gateway (called by devices during instantiation).
434
+
435
+ :param dev: The device instance to add.
436
+ :type dev: Device
437
+ :returns: None
438
+ :rtype: None
439
+ :raises LookupError: If the device already exists in the gateway.
440
+ """
347
441
 
348
442
  if dev.id in self.device_by_id:
349
443
  raise LookupError(f"Device already exists: {dev.id}")
@@ -360,13 +454,25 @@ class Gateway(Engine):
360
454
  child_id: str | None = None,
361
455
  is_sensor: bool | None = None,
362
456
  ) -> Device: # TODO: **schema/traits) -> Device: # may: LookupError
363
- """Return a device, create it if required.
364
-
365
- First, use the traits to create/update it, then pass it any msg to handle.
366
- All devices have traits, but only controllers (CTL, UFC) have a schema.
367
-
368
- Devices are uniquely identified by a device id.
369
- If a device is created, attach it to the gateway.
457
+ """Return a device, creating it if it does not already exist.
458
+
459
+ This method uses provided traits to create or update a device and optionally
460
+ passes a message for it to handle. All devices have traits, but only
461
+ controllers (CTL, UFC) have a schema.
462
+
463
+ :param device_id: The unique identifier for the device (e.g., '01:123456').
464
+ :type device_id: DeviceIdT
465
+ :param msg: An optional initial message for the device to process, defaults to None.
466
+ :type msg: Message | None, optional
467
+ :param parent: The parent entity of this device, if any, defaults to None.
468
+ :type parent: Parent | None, optional
469
+ :param child_id: The specific ID of the child component if applicable, defaults to None.
470
+ :type child_id: str | None, optional
471
+ :param is_sensor: Indicates if this device should be treated as a sensor, defaults to None.
472
+ :type is_sensor: bool | None, optional
473
+ :returns: The existing or newly created device instance.
474
+ :rtype: Device
475
+ :raises LookupError: If the device ID is blocked or not in the allowed known_list.
370
476
  """
371
477
 
372
478
  def check_filter_lists(dev_id: DeviceIdT) -> None: # may: LookupError
@@ -434,7 +540,21 @@ class Gateway(Engine):
434
540
  device_id: DeviceIdT,
435
541
  create_device: bool = False,
436
542
  ) -> Device | Fakeable:
437
- """Create a faked device."""
543
+ """Create a faked device.
544
+
545
+ Converts an existing device to a fake device, or creates a new fake device
546
+ if it satisfies strict criteria (valid ID, presence in known_list).
547
+
548
+ :param device_id: The ID of the device to fake.
549
+ :type device_id: DeviceIdT
550
+ :param create_device: If True, allow creation of a new device if it doesn't exist, defaults to False.
551
+ :type create_device: bool, optional
552
+ :returns: The faked device instance.
553
+ :rtype: Device | Fakeable
554
+ :raises TypeError: If the device ID is invalid or the device is not fakeable.
555
+ :raises LookupError: If the device does not exist and create_device is False,
556
+ or if create_device is True but the ID is not in known_list.
557
+ """
438
558
 
439
559
  if not is_valid_dev_id(device_id):
440
560
  raise TypeError(f"The device id is not valid: {device_id}")
@@ -452,7 +572,11 @@ class Gateway(Engine):
452
572
 
453
573
  @property
454
574
  def tcs(self) -> Evohome | None:
455
- """Return the primary TCS, if any."""
575
+ """Return the primary Temperature Control System (TCS), if any.
576
+
577
+ :returns: The primary Evohome system or None.
578
+ :rtype: Evohome | None
579
+ """
456
580
 
457
581
  if self._tcs is None and self.systems:
458
582
  self._tcs = self.systems[0]
@@ -465,6 +589,9 @@ class Gateway(Engine):
465
589
  Unlike orphans, which are always instantiated when a schema is loaded, these
466
590
  devices may/may not exist. However, if they are ever instantiated, they should
467
591
  be given these traits.
592
+
593
+ :returns: A dictionary where keys are device IDs and values are their traits.
594
+ :rtype: DeviceListT
468
595
  """
469
596
 
470
597
  result = self._include # could be devices here, not (yet) in gwy.devices
@@ -479,6 +606,11 @@ class Gateway(Engine):
479
606
 
480
607
  @property
481
608
  def system_by_id(self) -> dict[DeviceIdT, Evohome]:
609
+ """Return a mapping of device IDs to their associated Evohome systems.
610
+
611
+ :returns: A dictionary mapping DeviceId to Evohome instances.
612
+ :rtype: dict[DeviceIdT, Evohome]
613
+ """
482
614
  return {
483
615
  d.id: d.tcs
484
616
  for d in self.devices
@@ -487,6 +619,11 @@ class Gateway(Engine):
487
619
 
488
620
  @property
489
621
  def systems(self) -> list[Evohome]:
622
+ """Return a list of all identified Evohome systems.
623
+
624
+ :returns: A list of Evohome system instances.
625
+ :rtype: list[Evohome]
626
+ """
490
627
  return list(self.system_by_id.values())
491
628
 
492
629
  @property
@@ -498,6 +635,9 @@ class Gateway(Engine):
498
635
  - schema (everything else)
499
636
  - known_list
500
637
  - block_list
638
+
639
+ :returns: A dictionary representing the current internal configuration state.
640
+ :rtype: dict[str, Any]
501
641
  """
502
642
 
503
643
  return {
@@ -519,6 +659,9 @@ class Gateway(Engine):
519
659
  Orphans are devices that 'exist' but don't yet have a place in the schema
520
660
  hierarchy (if ever): therefore, they are instantiated when the schema is loaded,
521
661
  just like the other devices in the schema.
662
+
663
+ :returns: A dictionary representing the entire system schema structure.
664
+ :rtype: dict[str, Any]
522
665
  """
523
666
 
524
667
  schema: dict[str, Any] = {SZ_MAIN_TCS: self.tcs.ctl.id if self.tcs else None}
@@ -546,10 +689,20 @@ class Gateway(Engine):
546
689
 
547
690
  @property
548
691
  def params(self) -> dict[str, Any]:
692
+ """Return the parameters for all devices.
693
+
694
+ :returns: A dictionary containing parameters for every device in the gateway.
695
+ :rtype: dict[str, Any]
696
+ """
549
697
  return {SZ_DEVICES: {d.id: d.params for d in sorted(self.devices)}}
550
698
 
551
699
  @property
552
700
  def status(self) -> dict[str, Any]:
701
+ """Return the status for all devices and the transport rate.
702
+
703
+ :returns: A dictionary containing device statuses and transmission rate.
704
+ :rtype: dict[str, Any]
705
+ """
553
706
  tx_rate = self._transport.get_extra_info("tx_rate") if self._transport else None
554
707
  return {
555
708
  SZ_DEVICES: {d.id: d.status for d in sorted(self.devices)},
@@ -557,7 +710,15 @@ class Gateway(Engine):
557
710
  }
558
711
 
559
712
  def _msg_handler(self, msg: Message) -> None:
560
- """A callback to handle messages from the protocol stack."""
713
+ """A callback to handle messages from the protocol stack.
714
+
715
+ Handles message reassembly (fragmentation) and dispatches the message for processing.
716
+
717
+ :param msg: The incoming message to handle.
718
+ :type msg: Message
719
+ :returns: None
720
+ :rtype: None
721
+ """
561
722
 
562
723
  super()._msg_handler(msg)
563
724
 
@@ -585,9 +746,20 @@ class Gateway(Engine):
585
746
  ) -> asyncio.Task[Packet]:
586
747
  """Wrapper to schedule an async_send_cmd() and return the Task.
587
748
 
588
- num_repeats: 0 = send once, 1 = send twice, etc.
589
- gap_duration: the gap between repeats (in seconds)
590
- priority: the priority of the command
749
+ :param cmd: The command object to send.
750
+ :type cmd: Command
751
+ :param gap_duration: The gap between repeats (in seconds), defaults to DEFAULT_GAP_DURATION.
752
+ :type gap_duration: float, optional
753
+ :param num_repeats: Number of times to repeat the command (0 = once, 1 = twice, etc.), defaults to DEFAULT_NUM_REPEATS.
754
+ :type num_repeats: int, optional
755
+ :param priority: The priority of the command, defaults to Priority.DEFAULT.
756
+ :type priority: Priority, optional
757
+ :param timeout: Time to wait for a send to complete, defaults to DEFAULT_SEND_TIMEOUT.
758
+ :type timeout: float, optional
759
+ :param wait_for_reply: Whether to wait for a reply packet, defaults to DEFAULT_WAIT_FOR_REPLY.
760
+ :type wait_for_reply: bool | None, optional
761
+ :returns: The asyncio Task wrapping the send operation.
762
+ :rtype: asyncio.Task[Packet]
591
763
  """
592
764
 
593
765
  coro = self.async_send_cmd(
@@ -620,9 +792,24 @@ class Gateway(Engine):
620
792
  If wait_for_reply is True (*and* the Command has a rx_header), return the
621
793
  reply Packet. Otherwise, simply return the echo Packet.
622
794
 
623
- If the expected Packet can't be returned, raise:
624
- ProtocolSendFailed: tried to Tx Command, but didn't get echo/reply
625
- ProtocolError: didn't attempt to Tx Command for some reason
795
+ :param cmd: The command object to send.
796
+ :type cmd: Command
797
+ :param gap_duration: The gap between repeats (in seconds), defaults to DEFAULT_GAP_DURATION.
798
+ :type gap_duration: float, optional
799
+ :param num_repeats: Number of times to repeat the command, defaults to DEFAULT_NUM_REPEATS.
800
+ :type num_repeats: int, optional
801
+ :param priority: The priority of the command, defaults to Priority.DEFAULT.
802
+ :type priority: Priority, optional
803
+ :param max_retries: Maximum number of retries if sending fails, defaults to DEFAULT_MAX_RETRIES.
804
+ :type max_retries: int, optional
805
+ :param timeout: Time to wait for the command to send, defaults to DEFAULT_SEND_TIMEOUT.
806
+ :type timeout: float, optional
807
+ :param wait_for_reply: Whether to wait for a reply packet, defaults to DEFAULT_WAIT_FOR_REPLY.
808
+ :type wait_for_reply: bool | None, optional
809
+ :returns: The echo packet or reply packet depending on wait_for_reply.
810
+ :rtype: Packet
811
+ :raises ProtocolSendFailed: If the command was sent but no reply/echo was received.
812
+ :raises ProtocolError: If the system failed to attempt the transmission.
626
813
  """
627
814
 
628
815
  return await super().async_send_cmd(
ramses_rf/schemas.py CHANGED
@@ -237,6 +237,7 @@ SCH_GLOBAL_SCHEMAS_DICT = { # System schemas - can be 0-many Heat/HVAC schemas
237
237
  vol.Optional(SCH_DEVICE_ID_ANY): SCH_VCS, # must be after SCH_DEVICE_ID_CTL
238
238
  vol.Optional(SZ_ORPHANS_HEAT): vol.All([SCH_DEVICE_ID_ANY], vol.Unique()),
239
239
  vol.Optional(SZ_ORPHANS_HVAC): vol.All([SCH_DEVICE_ID_ANY], vol.Unique()),
240
+ vol.Optional("transport_constructor"): vol.Any(callable, None),
240
241
  }
241
242
  SCH_GLOBAL_SCHEMAS = vol.Schema(SCH_GLOBAL_SCHEMAS_DICT, extra=vol.PREVENT_EXTRA)
242
243
 
@@ -285,7 +286,7 @@ SCH_GLOBAL_CONFIG = (
285
286
  #
286
287
  # 6/7: External Schemas, to be used by clients of this library
287
288
  def NormaliseRestoreCache() -> Callable[[bool | dict[str, bool]], dict[str, bool]]:
288
- """Convert a short-hand restore_cache bool to a dict.
289
+ """Convert a shorthand restore_cache bool to a dict.
289
290
 
290
291
  restore_cache: bool -> restore_cache:
291
292
  restore_schema: bool
ramses_rf/system/zones.py CHANGED
@@ -36,7 +36,7 @@ from ramses_rf.device import (
36
36
  TrvActuator,
37
37
  UfhController,
38
38
  )
39
- from ramses_rf.entity_base import Child, Entity, Parent, class_by_attr
39
+ from ramses_rf.entity_base import _ID_SLICE, Child, Entity, Parent, class_by_attr
40
40
  from ramses_rf.helpers import shrink
41
41
  from ramses_rf.schemas import (
42
42
  SCH_TCS_DHW,
@@ -728,13 +728,33 @@ class Zone(ZoneSchedule):
728
728
  """Set the target temperature, until the next scheduled setpoint."""
729
729
 
730
730
  if value is None:
731
- return self.reset_mode()
731
+ self.reset_mode()
732
732
 
733
733
  cmd = Command.set_zone_setpoint(self.ctl.id, self.idx, value)
734
734
  self._gwy.send_cmd(cmd, priority=Priority.HIGH)
735
735
 
736
736
  @property
737
737
  def temperature(self) -> float | None: # 30C9
738
+ if self._gwy.msg_db:
739
+ # evohome zones only get initial temp from src + idx, so use zone sensor if newer
740
+ sql = f"""
741
+ SELECT dtm from messages WHERE verb in (' I', 'RP')
742
+ AND code = '30C9'
743
+ AND (plk LIKE '%{SZ_TEMPERATURE}%')
744
+ AND ((src = ? AND ctx = ?) OR src = ?)
745
+ """
746
+ sensor_id = "aa:aaaaaa" # should not match any device_id
747
+ if self._sensor:
748
+ sensor_id = self._sensor.id
749
+ # custom SQLite query on MessageIndex
750
+ msgs = self._gwy.msg_db.qry(
751
+ sql, (self.id[:_ID_SLICE], self.idx, sensor_id[:_ID_SLICE])
752
+ )
753
+ if msgs and len(msgs) > 0:
754
+ msgs_sorted = sorted(msgs, reverse=True)
755
+ return msgs_sorted[0].payload.get(SZ_TEMPERATURE) # type: ignore[no-any-return]
756
+ return None
757
+ # else: TODO Q1 2026 remove remainder
738
758
  return self._msg_value(Code._30C9, key=SZ_TEMPERATURE) # type: ignore[no-any-return]
739
759
 
740
760
  @property
ramses_rf/version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  """RAMSES RF - a RAMSES-II protocol decoder & analyser (application layer)."""
2
2
 
3
- __version__ = "0.52.4"
3
+ __version__ = "0.53.0"
4
4
  VERSION = __version__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ramses_rf
3
- Version: 0.52.4
3
+ Version: 0.53.0
4
4
  Summary: A stateful RAMSES-II protocol decoder & analyser.
5
5
  Project-URL: Homepage, https://github.com/ramses-rf/ramses_rf
6
6
  Project-URL: Bug Tracker, https://github.com/ramses-rf/ramses_rf/issues