jmri_nce.messages

NCE commands and replies.

Each command is a fixed-shape request → fixed-shape reply pair. We model this declaratively with NceCommand and a small set of factory functions that build commands for the opcodes the rest of the package needs. New commands can be added without touching the traffic controller.

Every factory in this file must trace to a specific JMRI Java NceMessage or NceBinaryCommand factory. The wire format is audit-checked against JMRI's NceMessage.java opcode constants and NceMonBinaryBundle.properties. Do not add a factory without citing the JMRI source it mirrors.

  1"""NCE commands and replies.
  2
  3Each command is a fixed-shape request → fixed-shape reply pair. We model
  4this declaratively with :class:`NceCommand` and a small set of factory
  5functions that build commands for the opcodes the rest of the package
  6needs. New commands can be added without touching the traffic controller.
  7
  8**Every factory in this file must trace to a specific JMRI Java
  9``NceMessage`` or ``NceBinaryCommand`` factory.** The wire format is
 10audit-checked against JMRI's ``NceMessage.java`` opcode constants and
 11``NceMonBinaryBundle.properties``. Do not add a factory without citing
 12the JMRI source it mirrors.
 13"""
 14from __future__ import annotations
 15
 16from dataclasses import dataclass
 17
 18from jmri_nce.opcodes import (
 19    CS_CLOCK_ADDR,
 20    LONG_ADDRESS_OFFSET,
 21    OP_ACCY_DECODER_OPS,
 22    OP_AIU_BASE_QUERY,
 23    OP_CLOCK_SET_1224,
 24    OP_CLOCK_SET_RATIO,
 25    OP_CLOCK_START,
 26    OP_CLOCK_STOP,
 27    OP_ENTER_PROG_MODE,
 28    OP_EXIT_PROG_MODE,
 29    OP_NCE_LOCO_CMD,
 30    OP_POWER_OFF,
 31    OP_POWER_ON,
 32    OP_QUERY_SW_REV,
 33    OP_QUEUE_PACKET_BASE,
 34    OP_RD_CV_DIRECT_MODE,
 35    OP_READ_16_MEM,
 36    OP_WR_CV_DIRECT_MODE,
 37    OP_WRITE_N_MEM,
 38)
 39
 40
 41@dataclass(frozen=True, slots=True)
 42class NceCommand:
 43    """One outbound request.
 44
 45    NCE has two distinct reply shapes:
 46
 47    * **Action commands** (power on/off, accessory throw, write CV, ...)
 48      reply with a single ACK byte (``!`` on success). ``reply_len`` is
 49      the number of *data* bytes that precede the ACK — usually 0.
 50    * **Read commands** (read memory, read AIU, ...) reply with exactly
 51      *reply_len* bytes of raw data, no trailing ACK. Set ``is_read=True``
 52      and the traffic controller will not consume an ACK byte after.
 53    """
 54
 55    name: str
 56    request: bytes
 57    reply_len: int = 0
 58    timeout_s: float = 0.5
 59    is_read: bool = False
 60
 61
 62@dataclass(frozen=True, slots=True)
 63class NceReply:
 64    """A successful response. ``data`` excludes the ACK byte itself."""
 65
 66    command: NceCommand
 67    data: bytes
 68
 69
 70class NceCommandError(Exception):
 71    """The command station replied with a non-OK ACK byte."""
 72
 73    def __init__(self, command: NceCommand, ack: bytes, data: bytes) -> None:
 74        super().__init__(
 75            f"NCE command {command.name!r} failed: ack={ack!r} data={data!r}"
 76        )
 77        self.command = command
 78        self.ack = ack
 79        self.data = data
 80
 81
 82# --- factory functions ------------------------------------------------------
 83
 84
 85def power_on() -> NceCommand:
 86    """JMRI: ``NceMessage.getEnableMain`` (opcode 137)."""
 87    return NceCommand(name="POWER_ON", request=bytes([OP_POWER_ON]))
 88
 89
 90def power_off() -> NceCommand:
 91    """JMRI: ``NceMessage.getKillMain`` (opcode 139)."""
 92    return NceCommand(name="POWER_OFF", request=bytes([OP_POWER_OFF]))
 93
 94
 95def read_16_memory(address: int) -> NceCommand:
 96    """Read exactly 16 bytes at *address*.
 97
 98    JMRI: ``NceBinaryCommand.accMemoryRead`` (opcode 0x8F, READ16_CMD).
 99    NCE has no "read N bytes" opcode — only fixed 16-byte or 1-byte
100    reads. For arbitrary lengths, issue multiple 16-byte reads.
101    """
102    return NceCommand(
103        name=f"READ16({address:#06x})",
104        request=bytes([OP_READ_16_MEM, (address >> 8) & 0xFF, address & 0xFF]),
105        reply_len=16,
106        is_read=True,
107    )
108
109
110# Backwards-compatible alias for the turnout monitor's existing call site.
111# The name emphasises that the accessory-state memory is the typical
112# caller, but the opcode itself is address-agnostic.
113def read_accessory_memory(address: int) -> NceCommand:
114    """Read 16 bytes of accessory-state memory at *address* (turnout poll).
115
116    Alias for :func:`read_16_memory` — the underlying opcode 0x8F works
117    at arbitrary addresses despite the historical "accessory" name.
118    """
119    return read_16_memory(address)
120
121
122def write_memory(address: int, data: bytes) -> NceCommand:
123    """Write *data* (1..16 bytes) starting at the 16-bit *address*.
124
125    JMRI: ``NceBinaryCommand.accMemoryWriteN`` (opcode 0x8E, WRITE_N_CMD).
126
127    IMPORTANT: The CS expects a **fixed 20-byte request** regardless of
128    payload length — JMRI Java allocates ``new byte[20]`` and fills only
129    the first ``4 + data.length`` bytes, leaving the rest zero-padded.
130    We match that shape byte-for-byte; without the pad the CS waits
131    forever for the remaining bytes and the ACK never arrives.
132    """
133    if not 1 <= len(data) <= 16:
134        raise ValueError(f"write length must be 1..16, got {len(data)}")
135    header = bytes([
136        OP_WRITE_N_MEM,
137        (address >> 8) & 0xFF, address & 0xFF,
138        len(data) & 0xFF,
139    ])
140    padding = bytes(20 - len(header) - len(data))
141    return NceCommand(
142        name=f"WRITE_N_MEM({address:#06x},{len(data)})",
143        request=header + data + padding,
144    )
145
146
147def accessory_decoder_normal_ops(address: int, on: bool) -> NceCommand:
148    """Operate an accessory via the NCE binary accessory-decoder command.
149
150    JMRI: ``NceBinaryCommand.accDecoder`` (opcode 0xAD, ACC_CMD).
151
152    *address* is the accessory address (1..2044). ``on=True`` selects the
153    "closed" (normal) output; ``on=False`` selects "thrown" (reverse).
154    Wire form: ``[0xAD, addr_hi, addr_lo, dir, 0x00]`` where *dir* is
155    the NMRA accessory-direction nibble (3=closed, 4=thrown).
156    """
157    if not 1 <= address <= 2044:
158        raise ValueError(f"accessory address must be 1..2044, got {address}")
159    direction = 3 if on else 4
160    return NceCommand(
161        name=f"ACCY({address},{'closed' if on else 'thrown'})",
162        request=bytes([
163            OP_ACCY_DECODER_OPS,
164            (address >> 8) & 0xFF,
165            address & 0xFF,
166            direction,
167            0x00,
168        ]),
169    )
170
171
172def nce_loco_cmd(
173    loco_address: int, *, long_address: bool, sub_cmd: int, data: int
174) -> NceCommand:
175    """Throttle command via NCE binary ``nceLocoCmd``.
176
177    JMRI: ``NceBinaryCommand.nceLocoCmd`` (opcode 0xA2, LOCO_CMD).
178
179    Modern firmware (commandOptions >= 10) uses this 5-byte command for
180    speed, direction, and function changes.
181
182        [0xA2] [addr_hi] [addr_lo] [sub_cmd] [data]   (5 bytes)
183
184    For long addresses, the wire address is ``loco_address + 0xC000``
185    so the CS can disambiguate from short addresses with the same value.
186
187    *sub_cmd* values come from :mod:`jmri_nce.opcodes`:
188
189    * ``LOCO_SUB_128_FWD`` / ``LOCO_SUB_128_REV`` — 128-step speed
190    * ``LOCO_SUB_ESTOP_FWD`` / ``LOCO_SUB_ESTOP_REV`` — emergency stop
191    * ``LOCO_SUB_FUNC_GROUP_1..5`` — function bitmasks
192    """
193    if not 0 <= sub_cmd <= 23:
194        raise ValueError(f"sub_cmd must be 0..23, got {sub_cmd}")
195    if not 0 <= data <= 255:
196        raise ValueError(f"data byte must be 0..255, got {data}")
197    wire = loco_address + LONG_ADDRESS_OFFSET if long_address else loco_address
198    return NceCommand(
199        name=f"LOCO({loco_address}{'L' if long_address else 'S'},sub={sub_cmd},data={data})",
200        request=bytes([
201            OP_NCE_LOCO_CMD,
202            (wire >> 8) & 0xFF,
203            wire & 0xFF,
204            sub_cmd & 0xFF,
205            data & 0xFF,
206        ]),
207    )
208
209
210def query_command_version() -> NceCommand:
211    """Read the command station firmware version. Reply is 3 bytes.
212
213    JMRI: ``NceBinaryCommand.getNceEpromRev`` (opcode 0xAA, SW_REV_CMD).
214    Safe: read-only, no side effects.
215    """
216    return NceCommand(
217        name="QUERY_SW_REV",
218        request=bytes([OP_QUERY_SW_REV]),
219        reply_len=3,
220        is_read=True,
221    )
222
223
224def enter_programming_mode() -> NceCommand:
225    """Put the CS into programming mode.
226
227    JMRI: ``NceMessage.getEnterProgMode`` (opcode 0x9E). Direct-mode
228    CV read/write commands (``0xA8``, ``0xA9``) require the CS to be
229    in this mode — outside it, the CS immediately NoAcks the request
230    with ``'0'`` (0x30) without running any DCC packets on the prog
231    track. Java JMRI's ``NceProgrammer`` wraps every direct-mode op
232    with this + :func:`exit_programming_mode`.
233
234    Reply is a single ACK byte (``!`` on success).
235    """
236    return NceCommand(
237        name="ENTER_PROG",
238        request=bytes([OP_ENTER_PROG_MODE]),
239        # Fast: CS just flips a state flag. Give it a generous window
240        # for slow USB-serial adapters.
241        timeout_s=1.0,
242    )
243
244
245def exit_programming_mode() -> NceCommand:
246    """Take the CS out of programming mode.
247
248    JMRI: ``NceMessage.getExitProgMode`` (opcode 0x9F). Send this after
249    a direct-mode CV op so the CS returns to normal operation and
250    layout traffic resumes. Reply is ``!`` on success.
251    """
252    return NceCommand(
253        name="EXIT_PROG",
254        request=bytes([OP_EXIT_PROG_MODE]),
255        timeout_s=1.0,
256    )
257
258
259def direct_mode_write_cv(cv: int, value: int) -> NceCommand:
260    """Write a CV on the programming track (direct mode).
261
262    JMRI: ``NceMessage.getWriteDirectCV`` (opcode 0xA8, WRITE_DIR_CV_CMD).
263    Layout: ``[0xA8, cv_hi, cv_lo, value]``.
264    """
265    if not 1 <= cv <= 1024:
266        raise ValueError(f"CV must be 1..1024, got {cv}")
267    if not 0 <= value <= 255:
268        raise ValueError(f"value must be 0..255, got {value}")
269    return NceCommand(
270        name=f"WR_CV_DIRECT({cv},{value})",
271        request=bytes([OP_WR_CV_DIRECT_MODE, (cv >> 8) & 0xFF, cv & 0xFF, value]),
272        timeout_s=6.0,  # service-mode writes take seconds
273    )
274
275
276def direct_mode_read_cv(cv: int) -> NceCommand:
277    """Read a CV on the programming track.
278
279    JMRI: ``NceMessage.getReadDirectCV`` (opcode 0xA9, READ_DIR_CV_CMD).
280    Reply is 1 byte (the CV value).
281    """
282    if not 1 <= cv <= 1024:
283        raise ValueError(f"CV must be 1..1024, got {cv}")
284    return NceCommand(
285        name=f"RD_CV_DIRECT({cv})",
286        request=bytes([OP_RD_CV_DIRECT_MODE, (cv >> 8) & 0xFF, cv & 0xFF]),
287        reply_len=1,
288        timeout_s=6.0,
289        is_read=True,
290    )
291
292
293def ops_mode_write_cv(
294    loco_address: int, cv: int, value: int, *, long_address: bool = False,
295) -> NceCommand:
296    """Ops-mode CV byte-write (Programming On the Main) to a specific loco.
297
298    JMRI: ``NceMessage.sendPacketMessage`` builds the equivalent wrapped
299    packet in ``NceProgrammer.writeCV`` when ops mode is selected.
300
301    Wire form: build a raw NMRA CV Access instruction packet, then wrap
302    it in the appropriate ``QUEUEn_TMP_CMD`` envelope where ``n`` is the
303    packet's byte length (opcode ``0x93 + (packet_len - 3)`` covers
304    3..6-byte packets, per :data:`OP_QUEUE_PACKET_BASE`). NCE has no
305    native ops-mode CV *read* (RailCom is out of PowerPro's scope) — do
306    not add ``ops_mode_read_cv``; use direct mode for verified reads.
307
308    NMRA S-9.2.1 CV Access — Long Form / Byte Write:
309
310        <address bytes>  1110 CCVV   VVVVVVVV   DDDDDDDD  <XOR>
311
312    * short address (1..127): 1 address byte ``0AAAAAAA``
313    * long address (128..9999): 2 address bytes ``11AAAAAA AAAAAAAA``
314    * ``CC = 11`` → byte-write (10 = verify, 01 = bit-manipulate)
315    * ``VVVVVVVVVV`` = CV number minus 1, 10-bit
316    * ``DDDDDDDD`` = value
317    * trailing XOR of all preceding bytes
318
319    Reply: single ACK byte from the CS (``!`` on success). The packet
320    goes on the wire from the CS; no decoder ack round-trip.
321    """
322    if not 1 <= cv <= 1024:
323        raise ValueError(f"CV must be 1..1024, got {cv}")
324    if not 0 <= value <= 255:
325        raise ValueError(f"value must be 0..255, got {value}")
326
327    if long_address:
328        if not 128 <= loco_address <= 9999:
329            raise ValueError(
330                f"long address must be 128..9999, got {loco_address}"
331            )
332        # 14-bit long address in the DCC packet: top 6 bits go into the
333        # low 6 bits of 0xC0, low 8 bits stand alone.
334        addr_bytes = [
335            0xC0 | ((loco_address >> 8) & 0x3F),
336            loco_address & 0xFF,
337        ]
338    else:
339        if not 1 <= loco_address <= 127:
340            raise ValueError(
341                f"short address must be 1..127, got {loco_address}"
342            )
343        addr_bytes = [loco_address & 0x7F]
344
345    # NMRA encodes CV number minus one, spread over two bytes: 2 high
346    # bits ride the instruction opcode, 8 low bits follow.
347    cv_index = cv - 1
348    inst_hi = 0xEC | ((cv_index >> 8) & 0x03)   # 1110_1100 with CC=11 + VV
349    inst_lo = cv_index & 0xFF
350
351    packet = bytes(addr_bytes + [inst_hi, inst_lo, value & 0xFF])
352    xor = 0
353    for b in packet:
354        xor ^= b
355    dcc_packet = packet + bytes([xor])
356
357    # QUEUEn_TMP_CMD wire form (verified against JMRI Java NCE Command
358    # Monitor trace on real PowerPro hardware, 2026-07-25):
359    #
360    #   [queue_opcode] [send_count] [dcc_packet_bytes...]
361    #
362    # * ``queue_opcode`` = 0x93..0x96 (packet length 3..6)
363    # * ``send_count``   = number of times the CS retransmits the
364    #                      packet on the wire. NMRA POM recommends
365    #                      3–5 repeats for reliability; JMRI Java uses
366    #                      5, which we mirror. The CS reads this byte
367    #                      as part of the command — earlier versions
368    #                      of this code omitted it, so the CS would
369    #                      read our first packet byte as the send
370    #                      count and then time out ~5 s waiting for a
371    #                      packet byte that never came, returning
372    #                      NoAck (``0``).
373    # * ``dcc_packet``   = 3..6 bytes: address + CV Access instruction
374    #                      + XOR checksum, per NMRA S-9.2.1.
375    packet_len = len(dcc_packet)
376    if not 3 <= packet_len <= 6:
377        # Defensive: the packet builder should never produce a length
378        # outside 3..6, but if a future edit changes the encoding this
379        # catches the mistake before the wrong opcode goes out.
380        raise ValueError(
381            f"internal error: DCC packet length {packet_len} outside 3..6",
382        )
383    queue_opcode = OP_QUEUE_PACKET_BASE + (packet_len - 3)
384    send_count = 5   # matches JMRI Java default
385
386    return NceCommand(
387        name=f"WR_CV_OPS({loco_address}{'L' if long_address else 'S'},{cv},{value})",
388        request=bytes([queue_opcode, send_count]) + dcc_packet,
389        # Ops-mode writes are fire-and-forget on the wire; only the CS
390        # queue-accept ACK ('!') comes back. No data bytes.
391        #
392        # ``timeout_s`` is 5.0 s — matches JMRI Java's timeout for
393        # equivalent operations. NCE PowerPro's actual queue-then-ack
394        # latency depends on the CS's current DCC output load: with
395        # active throttles + fast-clock ticks + accessory refresh, we
396        # observed >2 s on real hardware even after bumping from the
397        # 0.5 s default. 5 s gives comfortable headroom; a real hang
398        # (packet dropped, CS off-line) still surfaces before the user
399        # gives up on the batch.
400        timeout_s=5.0,
401    )
402
403
404def read_clock() -> NceCommand:
405    """Read the CS fast clock (16 bytes at ``CS_CLOCK_ADDR``).
406
407    JMRI: ``NceClockControl.issueReadOnlyRequest`` uses
408    ``NceBinaryCommand.accMemoryRead(getClockAddr())`` (opcode 0x8F).
409    Wire request: 3 bytes ``[0x8F, addr_hi, addr_lo]``; reply is 16
410    data bytes. Passive read — safe to send even when the clock isn't
411    running.
412
413    Reply byte layout mirrors ``NceClockControl.readClockPacket``:
414
415    * ``[0]`` scale — CS-internal divisor, ``ratio = 250 / scale``
416    * ``[1]`` tick
417    * ``[2]`` seconds (binary, not BCD)
418    * ``[3]`` minutes
419    * ``[4]`` hours (1..12 in 12h mode; 0..23 in 24h)
420    * ``[5]`` AM/PM flag (ASCII ``'A'`` = 65 → AM)
421    * ``[6]`` 12/24 mode (1 = 24h, else 12h)
422    * ``[13]`` status
423
424    1.5 s timeout (vs the 0.5 s default) leaves headroom on slow USB-serial
425    adapters where a tightly-scheduled read can otherwise time out while
426    its 16 bytes are still in flight.
427    """
428    return NceCommand(
429        name="READ_CLOCK",
430        request=bytes([
431            OP_READ_16_MEM,
432            (CS_CLOCK_ADDR >> 8) & 0xFF, CS_CLOCK_ADDR & 0xFF,
433        ]),
434        reply_len=16,
435        is_read=True,
436        timeout_s=1.5,
437    )
438
439
440def write_clock_time(hours: int, minutes: int, seconds: int) -> NceCommand:
441    """Write hh/mm/ss to the CS clock.
442
443    JMRI: ``NceClockControl.issueClockSetMem`` writes ``[ss, mm, hh]`` at
444    ``CS_CLOCK_ADDR + 2`` via ``accMemoryWriteN`` (opcode 0x8E, WRITE_N_CMD).
445    Delegates to :func:`write_memory` so the fixed-20-byte wire pad is
446    applied in one place. 1.5 s timeout mirrors the clock read.
447    """
448    if not 0 <= hours <= 23:
449        raise ValueError(f"hours must be 0..23, got {hours}")
450    if not 0 <= minutes <= 59:
451        raise ValueError(f"minutes must be 0..59, got {minutes}")
452    if not 0 <= seconds <= 59:
453        raise ValueError(f"seconds must be 0..59, got {seconds}")
454    payload = bytes([seconds & 0xFF, minutes & 0xFF, hours & 0xFF])
455    inner = write_memory(CS_CLOCK_ADDR + 2, payload)
456    # Reuse the well-formed request but with a clock-specific name + timeout.
457    return NceCommand(
458        name=f"WRITE_CLOCK_TIME({hours:02d}:{minutes:02d}:{seconds:02d})",
459        request=inner.request,
460        timeout_s=1.5,
461    )
462
463
464def set_clock_ratio(ratio: int) -> NceCommand:
465    """Set the CS fast-clock ratio (integer, 1..15).
466
467    JMRI: ``NceBinaryCommand.accSetClockRatio`` (opcode 0x87, CLOCK_RATIO_CMD).
468    Sends ``[0x87, ratio]``. Read-back later reports ``scale = 250 / ratio``.
469    """
470    if not 1 <= ratio <= 15:
471        raise ValueError(f"clock ratio must be 1..15, got {ratio}")
472    return NceCommand(
473        name=f"SET_CLOCK_RATIO({ratio})",
474        request=bytes([OP_CLOCK_SET_RATIO, ratio & 0xFF]),
475        timeout_s=1.5,
476    )
477
478
479def set_clock_1224(twenty_four_hour: bool) -> NceCommand:
480    """Switch the CS clock between 12h and 24h display.
481
482    JMRI: ``NceBinaryCommand.accSetClock1224`` (opcode 0x86, CLOCK_1224_CMD).
483    """
484    return NceCommand(
485        name=f"SET_CLOCK_1224({'24' if twenty_four_hour else '12'})",
486        request=bytes([OP_CLOCK_SET_1224, 1 if twenty_four_hour else 0]),
487        timeout_s=1.5,
488    )
489
490
491def start_clock() -> NceCommand:
492    """Start the CS fast clock.
493
494    JMRI: ``NceBinaryCommand.accStartClock`` (opcode 0x84, START_CLOCK_CMD).
495    """
496    return NceCommand(
497        name="START_CLOCK",
498        request=bytes([OP_CLOCK_START]),
499        timeout_s=1.5,
500    )
501
502
503def stop_clock() -> NceCommand:
504    """Stop (hold) the CS fast clock.
505
506    JMRI: ``NceBinaryCommand.accStopClock`` (opcode 0x83, STOP_CLOCK_CMD).
507    """
508    return NceCommand(
509        name="STOP_CLOCK",
510        request=bytes([OP_CLOCK_STOP]),
511        timeout_s=1.5,
512    )
513
514
515def read_aiu(aiu_address: int) -> NceCommand:
516    """Read one AIU module's 16-bit input state.
517
518    JMRI: ``NceBinaryCommand.accAiu2Read`` (opcode 0x9B, READ_AUI2_CMD).
519    Returns the AIU's two-byte word: bits 0..13 are inputs 1..14.
520    """
521    if not 1 <= aiu_address <= 63:
522        raise ValueError(f"AIU address must be 1..63, got {aiu_address}")
523    return NceCommand(
524        name=f"AIU_QUERY({aiu_address})",
525        request=bytes([OP_AIU_BASE_QUERY, aiu_address & 0xFF]),
526        reply_len=2,
527        is_read=True,
528    )
@dataclass(frozen=True, slots=True)
class NceCommand:
42@dataclass(frozen=True, slots=True)
43class NceCommand:
44    """One outbound request.
45
46    NCE has two distinct reply shapes:
47
48    * **Action commands** (power on/off, accessory throw, write CV, ...)
49      reply with a single ACK byte (``!`` on success). ``reply_len`` is
50      the number of *data* bytes that precede the ACK — usually 0.
51    * **Read commands** (read memory, read AIU, ...) reply with exactly
52      *reply_len* bytes of raw data, no trailing ACK. Set ``is_read=True``
53      and the traffic controller will not consume an ACK byte after.
54    """
55
56    name: str
57    request: bytes
58    reply_len: int = 0
59    timeout_s: float = 0.5
60    is_read: bool = False

