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)
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).
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.
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.
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.
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.