Coverage for src/ramses_rf/gateway.py: 24%
220 statements
« prev ^ index » next coverage.py v7.11.3, created at 2026-01-05 21:46 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2026-01-05 21:46 +0100
1#!/usr/bin/env python3
3# TODO:
4# - sort out gwy.config...
5# - sort out reduced processing
8"""RAMSES RF -the gateway (i.e. HGI80 / evofw3, not RFG100)."""
10from __future__ import annotations
12import asyncio
13import logging
14from collections.abc import Awaitable, Callable
15from types import SimpleNamespace
16from typing import TYPE_CHECKING, Any
18from ramses_tx import (
19 Address,
20 Command,
21 Engine,
22 Message,
23 Packet,
24 Priority,
25 extract_known_hgi_id,
26 is_valid_dev_id,
27 protocol_factory,
28 set_pkt_logging_config,
29 transport_factory,
30)
31from ramses_tx.const import (
32 DEFAULT_GAP_DURATION,
33 DEFAULT_MAX_RETRIES,
34 DEFAULT_NUM_REPEATS,
35 DEFAULT_SEND_TIMEOUT,
36 DEFAULT_WAIT_FOR_REPLY,
37 SZ_ACTIVE_HGI,
38)
39from ramses_tx.schemas import (
40 SCH_ENGINE_CONFIG,
41 SZ_BLOCK_LIST,
42 SZ_ENFORCE_KNOWN_LIST,
43 SZ_KNOWN_LIST,
44 PktLogConfigT,
45 PortConfigT,
46)
47from ramses_tx.transport import SZ_READER_TASK
49from .const import DONT_CREATE_MESSAGES, SZ_DEVICES
50from .database import MessageIndex
51from .device import DeviceHeat, DeviceHvac, Fakeable, HgiGateway, device_factory
52from .dispatcher import detect_array_fragment, process_msg
53from .schemas import (
54 SCH_GATEWAY_CONFIG,
55 SCH_GLOBAL_SCHEMAS,
56 SCH_TRAITS,
57 SZ_ALIAS,
58 SZ_CLASS,
59 SZ_CONFIG,
60 SZ_DISABLE_DISCOVERY,
61 SZ_ENABLE_EAVESDROP,
62 SZ_FAKED,
63 SZ_MAIN_TCS,
64 SZ_ORPHANS,
65 load_schema,
66)
67from .system import Evohome
69from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
70 I_,
71 RP,
72 RQ,
73 W_,
74 Code,
75)
77if TYPE_CHECKING:
78 from ramses_tx import DeviceIdT, DeviceListT, RamsesTransportT
80 from .device import Device
81 from .entity_base import Parent
83_LOGGER = logging.getLogger(__name__)
86class Gateway(Engine):
87 """The gateway class.
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 """
94 def __init__(
95 self,
96 port_name: str | None,
97 input_file: str | None = None,
98 port_config: PortConfigT | None = None,
99 packet_log: PktLogConfigT | None = None,
100 block_list: DeviceListT | None = None,
101 known_list: DeviceListT | None = None,
102 loop: asyncio.AbstractEventLoop | None = None,
103 transport_constructor: Callable[..., Awaitable[RamsesTransportT]] | None = None,
104 **kwargs: Any,
105 ) -> None:
106 """Initialize the Gateway instance.
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 """
127 if kwargs.pop("debug_mode", None):
128 _LOGGER.setLevel(logging.DEBUG)
130 kwargs = {k: v for k, v in kwargs.items() if k[:1] != "_"} # anachronism
131 config: dict[str, Any] = kwargs.pop(SZ_CONFIG, {})
133 super().__init__(
134 port_name,
135 input_file=input_file,
136 port_config=port_config,
137 packet_log=packet_log,
138 block_list=block_list,
139 known_list=known_list,
140 loop=loop,
141 transport_constructor=transport_constructor,
142 **SCH_ENGINE_CONFIG(config),
143 )
145 if self._disable_sending:
146 config[SZ_DISABLE_DISCOVERY] = True
147 if config.get(SZ_ENABLE_EAVESDROP):
148 _LOGGER.warning(
149 f"{SZ_ENABLE_EAVESDROP}=True: this is strongly discouraged"
150 " for routine use (there be dragons here)"
151 )
153 self.config = SimpleNamespace(**SCH_GATEWAY_CONFIG(config))
154 self._schema: dict[str, Any] = SCH_GLOBAL_SCHEMAS(kwargs)
156 self._tcs: Evohome | None = None
158 self.devices: list[Device] = []
159 self.device_by_id: dict[DeviceIdT, Device] = {}
161 self.msg_db: MessageIndex | None = None
163 def __repr__(self) -> str:
164 """Return a string representation of the Gateway.
166 :returns: A string describing the gateway's input source (port or file).
167 :rtype: str
168 """
169 if not self.ser_name:
170 return f"Gateway(input_file={self._input_file})"
171 return f"Gateway(port_name={self.ser_name}, port_config={self._port_config})"
173 @property
174 def hgi(self) -> HgiGateway | None:
175 """Return the active HGI80-compatible gateway device, if known.
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 """
181 if not self._transport:
182 return None
183 if device_id := self._transport.get_extra_info(SZ_ACTIVE_HGI):
184 return self.device_by_id.get(device_id) # type: ignore[return-value]
185 return None
187 async def start(
188 self,
189 /,
190 *,
191 start_discovery: bool = True,
192 cached_packets: dict[str, str] | None = None,
193 ) -> None:
194 """Start the Gateway and Initiate discovery as required.
196 This method initializes packet logging, the SQLite index, loads the schema,
197 and optionally restores state from cached packets before starting the transport.
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 """
207 def initiate_discovery(dev_list: list[Device], sys_list: list[Evohome]) -> None:
208 _LOGGER.debug("Engine: Initiating/enabling discovery...")
210 # [d._start_discovery_poller() for d in devs]
211 for device in dev_list:
212 device._start_discovery_poller()
214 for system in sys_list:
215 system._start_discovery_poller()
216 for zone in system.zones:
217 zone._start_discovery_poller()
218 if system.dhw:
219 system.dhw._start_discovery_poller()
221 await set_pkt_logging_config( # type: ignore[arg-type]
222 cc_console=self.config.reduce_processing >= DONT_CREATE_MESSAGES,
223 **self._packet_log,
224 )
226 # initialize SQLite index, set in _tx/Engine
227 if self._sqlite_index: # TODO(eb): default to ON in Q4 2025
228 _LOGGER.info("Ramses RF starts SQLite MessageIndex")
229 self.create_sqlite_message_index() # if activated in ramses_cc > Engine
231 # temporarily turn on discovery, remember original state
232 self.config.disable_discovery, disable_discovery = (
233 True,
234 self.config.disable_discovery,
235 )
237 load_schema(self, known_list=self._include, **self._schema) # create faked too
239 await super().start() # TODO: do this *after* restore cache
240 if cached_packets:
241 await self._restore_cached_packets(cached_packets)
243 # reset discovery to original state
244 self.config.disable_discovery = disable_discovery
246 if (
247 not self._disable_sending
248 and not self.config.disable_discovery
249 and start_discovery
250 ):
251 initiate_discovery(self.devices, self.systems)
253 def create_sqlite_message_index(self) -> None:
254 """Initialize the SQLite MessageIndex.
256 :returns: None
257 :rtype: None
258 """
259 self.msg_db = MessageIndex() # start the index
261 async def stop(self) -> None:
262 """Stop the Gateway and tidy up.
264 Stops the message database and the underlying engine/transport.
266 :returns: None
267 :rtype: None
268 """
270 if self.msg_db:
271 self.msg_db.stop()
272 await super().stop()
274 def _pause(self, *args: Any) -> None:
275 """Pause the (unpaused) gateway (disables sending/discovery).
277 There is the option to save other objects, as `args`.
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.
284 """
285 _LOGGER.debug("Gateway: Pausing engine...")
287 self.config.disable_discovery, disc_flag = True, self.config.disable_discovery
289 try:
290 super()._pause(disc_flag, *args)
291 except RuntimeError:
292 self.config.disable_discovery = disc_flag
293 raise
295 def _resume(self) -> tuple[Any]:
296 """Resume the (paused) gateway (enables sending/discovery, if applicable).
298 Will restore other objects, as `args`.
300 :returns: A tuple of arguments saved during the pause.
301 :rtype: tuple[Any]
302 """
303 args: tuple[Any]
305 _LOGGER.debug("Gateway: Resuming engine...")
307 self.config.disable_discovery, *args = super()._resume() # type: ignore[assignment]
309 return args
311 def get_state(
312 self, include_expired: bool = False
313 ) -> tuple[dict[str, Any], dict[str, str]]:
314 """Return the current schema & state (may include expired packets).
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 """
322 self._pause()
324 def wanted_msg(msg: Message, include_expired: bool = False) -> bool:
325 if msg.code == Code._313F:
326 return msg.verb in (I_, RP) # usu. expired, useful 4 back-back restarts
327 if msg._expired and not include_expired:
328 return False
329 if msg.code == Code._0404:
330 return msg.verb in (I_, W_) and msg._pkt._len > 7
331 if msg.verb in (W_, RQ):
332 return False
333 # if msg.code == Code._1FC9 and msg.verb != RP:
334 # return True
335 return include_expired or not msg._expired
337 if self.msg_db:
338 pkts = {
339 f"{repr(msg._pkt)[:26]}": f"{repr(msg._pkt)[27:]}"
340 for msg in self.msg_db.all(include_expired=True)
341 if wanted_msg(msg, include_expired=include_expired)
342 }
343 else: # deprecated, to be removed in Q1 2026
344 msgs = [m for device in self.devices for m in device._msg_list]
345 # add systems._msgs and zones._msgs
346 for system in self.systems:
347 msgs.extend(list(system._msgs.values()))
348 msgs.extend([m for z in system.zones for m in z._msgs.values()])
349 # msgs.extend([m for z in system.dhw for m in z._msgs.values()]) # TODO: DHW
350 # Related to/Fixes ramses_cc Issue 249 non-existing via-device _HW ?
352 pkts = { # BUG: assumes pkts have unique dtms: may be untrue for contrived logs
353 f"{repr(msg._pkt)[:26]}": f"{repr(msg._pkt)[27:]}"
354 for msg in msgs
355 if wanted_msg(msg, include_expired=include_expired)
356 }
357 # _LOGGER.warning("Missing MessageIndex")
359 self._resume()
361 return self.schema, dict(sorted(pkts.items()))
363 async def _restore_cached_packets(
364 self, packets: dict[str, str], _clear_state: bool = False
365 ) -> None:
366 """Restore cached packets (may include expired packets).
368 This process uses a temporary transport to replay the packet history
369 into the message handler.
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 """
379 def clear_state() -> None:
380 _LOGGER.info("Gateway: Clearing existing schema/state...")
382 # self._schema = {}
384 self._tcs = None
385 self.devices = []
386 self.device_by_id = {}
388 self._prev_msg = None
389 self._this_msg = None
391 tmp_transport: RamsesTransportT # mypy hint
393 _LOGGER.debug("Gateway: Restoring a cached packet log...")
394 self._pause()
396 if _clear_state: # only intended for test suite use
397 clear_state()
399 # We do not always enforce the known_list whilst restoring a cache because
400 # if it does not contain a correctly configured HGI, a 'working' address is
401 # used (which could be different to the address in the cache) & wanted packets
402 # can be dropped unnecessarily.
404 enforce_include_list = bool(
405 self._enforce_known_list
406 and extract_known_hgi_id(
407 self._include, disable_warnings=True, strict_checking=True
408 )
409 )
411 # The actual HGI address will be discovered when the actual transport was/is
412 # started up (usually before now)
414 tmp_protocol = protocol_factory(
415 self._msg_handler,
416 disable_sending=True,
417 enforce_include_list=enforce_include_list,
418 exclude_list=self._exclude,
419 include_list=self._include,
420 )
422 tmp_transport = await transport_factory(
423 tmp_protocol,
424 packet_dict=packets,
425 )
427 await tmp_transport.get_extra_info(SZ_READER_TASK)
429 _LOGGER.debug("Gateway: Restored, resuming")
430 self._resume()
432 def _add_device(self, dev: Device) -> None: # TODO: also: _add_system()
433 """Add a device to the gateway (called by devices during instantiation).
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 """
442 if dev.id in self.device_by_id:
443 raise LookupError(f"Device already exists: {dev.id}")
445 self.devices.append(dev)
446 self.device_by_id[dev.id] = dev
448 def get_device(
449 self,
450 device_id: DeviceIdT,
451 *,
452 msg: Message | None = None,
453 parent: Parent | None = None,
454 child_id: str | None = None,
455 is_sensor: bool | None = None,
456 ) -> Device: # TODO: **schema/traits) -> Device: # may: LookupError
457 """Return a device, creating it if it does not already exist.
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.
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.
476 """
478 def check_filter_lists(dev_id: DeviceIdT) -> None: # may: LookupError
479 """Raise a LookupError if a device_id is filtered out by a list."""
481 if dev_id in self._unwanted: # TODO: shouldn't invalidate a msg
482 raise LookupError(f"Can't create {dev_id}: it is unwanted or invalid")
484 if self._enforce_known_list and (
485 dev_id not in self._include and dev_id != getattr(self.hgi, "id", None)
486 ):
487 self._unwanted.append(dev_id)
488 raise LookupError(
489 f"Can't create {dev_id}: it is not an allowed device_id"
490 f" (if required, add it to the {SZ_KNOWN_LIST})"
491 )
493 if dev_id in self._exclude:
494 self._unwanted.append(dev_id)
495 raise LookupError(
496 f"Can't create {dev_id}: it is a blocked device_id"
497 f" (if required, remove it from the {SZ_BLOCK_LIST})"
498 )
500 try:
501 check_filter_lists(device_id)
502 except LookupError:
503 # have to allow for GWY not being in known_list...
504 if device_id != self._protocol.hgi_id:
505 raise # TODO: make parochial
507 dev = self.device_by_id.get(device_id)
509 if not dev:
510 # voluptuous bug workaround: https://github.com/alecthomas/voluptuous/pull/524
511 _traits: dict[str, Any] = self._include.get(device_id, {}) # type: ignore[assignment]
512 _traits.pop("commands", None)
514 traits: dict[str, Any] = SCH_TRAITS(self._include.get(device_id, {}))
516 dev = device_factory(self, Address(device_id), msg=msg, **_traits)
518 if traits.get(SZ_FAKED):
519 if isinstance(dev, Fakeable):
520 dev._make_fake()
521 else:
522 _LOGGER.warning(f"The device is not fakeable: {dev}")
524 # TODO: the exact order of the following may need refining...
525 # TODO: some will be done by devices themselves?
527 # if schema: # Step 2: Only controllers have a schema...
528 # dev._update_schema(**schema) # TODO: schema/traits
530 if parent or child_id:
531 dev.set_parent(parent, child_id=child_id, is_sensor=is_sensor)
533 # if msg:
534 # dev._handle_msg(msg)
536 return dev
538 def fake_device(
539 self,
540 device_id: DeviceIdT,
541 create_device: bool = False,
542 ) -> Device | Fakeable:
543 """Create a faked device.
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).
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 """
559 if not is_valid_dev_id(device_id):
560 raise TypeError(f"The device id is not valid: {device_id}")
562 if not create_device and device_id not in self.device_by_id:
563 raise LookupError(f"The device id does not exist: {device_id}")
564 elif create_device and device_id not in self.known_list:
565 raise LookupError(f"The device id is not in the known_list: {device_id}")
567 if (dev := self.get_device(device_id)) and isinstance(dev, Fakeable):
568 dev._make_fake()
569 return dev
571 raise TypeError(f"The device is not fakeable: {device_id}")
573 @property
574 def tcs(self) -> Evohome | None:
575 """Return the primary Temperature Control System (TCS), if any.
577 :returns: The primary Evohome system or None.
578 :rtype: Evohome | None
579 """
581 if self._tcs is None and self.systems:
582 self._tcs = self.systems[0]
583 return self._tcs
585 @property
586 def known_list(self) -> DeviceListT:
587 """Return the working known_list (a superset of the provided known_list).
589 Unlike orphans, which are always instantiated when a schema is loaded, these
590 devices may/may not exist. However, if they are ever instantiated, they should
591 be given these traits.
593 :returns: A dictionary where keys are device IDs and values are their traits.
594 :rtype: DeviceListT
595 """
597 result = self._include # could be devices here, not (yet) in gwy.devices
598 result.update(
599 {
600 d.id: {k: d.traits[k] for k in (SZ_CLASS, SZ_ALIAS, SZ_FAKED)} # type: ignore[misc]
601 for d in self.devices
602 if not self._enforce_known_list or d.id in self._include
603 }
604 )
605 return result
607 @property
608 def system_by_id(self) -> dict[DeviceIdT, Evohome]:
609 """Return a mapping of device IDs to their associated Evohome systems.
611 :returns: A dictionary mapping DeviceId to Evohome instances.
612 :rtype: dict[DeviceIdT, Evohome]
613 """
614 return {
615 d.id: d.tcs
616 for d in self.devices
617 if hasattr(d, "tcs") and getattr(d.tcs, "id", None) == d.id
618 } # why something so simple look so messy
620 @property
621 def systems(self) -> list[Evohome]:
622 """Return a list of all identified Evohome systems.
624 :returns: A list of Evohome system instances.
625 :rtype: list[Evohome]
626 """
627 return list(self.system_by_id.values())
629 @property
630 def _config(self) -> dict[str, Any]:
631 """Return the working configuration.
633 Includes:
634 - config
635 - schema (everything else)
636 - known_list
637 - block_list
639 :returns: A dictionary representing the current internal configuration state.
640 :rtype: dict[str, Any]
641 """
643 return {
644 "_gateway_id": self.hgi.id if self.hgi else None,
645 SZ_MAIN_TCS: self.tcs.id if self.tcs else None,
646 SZ_CONFIG: {SZ_ENFORCE_KNOWN_LIST: self._enforce_known_list},
647 SZ_KNOWN_LIST: self.known_list,
648 SZ_BLOCK_LIST: [{k: v} for k, v in self._exclude.items()],
649 "_unwanted": sorted(self._unwanted),
650 }
652 @property
653 def schema(self) -> dict[str, Any]:
654 """Return the global schema.
656 This 'active' schema may exclude non-present devices from the configured schema
657 that was loaded during initialisation.
659 Orphans are devices that 'exist' but don't yet have a place in the schema
660 hierarchy (if ever): therefore, they are instantiated when the schema is loaded,
661 just like the other devices in the schema.
663 :returns: A dictionary representing the entire system schema structure.
664 :rtype: dict[str, Any]
665 """
667 schema: dict[str, Any] = {SZ_MAIN_TCS: self.tcs.ctl.id if self.tcs else None}
669 for tcs in self.systems:
670 schema[tcs.ctl.id] = tcs.schema
672 dev_list: list[DeviceIdT] = sorted(
673 [
674 d.id
675 for d in self.devices
676 if not getattr(d, "tcs", None)
677 and isinstance(d, DeviceHeat)
678 and d._is_present
679 ]
680 )
681 schema[f"{SZ_ORPHANS}_heat"] = dev_list
683 dev_list = sorted(
684 [d.id for d in self.devices if isinstance(d, DeviceHvac) and d._is_present]
685 )
686 schema[f"{SZ_ORPHANS}_hvac"] = dev_list
688 return schema
690 @property
691 def params(self) -> dict[str, Any]:
692 """Return the parameters for all devices.
694 :returns: A dictionary containing parameters for every device in the gateway.
695 :rtype: dict[str, Any]
696 """
697 return {SZ_DEVICES: {d.id: d.params for d in sorted(self.devices)}}
699 @property
700 def status(self) -> dict[str, Any]:
701 """Return the status for all devices and the transport rate.
703 :returns: A dictionary containing device statuses and transmission rate.
704 :rtype: dict[str, Any]
705 """
706 tx_rate = self._transport.get_extra_info("tx_rate") if self._transport else None
707 return {
708 SZ_DEVICES: {d.id: d.status for d in sorted(self.devices)},
709 "_tx_rate": tx_rate,
710 }
712 def _msg_handler(self, msg: Message) -> None:
713 """A callback to handle messages from the protocol stack.
715 Handles message reassembly (fragmentation) and dispatches the message for processing.
717 :param msg: The incoming message to handle.
718 :type msg: Message
719 :returns: None
720 :rtype: None
721 """
723 super()._msg_handler(msg)
725 # TODO: ideally remove this feature...
726 assert self._this_msg # mypy check
728 if self._prev_msg and detect_array_fragment(self._this_msg, self._prev_msg):
729 msg._pkt._force_has_array() # may be an array of length 1
730 msg._payload = self._prev_msg.payload + (
731 msg.payload if isinstance(msg.payload, list) else [msg.payload]
732 )
734 process_msg(self, msg)
736 def send_cmd(
737 self,
738 cmd: Command,
739 /,
740 *,
741 gap_duration: float = DEFAULT_GAP_DURATION,
742 num_repeats: int = DEFAULT_NUM_REPEATS,
743 priority: Priority = Priority.DEFAULT,
744 timeout: float = DEFAULT_SEND_TIMEOUT,
745 wait_for_reply: bool | None = DEFAULT_WAIT_FOR_REPLY,
746 ) -> asyncio.Task[Packet]:
747 """Wrapper to schedule an async_send_cmd() and return the Task.
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]
763 """
765 coro = self.async_send_cmd(
766 cmd,
767 gap_duration=gap_duration,
768 num_repeats=num_repeats,
769 priority=priority,
770 timeout=timeout,
771 wait_for_reply=wait_for_reply,
772 )
774 task = self._loop.create_task(coro)
775 self.add_task(task)
776 return task
778 async def async_send_cmd(
779 self,
780 cmd: Command,
781 /,
782 *,
783 gap_duration: float = DEFAULT_GAP_DURATION,
784 num_repeats: int = DEFAULT_NUM_REPEATS,
785 priority: Priority = Priority.DEFAULT,
786 max_retries: int = DEFAULT_MAX_RETRIES,
787 timeout: float = DEFAULT_SEND_TIMEOUT,
788 wait_for_reply: bool | None = DEFAULT_WAIT_FOR_REPLY,
789 ) -> Packet:
790 """Send a Command and return the corresponding (echo or reply) Packet.
792 If wait_for_reply is True (*and* the Command has a rx_header), return the
793 reply Packet. Otherwise, simply return the echo Packet.
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.
813 """
815 return await super().async_send_cmd(
816 cmd,
817 gap_duration=gap_duration,
818 num_repeats=num_repeats,
819 priority=priority,
820 max_retries=max_retries,
821 timeout=timeout,
822 wait_for_reply=wait_for_reply,
823 ) # may: raise ProtocolError/ProtocolSendFailed