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]
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.
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.
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.
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.
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.
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()
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
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_lenis 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=Trueand the traffic controller will not consume an ACK byte after.
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.
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.
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.
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.
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.
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.
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.
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.
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
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
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.
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.
Inherited Members
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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))
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.
Inherited Members
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.
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
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.TimeoutErrorif the command station doesn't answer withincmd.timeout_s;NceCommandErrorif the ACK byte indicates failure.
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.
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).
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().
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.
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.
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.
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()
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().
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.
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").
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.
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.
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.
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.
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.