jmri_dccex.programmer

DCC-EX CV programmer.

Wire protocol, verified against DCC-EX/CommandStation-EX/DCCEXParser.cpp (upstream master, callbacks callback_R / callback_W / callback_Vbyte / callback_r):

  • <W cv value> — service-mode CV write. Response: <r cv value> on success, <r cv -1> on failure.
  • <R cv> — service-mode CV read. Response: <v cv value> on success, <v cv -1> on failure. Note the different letter: DCC-EX uses v for read replies and r for write acks. Older versions and some third-party firmwares reportedly emit r for both; the reply parser here accepts either so we work against both.
  • <w cab cv value> (lowercase w) — ops-mode CV write to cab. Fire-and-forget: no reply.
  • <r cab cv> (lowercase r) — ops-mode CV read on the main via RailCom. Response: <r cab cv value> (3 args) on success, <r cab cv -1> on RailCom no-response. The CS silently drops the request if RailCom isn't enabled in firmware, so callers see this as a plain timeout — see DccexProgrammer.read_cv_ops().

Service-mode operations block normal layout traffic on the CS; expect a ~1–3s wait per CV. Ops-mode writes are fire-and-forget. Ops-mode reads require RailCom on both the CS and the decoder; timeouts on non-RailCom setups are normal.

  1"""DCC-EX CV programmer.
  2
  3Wire protocol, verified against
  4``DCC-EX/CommandStation-EX/DCCEXParser.cpp`` (upstream ``master``,
  5callbacks ``callback_R`` / ``callback_W`` / ``callback_Vbyte`` /
  6``callback_r``):
  7
  8* ``<W cv value>`` — service-mode CV write. Response:
  9  ``<r cv value>`` on success, ``<r cv -1>`` on failure.
 10* ``<R cv>`` — service-mode CV read. Response: ``<v cv value>`` on
 11  success, ``<v cv -1>`` on failure. *Note the different letter*:
 12  DCC-EX uses ``v`` for read replies and ``r`` for write acks. Older
 13  versions and some third-party firmwares reportedly emit ``r`` for
 14  both; the reply parser here accepts either so we work against both.
 15* ``<w cab cv value>`` (lowercase ``w``) — ops-mode CV write to
 16  *cab*. Fire-and-forget: no reply.
 17* ``<r cab cv>`` (lowercase ``r``) — ops-mode CV read on the main via
 18  RailCom. Response: ``<r cab cv value>`` (3 args) on success,
 19  ``<r cab cv -1>`` on RailCom no-response. The CS silently drops the
 20  request if RailCom isn't enabled in firmware, so callers see this
 21  as a plain timeout — see :meth:`DccexProgrammer.read_cv_ops`.
 22
 23Service-mode operations block normal layout traffic on the CS; expect
 24a ~1–3s wait per CV. Ops-mode writes are fire-and-forget. Ops-mode
 25reads require RailCom on both the CS and the decoder; timeouts on
 26non-RailCom setups are normal.
 27"""
 28from __future__ import annotations
 29
 30import asyncio
 31import logging
 32
 33from jmri_core.beans.programmer import Programmer, ProgrammerManager, ProgrammingMode
 34
 35from jmri_dccex.codec import DccexMessage
 36from jmri_dccex.traffic import DccexTrafficController
 37
 38_log = logging.getLogger(__name__)
 39
 40
 41class DccexProgrammer:
 42    """Direct programmer driver — used by both service and ops adapters."""
 43
 44    def __init__(self, tc: DccexTrafficController) -> None:
 45        self._tc = tc
 46        self._busy = asyncio.Lock()
 47
 48    async def write_cv_service(
 49        self, cv: int, value: int, *, timeout: float = 6.0
 50    ) -> None:
 51        async with self._busy:
 52            await self._send_and_wait_r(
 53                DccexMessage(command="W", args=(str(cv), str(value))),
 54                expected_cv=cv,
 55                timeout=timeout,
 56            )
 57
 58    async def read_cv_service(self, cv: int, *, timeout: float = 6.0) -> int:
 59        async with self._busy:
 60            return await self._send_and_wait_r(
 61                DccexMessage(command="R", args=(str(cv),)),
 62                expected_cv=cv,
 63                timeout=timeout,
 64            )
 65
 66    async def write_cv_ops(
 67        self, address: int, cv: int, value: int, *, timeout: float = 2.0
 68    ) -> None:
 69        """Fire-and-forget — ops-mode writes don't ack in DCC-EX's text protocol."""
 70        async with self._busy:
 71            await self._tc.send(DccexMessage(
 72                command="w", args=(str(address), str(cv), str(value)),
 73            ))
 74
 75    async def read_cv_ops(
 76        self, address: int, cv: int, *, timeout: float = 6.0,
 77    ) -> int:
 78        """RailCom-backed ops-mode CV read on the main track.
 79
 80        Wire: ``<r cab cv>`` request → ``<r cab cv value>`` reply. The
 81        CS's ``r`` handler explicitly requires ``DCCWaveform::isRailcom()``
 82        to return true; if RailCom is disabled in the firmware config
 83        the request is silently dropped, so a caller sees this as a
 84        timeout rather than a distinctive error. The timeout message
 85        surfaces that possibility explicitly.
 86
 87        Failure (``value == -1``) surfaces as :class:`RuntimeError`
 88        matching the same-shaped failure on the service-mode path so
 89        callers can use one exception filter for both.
 90        """
 91        async with self._busy:
 92            return await self._send_and_wait_ops_read(
 93                address=address, cv=cv, timeout=timeout,
 94            )
 95
 96    async def _send_and_wait_r(
 97        self, request: DccexMessage, *, expected_cv: int, timeout: float
 98    ) -> int:
 99        """Await a 2-arg service-mode reply matching ``expected_cv``.
100
101        Accepts either ``<r cv value>`` (write ack + some firmwares'
102        read replies) or ``<v cv value>`` (canonical read reply per
103        DCC-EX upstream). Callers pass the CV they're expecting; a
104        reply for a different CV is ignored so an unrelated CS
105        message doesn't complete our future prematurely.
106        """
107        future: asyncio.Future[int] = asyncio.get_event_loop().create_future()
108
109        async def listener(msg: DccexMessage) -> None:
110            if future.done():
111                return
112            # Accept both letters — see the module docstring.
113            if msg.command not in ("r", "v") or len(msg.args) != 2:
114                return
115            try:
116                cv = int(msg.args[0])
117                value = int(msg.args[1])
118            except ValueError:
119                return
120            if cv != expected_cv:
121                return
122            if value < 0:
123                future.set_exception(
124                    RuntimeError(f"DCC-EX programming op failed on CV {cv}")
125                )
126            else:
127                future.set_result(value)
128
129        disconnect = self._tc.inbound.connect(listener)
130        try:
131            await self._tc.send(request)
132            return await asyncio.wait_for(future, timeout=timeout)
133        finally:
134            disconnect()
135
136    async def _send_and_wait_ops_read(
137        self, *, address: int, cv: int, timeout: float,
138    ) -> int:
139        """Send ``<r cab cv>`` and await ``<r cab cv value>`` (3 args).
140
141        The 3-arg shape lets the listener distinguish ops-mode
142        replies from service-mode replies unambiguously — a 2-arg
143        ``<r ...>`` is a write ack, a 3-arg ``<r ...>`` is a RailCom
144        read result.
145        """
146        future: asyncio.Future[int] = asyncio.get_event_loop().create_future()
147
148        async def listener(msg: DccexMessage) -> None:
149            if future.done():
150                return
151            if msg.command != "r" or len(msg.args) != 3:
152                return
153            try:
154                cab = int(msg.args[0])
155                got_cv = int(msg.args[1])
156                value = int(msg.args[2])
157            except ValueError:
158                return
159            if cab != address or got_cv != cv:
160                return
161            if value < 0:
162                future.set_exception(RuntimeError(
163                    f"DCC-EX ops-mode read failed on cab={address} CV={cv} "
164                    "(no RailCom response)"
165                ))
166            else:
167                future.set_result(value)
168
169        disconnect = self._tc.inbound.connect(listener)
170        try:
171            await self._tc.send(DccexMessage(
172                command="r", args=(str(address), str(cv)),
173            ))
174            return await asyncio.wait_for(future, timeout=timeout)
175        except asyncio.TimeoutError as e:
176            raise RuntimeError(
177                f"DCC-EX ops-mode read timed out on cab={address} CV={cv}. "
178                "The CS silently drops <r ...> if RailCom is disabled — "
179                "verify the firmware was built with RAILCOM enabled and "
180                "that the decoder supports RailCom cutout."
181            ) from e
182        finally:
183            disconnect()
184
185
186class DccexServiceProgrammer(Programmer):
187    """Service-mode adapter satisfying the core ABC."""
188
189    def __init__(self, inner: DccexProgrammer) -> None:
190        self._inner = inner
191
192    @property
193    def mode(self) -> ProgrammingMode:
194        return ProgrammingMode.SERVICE_DIRECT
195
196    async def read_cv(self, cv: int) -> int:
197        return await self._inner.read_cv_service(cv)
198
199    async def write_cv(self, cv: int, value: int) -> None:
200        await self._inner.write_cv_service(cv, value)
201
202
203class DccexOpsProgrammer(Programmer):
204    """Ops-mode adapter scoped to a specific DCC address."""
205
206    def __init__(self, inner: DccexProgrammer, address: int, *, long_address: bool) -> None:
207        self._inner = inner
208        self._address = address
209        self._long = long_address
210
211    @property
212    def mode(self) -> ProgrammingMode:
213        return ProgrammingMode.OPS_MODE
214
215    @property
216    def address(self) -> int:
217        return self._address
218
219    async def read_cv(self, cv: int) -> int:
220        """RailCom-backed ops-mode CV read for this address.
221
222        Raises :class:`RuntimeError` when the CS times out (usually
223        because RailCom isn't enabled in the firmware config) or
224        returns ``-1`` (decoder didn't answer via RailCom). Callers
225        that want a "try RailCom, fall back to write-and-read-back"
226        strategy should catch that exception at their layer.
227        """
228        return await self._inner.read_cv_ops(self._address, cv)
229
230    async def write_cv(self, cv: int, value: int) -> None:
231        await self._inner.write_cv_ops(self._address, cv, value)
232
233
234class DccexProgrammerManager(ProgrammerManager):
235    def __init__(self, inner: DccexProgrammer) -> None:
236        self._inner = inner
237        self._service_singleton: DccexServiceProgrammer | None = None
238
239    def global_programmer(self) -> DccexServiceProgrammer:
240        if self._service_singleton is None:
241            self._service_singleton = DccexServiceProgrammer(self._inner)
242        return self._service_singleton
243
244    def ops_programmer(self, address: int, *, long_address: bool = False) -> DccexOpsProgrammer:
245        return DccexOpsProgrammer(self._inner, address, long_address=long_address)
246
247    @property
248    def inner(self) -> DccexProgrammer:
249        return self._inner
class DccexProgrammer:
 42class DccexProgrammer:
 43    """Direct programmer driver — used by both service and ops adapters."""
 44
 45    def __init__(self, tc: DccexTrafficController) -> None:
 46        self._tc = tc
 47        self._busy = asyncio.Lock()
 48
 49    async def write_cv_service(
 50        self, cv: int, value: int, *, timeout: float = 6.0
 51    ) -> None:
 52        async with self._busy:
 53            await self._send_and_wait_r(
 54                DccexMessage(command="W", args=(str(cv), str(value))),
 55                expected_cv=cv,
 56                timeout=timeout,
 57            )
 58
 59    async def read_cv_service(self, cv: int, *, timeout: float = 6.0) -> int:
 60        async with self._busy:
 61            return await self._send_and_wait_r(
 62                DccexMessage(command="R", args=(str(cv),)),
 63                expected_cv=cv,
 64                timeout=timeout,
 65            )
 66
 67    async def write_cv_ops(
 68        self, address: int, cv: int, value: int, *, timeout: float = 2.0
 69    ) -> None:
 70        """Fire-and-forget — ops-mode writes don't ack in DCC-EX's text protocol."""
 71        async with self._busy:
 72            await self._tc.send(DccexMessage(
 73                command="w", args=(str(address), str(cv), str(value)),
 74            ))
 75
 76    async def read_cv_ops(
 77        self, address: int, cv: int, *, timeout: float = 6.0,
 78    ) -> int:
 79        """RailCom-backed ops-mode CV read on the main track.
 80
 81        Wire: ``<r cab cv>`` request → ``<r cab cv value>`` reply. The
 82        CS's ``r`` handler explicitly requires ``DCCWaveform::isRailcom()``
 83        to return true; if RailCom is disabled in the firmware config
 84        the request is silently dropped, so a caller sees this as a
 85        timeout rather than a distinctive error. The timeout message
 86        surfaces that possibility explicitly.
 87
 88        Failure (``value == -1``) surfaces as :class:`RuntimeError`
 89        matching the same-shaped failure on the service-mode path so
 90        callers can use one exception filter for both.
 91        """
 92        async with self._busy:
 93            return await self._send_and_wait_ops_read(
 94                address=address, cv=cv, timeout=timeout,
 95            )
 96
 97    async def _send_and_wait_r(
 98        self, request: DccexMessage, *, expected_cv: int, timeout: float
 99    ) -> int:
100        """Await a 2-arg service-mode reply matching ``expected_cv``.
101
102        Accepts either ``<r cv value>`` (write ack + some firmwares'
103        read replies) or ``<v cv value>`` (canonical read reply per
104        DCC-EX upstream). Callers pass the CV they're expecting; a
105        reply for a different CV is ignored so an unrelated CS
106        message doesn't complete our future prematurely.
107        """
108        future: asyncio.Future[int] = asyncio.get_event_loop().create_future()
109
110        async def listener(msg: DccexMessage) -> None:
111            if future.done():
112                return
113            # Accept both letters — see the module docstring.
114            if msg.command not in ("r", "v") or len(msg.args) != 2:
115                return
116            try:
117                cv = int(msg.args[0])
118                value = int(msg.args[1])
119            except ValueError:
120                return
121            if cv != expected_cv:
122                return
123            if value < 0:
124                future.set_exception(
125                    RuntimeError(f"DCC-EX programming op failed on CV {cv}")
126                )
127            else:
128                future.set_result(value)
129
130        disconnect = self._tc.inbound.connect(listener)
131        try:
132            await self._tc.send(request)
133            return await asyncio.wait_for(future, timeout=timeout)
134        finally:
135            disconnect()
136
137    async def _send_and_wait_ops_read(
138        self, *, address: int, cv: int, timeout: float,
139    ) -> int:
140        """Send ``<r cab cv>`` and await ``<r cab cv value>`` (3 args).
141
142        The 3-arg shape lets the listener distinguish ops-mode
143        replies from service-mode replies unambiguously — a 2-arg
144        ``<r ...>`` is a write ack, a 3-arg ``<r ...>`` is a RailCom
145        read result.
146        """
147        future: asyncio.Future[int] = asyncio.get_event_loop().create_future()
148
149        async def listener(msg: DccexMessage) -> None:
150            if future.done():
151                return
152            if msg.command != "r" or len(msg.args) != 3:
153                return
154            try:
155                cab = int(msg.args[0])
156                got_cv = int(msg.args[1])
157                value = int(msg.args[2])
158            except ValueError:
159                return
160            if cab != address or got_cv != cv:
161                return
162            if value < 0:
163                future.set_exception(RuntimeError(
164                    f"DCC-EX ops-mode read failed on cab={address} CV={cv} "
165                    "(no RailCom response)"
166                ))
167            else:
168                future.set_result(value)
169
170        disconnect = self._tc.inbound.connect(listener)
171        try:
172            await self._tc.send(DccexMessage(
173                command="r", args=(str(address), str(cv)),
174            ))
175            return await asyncio.wait_for(future, timeout=timeout)
176        except asyncio.TimeoutError as e:
177            raise RuntimeError(
178                f"DCC-EX ops-mode read timed out on cab={address} CV={cv}. "
179                "The CS silently drops <r ...> if RailCom is disabled — "
180                "verify the firmware was built with RAILCOM enabled and "
181                "that the decoder supports RailCom cutout."
182            ) from e
183        finally:
184            disconnect()