One outbound request.

NCE has two distinct reply shapes:

  • Action commands (power on/off, accessory throw, write CV, ...) reply with a single ACK byte (! on success). reply_len is the number of data bytes that precede the ACK — usually 0.
  • Read commands (read memory, read AIU, ...) reply with exactly reply_len bytes of raw data, no trailing ACK. Set is_read=True and the traffic controller will not consume an ACK byte after.
NceCommand( name: str, request: bytes, reply_len: int = 0, timeout_s: float = 0.5, is_read: bool = False)
name: str
request: bytes
reply_len: int
timeout_s: float
is_read: bool
@dataclass(frozen=True, slots=True)
class NceReply:
63@dataclass(frozen=True, slots=True)
64class NceReply:
65    """A successful response. ``data`` excludes the ACK byte itself."""
66
67    command: NceCommand
68    data: bytes

A successful response. data excludes the ACK byte itself.

NceReply(command: NceCommand, data: bytes)
command: NceCommand
data: bytes
class NceCommandError(builtins.Exception):
71class NceCommandError(Exception):
72    """The command station replied with a non-OK ACK byte."""
73
74    def __init__(self, command: NceCommand, ack: bytes, data: bytes) -> None:
75        super().__init__(
76            f"NCE command {command.name!r} failed: ack={ack!r} data={data!r}"
77        )
78        self.command = command
79        self.ack = ack
80        self.data = data

