jmri_ncemonitor.messages

Typed Cab Bus observation messages.

The NCE Cab Bus has many message types; this module covers the ones a monitor user actually wants to see decoded. Anything else surfaces as CabBusUnknown carrying the raw bytes so callers don't lose it.

Addresses observed on the bus:

  • 0x00..0x3F (with high bit set on the wire → 0x80..0xBF) — cab IDs.
  • 0x60..0x7F — fast clock, accessory packets, master traffic.
  1"""Typed Cab Bus observation messages.
  2
  3The NCE Cab Bus has many message types; this module covers the ones a
  4monitor user actually wants to see decoded. Anything else surfaces as
  5:class:`CabBusUnknown` carrying the raw bytes so callers don't lose it.
  6
  7Addresses observed on the bus:
  8
  9* ``0x00..0x3F`` (with high bit set on the wire → ``0x80..0xBF``) — cab IDs.
 10* ``0x60..0x7F`` — fast clock, accessory packets, master traffic.
 11"""
 12from __future__ import annotations
 13
 14from dataclasses import dataclass
 15
 16from jmri_ncemonitor.codec import address_of, is_address_byte
 17
 18# --- base + unknown ---------------------------------------------------------
 19
 20
 21class CabBusMessage:
 22    """Marker base for every decoded message."""
 23    address: int
 24
 25
 26@dataclass(frozen=True, slots=True)
 27class CabBusUnknown(CabBusMessage):
 28    """A well-formed frame whose body we don't have a typed decoder for."""
 29    address: int
 30    data: bytes  # raw bytes (excluding the address marker byte itself)
 31
 32
 33# --- cab traffic ------------------------------------------------------------
 34
 35
 36@dataclass(frozen=True, slots=True)
 37class CabPoll(CabBusMessage):
 38    """Master polling cab *address* for its current state.
 39
 40    Data is the 1-byte poll command (typically 0x00 = "send state").
 41    """
 42    address: int
 43    command: int
 44
 45
 46@dataclass(frozen=True, slots=True)
 47class CabResponse(CabBusMessage):
 48    """Cab *address* responding with its keyboard / encoder state.
 49
 50    Fields are decoded from the standard 8-byte response payload:
 51
 52    * ``key`` — last key code pressed (0 if none)
 53    * ``speed`` — encoder speed value 0..127
 54    * ``direction`` — 0 = reverse, 1 = forward
 55    * ``function_low`` — bit-packed F0..F4
 56    * ``function_high`` — bit-packed F5..F8
 57    """
 58    address: int
 59    key: int
 60    speed: int
 61    direction: int
 62    function_low: int
 63    function_high: int
 64
 65
 66# --- master traffic ---------------------------------------------------------
 67
 68
 69# Master "fake address" used for non-cab messages on the bus.
 70ADDR_FAST_CLOCK = 0x60
 71ADDR_ACCESSORY  = 0x68
 72ADDR_AIU_UPDATE = 0x70
 73
 74
 75@dataclass(frozen=True, slots=True)
 76class FastClockUpdate(CabBusMessage):
 77    """Fast-clock packet observed on the bus."""
 78    address: int  # always ADDR_FAST_CLOCK
 79    hour: int
 80    minute: int
 81
 82
 83@dataclass(frozen=True, slots=True)
 84class AccessoryObserved(CabBusMessage):
 85    """An accessory-decoder command seen on the bus.
 86
 87    The monitor captures the address that was thrown/closed and which way.
 88    """
 89    address: int  # always ADDR_ACCESSORY
 90    accessory_address: int
 91    closed: bool
 92
 93
 94@dataclass(frozen=True, slots=True)
 95class AiuUpdate(CabBusMessage):
 96    """An AIU input update observed on the bus."""
 97    address: int  # always ADDR_AIU_UPDATE
 98    aiu_module: int
 99    input_word: int  # 16-bit raw input word