Direct programmer driver — used by both service and ops adapters.

DccexProgrammer(tc: jmri_dccex.DccexTrafficController)
45    def __init__(self, tc: DccexTrafficController) -> None:
46        self._tc = tc
47        self._busy = asyncio.Lock()
async def write_cv_service(self, cv: int, value: int, *, timeout: float = 6.0) -> None:
49    async def write_cv_service(
50        self, cv: int, value: int, *, timeout: float = 6.0
51    ) -> None:
52        async with self._busy:
53            await self._send_and_wait_r(
54                DccexMessage(command="W", args=(str(cv), str(value))),
55                expected_cv=cv,
56                timeout=timeout,
57            )
async def read_cv_service(self, cv: int, *, timeout: float = 6.0) -> int:
59    async def read_cv_service(self, cv: int, *, timeout: float = 6.0) -> int:
60        async with self._busy:
61            return await self._send_and_wait_r(
62                DccexMessage(command="R", args=(str(cv),)),
63                expected_cv=cv,
64                timeout=timeout,
65            )
async def write_cv_ops(self, address: int, cv: int, value: int, *, timeout: float = 2.0) -> None:
67    async def write_cv_ops(
68        self, address: int, cv: int, value: int, *, timeout: float = 2.0
69    ) -> None:
70        """Fire-and-forget — ops-mode writes don't ack in DCC-EX's text protocol."""
71        async with self._busy:
72            await self._tc.send(DccexMessage(
73                command="w", args=(str(address), str(cv), str(value)),
74            ))

