jmri_dccex.codec

DCC-EX text protocol codec.

DCC-EX (formerly DCC++) is a bracket-delimited ASCII protocol:

<command arg1 arg2 ...>

Commands and responses both use the same bracket convention. Multiple commands can be sent back-to-back; the codec reads one complete bracketed message at a time and strips the brackets.

Common commands JMRI uses:

  • <1> / <0> — power on / off
  • <T id 1|0> — set turnout id to closed (1) or thrown (0)
  • <S id pin pullup> — define sensor (server-side state only)
  • <t cab loco speed dir> — throttle command (legacy form)
  • <R cv> — read CV; <W cv val> — write CV
  • <Q id> — sensor active report (server → client)
  • <q id> — sensor inactive report

Responses follow the same shape; unknown messages decode to DccexUnknown so listeners don't lose visibility.

 1"""DCC-EX text protocol codec.
 2
 3DCC-EX (formerly DCC++) is a bracket-delimited ASCII protocol:
 4
 5    <command arg1 arg2 ...>
 6
 7Commands and responses both use the same bracket convention. Multiple
 8commands can be sent back-to-back; the codec reads one complete bracketed
 9message at a time and strips the brackets.
10
11Common commands JMRI uses:
12
13* ``<1>`` / ``<0>`` — power on / off
14* ``<T id 1|0>`` — set turnout *id* to closed (1) or thrown (0)
15* ``<S id pin pullup>`` — define sensor (server-side state only)
16* ``<t cab loco speed dir>`` — throttle command (legacy form)
17* ``<R cv>`` — read CV; ``<W cv val>`` — write CV
18* ``<Q id>`` — sensor active report (server → client)
19* ``<q id>`` — sensor inactive report
20
21Responses follow the same shape; unknown messages decode to
22:class:`DccexUnknown` so listeners don't lose visibility.
23"""
24from __future__ import annotations
25
26from collections.abc import Awaitable, Callable
27from dataclasses import dataclass
28
29
30@dataclass(frozen=True, slots=True)
31class DccexMessage:
32    """A parsed message — command character + space-separated args."""
33
34    command: str          # first token (e.g. "1", "T", "Q")
35    args: tuple[str, ...] # remaining tokens
36
37    @property
38    def raw(self) -> str:
39        if not self.args:
40            return self.command
41        return f"{self.command} {' '.join(self.args)}"
42
43
44@dataclass(frozen=True, slots=True)
45class DccexUnknown(DccexMessage):
46    """Marker subclass: a well-formed bracket message we don't recognize."""
47
48
49def encode(msg: DccexMessage) -> bytes:
50    """Wrap a message in brackets and return as UTF-8 bytes."""
51    return f"<{msg.raw}>".encode()
52
53
54def decode(text: str) -> DccexMessage:
55    """Parse the content *between* the brackets into a :class:`DccexMessage`."""
56    text = text.strip()
57    if not text:
58        raise ValueError("empty DCC-EX message body")
59    tokens = text.split()
60    return DccexMessage(command=tokens[0], args=tuple(tokens[1:]))
61
62
63async def read_message(
64    read_byte: Callable[[], Awaitable[int]],
65) -> str:
66    """Read one bracketed message from the byte stream.
67
68    Bytes outside a ``<...>`` pair are silently discarded (matches DCC-EX
69    convention: newlines and other status text between messages are noise).
70    Returns the *unwrapped* contents (no brackets).
71    """
72    # Wait for opening bracket.
73    while True:
74        b = await read_byte()
75        if b == ord('<'):
76            break
77
78    out = bytearray()
79    while True:
80        b = await read_byte()
81        if b == ord('>'):
82            return out.decode("utf-8", errors="replace")
83        out.append(b)
@dataclass(frozen=True, slots=True)
class DccexMessage:
31@dataclass(frozen=True, slots=True)
32class DccexMessage:
33    """A parsed message — command character + space-separated args."""
34
35    command: str          # first token (e.g. "1", "T", "Q")
36    args: tuple[str, ...] # remaining tokens
37
38    @property
39    def raw(self) -> str:
40        if not self.args:
41            return self.command
42        return f"{self.command} {' '.join(self.args)}"

A parsed message — command character + space-separated args.

DccexMessage(command: str, args: tuple[str, ...])
command: str
args: tuple[str, ...]
raw: str
38    @property
39    def raw(self) -> str:
40        if not self.args:
41            return self.command
42        return f"{self.command} {' '.join(self.args)}"
@dataclass(frozen=True, slots=True)
class DccexUnknown(DccexMessage):
45@dataclass(frozen=True, slots=True)
46class DccexUnknown(DccexMessage):
47    """Marker subclass: a well-formed bracket message we don't recognize."""

Marker subclass: a well-formed bracket message we don't recognize.

DccexUnknown(command: str, args: tuple[str, ...])
Inherited Members
DccexMessage
command
args
raw
def encode(msg: DccexMessage) -> bytes:
50def encode(msg: DccexMessage) -> bytes:
51    """Wrap a message in brackets and return as UTF-8 bytes."""
52    return f"<{msg.raw}>".encode()

Wrap a message in brackets and return as UTF-8 bytes.

def decode(text: str) -> DccexMessage:
55def decode(text: str) -> DccexMessage:
56    """Parse the content *between* the brackets into a :class:`DccexMessage`."""
57    text = text.strip()
58    if not text:
59        raise ValueError("empty DCC-EX message body")
60    tokens = text.split()
61    return DccexMessage(command=tokens[0], args=tuple(tokens[1:]))

Parse the content between the brackets into a DccexMessage.

async def read_message(read_byte: Callable[[], Awaitable[int]]) -> str:
64async def read_message(
65    read_byte: Callable[[], Awaitable[int]],
66) -> str:
67    """Read one bracketed message from the byte stream.
68
69    Bytes outside a ``<...>`` pair are silently discarded (matches DCC-EX
70    convention: newlines and other status text between messages are noise).
71    Returns the *unwrapped* contents (no brackets).
72    """
73    # Wait for opening bracket.
74    while True:
75        b = await read_byte()
76        if b == ord('<'):
77            break
78
79    out = bytearray()
80    while True:
81        b = await read_byte()
82        if b == ord('>'):
83            return out.decode("utf-8", errors="replace")
84        out.append(b)

Read one bracketed message from the byte stream.

Bytes outside a <...> pair are silently discarded (matches DCC-EX convention: newlines and other status text between messages are noise). Returns the unwrapped contents (no brackets).