100
101
102# --- new observable messages (Stage 4.5) -----------------------------------
103
104
105ADDR_CONSIST_CMD  = 0x79   # consist add/remove observed on master → cab
106ADDR_MACRO_CMD    = 0x7B   # accessory macro triggered observed
107ADDR_MASTER_ACK   = 0x7C   # master acknowledgement of cab-sent command
108ADDR_THROTTLE_CMD = 0x78   # master-side throttle commands
109ADDR_PROG_RESP    = 0x7A   # programming response (ops + service mode)
110
111
112@dataclass(frozen=True, slots=True)
113class ThrottleObserved(CabBusMessage):
114    """A throttle command seen on the bus.
115
116    Observable form (master → cab broadcast):
117
118    * ``dcc_address`` — loco address the command targets
119    * ``speed`` — 0..127 wire form, 1 = e-stop
120    * ``forward`` — direction bit
121    * ``functions`` — 5-bit F0..F4 mask (the common short form)
122    """
123    address: int   # always ADDR_THROTTLE_CMD
124    dcc_address: int
125    speed: int
126    forward: bool
127    functions: int   # F0..F4 packed
128
129
130@dataclass(frozen=True, slots=True)
131class ProgrammingResponse(CabBusMessage):
132    """A response to a programming-track read seen on the bus.
133
134    Carries the CV number being read/written and the value byte. The
135    direction of the operation (read vs write) is encoded in the
136    ``operation`` byte the CS sends along.
137    """
138    address: int   # always ADDR_PROG_RESP
139    cv: int
140    value: int
141    operation: int   # 0 = read result, 1 = write ack, 2 = error
142
143
144@dataclass(frozen=True, slots=True)
145class ConsistObserved(CabBusMessage):
146    """A consist add / remove observed on the bus.
147
148    ``operation`` is 0 for add-to-consist, 1 for remove-from-consist,
149    2 for kill-consist. Address bytes carry the consist number
150    (1..127; matches the CV19 wire range).
151    """
152    address: int   # always ADDR_CONSIST_CMD
153    consist_number: int
154    loco_address: int
155    operation: int
156
157
158@dataclass(frozen=True, slots=True)
159class MacroObserved(CabBusMessage):
160    """A macro trigger observed on the bus.
161
162    Macros are pre-programmed sequences of accessory operations the
163    command station holds in memory. This message captures which
164    macro number was fired.
165    """
166    address: int   # always ADDR_MACRO_CMD
167    macro_number: int
168
169
170@dataclass(frozen=True, slots=True)
171class MasterAck(CabBusMessage):
172    """An acknowledgement byte from the master to a cab.
173
174    The master emits these one-byte ACKs after processing a cab
175    command. ``code`` is the raw ack code — non-zero values indicate
176    an error condition specific to whatever the cab last sent.
177    """
178    address: int   # always ADDR_MASTER_ACK
179    code: int
180
181
182# --- decode -----------------------------------------------------------------
183
184
185def decode(frame: bytes) -> CabBusMessage:
186    """Decode one Cab Bus frame into a typed message.
187
188    *frame* starts with the address byte (high bit set). Everything after
189    is body. Returns :class:`CabBusUnknown` for unrecognized frames.
190    """
191    if not frame or not is_address_byte(frame[0]):
192        raise ValueError("Cab Bus frame must start with an address byte")
193    addr = address_of(frame[0])
194    body = frame[1:]
195
196    # Cab range
197    if addr < 0x40:
198        if len(body) == 1:
199            return CabPoll(address=addr, command=body[0])
200        # JMRI's Cab Bus reference documents a fixed 9-byte cab
201        # response frame (5 state bytes + 3 pad + 1 checksum).
202        # Require the full length so short frames fall through to
203        # CabBusUnknown instead of silently succeeding on truncated
204        # data — the monitor operator wants to see truncation, not
205        # a plausible-looking half-decoded message.
206        if len(body) >= 9:
207            return CabResponse(
208                address=addr,
209                key=body[0],
210                speed=body[1] & 0x7F,
211                direction=(body[2] >> 0) & 0x01,
212                function_low=body[3] & 0x1F,
213                function_high=body[4] & 0x0F,
214            )
215
216    # Master-side traffic.
217    if addr == ADDR_FAST_CLOCK and len(body) >= 2:
218        return FastClockUpdate(address=addr, hour=body[0] & 0x7F, minute=body[1] & 0x7F)
219
220    if addr == ADDR_ACCESSORY and len(body) >= 3:
221        accy_addr = ((body[0] & 0x7F) << 7) | (body[1] & 0x7F)
222        closed = bool(body[2] & 0x01)
223        return AccessoryObserved(
224            address=addr, accessory_address=accy_addr + 1, closed=closed
225        )
226
227    if addr == ADDR_AIU_UPDATE and len(body) >= 3:
228        return AiuUpdate(
229            address=addr,
230            aiu_module=body[0] & 0x7F,
231            input_word=((body[1] & 0x7F) << 7) | (body[2] & 0x7F),
232        )
233
234    if addr == ADDR_THROTTLE_CMD and len(body) >= 4:
235        return ThrottleObserved(
236            address=addr,
237            dcc_address=((body[0] & 0x7F) << 7) | (body[1] & 0x7F),
238            speed=body[2] & 0x7F,
239            forward=bool(body[3] & 0x40),
240            functions=body[3] & 0x1F,
241        )
242
243    if addr == ADDR_PROG_RESP and len(body) >= 4:
244        return ProgrammingResponse(
245            address=addr,
246            cv=((body[0] & 0x7F) << 7) | (body[1] & 0x7F),
247            value=body[2] & 0xFF,
248            operation=body[3] & 0x03,
249        )
250
251    if addr == ADDR_CONSIST_CMD and len(body) >= 4:
252        return ConsistObserved(
253            address=addr,
254            consist_number=body[0] & 0x7F,
255            loco_address=((body[1] & 0x7F) << 7) | (body[2] & 0x7F),
256            operation=body[3] & 0x03,
257        )
258
259    if addr == ADDR_MACRO_CMD and len(body) >= 1:
260        return MacroObserved(
261            address=addr,
262            macro_number=body[0] & 0x7F,
263        )
264
265    if addr == ADDR_MASTER_ACK and len(body) >= 1:
266        return MasterAck(
267            address=addr,
268            code=body[0] & 0x7F,
269        )
270
271    return CabBusUnknown(address=addr, data=body)
272
273
274def encode(msg: CabBusMessage) -> bytes:
275    """Inverse of :func:`decode` — used only by tests / synthetic playback.
276
277    The real monitor never writes to the bus (it's passive); we provide
278    ``encode`` to construct test fixtures that feed the monitor through a
279    loopback transport.
280    """
281    if isinstance(msg, CabPoll):
282        return bytes([0x80 | msg.address, msg.command])
283    if isinstance(msg, CabResponse):
284        # 9-byte body: 5 state bytes + 3 pad + 1 checksum. The
285        # checksum is the XOR of the 5 state bytes on the wire — we
286        # emit it here so encode() → decode() round-trips against
287        # the tightened 9-byte length check in ``decode``.
288        state = (
289            msg.key & 0x7F,
290            msg.speed & 0x7F,
291            msg.direction & 0x7F,
292            msg.function_low & 0x7F,
293            msg.function_high & 0x7F,
294        )
295        checksum = 0
296        for b in state:
297            checksum ^= b
298        return bytes([
299            0x80 | msg.address,
300            *state,
301            0x00, 0x00, 0x00,
302            checksum & 0x7F,
303        ])
304    if isinstance(msg, FastClockUpdate):
305        return bytes([0x80 | ADDR_FAST_CLOCK, msg.hour & 0x7F, msg.minute & 0x7F])
306    if isinstance(msg, AccessoryObserved):
307        a = msg.accessory_address - 1
308        return bytes([
309            0x80 | ADDR_ACCESSORY,
310            (a >> 7) & 0x7F,
311            a & 0x7F,
312            0x01 if msg.closed else 0x00,
313        ])
314    if isinstance(msg, AiuUpdate):
315        return bytes([
316            0x80 | ADDR_AIU_UPDATE,
317            msg.aiu_module & 0x7F,
318            (msg.input_word >> 7) & 0x7F,
319            msg.input_word & 0x7F,
320        ])
321    if isinstance(msg, ThrottleObserved):
322        return bytes([
323            0x80 | ADDR_THROTTLE_CMD,
324            (msg.dcc_address >> 7) & 0x7F,
325            msg.dcc_address & 0x7F,
326            msg.speed & 0x7F,
327            (msg.functions & 0x1F) | (0x40 if msg.forward else 0),
328        ])
329    if isinstance(msg, ProgrammingResponse):
330        return bytes([
331            0x80 | ADDR_PROG_RESP,
332            (msg.cv >> 7) & 0x7F,
333            msg.cv & 0x7F,
334            msg.value & 0x7F,
335            msg.operation & 0x7F,
336        ])
337    if isinstance(msg, ConsistObserved):
338        return bytes([
339            0x80 | ADDR_CONSIST_CMD,
340            msg.consist_number & 0x7F,
341            (msg.loco_address >> 7) & 0x7F,
342            msg.loco_address & 0x7F,
343            msg.operation & 0x7F,
344        ])
345    if isinstance(msg, MacroObserved):
346        return bytes([
347            0x80 | ADDR_MACRO_CMD,
348            msg.macro_number & 0x7F,
349        ])
350    if isinstance(msg, MasterAck):
351        return bytes([
352            0x80 | ADDR_MASTER_ACK,
353            msg.code & 0x7F,
354        ])
355    if isinstance(msg, CabBusUnknown):
356        return bytes([0x80 | msg.address]) + msg.data
357    raise NotImplementedError(f"encode not implemented for {type(msg).__name__}")
class CabBusMessage:
22class CabBusMessage:
23    """Marker base for every decoded message."""
24    address: int

