ramses-rf 0.52.5__py3-none-any.whl → 0.53.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ramses_cli/client.py +167 -54
- ramses_cli/py.typed +0 -0
- ramses_rf/__init__.py +2 -0
- ramses_rf/entity_base.py +3 -1
- ramses_rf/gateway.py +203 -27
- ramses_rf/schemas.py +1 -0
- ramses_rf/version.py +1 -1
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.0.dist-info}/METADATA +1 -1
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.0.dist-info}/RECORD +19 -18
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.0.dist-info}/licenses/LICENSE +1 -1
- ramses_tx/command.py +1 -1
- ramses_tx/const.py +110 -23
- ramses_tx/protocol.py +22 -8
- ramses_tx/protocol_fsm.py +28 -10
- ramses_tx/schemas.py +2 -2
- ramses_tx/transport.py +499 -41
- ramses_tx/version.py +1 -1
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.0.dist-info}/WHEEL +0 -0
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.0.dist-info}/entry_points.txt +0 -0
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
|
-
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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:
|
|
302
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
+
"""Return 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(
|
|
993
|
-
elif _LOGGER.getEffectiveLevel()
|
|
994
|
-
_LOGGER.info(
|
|
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 =
|
|
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
|
-
|
|
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, 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'
|