jmri_ncemonitor.codec

NCE Cab Bus framing — read 9-bit-marker framed messages.

The Cab Bus is a polled RS-485 bus operating at 9600 baud where the command station addresses each cab in turn. Production hardware uses 9-bit serial; some USB-serial adapters can't deliver the 9th bit, so both JMRI and we use a "high bit set = address byte" wire convention when reading from such adapters. We adopt the same convention here:

  • If byte & 0x80 != 0 → this byte is the address and starts a new message frame. The low 7 bits are the cab ID (or master ID).
  • All following bytes (up to a per-address length) are 7-bit data.

This module is byte-stream level only; the message-level decoding lives in jmri_ncemonitor.messages.

  1"""NCE Cab Bus framing — read 9-bit-marker framed messages.
  2
  3The Cab Bus is a polled RS-485 bus operating at 9600 baud where the
  4command station addresses each cab in turn. Production hardware uses
  59-bit serial; some USB-serial adapters can't deliver the 9th bit, so
  6both JMRI and we use a "high bit set = address byte" wire convention
  7when reading from such adapters. We adopt the same convention here:
  8
  9* If ``byte & 0x80 != 0`` → this byte is the *address* and starts a new
 10  message frame. The low 7 bits are the cab ID (or master ID).
 11* All following bytes (up to a per-address length) are 7-bit data.
 12
 13This module is byte-stream level only; the message-level decoding lives
 14in :mod:`jmri_ncemonitor.messages`.
 15"""
 16from __future__ import annotations
 17
 18from collections.abc import Awaitable, Callable
 19
 20
 21def is_address_byte(b: int) -> bool:
 22    """True if *b* is an address byte (high bit set)."""
 23    return (b & 0x80) != 0
 24
 25
 26def address_of(b: int) -> int:
 27    """Extract the 7-bit address from an address byte."""
 28    return b & 0x7F
 29
 30
 31class PeekableReader:
 32    """Small wrapper around an async ``read_byte`` to support a 1-byte pushback.
 33
 34    The Cab Bus message boundary is "first byte with high bit set"; we
 35    don't know we've crossed it until we read the byte. ``PeekableReader``
 36    lets the codec push back that boundary byte for the next call to see.
 37    """
 38
 39    def __init__(self, read_byte: Callable[[], Awaitable[int]]) -> None:
 40        self._read = read_byte
 41        self._buf: int | None = None
 42
 43    def push_back(self, b: int) -> None:
 44        if self._buf is not None:
 45            raise RuntimeError("PeekableReader already has a pushed-back byte")
 46        self._buf = b
 47
 48    async def read_byte(self) -> int:
 49        if self._buf is not None:
 50            b, self._buf = self._buf, None
 51            return b
 52        return await self._read()
 53
 54
 55# Longest legitimate Cab Bus frame is ~10 bytes (address + 9-byte cab
 56# response). Anything past 32 bytes without a boundary is a stuck bus
 57# or a wildly misparsed stream — cap so we don't grow the buffer
 58# unboundedly on a jammed link and eventually raise cleanly.
 59MAX_BODY_BYTES = 32
 60
 61
 62class BodyOverflowError(RuntimeError):
 63    """Raised when a single frame's body exceeds :data:`MAX_BODY_BYTES`.
 64
 65    The monitor's read loop logs this at WARNING and resyncs to the
 66    next address byte on the wire. The raised instance carries the
 67    partial buffer so operators can inspect what showed up on the
 68    bus when the resync fired.
 69    """
 70
 71    def __init__(self, partial: bytes) -> None:
 72        super().__init__(
 73            f"cab bus frame exceeded {MAX_BODY_BYTES}-byte cap "
 74            f"(no address byte for {len(partial) - 1} data bytes)"
 75        )
 76        self.partial = partial
 77
 78
 79async def read_message_peekable(reader: PeekableReader) -> bytes:
 80    """Read one message using a :class:`PeekableReader` for clean pushback.
 81
 82    Raises :class:`BodyOverflowError` if the frame grows past
 83    :data:`MAX_BODY_BYTES` without seeing the next address byte.
 84    Callers (e.g. :class:`CabBusMonitor`) treat that as a resync
 85    signal — drop the partial and read the next address byte fresh.
 86    """
 87    # Resync to the next address byte.
 88    while True:
 89        b = await reader.read_byte()
 90        if is_address_byte(b):
 91            addr = b
 92            break
 93
 94    out = bytearray([addr])
 95    while True:
 96        try:
 97            b = await reader.read_byte()
 98        except EOFError:
 99            break
100        if is_address_byte(b):
101            reader.push_back(b)
102            break
103        out.append(b)
104        # Cap counts data bytes only (out[0] is the address byte).
105        # Strictly greater — a body of exactly MAX_BODY_BYTES is
106        # legitimate (JMRI-documented frames don't exceed 9 bytes,
107        # but we allow generous headroom for uncommon variants).
108        if len(out) - 1 > MAX_BODY_BYTES:
109            # Don't push back — no address boundary in sight. The
110            # partial we return via the exception is useful for
111            # diagnostics; the read loop resyncs on the next call.
112            raise BodyOverflowError(bytes(out))
113    return bytes(out)
def is_address_byte(b: int) -> bool:
22def is_address_byte(b: int) -> bool:
23    """True if *b* is an address byte (high bit set)."""
24    return (b & 0x80) != 0