Marker base for every decoded message.

address: int
@dataclass(frozen=True, slots=True)
class CabBusUnknown(CabBusMessage):
27@dataclass(frozen=True, slots=True)
28class CabBusUnknown(CabBusMessage):
29    """A well-formed frame whose body we don't have a typed decoder for."""
30    address: int
31    data: bytes  # raw bytes (excluding the address marker byte itself)

A well-formed frame whose body we don't have a typed decoder for.

CabBusUnknown(address: int, data: bytes)
address: int
data: bytes
@dataclass(frozen=True, slots=True)
class CabPoll(CabBusMessage):
37@dataclass(frozen=True, slots=True)
38class CabPoll(CabBusMessage):
39    """Master polling cab *address* for its current state.
40
41    Data is the 1-byte poll command (typically 0x00 = "send state").
42    """
43    address: int
44    command: int

Master polling cab address for its current state.

Data is the 1-byte poll command (typically 0x00 = "send state").

CabPoll(address: int, command: int)
address: int
command: int
@dataclass(frozen=True, slots=True)
class CabResponse(CabBusMessage):
47@dataclass(frozen=True, slots=True)
48class CabResponse(CabBusMessage):
49    """Cab *address* responding with its keyboard / encoder state.
50
51    Fields are decoded from the standard 8-byte response payload:
52
53    * ``key`` — last key code pressed (0 if none)
54    * ``speed`` — encoder speed value 0..127
55    * ``direction`` — 0 = reverse, 1 = forward
56    * ``function_low`` — bit-packed F0..F4
57    * ``function_high`` — bit-packed F5..F8
58    """
59    address: int
60    key: int
61    speed: int
62    direction: int
63    function_low: int
64    function_high: int