The command station replied with a non-OK ACK byte.

NceCommandError(command: NceCommand, ack: bytes, data: bytes)
74    def __init__(self, command: NceCommand, ack: bytes, data: bytes) -> None:
75        super().__init__(
76            f"NCE command {command.name!r} failed: ack={ack!r} data={data!r}"
77        )
78        self.command = command
79        self.ack = ack
80        self.data = data
command
ack
data
def power_on() -> NceCommand:
86def power_on() -> NceCommand:
87    """JMRI: ``NceMessage.getEnableMain`` (opcode 137)."""
88    return NceCommand(name="POWER_ON", request=bytes([OP_POWER_ON]))

JMRI: NceMessage.getEnableMain (opcode 137).

def power_off() -> NceCommand:
91def power_off() -> NceCommand:
92    """JMRI: ``NceMessage.getKillMain`` (opcode 139)."""
93    return NceCommand(name="POWER_OFF", request=bytes([OP_POWER_OFF]))

JMRI: NceMessage.getKillMain (opcode 139).

def read_16_memory(address: int) -> NceCommand:
 96def read_16_memory(address: int) -> NceCommand:
 97    """Read exactly 16 bytes at *address*.
 98
 99    JMRI: ``NceBinaryCommand.accMemoryRead`` (opcode 0x8F, READ16_CMD).
100    NCE has no "read N bytes" opcode — only fixed 16-byte or 1-byte
101    reads. For arbitrary lengths, issue multiple 16-byte reads.
102    """
103    return NceCommand(
104        name=f"READ16({address:#06x})",
105        request=bytes([OP_READ_16_MEM, (address >> 8) & 0xFF, address & 0xFF]),
106        reply_len=16,
107        is_read=True,
108    )

