jmri_nce.programmer

NCE CV programmer — ops-mode (main track) and direct-mode (programming track).

Two methods of CV access:

  • Ops mode (write_cv_ops / read_cv_ops) talks to a specific loco on the main track. Reads require RailCom-capable hardware downstream (NCE BD20, etc.); without it the read times out.
  • Direct mode (write_cv_direct / read_cv_direct) is the programming-track service mode. The CS pauses normal layout traffic while the operation is in progress.

The programmer exposes a "programming in progress" event so the ~jmri_nce.monitors.NceTurnoutMonitor can pause its memory polls during direct-mode sessions (otherwise the CS may queue reads behind the programming wait).

This module also adapts the NCE-specific programmer to the generic jmri_core.beans.programmer.Programmer ABC via NceServiceProgrammer and NceOpsProgrammer, which is what the ProgrammerManager hands out for DecoderPro use.

  1"""NCE CV programmer — ops-mode (main track) and direct-mode (programming track).
  2
  3Two methods of CV access:
  4
  5* **Ops mode** (``write_cv_ops`` / ``read_cv_ops``) talks to a specific loco
  6  on the main track. Reads require RailCom-capable hardware downstream
  7  (NCE BD20, etc.); without it the read times out.
  8* **Direct mode** (``write_cv_direct`` / ``read_cv_direct``) is the
  9  programming-track service mode. The CS pauses normal layout traffic
 10  while the operation is in progress.
 11
 12The programmer exposes a "programming in progress" event so the
 13:class:`~jmri_nce.monitors.NceTurnoutMonitor` can pause its memory polls
 14during direct-mode sessions (otherwise the CS may queue reads behind the
 15programming wait).
 16
 17This module also adapts the NCE-specific programmer to the generic
 18:class:`jmri_core.beans.programmer.Programmer` ABC via
 19:class:`NceServiceProgrammer` and :class:`NceOpsProgrammer`, which is
 20what the :class:`ProgrammerManager` hands out for DecoderPro use.
 21"""
 22from __future__ import annotations
 23
 24import asyncio
 25import logging
 26
 27from jmri_core.beans.programmer import (
 28    Programmer,
 29    ProgrammerManager,
 30    ProgrammingMode,
 31)
 32
 33from jmri_nce.messages import (
 34    NceCommandError,
 35    direct_mode_read_cv,
 36    direct_mode_write_cv,
 37    enter_programming_mode,
 38    exit_programming_mode,
 39    ops_mode_write_cv,
 40)
 41from jmri_nce.traffic import NceTrafficController
 42
 43_log = logging.getLogger(__name__)
 44
 45
 46class NceProgrammer:
 47    """CV access against an NCE command station."""
 48
 49    def __init__(self, tc: NceTrafficController) -> None:
 50        self._tc = tc
 51        self._programming_in_progress = asyncio.Event()
 52
 53    @property
 54    def is_programming(self) -> bool:
 55        """True while a direct-mode session is in flight."""
 56        return self._programming_in_progress.is_set()
 57
 58    async def wait_idle(self, timeout: float = 30.0) -> None:
 59        """Wait for any in-progress direct-mode session to finish."""
 60        if not self._programming_in_progress.is_set():
 61            return
 62        # ``wait`` returns True when set; we want to wait until it's clear.
 63        # asyncio.Event has no direct "wait clear" — poll briefly.
 64        deadline = asyncio.get_event_loop().time() + timeout
 65        while self._programming_in_progress.is_set():
 66            if asyncio.get_event_loop().time() > deadline:
 67                raise TimeoutError("programming did not complete in time")
 68            await asyncio.sleep(0.05)
 69
 70    # ----- ops mode (Programming On the Main) ----------------------------
 71    #
 72    # Rebuilt on the 2026-07-14 audit path: raw NMRA DCC packet wrapped
 73    # in NCE ``QUEUEn_TMP_CMD`` (opcodes 0x93..0x96). See
 74    # :func:`jmri_nce.messages.ops_mode_write_cv` for the packet layout.
 75    # NCE PowerPro has no ops-mode CV read (RailCom is out of scope), so
 76    # only ``write_cv_ops`` exists — reads must go via direct mode.
 77
 78    async def write_cv_ops(
 79        self, loco_address: int, cv: int, value: int,
 80        *, long_address: bool = False,
 81    ) -> None:
 82        """Ops-mode CV write (POM). Fires a single DCC packet on the main.
 83
 84        Unlike ``write_cv_direct``, this does *not* pause layout traffic:
 85        the CS just queues a service-mode packet in-between throttle
 86        packets. Result reaches the loco during normal operation.
 87
 88        There's no wire-level "did the decoder accept it?" — POM is
 89        inherently unverified without RailCom. The returned ACK confirms
 90        the CS queued the packet, not that the decoder wrote the CV.
 91        """
 92        await self._tc.send_command(
 93            ops_mode_write_cv(
 94                loco_address, cv, value, long_address=long_address,
 95            ),
 96        )
 97
 98    # ----- direct mode (programming track) -------------------------------
 99    #