Cab address responding with its keyboard / encoder state.

Fields are decoded from the standard 8-byte response payload:

CabResponse( address: int, key: int, speed: int, direction: int, function_low: int, function_high: int)
address: int
key: int
speed: int
direction: int
function_low: int
function_high: int
ADDR_FAST_CLOCK = 96
ADDR_ACCESSORY = 104
ADDR_AIU_UPDATE = 112
@dataclass(frozen=True, slots=True)
class FastClockUpdate(CabBusMessage):
76@dataclass(frozen=True, slots=True)
77class FastClockUpdate(CabBusMessage):
78    """Fast-clock packet observed on the bus."""
79    address: int  # always ADDR_FAST_CLOCK
80    hour: int
81    minute: int

Fast-clock packet observed on the bus.

FastClockUpdate(address: int, hour: int, minute: int)
address: int
hour: int
minute: int
@dataclass(frozen=True, slots=True)
class AccessoryObserved(CabBusMessage):
84@dataclass(frozen=True, slots=True)
85class AccessoryObserved(CabBusMessage):
86    """An accessory-decoder command seen on the bus.
87
88    The monitor captures the address that was thrown/closed and which way.
89    """
90    address: int  # always ADDR_ACCESSORY
91    accessory_address: int
92    closed: bool

An accessory-decoder command seen on the bus.

