Coverage for src/ramses_rf/gateway.py: 24%

1#!/usr/bin/env python3

2

3# TODO:

4# - sort out gwy.config...

5# - sort out reduced processing

6

7

8"""RAMSES RF -the gateway (i.e. HGI80 / evofw3, not RFG100)."""

9

10 from __future__ import annotations

11

12 import asyncio

13 import logging

14 from collections.abc import Awaitable, Callable

15 from types import SimpleNamespace

16 from typing import TYPE_CHECKING, Any

17

18 from 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)

31 from 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)

39 from 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)

47 from ramses_tx.transport import SZ_READER_TASK

48

49 from .const import DONT_CREATE_MESSAGES, SZ_DEVICES

50 from .database import MessageIndex

51 from .device import DeviceHeat, DeviceHvac, Fakeable, HgiGateway, device_factory

52 from .dispatcher import detect_array_fragment, process_msg

53 from .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)

67 from .system import Evohome

68

69 from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import

70 I_,

71 RP,

72 RQ,

73 W_,

74 Code,

75)

76

77 if TYPE_CHECKING:

78 from ramses_tx import DeviceIdT, DeviceListT, RamsesTransportT

79

80 from .device import Device

81 from .entity_base import Parent

82

83 _LOGGER = logging.getLogger(__name__)

84

85

86 class Gateway(Engine):

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 """

93

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.

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 """

127 if kwargs.pop("debug_mode", None):

128 _LOGGER.setLevel(logging.DEBUG)

129

130 kwargs = {k: v for k, v in kwargs.items() if k[:1] != "_"} # anachronism

131 config: dict[str, Any] = kwargs.pop(SZ_CONFIG, {})

132

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 )

144

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 )

152

153 self.config = SimpleNamespace(**SCH_GATEWAY_CONFIG(config))

154 self._schema: dict[str, Any] = SCH_GLOBAL_SCHEMAS(kwargs)

155

156 self._tcs: Evohome | None = None

157

158 self.devices: list[Device] = []

159 self.device_by_id: dict[DeviceIdT, Device] = {}

160

161 self.msg_db: MessageIndex | None = None

162

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 """

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})"

172

173 @property

174 def hgi(self) -> HgiGateway | None:

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 """

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

186

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.

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 """

206

207 def initiate_discovery(dev_list: list[Device], sys_list: list[Evohome]) -> None:

208 _LOGGER.debug("Engine: Initiating/enabling discovery...")

209

210 # [d._start_discovery_poller() for d in devs]

211 for device in dev_list:

212 device._start_discovery_poller()

213

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()

220

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 )

225

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

230

231 # temporarily turn on discovery, remember original state

232 self.config.disable_discovery, disable_discovery = (

233 True,

234 self.config.disable_discovery,

235 )

236

237 load_schema(self, known_list=self._include, **self._schema) # create faked too

238

239 await super().start() # TODO: do this *after* restore cache

240 if cached_packets:

241 await self._restore_cached_packets(cached_packets)

242

243 # reset discovery to original state

244 self.config.disable_discovery = disable_discovery

245

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)

252

253 def create_sqlite_message_index(self) -> None:

254 """Initialize the SQLite MessageIndex.

255

256 :returns: None

257 :rtype: None

258 """

259 self.msg_db = MessageIndex() # start the index

260

261 async def stop(self) -> None:

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 """

269

270 if self.msg_db:

271 self.msg_db.stop()

272 await super().stop()

273

274 def _pause(self, *args: Any) -> None:

275 """Pause the (unpaused) gateway (disables sending/discovery).

276

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.

284 """

285 _LOGGER.debug("Gateway: Pausing engine...")

286

287 self.config.disable_discovery, disc_flag = True, self.config.disable_discovery

288

289 try:

290 super()._pause(disc_flag, *args)

291 except RuntimeError:

292 self.config.disable_discovery = disc_flag

293 raise

294

295 def _resume(self) -> tuple[Any]:

296 """Resume the (paused) gateway (enables sending/discovery, if applicable).

297

298 Will restore other objects, as `args`.

299

300 :returns: A tuple of arguments saved during the pause.

301 :rtype: tuple[Any]

302 """

303 args: tuple[Any]

304

305 _LOGGER.debug("Gateway: Resuming engine...")

306

307 self.config.disable_discovery, *args = super()._resume() # type: ignore[assignment]

308

309 return args

310

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).

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 """

321

322 self._pause()

323

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

336

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 ?

351

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")

358

359 self._resume()

360

361 return self.schema, dict(sorted(pkts.items()))

362

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).

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 """

378

379 def clear_state() -> None:

380 _LOGGER.info("Gateway: Clearing existing schema/state...")