Fire-and-forget — ops-mode writes don't ack in DCC-EX's text protocol.

async def read_cv_ops(self, address: int, cv: int, *, timeout: float = 6.0) -> int:
76    async def read_cv_ops(
77        self, address: int, cv: int, *, timeout: float = 6.0,
78    ) -> int:
79        """RailCom-backed ops-mode CV read on the main track.
80
81        Wire: ``<r cab cv>`` request → ``<r cab cv value>`` reply. The
82        CS's ``r`` handler explicitly requires ``DCCWaveform::isRailcom()``
83        to return true; if RailCom is disabled in the firmware config
84        the request is silently dropped, so a caller sees this as a
85        timeout rather than a distinctive error. The timeout message
86        surfaces that possibility explicitly.
87
88        Failure (``value == -1``) surfaces as :class:`RuntimeError`
89        matching the same-shaped failure on the service-mode path so
90        callers can use one exception filter for both.
91        """
92        async with self._busy:
93            return await self._send_and_wait_ops_read(
94                address=address, cv=cv, timeout=timeout,
95            )

RailCom-backed ops-mode CV read on the main track.

Wire: <r cab cv> request → <r cab cv value> reply. The CS's r handler explicitly requires DCCWaveform::isRailcom() to return true; if RailCom is disabled in the firmware config the request is silently dropped, so a caller sees this as a timeout rather than a distinctive error. The timeout message surfaces that possibility explicitly.