The monitor captures the address that was thrown/closed and which way.

AccessoryObserved(address: int, accessory_address: int, closed: bool)
address: int
accessory_address: int
closed: bool
@dataclass(frozen=True, slots=True)
class AiuUpdate(CabBusMessage):
 95@dataclass(frozen=True, slots=True)
 96class AiuUpdate(CabBusMessage):
 97    """An AIU input update observed on the bus."""
 98    address: int  # always ADDR_AIU_UPDATE
 99    aiu_module: int
100    input_word: int  # 16-bit raw input word

An AIU input update observed on the bus.

AiuUpdate(address: int, aiu_module: int, input_word: int)
address: int
aiu_module: int
input_word: int
ADDR_CONSIST_CMD = 121
ADDR_MACRO_CMD = 123
ADDR_MASTER_ACK = 124
ADDR_THROTTLE_CMD = 120
ADDR_PROG_RESP = 122
@dataclass(frozen=True, slots=True)
class ThrottleObserved(CabBusMessage):
113@dataclass(frozen=True, slots=True)
114class ThrottleObserved(CabBusMessage):
115    """A throttle command seen on the bus.
116
117    Observable form (master → cab broadcast):
118
119    * ``dcc_address`` — loco address the command targets
120    * ``speed`` — 0..127 wire form, 1 = e-stop
121    * ``forward`` — direction bit
122    * ``functions`` — 5-bit F0..F4 mask (the common short form)
123    """
124    address: int   # always ADDR_THROTTLE_CMD
125    dcc_address: int
126    speed: int
127    forward: bool
128    functions: int   # F0..F4 packed

A throttle command seen on the bus.

Observable form (master → cab broadcast):

  • dcc_address — loco address the command targets
  • speed — 0..127 wire form, 1 = e-stop
  • forward — direction bit
  • functions — 5-bit F0..F4 mask (the common short form)
ThrottleObserved( address: int, dcc_address: int, speed: int, forward: bool, functions: int)
address: int
dcc_address: int
speed: int
forward: bool
functions: int
@dataclass(frozen=True, slots=True)
class ProgrammingResponse(CabBusMessage):
131@dataclass(frozen=True, slots=True)
132class ProgrammingResponse(CabBusMessage):
133    """A response to a programming-track read seen on the bus.
134
135    Carries the CV number being read/written and the value byte. The
136    direction of the operation (read vs write) is encoded in the
137    ``operation`` byte the CS sends along.
138    """
139    address: int   # always ADDR_PROG_RESP
140    cv: int
141    value: int
142    operation: int   # 0 = read result, 1 = write ack, 2 = error

A response to a programming-track read seen on the bus.

Carries the CV number being read/written and the value byte. The direction of the operation (read vs write) is encoded in the operation byte the CS sends along.

ProgrammingResponse(address: int, cv: int, value: int, operation: int)
address: int
cv: int
value: int
operation: int
@dataclass(frozen=True, slots=True)
class ConsistObserved(CabBusMessage):
145@dataclass(frozen=True, slots=True)
146class ConsistObserved(CabBusMessage):
147    """A consist add / remove observed on the bus.
148
149    ``operation`` is 0 for add-to-consist, 1 for remove-from-consist,
150    2 for kill-consist. Address bytes carry the consist number
151    (1..127; matches the CV19 wire range).
152    """
153    address: int   # always ADDR_CONSIST_CMD
154    consist_number: int
155    loco_address: int
156    operation: int

A consist add / remove observed on the bus.

operation is 0 for add-to-consist, 1 for remove-from-consist, 2 for kill-consist. Address bytes carry the consist number (1..127; matches the CV19 wire range).

ConsistObserved(address: int, consist_number: int, loco_address: int, operation: int)
address: int
consist_number: int
loco_address: int
operation: int
@dataclass(frozen=True, slots=True)
class MacroObserved(CabBusMessage):
159@dataclass(frozen=True, slots=True)
160class MacroObserved(CabBusMessage):
161    """A macro trigger observed on the bus.
162
163    Macros are pre-programmed sequences of accessory operations the
164    command station holds in memory. This message captures which
165    macro number was fired.
166    """
167    address: int   # always ADDR_MACRO_CMD
168    macro_number: int

