ramses-rf 0.52.5__py3-none-any.whl → 0.53.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ramses_tx/transport.py CHANGED
@@ -60,7 +60,12 @@ from typing import TYPE_CHECKING, Any, Final, TypeAlias
60
60
  from urllib.parse import parse_qs, unquote, urlparse
61
61
 
62
62
  from paho.mqtt import MQTTException, client as mqtt
63
- from paho.mqtt.enums import CallbackAPIVersion
63
+
64
+ try:
65
+ from paho.mqtt.enums import CallbackAPIVersion
66
+ except ImportError:
67
+ # Fallback for Paho MQTT < 2.0.0 (Home Assistant compatibility)
68
+ CallbackAPIVersion = None # type: ignore[assignment, misc]
64
69
  from serial import ( # type: ignore[import-untyped]
65
70
  Serial,
66
71
  SerialException,
@@ -149,7 +154,14 @@ else: # is linux
149
154
  from serial.tools.list_ports_linux import SysFS # type: ignore[import-untyped]
150
155
 
151
156
  def list_links(devices: set[str]) -> list[str]:
152
- """Search for symlinks to ports already listed in devices."""
157
+ """Search for symlinks to ports already listed in devices.
158
+
159
+ :param devices: A set of real device paths.
160
+ :type devices: set[str]
161
+ :return: A list of symlinks pointing to the devices.
162
+ :rtype: list[str]
163
+ """
164
+
153
165
  links: list[str] = []
154
166
  for device in glob.glob("/dev/*") + glob.glob("/dev/serial/by-id/*"):
155
167
  if os.path.islink(device) and os.path.realpath(device) in devices:
@@ -159,7 +171,15 @@ else: # is linux
159
171
  def comports( # type: ignore[no-any-unimported]
160
172
  include_links: bool = False, _hide_subsystems: list[str] | None = None
161
173
  ) -> list[SysFS]:
162
- """Return a list of Serial objects for all known serial ports."""
174
+ """Return a list of Serial objects for all known serial ports.
175
+
176
+ :param include_links: Whether to include symlinks in the results, defaults to False.
177
+ :type include_links: bool, optional
178
+ :param _hide_subsystems: List of subsystems to hide, defaults to None.
179
+ :type _hide_subsystems: list[str] | None, optional
180
+ :return: A list of SysFS objects representing the ports.
181
+ :rtype: list[SysFS]
182
+ """
163
183
 
164
184
  if _hide_subsystems is None:
165
185
  _hide_subsystems = ["platform"]
@@ -182,10 +202,16 @@ else: # is linux
182
202
 
183
203
 
184
204
  async def is_hgi80(serial_port: SerPortNameT) -> bool | None:
185
- """Return True/False if the device attached to the port has the attrs of an HGI80.
205
+ """Return True if the device attached to the port has the attributes of a Honeywell HGI80.
206
+
207
+ Return False if it appears to be an evofw3-compatible device (ATMega etc).
208
+ Return None if the type cannot be determined.
186
209
 
187
- Return None if it's not possible to tell (falsy should assume is evofw3).
188
- Raise TransportSerialError if the port is not found at all.
210
+ :param serial_port: The serial port path or URL.
211
+ :type serial_port: SerPortNameT
212
+ :return: True if HGI80, False if not (likely evofw3), None if undetermined.
213
+ :rtype: bool | None
214
+ :raises exc.TransportSerialError: If the serial port cannot be found.
189
215
  """
190
216
 
191
217
  if serial_port[:7] == "mqtt://":
@@ -258,12 +284,16 @@ async def is_hgi80(serial_port: SerPortNameT) -> bool | None:
258
284
 
259
285
 
260
286
  def _normalise(pkt_line: str) -> str:
261
- """
262
- Perform any (transparent) frame-level hacks, as required at (near-)RF layer.
287
+ """Perform any (transparent) frame-level hacks, as required at (near-)RF layer.
263
288
 
264
289
  Goals:
265
- - ensure an evofw3 provides the same output as a HGI80 (none, presently)
266
- - handle 'strange' packets (e.g. ``I|08:|0008``)
290
+ - ensure an evofw3 provides the same output as a HGI80 (none, presently)
291
+ - handle 'strange' packets (e.g. ``I|08:|0008``)
292
+
293
+ :param pkt_line: The raw packet string from the hardware.
294
+ :type pkt_line: str
295
+ :return: The normalized packet string.
296
+ :rtype: str
267
297
  """
268
298
 
269
299
  # TODO: deprecate as only for ramses_esp <0.4.0
@@ -283,6 +313,14 @@ def _normalise(pkt_line: str) -> str:
283
313
 
284
314
 
285
315
  def _str(value: bytes) -> str:
316
+ """Decode bytes to a string, ignoring non-printable characters.
317
+
318
+ :param value: The bytes to decode.
319
+ :type value: bytes
320
+ :return: The decoded string.
321
+ :rtype: str
322
+ """
323
+
286
324
  try:
287
325
  result = "".join(
288
326
  c for c in value.decode("ascii", errors="strict") if c in printable
@@ -298,8 +336,12 @@ def limit_duty_cycle(
298
336
  ) -> Callable[..., Any]:
299
337
  """Limit the Tx rate to the RF duty cycle regulations (e.g. 1% per hour).
300
338
 
301
- max_duty_cycle: bandwidth available per observation window (%)
302
- time_window: duration of the sliding observation window (default 60 seconds)
339
+ :param max_duty_cycle: Bandwidth available per observation window (percentage as 0.0-1.0).
340
+ :type max_duty_cycle: float
341
+ :param time_window: Duration of the sliding observation window in seconds, defaults to 60.
342
+ :type time_window: int
343
+ :return: A decorator that enforces the duty cycle limit.
344
+ :rtype: Callable[..., Any]
303
345
  """
304
346
 
305
347
  TX_RATE_AVAIL: int = 38400 # bits per second (deemed)
@@ -368,7 +410,13 @@ _global_sync_cycles: deque[Packet] = deque(maxlen=_MAX_TRACKED_SYNCS)
368
410
 
369
411
  # TODO: doesn't look right at all...
370
412
  def avoid_system_syncs(fnc: Callable[..., Awaitable[None]]) -> Callable[..., Any]:
371
- """Take measures to avoid Tx when any controller is doing a sync cycle."""
413
+ """Take measures to avoid Tx when any controller is doing a sync cycle.
414
+
415
+ :param fnc: The async function to decorate.
416
+ :type fnc: Callable[..., Awaitable[None]]
417
+ :return: The decorated function.
418
+ :rtype: Callable[..., Any]
419
+ """
372
420
 
373
421
  DURATION_PKT_GAP = 0.020 # 0.0200 for evohome, or 0.0127 for DTS92
374
422
  DURATION_LONG_PKT = 0.022 # time to tx I|2309|048 (or 30C9, or 000A)
@@ -408,7 +456,13 @@ def avoid_system_syncs(fnc: Callable[..., Awaitable[None]]) -> Callable[..., Any
408
456
 
409
457
 
410
458
  def track_system_syncs(fnc: Callable[..., None]) -> Callable[..., Any]:
411
- """Track/remember any new/outstanding TCS sync cycle."""
459
+ """Track/remember any new/outstanding TCS sync cycle.
460
+
461
+ :param fnc: The function to decorate (usually a packet reader).
462
+ :type fnc: Callable[..., None]
463
+ :return: The decorated function.
464
+ :rtype: Callable[..., Any]
465
+ """
412
466
 
413
467
  @wraps(fnc)
414
468
  def wrapper(self: PortTransport, pkt: Packet) -> None:
@@ -441,7 +495,25 @@ def track_system_syncs(fnc: Callable[..., None]) -> Callable[..., Any]:
441
495
  # ### Do the bare minimum to abstract each transport from its underlying class
442
496
 
443
497
 
498
+ class _CallbackTransportAbstractor:
499
+ """Do the bare minimum to abstract a transport from its underlying class."""
500
+
501
+ def __init__(
502
+ self, loop: asyncio.AbstractEventLoop | None = None, **kwargs: Any
503
+ ) -> None:
504
+ """Initialize the callback transport abstractor.
505
+
506
+ :param loop: The asyncio event loop, defaults to None.
507
+ :type loop: asyncio.AbstractEventLoop | None, optional
508
+ """
509
+ self._loop = loop or asyncio.get_event_loop()
510
+ # Consume 'kwargs' here. Do NOT pass them to object.__init__().
511
+ super().__init__()
512
+
513
+
444
514
  class _BaseTransport:
515
+ """Base class for all transports."""
516
+
445
517
  def __init__(self, *args: Any, **kwargs: Any) -> None:
446
518
  super().__init__(*args, **kwargs)
447
519
 
@@ -455,6 +527,15 @@ class _FileTransportAbstractor:
455
527
  protocol: RamsesProtocolT,
456
528
  loop: asyncio.AbstractEventLoop | None = None,
457
529
  ) -> None:
530
+ """Initialize the file transport abstractor.
531
+
532
+ :param pkt_source: The source of packets (file path, file object, or dict).
533
+ :type pkt_source: dict[str, str] | str | TextIOWrapper
534
+ :param protocol: The protocol instance.
535
+ :type protocol: RamsesProtocolT
536
+ :param loop: The asyncio event loop, defaults to None.
537
+ :type loop: asyncio.AbstractEventLoop | None, optional
538
+ """
458
539
  # per().__init__(extra=extra) # done in _BaseTransport
459
540
 
460
541
  self._pkt_source = pkt_source
@@ -474,6 +555,16 @@ class _PortTransportAbstractor(serial_asyncio.SerialTransport):
474
555
  protocol: RamsesProtocolT,
475
556
  loop: asyncio.AbstractEventLoop | None = None,
476
557
  ) -> None:
558
+ """Initialize the port transport abstractor.
559
+
560
+ :param serial_instance: The serial object instance.
561
+ :type serial_instance: Serial
562
+ :param protocol: The protocol instance.
563
+ :type protocol: RamsesProtocolT
564
+ :param loop: The asyncio event loop, defaults to None.
565
+ :type loop: asyncio.AbstractEventLoop | None, optional
566
+ """
567
+
477
568
  super().__init__(loop or asyncio.get_event_loop(), protocol, serial_instance)
478
569
 
479
570
  # lf._serial = serial_instance # ._serial, not .serial
@@ -491,6 +582,15 @@ class _MqttTransportAbstractor:
491
582
  protocol: RamsesProtocolT,
492
583
  loop: asyncio.AbstractEventLoop | None = None,
493
584
  ) -> None:
585
+ """Initialize the MQTT transport abstractor.
586
+
587
+ :param broker_url: The URL of the MQTT broker.
588
+ :type broker_url: str
589
+ :param protocol: The protocol instance.
590
+ :type protocol: RamsesProtocolT
591
+ :param loop: The asyncio event loop, defaults to None.
592
+ :type loop: asyncio.AbstractEventLoop | None, optional
593
+ """
494
594
  # per().__init__(extra=extra) # done in _BaseTransport
495
595
 
496
596
  self._broker_url = urlparse(broker_url)
@@ -516,6 +616,11 @@ class _ReadTransport(_BaseTransport):
516
616
  def __init__(
517
617
  self, *args: Any, extra: dict[str, Any] | None = None, **kwargs: Any
518
618
  ) -> None:
619
+ """Initialize the read-only transport.
620
+
621
+ :param extra: Extra info dict, defaults to None.
622
+ :type extra: dict[str, Any] | None, optional
623
+ """
519
624
  super().__init__(*args, loop=kwargs.pop("loop", None))
520
625
 
521
626
  self._extra: dict[str, Any] = {} if extra is None else extra
@@ -530,13 +635,17 @@ class _ReadTransport(_BaseTransport):
530
635
  self._prev_pkt: Packet | None = None
531
636
 