Read exactly 16 bytes at address.

JMRI: NceBinaryCommand.accMemoryRead (opcode 0x8F, READ16_CMD). NCE has no "read N bytes" opcode — only fixed 16-byte or 1-byte reads. For arbitrary lengths, issue multiple 16-byte reads.

def read_accessory_memory(address: int) -> NceCommand:
114def read_accessory_memory(address: int) -> NceCommand:
115    """Read 16 bytes of accessory-state memory at *address* (turnout poll).
116
117    Alias for :func:`read_16_memory` — the underlying opcode 0x8F works
118    at arbitrary addresses despite the historical "accessory" name.
119    """
120    return read_16_memory(address)

Read 16 bytes of accessory-state memory at address (turnout poll).

Alias for read_16_memory() — the underlying opcode 0x8F works at arbitrary addresses despite the historical "accessory" name.

def write_memory(address: int, data: bytes) -> NceCommand:
123def write_memory(address: int, data: bytes) -> NceCommand:
124    """Write *data* (1..16 bytes) starting at the 16-bit *address*.
125
126    JMRI: ``NceBinaryCommand.accMemoryWriteN`` (opcode 0x8E, WRITE_N_CMD).
127
128    IMPORTANT: The CS expects a **fixed 20-byte request** regardless of
129    payload length — JMRI Java allocates ``new byte[20]`` and fills only
130    the first ``4 + data.length`` bytes, leaving the rest zero-padded.
131    We match that shape byte-for-byte; without the pad the CS waits
132    forever for the remaining bytes and the ACK never arrives.
133    """
134    if not 1 <= len(data) <= 16:
135        raise ValueError(f"write length must be 1..16, got {len(data)}")
136    header = bytes([
137        OP_WRITE_N_MEM,
138        (address >> 8) & 0xFF, address & 0xFF,
139        len(data) & 0xFF,
140    ])
141    padding = bytes(20 - len(header) - len(data))
142    return NceCommand(
143        name=f"WRITE_N_MEM({address:#06x},{len(data)})",
144        request=header + data + padding,
145    )

