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
last_inbound_at: float | None
async def start(self) -> None:
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")
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()
async def send(self, msg: jmri_loconet.messages.LocoNetMessage) -> None:
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.