jmri_openlcb

jmri-openlcb — OpenLCB/LCC driver. Event-driven peer-to-peer.

 1"""jmri-openlcb — OpenLCB/LCC driver. Event-driven peer-to-peer."""
 2from jmri_openlcb.beans import (
 3    OlcbSensor,
 4    OlcbSensorManager,
 5    OlcbTurnout,
 6    OlcbTurnoutManager,
 7)
 8from jmri_openlcb.codec import (
 9    MTI_PCER,
10    OlcbFrame,
11    OlcbUnknown,
12    decode_frame,
13    decode_pcer_if_pcer,
14    encode_frame,
15    encode_pcer,
16    extract_mti,
17    extract_source_alias,
18    make_pcer_header,
19)
20from jmri_openlcb.connection import OlcbConnection
21from jmri_openlcb.datagrams import (
22    Datagram,
23    DatagramEngine,
24    DatagramRejected,
25)
26from jmri_openlcb.events import EventId, EventReport
27from jmri_openlcb.memory import (
28    MAX_MEMORY_CHUNK,
29    SPACE_ALL,
30    SPACE_CDI,
31    SPACE_CONFIG,
32    MemoryClient,
33    MemoryError_,
34)
35from jmri_openlcb.power import POWER_OFF, POWER_ON, OlcbPowerManager
36from jmri_openlcb.traffic import OlcbTrafficController
37from jmri_openlcb.transport import BytePort, LoopbackPort, SerialPort, TcpPort, loopback_pair
38
39__version__ = "0.1.0"
40__all__ = [
41    "BytePort", "Datagram", "DatagramEngine", "DatagramRejected", "EventId",
42    "EventReport", "LoopbackPort", "MAX_MEMORY_CHUNK", "MTI_PCER",
43    "MemoryClient", "MemoryError_", "OlcbConnection", "OlcbFrame",
44    "OlcbPowerManager", "OlcbSensor", "OlcbSensorManager",
45    "OlcbTrafficController", "OlcbTurnout", "OlcbTurnoutManager", "OlcbUnknown",
46    "POWER_OFF", "POWER_ON", "SPACE_ALL", "SPACE_CDI", "SPACE_CONFIG",
47    "SerialPort", "TcpPort", "__version__", "decode_frame", "decode_pcer_if_pcer",
48    "encode_frame", "encode_pcer", "extract_mti", "extract_source_alias",
49    "loopback_pair", "make_pcer_header",
50]
class BytePort(abc.ABC):
 7class BytePort(ABC):
 8    @abstractmethod
 9    async def write(self, data: bytes) -> None: ...
10    @abstractmethod
11    async def read_byte(self) -> int: ...
12    @abstractmethod
13    async def aclose(self) -> None: ...
14    @property
15    @abstractmethod
16    def is_open(self) -> bool: ...

Helper class that provides a standard way to create an ABC using inheritance.

@abstractmethod
async def write(self, data: bytes) -> None:
8    @abstractmethod
9    async def write(self, data: bytes) -> None: ...
@abstractmethod
async def read_byte(self) -> int:
10    @abstractmethod
11    async def read_byte(self) -> int: ...
@abstractmethod
async def aclose(self) -> None:
12    @abstractmethod
13    async def aclose(self) -> None: ...
is_open: bool
14    @property
15    @abstractmethod
16    def is_open(self) -> bool: ...
@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
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: 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.

@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
@dataclass(frozen=True, slots=True)
class EventId:
19@dataclass(frozen=True, slots=True)
20class EventId:
21    """64-bit OpenLCB event identifier."""
22
23    value: int  # 0..2**64-1
24
25    def __post_init__(self) -> None:
26        if not 0 <= self.value < (1 << 64):
27            raise ValueError(f"EventId must be 0..2**64-1, got {self.value}")
28
29    @classmethod
30    def parse(cls, text: str) -> EventId:
31        """Parse the dotted-hex or plain-hex text form into an :class:`EventId`."""
32        cleaned = text.replace(".", "").replace(" ", "").strip()
33        if not cleaned:
34            raise ValueError(f"empty EventId text {text!r}")
35        try:
36            return cls(int(cleaned, 16))
37        except ValueError as e:
38            raise ValueError(f"invalid EventId text {text!r}: {e}") from None
39
40    @property
41    def bytes(self) -> bytes:
42        return self.value.to_bytes(8, "big")
43
44    def __str__(self) -> str:
45        b = self.bytes
46        return ".".join(f"{x:02X}" for x in b)

64-bit OpenLCB event identifier.

EventId(value: int)
value: int
@classmethod
def parse(cls, text: str) -> EventId:
29    @classmethod
30    def parse(cls, text: str) -> EventId:
31        """Parse the dotted-hex or plain-hex text form into an :class:`EventId`."""
32        cleaned = text.replace(".", "").replace(" ", "").strip()
33        if not cleaned:
34            raise ValueError(f"empty EventId text {text!r}")
35        try:
36            return cls(int(cleaned, 16))
37        except ValueError as e:
38            raise ValueError(f"invalid EventId text {text!r}: {e}") from None

Parse the dotted-hex or plain-hex text form into an EventId.

bytes: <property object at 0x000002466E8652B0>
40    @property
41    def bytes(self) -> bytes:
42        return self.value.to_bytes(8, "big")
@dataclass(frozen=True, slots=True)
class EventReport:
49@dataclass(frozen=True, slots=True)
50class EventReport:
51    """Produced/Consumer Event Report — the workhorse message.
52
53    Sent by producers to announce "this event happened"; observed by
54    consumers to drive their state. The 64-bit ``event`` is enough; the
55    16-bit source ``alias`` identifies *which* node produced it (useful
56    for diagnostics; we don't validate aliases here).
57    """
58
59    event: EventId
60    source_alias: int  # 12-bit node alias, observed in the CAN header

Produced/Consumer Event Report — the workhorse message.

