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