jmri_openlcb.datagrams

OpenLCB Datagram Protocol (per S-9.7.4.1).

Datagrams are variable-length messages up to 72 bytes carried across 1..N CAN frames. Every datagram has one source node and one destination node; each direction must be acknowledged by a "Datagram Received OK" reply or rejected with "Datagram Rejected".

CAN header layout (29-bit ID, high bits first) for datagram frames::

bit 28       = 1  (extended CAN frame)
bits 24..27  = frame kind:
                 0x2  Complete datagram in one frame
                 0x3  First frame of a multi-frame datagram
                 0x4  Middle frame(s)
                 0x5  Final frame
bits 12..23  = destination node alias
bits 0..11   = source node alias

Reply MTIs use the global-messages framing (bits 24..27 = 0x1, bits 12..23 = 12-bit MTI). The two reply MTIs we care about are:

0x4C48  Datagram Received OK
0x4E48  Datagram Rejected

The engine below reads inbound frames from an ~jmri_openlcb.traffic.OlcbTrafficController via its frames ~jmri_core.events.Signal, reassembles them, and either exposes the resulting Datagram to callers via DatagramEngine.wait_for() or dispatches to a registered handler. Send side is symmetric: split the payload into 8-byte chunks, emit the right frame kinds, and (optionally) wait for the peer's Datagram Received OK reply.

  1"""OpenLCB Datagram Protocol (per S-9.7.4.1).
  2
  3Datagrams are variable-length messages up to 72 bytes carried across
  41..N CAN frames. Every datagram has one *source* node and one
  5*destination* node; each direction must be acknowledged by a
  6"Datagram Received OK" reply or rejected with "Datagram Rejected".
  7
  8CAN header layout (29-bit ID, high bits first) for datagram frames::
  9
 10    bit 28       = 1  (extended CAN frame)
 11    bits 24..27  = frame kind:
 12                     0x2  Complete datagram in one frame
 13                     0x3  First frame of a multi-frame datagram
 14                     0x4  Middle frame(s)
 15                     0x5  Final frame
 16    bits 12..23  = destination node alias
 17    bits 0..11   = source node alias
 18
 19Reply MTIs use the *global-messages* framing (bits 24..27 = 0x1,
 20bits 12..23 = 12-bit MTI). The two reply MTIs we care about are:
 21
 22    0x4C48  Datagram Received OK
 23    0x4E48  Datagram Rejected
 24
 25The engine below reads inbound frames from an
 26:class:`~jmri_openlcb.traffic.OlcbTrafficController` via its
 27``frames`` :class:`~jmri_core.events.Signal`, reassembles them, and
 28either exposes the resulting :class:`Datagram` to callers via
 29:meth:`DatagramEngine.wait_for` or dispatches to a registered
 30handler. Send side is symmetric: split the payload into 8-byte
 31chunks, emit the right frame kinds, and (optionally) wait for the
 32peer's Datagram Received OK reply.
 33"""
 34from __future__ import annotations
 35
 36import asyncio
 37import logging
 38from collections.abc import Awaitable, Callable
 39from dataclasses import dataclass, field
 40from typing import TYPE_CHECKING
 41
 42from jmri_core.events import Signal
 43
 44from jmri_openlcb.codec import OlcbFrame, encode_frame
 45
 46if TYPE_CHECKING:
 47    from jmri_openlcb.traffic import OlcbTrafficController
 48
 49_log = logging.getLogger(__name__)
 50
 51# ---- header helpers ----------------------------------------------------
 52
 53DATAGRAM_COMPLETE = 0x2
 54DATAGRAM_FIRST = 0x3
 55DATAGRAM_MIDDLE = 0x4
 56DATAGRAM_FINAL = 0x5
 57
 58# Global-message MTIs used for datagram replies (per S-9.7.4.1).
 59MTI_DATAGRAM_RECEIVED_OK = 0xA28
 60MTI_DATAGRAM_REJECTED = 0xA48
 61
 62# Max datagram body size — the standard caps at 72 bytes (9 frames of 8).
 63MAX_DATAGRAM_BYTES = 72
 64
 65
 66def _extract_frame_kind(header: int) -> int:
 67    """Bits 24..27 identify the frame kind."""
 68    return (header >> 24) & 0xF
 69
 70
 71def _extract_dest_alias(header: int) -> int:
 72    return (header >> 12) & 0xFFF
 73
 74
 75def _extract_source_alias(header: int) -> int:
 76    return header & 0xFFF
 77
 78
 79def _extract_mti(header: int) -> int:
 80    """The 12-bit MTI (bits 12..23), used for message-type frames.
 81
 82    Only meaningful when the frame kind (bits 24..27) is 0x1
 83    (OpenLCB global/addressed message). For datagram-kind frames the
 84    same bit range holds the destination alias, not an MTI.
 85    """
 86    return (header >> 12) & 0xFFF
 87
 88
 89def make_datagram_header(
 90    kind: int, dest_alias: int, source_alias: int,
 91) -> int:
 92    if kind not in (
 93        DATAGRAM_COMPLETE, DATAGRAM_FIRST,
 94        DATAGRAM_MIDDLE, DATAGRAM_FINAL,
 95    ):
 96        raise ValueError(f"invalid datagram frame kind: {kind:#x}")
 97    if not 0 <= dest_alias <= 0xFFF:
 98        raise ValueError(f"dest alias must be 12-bit: {dest_alias:#x}")
 99    if not 0 <= source_alias <= 0xFFF:
