jmri_loconet.traffic
LocoNetTrafficController — async read loop + send queue fan-out.
Owns one LocoNetPort and a background task that reads complete
frames, decodes them, and emits the typed message on the inbound Signal.
The send() method serializes a typed message to bytes, attaches the
checksum, and writes to the port.
LocoNet runs at ~16.6 kbaud, so back-to-back writes need a small interframe
gap; this is configurable via min_send_interval.
1"""``LocoNetTrafficController`` — async read loop + send queue fan-out. 2 3Owns one :class:`LocoNetPort` and a background task that reads complete 4frames, decodes them, and emits the typed message on the ``inbound`` Signal. 5The :meth:`send` method serializes a typed message to bytes, attaches the 6checksum, and writes to the port. 7 8LocoNet runs at ~16.6 kbaud, so back-to-back writes need a small interframe 9gap; this is configurable via ``min_send_interval``. 10""" 11from __future__ import annotations 12 13import asyncio 14import logging 15import time 16 17from jmri_core.events import Signal 18 19from jmri_loconet.codec import frame_length, read_frame 20from jmri_loconet.messages import LocoNetMessage, decode, encode 21from jmri_loconet.transport.port import LocoNetPort 22 23_log = logging.getLogger(__name__) 24 25 26class LocoNetTrafficController: 27 """The protocol-level conduit between transport bytes and typed messages. 28 29 Listeners connect to :attr:`inbound`; senders call :meth:`send`. The 30 read loop runs as a background asyncio task started by :meth:`start` 31 and cancelled by :meth:`aclose`. 32 """ 33 34 def __init__( 35 self, 36 port: LocoNetPort, 37 *, 38 min_send_interval: float = 0.005, 39 ) -> None: 40 self._port = port 41 self._min_send_interval = min_send_interval 42 self._send_lock = asyncio.Lock() 43 self._last_send: float = 0.0 44 self._read_task: asyncio.Task[None] | None = None 45 self.inbound: Signal[LocoNetMessage] = Signal() 46 self._closed = False 47 # Timestamp (``time.monotonic()``) of the most recent inbound 48 # frame. Read by :class:`LocoNetConnection`'s reachability 49 # monitor to flip CS-power state. LocoNet is chatty — the CS 50 # emits ``OPC_IDLE`` and other frames periodically — so a 51 # multi-second silence is a strong signal the bus is dark. 52 self.last_inbound_at: float | None = None 53 54 async def start(self) -> None: 55 if self._read_task is not None: 56 return 57 self._read_task = asyncio.create_task(self._read_loop(), name="loconet-read") 58 59 async def aclose(self) -> None: 60 self._closed = True 61 if self._read_task is not None: 62 self._read_task.cancel() 63 import contextlib # noqa: PLC0415 64 with contextlib.suppress(asyncio.CancelledError, Exception): 65 await self._read_task 66 self._read_task = None 67 await self._port.aclose() 68 69 async def send(self, msg: LocoNetMessage) -> None: 70 """Encode and write *msg* to the port, with interframe pacing.""" 71 frame = encode(msg) 72 async with self._send_lock: 73 now = asyncio.get_event_loop().time() 74 wait = self._min_send_interval - (now - self._last_send) 75 if wait > 0: 76 await asyncio.sleep(wait) 77 await self._port.write_frame(frame) 78 self._last_send = asyncio.get_event_loop().time() 79 80 # ----- internals -------------------------------------------------------- 81 82 async def _read_loop(self) -> None: 83 try: 84 while not self._closed: 85 try: 86 frame = await read_frame( 87 self._port.read_byte, self._port.read_exact 88 ) 89 except asyncio.CancelledError: 90 return 91 except Exception as e: # noqa: BLE001 92 _log.warning("LocoNet read error: %s", e) 93 continue 94 95 expected = frame_length(frame) 96 if expected is None or expected != len(frame): 97 _log.warning( 98 "LocoNet frame length mismatch: got %d bytes for %s", 99 len(frame), frame.hex() 100 ) 101 continue 102 103 try: 104 msg = decode(frame) 105 except ValueError as e: 106 _log.warning("LocoNet bad frame %s: %s", frame.hex(), e) 107 continue 108 109 # Any well-formed inbound frame counts as evidence the 110 # CS is alive — even a bare OPC_IDLE. The reachability 111 # monitor watches this timestamp. 112 self.last_inbound_at = time.monotonic() 113 await self.inbound.emit(msg) 114 except asyncio.CancelledError: 115 return
class
LocoNetTrafficController:
27class LocoNetTrafficController: 28 """The protocol-level conduit between transport bytes and typed messages. 29 30 Listeners connect to :attr:`inbound`; senders call :meth:`send`. The 31 read loop runs as a background asyncio task started by :meth:`start` 32 and cancelled by :meth:`aclose`. 33 """ 34 35 def __init__( 36 self, 37 port: LocoNetPort, 38 *, 39 min_send_interval: float = 0.005, 40 ) -> None: 41 self._port = port 42 self._min_send_interval = min_send_interval 43 self._send_lock = asyncio.Lock() 44 self._last_send: float = 0.0 45 self._read_task: asyncio.Task[None] | None = None 46 self.inbound: Signal[LocoNetMessage] = Signal() 47 self._closed = False 48 # Timestamp (``time.monotonic()``) of the most recent inbound 49 # frame. Read by :class:`LocoNetConnection`'s reachability 50 # monitor to flip CS-power state. LocoNet is chatty — the CS 51 # emits ``OPC_IDLE`` and other frames periodically — so a 52 # multi-second silence is a strong signal the bus is dark. 53 self.last_inbound_at: float | None = None 54 55 async def start(self) -> None: 56 if self._read_task is not None: 57 return 58 self._read_task = asyncio.create_task(self._read_loop(), name="loconet-read") 59 60 async def aclose(self) -> None: 61 self._closed = True 62 if self._read_task is not None: 63 self._read_task.cancel() 64 import contextlib # noqa: PLC0415 65 with contextlib.suppress(asyncio.CancelledError, Exception): 66 await self._read_task 67 self._read_task = None 68 await self._port.aclose() 69 70 async def send(self, msg: LocoNetMessage) -> None: 71 """Encode and write *msg* to the port, with interframe pacing.""" 72 frame = encode(msg) 73 async with self._send_lock: 74 now = asyncio.get_event_loop().time() 75 wait = self._min_send_interval - (now - self._last_send) 76 if wait > 0: 77 await asyncio.sleep(wait) 78 await self._port.write_frame(frame) 79 self._last_send = asyncio.get_event_loop().time() 80 81 # ----- internals -------------------------------------------------------- 82 83 async def _read_loop(self) -> None: 84 try: 85 while not self._closed: 86 try: 87 frame = await read_frame( 88 self._port.read_byte, self._port.read_exact 89 ) 90 except asyncio.CancelledError: 91 return 92 except Exception as e: # noqa: BLE001 93 _log.warning("LocoNet read error: %s", e) 94 continue 95 96 expected = frame_length(frame) 97 if expected is None or expected != len(frame): 98 _log.warning( 99 "LocoNet frame length mismatch: got %d bytes for %s", 100 len(frame), frame.hex() 101 ) 102 continue 103 104 try: 105 msg = decode(frame) 106 except ValueError as e: 107 _log.warning("LocoNet bad frame %s: %s", frame.hex(), e) 108 continue 109 110 # Any well-formed inbound frame counts as evidence the 111 # CS is alive — even a bare OPC_IDLE. The reachability 112 # monitor watches this timestamp. 113 self.last_inbound_at = time.monotonic() 114 await self.inbound.emit(msg) 115 except asyncio.CancelledError: 116 return
The protocol-level conduit between transport bytes and typed messages.
Listeners connect to inbound; senders call send(). The
read loop runs as a background asyncio task started by start()
and cancelled by aclose().
LocoNetTrafficController( port: jmri_loconet.LocoNetPort, *, min_send_interval: float = 0.005)
35 def __init__( 36 self, 37 port: LocoNetPort, 38 *, 39 min_send_interval: float = 0.005, 40 ) -> None: 41 self._port = port 42 self._min_send_interval = min_send_interval 43 self._send_lock = asyncio.Lock() 44 self._last_send: float = 0.0 45 self._read_task: asyncio.Task[None] | None = None 46 self.inbound: Signal[LocoNetMessage] = Signal() 47 self._closed = False 48 # Timestamp (``time.monotonic()``) of the most recent inbound 49 # frame. Read by :class:`LocoNetConnection`'s reachability 50 # monitor to flip CS-power state. LocoNet is chatty — the CS 51 # emits ``OPC_IDLE`` and other frames periodically — so a 52 # multi-second silence is a strong signal the bus is dark. 53 self.last_inbound_at: float | None = None
async def
aclose(self) -> None:
60 async def aclose(self) -> None: 61 self._closed = True 62 if self._read_task is not None: 63 self._read_task.cancel() 64 import contextlib # noqa: PLC0415 65 with contextlib.suppress(asyncio.CancelledError, Exception): 66 await self._read_task 67 self._read_task = None 68 await self._port.aclose()
70 async def send(self, msg: LocoNetMessage) -> None: 71 """Encode and write *msg* to the port, with interframe pacing.""" 72 frame = encode(msg) 73 async with self._send_lock: 74 now = asyncio.get_event_loop().time() 75 wait = self._min_send_interval - (now - self._last_send) 76 if wait > 0: 77 await asyncio.sleep(wait) 78 await self._port.write_frame(frame) 79 self._last_send = asyncio.get_event_loop().time()
Encode and write msg to the port, with interframe pacing.