A macro trigger observed on the bus.

Macros are pre-programmed sequences of accessory operations the command station holds in memory. This message captures which macro number was fired.

MacroObserved(address: int, macro_number: int)
address: int
macro_number: int
@dataclass(frozen=True, slots=True)
class MasterAck(CabBusMessage):
171@dataclass(frozen=True, slots=True)
172class MasterAck(CabBusMessage):
173    """An acknowledgement byte from the master to a cab.
174
175    The master emits these one-byte ACKs after processing a cab
176    command. ``code`` is the raw ack code — non-zero values indicate
177    an error condition specific to whatever the cab last sent.
178    """
179    address: int   # always ADDR_MASTER_ACK
180    code: int

An acknowledgement byte from the master to a cab.

The master emits these one-byte ACKs after processing a cab command. code is the raw ack code — non-zero values indicate an error condition specific to whatever the cab last sent.

MasterAck(address: int, code: int)
address: int
code: int
def decode(frame: bytes) -> CabBusMessage:
186def decode(frame: bytes) -> CabBusMessage:
187    """Decode one Cab Bus frame into a typed message.
188
189    *frame* starts with the address byte (high bit set). Everything after
190    is body. Returns :class:`CabBusUnknown` for unrecognized frames.
191    """
192    if not frame or not is_address_byte(frame[0]):
193        raise ValueError("Cab Bus frame must start with an address byte")
194    addr = address_of(frame[0])
195    body = frame[1:]
196
197    # Cab range
198    if addr < 0x40:
199        if len(body) == 1:
200            return CabPoll(address=addr, command=body[0])
201        # JMRI's Cab Bus reference documents a fixed 9-byte cab
202        # response frame (5 state bytes + 3 pad + 1 checksum).
203        # Require the full length so short frames fall through to
204        # CabBusUnknown instead of silently succeeding on truncated
205        # data — the monitor operator wants to see truncation, not
206        # a plausible-looking half-decoded message.
207        if len(body) >= 9:
208            return CabResponse(
209                address=addr,
210                key=body[0],
211                speed=body[1] & 0x7F,
212                direction=(body[2] >> 0) & 0x01,
213                function_low=body[3] & 0x1F,
214                function_high=body[4] & 0x0F,
215            )
216
217    # Master-side traffic.
218    if addr == ADDR_FAST_CLOCK and len(body) >= 2:
219        return FastClockUpdate(address=addr, hour=body[0] & 0x7F, minute=body[1] & 0x7F)
220
221    if addr == ADDR_ACCESSORY and len(body) >= 3:
222        accy_addr = ((body[0] & 0x7F) << 7) | (body[1] & 0x7F)
223        closed = bool(body[2] & 0x01)
224        return AccessoryObserved(
225            address=addr, accessory_address=accy_addr + 1, closed=closed
226        )
227
228    if addr == ADDR_AIU_UPDATE and len(body) >= 3:
229        return AiuUpdate(
230            address=addr,
231            aiu_module=body[0] & 0x7F,
232            input_word=((body[1] & 0x7F) << 7) | (body[2] & 0x7F),
233        )
234
235    if addr == ADDR_THROTTLE_CMD and len(body) >= 4:
236        return ThrottleObserved(
237            address=addr,
238            dcc_address=((body[0] & 0x7F) << 7) | (body[1] & 0x7F),
239            speed=body[2] & 0x7F,
240            forward=bool(body[3] & 0x40),
241            functions=body[3] & 0x1F,
242        )
243
244    if addr == ADDR_PROG_RESP and len(body) >= 4:
245        return ProgrammingResponse(
246            address=addr,
247            cv=((body[0] & 0x7F) << 7) | (body[1] & 0x7F),
248            value=body[2] & 0xFF,
249            operation=body[3] & 0x03,
250        )
251
252    if addr == ADDR_CONSIST_CMD and len(body) >= 4:
253        return ConsistObserved(
254            address=addr,
255            consist_number=body[0] & 0x7F,
256            loco_address=((body[1] & 0x7F) << 7) | (body[2] & 0x7F),
257            operation=body[3] & 0x03,
258        )
259
260    if addr == ADDR_MACRO_CMD and len(body) >= 1:
261        return MacroObserved(
262            address=addr,
263            macro_number=body[0] & 0x7F,
264        )
265
266    if addr == ADDR_MASTER_ACK and len(body) >= 1:
267        return MasterAck(
268            address=addr,
269            code=body[0] & 0x7F,
270        )
271
272    return CabBusUnknown(address=addr, data=body)