Write data (1..16 bytes) starting at the 16-bit address.

JMRI: NceBinaryCommand.accMemoryWriteN (opcode 0x8E, WRITE_N_CMD).

IMPORTANT: The CS expects a fixed 20-byte request regardless of payload length — JMRI Java allocates new byte[20] and fills only the first 4 + data.length bytes, leaving the rest zero-padded. We match that shape byte-for-byte; without the pad the CS waits forever for the remaining bytes and the ACK never arrives.

def accessory_decoder_normal_ops(address: int, on: bool) -> NceCommand:
148def accessory_decoder_normal_ops(address: int, on: bool) -> NceCommand:
149    """Operate an accessory via the NCE binary accessory-decoder command.
150
151    JMRI: ``NceBinaryCommand.accDecoder`` (opcode 0xAD, ACC_CMD).
152
153    *address* is the accessory address (1..2044). ``on=True`` selects the
154    "closed" (normal) output; ``on=False`` selects "thrown" (reverse).
155    Wire form: ``[0xAD, addr_hi, addr_lo, dir, 0x00]`` where *dir* is
156    the NMRA accessory-direction nibble (3=closed, 4=thrown).
157    """
158    if not 1 <= address <= 2044:
159        raise ValueError(f"accessory address must be 1..2044, got {address}")
160    direction = 3 if on else 4
161    return NceCommand(
162        name=f"ACCY({address},{'closed' if on else 'thrown'})",
163        request=bytes([
164            OP_ACCY_DECODER_OPS,
165            (address >> 8) & 0xFF,
166            address & 0xFF,
167            direction,
168            0x00,
169        ]),
170    )

Operate an accessory via the NCE binary accessory-decoder command.

JMRI: NceBinaryCommand.accDecoder (opcode 0xAD, ACC_CMD).

address is the accessory address (1..2044). on=True selects the "closed" (normal) output; on=False selects "thrown" (reverse). Wire form: [0xAD, addr_hi, addr_lo, dir, 0x00] where dir is the NMRA accessory-direction nibble (3=closed, 4=thrown).

def nce_loco_cmd( loco_address: int, *, long_address: bool, sub_cmd: int, data: int) -> NceCommand:
173def nce_loco_cmd(
174    loco_address: int, *, long_address: bool, sub_cmd: int, data: int
175) -> NceCommand:
176    """Throttle command via NCE binary ``nceLocoCmd``.
177
178    JMRI: ``NceBinaryCommand.nceLocoCmd`` (opcode 0xA2, LOCO_CMD).
179
180    Modern firmware (commandOptions >= 10) uses this 5-byte command for
181    speed, direction, and function changes.
182
183        [0xA2] [addr_hi] [addr_lo] [sub_cmd] [data]   (5 bytes)
184
185    For long addresses, the wire address is ``loco_address + 0xC000``
186    so the CS can disambiguate from short addresses with the same value.
187
188    *sub_cmd* values come from :mod:`jmri_nce.opcodes`:
189
190    * ``LOCO_SUB_128_FWD`` / ``LOCO_SUB_128_REV`` — 128-step speed
191    * ``LOCO_SUB_ESTOP_FWD`` / ``LOCO_SUB_ESTOP_REV`` — emergency stop
192    * ``LOCO_SUB_FUNC_GROUP_1..5`` — function bitmasks
193    """
194    if not 0 <= sub_cmd <= 23:
195        raise ValueError(f"sub_cmd must be 0..23, got {sub_cmd}")
196    if not 0 <= data <= 255:
197        raise ValueError(f"data byte must be 0..255, got {data}")
198    wire = loco_address + LONG_ADDRESS_OFFSET if long_address else loco_address
199    return NceCommand(
200        name=f"LOCO({loco_address}{'L' if long_address else 'S'},sub={sub_cmd},data={data})",
201        request=bytes([
202            OP_NCE_LOCO_CMD,
203            (wire >> 8) & 0xFF,
204            wire & 0xFF,
205            sub_cmd & 0xFF,
206            data & 0xFF,
207        ]),
208    )

Throttle command via NCE binary nceLocoCmd.

JMRI: NceBinaryCommand.nceLocoCmd (opcode 0xA2, LOCO_CMD).

Modern firmware (commandOptions >= 10) uses this 5-byte command for speed, direction, and function changes.

[0xA2] [addr_hi] [addr_lo] [sub_cmd] [data]   (5 bytes)