Failure (value == -1) surfaces as RuntimeError matching the same-shaped failure on the service-mode path so callers can use one exception filter for both.

class DccexServiceProgrammer(jmri_core.beans.programmer.Programmer):
187class DccexServiceProgrammer(Programmer):
188    """Service-mode adapter satisfying the core ABC."""
189
190    def __init__(self, inner: DccexProgrammer) -> None:
191        self._inner = inner
192
193    @property
194    def mode(self) -> ProgrammingMode:
195        return ProgrammingMode.SERVICE_DIRECT
196
197    async def read_cv(self, cv: int) -> int:
198        return await self._inner.read_cv_service(cv)
199
200    async def write_cv(self, cv: int, value: int) -> None:
201        await self._inner.write_cv_service(cv, value)

Service-mode adapter satisfying the core ABC.

DccexServiceProgrammer(inner: DccexProgrammer)
190    def __init__(self, inner: DccexProgrammer) -> None:
191        self._inner = inner
193    @property
194    def mode(self) -> ProgrammingMode:
195        return ProgrammingMode.SERVICE_DIRECT

Which mode this programmer instance uses.

async def read_cv(self, cv: int) -> int:
197    async def read_cv(self, cv: int) -> int:
198        return await self._inner.read_cv_service(cv)

Read CV cv, returns 0..255.

async def write_cv(self, cv: int, value: int) -> None:
200    async def write_cv(self, cv: int, value: int) -> None:
201        await self._inner.write_cv_service(cv, value)

Write value to CV cv.