381

382 # self._schema = {}

383

384 self._tcs = None

385 self.devices = []

386 self.device_by_id = {}

387

388 self._prev_msg = None

389 self._this_msg = None

390

391 tmp_transport: RamsesTransportT # mypy hint

392

393 _LOGGER.debug("Gateway: Restoring a cached packet log...")

394 self._pause()

395

396 if _clear_state: # only intended for test suite use

397 clear_state()

398

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.

403

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 )

410

411 # The actual HGI address will be discovered when the actual transport was/is

412 # started up (usually before now)

413

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 )

421

422 tmp_transport = await transport_factory(

423 tmp_protocol,

424 packet_dict=packets,

425 )

426

427 await tmp_transport.get_extra_info(SZ_READER_TASK)

428

429 _LOGGER.debug("Gateway: Restored, resuming")

430 self._resume()

431

432 def _add_device(self, dev: Device) -> None: # TODO: also: _add_system()

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 """

441

442 if dev.id in self.device_by_id:

443 raise LookupError(f"Device already exists: {dev.id}")

444

445 self.devices.append(dev)

446 self.device_by_id[dev.id] = dev

447

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.

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.

476 """

477

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."""

480

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")

483

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 )

492

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 )

499

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

506

507 dev = self.device_by_id.get(device_id)

508

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)

513

514 traits: dict[str, Any] = SCH_TRAITS(self._include.get(device_id, {}))

515

516 dev = device_factory(self, Address(device_id), msg=msg, **_traits)

517

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}")

523

524 # TODO: the exact order of the following may need refining...

525 # TODO: some will be done by devices themselves?

526

527 # if schema: # Step 2: Only controllers have a schema...

528 # dev._update_schema(**schema) # TODO: schema/traits

529

530 if parent or child_id:

531 dev.set_parent(parent, child_id=child_id, is_sensor=is_sensor)

532

533 # if msg:

534 # dev._handle_msg(msg)

535

536 return dev

537

538 def fake_device(

539 self,

540 device_id: DeviceIdT,

541 create_device: bool = False,

542 ) -> Device | Fakeable:

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 """

558

559 if not is_valid_dev_id(device_id):

560 raise TypeError(f"The device id is not valid: {device_id}")

561

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}")

566

567 if (dev := self.get_device(device_id)) and isinstance(dev, Fakeable):

568 dev._make_fake()

569 return dev

570

571 raise TypeError(f"The device is not fakeable: {device_id}")

572

573 @property

574 def tcs(self) -> Evohome | None:

575 """Return the primary Temperature Control System (TCS), if any.

576

577 :returns: The primary Evohome system or None.

578 :rtype: Evohome | None

579 """

580

581 if self._tcs is None and self.systems:

582 self._tcs = self.systems[0]

583 return self._tcs

584

585 @property

586 def known_list(self) -> DeviceListT:

587 """Return the working known_list (a superset of the provided known_list).

588

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.

592

593 :returns: A dictionary where keys are device IDs and values are their traits.

594 :rtype: DeviceListT

595 """

596

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

606

607 @property

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 """

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

619

620 @property

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 """

627 return list(self.system_by_id.values())

628

629 @property

630 def _config(self) -> dict[str, Any]:

631 """Return the working configuration.

632

633 Includes:

634 - config

635 - schema (everything else)

636 - known_list

637 - block_list

638

639 :returns: A dictionary representing the current internal configuration state.

640 :rtype: dict[str, Any]

641 """

642

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 }

651

652 @property

653 def schema(self) -> dict[str, Any]:

654 """Return the global schema.

655

656 This 'active' schema may exclude non-present devices from the configured schema

657 that was loaded during initialisation.

658

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.

662

663 :returns: A dictionary representing the entire system schema structure.

664 :rtype: dict[str, Any]

665 """

666

667 schema: dict[str, Any] = {SZ_MAIN_TCS: self.tcs.ctl.id if self.tcs else None}

668

669 for tcs in self.systems:

670 schema[tcs.ctl.id] = tcs.schema

671

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

682

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

687

688 return schema

689

690 @property

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 """

697 return {SZ_DEVICES: {d.id: d.params for d in sorted(self.devices)}}

698

699 @property

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 """

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 }

711

712 def _msg_handler(self, msg: Message) -> None:

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 """

722

723 super()._msg_handler(msg)

724

725 # TODO: ideally remove this feature...

726 assert self._this_msg # mypy check

727

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 )

733

734 process_msg(self, msg)

735

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.

748

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 """

764

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 )

773

774 task = self._loop.create_task(coro)

775 self.add_task(task)

776 return task

777

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.

791

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.

794

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 """

814

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