100    # NCE PowerPro's direct-mode CV opcodes (``0xA8`` / ``0xA9``) require
101    # the CS to be in *programming mode* — a persistent state entered
102    # via ``ENTER_PROG_CMD`` (0x9E) and exited via ``EXIT_PROG_CMD``
103    # (0x9F). Outside programming mode the CS immediately NoAcks the
104    # command with ``'0'`` (0x30) *without* running any DCC packets on
105    # the track. That looks like an "instant NoAck" on the wire (~ms,
106    # not the 2–6 s a real programming attempt takes).
107    #
108    # Java JMRI's ``NceProgrammer`` wraps every direct-mode read/write
109    # with enter/exit. We mirror that here in :meth:`_prog_op` — the
110    # write and read helpers both go through it so exit always happens
111    # even when the operation raises.
112
113    async def _prog_op(
114        self, op, *, consume_trailer: bool = False,
115    ) -> "NceReply":  # noqa: F821
116        """Run *op* (an NceCommand) wrapped in ENTER_PROG / EXIT_PROG.
117
118        The enter and exit commands are short (single-byte requests,
119        1-byte ACK reply) so the wrapper adds ~10 ms of latency, dwarfed
120        by the 2–6 s of any real direct-mode CV op. Best-effort exit:
121        if the wrapped op raises, we still send EXIT_PROG so the CS
122        returns to normal operation and layout traffic resumes. The
123        exit's own reply is swallowed on failure — we don't want it
124        masking the original error.
125
126        *consume_trailer*: for RD_CV_DIRECT (and any future command
127        where NCE PowerPro appends a trailing ACK byte after the
128        payload), set True so the trailer is eaten before EXIT_PROG
129        runs. Without this, the trailer can land inside EXIT_PROG's
130        expected-1-byte reply window and cascade into stream
131        desynchronization.
132        """
133        await self._tc.send_command(enter_programming_mode())
134        try:
135            reply = await self._tc.send_command(op)
136            if consume_trailer:
137                await self._tc.consume_trailer(max_wait_s=0.1)
138            return reply
139        finally:
140            try:
141                await self._tc.send_command(exit_programming_mode())
142            except Exception as e:  # noqa: BLE001
143                _log.warning(
144                    "NCE: EXIT_PROG failed after %s: %s", op.name, e,
145                )
146
147    async def write_cv_direct(self, cv: int, value: int) -> None:
148        """Service-mode write. Pauses layout traffic for ~2–6 s.
149
150        Wraps the write with :meth:`_prog_op` so the CS is in
151        programming mode when the ``WRITE_DIR_CV_CMD`` (0xA8) opcode
152        arrives — a hard requirement of NCE PowerPro firmware.
153        """
154        self._programming_in_progress.set()
155        try:
156            await self._prog_op(direct_mode_write_cv(cv, value))
157        finally:
158            self._programming_in_progress.clear()
159
160    async def read_cv_direct(self, cv: int) -> int:
161        """Service-mode read. Same caveats as :meth:`write_cv_direct`.
162
163        Wrapped in ENTER_PROG / EXIT_PROG for the same reason as writes.
164        Raises :class:`NceCommandError` when the decoder doesn't
165        acknowledge — NCE signals that by sending the single byte
166        ``0x30`` (ASCII ``'0'``). JMRI Java's ``NceProgrammer`` treats
167        byte ``'0'`` as ``NoAck`` regardless of whether the actual CV
168        happens to hold value 48; we mirror that here since the
169        alternative (a silent "value 48 always") is worse than the rare
170        false negative when a CV genuinely equals 48.
171
172        Firmware quirk on successful reads: NCE PowerPro sends *two*
173        bytes back — the CV value plus a trailing ``!`` ACK. Our
174        command spec asks for ``reply_len=1`` so the trailer lands in
175        the serial buffer. If the next command's drain catches it,
176        fine; if it doesn't (race), the trailer contaminates the next
177        reply. We proactively consume it here with a short blocking
178        wait, closing that race window.
179        """
180        self._programming_in_progress.set()
181        try:
182            reply = await self._prog_op(
183                direct_mode_read_cv(cv), consume_trailer=True,
184            )
185            byte = reply.data[0]
186            if byte == 0x30:   # '0' = NCE NoAck sentinel
187                # NoAck is a single byte on the wire; the trailer eater
188                # in ``_prog_op`` times out silently in that case,
189                # which is fine.
190                raise NceCommandError(
191                    reply.command, b"0",
192                    b"decoder did not ack read (no decoder on prog track?)",
193                )
194            return byte
195        except NceCommandError:
196            raise
197        finally:
198            self._programming_in_progress.clear()
199
200
201# ---- adapters to the jmri-core Programmer ABC ----------------------------
202
203
204class NceServiceProgrammer(Programmer):
205    """Service-mode adapter — programming-track CV access."""
206
207    def __init__(self, inner: NceProgrammer) -> None:
208        self._inner = inner
209
210    @property
211    def mode(self) -> ProgrammingMode:
212        return ProgrammingMode.SERVICE_DIRECT
213
214    async def read_cv(self, cv: int) -> int:
215        return await self._inner.read_cv_direct(cv)
216
217    async def write_cv(self, cv: int, value: int) -> None:
218        await self._inner.write_cv_direct(cv, value)
219
220
221class NceOpsProgrammer(Programmer):
222    """Ops-mode adapter — Programming On the Main for one specific loco.
223
224    Constructed via :meth:`NceProgrammerManager.ops_programmer`; carries
225    the target DCC address so callers can use the same ``read_cv`` /
226    ``write_cv`` surface as service mode.
227
228    ``read_cv`` raises :class:`NotImplementedError` — NCE PowerPro has
229    no ops-mode CV read opcode (no RailCom). Callers that need a verified
230    read must switch to service mode on the programming track.
231    """
232
233    def __init__(
234        self, inner: NceProgrammer, address: int, *, long_address: bool = False,
235    ) -> None:
236        self._inner = inner
237        self._address = address
238        self._long_address = long_address
239
240    @property
241    def mode(self) -> ProgrammingMode:
242        return ProgrammingMode.OPS_MODE
243
244    @property
245    def address(self) -> int:
246        return self._address
247
248    @property
249    def long_address(self) -> bool:
250        return self._long_address
251
252    async def read_cv(self, cv: int) -> int:
253        raise NotImplementedError(
254            "NCE has no ops-mode CV read (RailCom is not decoded by the "
255            "PowerPro command station). Use service mode via "
256            "NceServiceProgrammer for a verified read.",
257        )
258
259    async def write_cv(self, cv: int, value: int) -> None:
260        await self._inner.write_cv_ops(
261            self._address, cv, value, long_address=self._long_address,
262        )
263
264
265class NceProgrammerManager(ProgrammerManager):
266    """Hands out NCE service and ops programmers backed by one underlying
267    :class:`NceProgrammer`."""
268
269    def __init__(self, inner: NceProgrammer) -> None:
270        self._inner = inner
271        self._service_singleton: NceServiceProgrammer | None = None
272
273    def global_programmer(self) -> NceServiceProgrammer:
274        if self._service_singleton is None:
275            self._service_singleton = NceServiceProgrammer(self._inner)
276        return self._service_singleton
277
278    def ops_programmer(
279        self, address: int, *, long_address: bool = False,
280    ) -> NceOpsProgrammer:
281        """Return an ops-mode programmer bound to *address*.
282
283        Fresh instance per call — the caller usually owns one per loco
284        currently in the batch queue. Cheap: no wire traffic until the
285        first ``write_cv``.
286        """
287        return NceOpsProgrammer(
288            self._inner, address, long_address=long_address,
289        )
290
291    @property
292    def inner(self) -> NceProgrammer:
293        """Direct access to the underlying NCE-specific programmer."""
294        return self._inner
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: jmri_nce.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 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 NceOpsProgrammer(jmri_core.beans.programmer.Programmer):
222class NceOpsProgrammer(Programmer):
223    """Ops-mode adapter — Programming On the Main for one specific loco.
224
225    Constructed via :meth:`NceProgrammerManager.ops_programmer`; carries
226    the target DCC address so callers can use the same ``read_cv`` /
227    ``write_cv`` surface as service mode.
228
229    ``read_cv`` raises :class:`NotImplementedError` — NCE PowerPro has
230    no ops-mode CV read opcode (no RailCom). Callers that need a verified
231    read must switch to service mode on the programming track.
232    """
233
234    def __init__(
235        self, inner: NceProgrammer, address: int, *, long_address: bool = False,
236    ) -> None:
237        self._inner = inner
238        self._address = address
239        self._long_address = long_address
240
241    @property
242    def mode(self) -> ProgrammingMode:
243        return ProgrammingMode.OPS_MODE
244
245    @property
246    def address(self) -> int:
247        return self._address
248
249    @property
250    def long_address(self) -> bool:
251        return self._long_address
252
253    async def read_cv(self, cv: int) -> int:
254        raise NotImplementedError(
255            "NCE has no ops-mode CV read (RailCom is not decoded by the "
256            "PowerPro command station). Use service mode via "
257            "NceServiceProgrammer for a verified read.",
258        )
259
260    async def write_cv(self, cv: int, value: int) -> None:
261        await self._inner.write_cv_ops(
262            self._address, cv, value, long_address=self._long_address,
263        )