Sent by producers to announce "this event happened"; observed by consumers to drive their state. The 64-bit event is enough; the 16-bit source alias identifies which node produced it (useful for diagnostics; we don't validate aliases here).

EventReport(event: EventId, source_alias: int)
event: EventId
source_alias: int
class LoopbackPort(jmri_openlcb.BytePort):
 9class LoopbackPort(BytePort):
10    def __init__(self, read_q: asyncio.Queue[int], write_q: asyncio.Queue[int]) -> None:
11        self._r = read_q
12        self._w = write_q
13        self._open = True
14
15    async def write(self, data: bytes) -> None:
16        if not self._open:
17            raise RuntimeError("port is closed")
18        for b in data:
19            await self._w.put(b)
20
21    async def read_byte(self) -> int:
22        return await self._r.get()
23
24    async def aclose(self) -> None:
25        self._open = False
26
27    @property
28    def is_open(self) -> bool:
29        return self._open

Helper class that provides a standard way to create an ABC using inheritance.

LoopbackPort( read_q: asyncio.queues.Queue[int], write_q: asyncio.queues.Queue[int])
10    def __init__(self, read_q: asyncio.Queue[int], write_q: asyncio.Queue[int]) -> None:
11        self._r = read_q
12        self._w = write_q
13        self._open = True
async def write(self, data: bytes) -> None:
15    async def write(self, data: bytes) -> None:
16        if not self._open:
17            raise RuntimeError("port is closed")
18        for b in data:
19            await self._w.put(b)
async def read_byte(self) -> int:
21    async def read_byte(self) -> int:
22        return await self._r.get()
async def aclose(self) -> None:
24    async def aclose(self) -> None:
25        self._open = False
is_open: bool
27    @property
28    def is_open(self) -> bool:
29        return self._open
MAX_MEMORY_CHUNK = 64
MTI_PCER = 1460
class MemoryClient:
125class MemoryClient:
126    """Convenience API over the memory-config protocol.
127
128    Wraps a :class:`~jmri_openlcb.datagrams.DatagramEngine` so callers
129    talk to remote nodes in terms of ``read(space, address, count)`` /
130    ``write(space, address, data)`` and get bytes / None back
131    respectively. Failures raise :class:`MemoryError_`.
132
133    Each client instance registers ONE inbound listener on the
134    datagram engine and dispatches incoming replies to whatever
135    request is waiting. That means callers can await multiple
136    read/write operations against the same peer serially — a single
137    outstanding operation per peer is the assumption (matches the
138    protocol's implicit half-duplex nature at the memory layer).
139    """
140
141    def __init__(self, engine: DatagramEngine) -> None:
142        self._engine = engine
143        self._pending: dict[int, asyncio.Future[bytes]] = {}
144        self._unsubscribe = engine.inbound.connect(self._on_inbound)
145
146    def close(self) -> None:
147        self._unsubscribe()
148        for fut in self._pending.values():
149            if not fut.done():
150                fut.set_exception(RuntimeError("memory client closed"))
151        self._pending.clear()
152
153    async def read(
154        self,
155        peer_alias: int,
156        space: int,
157        address: int,
158        count: int,
159        *,
160        timeout: float = 3.0,
161    ) -> bytes:
162        """Read *count* bytes starting at *address* in *space* from
163        the peer. Automatically splits reads over
164        :data:`MAX_MEMORY_CHUNK` into multiple requests.
165        """
166        if count < 0:
167            raise ValueError(f"count must be non-negative: {count}")
168        chunks: list[bytes] = []
169        remaining = count
170        cursor = address
171        while remaining > 0:
172            batch = min(remaining, MAX_MEMORY_CHUNK)
173            chunk = await self._read_one(
174                peer_alias, space, cursor, batch, timeout=timeout,
175            )
176            chunks.append(chunk)
177            # A short reply signals end-of-space; stop early so we
178            # don't spin at the peer's expense.
179            if len(chunk) < batch:
180                break
181            cursor += batch
182            remaining -= batch
183        return b"".join(chunks)
184
185    async def write(
186        self,
187        peer_alias: int,
188        space: int,
189        address: int,
190        data: bytes,
191        *,
192        timeout: float = 3.0,
193    ) -> None:
194        """Write *data* starting at *address* in *space* on the peer.
195        Split into :data:`MAX_MEMORY_CHUNK`-byte writes."""
196        cursor = address
197        pos = 0
198        while pos < len(data):
199            batch = data[pos:pos + MAX_MEMORY_CHUNK]
200            await self._write_one(
201                peer_alias, space, cursor, batch, timeout=timeout,
202            )
203            cursor += len(batch)
204            pos += len(batch)
205
206    # ---- one-shot request path ----------------------------------------
207
208    async def _read_one(
209        self, peer_alias: int, space: int, address: int, count: int,
210        *, timeout: float,
211    ) -> bytes:
212        cmd, explicit = _encode_read_command(space)
213        body = self._build_request(cmd, address, explicit, space)
214        # count byte last; single byte, 1..64.
215        body += bytes([count & 0xFF])
216        return await self._request(peer_alias, body, timeout=timeout)
217
218    async def _write_one(
219        self, peer_alias: int, space: int, address: int, data: bytes,
220        *, timeout: float,
221    ) -> None:
222        cmd, explicit = _encode_write_command(space)
223        body = self._build_request(cmd, address, explicit, space) + data
224        await self._request(peer_alias, body, timeout=timeout)
225
226    def _build_request(
227        self, command: int, address: int, explicit_space: bool, space: int,
228    ) -> bytes:
229        addr_bytes = address.to_bytes(4, "big")
230        body = bytes([MEMORY_CONFIG, command]) + addr_bytes
231        if explicit_space:
232            body += bytes([space & 0xFF])
233        return body
234
235    async def _request(
236        self, peer_alias: int, body: bytes, *, timeout: float,
237    ) -> bytes:
238        """Send *body* as a datagram and await the peer's memory-config
239        reply (which arrives as an inbound datagram after the peer's
240        Received-OK)."""
241        if peer_alias in self._pending:
242            raise RuntimeError(
243                f"a memory op to alias {peer_alias:#x} is already in flight",
244            )
245        loop = asyncio.get_event_loop()
246        fut: asyncio.Future[bytes] = loop.create_future()
247        self._pending[peer_alias] = fut
248        try:
249            # The datagram engine's send() awaits Received OK before
250            # returning; the peer's memory-config *reply* arrives as
251            # a subsequent inbound datagram.
252            await self._engine.send(peer_alias, body, timeout=timeout)
253            data = await asyncio.wait_for(fut, timeout=timeout)
254        finally:
255            self._pending.pop(peer_alias, None)
256        return data
257
258    # ---- inbound reply handler ----------------------------------------
259
260    async def _on_inbound(self, dg: Datagram) -> None:
261        if len(dg.data) < 6:
262            return
263        if dg.data[0] != MEMORY_CONFIG:
264            return
265        command = dg.data[1]
266        # We match on peer alias (source of the datagram).
267        fut = self._pending.get(dg.source_alias)
268        if fut is None or fut.done():
269            return
270        if _is_failure_reply(command):
271            code = 0
272            # Optional 2-byte error code follows the address (6 bytes in).
273            if len(dg.data) >= 8:
274                code = (dg.data[6] << 8) | dg.data[7]
275            fut.set_exception(MemoryError_(error_code=code))
276            return
277        if _is_read_reply(command):
278            # Layout: proto, cmd, addr(4), [space], data...
279            # Compact spaces skip the extra space byte.
280            compact = _compact_space_bits_from_command(command)
281            data_start = 6 if compact is not None else 7
282            fut.set_result(bytes(dg.data[data_start:]))
283            return
284        if _is_write_reply(command):
285            # Write success carries no body; empty bytes signals done.
286            fut.set_result(b"")

Convenience API over the memory-config protocol.

Wraps a ~jmri_openlcb.datagrams.DatagramEngine so callers talk to remote nodes in terms of read(space, address, count) / write(space, address, data) and get bytes / None back respectively. Failures raise MemoryError_.

Each client instance registers ONE inbound listener on the datagram engine and dispatches incoming replies to whatever request is waiting. That means callers can await multiple read/write operations against the same peer serially — a single outstanding operation per peer is the assumption (matches the protocol's implicit half-duplex nature at the memory layer).

MemoryClient(engine: DatagramEngine)
141    def __init__(self, engine: DatagramEngine) -> None:
142        self._engine = engine
143        self._pending: dict[int, asyncio.Future[bytes]] = {}
144        self._unsubscribe = engine.inbound.connect(self._on_inbound)
def close(self) -> None:
146    def close(self) -> None:
147        self._unsubscribe()
148        for fut in self._pending.values():
149            if not fut.done():
150                fut.set_exception(RuntimeError("memory client closed"))
151        self._pending.clear()
async def read( self, peer_alias: int, space: int, address: int, count: int, *, timeout: float = 3.0) -> bytes:
153    async def read(
154        self,
155        peer_alias: int,
156        space: int,
157        address: int,
158        count: int,
159        *,
160        timeout: float = 3.0,
161    ) -> bytes:
162        """Read *count* bytes starting at *address* in *space* from
163        the peer. Automatically splits reads over
164        :data:`MAX_MEMORY_CHUNK` into multiple requests.
165        """
166        if count < 0:
167            raise ValueError(f"count must be non-negative: {count}")
168        chunks: list[bytes] = []
169        remaining = count
170        cursor = address
171        while remaining > 0:
172            batch = min(remaining, MAX_MEMORY_CHUNK)
173            chunk = await self._read_one(
174                peer_alias, space, cursor, batch, timeout=timeout,
175            )
176            chunks.append(chunk)
177            # A short reply signals end-of-space; stop early so we
178            # don't spin at the peer's expense.
179            if len(chunk) < batch:
180                break
181            cursor += batch
182            remaining -= batch
183        return b"".join(chunks)

Read count bytes starting at address in space from the peer. Automatically splits reads over MAX_MEMORY_CHUNK into multiple requests.

async def write( self, peer_alias: int, space: int, address: int, data: bytes, *, timeout: float = 3.0) -> None:
185    async def write(
186        self,
187        peer_alias: int,
188        space: int,
189        address: int,
190        data: bytes,
191        *,
192        timeout: float = 3.0,
193    ) -> None:
194        """Write *data* starting at *address* in *space* on the peer.
195        Split into :data:`MAX_MEMORY_CHUNK`-byte writes."""
196        cursor = address
197        pos = 0
198        while pos < len(data):
199            batch = data[pos:pos + MAX_MEMORY_CHUNK]
200            await self._write_one(
201                peer_alias, space, cursor, batch, timeout=timeout,
202            )
203            cursor += len(batch)
204            pos += len(batch)

Write data starting at address in space on the peer. Split into MAX_MEMORY_CHUNK-byte writes.

@dataclass(frozen=True, slots=True)
class MemoryError_(builtins.Exception):
109@dataclass(frozen=True, slots=True)
110class MemoryError_(Exception):
111    """Raised when a node returns a failure reply for a memory op.
112
113    Name has an underscore so it doesn't shadow Python's built-in.
114    """
115
116    error_code: int
117
118    def __str__(self) -> str:
119        return f"memory operation failed (code {self.error_code:#06x})"

Raised when a node returns a failure reply for a memory op.

Name has an underscore so it doesn't shadow Python's built-in.

MemoryError_(error_code: int)
error_code: int
class OlcbConnection(jmri_core.hal.connection.SystemConnection):
23class OlcbConnection(SystemConnection):
24    name: ClassVar[str] = "OpenLCB"
25    prefix: ClassVar[str] = "X"
26
27    def __init__(self) -> None:
28        super().__init__()
29        self._port: BytePort | None = None
30        self._tc: OlcbTrafficController | None = None
31
32    async def open(self, settings: ConnectionSettings | None = None) -> None:
33        s = settings or {}
34        self._port = await _build_port(s)
35        self._tc = OlcbTrafficController(
36            self._port, source_alias=int(s.get("source_alias", 0x123))
37        )
38        await self._tc.start()
39
40        self.turnouts = OlcbTurnoutManager(self.prefix, tc=self._tc)  # type: ignore[assignment]
41        self.sensors = OlcbSensorManager(self.prefix, tc=self._tc)    # type: ignore[assignment]
42        # Well-known event power manager — produces + consumes the
43        # reserved 01.00.00.00.00.00.FF.FE / .FF event IDs so a
44        # single set_power() call converges every OpenLCB node
45        # listening to the bus. See :mod:`jmri_openlcb.power`.
46        self.power = OlcbPowerManager(self._tc)
47
48        await super().open(settings)
49
50    async def close(self) -> None:
51        # Drop the power manager's listener before we tear down the
52        # traffic controller so the manager doesn't fire against a
53        # closed signal.
54        if isinstance(self.power, OlcbPowerManager):
55            self.power.close()
56        if self._tc is not None:
57            await self._tc.aclose()
58            self._tc = None
59        self._port = None
60        await super().close()
61
62    @property
63    def traffic(self) -> OlcbTrafficController:
64        if self._tc is None:
65            raise RuntimeError("connection not open")
66        return self._tc

One hardware connection to a layout.

Subclasses set name (vendor-friendly label) and prefix (the JMRI system-name prefix). After open() returns, all the bean managers must be populated; closed connections deregister themselves.

name: ClassVar[str] = 'OpenLCB'
prefix: ClassVar[str] = 'X'
async def open(self, settings: dict[str, typing.Any] | None = None) -> None:
32    async def open(self, settings: ConnectionSettings | None = None) -> None:
33        s = settings or {}
34        self._port = await _build_port(s)
35        self._tc = OlcbTrafficController(
36            self._port, source_alias=int(s.get("source_alias", 0x123))
37        )
38        await self._tc.start()
39
40        self.turnouts = OlcbTurnoutManager(self.prefix, tc=self._tc)  # type: ignore[assignment]
41        self.sensors = OlcbSensorManager(self.prefix, tc=self._tc)    # type: ignore[assignment]
42        # Well-known event power manager — produces + consumes the
43        # reserved 01.00.00.00.00.00.FF.FE / .FF event IDs so a
44        # single set_power() call converges every OpenLCB node
45        # listening to the bus. See :mod:`jmri_openlcb.power`.
46        self.power = OlcbPowerManager(self._tc)
47
48        await super().open(settings)

Connect to hardware and start any IO loops.

Concrete drivers acquire serial/TCP ports here, install protocol handlers, and start any required background tasks. The base implementation should be called via await super().open(settings) to mark the connection opened.

async def close(self) -> None:
50    async def close(self) -> None:
51        # Drop the power manager's listener before we tear down the
52        # traffic controller so the manager doesn't fire against a
53        # closed signal.
54        if isinstance(self.power, OlcbPowerManager):
55            self.power.close()
56        if self._tc is not None:
57            await self._tc.aclose()
58            self._tc = None
59        self._port = None
60        await super().close()

Disconnect from hardware. Idempotent. Base impl flips the flag.

traffic: OlcbTrafficController
62    @property
63    def traffic(self) -> OlcbTrafficController:
64        if self._tc is None:
65            raise RuntimeError("connection not open")
66        return self._tc
@dataclass(frozen=True, slots=True)
class OlcbFrame:
36@dataclass(frozen=True, slots=True)
37class OlcbFrame:
38    """One GridConnect frame, before MTI-level decoding."""
39
40    header: int       # 29-bit extended CAN ID
41    data: bytes       # 0..8 bytes

One GridConnect frame, before MTI-level decoding.

OlcbFrame(header: int, data: bytes)
header: int
data: bytes
class OlcbPowerManager(jmri_core.beans.power.PowerManager):
35class OlcbPowerManager(PowerManager):
36    """Power manager that produces + consumes the well-known events."""
37
38    def __init__(self, tc: OlcbTrafficController) -> None:
39        super().__init__()
40        self._tc = tc
41        self._unsubscribe = tc.events.connect(self._on_event)
42
43    async def set_power(self, state: PowerState) -> None:
44        # Emit the well-known event so every other node on the bus
45        # observes the transition. Local state is updated by the same
46        # inbound-event handler when our own broadcast comes back
47        # around, keeping the "we just heard about it from the bus"
48        # semantics uniform whether the change came from us or a peer.
49        if state is PowerState.ON:
50            await self._tc.produce_event(POWER_ON)
51        elif state is PowerState.OFF:
52            await self._tc.produce_event(POWER_OFF)
53        else:
54            # UNKNOWN is a "we don't know" state, not a command; no
55            # matching event exists.
56            return
57        # Some loopback / test transports don't echo our own frames
58        # back, so update the internal state explicitly. If the bus
59        # DOES echo, the inbound handler will see it and no-op on
60        # the already-current state (see ``_set_state`` in the base).
61        await self._set_state(state)
62
63    def close(self) -> None:
64        """Detach the inbound listener. Idempotent."""
65        if self._unsubscribe is not None:
66            self._unsubscribe()
67            self._unsubscribe = None
68
69    async def _on_event(self, report: EventReport) -> None:
70        if report.event == POWER_ON:
71            await self._set_state(PowerState.ON)
72        elif report.event == POWER_OFF:
73            await self._set_state(PowerState.OFF)

Power manager that produces + consumes the well-known events.

OlcbPowerManager(tc: OlcbTrafficController)
38    def __init__(self, tc: OlcbTrafficController) -> None:
39        super().__init__()
40        self._tc = tc
41        self._unsubscribe = tc.events.connect(self._on_event)
async def set_power(self, state: jmri_core.state.PowerState) -> None:
43    async def set_power(self, state: PowerState) -> None:
44        # Emit the well-known event so every other node on the bus
45        # observes the transition. Local state is updated by the same
46        # inbound-event handler when our own broadcast comes back
47        # around, keeping the "we just heard about it from the bus"
48        # semantics uniform whether the change came from us or a peer.
49        if state is PowerState.ON:
50            await self._tc.produce_event(POWER_ON)
51        elif state is PowerState.OFF:
52            await self._tc.produce_event(POWER_OFF)
53        else:
54            # UNKNOWN is a "we don't know" state, not a command; no
55            # matching event exists.
56            return
57        # Some loopback / test transports don't echo our own frames
58        # back, so update the internal state explicitly. If the bus
59        # DOES echo, the inbound handler will see it and no-op on
60        # the already-current state (see ``_set_state`` in the base).
61        await self._set_state(state)

Command the layout to state. Base impl just updates in-memory.

def close(self) -> None:
63    def close(self) -> None:
64        """Detach the inbound listener. Idempotent."""
65        if self._unsubscribe is not None:
66            self._unsubscribe()
67            self._unsubscribe = None

Detach the inbound listener. Idempotent.

class OlcbSensor(jmri_core.beans.sensor.Sensor):
108class OlcbSensor(Sensor):
109    """A sensor bound to two event IDs (active/inactive)."""
110
111    __slots__ = ("_active_event", "_inactive_event")
112
113    def __init__(
114        self,
115        system_name: str,
116        active_event: EventId,
117        inactive_event: EventId,
118    ) -> None:
119        super().__init__(system_name)
120        self._active_event = active_event
121        self._inactive_event = inactive_event
122
123    @property
124    def active_event(self) -> EventId:
125        return self._active_event
126
127    @property
128    def inactive_event(self) -> EventId:
129        return self._inactive_event

A sensor bound to two event IDs (active/inactive).

OlcbSensor( system_name: str, active_event: EventId, inactive_event: EventId)
113    def __init__(
114        self,
115        system_name: str,
116        active_event: EventId,
117        inactive_event: EventId,
118    ) -> None:
119        super().__init__(system_name)
120        self._active_event = active_event
121        self._inactive_event = inactive_event
active_event: EventId
123    @property
124    def active_event(self) -> EventId:
125        return self._active_event
inactive_event: EventId
127    @property
128    def inactive_event(self) -> EventId:
129        return self._inactive_event
class OlcbSensorManager(jmri_core.manager.Manager[jmri_openlcb.beans.OlcbSensor]):
132class OlcbSensorManager(Manager[OlcbSensor]):
133    type_letter = "S"
134
135    def __init__(self, system_prefix: str, *, tc: OlcbTrafficController) -> None:
136        super().__init__(system_prefix)
137        self._by_event: dict[EventId, tuple[OlcbSensor, SensorState]] = {}
138        self._unsub = tc.events.connect(self._on_event)
139
140    def _create(self, system_name: str) -> OlcbSensor:
141        raise NotImplementedError(
142            "OpenLCB sensors require explicit event-ID configuration; "
143            "use OlcbSensorManager.configure(...)"
144        )
145
146    async def configure(
147        self,
148        system_name: str,
149        active_event: EventId,
150        inactive_event: EventId,
151    ) -> OlcbSensor:
152        bean = OlcbSensor(system_name, active_event, inactive_event)
153        await self.register(bean)
154        self._by_event[active_event] = (bean, SensorState.ACTIVE)
155        self._by_event[inactive_event] = (bean, SensorState.INACTIVE)
156        return bean
157
158    async def _on_event(self, report: EventReport) -> None:
159        binding = self._by_event.get(report.event)
160        if binding is None:
161            return
162        bean, target_state = binding
163        await bean._set_known_state(target_state)

Per-type bean registry. Subclass and override _create().

Attributes set on the class describe the bean type:

  • type_letter — single uppercase letter the bean class owns (e.g. "T" for Turnout). Must be set by subclass.
  • system_prefix — connection prefix (e.g. "L"). Set per-instance because one bean class can have many connection-specific managers.
OlcbSensorManager( system_prefix: str, *, tc: OlcbTrafficController)
135    def __init__(self, system_prefix: str, *, tc: OlcbTrafficController) -> None:
136        super().__init__(system_prefix)
137        self._by_event: dict[EventId, tuple[OlcbSensor, SensorState]] = {}
138        self._unsub = tc.events.connect(self._on_event)
type_letter = 'S'
async def configure( self, system_name: str, active_event: EventId, inactive_event: EventId) -> OlcbSensor:
146    async def configure(
147        self,
148        system_name: str,
149        active_event: EventId,
150        inactive_event: EventId,
151    ) -> OlcbSensor:
152        bean = OlcbSensor(system_name, active_event, inactive_event)
153        await self.register(bean)
154        self._by_event[active_event] = (bean, SensorState.ACTIVE)
155        self._by_event[inactive_event] = (bean, SensorState.INACTIVE)
156        return bean
class OlcbTrafficController:
31class OlcbTrafficController:
32    def __init__(self, port: BytePort, *, source_alias: int = 0x123) -> None:
33        self._port = port
34        self._source_alias = source_alias
35        self._send_lock = asyncio.Lock()
36        self._read_task: asyncio.Task[None] | None = None
37        self._closed = False
38        self.events: Signal[EventReport] = Signal()
39        self.frames: Signal[OlcbFrame] = Signal()
40
41    @property
42    def source_alias(self) -> int:
43        return self._source_alias
44
45    async def start(self) -> None:
46        if self._read_task is not None:
47            return
48        self._read_task = asyncio.create_task(self._read_loop(), name="olcb-read")
49
50    async def aclose(self) -> None:
51        self._closed = True
52        if self._read_task is not None:
53            self._read_task.cancel()
54            import contextlib  # noqa: PLC0415
55            with contextlib.suppress(asyncio.CancelledError, Exception):
56                await self._read_task
57            self._read_task = None
58        await self._port.aclose()
59
60    async def produce_event(self, event: EventId) -> None:
61        """Emit a PCER for *event* with our source alias."""
62        async with self._send_lock:
63            await self._port.write(encode_pcer(event, self._source_alias))
64
65    async def send_frame(self, frame: OlcbFrame) -> None:
66        """Send a raw frame (escape hatch for unsupported MTIs)."""
67        async with self._send_lock:
68            await self._port.write(encode_frame(frame))
69
70    async def _read_loop(self) -> None:
71        try:
72            while not self._closed:
73                try:
74                    text = await read_frame(self._port.read_byte)
75                except asyncio.CancelledError:
76                    return
77                except Exception as e:  # noqa: BLE001
78                    _log.warning("OpenLCB read error: %s", e)
79                    continue
80                try:
81                    frame = decode_frame(text)
82                except ValueError as e:
83                    _log.warning("OpenLCB bad frame %r: %s", text, e)
84                    continue
85                await self.frames.emit(frame)
86                pcer = decode_pcer_if_pcer(frame)
87                if pcer is not None:
88                    await self.events.emit(pcer)
89        except asyncio.CancelledError:
90            return
OlcbTrafficController( port: BytePort, *, source_alias: int = 291)
32    def __init__(self, port: BytePort, *, source_alias: int = 0x123) -> None:
33        self._port = port
34        self._source_alias = source_alias
35        self._send_lock = asyncio.Lock()
36        self._read_task: asyncio.Task[None] | None = None
37        self._closed = False
38        self.events: Signal[EventReport] = Signal()
39        self.frames: Signal[OlcbFrame] = Signal()
source_alias: int
41    @property
42    def source_alias(self) -> int:
43        return self._source_alias
async def start(self) -> None:
45    async def start(self) -> None:
46        if self._read_task is not None:
47            return
48        self._read_task = asyncio.create_task(self._read_loop(), name="olcb-read")
async def aclose(self) -> None:
50    async def aclose(self) -> None:
51        self._closed = True
52        if self._read_task is not None:
53            self._read_task.cancel()
54            import contextlib  # noqa: PLC0415
55            with contextlib.suppress(asyncio.CancelledError, Exception):
56                await self._read_task
57            self._read_task = None
58        await self._port.aclose()
async def produce_event(self, event: EventId) -> None:
60    async def produce_event(self, event: EventId) -> None:
61        """Emit a PCER for *event* with our source alias."""
62        async with self._send_lock:
63            await self._port.write(encode_pcer(event, self._source_alias))

Emit a PCER for event with our source alias.

async def send_frame(self, frame: OlcbFrame) -> None:
65    async def send_frame(self, frame: OlcbFrame) -> None:
66        """Send a raw frame (escape hatch for unsupported MTIs)."""
67        async with self._send_lock:
68            await self._port.write(encode_frame(frame))

Send a raw frame (escape hatch for unsupported MTIs).

class OlcbTurnout(jmri_core.beans.turnout.Turnout):
34class OlcbTurnout(Turnout):
35    """A turnout bound to two event IDs (closed/thrown)."""
36
37    __slots__ = ("_closed_event", "_tc", "_thrown_event")
38
39    def __init__(
40        self,
41        system_name: str,
42        closed_event: EventId,
43        thrown_event: EventId,
44        tc: OlcbTrafficController,
45    ) -> None:
46        super().__init__(system_name)
47        self._closed_event = closed_event
48        self._thrown_event = thrown_event
49        self._tc = tc
50
51    @property
52    def closed_event(self) -> EventId:
53        return self._closed_event
54
55    @property
56    def thrown_event(self) -> EventId:
57        return self._thrown_event
58
59    async def set_commanded_state(self, state: TurnoutState) -> None:
60        if state == TurnoutState.CLOSED:
61            await self._tc.produce_event(self._closed_event)
62        elif state == TurnoutState.THROWN:
63            await self._tc.produce_event(self._thrown_event)
64        await super().set_commanded_state(state)

A turnout bound to two event IDs (closed/thrown).

OlcbTurnout( system_name: str, closed_event: EventId, thrown_event: EventId, tc: OlcbTrafficController)
39    def __init__(
40        self,
41        system_name: str,
42        closed_event: EventId,
43        thrown_event: EventId,
44        tc: OlcbTrafficController,
45    ) -> None:
46        super().__init__(system_name)
47        self._closed_event = closed_event
48        self._thrown_event = thrown_event
49        self._tc = tc
closed_event: EventId
51    @property
52    def closed_event(self) -> EventId:
53        return self._closed_event
thrown_event: EventId
55    @property
56    def thrown_event(self) -> EventId:
57        return self._thrown_event
async def set_commanded_state(self, state: jmri_core.state.TurnoutState) -> None:
59    async def set_commanded_state(self, state: TurnoutState) -> None:
60        if state == TurnoutState.CLOSED:
61            await self._tc.produce_event(self._closed_event)
62        elif state == TurnoutState.THROWN:
63            await self._tc.produce_event(self._thrown_event)
64        await super().set_commanded_state(state)

Ask the layout to throw or close the switch.

The base implementation only updates the in-memory commanded state and emits the signal. Driver subclasses override this to push a hardware message and then call super().set_commanded_state().

class OlcbTurnoutManager(jmri_core.manager.Manager[jmri_openlcb.beans.OlcbTurnout]):
 67class OlcbTurnoutManager(Manager[OlcbTurnout]):
 68    """Manager that creates turnouts only via :meth:`configure`."""
 69
 70    type_letter = "T"
 71
 72    def __init__(self, system_prefix: str, *, tc: OlcbTrafficController) -> None:
 73        super().__init__(system_prefix)
 74        self._tc = tc
 75        self._by_event: dict[EventId, tuple[OlcbTurnout, TurnoutState]] = {}
 76        self._unsub = tc.events.connect(self._on_event)
 77
 78    def _create(self, system_name: str) -> OlcbTurnout:
 79        raise NotImplementedError(
 80            "OpenLCB turnouts require explicit event-ID configuration; "
 81            "use OlcbTurnoutManager.configure(...) instead of provide()"
 82        )
 83
 84    async def configure(
 85        self,
 86        system_name: str,
 87        closed_event: EventId,
 88        thrown_event: EventId,
 89    ) -> OlcbTurnout:
 90        """Create and register a turnout with the given event bindings."""
 91        bean = OlcbTurnout(system_name, closed_event, thrown_event, self._tc)
 92        await self.register(bean)
 93        self._by_event[closed_event] = (bean, TurnoutState.CLOSED)
 94        self._by_event[thrown_event] = (bean, TurnoutState.THROWN)
 95        return bean
 96
 97    async def _on_event(self, report: EventReport) -> None:
 98        binding = self._by_event.get(report.event)
 99        if binding is None:
100            return
101        bean, target_state = binding
102        await bean._set_known_state(target_state)

Manager that creates turnouts only via configure().

OlcbTurnoutManager( system_prefix: str, *, tc: OlcbTrafficController)
72    def __init__(self, system_prefix: str, *, tc: OlcbTrafficController) -> None:
73        super().__init__(system_prefix)
74        self._tc = tc
75        self._by_event: dict[EventId, tuple[OlcbTurnout, TurnoutState]] = {}
76        self._unsub = tc.events.connect(self._on_event)
type_letter = 'T'
async def configure( self, system_name: str, closed_event: EventId, thrown_event: EventId) -> OlcbTurnout:
84    async def configure(
85        self,
86        system_name: str,
87        closed_event: EventId,
88        thrown_event: EventId,
89    ) -> OlcbTurnout:
90        """Create and register a turnout with the given event bindings."""
91        bean = OlcbTurnout(system_name, closed_event, thrown_event, self._tc)
92        await self.register(bean)
93        self._by_event[closed_event] = (bean, TurnoutState.CLOSED)
94        self._by_event[thrown_event] = (bean, TurnoutState.THROWN)
95        return bean

Create and register a turnout with the given event bindings.

@dataclass(frozen=True, slots=True)
class OlcbUnknown:
44@dataclass(frozen=True, slots=True)
45class OlcbUnknown:
46    """A well-formed CAN frame whose MTI we don't have a typed wrapper for."""
47
48    header: int
49    data: bytes

A well-formed CAN frame whose MTI we don't have a typed wrapper for.

OlcbUnknown(header: int, data: bytes)
header: int
data: bytes
POWER_OFF = EventId(value=72057594037993471)
POWER_ON = EventId(value=72057594037993470)
SPACE_ALL = 253
SPACE_CDI = 255
SPACE_CONFIG = 254
class SerialPort(jmri_openlcb.BytePort):
18class SerialPort(BytePort):
19    def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
20        self._reader = reader
21        self._writer = writer
22        self._open = True
23
24    @classmethod
25    async def open(cls, port: str, baud: int = DEFAULT_BAUD) -> SerialPort:
26        import serial_asyncio  # noqa: PLC0415
27        reader, writer = await serial_asyncio.open_serial_connection(url=port, baudrate=baud)
28        return cls(reader=reader, writer=writer)
29
30    async def write(self, data: bytes) -> None:
31        if not self._open:
32            raise RuntimeError("port is closed")
33        self._writer.write(data)
34        await self._writer.drain()
35
36    async def read_byte(self) -> int:
37        data = await self._reader.readexactly(1)
38        return data[0]
39
40    async def aclose(self) -> None:
41        if not self._open:
42            return
43        self._open = False
44        self._writer.close()
45        with contextlib.suppress(Exception):
46            await self._writer.wait_closed()
47
48    @property
49    def is_open(self) -> bool:
50        return self._open

Helper class that provides a standard way to create an ABC using inheritance.

SerialPort( reader: asyncio.streams.StreamReader, writer: asyncio.streams.StreamWriter)
19    def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
20        self._reader = reader
21        self._writer = writer
22        self._open = True
@classmethod
async def open( cls, port: str, baud: int = 333333) -> SerialPort:
24    @classmethod
25    async def open(cls, port: str, baud: int = DEFAULT_BAUD) -> SerialPort:
26        import serial_asyncio  # noqa: PLC0415
27        reader, writer = await serial_asyncio.open_serial_connection(url=port, baudrate=baud)
28        return cls(reader=reader, writer=writer)
async def write(self, data: bytes) -> None:
30    async def write(self, data: bytes) -> None:
31        if not self._open:
32            raise RuntimeError("port is closed")
33        self._writer.write(data)
34        await self._writer.drain()
async def read_byte(self) -> int:
36    async def read_byte(self) -> int:
37        data = await self._reader.readexactly(1)
38        return data[0]
async def aclose(self) -> None:
40    async def aclose(self) -> None:
41        if not self._open:
42            return
43        self._open = False
44        self._writer.close()
45        with contextlib.suppress(Exception):
46            await self._writer.wait_closed()
is_open: bool
48    @property
49    def is_open(self) -> bool:
50        return self._open
class TcpPort(jmri_openlcb.BytePort):
15class TcpPort(BytePort):
16    def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
17        self._reader = reader
18        self._writer = writer
19        self._open = True
20
21    @classmethod
22    async def open(cls, host: str, port: int) -> TcpPort:
23        reader, writer = await asyncio.open_connection(host, port)
24        return cls(reader=reader, writer=writer)
25
26    async def write(self, data: bytes) -> None:
27        if not self._open:
28            raise RuntimeError("port is closed")
29        self._writer.write(data)
30        await self._writer.drain()
31
32    async def read_byte(self) -> int:
33        data = await self._reader.readexactly(1)
34        return data[0]
35
36    async def aclose(self) -> None:
37        if not self._open:
38            return
39        self._open = False
40        self._writer.close()
41        with contextlib.suppress(Exception):
42            await self._writer.wait_closed()
43
44    @property
45    def is_open(self) -> bool:
46        return self._open

Helper class that provides a standard way to create an ABC using inheritance.

TcpPort( reader: asyncio.streams.StreamReader, writer: asyncio.streams.StreamWriter)
16    def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
17        self._reader = reader
18        self._writer = writer
19        self._open = True
@classmethod
async def open(cls, host: str, port: int) -> TcpPort:
21    @classmethod
22    async def open(cls, host: str, port: int) -> TcpPort:
23        reader, writer = await asyncio.open_connection(host, port)
24        return cls(reader=reader, writer=writer)
async def write(self, data: bytes) -> None:
26    async def write(self, data: bytes) -> None:
27        if not self._open:
28            raise RuntimeError("port is closed")
29        self._writer.write(data)
30        await self._writer.drain()
async def read_byte(self) -> int:
32    async def read_byte(self) -> int:
33        data = await self._reader.readexactly(1)
34        return data[0]
async def aclose(self) -> None:
36    async def aclose(self) -> None:
37        if not self._open:
38            return
39        self._open = False
40        self._writer.close()
41        with contextlib.suppress(Exception):
42            await self._writer.wait_closed()
is_open: bool
44    @property
45    def is_open(self) -> bool:
46        return self._open
__version__ = '0.1.0'
def decode_frame(text: str) -> OlcbFrame:
58def decode_frame(text: str) -> OlcbFrame:
59    """Parse one GridConnect message into an :class:`OlcbFrame`.
60
61    Accepts either ``:X<hex>N<hex>;`` (newer LCC format) or ``:X<hex>;`` for
62    data-less frames.
63    """
64    text = text.strip()
65    if not text.startswith(":X") or not text.endswith(";"):
66        raise ValueError(f"not a GridConnect frame: {text!r}")
67    body = text[2:-1]
68    if "N" in body:
69        head_hex, _, data_hex = body.partition("N")
70    else:
71        head_hex, data_hex = body, ""
72    try:
73        header = int(head_hex, 16)
74        data = bytes.fromhex(data_hex) if data_hex else b""
75    except ValueError as e:
76        raise ValueError(f"invalid GridConnect frame {text!r}: {e}") from None
77    return OlcbFrame(header=header, data=data)

Parse one GridConnect message into an OlcbFrame.

Accepts either :X<hex>N<hex>; (newer LCC format) or :X<hex>; for data-less frames.

def decode_pcer_if_pcer( frame: OlcbFrame) -> EventReport | None:
106def decode_pcer_if_pcer(frame: OlcbFrame) -> EventReport | None:
107    """Return an :class:`EventReport` iff *frame* is a PCER, else None."""
108    if extract_mti(frame.header) != MTI_PCER:
109        return None
110    if len(frame.data) != 8:
111        return None
112    return EventReport(
113        event=EventId(int.from_bytes(frame.data, "big")),
114        source_alias=extract_source_alias(frame.header),
115    )

Return an EventReport iff frame is a PCER, else None.

def encode_frame(frame: OlcbFrame) -> bytes:
52def encode_frame(frame: OlcbFrame) -> bytes:
53    """Encode an :class:`OlcbFrame` to GridConnect ASCII (with trailing newline)."""
54    body = f":X{frame.header:08X}N{frame.data.hex().upper()};"
55    return body.encode("ascii")

Encode an OlcbFrame to GridConnect ASCII (with trailing newline).

def encode_pcer(event: EventId, source_alias: int) -> bytes:
 98def encode_pcer(event: EventId, source_alias: int) -> bytes:
 99    """Encode an event report as a GridConnect frame."""
100    return encode_frame(OlcbFrame(
101        header=make_pcer_header(source_alias),
102        data=event.bytes,
103    ))

Encode an event report as a GridConnect frame.

def extract_mti(header: int) -> int:
80def extract_mti(header: int) -> int:
81    """Pull the MTI bits out of the 29-bit header (bits 12..23)."""
82    return (header >> 12) & 0xFFF

Pull the MTI bits out of the 29-bit header (bits 12..23).

def extract_source_alias(header: int) -> int:
85def extract_source_alias(header: int) -> int:
86    """Pull the 12-bit source node alias from the header low bits."""
87    return header & 0x0FFF

Pull the 12-bit source node alias from the header low bits.

def loopback_pair() -> tuple[LoopbackPort, LoopbackPort]:
32def loopback_pair() -> tuple[LoopbackPort, LoopbackPort]:
33    a_to_b: asyncio.Queue[int] = asyncio.Queue()
34    b_to_a: asyncio.Queue[int] = asyncio.Queue()
35    return LoopbackPort(b_to_a, a_to_b), LoopbackPort(a_to_b, b_to_a)
def make_pcer_header(source_alias: int) -> int:
90def make_pcer_header(source_alias: int) -> int:
91    """Compose the 29-bit CAN header for a PCER frame from *source_alias*."""
92    if not 0 <= source_alias <= 0xFFF:
93        raise ValueError(f"source alias must be 12-bit, got {source_alias:#x}")
94    # bit 28 set (extended frame), MTI in bits 12..23, alias in bits 0..11.
95    return (1 << 28) | (MTI_PCER << 12) | source_alias

Compose the 29-bit CAN header for a PCER frame from source_alias.