Decode one Cab Bus frame into a typed message.

frame starts with the address byte (high bit set). Everything after is body. Returns CabBusUnknown for unrecognized frames.

def encode(msg: CabBusMessage) -> bytes:
275def encode(msg: CabBusMessage) -> bytes:
276    """Inverse of :func:`decode` — used only by tests / synthetic playback.
277
278    The real monitor never writes to the bus (it's passive); we provide
279    ``encode`` to construct test fixtures that feed the monitor through a
280    loopback transport.
281    """
282    if isinstance(msg, CabPoll):
283        return bytes([0x80 | msg.address, msg.command])
284    if isinstance(msg, CabResponse):
285        # 9-byte body: 5 state bytes + 3 pad + 1 checksum. The
286        # checksum is the XOR of the 5 state bytes on the wire — we
287        # emit it here so encode() → decode() round-trips against
288        # the tightened 9-byte length check in ``decode``.
289        state = (
290            msg.key & 0x7F,
291            msg.speed & 0x7F,
292            msg.direction & 0x7F,
293            msg.function_low & 0x7F,
294            msg.function_high & 0x7F,
295        )
296        checksum = 0
297        for b in state:
298            checksum ^= b
299        return bytes([
300            0x80 | msg.address,
301            *state,
302            0x00, 0x00, 0x00,
303            checksum & 0x7F,
304        ])
305    if isinstance(msg, FastClockUpdate):
306        return bytes([0x80 | ADDR_FAST_CLOCK, msg.hour & 0x7F, msg.minute & 0x7F])
307    if isinstance(msg, AccessoryObserved):
308        a = msg.accessory_address - 1
309        return bytes([
310            0x80 | ADDR_ACCESSORY,
311            (a >> 7) & 0x7F,
312            a & 0x7F,
313            0x01 if msg.closed else 0x00,
314        ])
315    if isinstance(msg, AiuUpdate):
316        return bytes([
317            0x80 | ADDR_AIU_UPDATE,
318            msg.aiu_module & 0x7F,
319            (msg.input_word >> 7) & 0x7F,
320            msg.input_word & 0x7F,
321        ])
322    if isinstance(msg, ThrottleObserved):
323        return bytes([
324            0x80 | ADDR_THROTTLE_CMD,
325            (msg.dcc_address >> 7) & 0x7F,
326            msg.dcc_address & 0x7F,
327            msg.speed & 0x7F,
328            (msg.functions & 0x1F) | (0x40 if msg.forward else 0),
329        ])
330    if isinstance(msg, ProgrammingResponse):
331        return bytes([
332            0x80 | ADDR_PROG_RESP,
333            (msg.cv >> 7) & 0x7F,
334            msg.cv & 0x7F,
335            msg.value & 0x7F,
336            msg.operation & 0x7F,
337        ])
338    if isinstance(msg, ConsistObserved):
339        return bytes([
340            0x80 | ADDR_CONSIST_CMD,
341            msg.consist_number & 0x7F,
342            (msg.loco_address >> 7) & 0x7F,
343            msg.loco_address & 0x7F,
344            msg.operation & 0x7F,
345        ])
346    if isinstance(msg, MacroObserved):
347        return bytes([
348            0x80 | ADDR_MACRO_CMD,
349            msg.macro_number & 0x7F,
350        ])
351    if isinstance(msg, MasterAck):
352        return bytes([
353            0x80 | ADDR_MASTER_ACK,
354            msg.code & 0x7F,
355        ])
356    if isinstance(msg, CabBusUnknown):
357        return bytes([0x80 | msg.address]) + msg.data
358    raise NotImplementedError(f"encode not implemented for {type(msg).__name__}")

Inverse of decode() — used only by tests / synthetic playback.

The real monitor never writes to the bus (it's passive); we provide encode to construct test fixtures that feed the monitor through a loopback transport.