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 usesvfor read replies andrfor write acks. Older versions and some third-party firmwares reportedly emitrfor both; the reply parser here accepts either so we work against both.<w cab cv value>(lowercasew) — ops-mode CV write to cab. Fire-and-forget: no reply.<r cab cv>(lowercaser) — 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 — seeDccexProgrammer.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
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.
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.
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.
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.
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.
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.
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.