100        raise ValueError(f"source alias must be 12-bit: {source_alias:#x}")
101    return (
102        (1 << 28)
103        | (kind << 24)
104        | (dest_alias << 12)
105        | source_alias
106    )
107
108
109def make_reply_header(
110    mti: int, source_alias: int,
111) -> int:
112    """CAN header for a global/addressed message frame (frame kind 1).
113
114    For addressed messages, the destination alias travels in the
115    *data* payload (bytes 0..1) — the header itself only carries
116    the MTI + source alias.
117    """
118    return (
119        (1 << 28)
120        | (0x1 << 24)
121        | (mti << 12)
122        | source_alias
123    )
124
125
126def _encode_dest_prefix(dest_alias: int) -> bytes:
127    """The 2-byte destination-alias prefix that leads every addressed
128    message's data field."""
129    return bytes([(dest_alias >> 8) & 0xFF, dest_alias & 0xFF])
130
131
132def _decode_dest_alias_from_body(data: bytes) -> int | None:
133    """Peel the destination alias off the first two bytes of an
134    addressed-message body. Returns None when the frame doesn't
135    carry one (short frame, corrupted header)."""
136    if len(data) < 2:
137        return None
138    return ((data[0] & 0x0F) << 8) | data[1]
139
140
141# ---- dataclasses --------------------------------------------------------
142
143
144@dataclass(frozen=True, slots=True)
145class Datagram:
146    """A fully-reassembled datagram — the body plus the two node aliases."""
147
148    source_alias: int
149    dest_alias: int
150    data: bytes
151
152
153@dataclass(frozen=True, slots=True)
154class DatagramRejected(Exception):
155    """Raised when the peer replies with ``Datagram Rejected`` to a
156    :meth:`DatagramEngine.send` call. The 2-byte error code is
157    exposed so callers can distinguish "permanent" (bit 15 set)
158    from "temporary" and log accordingly."""
159
160    error_code: int
161
162    def __str__(self) -> str:  # pragma: no cover — trivial
163        return f"datagram rejected (code {self.error_code:#06x})"
164
165
166# ---- engine -------------------------------------------------------------
167
168
169@dataclass
170class _Pending:
171    """One in-flight outgoing datagram, waiting for the peer's reply."""
172
173    fut: asyncio.Future[None]
174    # The engine assumes one outstanding datagram per (src → dst)
175    # pair; the standard allows more but no known real node needs it
176    # and the extra bookkeeping is fragile.
177    dest_alias: int
178
179
180class DatagramEngine:
181    """Reassembles inbound datagrams, sends outbound ones with
182    fragmentation, and pairs sends with the peer's ``Received OK`` /
183    ``Rejected`` reply.
184
185    The reader hooks into an :class:`OlcbTrafficController`'s frames
186    signal via :meth:`attach`; the caller is responsible for
187    :meth:`detach` (or calling ``aclose``) at shutdown so the signal
188    listener doesn't outlive the engine.
189    """
190
191    def __init__(
192        self, tc: OlcbTrafficController,
193    ) -> None:
194        self._tc = tc
195        self._unsubscribe: Callable[[], None] | None = None
196        # Per-peer reassembly buffers keyed by source alias.
197        self._partial: dict[int, bytearray] = {}
198        # Pending sends keyed by destination alias.
199        self._pending: dict[int, _Pending] = {}
200        # Fires with every fully-received *inbound* datagram after the
201        # engine has already sent Received OK to the peer.
202        self.inbound: Signal[Datagram] = Signal()
203
204    async def attach(self) -> None:
205        if self._unsubscribe is None:
206            self._unsubscribe = self._tc.frames.connect(self._on_frame)
207
208    async def detach(self) -> None:
209        if self._unsubscribe is not None:
210            self._unsubscribe()
211            self._unsubscribe = None
212        # Fail any outstanding sends so callers don't hang forever.
213        for pending in list(self._pending.values()):
214            if not pending.fut.done():
215                pending.fut.set_exception(
216                    RuntimeError("datagram engine closed"),
217                )
218        self._pending.clear()
219        self._partial.clear()
220
221    # ---- send -----------------------------------------------------------
222
223    async def send(
224        self,
225        dest_alias: int,
226        data: bytes,
227        *,
228        timeout: float = 3.0,
229        wait_for_reply: bool = True,
230    ) -> None:
231        """Send *data* to *dest_alias*. Awaits the peer's Datagram
232        Received OK when ``wait_for_reply`` is True (default). Raises
233        :class:`DatagramRejected` on Rejected, :class:`TimeoutError`
234        on no reply, :class:`ValueError` on bad inputs.
235
236        Data over 8 bytes is fragmented into First / Middle... / Final
237        frames per S-9.7.4.1. Zero-byte payloads are permitted and
238        travel as a single Complete frame with an empty data field.
239        """
240        if not 0 <= dest_alias <= 0xFFF:
241            raise ValueError(f"dest alias must be 12-bit: {dest_alias:#x}")
242        if len(data) > MAX_DATAGRAM_BYTES:
243            raise ValueError(
244                f"datagram body {len(data)} exceeds "
245                f"{MAX_DATAGRAM_BYTES}-byte cap",
246            )
247        if dest_alias in self._pending:
248            raise RuntimeError(
249                f"a datagram to alias {dest_alias:#x} is already in flight",
250            )
251
252        source_alias = self._tc.source_alias
253
254        # Arm the reply-waiter BEFORE sending so a very fast peer
255        # can't beat us to it. Skipped when the caller opts out.
256        pending: _Pending | None = None
257        if wait_for_reply:
258            loop = asyncio.get_event_loop()
259            pending = _Pending(fut=loop.create_future(), dest_alias=dest_alias)
260            self._pending[dest_alias] = pending
261
262        try:
263            await self._transmit(source_alias, dest_alias, data)
264            if pending is not None:
265                await asyncio.wait_for(pending.fut, timeout=timeout)
266        finally:
267            if pending is not None:
268                self._pending.pop(dest_alias, None)
269
270    async def _transmit(
271        self, source_alias: int, dest_alias: int, data: bytes,
272    ) -> None:
273        """Write out the datagram frames. Single Complete when body ≤ 8,
274        First + N × Middle + Final otherwise."""
275        if len(data) <= 8:
276            # Zero-length datagrams still go on the wire as a Complete
277            # frame — the protocol permits them even if no real
278            # sub-protocol uses them.
279            frame = OlcbFrame(
280                header=make_datagram_header(
281                    DATAGRAM_COMPLETE, dest_alias, source_alias,
282                ),
283                data=data,
284            )
285            await self._tc.send_frame(frame)
286            return
287
288        # Multi-frame: First, then Middle...Middle, then Final.
289        chunks: list[bytes] = [
290            data[i:i + 8] for i in range(0, len(data), 8)
291        ]
292        for i, chunk in enumerate(chunks):
293            if i == 0:
294                kind = DATAGRAM_FIRST
295            elif i == len(chunks) - 1:
296                kind = DATAGRAM_FINAL
297            else:
298                kind = DATAGRAM_MIDDLE
299            frame = OlcbFrame(
300                header=make_datagram_header(kind, dest_alias, source_alias),
301                data=chunk,
302            )
303            await self._tc.send_frame(frame)
304
305    # ---- receive --------------------------------------------------------
306
307    async def _on_frame(self, frame: OlcbFrame) -> None:
308        kind = _extract_frame_kind(frame.header)
309        if kind in (
310            DATAGRAM_COMPLETE, DATAGRAM_FIRST,
311            DATAGRAM_MIDDLE, DATAGRAM_FINAL,
312        ):
313            await self._handle_datagram_frame(kind, frame)
314            return
315        # Not a datagram frame — is it a reply to something WE sent?
316        if kind == 0x1:
317            mti = _extract_mti(frame.header)
318            if mti in (MTI_DATAGRAM_RECEIVED_OK, MTI_DATAGRAM_REJECTED):
319                await self._handle_reply(mti, frame)
320
321    async def _handle_datagram_frame(
322        self, kind: int, frame: OlcbFrame,
323    ) -> None:
324        dest_alias = _extract_dest_alias(frame.header)
325        source_alias = _extract_source_alias(frame.header)
326
327        # We accept datagrams only when they're addressed to our own
328        # alias — the same rule real hardware follows. Everything else
329        # is on the bus but not for us.
330        if dest_alias != self._tc.source_alias:
331            return
332
333        if kind == DATAGRAM_COMPLETE:
334            body = bytes(frame.data)
335            await self._deliver(source_alias, dest_alias, body)
336            return
337
338        buf = self._partial.setdefault(source_alias, bytearray())
339        if kind == DATAGRAM_FIRST:
340            # Reset any dangling partial (peer aborted a previous send).
341            buf.clear()
342            buf.extend(frame.data)
343            return
344        if kind == DATAGRAM_MIDDLE:
345            buf.extend(frame.data)
346            return
347        if kind == DATAGRAM_FINAL:
348            buf.extend(frame.data)
349            body = bytes(buf)
350            self._partial.pop(source_alias, None)
351            await self._deliver(source_alias, dest_alias, body)
352
353    async def _deliver(
354        self, source_alias: int, dest_alias: int, body: bytes,
355    ) -> None:
356        # Send Received OK before firing our own signal so the reply
357        # is on the wire even if a subscriber raises.
358        await self._send_received_ok(source_alias)
359        dg = Datagram(
360            source_alias=source_alias,
361            dest_alias=dest_alias,
362            data=body,
363        )
364        await self.inbound.emit(dg)
365
366    async def _send_received_ok(self, dest_alias: int) -> None:
367        frame = OlcbFrame(
368            header=make_reply_header(
369                MTI_DATAGRAM_RECEIVED_OK,
370                self._tc.source_alias,
371            ),
372            # Addressed messages carry the destination alias in the
373            # first two body bytes, followed by protocol-specific
374            # fields (here: a single 0x00 = "no reply pending").
375            data=_encode_dest_prefix(dest_alias) + b"\x00",
376        )
377        await self._tc.send_frame(frame)
378
379    async def _handle_reply(self, mti: int, frame: OlcbFrame) -> None:
380        # The reply's *source* is the peer we sent to. Match on that.
381        peer_alias = _extract_source_alias(frame.header)
382        # For addressed messages, the destination alias travels in
383        # the first 2 body bytes, not the header.
384        addressed_dest = _decode_dest_alias_from_body(frame.data)
385        if addressed_dest != self._tc.source_alias:
386            return
387        pending = self._pending.get(peer_alias)
388        if pending is None or pending.fut.done():
389            return
390        # The status/error bytes follow the 2-byte destination
391        # prefix. Received OK carries one byte (0x00 or reply-pending
392        # flag); Rejected carries the 2-byte error code.
393        body = frame.data[2:]
394        if mti == MTI_DATAGRAM_RECEIVED_OK:
395            pending.fut.set_result(None)
396        else:  # MTI_DATAGRAM_REJECTED
397            code = 0
398            if len(body) >= 2:
399                code = (body[0] << 8) | body[1]
400            pending.fut.set_exception(DatagramRejected(error_code=code))
DATAGRAM_COMPLETE = 2
DATAGRAM_FIRST = 3
DATAGRAM_MIDDLE = 4
DATAGRAM_FINAL = 5
MTI_DATAGRAM_RECEIVED_OK = 2600
MTI_DATAGRAM_REJECTED = 2632
MAX_DATAGRAM_BYTES = 72
def make_datagram_header(kind: int, dest_alias: int, source_alias: int) -> int:
 90def make_datagram_header(
 91    kind: int, dest_alias: int, source_alias: int,
 92) -> int:
 93    if kind not in (
 94        DATAGRAM_COMPLETE, DATAGRAM_FIRST,
 95        DATAGRAM_MIDDLE, DATAGRAM_FINAL,
 96    ):
 97        raise ValueError(f"invalid datagram frame kind: {kind:#x}")
 98    if not 0 <= dest_alias <= 0xFFF:
 99        raise ValueError(f"dest alias must be 12-bit: {dest_alias:#x}")