Ops-mode adapter — Programming On the Main for one specific loco.

Constructed via NceProgrammerManager.ops_programmer(); carries the target DCC address so callers can use the same read_cv / write_cv surface as service mode.

read_cv raises NotImplementedError — NCE PowerPro has no ops-mode CV read opcode (no RailCom). Callers that need a verified read must switch to service mode on the programming track.

NceOpsProgrammer( inner: NceProgrammer, address: int, *, long_address: bool = False)
234    def __init__(
235        self, inner: NceProgrammer, address: int, *, long_address: bool = False,
236    ) -> None:
237        self._inner = inner
238        self._address = address
239        self._long_address = long_address
241    @property
242    def mode(self) -> ProgrammingMode:
243        return ProgrammingMode.OPS_MODE

Which mode this programmer instance uses.

address: int
245    @property
246    def address(self) -> int:
247        return self._address
long_address: bool
249    @property
250    def long_address(self) -> bool:
251        return self._long_address
async def read_cv(self, cv: int) -> int:
253    async def read_cv(self, cv: int) -> int:
254        raise NotImplementedError(
255            "NCE has no ops-mode CV read (RailCom is not decoded by the "
256            "PowerPro command station). Use service mode via "
257            "NceServiceProgrammer for a verified read.",
258        )

Read CV cv, returns 0..255.

async def write_cv(self, cv: int, value: int) -> None:
260    async def write_cv(self, cv: int, value: int) -> None:
261        await self._inner.write_cv_ops(
262            self._address, cv, value, long_address=self._long_address,
263        )

Write value to CV cv.

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) -> 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.