class DccexOpsProgrammer(jmri_core.beans.programmer.Programmer):
204class DccexOpsProgrammer(Programmer):
205    """Ops-mode adapter scoped to a specific DCC address."""
206
207    def __init__(self, inner: DccexProgrammer, address: int, *, long_address: bool) -> None:
208        self._inner = inner
209        self._address = address
210        self._long = long_address
211
212    @property
213    def mode(self) -> ProgrammingMode:
214        return ProgrammingMode.OPS_MODE
215
216    @property
217    def address(self) -> int:
218        return self._address
219
220    async def read_cv(self, cv: int) -> int:
221        """RailCom-backed ops-mode CV read for this address.
222
223        Raises :class:`RuntimeError` when the CS times out (usually
224        because RailCom isn't enabled in the firmware config) or
225        returns ``-1`` (decoder didn't answer via RailCom). Callers
226        that want a "try RailCom, fall back to write-and-read-back"
227        strategy should catch that exception at their layer.
228        """
229        return await self._inner.read_cv_ops(self._address, cv)
230
231    async def write_cv(self, cv: int, value: int) -> None:
232        await self._inner.write_cv_ops(self._address, cv, value)

Ops-mode adapter scoped to a specific DCC address.

DccexOpsProgrammer( inner: DccexProgrammer, address: int, *, long_address: bool)
207    def __init__(self, inner: DccexProgrammer, address: int, *, long_address: bool) -> None:
208        self._inner = inner
209        self._address = address
210        self._long = long_address
212    @property
213    def mode(self) -> ProgrammingMode:
214        return ProgrammingMode.OPS_MODE

Which mode this programmer instance uses.

address: int
216    @property
217    def address(self) -> int:
218        return self._address
async def read_cv(self, cv: int) -> int:
220    async def read_cv(self, cv: int) -> int:
221        """RailCom-backed ops-mode CV read for this address.
222
223        Raises :class:`RuntimeError` when the CS times out (usually
224        because RailCom isn't enabled in the firmware config) or
225        returns ``-1`` (decoder didn't answer via RailCom). Callers
226        that want a "try RailCom, fall back to write-and-read-back"
227        strategy should catch that exception at their layer.
228        """
229        return await self._inner.read_cv_ops(self._address, cv)

RailCom-backed ops-mode CV read for this address.

Raises RuntimeError when the CS times out (usually because RailCom isn't enabled in the firmware config) or returns -1 (decoder didn't answer via RailCom). Callers that want a "try RailCom, fall back to write-and-read-back" strategy should catch that exception at their layer.

async def write_cv(self, cv: int, value: int) -> None:
231    async def write_cv(self, cv: int, value: int) -> None:
232        await self._inner.write_cv_ops(self._address, cv, value)

Write value to CV cv.

class DccexProgrammerManager(jmri_core.beans.programmer.ProgrammerManager):
235class DccexProgrammerManager(ProgrammerManager):
236    def __init__(self, inner: DccexProgrammer) -> None:
237        self._inner = inner
238        self._service_singleton: DccexServiceProgrammer | None = None
239
240    def global_programmer(self) -> DccexServiceProgrammer:
241        if self._service_singleton is None:
242            self._service_singleton = DccexServiceProgrammer(self._inner)
243        return self._service_singleton
244
245    def ops_programmer(self, address: int, *, long_address: bool = False) -> DccexOpsProgrammer:
246        return DccexOpsProgrammer(self._inner, address, long_address=long_address)
247
248    @property
249    def inner(self) -> DccexProgrammer:
250        return self._inner

Hands out programmer instances.

  • global_programmer() returns the service-mode programmer (one per connection — the programming track is a singleton).
  • ops_programmer(address, long_address)() returns an ops-mode programmer scoped to the given DCC address.

The base class returns None from both; drivers override.

DccexProgrammerManager(inner: DccexProgrammer)
236    def __init__(self, inner: DccexProgrammer) -> None:
237        self._inner = inner
238        self._service_singleton: DccexServiceProgrammer | None = None
def global_programmer(self) -> DccexServiceProgrammer:
240    def global_programmer(self) -> DccexServiceProgrammer:
241        if self._service_singleton is None:
242            self._service_singleton = DccexServiceProgrammer(self._inner)
243        return self._service_singleton
def ops_programmer( self, address: int, *, long_address: bool = False) -> DccexOpsProgrammer:
245    def ops_programmer(self, address: int, *, long_address: bool = False) -> DccexOpsProgrammer:
246        return DccexOpsProgrammer(self._inner, address, long_address=long_address)
inner: DccexProgrammer
248    @property
249    def inner(self) -> DccexProgrammer:
250        return self._inner