For long addresses, the wire address is loco_address + 0xC000 so the CS can disambiguate from short addresses with the same value.

sub_cmd values come from jmri_nce.opcodes:

  • LOCO_SUB_128_FWD / LOCO_SUB_128_REV — 128-step speed
  • LOCO_SUB_ESTOP_FWD / LOCO_SUB_ESTOP_REV — emergency stop
  • LOCO_SUB_FUNC_GROUP_1..5 — function bitmasks
def query_command_version() -> NceCommand:
211def query_command_version() -> NceCommand:
212    """Read the command station firmware version. Reply is 3 bytes.
213
214    JMRI: ``NceBinaryCommand.getNceEpromRev`` (opcode 0xAA, SW_REV_CMD).
215    Safe: read-only, no side effects.
216    """
217    return NceCommand(
218        name="QUERY_SW_REV",
219        request=bytes([OP_QUERY_SW_REV]),
220        reply_len=3,
221        is_read=True,
222    )

Read the command station firmware version. Reply is 3 bytes.

JMRI: NceBinaryCommand.getNceEpromRev (opcode 0xAA, SW_REV_CMD). Safe: read-only, no side effects.

def enter_programming_mode() -> NceCommand:
225def enter_programming_mode() -> NceCommand:
226    """Put the CS into programming mode.
227
228    JMRI: ``NceMessage.getEnterProgMode`` (opcode 0x9E). Direct-mode
229    CV read/write commands (``0xA8``, ``0xA9``) require the CS to be
230    in this mode — outside it, the CS immediately NoAcks the request
231    with ``'0'`` (0x30) without running any DCC packets on the prog
232    track. Java JMRI's ``NceProgrammer`` wraps every direct-mode op
233    with this + :func:`exit_programming_mode`.
234
235    Reply is a single ACK byte (``!`` on success).
236    """
237    return NceCommand(
238        name="ENTER_PROG",
239        request=bytes([OP_ENTER_PROG_MODE]),
240        # Fast: CS just flips a state flag. Give it a generous window
241        # for slow USB-serial adapters.
242        timeout_s=1.0,
243    )

Put the CS into programming mode.

JMRI: NceMessage.getEnterProgMode (opcode 0x9E). Direct-mode CV read/write commands (0xA8, 0xA9) require the CS to be in this mode — outside it, the CS immediately NoAcks the request with '0' (0x30) without running any DCC packets on the prog track. Java JMRI's NceProgrammer wraps every direct-mode op with this + exit_programming_mode().

Reply is a single ACK byte (! on success).

def exit_programming_mode() -> NceCommand:
246def exit_programming_mode() -> NceCommand:
247    """Take the CS out of programming mode.
248
249    JMRI: ``NceMessage.getExitProgMode`` (opcode 0x9F). Send this after
250    a direct-mode CV op so the CS returns to normal operation and
251    layout traffic resumes. Reply is ``!`` on success.
252    """
253    return NceCommand(
254        name="EXIT_PROG",
255        request=bytes([OP_EXIT_PROG_MODE]),
256        timeout_s=1.0,
257    )

Take the CS out of programming mode.

JMRI: NceMessage.getExitProgMode (opcode 0x9F). Send this after a direct-mode CV op so the CS returns to normal operation and layout traffic resumes. Reply is ! on success.

def direct_mode_write_cv(cv: int, value: int) -> NceCommand:
260def direct_mode_write_cv(cv: int, value: int) -> NceCommand:
261    """Write a CV on the programming track (direct mode).
262
263    JMRI: ``NceMessage.getWriteDirectCV`` (opcode 0xA8, WRITE_DIR_CV_CMD).
264    Layout: ``[0xA8, cv_hi, cv_lo, value]``.
265    """
266    if not 1 <= cv <= 1024:
267        raise ValueError(f"CV must be 1..1024, got {cv}")
268    if not 0 <= value <= 255:
269        raise ValueError(f"value must be 0..255, got {value}")
270    return NceCommand(
271        name=f"WR_CV_DIRECT({cv},{value})",
272        request=bytes([OP_WR_CV_DIRECT_MODE, (cv >> 8) & 0xFF, cv & 0xFF, value]),
273        timeout_s=6.0,  # service-mode writes take seconds
274    )

Write a CV on the programming track (direct mode).

JMRI: NceMessage.getWriteDirectCV (opcode 0xA8, WRITE_DIR_CV_CMD). Layout: [0xA8, cv_hi, cv_lo, value].

def direct_mode_read_cv(cv: int) -> NceCommand:
277def direct_mode_read_cv(cv: int) -> NceCommand:
278    """Read a CV on the programming track.
279
280    JMRI: ``NceMessage.getReadDirectCV`` (opcode 0xA9, READ_DIR_CV_CMD).
281    Reply is 1 byte (the CV value).
282    """
283    if not 1 <= cv <= 1024:
284        raise ValueError(f"CV must be 1..1024, got {cv}")
285    return NceCommand(
286        name=f"RD_CV_DIRECT({cv})",
287        request=bytes([OP_RD_CV_DIRECT_MODE, (cv >> 8) & 0xFF, cv & 0xFF]),
288        reply_len=1,
289        timeout_s=6.0,
290        is_read=True,
291    )

Read a CV on the programming track.

JMRI: NceMessage.getReadDirectCV (opcode 0xA9, READ_DIR_CV_CMD). Reply is 1 byte (the CV value).