532
637
  for key in (SZ_ACTIVE_HGI, SZ_SIGNATURE):
533
- self._extra[key] = None
638
+ self._extra.setdefault(key, None)
534
639
 
535
640
  def __repr__(self) -> str:
536
641
  return f"{self.__class__.__name__}({self._protocol})"
537
642
 
538
643
  def _dt_now(self) -> dt:
539
- """Return a precise datetime, using last packet's dtm field."""
644
+ """Return a precise datetime, using last packet's dtm field.
645
+
646
+ :return: The timestamp of the current packet or a default.
647
+ :rtype: dt
648
+ """
540
649
 
541
650
  try:
542
651
  return self._this_pkt.dtm # type: ignore[union-attr]
@@ -545,20 +654,41 @@ class _ReadTransport(_BaseTransport):
545
654
 
546
655
  @property
547
656
  def loop(self) -> asyncio.AbstractEventLoop:
548
- """The asyncio event loop as declared by SerialTransport."""
657
+ """The asyncio event loop as declared by SerialTransport.
658
+
659
+ :return: The event loop.
660
+ :rtype: asyncio.AbstractEventLoop
661
+ """
549
662
  return self._loop
550
663
 
551
664
  def get_extra_info(self, name: str, default: Any = None) -> Any:
665
+ """Get extra information about the transport.
666
+
667
+ :param name: The name of the information to retrieve.
668
+ :type name: str
669
+ :param default: Default value if name is not found, defaults to None.
670
+ :type default: Any, optional
671
+ :return: The value associated with name.
672
+ :rtype: Any
673
+ """
552
674
  if name == SZ_IS_EVOFW3:
553
675
  return not self._is_hgi80
554
676
  return self._extra.get(name, default)
555
677
 
556
678
  def is_closing(self) -> bool:
557
- """Return True if the transport is closing or has closed."""
679
+ """Return True if the transport is closing or has closed.
680
+
681
+ :return: Closing state.
682
+ :rtype: bool
683
+ """
558
684
  return self._closing
559
685
 
560
686
  def _close(self, exc: exc.RamsesException | None = None) -> None:
561
- """Inform the protocol that this transport has closed."""
687
+ """Inform the protocol that this transport has closed.
688
+
689
+ :param exc: The exception that caused the closure, if any.
690
+ :type exc: exc.RamsesException | None, optional
691
+ """
562
692
 
563
693
  if self._closing:
564
694
  return
@@ -573,7 +703,11 @@ class _ReadTransport(_BaseTransport):
573
703
  self._close()
574
704
 
575
705
  def is_reading(self) -> bool:
576
- """Return True if the transport is receiving."""
706
+ """Return True if the transport is receiving.
707
+
708
+ :return: Reading state.
709
+ :rtype: bool
710
+ """
577
711
  return self._reading
578
712
 
579
713
  def pause_reading(self) -> None:
@@ -585,6 +719,11 @@ class _ReadTransport(_BaseTransport):
585
719
  self._reading = True
586
720
 
587
721
  def _make_connection(self, gwy_id: DeviceIdT | None) -> None:
722
+ """Register the connection with the protocol.
723
+
724
+ :param gwy_id: The ID of the gateway device, if known.
725
+ :type gwy_id: DeviceIdT | None
726
+ """
588
727
  self._extra[SZ_ACTIVE_HGI] = gwy_id # or HGI_DEV_ADDR.id
589
728
 