100    if not 0 <= source_alias <= 0xFFF:
101        raise ValueError(f"source alias must be 12-bit: {source_alias:#x}")
102    return (
103        (1 << 28)
104        | (kind << 24)
105        | (dest_alias << 12)
106        | source_alias
107    )
def make_reply_header(mti: int, source_alias: int) -> int:
110def make_reply_header(
111    mti: int, source_alias: int,
112) -> int:
113    """CAN header for a global/addressed message frame (frame kind 1).
114
115    For addressed messages, the destination alias travels in the
116    *data* payload (bytes 0..1) — the header itself only carries
117    the MTI + source alias.
118    """
119    return (
120        (1 << 28)
121        | (0x1 << 24)
122        | (mti << 12)
123        | source_alias
124    )

CAN header for a global/addressed message frame (frame kind 1).

For addressed messages, the destination alias travels in the data payload (bytes 0..1) — the header itself only carries the MTI + source alias.

@dataclass(frozen=True, slots=True)
class Datagram:
145@dataclass(frozen=True, slots=True)
146class Datagram:
147    """A fully-reassembled datagram — the body plus the two node aliases."""
148
149    source_alias: int
150    dest_alias: int
151    data: bytes

A fully-reassembled datagram — the body plus the two node aliases.

Datagram(source_alias: int, dest_alias: int, data: bytes)
source_alias: int
dest_alias: int
data: bytes
@dataclass(frozen=True, slots=True)
class DatagramRejected(builtins.Exception):
154@dataclass(frozen=True, slots=True)
155class DatagramRejected(Exception):
156    """Raised when the peer replies with ``Datagram Rejected`` to a
157    :meth:`DatagramEngine.send` call. The 2-byte error code is
158    exposed so callers can distinguish "permanent" (bit 15 set)
159    from "temporary" and log accordingly."""
160
161    error_code: int
162
163    def __str__(self) -> str:  # pragma: no cover — trivial
164        return f"datagram rejected (code {self.error_code:#06x})"