def ops_mode_write_cv( loco_address: int, cv: int, value: int, *, long_address: bool = False) -> NceCommand:
294def ops_mode_write_cv(
295    loco_address: int, cv: int, value: int, *, long_address: bool = False,
296) -> NceCommand:
297    """Ops-mode CV byte-write (Programming On the Main) to a specific loco.
298
299    JMRI: ``NceMessage.sendPacketMessage`` builds the equivalent wrapped
300    packet in ``NceProgrammer.writeCV`` when ops mode is selected.
301
302    Wire form: build a raw NMRA CV Access instruction packet, then wrap
303    it in the appropriate ``QUEUEn_TMP_CMD`` envelope where ``n`` is the
304    packet's byte length (opcode ``0x93 + (packet_len - 3)`` covers
305    3..6-byte packets, per :data:`OP_QUEUE_PACKET_BASE`). NCE has no
306    native ops-mode CV *read* (RailCom is out of PowerPro's scope) — do
307    not add ``ops_mode_read_cv``; use direct mode for verified reads.
308
309    NMRA S-9.2.1 CV Access — Long Form / Byte Write:
310
311        <address bytes>  1110 CCVV   VVVVVVVV   DDDDDDDD  <XOR>
312
313    * short address (1..127): 1 address byte ``0AAAAAAA``
314    * long address (128..9999): 2 address bytes ``11AAAAAA AAAAAAAA``
315    * ``CC = 11`` → byte-write (10 = verify, 01 = bit-manipulate)
316    * ``VVVVVVVVVV`` = CV number minus 1, 10-bit
317    * ``DDDDDDDD`` = value
318    * trailing XOR of all preceding bytes
319
320    Reply: single ACK byte from the CS (``!`` on success). The packet
321    goes on the wire from the CS; no decoder ack round-trip.
322    """
323    if not 1 <= cv <= 1024:
324        raise ValueError(f"CV must be 1..1024, got {cv}")
325    if not 0 <= value <= 255:
326        raise ValueError(f"value must be 0..255, got {value}")
327
328    if long_address:
329        if not 128 <= loco_address <= 9999:
330            raise ValueError(
331                f"long address must be 128..9999, got {loco_address}"
332            )
333        # 14-bit long address in the DCC packet: top 6 bits go into the
334        # low 6 bits of 0xC0, low 8 bits stand alone.
335        addr_bytes = [
336            0xC0 | ((loco_address >> 8) & 0x3F),
337            loco_address & 0xFF,
338        ]
339    else:
340        if not 1 <= loco_address <= 127:
341            raise ValueError(
342                f"short address must be 1..127, got {loco_address}"
343            )
344        addr_bytes = [loco_address & 0x7F]
345
346    # NMRA encodes CV number minus one, spread over two bytes: 2 high
347    # bits ride the instruction opcode, 8 low bits follow.
348    cv_index = cv - 1
349    inst_hi = 0xEC | ((cv_index >> 8) & 0x03)   # 1110_1100 with CC=11 + VV
350    inst_lo = cv_index & 0xFF
351
352    packet = bytes(addr_bytes + [inst_hi, inst_lo, value & 0xFF])
353    xor = 0
354    for b in packet:
355        xor ^= b
356    dcc_packet = packet + bytes([xor])
357
358    # QUEUEn_TMP_CMD wire form (verified against JMRI Java NCE Command
359    # Monitor trace on real PowerPro hardware, 2026-07-25):
360    #
361    #   [queue_opcode] [send_count] [dcc_packet_bytes...]
362    #
363    # * ``queue_opcode`` = 0x93..0x96 (packet length 3..6)
364    # * ``send_count``   = number of times the CS retransmits the
365    #                      packet on the wire. NMRA POM recommends
366    #                      3–5 repeats for reliability; JMRI Java uses
367    #                      5, which we mirror. The CS reads this byte
368    #                      as part of the command — earlier versions
369    #                      of this code omitted it, so the CS would
370    #                      read our first packet byte as the send
371    #                      count and then time out ~5 s waiting for a
372    #                      packet byte that never came, returning
373    #                      NoAck (``0``).
374    # * ``dcc_packet``   = 3..6 bytes: address + CV Access instruction
375    #                      + XOR checksum, per NMRA S-9.2.1.
376    packet_len = len(dcc_packet)
377    if not 3 <= packet_len <= 6:
378        # Defensive: the packet builder should never produce a length
379        # outside 3..6, but if a future edit changes the encoding this
380        # catches the mistake before the wrong opcode goes out.
381        raise ValueError(
382            f"internal error: DCC packet length {packet_len} outside 3..6",
383        )
384    queue_opcode = OP_QUEUE_PACKET_BASE + (packet_len - 3)
385    send_count = 5   # matches JMRI Java default
386
387    return NceCommand(
388        name=f"WR_CV_OPS({loco_address}{'L' if long_address else 'S'},{cv},{value})",
389        request=bytes([queue_opcode, send_count]) + dcc_packet,
390        # Ops-mode writes are fire-and-forget on the wire; only the CS
391        # queue-accept ACK ('!') comes back. No data bytes.
392        #
393        # ``timeout_s`` is 5.0 s — matches JMRI Java's timeout for
394        # equivalent operations. NCE PowerPro's actual queue-then-ack
395        # latency depends on the CS's current DCC output load: with
396        # active throttles + fast-clock ticks + accessory refresh, we
397        # observed >2 s on real hardware even after bumping from the
398        # 0.5 s default. 5 s gives comfortable headroom; a real hang
399        # (packet dropped, CS off-line) still surfaces before the user
400        # gives up on the batch.
401        timeout_s=5.0,
402    )

Ops-mode CV byte-write (Programming On the Main) to a specific loco.

JMRI: NceMessage.sendPacketMessage builds the equivalent wrapped packet in NceProgrammer.writeCV when ops mode is selected.

Wire form: build a raw NMRA CV Access instruction packet, then wrap it in the appropriate QUEUEn_TMP_CMD envelope where n is the packet's byte length (opcode 0x93 + (packet_len - 3) covers 3..6-byte packets, per OP_QUEUE_PACKET_BASE). NCE has no native ops-mode CV read (RailCom is out of PowerPro's scope) — do not add ops_mode_read_cv; use direct mode for verified reads.

NMRA S-9.2.1 CV Access — Long Form / Byte Write:

<address bytes>  1110 CCVV   VVVVVVVV   DDDDDDDD  <XOR>
  • short address (1..127): 1 address byte 0AAAAAAA
  • long address (128..9999): 2 address bytes 11AAAAAA AAAAAAAA
  • CC = 11 → byte-write (10 = verify, 01 = bit-manipulate)
  • VVVVVVVVVV = CV number minus 1, 10-bit
  • DDDDDDDD = value
  • trailing XOR of all preceding bytes

Reply: single ACK byte from the CS (! on success). The packet goes on the wire from the CS; no decoder ack round-trip.

def read_clock() -> NceCommand:
405def read_clock() -> NceCommand:
406    """Read the CS fast clock (16 bytes at ``CS_CLOCK_ADDR``).
407
408    JMRI: ``NceClockControl.issueReadOnlyRequest`` uses
409    ``NceBinaryCommand.accMemoryRead(getClockAddr())`` (opcode 0x8F).
410    Wire request: 3 bytes ``[0x8F, addr_hi, addr_lo]``; reply is 16
411    data bytes. Passive read — safe to send even when the clock isn't
412    running.
413
414    Reply byte layout mirrors ``NceClockControl.readClockPacket``:
415
416    * ``[0]`` scale — CS-internal divisor, ``ratio = 250 / scale``
417    * ``[1]`` tick
418    * ``[2]`` seconds (binary, not BCD)
419    * ``[3]`` minutes
420    * ``[4]`` hours (1..12 in 12h mode; 0..23 in 24h)
421    * ``[5]`` AM/PM flag (ASCII ``'A'`` = 65 → AM)
422    * ``[6]`` 12/24 mode (1 = 24h, else 12h)
423    * ``[13]`` status
424
425    1.5 s timeout (vs the 0.5 s default) leaves headroom on slow USB-serial
426    adapters where a tightly-scheduled read can otherwise time out while
427    its 16 bytes are still in flight.
428    """
429    return NceCommand(
430        name="READ_CLOCK",
431        request=bytes([
432            OP_READ_16_MEM,
433            (CS_CLOCK_ADDR >> 8) & 0xFF, CS_CLOCK_ADDR & 0xFF,
434        ]),
435        reply_len=16,
436        is_read=True,
437        timeout_s=1.5,
438    )

