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

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 

10from __future__ import annotations 

11 

12import asyncio 

13import logging 

14from collections.abc import Awaitable, Callable 

15from types import SimpleNamespace 

16from typing import TYPE_CHECKING, Any 

17 

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 

48 

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 

68 

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

70 I_, 

71 RP, 

72 RQ, 

73 W_, 

74 Code, 

75) 

76 

77if 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 

86class 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