590
729
  self.loop.call_soon_threadsafe( # shouldn't call this until we have HGI-ID
@@ -593,7 +732,13 @@ class _ReadTransport(_BaseTransport):
593
732
 
594
733
  # NOTE: all transport should call this method when they receive data
595
734
  def _frame_read(self, dtm_str: str, frame: str) -> None:
596
- """Make a Packet from the Frame and process it (called by each specific Tx)."""
735
+ """Make a Packet from the Frame and process it (called by each specific Tx).
736
+
737
+ :param dtm_str: The timestamp string of the frame.
738
+ :type dtm_str: str
739
+ :param frame: The raw frame string.
740
+ :type frame: str
741
+ """
597
742
 
598
743
  if not frame.strip():
599
744
  return
@@ -613,7 +758,12 @@ class _ReadTransport(_BaseTransport):
613
758
 
614
759
  # NOTE: all protocol callbacks should be invoked from here
615
760
  def _pkt_read(self, pkt: Packet) -> None:
616
- """Pass any valid Packets to the protocol's callback (_prev_pkt, _this_pkt)."""
761
+ """Pass any valid Packets to the protocol's callback (_prev_pkt, _this_pkt).
762
+
763
+ :param pkt: The parsed packet.
764
+ :type pkt: Packet
765
+ :raises exc.TransportError: If called while closing.
766
+ """
617
767
 
618
768
  self._this_pkt, self._prev_pkt = pkt, self._this_pkt
619
769
 
@@ -622,7 +772,7 @@ class _ReadTransport(_BaseTransport):
622
772
  if self._closing is True: # raise, or warn & return?
623
773
  raise exc.TransportError("Transport is closing or has closed")
624
774
 
625
- # TODO: can we switch to call_soon now QoS has been refactored?
775
+ # TODO: can we switch to call_soon now that QoS has been refactored?
626
776
  # NOTE: No need to use call_soon() here, and they may break Qos/Callbacks
627
777
  # NOTE: Thus, excepts need checking
628
778
  try: # below could be a call_soon?
@@ -633,7 +783,14 @@ class _ReadTransport(_BaseTransport):
633
783
  _LOGGER.error("%s < exception from msg layer: %s", pkt, err)
634
784
 
635
785
  async def write_frame(self, frame: str, disable_tx_limits: bool = False) -> None:
636
- """Transmit a frame via the underlying handler (e.g. serial port, MQTT)."""
786
+ """Transmit a frame via the underlying handler (e.g. serial port, MQTT).
787
+
788
+ :param frame: The frame to write.
789
+ :type frame: str
790
+ :param disable_tx_limits: Whether to bypass duty cycle limits, defaults to False.
791
+ :type disable_tx_limits: bool, optional
792
+ :raises exc.TransportSerialError: Because this transport is read-only.
793
+ """
637
794
  raise exc.TransportSerialError("This transport is read only")
638
795
 
639
796
 
@@ -643,24 +800,46 @@ class _FullTransport(_ReadTransport): # asyncio.Transport
643
800
  def __init__(
644
801
  self, *args: Any, disable_sending: bool = False, **kwargs: Any
645
802
  ) -> None:
803
+ """Initialize the full transport.
804
+
805
+ :param disable_sending: Whether to disable sending capabilities, defaults to False.
806
+ :type disable_sending: bool, optional
807
+ """
646
808
  super().__init__(*args, **kwargs)
647
809
 
648
810
  self._disable_sending = disable_sending
649
811
  self._transmit_times: deque[dt] = deque(maxlen=_MAX_TRACKED_TRANSMITS)
650
812
 
651
813
  def _dt_now(self) -> dt:
652
- """Return a precise datetime, using the current dtm."""
814
+ """Get a precise datetime, using the current dtm.
815
+
816
+ :return: Current datetime.
817
+ :rtype: dt
818
+ """
653
819
  # _LOGGER.error("Full._dt_now()")
654
820
 
655
821
  return dt_now()
656
822
 
657
823
  def get_extra_info(self, name: str, default: Any = None) -> Any:
824
+ """Get extra info, including transmit rate calculations.
825
+
826
+ :param name: Name of info.
827
+ :type name: str
828
+ :param default: Default value.
829
+ :type default: Any, optional
830
+ :return: The requested info.
831
+ :rtype: Any
832
+ """
658
833
  if name == "tx_rate":
659
834
  return self._report_transmit_rate()
660
835
  return super().get_extra_info(name, default=default)
661
836
 
662
837
  def _report_transmit_rate(self) -> float:
663
- """Return the transmit rate in transmits per minute."""
838
+ """Return the transmit rate in transmits per minute.
839
+
840
+ :return: Transmits per minute.
841
+ :rtype: float
842
+ """
664
843
 
665
844
  dt_now = dt.now()
666
845
  dtm = dt_now - td(seconds=_MAX_TRACKED_DURATION)
@@ -684,7 +863,12 @@ class _FullTransport(_ReadTransport): # asyncio.Transport
684
863
 
685
864
  # NOTE: Protocols call write_frame(), not write()
686
865
  def write(self, data: bytes) -> None:
687
- """Write the data to the underlying handler."""
866
+ """Write the data to the underlying handler.
867
+
868
+ :param data: The data to write.
869
+ :type data: bytes
870
+ :raises exc.TransportError: Always raises, use write_frame instead.
871
+ """
688
872
  # _LOGGER.error("Full.write(%s)", data)
689
873
 
690
874
  raise exc.TransportError("write() not implemented, use write_frame() instead")
@@ -693,6 +877,12 @@ class _FullTransport(_ReadTransport): # asyncio.Transport
693
877
  """Transmit a frame via the underlying handler (e.g. serial port, MQTT).
694
878
 
695
879
  Protocols call Transport.write_frame(), not Transport.write().
880
+
881
+ :param frame: The frame to transmit.
882
+ :type frame: str
883
+ :param disable_tx_limits: Whether to disable duty cycle limits, defaults to False.
884
+ :type disable_tx_limits: bool, optional
885
+ :raises exc.TransportError: If sending is disabled or transport is closed.
696
886
  """
697
887
 
698
888
  if self._disable_sending is True:
@@ -705,7 +895,12 @@ class _FullTransport(_ReadTransport): # asyncio.Transport
705
895
  await self._write_frame(frame)
706
896
 
707
897
  async def _write_frame(self, frame: str) -> None:
708
- """Write some data bytes to the underlying transport."""
898
+ """Write some data bytes to the underlying transport.
899
+
900
+ :param frame: The frame to write.
901
+ :type frame: str
902
+ :raises NotImplementedError: Abstract method.
903
+ """
709
904
  # _LOGGER.error("Full._write_frame(%s)", frame)
710
905
 
711
906
  raise NotImplementedError("_write_frame() not implemented here")
@@ -715,9 +910,16 @@ _RegexRuleT: TypeAlias = dict[str, str]
715
910
 
716
911
 
717
912
  class _RegHackMixin:
913
+ """Mixin to apply regex rules to inbound and outbound frames."""
914
+
718
915
  def __init__(
719
916
  self, *args: Any, use_regex: dict[str, _RegexRuleT] | None = None, **kwargs: Any
720
917
  ) -> None:
918
+ """Initialize the regex mixin.
919
+
920
+ :param use_regex: Dictionary containing inbound/outbound regex rules.
921
+ :type use_regex: dict[str, _RegexRuleT] | None, optional
922
+ """
721
923
  super().__init__(*args, **kwargs)
722
924
 
723
925
  use_regex = use_regex or {}
@@ -727,6 +929,15 @@ class _RegHackMixin:
727
929
 
728
930
  @staticmethod
729
931
  def _regex_hack(pkt_line: str, regex_rules: _RegexRuleT) -> str:
932
+ """Apply regex rules to a packet line.
933
+
934
+ :param pkt_line: The packet line to process.
935
+ :type pkt_line: str
936
+ :param regex_rules: The rules to apply.
937
+ :type regex_rules: _RegexRuleT
938
+ :return: The modified packet line.
939
+ :rtype: str
940
+ """
730
941
  if not regex_rules:
731
942
  return pkt_line
732
943
 
@@ -756,6 +967,12 @@ class FileTransport(_ReadTransport, _FileTransportAbstractor):
756
967
  """Receive packets from a read-only source such as packet log or a dict."""
757
968
 
758
969
  def __init__(self, *args: Any, disable_sending: bool = True, **kwargs: Any) -> None:
970
+ """Initialize the file transport.
971
+
972
+ :param disable_sending: Must be True for FileTransport.
973
+ :type disable_sending: bool
974
+ :raises exc.TransportSourceInvalid: If disable_sending is False.
975
+ """
759
976
  super().__init__(*args, **kwargs)
760
977
 
761
978
  if bool(disable_sending) is False:
@@ -768,6 +985,7 @@ class FileTransport(_ReadTransport, _FileTransportAbstractor):
768
985
  self._make_connection(None)
769
986
 
770
987
  async def _start_reader(self) -> None: # TODO
988
+ """Start the reader task."""
771
989
  self._reading = True
772
990
  try:
773
991
  await self._reader()
@@ -825,7 +1043,11 @@ class FileTransport(_ReadTransport, _FileTransportAbstractor):
825
1043
  )