True if b is an address byte (high bit set).

def address_of(b: int) -> int:
27def address_of(b: int) -> int:
28    """Extract the 7-bit address from an address byte."""
29    return b & 0x7F

Extract the 7-bit address from an address byte.

class PeekableReader:
32class PeekableReader:
33    """Small wrapper around an async ``read_byte`` to support a 1-byte pushback.
34
35    The Cab Bus message boundary is "first byte with high bit set"; we
36    don't know we've crossed it until we read the byte. ``PeekableReader``
37    lets the codec push back that boundary byte for the next call to see.
38    """
39
40    def __init__(self, read_byte: Callable[[], Awaitable[int]]) -> None:
41        self._read = read_byte
42        self._buf: int | None = None
43
44    def push_back(self, b: int) -> None:
45        if self._buf is not None:
46            raise RuntimeError("PeekableReader already has a pushed-back byte")
47        self._buf = b
48
49    async def read_byte(self) -> int:
50        if self._buf is not None:
51            b, self._buf = self._buf, None
52            return b
53        return await self._read()

Small wrapper around an async read_byte to support a 1-byte pushback.

The Cab Bus message boundary is "first byte with high bit set"; we don't know we've crossed it until we read the byte. PeekableReader lets the codec push back that boundary byte for the next call to see.

PeekableReader(read_byte: Callable[[], Awaitable[int]])
40    def __init__(self, read_byte: Callable[[], Awaitable[int]]) -> None:
41        self._read = read_byte
42        self._buf: int | None = None
def push_back(self, b: int) -> None:
44    def push_back(self, b: int) -> None:
45        if self._buf is not None:
46            raise RuntimeError("PeekableReader already has a pushed-back byte")
47        self._buf = b
async def read_byte(self) -> int:
49    async def read_byte(self) -> int:
50        if self._buf is not None:
51            b, self._buf = self._buf, None
52            return b
53        return await self._read()
MAX_BODY_BYTES = 32
class BodyOverflowError(builtins.RuntimeError):
63class BodyOverflowError(RuntimeError):
64    """Raised when a single frame's body exceeds :data:`MAX_BODY_BYTES`.
65
66    The monitor's read loop logs this at WARNING and resyncs to the
67    next address byte on the wire. The raised instance carries the
68    partial buffer so operators can inspect what showed up on the
69    bus when the resync fired.
70    """
71
72    def __init__(self, partial: bytes) -> None:
73        super().__init__(
74            f"cab bus frame exceeded {MAX_BODY_BYTES}-byte cap "
75            f"(no address byte for {len(partial) - 1} data bytes)"
76        )
77        self.partial = partial

Raised when a single frame's body exceeds MAX_BODY_BYTES.

The monitor's read loop logs this at WARNING and resyncs to the next address byte on the wire. The raised instance carries the partial buffer so operators can inspect what showed up on the bus when the resync fired.

BodyOverflowError(partial: bytes)
72    def __init__(self, partial: bytes) -> None:
73        super().__init__(
74            f"cab bus frame exceeded {MAX_BODY_BYTES}-byte cap "
75            f"(no address byte for {len(partial) - 1} data bytes)"
76        )
77        self.partial = partial
partial
async def read_message_peekable(reader: PeekableReader) -> bytes:
 80async def read_message_peekable(reader: PeekableReader) -> bytes:
 81    """Read one message using a :class:`PeekableReader` for clean pushback.
 82
 83    Raises :class:`BodyOverflowError` if the frame grows past
 84    :data:`MAX_BODY_BYTES` without seeing the next address byte.
 85    Callers (e.g. :class:`CabBusMonitor`) treat that as a resync
 86    signal — drop the partial and read the next address byte fresh.
 87    """
 88    # Resync to the next address byte.
 89    while True:
 90        b = await reader.read_byte()
 91        if is_address_byte(b):
 92            addr = b
 93            break
 94
 95    out = bytearray([addr])
 96    while True:
 97        try:
 98            b = await reader.read_byte()
 99        except EOFError:
100            break
101        if is_address_byte(b):
102            reader.push_back(b)
103            break
104        out.append(b)
105        # Cap counts data bytes only (out[0] is the address byte).
106        # Strictly greater — a body of exactly MAX_BODY_BYTES is
107        # legitimate (JMRI-documented frames don't exceed 9 bytes,
108        # but we allow generous headroom for uncommon variants).
109        if len(out) - 1 > MAX_BODY_BYTES:
110            # Don't push back — no address boundary in sight. The
111            # partial we return via the exception is useful for
112            # diagnostics; the read loop resyncs on the next call.
113            raise BodyOverflowError(bytes(out))
114    return bytes(out)

Read one message using a PeekableReader for clean pushback.

Raises BodyOverflowError if the frame grows past MAX_BODY_BYTES without seeing the next address byte. Callers (e.g. CabBusMonitor) treat that as a resync signal — drop the partial and read the next address byte fresh.