Raised when the peer replies with Datagram Rejected to a DatagramEngine.send() call. The 2-byte error code is exposed so callers can distinguish "permanent" (bit 15 set) from "temporary" and log accordingly.

DatagramRejected(error_code: int)
error_code: int
class DatagramEngine:
181class DatagramEngine:
182    """Reassembles inbound datagrams, sends outbound ones with
183    fragmentation, and pairs sends with the peer's ``Received OK`` /
184    ``Rejected`` reply.
185
186    The reader hooks into an :class:`OlcbTrafficController`'s frames
187    signal via :meth:`attach`; the caller is responsible for
188    :meth:`detach` (or calling ``aclose``) at shutdown so the signal
189    listener doesn't outlive the engine.
190    """
191
192    def __init__(
193        self, tc: OlcbTrafficController,
194    ) -> None:
195        self._tc = tc
196        self._unsubscribe: Callable[[], None] | None = None
197        # Per-peer reassembly buffers keyed by source alias.
198        self._partial: dict[int, bytearray] = {}
199        # Pending sends keyed by destination alias.
200        self._pending: dict[int, _Pending] = {}
201        # Fires with every fully-received *inbound* datagram after the
202        # engine has already sent Received OK to the peer.
203        self.inbound: Signal[Datagram] = Signal()
204
205    async def attach(self) -> None:
206        if self._unsubscribe is None:
207            self._unsubscribe = self._tc.frames.connect(self._on_frame)
208
209    async def detach(self) -> None:
210        if self._unsubscribe is not None:
211            self._unsubscribe()
212            self._unsubscribe = None
213        # Fail any outstanding sends so callers don't hang forever.
214        for pending in list(self._pending.values()):
215            if not pending.fut.done():
216                pending.fut.set_exception(
217                    RuntimeError("datagram engine closed"),
218                )
219        self._pending.clear()
220        self._partial.clear()
221
222    # ---- send -----------------------------------------------------------
223
224    async def send(
225        self,
226        dest_alias: int,
227        data: bytes,
228        *,
229        timeout: float = 3.0,
230        wait_for_reply: bool = True,
231    ) -> None:
232        """Send *data* to *dest_alias*. Awaits the peer's Datagram
233        Received OK when ``wait_for_reply`` is True (default). Raises
234        :class:`DatagramRejected` on Rejected, :class:`TimeoutError`
235        on no reply, :class:`ValueError` on bad inputs.
236
237        Data over 8 bytes is fragmented into First / Middle... / Final
238        frames per S-9.7.4.1. Zero-byte payloads are permitted and
239        travel as a single Complete frame with an empty data field.
240        """
241        if not 0 <= dest_alias <= 0xFFF:
242            raise ValueError(f"dest alias must be 12-bit: {dest_alias:#x}")
243        if len(data) > MAX_DATAGRAM_BYTES:
244            raise ValueError(
245                f"datagram body {len(data)} exceeds "
246                f"{MAX_DATAGRAM_BYTES}-byte cap",
247            )
248        if dest_alias in self._pending:
249            raise RuntimeError(
250                f"a datagram to alias {dest_alias:#x} is already in flight",
251            )
252
253        source_alias = self._tc.source_alias
254
255        # Arm the reply-waiter BEFORE sending so a very fast peer
256        # can't beat us to it. Skipped when the caller opts out.
257        pending: _Pending | None = None
258        if wait_for_reply:
259            loop = asyncio.get_event_loop()
260            pending = _Pending(fut=loop.create_future(), dest_alias=dest_alias)
261            self._pending[dest_alias] = pending
262
263        try:
264            await self._transmit(source_alias, dest_alias, data)
265            if pending is not None:
266                await asyncio.wait_for(pending.fut, timeout=timeout)
267        finally:
268            if pending is not None:
269                self._pending.pop(dest_alias, None)
270
271    async def _transmit(
272        self, source_alias: int, dest_alias: int, data: bytes,
273    ) -> None:
274        """Write out the datagram frames. Single Complete when body ≤ 8,
275        First + N × Middle + Final otherwise."""
276        if len(data) <= 8:
277            # Zero-length datagrams still go on the wire as a Complete
278            # frame — the protocol permits them even if no real
279            # sub-protocol uses them.
280            frame = OlcbFrame(
281                header=make_datagram_header(
282                    DATAGRAM_COMPLETE, dest_alias, source_alias,
283                ),
284                data=data,
285            )
286            await self._tc.send_frame(frame)
287            return
288
289        # Multi-frame: First, then Middle...Middle, then Final.
290        chunks: list[bytes] = [
291            data[i:i + 8] for i in range(0, len(data), 8)
292        ]
293        for i, chunk in enumerate(chunks):
294            if i == 0:
295                kind = DATAGRAM_FIRST
296            elif i == len(chunks) - 1:
297                kind = DATAGRAM_FINAL
298            else:
299                kind = DATAGRAM_MIDDLE
300            frame = OlcbFrame(
301                header=make_datagram_header(kind, dest_alias, source_alias),
302                data=chunk,
303            )
304            await self._tc.send_frame(frame)
305
306    # ---- receive --------------------------------------------------------
307
308    async def _on_frame(self, frame: OlcbFrame) -> None:
309        kind = _extract_frame_kind(frame.header)
310        if kind in (
311            DATAGRAM_COMPLETE, DATAGRAM_FIRST,
312            DATAGRAM_MIDDLE, DATAGRAM_FINAL,
313        ):
314            await self._handle_datagram_frame(kind, frame)
315            return
316        # Not a datagram frame — is it a reply to something WE sent?
317        if kind == 0x1:
318            mti = _extract_mti(frame.header)
319            if mti in (MTI_DATAGRAM_RECEIVED_OK, MTI_DATAGRAM_REJECTED):
320                await self._handle_reply(mti, frame)
321
322    async def _handle_datagram_frame(
323        self, kind: int, frame: OlcbFrame,
324    ) -> None:
325        dest_alias = _extract_dest_alias(frame.header)
326        source_alias = _extract_source_alias(frame.header)
327
328        # We accept datagrams only when they're addressed to our own
329        # alias — the same rule real hardware follows. Everything else
330        # is on the bus but not for us.
331        if dest_alias != self._tc.source_alias:
332            return
333
334        if kind == DATAGRAM_COMPLETE:
335            body = bytes(frame.data)
336            await self._deliver(source_alias, dest_alias, body)
337            return
338
339        buf = self._partial.setdefault(source_alias, bytearray())
340        if kind == DATAGRAM_FIRST:
341            # Reset any dangling partial (peer aborted a previous send).
342            buf.clear()
343            buf.extend(frame.data)
344            return
345        if kind == DATAGRAM_MIDDLE:
346            buf.extend(frame.data)
347            return
348        if kind == DATAGRAM_FINAL:
349            buf.extend(frame.data)
350            body = bytes(buf)
351            self._partial.pop(source_alias, None)
352            await self._deliver(source_alias, dest_alias, body)
353
354    async def _deliver(
355        self, source_alias: int, dest_alias: int, body: bytes,
356    ) -> None:
357        # Send Received OK before firing our own signal so the reply
358        # is on the wire even if a subscriber raises.
359        await self._send_received_ok(source_alias)
360        dg = Datagram(
361            source_alias=source_alias,
362            dest_alias=dest_alias,
363            data=body,
364        )
365        await self.inbound.emit(dg)
366
367    async def _send_received_ok(self, dest_alias: int) -> None:
368        frame = OlcbFrame(
369            header=make_reply_header(
370                MTI_DATAGRAM_RECEIVED_OK,
371                self._tc.source_alias,
372            ),
373            # Addressed messages carry the destination alias in the
374            # first two body bytes, followed by protocol-specific
375            # fields (here: a single 0x00 = "no reply pending").
376            data=_encode_dest_prefix(dest_alias) + b"\x00",
377        )
378        await self._tc.send_frame(frame)
379
380    async def _handle_reply(self, mti: int, frame: OlcbFrame) -> None:
381        # The reply's *source* is the peer we sent to. Match on that.
382        peer_alias = _extract_source_alias(frame.header)
383        # For addressed messages, the destination alias travels in
384        # the first 2 body bytes, not the header.
385        addressed_dest = _decode_dest_alias_from_body(frame.data)
386        if addressed_dest != self._tc.source_alias:
387            return
388        pending = self._pending.get(peer_alias)
389        if pending is None or pending.fut.done():
390            return
391        # The status/error bytes follow the 2-byte destination
392        # prefix. Received OK carries one byte (0x00 or reply-pending
393        # flag); Rejected carries the 2-byte error code.
394        body = frame.data[2:]
395        if mti == MTI_DATAGRAM_RECEIVED_OK:
396            pending.fut.set_result(None)
397        else:  # MTI_DATAGRAM_REJECTED
398            code = 0
399            if len(body) >= 2:
400                code = (body[0] << 8) | body[1]
401            pending.fut.set_exception(DatagramRejected(error_code=code))