Read the CS fast clock (16 bytes at CS_CLOCK_ADDR).

JMRI: NceClockControl.issueReadOnlyRequest uses NceBinaryCommand.accMemoryRead(getClockAddr()) (opcode 0x8F). Wire request: 3 bytes [0x8F, addr_hi, addr_lo]; reply is 16 data bytes. Passive read — safe to send even when the clock isn't running.

Reply byte layout mirrors NceClockControl.readClockPacket:

  • [0] scale — CS-internal divisor, ratio = 250 / scale
  • [1] tick
  • [2] seconds (binary, not BCD)
  • [3] minutes
  • [4] hours (1..12 in 12h mode; 0..23 in 24h)
  • [5] AM/PM flag (ASCII 'A' = 65 → AM)
  • [6] 12/24 mode (1 = 24h, else 12h)
  • [13] status

1.5 s timeout (vs the 0.5 s default) leaves headroom on slow USB-serial adapters where a tightly-scheduled read can otherwise time out while its 16 bytes are still in flight.

def write_clock_time(hours: int, minutes: int, seconds: int) -> NceCommand:
441def write_clock_time(hours: int, minutes: int, seconds: int) -> NceCommand:
442    """Write hh/mm/ss to the CS clock.
443
444    JMRI: ``NceClockControl.issueClockSetMem`` writes ``[ss, mm, hh]`` at
445    ``CS_CLOCK_ADDR + 2`` via ``accMemoryWriteN`` (opcode 0x8E, WRITE_N_CMD).
446    Delegates to :func:`write_memory` so the fixed-20-byte wire pad is
447    applied in one place. 1.5 s timeout mirrors the clock read.
448    """
449    if not 0 <= hours <= 23:
450        raise ValueError(f"hours must be 0..23, got {hours}")
451    if not 0 <= minutes <= 59:
452        raise ValueError(f"minutes must be 0..59, got {minutes}")
453    if not 0 <= seconds <= 59:
454        raise ValueError(f"seconds must be 0..59, got {seconds}")
455    payload = bytes([seconds & 0xFF, minutes & 0xFF, hours & 0xFF])
456    inner = write_memory(CS_CLOCK_ADDR + 2, payload)
457    # Reuse the well-formed request but with a clock-specific name + timeout.
458    return NceCommand(
459        name=f"WRITE_CLOCK_TIME({hours:02d}:{minutes:02d}:{seconds:02d})",
460        request=inner.request,
461        timeout_s=1.5,
462    )

Write hh/mm/ss to the CS clock.

JMRI: NceClockControl.issueClockSetMem writes [ss, mm, hh] at CS_CLOCK_ADDR + 2 via accMemoryWriteN (opcode 0x8E, WRITE_N_CMD). Delegates to write_memory() so the fixed-20-byte wire pad is applied in one place. 1.5 s timeout mirrors the clock read.

def set_clock_ratio(ratio: int) -> NceCommand:
465def set_clock_ratio(ratio: int) -> NceCommand:
466    """Set the CS fast-clock ratio (integer, 1..15).
467
468    JMRI: ``NceBinaryCommand.accSetClockRatio`` (opcode 0x87, CLOCK_RATIO_CMD).
469    Sends ``[0x87, ratio]``. Read-back later reports ``scale = 250 / ratio``.
470    """
471    if not 1 <= ratio <= 15:
472        raise ValueError(f"clock ratio must be 1..15, got {ratio}")
473    return NceCommand(
474        name=f"SET_CLOCK_RATIO({ratio})",
475        request=bytes([OP_CLOCK_SET_RATIO, ratio & 0xFF]),
476        timeout_s=1.5,
477    )

Set the CS fast-clock ratio (integer, 1..15).

JMRI: NceBinaryCommand.accSetClockRatio (opcode 0x87, CLOCK_RATIO_CMD). Sends [0x87, ratio]. Read-back later reports scale = 250 / ratio.

def set_clock_1224(twenty_four_hour: bool) -> NceCommand:
480def set_clock_1224(twenty_four_hour: bool) -> NceCommand:
481    """Switch the CS clock between 12h and 24h display.
482
483    JMRI: ``NceBinaryCommand.accSetClock1224`` (opcode 0x86, CLOCK_1224_CMD).
484    """
485    return NceCommand(
486        name=f"SET_CLOCK_1224({'24' if twenty_four_hour else '12'})",
487        request=bytes([OP_CLOCK_SET_1224, 1 if twenty_four_hour else 0]),
488        timeout_s=1.5,
489    )

Switch the CS clock between 12h and 24h display.

JMRI: NceBinaryCommand.accSetClock1224 (opcode 0x86, CLOCK_1224_CMD).

def start_clock() -> NceCommand:
492def start_clock() -> NceCommand:
493    """Start the CS fast clock.
494
495    JMRI: ``NceBinaryCommand.accStartClock`` (opcode 0x84, START_CLOCK_CMD).
496    """
497    return NceCommand(
498        name="START_CLOCK",
499        request=bytes([OP_CLOCK_START]),
500        timeout_s=1.5,
501    )

Start the CS fast clock.

JMRI: NceBinaryCommand.accStartClock (opcode 0x84, START_CLOCK_CMD).

def stop_clock() -> NceCommand:
504def stop_clock() -> NceCommand:
505    """Stop (hold) the CS fast clock.
506
507    JMRI: ``NceBinaryCommand.accStopClock`` (opcode 0x83, STOP_CLOCK_CMD).
508    """
509    return NceCommand(
510        name="STOP_CLOCK",
511        request=bytes([OP_CLOCK_STOP]),
512        timeout_s=1.5,
513    )

Stop (hold) the CS fast clock.

JMRI: NceBinaryCommand.accStopClock (opcode 0x83, STOP_CLOCK_CMD).

def read_aiu(aiu_address: int) -> NceCommand:
516def read_aiu(aiu_address: int) -> NceCommand:
517    """Read one AIU module's 16-bit input state.
518
519    JMRI: ``NceBinaryCommand.accAiu2Read`` (opcode 0x9B, READ_AUI2_CMD).
520    Returns the AIU's two-byte word: bits 0..13 are inputs 1..14.
521    """
522    if not 1 <= aiu_address <= 63:
523        raise ValueError(f"AIU address must be 1..63, got {aiu_address}")
524    return NceCommand(
525        name=f"AIU_QUERY({aiu_address})",
526        request=bytes([OP_AIU_BASE_QUERY, aiu_address & 0xFF]),
527        reply_len=2,
528        is_read=True,
529    )

Read one AIU module's 16-bit input state.

JMRI: NceBinaryCommand.accAiu2Read (opcode 0x9B, READ_AUI2_CMD). Returns the AIU's two-byte word: bits 0..13 are inputs 1..14.