jmri_nce

jmri-nce — Stage 4 second hardware driver (NCE).

Where jmri_loconet exercises the HAL with an async-broadcast wire protocol, this package exercises it with a synchronous request/response protocol plus polling. Both drivers plug into the same SystemConnection ABC without changes to jmri-core or jmri-server — the abstraction earned its keep.

 1"""jmri-nce — Stage 4 second hardware driver (NCE).
 2
 3Where :mod:`jmri_loconet` exercises the HAL with an async-broadcast wire
 4protocol, this package exercises it with a synchronous request/response
 5protocol plus polling. Both drivers plug into the same
 6``SystemConnection`` ABC without changes to ``jmri-core`` or ``jmri-server``
 7— the abstraction earned its keep.
 8"""
 9from jmri_nce.beans import (
10    NcePowerManager,
11    NceSensor,
12    NceSensorManager,
13    NceTurnout,
14    NceTurnoutManager,
15)
16from jmri_nce.command_set import (
17    COMMAND_SETS,
18    NCE_PH_PRO_2006,
19    NCE_POWERCAB,
20    NCE_SB5,
21    NceCommandSet,
22    get_command_set,
23)
24from jmri_nce.connection import NceConnection
25from jmri_nce.detect import detect_command_set, model_to_command_set
26from jmri_nce.messages import NceCommand, NceCommandError, NceReply
27from jmri_nce.monitors import NceAiuPoller, NceTurnoutMonitor
28from jmri_nce.programmer import (
29    NceProgrammer,
30    NceProgrammerManager,
31    NceServiceProgrammer,
32)
33from jmri_nce.throttle import NceThrottle, NceThrottleManager
34from jmri_nce.traffic import NceTrafficController
35from jmri_nce.transport import BytePort, LoopbackPort, SerialPort, loopback_pair
36
37__version__ = "0.1.0"
38
39__all__ = [
40    "COMMAND_SETS",
41    "BytePort",
42    "LoopbackPort",
43    "NCE_PH_PRO_2006",
44    "NCE_POWERCAB",
45    "NCE_SB5",
46    "NceAiuPoller",
47    "NceCommand",
48    "NceCommandError",
49    "NceCommandSet",
50    "NceConnection",
51    "NcePowerManager",
52    "NceProgrammer",
53    "NceProgrammerManager",
54    "NceReply",
55    "NceServiceProgrammer",
56    "NceSensor",
57    "NceSensorManager",
58    "NceThrottle",
59    "NceThrottleManager",
60    "NceTrafficController",
61    "NceTurnout",
62    "NceTurnoutManager",
63    "NceTurnoutMonitor",
64    "SerialPort",
65    "__version__",
66    "detect_command_set",
67    "get_command_set",
68    "loopback_pair",
69    "model_to_command_set",
70]
COMMAND_SETS = {'PH-Pro 2006+': NceCommandSet(name='PH-Pro 2006+', turnout_memory_base=60416, turnout_table_bytes=256, macro_memory_base=51200, consist_memory_base=62720, supports_ops_mode_prog=True, aiu_polling_supported=True), 'PowerCab': NceCommandSet(name='PowerCab', turnout_memory_base=60416, turnout_table_bytes=64, macro_memory_base=51200, consist_memory_base=62720, supports_ops_mode_prog=True, aiu_polling_supported=False), 'Smart Booster SB5': NceCommandSet(name='Smart Booster SB5', turnout_memory_base=60416, turnout_table_bytes=256, macro_memory_base=51200, consist_memory_base=62720, supports_ops_mode_prog=True, aiu_polling_supported=True)}
class BytePort(abc.ABC):
12class BytePort(ABC):
13    """Bidirectional byte-stream port for NCE."""
14
15    @abstractmethod
16    async def write(self, data: bytes) -> None: ...
17
18    @abstractmethod
19    async def read_exact(self, n: int) -> bytes: ...
20
21    @abstractmethod
22    async def read_pending(self) -> bytes:
23        """Read any bytes already buffered, without waiting for more.
24
25        Used by the traffic controller to drain stale bytes between
26        commands. Returns ``b""`` if nothing is currently buffered.
27        """
28
29    @abstractmethod
30    async def aclose(self) -> None: ...
31
32    @property
33    @abstractmethod
34    def is_open(self) -> bool: ...

Bidirectional byte-stream port for NCE.

@abstractmethod
async def write(self, data: bytes) -> None:
15    @abstractmethod
16    async def write(self, data: bytes) -> None: ...
@abstractmethod
async def read_exact(self, n: int) -> bytes:
18    @abstractmethod
19    async def read_exact(self, n: int) -> bytes: ...
@abstractmethod
async def read_pending(self) -> bytes:
21    @abstractmethod
22    async def read_pending(self) -> bytes:
23        """Read any bytes already buffered, without waiting for more.
24
25        Used by the traffic controller to drain stale bytes between
26        commands. Returns ``b""`` if nothing is currently buffered.
27        """

Read any bytes already buffered, without waiting for more.

Used by the traffic controller to drain stale bytes between commands. Returns b"" if nothing is currently buffered.

@abstractmethod
async def aclose(self) -> None:
29    @abstractmethod
30    async def aclose(self) -> None: ...
is_open: bool
32    @property
33    @abstractmethod
34    def is_open(self) -> bool: ...
class LoopbackPort(jmri_nce.BytePort):
16class LoopbackPort(BytePort):
17    """One half of a connected pair."""
18
19    def __init__(self, read_q: asyncio.Queue[int], write_q: asyncio.Queue[int]) -> None:
20        self._r = read_q
21        self._w = write_q
22        self._open = True
23
24    async def write(self, data: bytes) -> None:
25        if not self._open:
26            raise RuntimeError("port is closed")
27        for b in data:
28            await self._w.put(b)
29
30    async def read_exact(self, n: int) -> bytes:
31        out = bytearray()
32        for _ in range(n):
33            out.append(await self._r.get())
34        return bytes(out)
35
36    async def read_pending(self) -> bytes:
37        """Drain anything currently sitting in the receive queue."""
38        out = bytearray()
39        while not self._r.empty():
40            out.append(self._r.get_nowait())
41        return bytes(out)
42
43    async def aclose(self) -> None:
44        self._open = False
45
46    @property
47    def is_open(self) -> bool:
48        return self._open

One half of a connected pair.

LoopbackPort( read_q: asyncio.queues.Queue[int], write_q: asyncio.queues.Queue[int])
19    def __init__(self, read_q: asyncio.Queue[int], write_q: asyncio.Queue[int]) -> None:
20        self._r = read_q
21        self._w = write_q
22        self._open = True
async def write(self, data: bytes) -> None:
24    async def write(self, data: bytes) -> None:
25        if not self._open:
26            raise RuntimeError("port is closed")
27        for b in data:
28            await self._w.put(b)
async def read_exact(self, n: int) -> bytes:
30    async def read_exact(self, n: int) -> bytes:
31        out = bytearray()
32        for _ in range(n):
33            out.append(await self._r.get())
34        return bytes(out)
async def read_pending(self) -> bytes:
36    async def read_pending(self) -> bytes:
37        """Drain anything currently sitting in the receive queue."""
38        out = bytearray()
39        while not self._r.empty():
40            out.append(self._r.get_nowait())
41        return bytes(out)

Drain anything currently sitting in the receive queue.

async def aclose(self) -> None:
43    async def aclose(self) -> None:
44        self._open = False
is_open: bool
46    @property
47    def is_open(self) -> bool:
48        return self._open
NCE_PH_PRO_2006 = NceCommandSet(name='PH-Pro 2006+', turnout_memory_base=60416, turnout_table_bytes=256, macro_memory_base=51200, consist_memory_base=62720, supports_ops_mode_prog=True, aiu_polling_supported=True)
NCE_POWERCAB = NceCommandSet(name='PowerCab', turnout_memory_base=60416, turnout_table_bytes=64, macro_memory_base=51200, consist_memory_base=62720, supports_ops_mode_prog=True, aiu_polling_supported=False)
NCE_SB5 = NceCommandSet(name='Smart Booster SB5', turnout_memory_base=60416, turnout_table_bytes=256, macro_memory_base=51200, consist_memory_base=62720, supports_ops_mode_prog=True, aiu_polling_supported=True)
class NceAiuPoller:
 99class NceAiuPoller:
100    """Periodically poll the configured AIU modules and emit sensor state."""
101
102    POLL_INTERVAL_S = 0.25
103
104    def __init__(
105        self,
106        tc: NceTrafficController,
107        mgr: NceSensorManager,
108        aiu_addresses: list[int],
109    ) -> None:
110        self._tc = tc
111        self._mgr = mgr
112        self._aius = list(aiu_addresses)
113        self._snapshots: dict[int, int] = {}  # AIU address → last raw 16-bit word
114        self._stop = asyncio.Event()
115
116    async def run(self) -> None:
117        import contextlib  # noqa: PLC0415
118        try:
119            while not self._stop.is_set():
120                for aiu in self._aius:
121                    await self._poll_one(aiu)
122                with contextlib.suppress(TimeoutError):
123                    await asyncio.wait_for(
124                        self._stop.wait(), timeout=self.POLL_INTERVAL_S
125                    )
126        except asyncio.CancelledError:
127            return
128
129    def stop(self) -> None:
130        self._stop.set()
131
132    async def _poll_one(self, aiu: int) -> None:
133        try:
134            reply = await self._tc.send_command(read_aiu(aiu))
135        except (TimeoutError, Exception) as e:  # noqa: BLE001
136            _log.warning("NCE AIU-poll failed for %d: %s", aiu, e)
137            return
138        if len(reply.data) < 2:
139            return
140        word = (reply.data[0] << 8) | reply.data[1]
141        prev = self._snapshots.get(aiu, ~word & 0xFFFF)  # force diff first time
142        changed = prev ^ word
143        for bit in range(14):                 # 14 inputs per AIU
144            if not (changed & (1 << bit)):
145                continue
146            sensor_address = (aiu - 1) * 14 + bit + 1
147            sensor = self._mgr.get_by_address(sensor_address)
148            if sensor is None:
149                continue
150            state = SensorState.ACTIVE if (word & (1 << bit)) else SensorState.INACTIVE
151            await sensor._set_known_state(state)
152        self._snapshots[aiu] = word

Periodically poll the configured AIU modules and emit sensor state.

NceAiuPoller( tc: NceTrafficController, mgr: NceSensorManager, aiu_addresses: list[int])
104    def __init__(
105        self,
106        tc: NceTrafficController,
107        mgr: NceSensorManager,
108        aiu_addresses: list[int],
109    ) -> None:
110        self._tc = tc
111        self._mgr = mgr
112        self._aius = list(aiu_addresses)
113        self._snapshots: dict[int, int] = {}  # AIU address → last raw 16-bit word
114        self._stop = asyncio.Event()
POLL_INTERVAL_S = 0.25
async def run(self) -> None:
116    async def run(self) -> None:
117        import contextlib  # noqa: PLC0415
118        try:
119            while not self._stop.is_set():
120                for aiu in self._aius:
121                    await self._poll_one(aiu)
122                with contextlib.suppress(TimeoutError):
123                    await asyncio.wait_for(
124                        self._stop.wait(), timeout=self.POLL_INTERVAL_S
125                    )
126        except asyncio.CancelledError:
127            return
def stop(self) -> None:
129    def stop(self) -> None:
130        self._stop.set()
@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
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
@dataclass(frozen=True, slots=True)
class NceCommandSet:
23@dataclass(frozen=True, slots=True)
24class NceCommandSet:
25    """Per-firmware constants used by services."""
26
27    name: str
28    turnout_memory_base: int    # base of accessory/turnout status table
29    turnout_table_bytes: int    # 8 turnouts per byte (256 bytes → 2048 turnouts)
30    macro_memory_base: int      # base of macro table
31    consist_memory_base: int    # base of consist table
32    supports_ops_mode_prog: bool
33    aiu_polling_supported: bool