826
1044
 
827
1045
  def _close(self, exc: exc.RamsesException | None = None) -> None:
828
- """Close the transport (cancel any outstanding tasks)."""
1046
+ """Close the transport (cancel any outstanding tasks).
1047
+
1048
+ :param exc: The exception causing closure.
1049
+ :type exc: exc.RamsesException | None, optional
1050
+ """
829
1051
 
830
1052
  super()._close(exc)
831
1053
 
@@ -845,6 +1067,7 @@ class PortTransport(_RegHackMixin, _FullTransport, _PortTransportAbstractor): #
845
1067
  _recv_buffer: bytes = b""
846
1068
 
847
1069
  def __init__(self, *args: Any, **kwargs: Any) -> None:
1070
+ """Initialize the port transport."""
848
1071
  super().__init__(*args, **kwargs)
849
1072
 
850
1073
  self._leaker_sem = asyncio.BoundedSemaphore()
@@ -975,6 +1198,11 @@ class PortTransport(_RegHackMixin, _FullTransport, _PortTransportAbstractor): #
975
1198
  """Transmit a frame via the underlying handler (e.g. serial port, MQTT).
976
1199
 
977
1200
  Protocols call Transport.write_frame(), not Transport.write().
1201
+
1202
+ :param frame: The frame to transmit.
1203
+ :type frame: str
1204
+ :param disable_tx_limits: Whether to disable duty cycle limits, defaults to False.
1205
+ :type disable_tx_limits: bool, optional
978
1206
  """
979
1207
 
980
1208
  await self._leaker_sem.acquire() # MIN_INTER_WRITE_GAP
@@ -984,14 +1212,21 @@ class PortTransport(_RegHackMixin, _FullTransport, _PortTransportAbstractor): #
984
1212
  # then the code that avoids the controller sync cycles
985
1213
 
986
1214
  async def _write_frame(self, frame: str) -> None:
987
- """Write some data bytes to the underlying transport."""
1215
+ """Write some data bytes to the underlying transport.
1216
+
1217
+ :param frame: The frame to write.
1218
+ :type frame: str
1219
+ """
988
1220
 
989
1221
  data = bytes(frame, "ascii") + b"\r\n"
990
1222
 
1223
+ log_msg = f"Serial transport transmitting frame: {frame}"
991
1224
  if _DBG_FORCE_FRAME_LOGGING:
992
- _LOGGER.warning("Tx: %s", data)
993
- elif _LOGGER.getEffectiveLevel() == logging.INFO: # log for INFO not DEBUG
994
- _LOGGER.info("Tx: %s", data)
1225
+ _LOGGER.warning(log_msg)
1226
+ elif _LOGGER.getEffectiveLevel() > logging.DEBUG:
1227
+ _LOGGER.info(log_msg)
1228
+ else:
1229
+ _LOGGER.debug(log_msg)
995
1230
 
996
1231
  try:
997
1232
  self._write(data)
@@ -1000,9 +1235,19 @@ class PortTransport(_RegHackMixin, _FullTransport, _PortTransportAbstractor): #
1000
1235
  return
1001
1236
 
1002
1237
  def _write(self, data: bytes) -> None:
1238
+ """Perform the actual write to the serial port.
1239
+
1240
+ :param data: The bytes to write.
1241
+ :type data: bytes
1242
+ """
1003
1243
  self.serial.write(data)
