jmri_dccex.connection
DccexConnection — SystemConnection subclass for DCC-EX command stations.
1"""``DccexConnection`` — SystemConnection subclass for DCC-EX command stations.""" 2from __future__ import annotations 3 4import asyncio 5import time 6from typing import Any, ClassVar 7 8from jmri_core.hal.connection import ConnectionSettings, SystemConnection 9 10from jmri_dccex.beans import DccexPowerManager, DccexSensorManager, DccexTurnoutManager 11from jmri_dccex.codec import DccexMessage 12from jmri_dccex.programmer import DccexProgrammer, DccexProgrammerManager 13from jmri_dccex.throttle import DccexThrottleManager 14from jmri_dccex.traffic import DccexTrafficController 15from jmri_dccex.transport import BytePort, LoopbackPort, SerialPort, TcpPort 16 17 18class DccexConnection(SystemConnection): 19 name: ClassVar[str] = "DCC-EX" 20 prefix: ClassVar[str] = "D" 21 22 def __init__(self) -> None: 23 super().__init__() 24 self._port: BytePort | None = None 25 self._tc: DccexTrafficController | None = None 26 self._programmer: DccexProgrammer | None = None 27 self._cs_monitor_task: asyncio.Task[None] | None = None 28 self._cs_ping_task: asyncio.Task[None] | None = None 29 30 async def open(self, settings: ConnectionSettings | None = None) -> None: 31 s = settings or {} 32 self._port = await _build_port(s) 33 self._tc = DccexTrafficController(self._port) 34 await self._tc.start() 35 36 self.turnouts = DccexTurnoutManager(self.prefix, tc=self._tc) # type: ignore[assignment] 37 self.sensors = DccexSensorManager(self.prefix, tc=self._tc) # type: ignore[assignment] 38 self.throttles = DccexThrottleManager(self.prefix, tc=self._tc) 39 # Rebind the consist manager onto the DCC-EX-aware throttle 40 # manager. Without this the fan-out drives placeholder beans 41 # and no ``<t>`` (throttle) frames reach the CS. 42 self.consists.rebind_throttles(self.throttles) 43 self.power = DccexPowerManager(self._tc) 44 self._programmer = DccexProgrammer(self._tc) 45 self.programmers = DccexProgrammerManager(self._programmer) 46 # Wire advanced-consist CV19 writes onto the DCC-EX programmer. 47 self.consists.rebind_programmers(self.programmers) 48 49 # CS reachability: DCC-EX doesn't chatter unsolicited, so we 50 # ping ``<s>`` (status query) every 2 seconds and watch for 51 # ANY inbound message within 3 seconds. Status ping response 52 # is ``<p 0>``/``<p 1>`` plus other startup chatter — any of 53 # which resets the monitor's silence timer. Start pessimistic 54 # (base class default is optimistic True) so a home-page 55 # render during the monitor's startup grace shows "Off" until 56 # we've actually seen a reply, not a false "On". 57 self._cs_reachable = False 58 self._cs_ping_task = asyncio.create_task( 59 self._cs_ping_loop(), name="dccex-cs-ping", 60 ) 61 self._cs_monitor_task = asyncio.create_task( 62 self._cs_reachability_loop(), name="dccex-cs-reachability", 63 ) 64 65 await super().open(settings) 66 67 async def _cs_ping_loop(self) -> None: 68 """Send ``<s>`` every 2s so the CS keeps replying with status 69 traffic — that's what the reachability monitor watches for.""" 70 import contextlib # noqa: PLC0415 71 try: 72 while True: 73 await asyncio.sleep(2.0) 74 if self._tc is None: 75 return 76 try: 77 await self._tc.send(DccexMessage(command="s", args=())) 78 except Exception: # noqa: BLE001 79 # Send may raise on a closed port; the monitor 80 # loop notices via the silence timer. 81 pass 82 except asyncio.CancelledError: 83 with contextlib.suppress(Exception): 84 return 85 86 async def _cs_reachability_loop(self) -> None: 87 """3-second silence rule against ``last_inbound_at``.""" 88 import contextlib # noqa: PLC0415 89 try: 90 await asyncio.sleep(3.0) # startup grace 91 while True: 92 await asyncio.sleep(1.0) 93 if self._tc is None: 94 return 95 last = self._tc.last_inbound_at 96 if last is None: 97 await self._set_cs_reachable(False) 98 continue 99 age = time.monotonic() - last 100 await self._set_cs_reachable(age <= 3.0) 101 except asyncio.CancelledError: 102 with contextlib.suppress(Exception): 103 return 104 105 async def close(self) -> None: 106 for task in (self._cs_ping_task, self._cs_monitor_task): 107 if task is not None: 108 task.cancel() 109 import contextlib # noqa: PLC0415 110 with contextlib.suppress(asyncio.CancelledError, Exception): 111 await task 112 self._cs_ping_task = None 113 self._cs_monitor_task = None 114 if self._tc is not None: 115 await self._tc.aclose() 116 self._tc = None 117 self._port = None 118 await super().close() 119 120 @property 121 def traffic(self) -> DccexTrafficController: 122 if self._tc is None: 123 raise RuntimeError("connection not open") 124 return self._tc 125 126 127async def _build_port(settings: dict[str, Any]) -> BytePort: 128 kind = settings.get("kind", "loopback") 129 if kind == "serial": 130 port = settings.get("port") 131 if not port: 132 raise ValueError("serial transport requires 'port'") 133 baud = int(settings.get("baud", 115200)) 134 return await SerialPort.open(port=port, baud=baud) 135 if kind == "tcp": 136 host = settings.get("host") 137 tcp_port = settings.get("tcp_port") 138 if not host or tcp_port is None: 139 raise ValueError("tcp transport requires 'host' and 'tcp_port'") 140 return await TcpPort.open(host=host, port=int(tcp_port)) 141 if kind == "loopback": 142 lb = settings.get("loopback_port") 143 if not isinstance(lb, LoopbackPort): 144 raise ValueError("loopback transport requires 'loopback_port'") 145 return lb 146 raise ValueError(f"unknown DCC-EX transport kind: {kind!r}")
19class DccexConnection(SystemConnection): 20 name: ClassVar[str] = "DCC-EX" 21 prefix: ClassVar[str] = "D" 22 23 def __init__(self) -> None: 24 super().__init__() 25 self._port: BytePort | None = None 26 self._tc: DccexTrafficController | None = None 27 self._programmer: DccexProgrammer | None = None 28 self._cs_monitor_task: asyncio.Task[None] | None = None 29 self._cs_ping_task: asyncio.Task[None] | None = None 30 31 async def open(self, settings: ConnectionSettings | None = None) -> None: 32 s = settings or {} 33 self._port = await _build_port(s) 34 self._tc = DccexTrafficController(self._port) 35 await self._tc.start() 36 37 self.turnouts = DccexTurnoutManager(self.prefix, tc=self._tc) # type: ignore[assignment] 38 self.sensors = DccexSensorManager(self.prefix, tc=self._tc) # type: ignore[assignment] 39 self.throttles = DccexThrottleManager(self.prefix, tc=self._tc) 40 # Rebind the consist manager onto the DCC-EX-aware throttle 41 # manager. Without this the fan-out drives placeholder beans 42 # and no ``<t>`` (throttle) frames reach the CS. 43 self.consists.rebind_throttles(self.throttles) 44 self.power = DccexPowerManager(self._tc) 45 self._programmer = DccexProgrammer(self._tc) 46 self.programmers = DccexProgrammerManager(self._programmer) 47 # Wire advanced-consist CV19 writes onto the DCC-EX programmer. 48 self.consists.rebind_programmers(self.programmers) 49 50 # CS reachability: DCC-EX doesn't chatter unsolicited, so we 51 # ping ``<s>`` (status query) every 2 seconds and watch for 52 # ANY inbound message within 3 seconds. Status ping response 53 # is ``<p 0>``/``<p 1>`` plus other startup chatter — any of 54 # which resets the monitor's silence timer. Start pessimistic 55 # (base class default is optimistic True) so a home-page 56 # render during the monitor's startup grace shows "Off" until 57 # we've actually seen a reply, not a false "On". 58 self._cs_reachable = False 59 self._cs_ping_task = asyncio.create_task( 60 self._cs_ping_loop(), name="dccex-cs-ping", 61 ) 62 self._cs_monitor_task = asyncio.create_task( 63 self._cs_reachability_loop(), name="dccex-cs-reachability", 64 ) 65 66 await super().open(settings) 67 68 async def _cs_ping_loop(self) -> None: 69 """Send ``<s>`` every 2s so the CS keeps replying with status 70 traffic — that's what the reachability monitor watches for.""" 71 import contextlib # noqa: PLC0415 72 try: 73 while True: 74 await asyncio.sleep(2.0) 75 if self._tc is None: 76 return 77 try: 78 await self._tc.send(DccexMessage(command="s", args=())) 79 except Exception: # noqa: BLE001 80 # Send may raise on a closed port; the monitor 81 # loop notices via the silence timer. 82 pass 83 except asyncio.CancelledError: 84 with contextlib.suppress(Exception): 85 return 86 87 async def _cs_reachability_loop(self) -> None: 88 """3-second silence rule against ``last_inbound_at``.""" 89 import contextlib # noqa: PLC0415 90 try: 91 await asyncio.sleep(3.0) # startup grace 92 while True: 93 await asyncio.sleep(1.0) 94 if self._tc is None: 95 return 96 last = self._tc.last_inbound_at 97 if last is None: 98 await self._set_cs_reachable(False) 99 continue 100 age = time.monotonic() - last 101 await self._set_cs_reachable(age <= 3.0) 102 except asyncio.CancelledError: 103 with contextlib.suppress(Exception): 104 return 105 106 async def close(self) -> None: 107 for task in (self._cs_ping_task, self._cs_monitor_task): 108 if task is not None: 109 task.cancel() 110 import contextlib # noqa: PLC0415 111 with contextlib.suppress(asyncio.CancelledError, Exception): 112 await task 113 self._cs_ping_task = None 114 self._cs_monitor_task = None 115 if self._tc is not None: 116 await self._tc.aclose() 117 self._tc = None 118 self._port = None 119 await super().close() 120 121 @property 122 def traffic(self) -> DccexTrafficController: 123 if self._tc is None: 124 raise RuntimeError("connection not open") 125 return self._tc
One hardware connection to a layout.
Subclasses set name (vendor-friendly label) and prefix (the JMRI
system-name prefix). After open() returns, all the bean managers
must be populated; closed connections deregister themselves.
async def
open(self, settings: dict[str, typing.Any] | None = None) -> None:
31 async def open(self, settings: ConnectionSettings | None = None) -> None: 32 s = settings or {} 33 self._port = await _build_port(s) 34 self._tc = DccexTrafficController(self._port) 35 await self._tc.start() 36 37 self.turnouts = DccexTurnoutManager(self.prefix, tc=self._tc) # type: ignore[assignment] 38 self.sensors = DccexSensorManager(self.prefix, tc=self._tc) # type: ignore[assignment] 39 self.throttles = DccexThrottleManager(self.prefix, tc=self._tc) 40 # Rebind the consist manager onto the DCC-EX-aware throttle 41 # manager. Without this the fan-out drives placeholder beans 42 # and no ``<t>`` (throttle) frames reach the CS. 43 self.consists.rebind_throttles(self.throttles) 44 self.power = DccexPowerManager(self._tc) 45 self._programmer = DccexProgrammer(self._tc) 46 self.programmers = DccexProgrammerManager(self._programmer) 47 # Wire advanced-consist CV19 writes onto the DCC-EX programmer. 48 self.consists.rebind_programmers(self.programmers) 49 50 # CS reachability: DCC-EX doesn't chatter unsolicited, so we 51 # ping ``<s>`` (status query) every 2 seconds and watch for 52 # ANY inbound message within 3 seconds. Status ping response 53 # is ``<p 0>``/``<p 1>`` plus other startup chatter — any of 54 # which resets the monitor's silence timer. Start pessimistic 55 # (base class default is optimistic True) so a home-page 56 # render during the monitor's startup grace shows "Off" until 57 # we've actually seen a reply, not a false "On". 58 self._cs_reachable = False 59 self._cs_ping_task = asyncio.create_task( 60 self._cs_ping_loop(), name="dccex-cs-ping", 61 ) 62 self._cs_monitor_task = asyncio.create_task( 63 self._cs_reachability_loop(), name="dccex-cs-reachability", 64 ) 65 66 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:
106 async def close(self) -> None: 107 for task in (self._cs_ping_task, self._cs_monitor_task): 108 if task is not None: 109 task.cancel() 110 import contextlib # noqa: PLC0415 111 with contextlib.suppress(asyncio.CancelledError, Exception): 112 await task 113 self._cs_ping_task = None 114 self._cs_monitor_task = None 115 if self._tc is not None: 116 await self._tc.aclose() 117 self._tc = None 118 self._port = None 119 await super().close()
Disconnect from hardware. Idempotent. Base impl flips the flag.
traffic: jmri_dccex.DccexTrafficController