Per-firmware constants used by services.

NceCommandSet( name: str, turnout_memory_base: int, turnout_table_bytes: int, macro_memory_base: int, consist_memory_base: int, supports_ops_mode_prog: bool, aiu_polling_supported: bool)
name: str
turnout_memory_base: int
turnout_table_bytes: int
macro_memory_base: int
consist_memory_base: int
supports_ops_mode_prog: bool
aiu_polling_supported: bool
class NceConnection(jmri_core.hal.connection.SystemConnection):
 43class NceConnection(SystemConnection):
 44    """NCE hardware connection. Prefix ``N``, name ``NCE``."""
 45
 46    name: ClassVar[str] = "NCE"
 47    prefix: ClassVar[str] = "N"
 48
 49    def __init__(self) -> None:
 50        super().__init__()
 51        self._port: BytePort | None = None
 52        self._tc: NceTrafficController | None = None
 53        self._cmd_set: NceCommandSet | None = None
 54        self._monitor: NceTurnoutMonitor | None = None
 55        self._aiu_poller: NceAiuPoller | None = None
 56        self._programmer: NceProgrammer | None = None
 57        self._tasks: list[asyncio.Task[None]] = []
 58        # Fast-clock running state — inferred from tick advancement across
 59        # consecutive reads (JMRI does the same). See ``read_hardware_clock``.
 60        self._last_clock_seconds: int | None = None
 61        self._last_clock_running: bool = False
 62
 63    # ----- lifecycle --------------------------------------------------------
 64
 65    async def open(self, settings: ConnectionSettings | None = None) -> None:
 66        s = settings or {}
 67        explicit = s.get("command_set")
 68        # Convenience: set ``JMRI_NCE_DEBUG=1`` to turn on wire-level
 69        # DEBUG for the ``jmri_nce`` logger without having to write a
 70        # separate uvicorn --log-config file. Uvicorn's own --log-level
 71        # only affects uvicorn's loggers; our module loggers inherit
 72        # from root, so we need to boost the module logger explicitly.
 73        # Go through :mod:`jmri_core.env` so the truthy/whitespace
 74        # semantics match every other JMRI_* var.
 75        from jmri_core.env import nce_debug  # noqa: PLC0415
 76        if nce_debug():
 77            _nce_root = logging.getLogger("jmri_nce")
 78            _nce_root.setLevel(logging.DEBUG)
 79            # Make sure there's a handler that will actually print the
 80            # DEBUG records. Uvicorn installs a root-level handler at
 81            # startup that emits INFO+; adding a StreamHandler here
 82            # ensures our DEBUG lines aren't swallowed.
 83            if not any(
 84                isinstance(h, logging.StreamHandler)
 85                for h in _nce_root.handlers
 86            ):
 87                h = logging.StreamHandler()
 88                h.setLevel(logging.DEBUG)
 89                h.setFormatter(logging.Formatter(
 90                    "%(levelname)s:%(name)s:%(message)s",
 91                ))
 92                _nce_root.addHandler(h)
 93                _nce_root.propagate = False
 94            _log.warning("jmri_nce DEBUG logging enabled via JMRI_NCE_DEBUG=1")
 95        _log.warning(
 96            "NCE connection opening: kind=%s port=%s command_set=%s",
 97            s.get("kind"), s.get("port"), explicit or "(default)",
 98        )
 99        self._port = await _build_port(s)
