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

name: ClassVar[str] = 'DCC-EX'
prefix: ClassVar[str] = 'D'
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
121    @property
122    def traffic(self) -> DccexTrafficController:
123        if self._tc is None:
124            raise RuntimeError("connection not open")
125        return self._tc