1004
1244
 
1005
1245
  def _abort(self, exc: ExceptionT) -> None: # type: ignore[override] # used by serial_asyncio.SerialTransport
1246
+ """Abort the transport.
1247
+
1248
+ :param exc: The exception causing the abort.
1249
+ :type exc: ExceptionT
1250
+ """
1006
1251
  super()._abort(exc) # type: ignore[arg-type]
1007
1252
 
1008
1253
  if self._init_task:
@@ -1011,7 +1256,11 @@ class PortTransport(_RegHackMixin, _FullTransport, _PortTransportAbstractor): #
1011
1256
  self._leaker_task.cancel()
1012
1257
 
1013
1258
  def _close(self, exc: exc.RamsesException | None = None) -> None: # type: ignore[override]
1014
- """Close the transport (cancel any outstanding tasks)."""
1259
+ """Close the transport (cancel any outstanding tasks).
1260
+
1261
+ :param exc: The exception causing closure.
1262
+ :type exc: exc.RamsesException | None, optional
1263
+ """
1015
1264
 
1016
1265
  super()._close(exc)
1017
1266
 
@@ -1138,6 +1387,19 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1138
1387
  reason_code: Any,
1139
1388
  properties: Any | None,
1140
1389
  ) -> None:
1390
+ """Handle MQTT connection success.
1391
+
1392
+ :param client: The MQTT client.
1393
+ :type client: mqtt.Client
1394
+ :param userdata: User data.
1395
+ :type userdata: Any
1396
+ :param flags: Connection flags.
1397
+ :type flags: dict[str, Any]
1398
+ :param reason_code: Connection reason code.
1399
+ :type reason_code: Any
1400
+ :param properties: Connection properties.
1401
+ :type properties: Any | None
1402
+ """
1141
1403
  # _LOGGER.error("Mqtt._on_connect(%s, %s, %s, %s)", client, userdata, flags, reason_code.getName())
1142
1404
 
1143
1405
  self._connecting = False
@@ -1190,6 +1452,13 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1190
1452
  client: mqtt.Client,
1191
1453
  userdata: Any,
1192
1454
  ) -> None:
1455
+ """Handle MQTT connection failure.
1456
+
1457
+ :param client: The MQTT client.
1458
+ :type client: mqtt.Client
1459
+ :param userdata: User data.
1460
+ :type userdata: Any
1461
+ """
1193
1462
  _LOGGER.error("MQTT connection failed")
1194
1463
 
1195
1464
  self._connecting = False
@@ -1205,6 +1474,13 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1205
1474
  *args: Any,
1206
1475
  **kwargs: Any,
1207
1476
  ) -> None:
1477
+ """Handle MQTT disconnection.
1478
+
1479
+ :param client: The MQTT client.
1480
+ :type client: mqtt.Client
1481
+ :param userdata: User data.
1482
+ :type userdata: Any
1483
+ """
1208
1484
  # Handle different paho-mqtt callback signatures
1209
1485
  reason_code = args[0] if len(args) >= 1 else None
1210
1486
 
@@ -1235,7 +1511,11 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1235
1511
  self._schedule_reconnect()
1236
1512
 
1237
1513
  def _create_connection(self, msg: mqtt.MQTTMessage) -> None:
1238
- """Invoke the Protocols's connection_made() callback MQTT is established."""
1514
+ """Invoke the Protocols's connection_made() callback MQTT is established.
1515
+
1516
+ :param msg: The online message triggering the connection.
1517
+ :type msg: mqtt.MQTTMessage
1518
+ """
1239
1519
  # _LOGGER.error("Mqtt._create_connection(%s)", msg)
1240
1520
 
1241
1521
  assert msg.payload == b"online", "Coding error"
@@ -1277,7 +1557,15 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1277
1557
  def _on_message(
1278
1558
  self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage
1279
1559
  ) -> None:
1280
- """Make a Frame from the MQTT message and process it."""
1560
+ """Make a Frame from the MQTT message and process it.
1561
+
1562
+ :param client: The MQTT client.
1563
+ :type client: mqtt.Client
1564
+ :param userdata: User data.
1565
+ :type userdata: Any
1566
+ :param msg: The received message.
1567
+ :type msg: mqtt.MQTTMessage
1568
+ """
1281
1569
  # _LOGGER.error(
1282
1570
  # "Mqtt._on_message(%s, %s, %s)",
1283
1571
  # client,
@@ -1383,6 +1671,11 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1383
1671
  seconds, except when disable_tx_limits is True (for e.g. user commands).
1384
1672
 
1385
1673
  Protocols call Transport.write_frame(), not Transport.write().