100        self._tc = NceTrafficController(self._port)
101
102        # Command-set selection:
103        #
104        # 1. Explicit ``command_set`` setting wins — production users should
105        #    just specify which CS they have.
106        # 2. ``auto_detect`` is OPT-IN (default False). The version probe
107        #    opcode isn't verified across all firmwares; on some
108        #    PowerCab/PowerPro variants it leaves the CS waiting for
109        #    additional argument bytes, wedging the serial bus until the CS
110        #    is power-cycled. Don't enable auto-detect on production hardware
111        #    until the opcode is verified against your specific firmware.
112        # 3. Otherwise default to PH-Pro 2006+ (works on the largest set of
113        #    deployed NCE command stations).
114        if explicit and explicit in COMMAND_SETS:
115            self._cmd_set = COMMAND_SETS[explicit]
116        elif s.get("auto_detect", False):
117            self._cmd_set = await detect_command_set(self._tc)
118        else:
119            self._cmd_set = NCE_PH_PRO_2006
120
121        # Replace the base in-memory managers with NCE-aware ones.
122        nce_turnouts = NceTurnoutManager(self.prefix, tc=self._tc)
123        nce_sensors = NceSensorManager(self.prefix)
124        self.turnouts = nce_turnouts                      # type: ignore[assignment]
125        self.sensors = nce_sensors                        # type: ignore[assignment]
126        self.throttles = NceThrottleManager(self.prefix, tc=self._tc)
127        self.power = NcePowerManager(self._tc)
128        self._programmer = NceProgrammer(self._tc)
129        self.programmers = NceProgrammerManager(self._programmer)
130        # Replace the base placeholder ``ConsistManager`` (built in
131        # ``SystemConnection.__init__`` before drivers had a chance to
132        # install hardware-aware managers) with the NCE-aware one. This
133        # brings CS-managed (``command_station``) consists to life via
134        # 0xA2 sub-commands; software and advanced/CV19 consists still
135        # work because the subclass falls through to the base for those
136        # kinds. The rebind hooks used elsewhere aren't enough here —
137        # the fan-out overrides only live on ``NceConsistManager``.
138        from jmri_nce.consist import NceConsistManager  # noqa: PLC0415
139        self.consists = NceConsistManager(
140            self.throttles, self.prefix, self.programmers, tc=self._tc,
141        )
142
143        # Start polling tasks (turnout monitor + optional AIU poller).
144        # The turnout monitor can be opted out via ``disable_turnout_monitor``
145        # — useful when the memory base for a CS firmware variant isn't
146        # known yet, or when the poll is failing for some other reason and
147        # the user just wants the rest of the driver to function.
148        if not s.get("disable_turnout_monitor", False):
149            self._monitor = NceTurnoutMonitor(self._tc, nce_turnouts, self._cmd_set)
150            self._tasks.append(
151                asyncio.create_task(self._monitor.run(), name="nce-turnout-monitor"),
152            )
153
154        aius: list[int] = list(s.get("aiu_addresses", []) or [])
155        if aius and self._cmd_set.aiu_polling_supported:
156            self._aiu_poller = NceAiuPoller(self._tc, nce_sensors, aius)
157            self._tasks.append(asyncio.create_task(self._aiu_poller.run(), name="nce-aiu-poller"))
158
159        # CS reachability monitor. Polls the traffic controller's
160        # ``last_success_at`` timestamp once a second; if we haven't
161        # heard a completed round-trip in more than 3 seconds, mark
162        # the CS unreachable — the turnout monitor (1.5s cadence) or
163        # AIU poller (250ms) would have brought that number forward if
164        # the CS were still answering. Any fresh success flips it
165        # back on immediately.
166        #
167        # Start pessimistic: the base class defaults ``_cs_reachable``
168        # to True (matches ``is_open`` for drivers with no real
169        # detection), but because we DO have detection, we don't yet
170        # know if the CS is answering. Home-page renders in the first
171        # ~3 s would otherwise show "On" even against a dark CS; the
172        # monitor's first True-transition flips the badge live via
173        # the existing WebSocket push once we hear from the CS.
174        self._cs_reachable = False
175        self._tasks.append(asyncio.create_task(
176            self._cs_reachability_loop(), name="nce-cs-reachability",
177        ))
178
179        await super().open(settings)
180
181    async def _cs_reachability_loop(self) -> None:
182        """Flip ``_cs_reachable`` on the 3-second silence rule.
183
184        Runs until cancelled by :meth:`close`. Uses ``time.monotonic()``
185        to avoid wall-clock jumps confusing the window.
186        """
187        import contextlib  # noqa: PLC0415
188        try:
189            # Give the CS 3 seconds of grace on open — the first
190            # turnout-monitor poll or AIU read is what generates our
191            # first ``last_success_at`` value, and it can take a
192            # couple of seconds on a slow serial adapter.
193            await asyncio.sleep(3.0)
194            while True:
195                await asyncio.sleep(1.0)
196                if self._tc is None:
197                    return
198                last = self._tc.last_success_at
199                if last is None:
200                    # No successful round-trip has happened yet. Treat
201                    # as "not reachable" so the UI doesn't lie during
202                    # a stuck-open serial hang.
203                    await self._set_cs_reachable(False)
204                    continue
205                age = time.monotonic() - last
206                await self._set_cs_reachable(age <= 3.0)
207        except asyncio.CancelledError:
208            with contextlib.suppress(Exception):
209                return
210
211    async def close(self) -> None:
212        if self._monitor is not None:
213            self._monitor.stop()
214        if self._aiu_poller is not None:
215            self._aiu_poller.stop()
216        for t in self._tasks:
217            t.cancel()
218        import contextlib  # noqa: PLC0415
219        for t in self._tasks:
220            with contextlib.suppress(asyncio.CancelledError, Exception):
221                await t
222        self._tasks.clear()
223        if self._tc is not None:
224            await self._tc.aclose()
225            self._tc = None
226        self._port = None
227        await super().close()
228
229    # ----- accessors --------------------------------------------------------
230
231    @property
232    def command_set(self) -> NceCommandSet:
233        if self._cmd_set is None:
234            raise RuntimeError("connection not open")
235        return self._cmd_set
236
237    @property
238    def traffic(self) -> NceTrafficController:
239        if self._tc is None:
240            raise RuntimeError("connection not open")
241        return self._tc
242
243    @property
244    def programmer(self) -> NceProgrammer:
245        if self._programmer is None:
246            raise RuntimeError("connection not open")
247        return self._programmer
248
249    # ----- hardware clock ---------------------------------------------------
250
251    async def read_hardware_clock(self) -> HardwareClockState:
252        """Read the CS's fast-clock state.
253
254        Talks to command-station memory at ``CS_CLOCK_ADDR`` and decodes
255        the 16-byte reply into a :class:`HardwareClockState`. In 12h mode
256        the CS reports hours 1..12 with an AM/PM flag; we normalise to
257        24-hour before returning so the JSON/web layer never has to care.
258        """
259        cmd = nce_messages.read_clock()
260        reply = await self.traffic.send_command(cmd)
261        data = reply.data
262        if _log.isEnabledFor(logging.DEBUG):
263            _log.debug(
264                "NCE clock read bytes: %s",
265                " ".join(f"{b:02X}" for b in data),
266            )
267        scale = data[0]
268        seconds = data[2] & 0xFF
269        minutes = data[3] & 0xFF
270        hours_raw = data[4] & 0xFF
271        ampm_flag = data[5]              # ASCII 'A' (65) = AM, else PM
272        twenty_four = (data[6] & 0xFF) == 1
273        # In 12h mode, hours cycle 12,1..11,12,1..11 with AM flipping to PM
274        # at each 12. Normalise: AM 12 → 0, PM 12 → 12, PM 1..11 → 13..23.
275        if twenty_four:
276            hours = hours_raw % 24
277        else:
278            is_am = ampm_flag == ord("A")
279            h = hours_raw % 12               # 12 → 0
280            hours = h if is_am else h + 12
281        # Scale of 0 would divide-by-zero; treat as "unknown, don't touch".
282        rate = float(250 // scale) if scale else 1.0
283        # The CS's 16-byte poll has no clean "running" bit — byte [1] is a
284        # scale-prescaler tick that stays nonzero even when stopped, so
285        # decoding it as a run flag reports running=True for a stopped
286        # clock. JMRI's ``NceClockControl`` infers running by watching
287        # tick advancement across reads instead; we do the same:
288        # if this read's seconds byte differs from the last one, the CS
289        # advanced → running. If identical, → stopped. On the very first
290        # read we have no history and fall back to whatever the last
291        # explicit start/stop button intent was.
292        if self._last_clock_seconds is None:
293            running = self._last_clock_running
294        else:
295            running = seconds != self._last_clock_seconds
296        self._last_clock_seconds = seconds
297        self._last_clock_running = running
298        return HardwareClockState(
299            hours=hours,
300            minutes=minutes,
301            seconds=seconds,
302            rate=rate,
303            running=running,
304            twenty_four_hour=twenty_four,
305        )
306
307    async def set_hardware_clock_time(
308        self, hours: int, minutes: int, seconds: int = 0,
309    ) -> None:
310        """Write hh:mm:ss to the CS clock."""
311        await self.traffic.send_command(
312            nce_messages.write_clock_time(hours, minutes, seconds),
313        )
314
315    async def set_hardware_clock_rate(self, rate: float) -> None:
316        """Set the CS fast-clock ratio. NCE only stores integer ratios 1..15;
317        we round and clamp to that range so the user's float input doesn't
318        drop a command."""
319        r = max(1, min(15, int(round(rate))))
320        await self.traffic.send_command(nce_messages.set_clock_ratio(r))
321
322    async def start_hardware_clock(self) -> None:
323        await self.traffic.send_command(nce_messages.start_clock())
324        # Mark the run intent so the *next* read (which may sample the
325        # seconds byte before the CS has ticked) doesn't wrongly report
326        # stopped. Clear the last-seconds cache so tick-advancement
327        # inference restarts on the second post-start read.
328        self._last_clock_running = True
329        self._last_clock_seconds = None
330
331    async def stop_hardware_clock(self) -> None:
332        await self.traffic.send_command(nce_messages.stop_clock())
333        # Explicit stop overrides tick-advancement inference for the next
334        # read; the last-seconds cache stays valid because a stopped clock
335        # will read the same seconds byte again.
336        self._last_clock_running = False

NCE hardware connection. Prefix N, name NCE.

name: ClassVar[str] = 'NCE'
prefix: ClassVar[str] = 'N'
async def open(self, settings: dict[str, typing.Any] | None = None) -> None:
 65    async def open(self, settings: ConnectionSettings | None = None) -> None:
 66        s = settings or {}
 67        explicit = s.get("command_set")
 68        # Convenience: set ``JMRI_NCE_DEBUG=1`` to turn on wire-level
 69        # DEBUG for the ``jmri_nce`` logger without having to write a
 70        # separate uvicorn --log-config file. Uvicorn's own --log-level
 71        # only affects uvicorn's loggers; our module loggers inherit
 72        # from root, so we need to boost the module logger explicitly.
 73        # Go through :mod:`jmri_core.env` so the truthy/whitespace
 74        # semantics match every other JMRI_* var.
 75        from jmri_core.env import nce_debug  # noqa: PLC0415
 76        if nce_debug():
 77            _nce_root = logging.getLogger("jmri_nce")
 78            _nce_root.setLevel(logging.DEBUG)
 79            # Make sure there's a handler that will actually print the
 80            # DEBUG records. Uvicorn installs a root-level handler at
 81            # startup that emits INFO+; adding a StreamHandler here
 82            # ensures our DEBUG lines aren't swallowed.
 83            if not any(
 84                isinstance(h, logging.StreamHandler)
 85                for h in _nce_root.handlers
 86            ):
 87                h = logging.StreamHandler()
 88                h.setLevel(logging.DEBUG)
 89                h.setFormatter(logging.Formatter(
 90                    "%(levelname)s:%(name)s:%(message)s",
 91                ))
 92                _nce_root.addHandler(h)
 93                _nce_root.propagate = False
 94            _log.warning("jmri_nce DEBUG logging enabled via JMRI_NCE_DEBUG=1")
 95        _log.warning(
 96            "NCE connection opening: kind=%s port=%s command_set=%s",
 97            s.get("kind"), s.get("port"), explicit or "(default)",
 98        )
 99        self._port = await _build_port(s)
100        self._tc = NceTrafficController(self._port)
101
102        # Command-set selection:
103        #
104        # 1. Explicit ``command_set`` setting wins — production users should
105        #    just specify which CS they have.
106        # 2. ``auto_detect`` is OPT-IN (default False). The version probe
107        #    opcode isn't verified across all firmwares; on some
108        #    PowerCab/PowerPro variants it leaves the CS waiting for
109        #    additional argument bytes, wedging the serial bus until the CS
110        #    is power-cycled. Don't enable auto-detect on production hardware
111        #    until the opcode is verified against your specific firmware.
112        # 3. Otherwise default to PH-Pro 2006+ (works on the largest set of
113        #    deployed NCE command stations).
114        if explicit and explicit in COMMAND_SETS:
115            self._cmd_set = COMMAND_SETS[explicit]
116        elif s.get("auto_detect", False):
117            self._cmd_set = await detect_command_set(self._tc)
118        else:
119            self._cmd_set = NCE_PH_PRO_2006
120
121        # Replace the base in-memory managers with NCE-aware ones.
122        nce_turnouts = NceTurnoutManager(self.prefix, tc=self._tc)
123        nce_sensors = NceSensorManager(self.prefix)
124        self.turnouts = nce_turnouts                      # type: ignore[assignment]
125        self.sensors = nce_sensors                        # type: ignore[assignment]
126        self.throttles = NceThrottleManager(self.prefix, tc=self._tc)
127        self.power = NcePowerManager(self._tc)
128        self._programmer = NceProgrammer(self._tc)
129        self.programmers = NceProgrammerManager(self._programmer)
130        # Replace the base placeholder ``ConsistManager`` (built in
131        # ``SystemConnection.__init__`` before drivers had a chance to
132        # install hardware-aware managers) with the NCE-aware one. This
133        # brings CS-managed (``command_station``) consists to life via
134        # 0xA2 sub-commands; software and advanced/CV19 consists still
135        # work because the subclass falls through to the base for those
136        # kinds. The rebind hooks used elsewhere aren't enough here —
137        # the fan-out overrides only live on ``NceConsistManager``.
138        from jmri_nce.consist import NceConsistManager  # noqa: PLC0415
139        self.consists = NceConsistManager(
140            self.throttles, self.prefix, self.programmers, tc=self._tc,
141        )
142
143        # Start polling tasks (turnout monitor + optional AIU poller).
144        # The turnout monitor can be opted out via ``disable_turnout_monitor``
145        # — useful when the memory base for a CS firmware variant isn't
146        # known yet, or when the poll is failing for some other reason and
147        # the user just wants the rest of the driver to function.
148        if not s.get("disable_turnout_monitor", False):
149            self._monitor = NceTurnoutMonitor(self._tc, nce_turnouts, self._cmd_set)
150            self._tasks.append(
151                asyncio.create_task(self._monitor.run(), name="nce-turnout-monitor"),
152            )
153
154        aius: list[int] = list(s.get("aiu_addresses", []) or [])
155        if aius and self._cmd_set.aiu_polling_supported:
156            self._aiu_poller = NceAiuPoller(self._tc, nce_sensors, aius)
157            self._tasks.append(asyncio.create_task(self._aiu_poller.run(), name="nce-aiu-poller"))
158
159        # CS reachability monitor. Polls the traffic controller's
160        # ``last_success_at`` timestamp once a second; if we haven't
161        # heard a completed round-trip in more than 3 seconds, mark
162        # the CS unreachable — the turnout monitor (1.5s cadence) or
163        # AIU poller (250ms) would have brought that number forward if
164        # the CS were still answering. Any fresh success flips it
165        # back on immediately.
166        #
167        # Start pessimistic: the base class defaults ``_cs_reachable``
168        # to True (matches ``is_open`` for drivers with no real
169        # detection), but because we DO have detection, we don't yet
170        # know if the CS is answering. Home-page renders in the first
171        # ~3 s would otherwise show "On" even against a dark CS; the
172        # monitor's first True-transition flips the badge live via
173        # the existing WebSocket push once we hear from the CS.
174        self._cs_reachable = False
175        self._tasks.append(asyncio.create_task(
176            self._cs_reachability_loop(), name="nce-cs-reachability",
177        ))
178
179        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:
211    async def close(self) -> None:
212        if self._monitor is not None:
213            self._monitor.stop()
214        if self._aiu_poller is not None:
215            self._aiu_poller.stop()
216        for t in self._tasks:
217            t.cancel()
218        import contextlib  # noqa: PLC0415
219        for t in self._tasks:
220            with contextlib.suppress(asyncio.CancelledError, Exception):
221                await t
222        self._tasks.clear()
223        if self._tc is not None:
224            await self._tc.aclose()
225            self._tc = None
226        self._port = None
227        await super().close()

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

command_set: NceCommandSet
231    @property
232    def command_set(self) -> NceCommandSet:
233        if self._cmd_set is None:
234            raise RuntimeError("connection not open")
235        return self._cmd_set
traffic: NceTrafficController
237    @property
238    def traffic(self) -> NceTrafficController:
239        if self._tc is None:
240            raise RuntimeError("connection not open")
241        return self._tc
programmer: NceProgrammer
243    @property
244    def programmer(self) -> NceProgrammer:
245        if self._programmer is None:
246            raise RuntimeError("connection not open")
247        return self._programmer
async def read_hardware_clock(self) -> jmri_core.beans.HardwareClockState:
251    async def read_hardware_clock(self) -> HardwareClockState:
252        """Read the CS's fast-clock state.
253
254        Talks to command-station memory at ``CS_CLOCK_ADDR`` and decodes
255        the 16-byte reply into a :class:`HardwareClockState`. In 12h mode
256        the CS reports hours 1..12 with an AM/PM flag; we normalise to
257        24-hour before returning so the JSON/web layer never has to care.
258        """
259        cmd = nce_messages.read_clock()
260        reply = await self.traffic.send_command(cmd)
261        data = reply.data
262        if _log.isEnabledFor(logging.DEBUG):
263            _log.debug(
264                "NCE clock read bytes: %s",
265                " ".join(f"{b:02X}" for b in data),
266            )
267        scale = data[0]
268        seconds = data[2] & 0xFF
269        minutes = data[3] & 0xFF
270        hours_raw = data[4] & 0xFF
271        ampm_flag = data[5]              # ASCII 'A' (65) = AM, else PM
272        twenty_four = (data[6] & 0xFF) == 1
273        # In 12h mode, hours cycle 12,1..11,12,1..11 with AM flipping to PM
274        # at each 12. Normalise: AM 12 → 0, PM 12 → 12, PM 1..11 → 13..23.
275        if twenty_four:
276            hours = hours_raw % 24
277        else:
278            is_am = ampm_flag == ord("A")
279            h = hours_raw % 12               # 12 → 0
280            hours = h if is_am else h + 12
281        # Scale of 0 would divide-by-zero; treat as "unknown, don't touch".
282        rate = float(250 // scale) if scale else 1.0
283        # The CS's 16-byte poll has no clean "running" bit — byte [1] is a
284        # scale-prescaler tick that stays nonzero even when stopped, so
285        # decoding it as a run flag reports running=True for a stopped
286        # clock. JMRI's ``NceClockControl`` infers running by watching
287        # tick advancement across reads instead; we do the same:
288        # if this read's seconds byte differs from the last one, the CS
289        # advanced → running. If identical, → stopped. On the very first
290        # read we have no history and fall back to whatever the last
291        # explicit start/stop button intent was.
292        if self._last_clock_seconds is None:
293            running = self._last_clock_running
294        else:
295            running = seconds != self._last_clock_seconds
296        self._last_clock_seconds = seconds
297        self._last_clock_running = running
298        return HardwareClockState(
299            hours=hours,
300            minutes=minutes,
301            seconds=seconds,
302            rate=rate,
303            running=running,
304            twenty_four_hour=twenty_four,
305        )

Read the CS's fast-clock state.

Talks to command-station memory at CS_CLOCK_ADDR and decodes the 16-byte reply into a HardwareClockState. In 12h mode the CS reports hours 1..12 with an AM/PM flag; we normalise to 24-hour before returning so the JSON/web layer never has to care.

async def set_hardware_clock_time(self, hours: int, minutes: int, seconds: int = 0) -> None:
307    async def set_hardware_clock_time(
308        self, hours: int, minutes: int, seconds: int = 0,
309    ) -> None:
310        """Write hh:mm:ss to the CS clock."""
311        await self.traffic.send_command(
312            nce_messages.write_clock_time(hours, minutes, seconds),
313        )

Write hh:mm:ss to the CS clock.

async def set_hardware_clock_rate(self, rate: float) -> None:
315    async def set_hardware_clock_rate(self, rate: float) -> None:
316        """Set the CS fast-clock ratio. NCE only stores integer ratios 1..15;
317        we round and clamp to that range so the user's float input doesn't
318        drop a command."""
319        r = max(1, min(15, int(round(rate))))
320        await self.traffic.send_command(nce_messages.set_clock_ratio(r))

Set the CS fast-clock ratio. NCE only stores integer ratios 1..15; we round and clamp to that range so the user's float input doesn't drop a command.

async def start_hardware_clock(self) -> None:
322    async def start_hardware_clock(self) -> None:
323        await self.traffic.send_command(nce_messages.start_clock())
324        # Mark the run intent so the *next* read (which may sample the
325        # seconds byte before the CS has ticked) doesn't wrongly report
326        # stopped. Clear the last-seconds cache so tick-advancement
327        # inference restarts on the second post-start read.
328        self._last_clock_running = True
329        self._last_clock_seconds = None
async def stop_hardware_clock(self) -> None:
331    async def stop_hardware_clock(self) -> None:
332        await self.traffic.send_command(nce_messages.stop_clock())
333        # Explicit stop overrides tick-advancement inference for the next
334        # read; the last-seconds cache stays valid because a stopped clock
335        # will read the same seconds byte again.
336        self._last_clock_running = False
class NcePowerManager(jmri_core.beans.power.PowerManager):
119class NcePowerManager(PowerManager):
120    """Send OP_POWER_ON / OP_POWER_OFF; state is set on successful reply."""
121
122    def __init__(self, tc: NceTrafficController) -> None:
123        super().__init__()
124        self._tc = tc
125
126    async def set_power(self, state: PowerState) -> None:
127        if state == PowerState.ON:
128            await self._tc.send_command(power_on())
129        elif state == PowerState.OFF:
130            await self._tc.send_command(power_off())
131        # NCE has no IDLE wire command; ignore other targets here.
132        await self._set_state(state)

Send OP_POWER_ON / OP_POWER_OFF; state is set on successful reply.

NcePowerManager(tc: NceTrafficController)
122    def __init__(self, tc: NceTrafficController) -> None:
123        super().__init__()
124        self._tc = tc
async def set_power(self, state: jmri_core.state.PowerState) -> None:
126    async def set_power(self, state: PowerState) -> None:
127        if state == PowerState.ON:
128            await self._tc.send_command(power_on())
129        elif state == PowerState.OFF:
130            await self._tc.send_command(power_off())
131        # NCE has no IDLE wire command; ignore other targets here.
132        await self._set_state(state)

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

class NceProgrammer:
 47class NceProgrammer:
 48    """CV access against an NCE command station."""
 49
 50    def __init__(self, tc: NceTrafficController) -> None:
 51        self._tc = tc
 52        self._programming_in_progress = asyncio.Event()
 53
 54    @property
 55    def is_programming(self) -> bool:
 56        """True while a direct-mode session is in flight."""
 57        return self._programming_in_progress.is_set()
 58
 59    async def wait_idle(self, timeout: float = 30.0) -> None:
 60        """Wait for any in-progress direct-mode session to finish."""
 61        if not self._programming_in_progress.is_set():
 62            return
 63        # ``wait`` returns True when set; we want to wait until it's clear.
 64        # asyncio.Event has no direct "wait clear" — poll briefly.
 65        deadline = asyncio.get_event_loop().time() + timeout
 66        while self._programming_in_progress.is_set():
 67            if asyncio.get_event_loop().time() > deadline:
 68                raise TimeoutError("programming did not complete in time")
 69            await asyncio.sleep(0.05)
 70
 71    # ----- ops mode (Programming On the Main) ----------------------------
 72    #
 73    # Rebuilt on the 2026-07-14 audit path: raw NMRA DCC packet wrapped
 74    # in NCE ``QUEUEn_TMP_CMD`` (opcodes 0x93..0x96). See
 75    # :func:`jmri_nce.messages.ops_mode_write_cv` for the packet layout.
 76    # NCE PowerPro has no ops-mode CV read (RailCom is out of scope), so
 77    # only ``write_cv_ops`` exists — reads must go via direct mode.
 78
 79    async def write_cv_ops(
 80        self, loco_address: int, cv: int, value: int,
 81        *, long_address: bool = False,
 82    ) -> None:
 83        """Ops-mode CV write (POM). Fires a single DCC packet on the main.
 84
 85        Unlike ``write_cv_direct``, this does *not* pause layout traffic:
 86        the CS just queues a service-mode packet in-between throttle
 87        packets. Result reaches the loco during normal operation.
 88
 89        There's no wire-level "did the decoder accept it?" — POM is
 90        inherently unverified without RailCom. The returned ACK confirms
 91        the CS queued the packet, not that the decoder wrote the CV.
 92        """
 93        await self._tc.send_command(
 94            ops_mode_write_cv(
 95                loco_address, cv, value, long_address=long_address,
 96            ),
 97        )
 98
 99    # ----- direct mode (programming track) -------------------------------
100    #
101    # NCE PowerPro's direct-mode CV opcodes (``0xA8`` / ``0xA9``) require
102    # the CS to be in *programming mode* — a persistent state entered
103    # via ``ENTER_PROG_CMD`` (0x9E) and exited via ``EXIT_PROG_CMD``
104    # (0x9F). Outside programming mode the CS immediately NoAcks the
105    # command with ``'0'`` (0x30) *without* running any DCC packets on
106    # the track. That looks like an "instant NoAck" on the wire (~ms,
107    # not the 2–6 s a real programming attempt takes).
108    #
109    # Java JMRI's ``NceProgrammer`` wraps every direct-mode read/write
110    # with enter/exit. We mirror that here in :meth:`_prog_op` — the
111    # write and read helpers both go through it so exit always happens
112    # even when the operation raises.
113
114    async def _prog_op(
115        self, op, *, consume_trailer: bool = False,
116    ) -> "NceReply":  # noqa: F821
117        """Run *op* (an NceCommand) wrapped in ENTER_PROG / EXIT_PROG.
118
119        The enter and exit commands are short (single-byte requests,
120        1-byte ACK reply) so the wrapper adds ~10 ms of latency, dwarfed
121        by the 2–6 s of any real direct-mode CV op. Best-effort exit:
122        if the wrapped op raises, we still send EXIT_PROG so the CS
123        returns to normal operation and layout traffic resumes. The
124        exit's own reply is swallowed on failure — we don't want it
125        masking the original error.
126
127        *consume_trailer*: for RD_CV_DIRECT (and any future command
128        where NCE PowerPro appends a trailing ACK byte after the
129        payload), set True so the trailer is eaten before EXIT_PROG
130        runs. Without this, the trailer can land inside EXIT_PROG's
131        expected-1-byte reply window and cascade into stream
132        desynchronization.
133        """
134        await self._tc.send_command(enter_programming_mode())
135        try:
136            reply = await self._tc.send_command(op)
137            if consume_trailer:
138                await self._tc.consume_trailer(max_wait_s=0.1)
139            return reply
140        finally:
141            try:
142                await self._tc.send_command(exit_programming_mode())
143            except Exception as e:  # noqa: BLE001
144                _log.warning(
145                    "NCE: EXIT_PROG failed after %s: %s", op.name, e,
146                )
147
148    async def write_cv_direct(self, cv: int, value: int) -> None:
149        """Service-mode write. Pauses layout traffic for ~2–6 s.
150
151        Wraps the write with :meth:`_prog_op` so the CS is in
152        programming mode when the ``WRITE_DIR_CV_CMD`` (0xA8) opcode
153        arrives — a hard requirement of NCE PowerPro firmware.
154        """
155        self._programming_in_progress.set()
156        try:
157            await self._prog_op(direct_mode_write_cv(cv, value))
158        finally:
159            self._programming_in_progress.clear()
160
161    async def read_cv_direct(self, cv: int) -> int:
162        """Service-mode read. Same caveats as :meth:`write_cv_direct`.
163
164        Wrapped in ENTER_PROG / EXIT_PROG for the same reason as writes.
165        Raises :class:`NceCommandError` when the decoder doesn't
166        acknowledge — NCE signals that by sending the single byte
167        ``0x30`` (ASCII ``'0'``). JMRI Java's ``NceProgrammer`` treats
168        byte ``'0'`` as ``NoAck`` regardless of whether the actual CV
169        happens to hold value 48; we mirror that here since the
170        alternative (a silent "value 48 always") is worse than the rare
171        false negative when a CV genuinely equals 48.
172
173        Firmware quirk on successful reads: NCE PowerPro sends *two*
174        bytes back — the CV value plus a trailing ``!`` ACK. Our
175        command spec asks for ``reply_len=1`` so the trailer lands in
176        the serial buffer. If the next command's drain catches it,
177        fine; if it doesn't (race), the trailer contaminates the next
178        reply. We proactively consume it here with a short blocking
179        wait, closing that race window.
180        """
181        self._programming_in_progress.set()
182        try:
183            reply = await self._prog_op(
184                direct_mode_read_cv(cv), consume_trailer=True,
185            )
186            byte = reply.data[0]
187            if byte == 0x30:   # '0' = NCE NoAck sentinel
188                # NoAck is a single byte on the wire; the trailer eater
189                # in ``_prog_op`` times out silently in that case,
190                # which is fine.
191                raise NceCommandError(
192                    reply.command, b"0",
193                    b"decoder did not ack read (no decoder on prog track?)",
194                )
195            return byte
196        except NceCommandError:
197            raise
198        finally:
199            self._programming_in_progress.clear()

CV access against an NCE command station.

NceProgrammer(tc: NceTrafficController)
50    def __init__(self, tc: NceTrafficController) -> None:
51        self._tc = tc
52        self._programming_in_progress = asyncio.Event()
is_programming: bool
54    @property
55    def is_programming(self) -> bool:
56        """True while a direct-mode session is in flight."""
57        return self._programming_in_progress.is_set()

True while a direct-mode session is in flight.

async def wait_idle(self, timeout: float = 30.0) -> None:
59    async def wait_idle(self, timeout: float = 30.0) -> None:
60        """Wait for any in-progress direct-mode session to finish."""
61        if not self._programming_in_progress.is_set():
62            return
63        # ``wait`` returns True when set; we want to wait until it's clear.
64        # asyncio.Event has no direct "wait clear" — poll briefly.
65        deadline = asyncio.get_event_loop().time() + timeout
66        while self._programming_in_progress.is_set():
67            if asyncio.get_event_loop().time() > deadline:
68                raise TimeoutError("programming did not complete in time")
69            await asyncio.sleep(0.05)

Wait for any in-progress direct-mode session to finish.

async def write_cv_ops( self, loco_address: int, cv: int, value: int, *, long_address: bool = False) -> None:
79    async def write_cv_ops(
80        self, loco_address: int, cv: int, value: int,
81        *, long_address: bool = False,
82    ) -> None:
83        """Ops-mode CV write (POM). Fires a single DCC packet on the main.
84
85        Unlike ``write_cv_direct``, this does *not* pause layout traffic:
86        the CS just queues a service-mode packet in-between throttle
87        packets. Result reaches the loco during normal operation.
88
89        There's no wire-level "did the decoder accept it?" — POM is
90        inherently unverified without RailCom. The returned ACK confirms
91        the CS queued the packet, not that the decoder wrote the CV.
92        """
93        await self._tc.send_command(
94            ops_mode_write_cv(
95                loco_address, cv, value, long_address=long_address,
96            ),
97        )

Ops-mode CV write (POM). Fires a single DCC packet on the main.

Unlike write_cv_direct, this does not pause layout traffic: the CS just queues a service-mode packet in-between throttle packets. Result reaches the loco during normal operation.

There's no wire-level "did the decoder accept it?" — POM is inherently unverified without RailCom. The returned ACK confirms the CS queued the packet, not that the decoder wrote the CV.

async def write_cv_direct(self, cv: int, value: int) -> None:
148    async def write_cv_direct(self, cv: int, value: int) -> None:
149        """Service-mode write. Pauses layout traffic for ~2–6 s.
150
151        Wraps the write with :meth:`_prog_op` so the CS is in
152        programming mode when the ``WRITE_DIR_CV_CMD`` (0xA8) opcode
153        arrives — a hard requirement of NCE PowerPro firmware.
154        """
155        self._programming_in_progress.set()
156        try:
157            await self._prog_op(direct_mode_write_cv(cv, value))
158        finally:
159            self._programming_in_progress.clear()

Service-mode write. Pauses layout traffic for ~2–6 s.

Wraps the write with _prog_op() so the CS is in programming mode when the WRITE_DIR_CV_CMD (0xA8) opcode arrives — a hard requirement of NCE PowerPro firmware.

async def read_cv_direct(self, cv: int) -> int:
161    async def read_cv_direct(self, cv: int) -> int:
162        """Service-mode read. Same caveats as :meth:`write_cv_direct`.
163
164        Wrapped in ENTER_PROG / EXIT_PROG for the same reason as writes.
165        Raises :class:`NceCommandError` when the decoder doesn't
166        acknowledge — NCE signals that by sending the single byte
167        ``0x30`` (ASCII ``'0'``). JMRI Java's ``NceProgrammer`` treats
168        byte ``'0'`` as ``NoAck`` regardless of whether the actual CV
169        happens to hold value 48; we mirror that here since the
170        alternative (a silent "value 48 always") is worse than the rare
171        false negative when a CV genuinely equals 48.
172
173        Firmware quirk on successful reads: NCE PowerPro sends *two*
174        bytes back — the CV value plus a trailing ``!`` ACK. Our
175        command spec asks for ``reply_len=1`` so the trailer lands in
176        the serial buffer. If the next command's drain catches it,
177        fine; if it doesn't (race), the trailer contaminates the next
178        reply. We proactively consume it here with a short blocking
179        wait, closing that race window.
180        """
181        self._programming_in_progress.set()
182        try:
183            reply = await self._prog_op(
184                direct_mode_read_cv(cv), consume_trailer=True,
185            )
186            byte = reply.data[0]
187            if byte == 0x30:   # '0' = NCE NoAck sentinel
188                # NoAck is a single byte on the wire; the trailer eater
189                # in ``_prog_op`` times out silently in that case,
190                # which is fine.
191                raise NceCommandError(
192                    reply.command, b"0",
193                    b"decoder did not ack read (no decoder on prog track?)",
194                )
195            return byte
196        except NceCommandError:
197            raise
198        finally:
199            self._programming_in_progress.clear()

Service-mode read. Same caveats as write_cv_direct().

Wrapped in ENTER_PROG / EXIT_PROG for the same reason as writes. Raises NceCommandError when the decoder doesn't acknowledge — NCE signals that by sending the single byte 0x30 (ASCII '0'). JMRI Java's NceProgrammer treats byte '0' as NoAck regardless of whether the actual CV happens to hold value 48; we mirror that here since the alternative (a silent "value 48 always") is worse than the rare false negative when a CV genuinely equals 48.

Firmware quirk on successful reads: NCE PowerPro sends two bytes back — the CV value plus a trailing ! ACK. Our command spec asks for reply_len=1 so the trailer lands in the serial buffer. If the next command's drain catches it, fine; if it doesn't (race), the trailer contaminates the next reply. We proactively consume it here with a short blocking wait, closing that race window.

class NceProgrammerManager(jmri_core.beans.programmer.ProgrammerManager):
266class NceProgrammerManager(ProgrammerManager):
267    """Hands out NCE service and ops programmers backed by one underlying
268    :class:`NceProgrammer`."""
269
270    def __init__(self, inner: NceProgrammer) -> None:
271        self._inner = inner
272        self._service_singleton: NceServiceProgrammer | None = None
273
274    def global_programmer(self) -> NceServiceProgrammer:
275        if self._service_singleton is None:
276            self._service_singleton = NceServiceProgrammer(self._inner)
277        return self._service_singleton
278
279    def ops_programmer(
280        self, address: int, *, long_address: bool = False,
281    ) -> NceOpsProgrammer:
282        """Return an ops-mode programmer bound to *address*.
283
284        Fresh instance per call — the caller usually owns one per loco
285        currently in the batch queue. Cheap: no wire traffic until the
286        first ``write_cv``.
287        """
288        return NceOpsProgrammer(
289            self._inner, address, long_address=long_address,
290        )
291
292    @property
293    def inner(self) -> NceProgrammer:
294        """Direct access to the underlying NCE-specific programmer."""
295        return self._inner

Hands out NCE service and ops programmers backed by one underlying NceProgrammer.

NceProgrammerManager(inner: NceProgrammer)
270    def __init__(self, inner: NceProgrammer) -> None:
271        self._inner = inner
272        self._service_singleton: NceServiceProgrammer | None = None
def global_programmer(self) -> NceServiceProgrammer:
274    def global_programmer(self) -> NceServiceProgrammer:
275        if self._service_singleton is None:
276            self._service_singleton = NceServiceProgrammer(self._inner)
277        return self._service_singleton
def ops_programmer( self, address: int, *, long_address: bool = False) -> jmri_nce.programmer.NceOpsProgrammer:
279    def ops_programmer(
280        self, address: int, *, long_address: bool = False,
281    ) -> NceOpsProgrammer:
282        """Return an ops-mode programmer bound to *address*.
283
284        Fresh instance per call — the caller usually owns one per loco
285        currently in the batch queue. Cheap: no wire traffic until the
286        first ``write_cv``.
287        """
288        return NceOpsProgrammer(
289            self._inner, address, long_address=long_address,
290        )

Return an ops-mode programmer bound to address.

Fresh instance per call — the caller usually owns one per loco currently in the batch queue. Cheap: no wire traffic until the first write_cv.

inner: NceProgrammer
292    @property
293    def inner(self) -> NceProgrammer:
294        """Direct access to the underlying NCE-specific programmer."""
295        return self._inner

Direct access to the underlying NCE-specific programmer.

@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 NceServiceProgrammer(jmri_core.beans.programmer.Programmer):
205class NceServiceProgrammer(Programmer):
206    """Service-mode adapter — programming-track CV access."""
207
208    def __init__(self, inner: NceProgrammer) -> None:
209        self._inner = inner
210
211    @property
212    def mode(self) -> ProgrammingMode:
213        return ProgrammingMode.SERVICE_DIRECT
214
215    async def read_cv(self, cv: int) -> int:
216        return await self._inner.read_cv_direct(cv)
217
218    async def write_cv(self, cv: int, value: int) -> None:
219        await self._inner.write_cv_direct(cv, value)

Service-mode adapter — programming-track CV access.

NceServiceProgrammer(inner: NceProgrammer)
208    def __init__(self, inner: NceProgrammer) -> None:
209        self._inner = inner
211    @property
212    def mode(self) -> ProgrammingMode:
213        return ProgrammingMode.SERVICE_DIRECT

Which mode this programmer instance uses.

async def read_cv(self, cv: int) -> int:
215    async def read_cv(self, cv: int) -> int:
216        return await self._inner.read_cv_direct(cv)

Read CV cv, returns 0..255.

async def write_cv(self, cv: int, value: int) -> None:
218    async def write_cv(self, cv: int, value: int) -> None:
219        await self._inner.write_cv_direct(cv, value)

Write value to CV cv.

class NceSensor(jmri_core.beans.sensor.Sensor):
80class NceSensor(Sensor):
81    """Passive — its state is set by the AIU poller.
82
83    Sensor system names embed the AIU address + input number: ``NS<n>``
84    where *n* = (aiu_addr-1) * 14 + input_bit + 1. The poller does the
85    decoding; the bean is just storage.
86    """
87
88    __slots__ = ("_address",)
89
90    def __init__(self, system_name: str, address: int) -> None:
91        super().__init__(system_name)
92        self._address = address
93
94    @property
95    def address(self) -> int:
96        return self._address

Passive — its state is set by the AIU poller.

Sensor system names embed the AIU address + input number: NS<n> where n = (aiu_addr-1) * 14 + input_bit + 1. The poller does the decoding; the bean is just storage.

NceSensor(system_name: str, address: int)
90    def __init__(self, system_name: str, address: int) -> None:
91        super().__init__(system_name)
92        self._address = address
address: int
94    @property
95    def address(self) -> int:
96        return self._address
class NceSensorManager(jmri_core.manager.Manager[jmri_nce.beans.NceSensor]):
 99class NceSensorManager(Manager[NceSensor]):
100    type_letter = "S"
101
102    def __init__(self, system_prefix: str) -> None:
103        super().__init__(system_prefix)
104        self._by_address: dict[int, NceSensor] = {}
105
106    def _create(self, system_name: str) -> NceSensor:
107        address = _parse_address(system_name, self.full_prefix)
108        bean = NceSensor(system_name, address)
109        self._by_address[address] = bean
110        return bean
111
112    def get_by_address(self, address: int) -> NceSensor | None:
113        return self._by_address.get(address)

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.
NceSensorManager(system_prefix: str)
102    def __init__(self, system_prefix: str) -> None:
103        super().__init__(system_prefix)
104        self._by_address: dict[int, NceSensor] = {}
type_letter = 'S'
def get_by_address(self, address: int) -> NceSensor | None:
112    def get_by_address(self, address: int) -> NceSensor | None:
113        return self._by_address.get(address)
class NceThrottle(jmri_core.beans.throttle.Throttle):
112class NceThrottle(Throttle):
113    """Throttle driving an NCE command station via the 0xA2 loco command."""
114
115    def __init__(
116        self, address: int, tc: NceTrafficController, *, long_address: bool = False
117    ) -> None:
118        super().__init__(address=address, long_address=long_address)
119        self._tc = tc
120
121    async def set_speed(self, speed: float) -> None:
122        await super().set_speed(speed)
123        await self._push_speed()
124
125    async def set_direction(self, direction: ThrottleDirection) -> None:
126        await super().set_direction(direction)
127        await self._push_speed()
128
129    async def set_function(self, index: int, on: bool) -> None:
130        await super().set_function(index, on)
131        if index <= 4:
132            await self._push_group(LOCO_SUB_FUNC_GROUP_1, _func_group_1_data(self.functions))
133        elif index <= 8:
134            await self._push_group(LOCO_SUB_FUNC_GROUP_2, _func_group_2_data(self.functions))
135        elif index <= 12:
136            await self._push_group(LOCO_SUB_FUNC_GROUP_3, _func_group_3_data(self.functions))
137        elif index <= 20:
138            await self._push_group(LOCO_SUB_FUNC_GROUP_4, _func_group_4_data(self.functions))
139        else:
140            await self._push_group(LOCO_SUB_FUNC_GROUP_5, _func_group_5_data(self.functions))
141
142    # ----- helpers --------------------------------------------------------
143
144    async def _push_speed(self) -> None:
145        forward = self.direction == ThrottleDirection.FORWARD
146        if self.speed == -1.0:
147            sub = LOCO_SUB_ESTOP_FWD if forward else LOCO_SUB_ESTOP_REV
148            await self._send(sub, 0)
149            return
150        sub = LOCO_SUB_128_FWD if forward else LOCO_SUB_128_REV
151        await self._send(sub, speed_to_wire_128(self.speed))
152
153    async def _push_group(self, sub_cmd: int, data: int) -> None:
154        await self._send(sub_cmd, data)
155
156    async def _send(self, sub_cmd: int, data: int) -> None:
157        cmd = nce_loco_cmd(
158            self.address,
159            long_address=self.long_address,
160            sub_cmd=sub_cmd,
161            data=data,
162        )
163        if _log.isEnabledFor(logging.DEBUG):
164            _log.debug(
165                "NCE throttle out: %s bytes=%s",
166                cmd.name, " ".join(f"{b:02X}" for b in cmd.request),
167            )
168        await self._tc.send_command(cmd)

Throttle driving an NCE command station via the 0xA2 loco command.

NceThrottle( address: int, tc: NceTrafficController, *, long_address: bool = False)
115    def __init__(
116        self, address: int, tc: NceTrafficController, *, long_address: bool = False
117    ) -> None:
118        super().__init__(address=address, long_address=long_address)
119        self._tc = tc
async def set_speed(self, speed: float) -> None:
121    async def set_speed(self, speed: float) -> None:
122        await super().set_speed(speed)
123        await self._push_speed()
async def set_direction(self, direction: jmri_core.state.ThrottleDirection) -> None:
125    async def set_direction(self, direction: ThrottleDirection) -> None:
126        await super().set_direction(direction)
127        await self._push_speed()
async def set_function(self, index: int, on: bool) -> None:
129    async def set_function(self, index: int, on: bool) -> None:
130        await super().set_function(index, on)
131        if index <= 4:
132            await self._push_group(LOCO_SUB_FUNC_GROUP_1, _func_group_1_data(self.functions))
133        elif index <= 8:
134            await self._push_group(LOCO_SUB_FUNC_GROUP_2, _func_group_2_data(self.functions))
135        elif index <= 12:
136            await self._push_group(LOCO_SUB_FUNC_GROUP_3, _func_group_3_data(self.functions))
137        elif index <= 20:
138            await self._push_group(LOCO_SUB_FUNC_GROUP_4, _func_group_4_data(self.functions))
139        else:
140            await self._push_group(LOCO_SUB_FUNC_GROUP_5, _func_group_5_data(self.functions))
class NceThrottleManager(jmri_core.beans.throttle_manager.ThrottleManager):
171class NceThrottleManager(ThrottleManager):
172    """NCE throttle manager — uses the core ThrottleManager hook."""
173
174    def __init__(self, system_prefix: str, *, tc: NceTrafficController) -> None:
175        super().__init__(system_prefix)
176        self._tc = tc
177
178    async def _construct(self, address: int, *, long_address: bool) -> NceThrottle:
179        return NceThrottle(address=address, tc=self._tc, long_address=long_address)

NCE throttle manager — uses the core ThrottleManager hook.

NceThrottleManager(system_prefix: str, *, tc: NceTrafficController)
174    def __init__(self, system_prefix: str, *, tc: NceTrafficController) -> None:
175        super().__init__(system_prefix)
176        self._tc = tc
class NceTrafficController:
 29class NceTrafficController:
 30    """One command in flight at a time. All sends serialize through ``_lock``."""
 31
 32    # Fast-fail behaviour: once the CS has been silent for more than
 33    # ``SILENCE_THRESHOLD_S`` (matching the reachability monitor's
 34    # window plus a small margin so the two don't fight for control),
 35    # ``send_command`` refuses to touch the serial port and raises
 36    # ``TimeoutError`` immediately. Otherwise the browser's 1-Hz
 37    # fast-clock polls + the turnout monitor's 1.5 s poll cadence
 38    # queue up behind the ``_lock`` and each pays the full 500 ms
 39    # timeout — a home-page render or profile-load can stall for
 40    # 5–10 seconds even though we already know the CS is off.
 41    #
 42    # Every ``PROBE_INTERVAL_S`` one command IS let through to probe
 43    # whether the CS has come back. On success the timestamp gets
 44    # updated, silence drops below threshold, and normal operation
 45    # resumes.
 46    SILENCE_THRESHOLD_S = 4.0
 47    PROBE_INTERVAL_S = 5.0
 48
 49    def __init__(self, port: BytePort) -> None:
 50        self._port = port
 51        self._lock = asyncio.Lock()
 52        self._closed = False
 53        # Timestamp (``time.monotonic()`` scale) of the most recent
 54        # completed round-trip with the CS. ``None`` before any
 55        # command has been sent. Read by :class:`NceConnection`'s
 56        # reachability monitor to flip the CS-power state; write-only
 57        # here — updates on every success in :meth:`send_command` and
 58        # in :meth:`consume_trailer` when the trailer arrives.
 59        self.last_success_at: float | None = None
 60        # Fallback reference for the fast-fail silence calculation.
 61        # If the CS has been off since server start, ``last_success_at``
 62        # is always ``None`` and would defeat the short-circuit —
 63        # every command would still pay its own serial timeout. Using
 64        # ``max(start_time, last_success_at)`` means an always-off CS
 65        # is detected the moment startup grace elapses, and a CS that
 66        # went off after some traffic is measured from its last reply.
 67        self._start_time: float = time.monotonic()
 68        # Next moment at which a probe attempt is allowed while the CS
 69        # is known silent. See the class-level fast-fail comment.
 70        self._next_probe_at: float | None = None
 71
 72    async def aclose(self) -> None:
 73        self._closed = True
 74        await self._port.aclose()
 75
 76    async def send_command(self, cmd: NceCommand) -> NceReply:
 77        """Write *cmd*, await its reply with timeout, return the reply data.
 78
 79        Raises:
 80          - :class:`asyncio.TimeoutError` if the command station doesn't
 81            answer within ``cmd.timeout_s``;
 82          - :class:`NceCommandError` if the ACK byte indicates failure.
 83        """
 84        if self._closed:
 85            raise RuntimeError("traffic controller is closed")
 86        # Fast-fail if the CS has been silent long enough that the
 87        # reachability monitor has already flipped it to unreachable.
 88        # Without this, a browser page-load can queue ~10 commands (fast
 89        # clock + turnout monitor + user actions) that each pay 500 ms
 90        # of serial timeout in series — 5–9 s of hang on GET / and
 91        # POST /profiles/load. Let one probe through every
 92        # ``PROBE_INTERVAL_S`` so recovery is detected.
 93        #
 94        # ``reference`` is the later of first-open and last successful
 95        # round-trip. If the CS has been off since startup,
 96        # ``last_success_at`` is ``None`` — measuring silence from
 97        # ``_start_time`` still trips the short-circuit once startup
 98        # grace elapses.
 99        now = time.monotonic()
100        reference = (
101            self.last_success_at
102            if self.last_success_at is not None
103            else self._start_time
104        )
105        silence = now - reference
106        if silence > self.SILENCE_THRESHOLD_S:
107            if self._next_probe_at is not None and now < self._next_probe_at:
108                raise TimeoutError(
109                    f"NCE CS silent for {silence:.1f}s; "
110                    f"{cmd.name} short-circuited"
111                )
112            # Let this one through as a probe; re-arm for the next
113            # window so only one attempt hits serial per interval.
114            self._next_probe_at = now + self.PROBE_INTERVAL_S
115        async with self._lock:
116            # Drain any bytes the CS left in the read buffer before the
117            # request. Without this, leftover bytes — CS startup chatter,
118            # or extras the CS sends after a documented reply — are read
119            # as the *current* command's ACK on the next send. (Action
120            # commands then fail with random non-``!`` ACKs.)
121            await self._drain_pending()
122            _log.debug("NCE send %s: %s", cmd.name, cmd.request.hex())
123            await self._port.write(cmd.request)
124            # Read commands return exactly ``reply_len`` data bytes (no ACK).
125            # Action commands return ``reply_len`` data bytes + 1 ACK byte.
126            n = cmd.reply_len if cmd.is_read else cmd.reply_len + 1
127            try:
128                full = await asyncio.wait_for(
129                    self._port.read_exact(n),
130                    timeout=cmd.timeout_s,
131                )
132            except TimeoutError:
133                _log.warning("NCE command %s timed out", cmd.name)
134                raise
135            _log.debug("NCE recv %s: %s", cmd.name, full.hex())
136            # Any completed round-trip counts as evidence the CS is on.
137            self.last_success_at = time.monotonic()
138            if cmd.is_read:
139                return NceReply(command=cmd, data=full)
140            data = full[: cmd.reply_len]
141            ack = full[cmd.reply_len : cmd.reply_len + 1]
142            # NCE's reply byte semantics vary by command and firmware: some
143            # commands return ``!`` (ACK_OK), others echo the opcode or
144            # respond with a status byte that's command-specific. JMRI Java
145            # treats action commands as fire-and-forget (null listener,
146            # no value check). The one signal we *do* care about is the
147            # explicit-NACK byte ``b'0'`` (ACK_ERROR) — anything else is
148            # accepted as success.
149            if ack == ACK_ERROR:
150                raise NceCommandError(cmd, ack, data)
151            return NceReply(command=cmd, data=data)
152
153    async def _drain_pending(self) -> None:
154        """Discard any bytes already sitting in the read buffer.
155
156        Non-blocking: returns immediately if the buffer is empty. Avoids
157        the ~5ms-per-command timeout penalty of a polling loop.
158        """
159        stray = await self._port.read_pending()
160        if stray:
161            _log.debug("NCE drained %d stale byte(s): %s", len(stray), stray.hex())
162
163    async def consume_trailer(self, max_wait_s: float = 0.1) -> bytes:
164        """Wait briefly for a trailing byte and consume it if it arrives.
165
166        NCE PowerPro firmware quirk: some commands (notably
167        RD_CV_DIRECT on successful decoder ack) send TWO bytes back —
168        the payload byte plus a trailing ``!`` ACK. Our command spec
169        for RD_CV_DIRECT expects ``reply_len=1``, so the second byte
170        lands in the serial buffer. Usually the next command's drain
171        catches it, but occasionally it arrives *after* the drain
172        window closes but *before* the next command's expected reply
173        — corrupting the reply as though we'd shifted by one byte.
174
175        Callers who know their command has an optional trailer should
176        call this immediately after :meth:`send_command`. Holds no
177        lock (the caller's ``send_command`` call already released it),
178        which is fine because we're only READING from the port, not
179        writing — a concurrent write can't interleave.
180
181        Returns the trailer byte if seen, empty bytes otherwise.
182        """
183        try:
184            trailer = await asyncio.wait_for(
185                self._port.read_exact(1),
186                timeout=max_wait_s,
187            )
188        except TimeoutError:
189            return b""
190        _log.debug("NCE consumed trailer byte: %02x", trailer[0])
191        return trailer

One command in flight at a time. All sends serialize through _lock.

NceTrafficController(port: BytePort)
49    def __init__(self, port: BytePort) -> None:
50        self._port = port
51        self._lock = asyncio.Lock()
52        self._closed = False
53        # Timestamp (``time.monotonic()`` scale) of the most recent
54        # completed round-trip with the CS. ``None`` before any
55        # command has been sent. Read by :class:`NceConnection`'s
56        # reachability monitor to flip the CS-power state; write-only
57        # here — updates on every success in :meth:`send_command` and
58        # in :meth:`consume_trailer` when the trailer arrives.
59        self.last_success_at: float | None = None
60        # Fallback reference for the fast-fail silence calculation.
61        # If the CS has been off since server start, ``last_success_at``
62        # is always ``None`` and would defeat the short-circuit —
63        # every command would still pay its own serial timeout. Using
64        # ``max(start_time, last_success_at)`` means an always-off CS
65        # is detected the moment startup grace elapses, and a CS that
66        # went off after some traffic is measured from its last reply.
67        self._start_time: float = time.monotonic()
68        # Next moment at which a probe attempt is allowed while the CS
69        # is known silent. See the class-level fast-fail comment.
70        self._next_probe_at: float | None = None
SILENCE_THRESHOLD_S = 4.0
PROBE_INTERVAL_S = 5.0
last_success_at: float | None
async def aclose(self) -> None:
72    async def aclose(self) -> None:
73        self._closed = True
74        await self._port.aclose()
async def send_command(self, cmd: NceCommand) -> NceReply:
 76    async def send_command(self, cmd: NceCommand) -> NceReply:
 77        """Write *cmd*, await its reply with timeout, return the reply data.
 78
 79        Raises:
 80          - :class:`asyncio.TimeoutError` if the command station doesn't
 81            answer within ``cmd.timeout_s``;
 82          - :class:`NceCommandError` if the ACK byte indicates failure.
 83        """
 84        if self._closed:
 85            raise RuntimeError("traffic controller is closed")
 86        # Fast-fail if the CS has been silent long enough that the
 87        # reachability monitor has already flipped it to unreachable.
 88        # Without this, a browser page-load can queue ~10 commands (fast
 89        # clock + turnout monitor + user actions) that each pay 500 ms
 90        # of serial timeout in series — 5–9 s of hang on GET / and
 91        # POST /profiles/load. Let one probe through every
 92        # ``PROBE_INTERVAL_S`` so recovery is detected.
 93        #
 94        # ``reference`` is the later of first-open and last successful
 95        # round-trip. If the CS has been off since startup,
 96        # ``last_success_at`` is ``None`` — measuring silence from
 97        # ``_start_time`` still trips the short-circuit once startup
 98        # grace elapses.
 99        now = time.monotonic()
100        reference = (
101            self.last_success_at
102            if self.last_success_at is not None
103            else self._start_time
104        )
105        silence = now - reference
106        if silence > self.SILENCE_THRESHOLD_S:
107            if self._next_probe_at is not None and now < self._next_probe_at:
108                raise TimeoutError(
109                    f"NCE CS silent for {silence:.1f}s; "
110                    f"{cmd.name} short-circuited"
111                )
112            # Let this one through as a probe; re-arm for the next
113            # window so only one attempt hits serial per interval.
114            self._next_probe_at = now + self.PROBE_INTERVAL_S
115        async with self._lock:
116            # Drain any bytes the CS left in the read buffer before the
117            # request. Without this, leftover bytes — CS startup chatter,
118            # or extras the CS sends after a documented reply — are read
119            # as the *current* command's ACK on the next send. (Action
120            # commands then fail with random non-``!`` ACKs.)
121            await self._drain_pending()
122            _log.debug("NCE send %s: %s", cmd.name, cmd.request.hex())
123            await self._port.write(cmd.request)
124            # Read commands return exactly ``reply_len`` data bytes (no ACK).
125            # Action commands return ``reply_len`` data bytes + 1 ACK byte.
126            n = cmd.reply_len if cmd.is_read else cmd.reply_len + 1
127            try:
128                full = await asyncio.wait_for(
129                    self._port.read_exact(n),
130                    timeout=cmd.timeout_s,
131                )
132            except TimeoutError:
133                _log.warning("NCE command %s timed out", cmd.name)
134                raise
135            _log.debug("NCE recv %s: %s", cmd.name, full.hex())
136            # Any completed round-trip counts as evidence the CS is on.
137            self.last_success_at = time.monotonic()
138            if cmd.is_read:
139                return NceReply(command=cmd, data=full)
140            data = full[: cmd.reply_len]
141            ack = full[cmd.reply_len : cmd.reply_len + 1]
142            # NCE's reply byte semantics vary by command and firmware: some
143            # commands return ``!`` (ACK_OK), others echo the opcode or
144            # respond with a status byte that's command-specific. JMRI Java
145            # treats action commands as fire-and-forget (null listener,
146            # no value check). The one signal we *do* care about is the
147            # explicit-NACK byte ``b'0'`` (ACK_ERROR) — anything else is
148            # accepted as success.
149            if ack == ACK_ERROR:
150                raise NceCommandError(cmd, ack, data)
151            return NceReply(command=cmd, data=data)

Write cmd, await its reply with timeout, return the reply data.

Raises:

  • asyncio.TimeoutError if the command station doesn't answer within cmd.timeout_s;
  • NceCommandError if the ACK byte indicates failure.
async def consume_trailer(self, max_wait_s: float = 0.1) -> bytes:
163    async def consume_trailer(self, max_wait_s: float = 0.1) -> bytes:
164        """Wait briefly for a trailing byte and consume it if it arrives.
165
166        NCE PowerPro firmware quirk: some commands (notably
167        RD_CV_DIRECT on successful decoder ack) send TWO bytes back —
168        the payload byte plus a trailing ``!`` ACK. Our command spec
169        for RD_CV_DIRECT expects ``reply_len=1``, so the second byte
170        lands in the serial buffer. Usually the next command's drain
171        catches it, but occasionally it arrives *after* the drain
172        window closes but *before* the next command's expected reply
173        — corrupting the reply as though we'd shifted by one byte.
174
175        Callers who know their command has an optional trailer should
176        call this immediately after :meth:`send_command`. Holds no
177        lock (the caller's ``send_command`` call already released it),
178        which is fine because we're only READING from the port, not
179        writing — a concurrent write can't interleave.
180
181        Returns the trailer byte if seen, empty bytes otherwise.
182        """
183        try:
184            trailer = await asyncio.wait_for(
185                self._port.read_exact(1),
186                timeout=max_wait_s,
187            )
188        except TimeoutError:
189            return b""
190        _log.debug("NCE consumed trailer byte: %02x", trailer[0])
191        return trailer

Wait briefly for a trailing byte and consume it if it arrives.

NCE PowerPro firmware quirk: some commands (notably RD_CV_DIRECT on successful decoder ack) send TWO bytes back — the payload byte plus a trailing ! ACK. Our command spec for RD_CV_DIRECT expects reply_len=1, so the second byte lands in the serial buffer. Usually the next command's drain catches it, but occasionally it arrives after the drain window closes but before the next command's expected reply — corrupting the reply as though we'd shifted by one byte.

Callers who know their command has an optional trailer should call this immediately after send_command(). Holds no lock (the caller's send_command call already released it), which is fine because we're only READING from the port, not writing — a concurrent write can't interleave.

Returns the trailer byte if seen, empty bytes otherwise.

class NceTurnout(jmri_core.beans.turnout.Turnout):
17class NceTurnout(Turnout):
18    """Sends an accessory-decoder normal-ops packet on commanded-state change.
19
20    Known state arrives via :class:`~jmri_nce.monitors.NceTurnoutMonitor`
21    polling, not in-line, so :meth:`set_commanded_state` does **not** mirror
22    commanded into known. The monitor sets known state on the next poll
23    cycle (default 1.5s).
24    """
25
26    __slots__ = ("_address", "_tc")
27
28    def __init__(self, system_name: str, address: int, tc: NceTrafficController) -> None:
29        super().__init__(system_name)
30        self._address = address
31        self._tc = tc
32
33    @property
34    def address(self) -> int:
35        return self._address
36
37    async def set_commanded_state(self, state: TurnoutState) -> None:
38        if state in (TurnoutState.CLOSED, TurnoutState.THROWN):
39            await self._tc.send_command(
40                accessory_decoder_normal_ops(self._address, on=(state == TurnoutState.CLOSED))
41            )
42        prev = self._commanded_state
43        if prev == state:
44            return
45        self._commanded_state = state
46        # Mirror commanded → known for immediate UI feedback. JMRI Java
47        # does the same. The next monitor poll will correct this back if
48        # the real CS state disagrees (e.g., motor stall, no decoder).
49        if state in (TurnoutState.CLOSED, TurnoutState.THROWN):
50            await self._set_known_state(state)

Sends an accessory-decoder normal-ops packet on commanded-state change.

Known state arrives via ~jmri_nce.monitors.NceTurnoutMonitor polling, not in-line, so set_commanded_state() does not mirror commanded into known. The monitor sets known state on the next poll cycle (default 1.5s).

NceTurnout( system_name: str, address: int, tc: NceTrafficController)
28    def __init__(self, system_name: str, address: int, tc: NceTrafficController) -> None:
29        super().__init__(system_name)
30        self._address = address
31        self._tc = tc
address: int
33    @property
34    def address(self) -> int:
35        return self._address
async def set_commanded_state(self, state: jmri_core.state.TurnoutState) -> None:
37    async def set_commanded_state(self, state: TurnoutState) -> None:
38        if state in (TurnoutState.CLOSED, TurnoutState.THROWN):
39            await self._tc.send_command(
40                accessory_decoder_normal_ops(self._address, on=(state == TurnoutState.CLOSED))
41            )
42        prev = self._commanded_state
43        if prev == state:
44            return
45        self._commanded_state = state
46        # Mirror commanded → known for immediate UI feedback. JMRI Java
47        # does the same. The next monitor poll will correct this back if
48        # the real CS state disagrees (e.g., motor stall, no decoder).
49        if state in (TurnoutState.CLOSED, TurnoutState.THROWN):
50            await self._set_known_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 NceTurnoutManager(jmri_core.manager.Manager[jmri_nce.beans.NceTurnout]):
53class NceTurnoutManager(Manager[NceTurnout]):
54    """Constructs and indexes NCE turnouts by accessory address."""
55
56    type_letter = "T"
57
58    def __init__(self, system_prefix: str, *, tc: NceTrafficController) -> None:
59        super().__init__(system_prefix)
60        self._tc = tc
61        self._by_address: dict[int, NceTurnout] = {}
62
63    def _create(self, system_name: str) -> NceTurnout:
64        address = _parse_address(system_name, self.full_prefix)
65        bean = NceTurnout(system_name, address, self._tc)
66        self._by_address[address] = bean
67        return bean
68
69    async def deregister(self, bean: NceTurnout) -> None:
70        self._by_address.pop(bean.address, None)
71        await super().deregister(bean)
72
73    def get_by_address(self, address: int) -> NceTurnout | None:
74        return self._by_address.get(address)

Constructs and indexes NCE turnouts by accessory address.

NceTurnoutManager(system_prefix: str, *, tc: NceTrafficController)
58    def __init__(self, system_prefix: str, *, tc: NceTrafficController) -> None:
59        super().__init__(system_prefix)
60        self._tc = tc
61        self._by_address: dict[int, NceTurnout] = {}
type_letter = 'T'
async def deregister(self, bean: NceTurnout) -> None:
69    async def deregister(self, bean: NceTurnout) -> None:
70        self._by_address.pop(bean.address, None)
71        await super().deregister(bean)

Remove bean from the registry. No-op if not present.

def get_by_address(self, address: int) -> NceTurnout | None:
73    def get_by_address(self, address: int) -> NceTurnout | None:
74        return self._by_address.get(address)
class NceTurnoutMonitor:
35class NceTurnoutMonitor:
36    """Periodically poll the command-station turnout-status memory."""
37
38    POLL_INTERVAL_S = 1.5
39    CHUNK_BYTES = 16          # 16 bytes × 8 turnouts/byte = 128 per chunk
40
41    def __init__(
42        self,
43        tc: NceTrafficController,
44        mgr: NceTurnoutManager,
45        cmd_set: NceCommandSet,
46    ) -> None:
47        self._tc = tc
48        self._mgr = mgr
49        self._cs = cmd_set
50        # Pre-allocated snapshot buffer: the bytes we most recently read.
51        self._snapshot = bytearray(cmd_set.turnout_table_bytes)
52        self._stop = asyncio.Event()
53
54    async def run(self) -> None:
55        """Run forever until stopped via :meth:`stop`."""
56        import contextlib  # noqa: PLC0415
57        try:
58            while not self._stop.is_set():
59                await self._poll_once()
60                with contextlib.suppress(TimeoutError):
61                    await asyncio.wait_for(
62                        self._stop.wait(), timeout=self.POLL_INTERVAL_S
63                    )
64        except asyncio.CancelledError:
65            return
66
67    def stop(self) -> None:
68        self._stop.set()
69
70    async def _poll_once(self) -> None:
71        for offset in range(0, self._cs.turnout_table_bytes, self.CHUNK_BYTES):
72            addr = self._cs.turnout_memory_base + offset
73            try:
74                reply = await self._tc.send_command(read_accessory_memory(addr))
75            except (TimeoutError, Exception) as e:  # noqa: BLE001
76                _log.warning("NCE turnout-monitor poll failed at %#06x: %s", addr, e)
77                return
78            await self._diff_and_emit(offset, reply.data)
79
80    async def _diff_and_emit(self, offset: int, fresh: bytes) -> None:
81        """Compare *fresh* against the snapshot at *offset*; emit per flipped bit."""
82        old_chunk = self._snapshot[offset : offset + len(fresh)]
83        for i, (old, new) in enumerate(zip(old_chunk, fresh, strict=False)):
84            if old == new:
85                continue
86            changed = old ^ new
87            for bit in range(8):
88                if not (changed & (1 << bit)):
89                    continue
90                address = (offset + i) * 8 + bit + 1
91                turnout = self._mgr.get_by_address(address)
92                if turnout is None:
93                    continue
94                state = TurnoutState.CLOSED if (new & (1 << bit)) else TurnoutState.THROWN
95                await turnout._set_known_state(state)
96        self._snapshot[offset : offset + len(fresh)] = fresh

Periodically poll the command-station turnout-status memory.

NceTurnoutMonitor( tc: NceTrafficController, mgr: NceTurnoutManager, cmd_set: NceCommandSet)
41    def __init__(
42        self,
43        tc: NceTrafficController,
44        mgr: NceTurnoutManager,
45        cmd_set: NceCommandSet,
46    ) -> None:
47        self._tc = tc
48        self._mgr = mgr
49        self._cs = cmd_set
50        # Pre-allocated snapshot buffer: the bytes we most recently read.
51        self._snapshot = bytearray(cmd_set.turnout_table_bytes)
52        self._stop = asyncio.Event()
POLL_INTERVAL_S = 1.5
CHUNK_BYTES = 16
async def run(self) -> None:
54    async def run(self) -> None:
55        """Run forever until stopped via :meth:`stop`."""
56        import contextlib  # noqa: PLC0415
57        try:
58            while not self._stop.is_set():
59                await self._poll_once()
60                with contextlib.suppress(TimeoutError):
61                    await asyncio.wait_for(
62                        self._stop.wait(), timeout=self.POLL_INTERVAL_S
63                    )
64        except asyncio.CancelledError:
65            return

Run forever until stopped via stop().

def stop(self) -> None:
67    def stop(self) -> None:
68        self._stop.set()
class SerialPort(jmri_nce.BytePort):
22class SerialPort(BytePort):
23    """NCE over an async serial port."""
24
25    def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
26        self._reader = reader
27        self._writer = writer
28        self._open = True
29
30    @classmethod
31    async def open(cls, port: str, baud: int = DEFAULT_BAUD) -> SerialPort:
32        """Open *port* (e.g. ``"COM7"`` or ``"/dev/ttyUSB0"``)."""
33        import serial_asyncio  # noqa: PLC0415
34        reader, writer = await serial_asyncio.open_serial_connection(
35            url=port, baudrate=baud
36        )
37        return cls(reader=reader, writer=writer)
38
39    async def write(self, data: bytes) -> None:
40        if not self._open:
41            raise RuntimeError("port is closed")
42        self._writer.write(data)
43        await self._writer.drain()
44
45    async def read_exact(self, n: int) -> bytes:
46        return await self._reader.readexactly(n)
47
48    async def read_pending(self) -> bytes:
49        """Return whatever bytes are already buffered without waiting.
50
51        ``StreamReader._buffer`` is private API but stable across asyncio
52        releases; ``read()`` blocks until at least one byte arrives, which
53        is the opposite of what the drain needs.
54        """
55        buf = self._reader._buffer  # type: ignore[attr-defined]
56        if not buf:
57            return b""
58        n = len(buf)
59        return await self._reader.readexactly(n)
60
61    async def aclose(self) -> None:
62        if not self._open:
63            return
64        self._open = False
65        self._writer.close()
66        with contextlib.suppress(Exception):
67            await self._writer.wait_closed()
68
69    @property
70    def is_open(self) -> bool:
71        return self._open

NCE over an async serial port.

SerialPort( reader: asyncio.streams.StreamReader, writer: asyncio.streams.StreamWriter)
25    def __init__(self, reader: StreamReader, writer: StreamWriter) -> None:
26        self._reader = reader
27        self._writer = writer
28        self._open = True
@classmethod
async def open( cls, port: str, baud: int = 9600) -> SerialPort:
30    @classmethod
31    async def open(cls, port: str, baud: int = DEFAULT_BAUD) -> SerialPort:
32        """Open *port* (e.g. ``"COM7"`` or ``"/dev/ttyUSB0"``)."""
33        import serial_asyncio  # noqa: PLC0415
34        reader, writer = await serial_asyncio.open_serial_connection(
35            url=port, baudrate=baud
36        )
37        return cls(reader=reader, writer=writer)

Open port (e.g. "COM7" or "/dev/ttyUSB0").

async def write(self, data: bytes) -> None:
39    async def write(self, data: bytes) -> None:
40        if not self._open:
41            raise RuntimeError("port is closed")
42        self._writer.write(data)
43        await self._writer.drain()
async def read_exact(self, n: int) -> bytes:
45    async def read_exact(self, n: int) -> bytes:
46        return await self._reader.readexactly(n)
async def read_pending(self) -> bytes:
48    async def read_pending(self) -> bytes:
49        """Return whatever bytes are already buffered without waiting.
50
51        ``StreamReader._buffer`` is private API but stable across asyncio
52        releases; ``read()`` blocks until at least one byte arrives, which
53        is the opposite of what the drain needs.
54        """
55        buf = self._reader._buffer  # type: ignore[attr-defined]
56        if not buf:
57            return b""
58        n = len(buf)
59        return await self._reader.readexactly(n)

Return whatever bytes are already buffered without waiting.

StreamReader._buffer is private API but stable across asyncio releases; read() blocks until at least one byte arrives, which is the opposite of what the drain needs.

async def aclose(self) -> None:
61    async def aclose(self) -> None:
62        if not self._open:
63            return
64        self._open = False
65        self._writer.close()
66        with contextlib.suppress(Exception):
67            await self._writer.wait_closed()
is_open: bool
69    @property
70    def is_open(self) -> bool:
71        return self._open
__version__ = '0.1.0'
async def detect_command_set( tc: NceTrafficController, *, fallback: NceCommandSet = NceCommandSet(name='PH-Pro 2006+', turnout_memory_base=60416, turnout_table_bytes=256, macro_memory_base=51200, consist_memory_base=62720, supports_ops_mode_prog=True, aiu_polling_supported=True)) -> NceCommandSet:
42async def detect_command_set(
43    tc: NceTrafficController, *, fallback: NceCommandSet = NCE_PH_PRO_2006
44) -> NceCommandSet:
45    """Query the command station and pick the matching command set.
46
47    On any error (timeout, unsupported by old firmware, NACK) we log a
48    warning and return *fallback* so the connection still opens.
49    """
50    try:
51        reply = await tc.send_command(query_command_version())
52    except (TimeoutError, NceCommandError) as e:
53        _log.warning("NCE version query failed (%s); falling back to %s", e, fallback.name)
54        return fallback
55    if len(reply.data) < 3:
56        _log.warning("NCE version reply too short: %r", reply.data)
57        return fallback
58    major, minor, model = reply.data[0], reply.data[1], reply.data[2]
59    cs = model_to_command_set(model)
60    _log.info("NCE firmware v%d.%d, model=%#x → %s", major, minor, model, cs.name)
61    return cs

Query the command station and pick the matching command set.

On any error (timeout, unsupported by old firmware, NACK) we log a warning and return fallback so the connection still opens.

def get_command_set(name: str) -> NceCommandSet:
75def get_command_set(name: str) -> NceCommandSet:
76    """Look up a command set by friendly name. Raises ``KeyError`` if unknown."""
77    if name not in COMMAND_SETS:
78        raise KeyError(
79            f"unknown NCE command set: {name!r} (have: {sorted(COMMAND_SETS)})"
80        )
81    return COMMAND_SETS[name]

Look up a command set by friendly name. Raises KeyError if unknown.

def loopback_pair() -> tuple[LoopbackPort, LoopbackPort]:
51def loopback_pair() -> tuple[LoopbackPort, LoopbackPort]:
52    """Two connected loopback ports — A writes to B's read stream and vice versa."""
53    a_to_b: asyncio.Queue[int] = asyncio.Queue()
54    b_to_a: asyncio.Queue[int] = asyncio.Queue()
55    return LoopbackPort(b_to_a, a_to_b), LoopbackPort(a_to_b, b_to_a)

Two connected loopback ports — A writes to B's read stream and vice versa.

def model_to_command_set(model: int) -> NceCommandSet:
32def model_to_command_set(model: int) -> NceCommandSet:
33    """Choose the canonical command set for an NCE model byte."""
34    if model == MODEL_POWERCAB:
35        return NCE_POWERCAB
36    if model == MODEL_SB5:
37        return NCE_SB5
38    # PowerPro, Twin, and unknowns all use the PH-Pro 2006+ map.
39    return NCE_PH_PRO_2006

Choose the canonical command set for an NCE model byte.