Reassembles inbound datagrams, sends outbound ones with fragmentation, and pairs sends with the peer's Received OK / Rejected reply.

The reader hooks into an OlcbTrafficController's frames signal via attach(); the caller is responsible for detach() (or calling aclose) at shutdown so the signal listener doesn't outlive the engine.

DatagramEngine(tc: jmri_openlcb.OlcbTrafficController)
192    def __init__(
193        self, tc: OlcbTrafficController,
194    ) -> None:
195        self._tc = tc
196        self._unsubscribe: Callable[[], None] | None = None
197        # Per-peer reassembly buffers keyed by source alias.
198        self._partial: dict[int, bytearray] = {}
199        # Pending sends keyed by destination alias.
200        self._pending: dict[int, _Pending] = {}
201        # Fires with every fully-received *inbound* datagram after the
202        # engine has already sent Received OK to the peer.
203        self.inbound: Signal[Datagram] = Signal()
async def attach(self) -> None:
205    async def attach(self) -> None:
206        if self._unsubscribe is None:
207            self._unsubscribe = self._tc.frames.connect(self._on_frame)
async def detach(self) -> None:
209    async def detach(self) -> None:
210        if self._unsubscribe is not None:
211            self._unsubscribe()
212            self._unsubscribe = None
213        # Fail any outstanding sends so callers don't hang forever.
214        for pending in list(self._pending.values()):
215            if not pending.fut.done():
216                pending.fut.set_exception(
217                    RuntimeError("datagram engine closed"),
218                )
219        self._pending.clear()
220        self._partial.clear()
async def send( self, dest_alias: int, data: bytes, *, timeout: float = 3.0, wait_for_reply: bool = True) -> None:
224    async def send(
225        self,
226        dest_alias: int,
227        data: bytes,
228        *,
229        timeout: float = 3.0,
230        wait_for_reply: bool = True,
231    ) -> None:
232        """Send *data* to *dest_alias*. Awaits the peer's Datagram
233        Received OK when ``wait_for_reply`` is True (default). Raises
234        :class:`DatagramRejected` on Rejected, :class:`TimeoutError`
235        on no reply, :class:`ValueError` on bad inputs.
236
237        Data over 8 bytes is fragmented into First / Middle... / Final
238        frames per S-9.7.4.1. Zero-byte payloads are permitted and
239        travel as a single Complete frame with an empty data field.
240        """
241        if not 0 <= dest_alias <= 0xFFF:
242            raise ValueError(f"dest alias must be 12-bit: {dest_alias:#x}")
243        if len(data) > MAX_DATAGRAM_BYTES:
244            raise ValueError(
245                f"datagram body {len(data)} exceeds "
246                f"{MAX_DATAGRAM_BYTES}-byte cap",
247            )
248        if dest_alias in self._pending:
249            raise RuntimeError(
250                f"a datagram to alias {dest_alias:#x} is already in flight",
251            )
252
253        source_alias = self._tc.source_alias
254
255        # Arm the reply-waiter BEFORE sending so a very fast peer
256        # can't beat us to it. Skipped when the caller opts out.
257        pending: _Pending | None = None
258        if wait_for_reply:
259            loop = asyncio.get_event_loop()
260            pending = _Pending(fut=loop.create_future(), dest_alias=dest_alias)
261            self._pending[dest_alias] = pending
262
263        try:
264            await self._transmit(source_alias, dest_alias, data)
265            if pending is not None:
266                await asyncio.wait_for(pending.fut, timeout=timeout)
267        finally:
268            if pending is not None:
269                self._pending.pop(dest_alias, None)

Send data to dest_alias. Awaits the peer's Datagram Received OK when wait_for_reply is True (default). Raises DatagramRejected on Rejected, TimeoutError on no reply, ValueError on bad inputs.

Data over 8 bytes is fragmented into First / Middle... / Final frames per S-9.7.4.1. Zero-byte payloads are permitted and travel as a single Complete frame with an empty data field.