1674
+
1675
+ :param frame: The frame to transmit.
1676
+ :type frame: str
1677
+ :param disable_tx_limits: Whether to disable rate limiting, defaults to False.
1678
+ :type disable_tx_limits: bool, optional
1386
1679
  """
1387
1680
 
1388
1681
  # Check if we're connected before attempting to write
@@ -1416,7 +1709,11 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1416
1709
  await super().write_frame(frame)
1417
1710
 
1418
1711
  async def _write_frame(self, frame: str) -> None:
1419
- """Write some data bytes to the underlying transport."""
1712
+ """Write some data bytes to the underlying transport.
1713
+
1714
+ :param frame: The frame to write.
1715
+ :type frame: str
1716
+ """
1420
1717
  # _LOGGER.error("Mqtt._write_frame(%s)", frame)
1421
1718
 
1422
1719
  data = json.dumps({"msg": frame})
@@ -1435,6 +1732,11 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1435
1732
  return
1436
1733
 
1437
1734
  def _publish(self, payload: str) -> None:
1735
+ """Publish the payload to the MQTT broker.
1736
+
1737
+ :param payload: The data payload to publish.
1738
+ :type payload: str
1739
+ """
1438
1740
  # _LOGGER.error("Mqtt._publish(%s)", message)
1439
1741
 
1440
1742
  if not self._connected:
@@ -1456,7 +1758,11 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1456
1758
  self._schedule_reconnect()
1457
1759
 
1458
1760
  def _close(self, exc: exc.RamsesException | None = None) -> None:
1459
- """Close the transport (disconnect from the broker and stop its poller)."""
1761
+ """Close the transport (disconnect from the broker and stop its poller).
1762
+
1763
+ :param exc: The exception causing closure.
1764
+ :type exc: exc.RamsesException | None, optional
1765
+ """
1460
1766
  # _LOGGER.error("Mqtt._close(%s)", exc)
1461
1767
 
1462
1768
  super()._close(exc)
@@ -1478,8 +1784,115 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1478
1784
  _LOGGER.debug(f"Error during MQTT cleanup: {err}")
1479
1785
 
1480
1786
 
1787
+ class CallbackTransport(_FullTransport, _CallbackTransportAbstractor):
1788
+ """A virtual transport that delegates I/O to external callbacks (Inversion of Control).
1789
+
1790
+ This transport allows ramses_rf to be used with external connection managers
1791
+ (like Home Assistant's MQTT integration) without direct dependencies.
1792
+ """
1793
+
1794
+ def __init__(
1795
+ self,
1796
+ protocol: RamsesProtocolT,
1797
+ io_writer: Callable[[str], Awaitable[None]],
1798
+ disable_sending: bool = False,
1799
+ **kwargs: Any,
1800
+ ) -> None:
1801
+ """Initialize the callback transport.
1802
+
1803
+ :param protocol: The protocol instance.
1804
+ :type protocol: RamsesProtocolT
1805
+ :param io_writer: Async callable to handle outbound frames.
1806
+ :type io_writer: Callable[[str], Awaitable[None]]
1807
+ :param disable_sending: Whether to disable sending, defaults to False.
1808
+ :type disable_sending: bool, optional
1809
+ """
1810
+ # Pass kwargs up the chain. _ReadTransport will extract 'loop' if present.
1811
+ # _BaseTransport will pass 'loop' to _CallbackTransportAbstractor, which consumes it.
1812
+ super().__init__(disable_sending=disable_sending, **kwargs)
1813
+
1814
+ self._protocol = protocol
1815
+ self._io_writer = io_writer
1816
+
1817
+ # Section 3.1: "Initial State: Default to a PAUSED state"
1818
+ self._reading = False
1819
+
1820
+ # Section 6.1: Object Lifecycle Logging
1821
+ _LOGGER.info(f"CallbackTransport created with io_writer={io_writer}")
1822
+
1823
+ # NOTE: connection_made is NOT called here. It must be triggered
1824
+ # externally (e.g. by the Bridge) via the protocol methods once
1825
+ # the external connection is ready.
1826
+
1827
+ async def write_frame(self, frame: str, disable_tx_limits: bool = False) -> None:
1828
+ """Process a frame for transmission by passing it to the external writer.
1829
+
1830
+ :param frame: The frame to write.
1831
+ :type frame: str
1832
+ :param disable_tx_limits: Unused for this transport, kept for API compatibility.
1833
+ :type disable_tx_limits: bool, optional
1834
+ :raises exc.TransportError: If sending is disabled or the writer fails.
1835
+ """
1836
+ if self._disable_sending:
1837
+ raise exc.TransportError("Sending has been disabled")
1838
+
1839
+ # Section 6.1: Boundary Logging (Outgoing)
1840
+ _LOGGER.debug(f"Sending frame via external writer: {frame}")
1841
+
1842
+ try:
1843
+ await self._io_writer(frame)
1844
+ except Exception as err:
1845
+ _LOGGER.error(f"External writer failed to send frame: {err}")
1846
+ raise exc.TransportError(f"External writer failed: {err}") from err
1847
+
1848
+ async def _write_frame(self, frame: str) -> None:
1849
+ """Wait for the frame to be written by the external writer.
1850
+
1851
+ :param frame: The frame to write.
1852
+ :type frame: str
1853
+ """
1854
+ # Wrapper to satisfy abstract base class, though logic is in write_frame
1855
+ await self.write_frame(frame)
1856
+
1857
+ def receive_frame(self, frame: str, dtm: str | None = None) -> None:
1858
+ """Ingest a frame from the external source (Read Path).
1859
+
1860
+ This is the public method called by the Bridge to inject data.
1861
+
1862
+ :param frame: The raw frame string to receive.
1863
+ :type frame: str
1864
+ :param dtm: The timestamp of the frame, defaults to current time.
1865
+ :type dtm: str | None, optional
1866
+ """
1867
+ _LOGGER.debug(
1868
+ f"Received frame from external source: frame='{frame}', timestamp={dtm}"
1869
+ )
1870
+
1871
+ # Section 4.2: Circuit Breaker implementation (Packet gating)
1872
+ if not self._reading:
1873
+ _LOGGER.debug(f"Dropping received frame (transport paused): {repr(frame)}")
1874
+ return
1875
+
1876
+ dtm = dtm or dt_now().isoformat()
1877
+
1878
+ # Section 6.1: Boundary Logging (Incoming)
1879
+ _LOGGER.debug(
1880
+ f"Ingesting frame into transport: frame='{frame}', timestamp={dtm}"
1881
+ )
1882
+
1883
+ # Pass to the standard processing pipeline
1884
+ self._frame_read(dtm, frame.rstrip())
1885
+
1886
+
1481
1887
  def validate_topic_path(path: str) -> str:
1482
- """Test the topic path."""
1888
+ """Test the topic path and normalize it.
1889
+
1890
+ :param path: The candidate topic path.
1891
+ :type path: str
1892
+ :return: The valid, normalized path.
1893
+ :rtype: str
1894
+ :raises ValueError: If the path format is invalid.
1895
+ """
1483
1896
 
1484
1897
  # The user can supply the following paths:
1485
1898
  # - ""
@@ -1504,7 +1917,9 @@ def validate_topic_path(path: str) -> str:
1504
1917
  return new_path
1505
1918
 
1506
1919
 
1507
- RamsesTransportT: TypeAlias = FileTransport | MqttTransport | PortTransport
1920
+ RamsesTransportT: TypeAlias = (
1921
+ FileTransport | MqttTransport | PortTransport | CallbackTransport
1922
+ )
1508
1923
 
1509
1924
 
1510
1925
  async def transport_factory(
@@ -1515,13 +1930,48 @@ async def transport_factory(
1515
1930
  port_config: PortConfigT | None = None,
1516
1931
  packet_log: str | None = None,
1517
1932
  packet_dict: dict[str, str] | None = None,
1518
- disable_sending: bool | None = False,
1933
+ transport_constructor: Callable[..., Awaitable[RamsesTransportT]] | None = None,
1934
+ disable_sending: bool = False,
1519
1935
  extra: dict[str, Any] | None = None,
1520
1936
  loop: asyncio.AbstractEventLoop | None = None,
1521
1937
  log_all: bool = False,
1522
1938
  **kwargs: Any, # HACK: odd/misc params
1523
1939
  ) -> RamsesTransportT:
1524
- """Create and return a Ramses-specific async packet Transport."""
1940
+ """Create and return a Ramses-specific async packet Transport.
1941
+
1942
+ :param protocol: The protocol instance that will use this transport.
1943
+ :type protocol: RamsesProtocolT
1944
+ :param port_name: Serial port name or MQTT URL, defaults to None.
1945
+ :type port_name: SerPortNameT | None, optional
1946
+ :param port_config: Configuration dictionary for serial port, defaults to None.
1947
+ :type port_config: PortConfigT | None, optional
1948
+ :param packet_log: Path to a file containing packet logs for playback/parsing, defaults to None.
1949
+ :type packet_log: str | None, optional
1950
+ :param packet_dict: Dictionary of packets for playback, defaults to None.
1951
+ :type packet_dict: dict[str, str] | None, optional
1952
+ :param transport_constructor: Custom async callable to create a transport, defaults to None.
1953
+ :type transport_constructor: Callable[..., Awaitable[RamsesTransportT]] | None, optional
1954
+ :param disable_sending: If True, the transport will not transmit packets, defaults to False.
1955
+ :type disable_sending: bool | None, optional
1956
+ :param extra: Extra configuration options, defaults to None.
1957
+ :type extra: dict[str, Any] | None, optional
1958
+ :param loop: Asyncio event loop, defaults to None.
1959
+ :type loop: asyncio.AbstractEventLoop | None, optional
1960
+ :param log_all: If True, log all MQTT messages including non-protocol ones, defaults to False.
1961
+ :type log_all: bool, optional
1962
+ :param kwargs: Additional keyword arguments for specific transports.
1963
+ :type kwargs: Any
1964
+ :return: An instantiated RamsesTransportT object.
1965
+ :rtype: RamsesTransportT
1966
+ :raises exc.TransportSourceInvalid: If the packet source is invalid or multiple sources are specified.
1967
+ """
1968
+
1969
+ # If a constructor is provided, delegate entirely to it.
1970
+ if transport_constructor:
1971
+ _LOGGER.debug("transport_factory: Delegating to external transport_constructor")
1972
+ return await transport_constructor(
1973
+ protocol, disable_sending=disable_sending, extra=extra, **kwargs
1974
+ )
1525
1975
 
1526
1976
  # kwargs are specific to a transport. The above transports have:
1527
1977
  # evofw3_flag, use_regex
@@ -1532,6 +1982,14 @@ async def transport_factory(
1532
1982
  """Return a Serial instance for the given port name and config.
1533
1983
 
1534
1984
  May: raise TransportSourceInvalid("Unable to open serial port...")
1985
+
1986
+ :param ser_name: Name of the serial port.
1987
+ :type ser_name: SerPortNameT
1988
+ :param ser_config: Configuration for the serial port.
1989
+ :type ser_config: PortConfigT | None
1990
+ :return: Configured Serial object.
1991
+ :rtype: Serial
1992
+ :raises exc.TransportSourceInvalid: If the serial port cannot be opened.
1535
1993
  """
1536
1994
  # For example:
1537
1995
  # - python client.py monitor 'rfc2217://localhost:5001'
@@ -1566,6 +2024,9 @@ async def transport_factory(
1566
2024
  )
1567
2025
 
1568
2026
  if len([x for x in (packet_dict, packet_log, port_name) if x is not None]) != 1:
2027
+ _LOGGER.warning(
2028
+ f"Input: packet_dict: {packet_dict}, packet_log: {packet_log}, port_name: {port_name}"
2029
+ )
1569
2030
  raise exc.TransportSourceInvalid(
1570
2031
  "Packet source must be exactly one of: packet_dict, packet_log, port_name"
1571
2032
  )