jmri_loconet.connection
LocoNetConnection — the SystemConnection subclass.
Construct, configure, open(...): builds the appropriate
LocoNetPort, starts the LocoNetTrafficController, wires
up bean managers + power + throttle. On close() everything is torn
down cleanly.
Settings dict: { "kind": "loopback" | "serial" | "tcp", # for serial: "port": "COM5", "baud": 16457, # for tcp: "host": "lbserver.local", "tcp_port": 1234, # for loopback (tests): "loopback_port": LoopbackPort, }
1"""``LocoNetConnection`` — the ``SystemConnection`` subclass. 2 3Construct, configure, ``open(...)``: builds the appropriate 4:class:`LocoNetPort`, starts the :class:`LocoNetTrafficController`, wires 5up bean managers + power + throttle. On ``close()`` everything is torn 6down cleanly. 7 8Settings dict: 9 { 10 "kind": "loopback" | "serial" | "tcp", 11 # for serial: 12 "port": "COM5", 13 "baud": 16457, 14 # for tcp: 15 "host": "lbserver.local", 16 "tcp_port": 1234, 17 # for loopback (tests): 18 "loopback_port": LoopbackPort, 19 } 20""" 21from __future__ import annotations 22 23import asyncio 24import time 25from typing import Any, ClassVar 26 27from jmri_core.beans.block import BlockManager 28from jmri_core.beans.light import LightManager 29from jmri_core.beans.memory import MemoryManager 30from jmri_core.beans.power import PowerManager 31from jmri_core.beans.reporter import ReporterManager 32from jmri_core.beans.route import RouteManager 33from jmri_core.beans.signal_head import SignalHeadManager 34from jmri_core.beans.signal_mast import SignalMastManager 35from jmri_core.beans.turnout import TurnoutManager 36from jmri_core.hal.connection import ConnectionSettings, SystemConnection 37from jmri_core.manager import Manager 38 39from jmri_loconet.beans import ( 40 LnPowerManager, 41 LnSensorManager, 42 LnTurnoutManager, 43) 44from jmri_loconet.consist import LnConsistManager 45from jmri_loconet.programmer import LnProgrammer, LnProgrammerManager 46from jmri_loconet.slots import SlotManager 47from jmri_loconet.throttle import LnThrottleManager 48from jmri_loconet.traffic import LocoNetTrafficController 49from jmri_loconet.transport import LocoNetPort, LoopbackPort, SerialPort, TcpPort 50 51 52class LocoNetConnection(SystemConnection): 53 """LocoNet hardware connection. Prefix ``L``, name ``LocoNet``.""" 54 55 name: ClassVar[str] = "LocoNet" 56 prefix: ClassVar[str] = "L" 57 58 def __init__(self) -> None: 59 super().__init__() 60 # These are filled in by open(). The base-class defaults stay in place 61 # until then so the connection has well-formed managers if opened with 62 # no settings (rare; mainly the test path). 63 self._port: LocoNetPort | None = None 64 self._tc: LocoNetTrafficController | None = None 65 self._slots: SlotManager | None = None 66 self._programmer: LnProgrammer | None = None 67 self._cs_monitor_task: asyncio.Task[None] | None = None 68 69 # ----- lifecycle -------------------------------------------------------- 70 71 async def open(self, settings: ConnectionSettings | None = None) -> None: 72 s = settings or {} 73 self._port = await _build_port(s) 74 self._tc = LocoNetTrafficController(self._port) 75 await self._tc.start() 76 self._slots = SlotManager(self._tc) 77 78 # Replace the base default managers with our LocoNet-aware ones. 79 # ``LnTurnoutManager`` and friends are subclasses of the base managers, 80 # but mypy's invariance in attribute typing means we have to ignore 81 # the assignment — the runtime substitution is sound (Liskov). 82 self.turnouts = LnTurnoutManager(self.prefix, tc=self._tc) # type: ignore[assignment] 83 self.sensors = LnSensorManager(self.prefix, tc=self._tc) # type: ignore[assignment] 84 throttle_mgr = LnThrottleManager( 85 self.prefix, tc=self._tc, slot_manager=self._slots, 86 ) 87 self.throttles = throttle_mgr 88 # Rebind the consist manager onto the LocoNet-aware throttle 89 # manager. Without this, consist fan-out drives placeholder 90 # Throttle beans left over from base ``__init__``, and no 91 # OPC_LOCO_SPD frames reach the wire. 92 self.consists.rebind_throttles(self.throttles) 93 throttle_mgr.start_refresh() 94 self.consists = LnConsistManager( # type: ignore[assignment] 95 throttle_mgr, self.prefix, tc=self._tc, slots=self._slots, 96 ) 97 self.power = LnPowerManager(self._tc) 98 self._programmer = LnProgrammer(self._tc) 99 self.programmers = LnProgrammerManager(self._programmer) 100 # Wire the advanced-consist CV19 write path to the LocoNet 101 # programmer manager. Without this rebind, ``advanced`` consists 102 # go through the base placeholder manager and CV19 never 103 # actually programs. 104 self.consists.rebind_programmers(self.programmers) 105 106 # CS reachability monitor. LocoNet CS emit OPC_IDLE and other 107 # frames continuously; 3s of complete silence is a strong 108 # signal the bus is dark (CS off or unplugged). Start 109 # pessimistic (base class default is optimistic True) so a 110 # home-page render during the monitor's startup grace shows 111 # "Off" until we've actually seen a frame, not a false "On". 112 self._cs_reachable = False 113 self._cs_monitor_task = asyncio.create_task( 114 self._cs_reachability_loop(), name="loconet-cs-reachability", 115 ) 116 117 await super().open(settings) 118 119 async def _cs_reachability_loop(self) -> None: 120 """Flip ``_cs_reachable`` on the 3-second inbound-silence rule. 121 122 LocoNet's continuous chatter (CS heartbeat + slot poll traffic) 123 means any real silence past 3 seconds indicates the CS has 124 stopped talking. Any fresh inbound frame flips reachable back 125 to True immediately. 126 """ 127 import contextlib # noqa: PLC0415 128 try: 129 # 3-second grace so the first CS heartbeat can arrive. 130 await asyncio.sleep(3.0) 131 while True: 132 await asyncio.sleep(1.0) 133 if self._tc is None: 134 return 135 last = self._tc.last_inbound_at 136 if last is None: 137 await self._set_cs_reachable(False) 138 continue 139 age = time.monotonic() - last 140 await self._set_cs_reachable(age <= 3.0) 141 except asyncio.CancelledError: 142 with contextlib.suppress(Exception): 143 return 144 145 async def close(self) -> None: 146 if isinstance(self.throttles, LnThrottleManager): 147 await self.throttles.stop_refresh() 148 if self._cs_monitor_task is not None: 149 self._cs_monitor_task.cancel() 150 import contextlib # noqa: PLC0415 151 with contextlib.suppress(asyncio.CancelledError, Exception): 152 await self._cs_monitor_task 153 self._cs_monitor_task = None 154 if self._tc is not None: 155 await self._tc.aclose() 156 self._tc = None 157 self._port = None 158 await super().close() 159 160 # ----- accessors -------------------------------------------------------- 161 162 @property 163 def slots(self) -> SlotManager: 164 if self._slots is None: 165 raise RuntimeError("connection not open") 166 return self._slots 167 168 @property 169 def traffic(self) -> LocoNetTrafficController: 170 if self._tc is None: 171 raise RuntimeError("connection not open") 172 return self._tc 173 174 # ----- fast clock (slot 123) -------------------------------------------- 175 176 async def read_hardware_clock(self): # noqa: ANN201 177 """Read the CS fast clock from slot 123. 178 179 Sends ``OPC_RQ_SL_DATA(slot=123)`` and awaits the paired 180 ``OPC_SL_RD_DATA`` reply on the inbound signal. Returns a 181 :class:`~jmri_core.beans.fast_clock.HardwareClockState`. 182 183 The :class:`~jmri_server.services.fast_clock.TimeService` 184 prefers this method over the software bean whenever the 185 connection exposes it, so wiring this on ``LocoNetConnection`` 186 automatically makes ``/json/time`` reflect the CS clock. 187 """ 188 from jmri_loconet.clock import FAST_CLOCK_SLOT, decode_slot123 # noqa: PLC0415 189 from jmri_loconet.messages import OpcRqSlData, OpcSlRdData # noqa: PLC0415 190 191 if self._tc is None: 192 raise RuntimeError("connection not open") 193 tc = self._tc 194 loop = asyncio.get_event_loop() 195 fut: asyncio.Future[OpcSlRdData] = loop.create_future() 196 197 async def on_inbound(msg) -> None: # noqa: ANN001 198 if ( 199 isinstance(msg, OpcSlRdData) 200 and msg.slot == FAST_CLOCK_SLOT 201 and not fut.done() 202 ): 203 fut.set_result(msg) 204 205 unsub = tc.inbound.connect(on_inbound) 206 try: 207 await tc.send(OpcRqSlData(slot=FAST_CLOCK_SLOT)) 208 reply = await asyncio.wait_for(fut, timeout=2.0) 209 finally: 210 unsub() 211 return decode_slot123(reply) 212 213 async def _write_hardware_clock(self, state) -> None: # noqa: ANN001 214 """Common write path — reads the current slot to preserve 215 non-clock fields, merges *state*, and sends the write.""" 216 current = await self.read_hardware_clock() 217 # ``current`` gave us the CS's authoritative days / trk / snd 218 # bytes indirectly by reading the raw slot; we lose that in 219 # the decode. To keep this pass simple we send zeros for those 220 # fields — JMRI Java's LnClockControl does the same on write, 221 # relying on the CS to preserve day count internally. 222 from jmri_loconet.clock import build_slot123_write # noqa: PLC0415 223 224 merged = state.__class__( 225 hours=state.hours if state.hours is not None else current.hours, 226 minutes=state.minutes if state.minutes is not None else current.minutes, 227 seconds=state.seconds if state.seconds is not None else current.seconds, 228 rate=state.rate if state.rate is not None else current.rate, 229 running=state.running if state.running is not None else current.running, 230 twenty_four_hour=True, 231 ) 232 msg = build_slot123_write(merged) 233 if self._tc is None: 234 raise RuntimeError("connection not open") 235 await self._tc.send(msg) 236 237 async def set_hardware_clock_time( 238 self, hours: int, minutes: int, seconds: int = 0, 239 ) -> None: 240 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 241 current = await self.read_hardware_clock() 242 await self._write_hardware_clock(HardwareClockState( 243 hours=hours, minutes=minutes, seconds=seconds, 244 rate=current.rate, running=current.running, 245 twenty_four_hour=True, 246 )) 247 248 async def set_hardware_clock_rate(self, rate: float) -> None: 249 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 250 current = await self.read_hardware_clock() 251 await self._write_hardware_clock(HardwareClockState( 252 hours=current.hours, minutes=current.minutes, 253 seconds=current.seconds, 254 rate=rate, running=current.running, 255 twenty_four_hour=True, 256 )) 257 258 async def start_hardware_clock(self) -> None: 259 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 260 current = await self.read_hardware_clock() 261 # LocoNet uses rate=0 to indicate stopped, so "start" without a 262 # remembered rate defaults to 1× (real time). If the CS already 263 # has a stored non-zero rate we preserve it. 264 rate = current.rate if current.rate > 0 else 1.0 265 await self._write_hardware_clock(HardwareClockState( 266 hours=current.hours, minutes=current.minutes, 267 seconds=current.seconds, 268 rate=rate, running=True, 269 twenty_four_hour=True, 270 )) 271 272 async def stop_hardware_clock(self) -> None: 273 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 274 current = await self.read_hardware_clock() 275 await self._write_hardware_clock(HardwareClockState( 276 hours=current.hours, minutes=current.minutes, 277 seconds=current.seconds, 278 rate=current.rate, running=False, 279 twenty_four_hour=True, 280 )) 281 282 # ----- manager overrides ------------------------------------------------ 283 # We only customize turnouts, sensors, and power; everything else uses the 284 # base default in-memory managers (suitable since they don't talk to the 285 # hardware). Stage 4.5 can add LnLightManager, LnReporterManager, etc. 286 287 def _make_turnout_manager(self) -> TurnoutManager: # pragma: no cover - overridden in open 288 return TurnoutManager(self.prefix) 289 290 # Keep all other _make_*_manager hooks at the inherited in-memory defaults. 291 # The Manager generic type-cast below is just to keep mypy happy with the 292 # in-memory defaults that aren't LocoNet-aware. 293 _GENERIC_HINT: ClassVar[type[Manager[Any]]] = LightManager 294 _OTHER_HINTS: ClassVar[tuple[type, ...]] = ( 295 BlockManager, 296 MemoryManager, 297 PowerManager, 298 ReporterManager, 299 RouteManager, 300 SignalHeadManager, 301 SignalMastManager, 302 ) 303 304 305# ----- transport construction ----------------------------------------------- 306 307 308async def _build_port(settings: dict[str, Any]) -> LocoNetPort: 309 kind = settings.get("kind", "loopback") 310 if kind == "serial": 311 port = settings.get("port") 312 if not port: 313 raise ValueError("serial transport requires 'port'") 314 baud = int(settings.get("baud", 16457)) 315 return await SerialPort.open(port=port, baud=baud) 316 if kind == "tcp": 317 host = settings.get("host") 318 tcp_port = settings.get("tcp_port") 319 if not host or tcp_port is None: 320 raise ValueError("tcp transport requires 'host' and 'tcp_port'") 321 return await TcpPort.open(host=host, port=int(tcp_port)) 322 if kind == "loopback": 323 port = settings.get("loopback_port") 324 if not isinstance(port, LoopbackPort): 325 raise ValueError( 326 "loopback transport requires 'loopback_port' to be a LoopbackPort" 327 ) 328 return port 329 raise ValueError(f"unknown LocoNet transport kind: {kind!r}")
53class LocoNetConnection(SystemConnection): 54 """LocoNet hardware connection. Prefix ``L``, name ``LocoNet``.""" 55 56 name: ClassVar[str] = "LocoNet" 57 prefix: ClassVar[str] = "L" 58 59 def __init__(self) -> None: 60 super().__init__() 61 # These are filled in by open(). The base-class defaults stay in place 62 # until then so the connection has well-formed managers if opened with 63 # no settings (rare; mainly the test path). 64 self._port: LocoNetPort | None = None 65 self._tc: LocoNetTrafficController | None = None 66 self._slots: SlotManager | None = None 67 self._programmer: LnProgrammer | None = None 68 self._cs_monitor_task: asyncio.Task[None] | None = None 69 70 # ----- lifecycle -------------------------------------------------------- 71 72 async def open(self, settings: ConnectionSettings | None = None) -> None: 73 s = settings or {} 74 self._port = await _build_port(s) 75 self._tc = LocoNetTrafficController(self._port) 76 await self._tc.start() 77 self._slots = SlotManager(self._tc) 78 79 # Replace the base default managers with our LocoNet-aware ones. 80 # ``LnTurnoutManager`` and friends are subclasses of the base managers, 81 # but mypy's invariance in attribute typing means we have to ignore 82 # the assignment — the runtime substitution is sound (Liskov). 83 self.turnouts = LnTurnoutManager(self.prefix, tc=self._tc) # type: ignore[assignment] 84 self.sensors = LnSensorManager(self.prefix, tc=self._tc) # type: ignore[assignment] 85 throttle_mgr = LnThrottleManager( 86 self.prefix, tc=self._tc, slot_manager=self._slots, 87 ) 88 self.throttles = throttle_mgr 89 # Rebind the consist manager onto the LocoNet-aware throttle 90 # manager. Without this, consist fan-out drives placeholder 91 # Throttle beans left over from base ``__init__``, and no 92 # OPC_LOCO_SPD frames reach the wire. 93 self.consists.rebind_throttles(self.throttles) 94 throttle_mgr.start_refresh() 95 self.consists = LnConsistManager( # type: ignore[assignment] 96 throttle_mgr, self.prefix, tc=self._tc, slots=self._slots, 97 ) 98 self.power = LnPowerManager(self._tc) 99 self._programmer = LnProgrammer(self._tc) 100 self.programmers = LnProgrammerManager(self._programmer) 101 # Wire the advanced-consist CV19 write path to the LocoNet 102 # programmer manager. Without this rebind, ``advanced`` consists 103 # go through the base placeholder manager and CV19 never 104 # actually programs. 105 self.consists.rebind_programmers(self.programmers) 106 107 # CS reachability monitor. LocoNet CS emit OPC_IDLE and other 108 # frames continuously; 3s of complete silence is a strong 109 # signal the bus is dark (CS off or unplugged). Start 110 # pessimistic (base class default is optimistic True) so a 111 # home-page render during the monitor's startup grace shows 112 # "Off" until we've actually seen a frame, not a false "On". 113 self._cs_reachable = False 114 self._cs_monitor_task = asyncio.create_task( 115 self._cs_reachability_loop(), name="loconet-cs-reachability", 116 ) 117 118 await super().open(settings) 119 120 async def _cs_reachability_loop(self) -> None: 121 """Flip ``_cs_reachable`` on the 3-second inbound-silence rule. 122 123 LocoNet's continuous chatter (CS heartbeat + slot poll traffic) 124 means any real silence past 3 seconds indicates the CS has 125 stopped talking. Any fresh inbound frame flips reachable back 126 to True immediately. 127 """ 128 import contextlib # noqa: PLC0415 129 try: 130 # 3-second grace so the first CS heartbeat can arrive. 131 await asyncio.sleep(3.0) 132 while True: 133 await asyncio.sleep(1.0) 134 if self._tc is None: 135 return 136 last = self._tc.last_inbound_at 137 if last is None: 138 await self._set_cs_reachable(False) 139 continue 140 age = time.monotonic() - last 141 await self._set_cs_reachable(age <= 3.0) 142 except asyncio.CancelledError: 143 with contextlib.suppress(Exception): 144 return 145 146 async def close(self) -> None: 147 if isinstance(self.throttles, LnThrottleManager): 148 await self.throttles.stop_refresh() 149 if self._cs_monitor_task is not None: 150 self._cs_monitor_task.cancel() 151 import contextlib # noqa: PLC0415 152 with contextlib.suppress(asyncio.CancelledError, Exception): 153 await self._cs_monitor_task 154 self._cs_monitor_task = None 155 if self._tc is not None: 156 await self._tc.aclose() 157 self._tc = None 158 self._port = None 159 await super().close() 160 161 # ----- accessors -------------------------------------------------------- 162 163 @property 164 def slots(self) -> SlotManager: 165 if self._slots is None: 166 raise RuntimeError("connection not open") 167 return self._slots 168 169 @property 170 def traffic(self) -> LocoNetTrafficController: 171 if self._tc is None: 172 raise RuntimeError("connection not open") 173 return self._tc 174 175 # ----- fast clock (slot 123) -------------------------------------------- 176 177 async def read_hardware_clock(self): # noqa: ANN201 178 """Read the CS fast clock from slot 123. 179 180 Sends ``OPC_RQ_SL_DATA(slot=123)`` and awaits the paired 181 ``OPC_SL_RD_DATA`` reply on the inbound signal. Returns a 182 :class:`~jmri_core.beans.fast_clock.HardwareClockState`. 183 184 The :class:`~jmri_server.services.fast_clock.TimeService` 185 prefers this method over the software bean whenever the 186 connection exposes it, so wiring this on ``LocoNetConnection`` 187 automatically makes ``/json/time`` reflect the CS clock. 188 """ 189 from jmri_loconet.clock import FAST_CLOCK_SLOT, decode_slot123 # noqa: PLC0415 190 from jmri_loconet.messages import OpcRqSlData, OpcSlRdData # noqa: PLC0415 191 192 if self._tc is None: 193 raise RuntimeError("connection not open") 194 tc = self._tc 195 loop = asyncio.get_event_loop() 196 fut: asyncio.Future[OpcSlRdData] = loop.create_future() 197 198 async def on_inbound(msg) -> None: # noqa: ANN001 199 if ( 200 isinstance(msg, OpcSlRdData) 201 and msg.slot == FAST_CLOCK_SLOT 202 and not fut.done() 203 ): 204 fut.set_result(msg) 205 206 unsub = tc.inbound.connect(on_inbound) 207 try: 208 await tc.send(OpcRqSlData(slot=FAST_CLOCK_SLOT)) 209 reply = await asyncio.wait_for(fut, timeout=2.0) 210 finally: 211 unsub() 212 return decode_slot123(reply) 213 214 async def _write_hardware_clock(self, state) -> None: # noqa: ANN001 215 """Common write path — reads the current slot to preserve 216 non-clock fields, merges *state*, and sends the write.""" 217 current = await self.read_hardware_clock() 218 # ``current`` gave us the CS's authoritative days / trk / snd 219 # bytes indirectly by reading the raw slot; we lose that in 220 # the decode. To keep this pass simple we send zeros for those 221 # fields — JMRI Java's LnClockControl does the same on write, 222 # relying on the CS to preserve day count internally. 223 from jmri_loconet.clock import build_slot123_write # noqa: PLC0415 224 225 merged = state.__class__( 226 hours=state.hours if state.hours is not None else current.hours, 227 minutes=state.minutes if state.minutes is not None else current.minutes, 228 seconds=state.seconds if state.seconds is not None else current.seconds, 229 rate=state.rate if state.rate is not None else current.rate, 230 running=state.running if state.running is not None else current.running, 231 twenty_four_hour=True, 232 ) 233 msg = build_slot123_write(merged) 234 if self._tc is None: 235 raise RuntimeError("connection not open") 236 await self._tc.send(msg) 237 238 async def set_hardware_clock_time( 239 self, hours: int, minutes: int, seconds: int = 0, 240 ) -> None: 241 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 242 current = await self.read_hardware_clock() 243 await self._write_hardware_clock(HardwareClockState( 244 hours=hours, minutes=minutes, seconds=seconds, 245 rate=current.rate, running=current.running, 246 twenty_four_hour=True, 247 )) 248 249 async def set_hardware_clock_rate(self, rate: float) -> None: 250 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 251 current = await self.read_hardware_clock() 252 await self._write_hardware_clock(HardwareClockState( 253 hours=current.hours, minutes=current.minutes, 254 seconds=current.seconds, 255 rate=rate, running=current.running, 256 twenty_four_hour=True, 257 )) 258 259 async def start_hardware_clock(self) -> None: 260 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 261 current = await self.read_hardware_clock() 262 # LocoNet uses rate=0 to indicate stopped, so "start" without a 263 # remembered rate defaults to 1× (real time). If the CS already 264 # has a stored non-zero rate we preserve it. 265 rate = current.rate if current.rate > 0 else 1.0 266 await self._write_hardware_clock(HardwareClockState( 267 hours=current.hours, minutes=current.minutes, 268 seconds=current.seconds, 269 rate=rate, running=True, 270 twenty_four_hour=True, 271 )) 272 273 async def stop_hardware_clock(self) -> None: 274 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 275 current = await self.read_hardware_clock() 276 await self._write_hardware_clock(HardwareClockState( 277 hours=current.hours, minutes=current.minutes, 278 seconds=current.seconds, 279 rate=current.rate, running=False, 280 twenty_four_hour=True, 281 )) 282 283 # ----- manager overrides ------------------------------------------------ 284 # We only customize turnouts, sensors, and power; everything else uses the 285 # base default in-memory managers (suitable since they don't talk to the 286 # hardware). Stage 4.5 can add LnLightManager, LnReporterManager, etc. 287 288 def _make_turnout_manager(self) -> TurnoutManager: # pragma: no cover - overridden in open 289 return TurnoutManager(self.prefix) 290 291 # Keep all other _make_*_manager hooks at the inherited in-memory defaults. 292 # The Manager generic type-cast below is just to keep mypy happy with the 293 # in-memory defaults that aren't LocoNet-aware. 294 _GENERIC_HINT: ClassVar[type[Manager[Any]]] = LightManager 295 _OTHER_HINTS: ClassVar[tuple[type, ...]] = ( 296 BlockManager, 297 MemoryManager, 298 PowerManager, 299 ReporterManager, 300 RouteManager, 301 SignalHeadManager, 302 SignalMastManager, 303 )
LocoNet hardware connection. Prefix L, name LocoNet.
72 async def open(self, settings: ConnectionSettings | None = None) -> None: 73 s = settings or {} 74 self._port = await _build_port(s) 75 self._tc = LocoNetTrafficController(self._port) 76 await self._tc.start() 77 self._slots = SlotManager(self._tc) 78 79 # Replace the base default managers with our LocoNet-aware ones. 80 # ``LnTurnoutManager`` and friends are subclasses of the base managers, 81 # but mypy's invariance in attribute typing means we have to ignore 82 # the assignment — the runtime substitution is sound (Liskov). 83 self.turnouts = LnTurnoutManager(self.prefix, tc=self._tc) # type: ignore[assignment] 84 self.sensors = LnSensorManager(self.prefix, tc=self._tc) # type: ignore[assignment] 85 throttle_mgr = LnThrottleManager( 86 self.prefix, tc=self._tc, slot_manager=self._slots, 87 ) 88 self.throttles = throttle_mgr 89 # Rebind the consist manager onto the LocoNet-aware throttle 90 # manager. Without this, consist fan-out drives placeholder 91 # Throttle beans left over from base ``__init__``, and no 92 # OPC_LOCO_SPD frames reach the wire. 93 self.consists.rebind_throttles(self.throttles) 94 throttle_mgr.start_refresh() 95 self.consists = LnConsistManager( # type: ignore[assignment] 96 throttle_mgr, self.prefix, tc=self._tc, slots=self._slots, 97 ) 98 self.power = LnPowerManager(self._tc) 99 self._programmer = LnProgrammer(self._tc) 100 self.programmers = LnProgrammerManager(self._programmer) 101 # Wire the advanced-consist CV19 write path to the LocoNet 102 # programmer manager. Without this rebind, ``advanced`` consists 103 # go through the base placeholder manager and CV19 never 104 # actually programs. 105 self.consists.rebind_programmers(self.programmers) 106 107 # CS reachability monitor. LocoNet CS emit OPC_IDLE and other 108 # frames continuously; 3s of complete silence is a strong 109 # signal the bus is dark (CS off or unplugged). Start 110 # pessimistic (base class default is optimistic True) so a 111 # home-page render during the monitor's startup grace shows 112 # "Off" until we've actually seen a frame, not a false "On". 113 self._cs_reachable = False 114 self._cs_monitor_task = asyncio.create_task( 115 self._cs_reachability_loop(), name="loconet-cs-reachability", 116 ) 117 118 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.
146 async def close(self) -> None: 147 if isinstance(self.throttles, LnThrottleManager): 148 await self.throttles.stop_refresh() 149 if self._cs_monitor_task is not None: 150 self._cs_monitor_task.cancel() 151 import contextlib # noqa: PLC0415 152 with contextlib.suppress(asyncio.CancelledError, Exception): 153 await self._cs_monitor_task 154 self._cs_monitor_task = None 155 if self._tc is not None: 156 await self._tc.aclose() 157 self._tc = None 158 self._port = None 159 await super().close()
Disconnect from hardware. Idempotent. Base impl flips the flag.
177 async def read_hardware_clock(self): # noqa: ANN201 178 """Read the CS fast clock from slot 123. 179 180 Sends ``OPC_RQ_SL_DATA(slot=123)`` and awaits the paired 181 ``OPC_SL_RD_DATA`` reply on the inbound signal. Returns a 182 :class:`~jmri_core.beans.fast_clock.HardwareClockState`. 183 184 The :class:`~jmri_server.services.fast_clock.TimeService` 185 prefers this method over the software bean whenever the 186 connection exposes it, so wiring this on ``LocoNetConnection`` 187 automatically makes ``/json/time`` reflect the CS clock. 188 """ 189 from jmri_loconet.clock import FAST_CLOCK_SLOT, decode_slot123 # noqa: PLC0415 190 from jmri_loconet.messages import OpcRqSlData, OpcSlRdData # noqa: PLC0415 191 192 if self._tc is None: 193 raise RuntimeError("connection not open") 194 tc = self._tc 195 loop = asyncio.get_event_loop() 196 fut: asyncio.Future[OpcSlRdData] = loop.create_future() 197 198 async def on_inbound(msg) -> None: # noqa: ANN001 199 if ( 200 isinstance(msg, OpcSlRdData) 201 and msg.slot == FAST_CLOCK_SLOT 202 and not fut.done() 203 ): 204 fut.set_result(msg) 205 206 unsub = tc.inbound.connect(on_inbound) 207 try: 208 await tc.send(OpcRqSlData(slot=FAST_CLOCK_SLOT)) 209 reply = await asyncio.wait_for(fut, timeout=2.0) 210 finally: 211 unsub() 212 return decode_slot123(reply)
Read the CS fast clock from slot 123.
Sends OPC_RQ_SL_DATA(slot=123) and awaits the paired
OPC_SL_RD_DATA reply on the inbound signal. Returns a
~jmri_core.beans.fast_clock.HardwareClockState.
The ~jmri_server.services.fast_clock.TimeService
prefers this method over the software bean whenever the
connection exposes it, so wiring this on LocoNetConnection
automatically makes /json/time reflect the CS clock.
238 async def set_hardware_clock_time( 239 self, hours: int, minutes: int, seconds: int = 0, 240 ) -> None: 241 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 242 current = await self.read_hardware_clock() 243 await self._write_hardware_clock(HardwareClockState( 244 hours=hours, minutes=minutes, seconds=seconds, 245 rate=current.rate, running=current.running, 246 twenty_four_hour=True, 247 ))
249 async def set_hardware_clock_rate(self, rate: float) -> None: 250 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 251 current = await self.read_hardware_clock() 252 await self._write_hardware_clock(HardwareClockState( 253 hours=current.hours, minutes=current.minutes, 254 seconds=current.seconds, 255 rate=rate, running=current.running, 256 twenty_four_hour=True, 257 ))
259 async def start_hardware_clock(self) -> None: 260 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 261 current = await self.read_hardware_clock() 262 # LocoNet uses rate=0 to indicate stopped, so "start" without a 263 # remembered rate defaults to 1× (real time). If the CS already 264 # has a stored non-zero rate we preserve it. 265 rate = current.rate if current.rate > 0 else 1.0 266 await self._write_hardware_clock(HardwareClockState( 267 hours=current.hours, minutes=current.minutes, 268 seconds=current.seconds, 269 rate=rate, running=True, 270 twenty_four_hour=True, 271 ))
273 async def stop_hardware_clock(self) -> None: 274 from jmri_core.beans.fast_clock import HardwareClockState # noqa: PLC0415 275 current = await self.read_hardware_clock() 276 await self._write_hardware_clock(HardwareClockState( 277 hours=current.hours, minutes=current.minutes, 278 seconds=current.seconds, 279 rate=current.rate, running=False, 280 twenty_four_hour=True, 281 ))