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}")
class LocoNetConnection(jmri_core.hal.connection.SystemConnection):
 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.

name: ClassVar[str] = 'LocoNet'
prefix: ClassVar[str] = 'L'
async def open(self, settings: dict[str, typing.Any] | None = None) -> None:
 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.

async def close(self) -> None:
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.

slots: jmri_loconet.SlotManager
163    @property
164    def slots(self) -> SlotManager:
165        if self._slots is None:
166            raise RuntimeError("connection not open")
167        return self._slots
169    @property
170    def traffic(self) -> LocoNetTrafficController:
171        if self._tc is None:
172            raise RuntimeError("connection not open")
173        return self._tc
async def read_hardware_clock(self):
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.

async def set_hardware_clock_time(self, hours: int, minutes: int, seconds: int = 0) -> None:
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        ))
async def set_hardware_clock_rate(self, rate: float) -> None:
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        ))
async def start_hardware_clock(self) -> None:
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        ))
async def stop_hardware_clock(self) -> None:
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        ))