pmri-dccex 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pmri_dccex/__init__.py +37 -0
- pmri_dccex/beans.py +167 -0
- pmri_dccex/codec.py +83 -0
- pmri_dccex/connection.py +162 -0
- pmri_dccex/exrail.py +303 -0
- pmri_dccex/programmer.py +249 -0
- pmri_dccex/throttle.py +88 -0
- pmri_dccex/traffic.py +73 -0
- pmri_dccex/transport/__init__.py +7 -0
- pmri_dccex/transport/loopback.py +34 -0
- pmri_dccex/transport/port.py +16 -0
- pmri_dccex/transport/serial_port.py +48 -0
- pmri_dccex/transport/tcp_port.py +45 -0
- pmri_dccex-0.1.0.dist-info/METADATA +15 -0
- pmri_dccex-0.1.0.dist-info/RECORD +18 -0
- pmri_dccex-0.1.0.dist-info/WHEEL +4 -0
- pmri_dccex-0.1.0.dist-info/entry_points.txt +2 -0
- pmri_dccex-0.1.0.dist-info/licenses/LICENSE +674 -0
pmri_dccex/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""pmri-dccex -- DCC-EX driver. Bracket-delimited ASCII protocol."""
|
|
2
|
+
from pmri_dccex.beans import (
|
|
3
|
+
DccexPowerManager,
|
|
4
|
+
DccexSensor,
|
|
5
|
+
DccexSensorManager,
|
|
6
|
+
DccexTurnout,
|
|
7
|
+
DccexTurnoutManager,
|
|
8
|
+
)
|
|
9
|
+
from pmri_dccex.codec import DccexMessage, DccexUnknown, decode, encode
|
|
10
|
+
from pmri_dccex.connection import DccexConnection
|
|
11
|
+
from pmri_dccex.exrail import (
|
|
12
|
+
DccexExRailManager,
|
|
13
|
+
ExRailAspect,
|
|
14
|
+
ExRailEntry,
|
|
15
|
+
ExRailKind,
|
|
16
|
+
)
|
|
17
|
+
from pmri_dccex.programmer import (
|
|
18
|
+
DccexOpsProgrammer,
|
|
19
|
+
DccexProgrammer,
|
|
20
|
+
DccexProgrammerManager,
|
|
21
|
+
DccexServiceProgrammer,
|
|
22
|
+
)
|
|
23
|
+
from pmri_dccex.throttle import DccexThrottle, DccexThrottleManager
|
|
24
|
+
from pmri_dccex.traffic import DccexTrafficController
|
|
25
|
+
from pmri_dccex.transport import BytePort, LoopbackPort, SerialPort, TcpPort, loopback_pair
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BytePort", "DccexConnection", "DccexExRailManager", "DccexMessage",
|
|
30
|
+
"DccexOpsProgrammer", "DccexPowerManager", "DccexProgrammer",
|
|
31
|
+
"DccexProgrammerManager", "DccexSensor", "DccexSensorManager",
|
|
32
|
+
"DccexServiceProgrammer", "DccexThrottle", "DccexThrottleManager",
|
|
33
|
+
"DccexTrafficController", "DccexTurnout", "DccexTurnoutManager",
|
|
34
|
+
"DccexUnknown", "ExRailAspect", "ExRailEntry", "ExRailKind",
|
|
35
|
+
"LoopbackPort", "SerialPort", "TcpPort", "__version__", "decode",
|
|
36
|
+
"encode", "loopback_pair",
|
|
37
|
+
]
|
pmri_dccex/beans.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""DCC-EX-aware beans + their managers + power manager.
|
|
2
|
+
|
|
3
|
+
Wire-protocol summary (CommandStation-EX docs, command-summary-consolidated):
|
|
4
|
+
|
|
5
|
+
* Turnouts: ``<T id state>`` where state 0 = CLOSED, 1 = THROWN.
|
|
6
|
+
Response: ``<H id state>`` using the same encoding.
|
|
7
|
+
Note: this is the OPPOSITE of the NMRA DCC packet convention where
|
|
8
|
+
bit 0 of the accessory data byte is 1 = "closed / normal". DCC-EX
|
|
9
|
+
chose the human-facing "0 = at rest, 1 = deflected" mapping instead.
|
|
10
|
+
Do not "correct" this to match DCC -- verified against
|
|
11
|
+
https://dcc-ex.com/reference/software/command-summary-consolidated.html
|
|
12
|
+
* Sensors: ``<Q id>`` = active, ``<q id>`` = inactive (server -> client).
|
|
13
|
+
* Power: ``<1>`` = on, ``<0>`` = off. Response: ``<p state>``.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
|
|
19
|
+
from pmri_core.beans.power import PowerManager
|
|
20
|
+
from pmri_core.beans.sensor import Sensor
|
|
21
|
+
from pmri_core.beans.turnout import Turnout
|
|
22
|
+
from pmri_core.manager import Manager
|
|
23
|
+
from pmri_core.state import PowerState, SensorState, TurnoutState
|
|
24
|
+
|
|
25
|
+
from pmri_dccex.codec import DccexMessage
|
|
26
|
+
from pmri_dccex.traffic import DccexTrafficController
|
|
27
|
+
|
|
28
|
+
# ----- Turnout -------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DccexTurnout(Turnout):
|
|
32
|
+
"""Sends ``<T id state>`` on commanded-state change."""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("_address", "_tc")
|
|
35
|
+
|
|
36
|
+
def __init__(self, system_name: str, address: int, tc: DccexTrafficController) -> None:
|
|
37
|
+
super().__init__(system_name)
|
|
38
|
+
self._address = address
|
|
39
|
+
self._tc = tc
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def address(self) -> int:
|
|
43
|
+
return self._address
|
|
44
|
+
|
|
45
|
+
async def set_commanded_state(self, state: TurnoutState) -> None:
|
|
46
|
+
# DCC-EX: 0 = CLOSED, 1 = THROWN (opposite of the DCC accessory
|
|
47
|
+
# packet convention -- see module docstring).
|
|
48
|
+
wire = 0 if state == TurnoutState.CLOSED else 1
|
|
49
|
+
await self._tc.send(DccexMessage(command="T", args=(str(self._address), str(wire))))
|
|
50
|
+
await super().set_commanded_state(state)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DccexTurnoutManager(Manager[DccexTurnout]):
|
|
54
|
+
type_letter = "T"
|
|
55
|
+
|
|
56
|
+
def __init__(self, system_prefix: str, *, tc: DccexTrafficController) -> None:
|
|
57
|
+
super().__init__(system_prefix)
|
|
58
|
+
self._tc = tc
|
|
59
|
+
self._by_address: dict[int, DccexTurnout] = {}
|
|
60
|
+
self._unsub = tc.inbound.connect(self._on_message)
|
|
61
|
+
|
|
62
|
+
def _create(self, system_name: str) -> DccexTurnout:
|
|
63
|
+
address = _parse_address(system_name, self.full_prefix)
|
|
64
|
+
bean = DccexTurnout(system_name, address, self._tc)
|
|
65
|
+
self._by_address[address] = bean
|
|
66
|
+
return bean
|
|
67
|
+
|
|
68
|
+
def get_by_address(self, address: int) -> DccexTurnout | None:
|
|
69
|
+
return self._by_address.get(address)
|
|
70
|
+
|
|
71
|
+
async def _on_message(self, msg: DccexMessage) -> None:
|
|
72
|
+
# <H id state> -- turnout state echo from CS.
|
|
73
|
+
if msg.command != "H" or len(msg.args) < 2:
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
addr = int(msg.args[0])
|
|
77
|
+
state_int = int(msg.args[1])
|
|
78
|
+
except ValueError:
|
|
79
|
+
return
|
|
80
|
+
bean = self._by_address.get(addr)
|
|
81
|
+
if bean is None:
|
|
82
|
+
return
|
|
83
|
+
# DCC-EX response encoding matches the command: 0 = CLOSED, 1 = THROWN.
|
|
84
|
+
state = TurnoutState.THROWN if state_int else TurnoutState.CLOSED
|
|
85
|
+
await bean._set_known_state(state)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ----- Sensor --------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class DccexSensor(Sensor):
|
|
92
|
+
__slots__ = ("_address",)
|
|
93
|
+
|
|
94
|
+
def __init__(self, system_name: str, address: int) -> None:
|
|
95
|
+
super().__init__(system_name)
|
|
96
|
+
self._address = address
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def address(self) -> int:
|
|
100
|
+
return self._address
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class DccexSensorManager(Manager[DccexSensor]):
|
|
104
|
+
type_letter = "S"
|
|
105
|
+
|
|
106
|
+
def __init__(self, system_prefix: str, *, tc: DccexTrafficController) -> None:
|
|
107
|
+
super().__init__(system_prefix)
|
|
108
|
+
self._by_address: dict[int, DccexSensor] = {}
|
|
109
|
+
self._unsub = tc.inbound.connect(self._on_message)
|
|
110
|
+
|
|
111
|
+
def _create(self, system_name: str) -> DccexSensor:
|
|
112
|
+
address = _parse_address(system_name, self.full_prefix)
|
|
113
|
+
bean = DccexSensor(system_name, address)
|
|
114
|
+
self._by_address[address] = bean
|
|
115
|
+
return bean
|
|
116
|
+
|
|
117
|
+
def get_by_address(self, address: int) -> DccexSensor | None:
|
|
118
|
+
return self._by_address.get(address)
|
|
119
|
+
|
|
120
|
+
async def _on_message(self, msg: DccexMessage) -> None:
|
|
121
|
+
# <Q id> active, <q id> inactive.
|
|
122
|
+
if msg.command not in ("Q", "q") or not msg.args:
|
|
123
|
+
return
|
|
124
|
+
with contextlib.suppress(ValueError):
|
|
125
|
+
addr = int(msg.args[0])
|
|
126
|
+
bean = self._by_address.get(addr)
|
|
127
|
+
if bean is not None:
|
|
128
|
+
state = SensorState.ACTIVE if msg.command == "Q" else SensorState.INACTIVE
|
|
129
|
+
await bean._set_known_state(state)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ----- Power ---------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class DccexPowerManager(PowerManager):
|
|
136
|
+
"""``<1>`` on, ``<0>`` off."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, tc: DccexTrafficController) -> None:
|
|
139
|
+
super().__init__()
|
|
140
|
+
self._tc = tc
|
|
141
|
+
self._unsub = tc.inbound.connect(self._on_message)
|
|
142
|
+
|
|
143
|
+
async def set_power(self, state: PowerState) -> None:
|
|
144
|
+
cmd = "1" if state == PowerState.ON else "0"
|
|
145
|
+
await self._tc.send(DccexMessage(command=cmd, args=()))
|
|
146
|
+
await self._set_state(state)
|
|
147
|
+
|
|
148
|
+
async def _on_message(self, msg: DccexMessage) -> None:
|
|
149
|
+
# <p state> -- power state echo. state=1 means on, 0 off.
|
|
150
|
+
if msg.command != "p" or not msg.args:
|
|
151
|
+
return
|
|
152
|
+
with contextlib.suppress(ValueError):
|
|
153
|
+
wire = int(msg.args[0])
|
|
154
|
+
await self._set_state(PowerState.ON if wire else PowerState.OFF)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ----- helpers --------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_address(system_name: str, full_prefix: str) -> int:
|
|
161
|
+
rest = system_name.removeprefix(full_prefix)
|
|
162
|
+
try:
|
|
163
|
+
return int(rest)
|
|
164
|
+
except ValueError as e:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"DCC-EX system name {system_name!r} must end in a numeric address"
|
|
167
|
+
) from e
|
pmri_dccex/codec.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""DCC-EX text protocol codec.
|
|
2
|
+
|
|
3
|
+
DCC-EX (formerly DCC++) is a bracket-delimited ASCII protocol:
|
|
4
|
+
|
|
5
|
+
<command arg1 arg2 ...>
|
|
6
|
+
|
|
7
|
+
Commands and responses both use the same bracket convention. Multiple
|
|
8
|
+
commands can be sent back-to-back; the codec reads one complete bracketed
|
|
9
|
+
message at a time and strips the brackets.
|
|
10
|
+
|
|
11
|
+
Common commands JMRI uses:
|
|
12
|
+
|
|
13
|
+
* ``<1>`` / ``<0>`` -- power on / off
|
|
14
|
+
* ``<T id 1|0>`` -- set turnout *id* to closed (1) or thrown (0)
|
|
15
|
+
* ``<S id pin pullup>`` -- define sensor (server-side state only)
|
|
16
|
+
* ``<t cab loco speed dir>`` -- throttle command (legacy form)
|
|
17
|
+
* ``<R cv>`` -- read CV; ``<W cv val>`` -- write CV
|
|
18
|
+
* ``<Q id>`` -- sensor active report (server -> client)
|
|
19
|
+
* ``<q id>`` -- sensor inactive report
|
|
20
|
+
|
|
21
|
+
Responses follow the same shape; unknown messages decode to
|
|
22
|
+
:class:`DccexUnknown` so listeners don't lose visibility.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class DccexMessage:
|
|
32
|
+
"""A parsed message -- command character + space-separated args."""
|
|
33
|
+
|
|
34
|
+
command: str # first token (e.g. "1", "T", "Q")
|
|
35
|
+
args: tuple[str, ...] # remaining tokens
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def raw(self) -> str:
|
|
39
|
+
if not self.args:
|
|
40
|
+
return self.command
|
|
41
|
+
return f"{self.command} {' '.join(self.args)}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class DccexUnknown(DccexMessage):
|
|
46
|
+
"""Marker subclass: a well-formed bracket message we don't recognize."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def encode(msg: DccexMessage) -> bytes:
|
|
50
|
+
"""Wrap a message in brackets and return as UTF-8 bytes."""
|
|
51
|
+
return f"<{msg.raw}>".encode()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def decode(text: str) -> DccexMessage:
|
|
55
|
+
"""Parse the content *between* the brackets into a :class:`DccexMessage`."""
|
|
56
|
+
text = text.strip()
|
|
57
|
+
if not text:
|
|
58
|
+
raise ValueError("empty DCC-EX message body")
|
|
59
|
+
tokens = text.split()
|
|
60
|
+
return DccexMessage(command=tokens[0], args=tuple(tokens[1:]))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def read_message(
|
|
64
|
+
read_byte: Callable[[], Awaitable[int]],
|
|
65
|
+
) -> str:
|
|
66
|
+
"""Read one bracketed message from the byte stream.
|
|
67
|
+
|
|
68
|
+
Bytes outside a ``<...>`` pair are silently discarded (matches DCC-EX
|
|
69
|
+
convention: newlines and other status text between messages are noise).
|
|
70
|
+
Returns the *unwrapped* contents (no brackets).
|
|
71
|
+
"""
|
|
72
|
+
# Wait for opening bracket.
|
|
73
|
+
while True:
|
|
74
|
+
b = await read_byte()
|
|
75
|
+
if b == ord('<'):
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
out = bytearray()
|
|
79
|
+
while True:
|
|
80
|
+
b = await read_byte()
|
|
81
|
+
if b == ord('>'):
|
|
82
|
+
return out.decode("utf-8", errors="replace")
|
|
83
|
+
out.append(b)
|
pmri_dccex/connection.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""``DccexConnection`` -- SystemConnection subclass for DCC-EX command stations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from pmri_core.hal.connection import ConnectionSettings, SystemConnection
|
|
9
|
+
|
|
10
|
+
from pmri_dccex.beans import DccexPowerManager, DccexSensorManager, DccexTurnoutManager
|
|
11
|
+
from pmri_dccex.codec import DccexMessage
|
|
12
|
+
from pmri_dccex.exrail import DccexExRailManager
|
|
13
|
+
from pmri_dccex.programmer import DccexProgrammer, DccexProgrammerManager
|
|
14
|
+
from pmri_dccex.throttle import DccexThrottleManager
|
|
15
|
+
from pmri_dccex.traffic import DccexTrafficController
|
|
16
|
+
from pmri_dccex.transport import BytePort, LoopbackPort, SerialPort, TcpPort
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class 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
|
+
self._exrail: DccexExRailManager | None = None
|
|
31
|
+
|
|
32
|
+
async def open(self, settings: ConnectionSettings | None = None) -> None:
|
|
33
|
+
s = settings or {}
|
|
34
|
+
self._port = await _build_port(s)
|
|
35
|
+
self._tc = DccexTrafficController(self._port)
|
|
36
|
+
await self._tc.start()
|
|
37
|
+
|
|
38
|
+
self.turnouts = DccexTurnoutManager(self.prefix, tc=self._tc) # type: ignore[assignment]
|
|
39
|
+
self.sensors = DccexSensorManager(self.prefix, tc=self._tc) # type: ignore[assignment]
|
|
40
|
+
self.throttles = DccexThrottleManager(self.prefix, tc=self._tc)
|
|
41
|
+
# Rebind the consist manager onto the DCC-EX-aware throttle
|
|
42
|
+
# manager. Without this the fan-out drives placeholder beans
|
|
43
|
+
# and no ``<t>`` (throttle) frames reach the CS.
|
|
44
|
+
self.consists.rebind_throttles(self.throttles)
|
|
45
|
+
self.power = DccexPowerManager(self._tc)
|
|
46
|
+
self._programmer = DccexProgrammer(self._tc)
|
|
47
|
+
self.programmers = DccexProgrammerManager(self._programmer)
|
|
48
|
+
# Wire advanced-consist CV19 writes onto the DCC-EX programmer.
|
|
49
|
+
self.consists.rebind_programmers(self.programmers)
|
|
50
|
+
# EX-RAIL automation client. Routes/automations are compiled
|
|
51
|
+
# into the CS firmware; we only introspect + trigger.
|
|
52
|
+
self._exrail = DccexExRailManager(self._tc)
|
|
53
|
+
|
|
54
|
+
# CS reachability: DCC-EX doesn't chatter unsolicited, so we
|
|
55
|
+
# ping ``<s>`` (status query) every 2 seconds and watch for
|
|
56
|
+
# ANY inbound message within 3 seconds. Status ping response
|
|
57
|
+
# is ``<p 0>``/``<p 1>`` plus other startup chatter -- any of
|
|
58
|
+
# which resets the monitor's silence timer. Start pessimistic
|
|
59
|
+
# (base class default is optimistic True) so a home-page
|
|
60
|
+
# render during the monitor's startup grace shows "Off" until
|
|
61
|
+
# we've actually seen a reply, not a false "On".
|
|
62
|
+
self._cs_reachable = False
|
|
63
|
+
self._cs_ping_task = asyncio.create_task(
|
|
64
|
+
self._cs_ping_loop(), name="dccex-cs-ping",
|
|
65
|
+
)
|
|
66
|
+
self._cs_monitor_task = asyncio.create_task(
|
|
67
|
+
self._cs_reachability_loop(), name="dccex-cs-reachability",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
await super().open(settings)
|
|
71
|
+
|
|
72
|
+
async def _cs_ping_loop(self) -> None:
|
|
73
|
+
"""Send ``<s>`` every 2s so the CS keeps replying with status
|
|
74
|
+
traffic -- that's what the reachability monitor watches for."""
|
|
75
|
+
import contextlib # noqa: PLC0415
|
|
76
|
+
try:
|
|
77
|
+
while True:
|
|
78
|
+
await asyncio.sleep(2.0)
|
|
79
|
+
if self._tc is None:
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
await self._tc.send(DccexMessage(command="s", args=()))
|
|
83
|
+
except Exception: # noqa: BLE001
|
|
84
|
+
# Send may raise on a closed port; the monitor
|
|
85
|
+
# loop notices via the silence timer.
|
|
86
|
+
pass
|
|
87
|
+
except asyncio.CancelledError:
|
|
88
|
+
with contextlib.suppress(Exception):
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
async def _cs_reachability_loop(self) -> None:
|
|
92
|
+
"""3-second silence rule against ``last_inbound_at``."""
|
|
93
|
+
import contextlib # noqa: PLC0415
|
|
94
|
+
try:
|
|
95
|
+
await asyncio.sleep(3.0) # startup grace
|
|
96
|
+
while True:
|
|
97
|
+
await asyncio.sleep(1.0)
|
|
98
|
+
if self._tc is None:
|
|
99
|
+
return
|
|
100
|
+
last = self._tc.last_inbound_at
|
|
101
|
+
if last is None:
|
|
102
|
+
await self._set_cs_reachable(False)
|
|
103
|
+
continue
|
|
104
|
+
age = time.monotonic() - last
|
|
105
|
+
await self._set_cs_reachable(age <= 3.0)
|
|
106
|
+
except asyncio.CancelledError:
|
|
107
|
+
with contextlib.suppress(Exception):
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
async def close(self) -> None:
|
|
111
|
+
for task in (self._cs_ping_task, self._cs_monitor_task):
|
|
112
|
+
if task is not None:
|
|
113
|
+
task.cancel()
|
|
114
|
+
import contextlib # noqa: PLC0415
|
|
115
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
116
|
+
await task
|
|
117
|
+
self._cs_ping_task = None
|
|
118
|
+
self._cs_monitor_task = None
|
|
119
|
+
if self._tc is not None:
|
|
120
|
+
await self._tc.aclose()
|
|
121
|
+
self._tc = None
|
|
122
|
+
self._port = None
|
|
123
|
+
self._exrail = None
|
|
124
|
+
await super().close()
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def traffic(self) -> DccexTrafficController:
|
|
128
|
+
if self._tc is None:
|
|
129
|
+
raise RuntimeError("connection not open")
|
|
130
|
+
return self._tc
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def exrail(self) -> DccexExRailManager:
|
|
134
|
+
"""EX-RAIL manager: inspect compiled routes/automations and
|
|
135
|
+
trigger them at runtime. Routes/automations are authored in
|
|
136
|
+
the CS firmware's ``myAutomation.h``; the client can only
|
|
137
|
+
introspect + control, not upload. Available after :meth:`open`."""
|
|
138
|
+
if self._exrail is None:
|
|
139
|
+
raise RuntimeError("connection not open")
|
|
140
|
+
return self._exrail
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _build_port(settings: dict[str, Any]) -> BytePort:
|
|
144
|
+
kind = settings.get("kind", "loopback")
|
|
145
|
+
if kind == "serial":
|
|
146
|
+
port = settings.get("port")
|
|
147
|
+
if not port:
|
|
148
|
+
raise ValueError("serial transport requires 'port'")
|
|
149
|
+
baud = int(settings.get("baud", 115200))
|
|
150
|
+
return await SerialPort.open(port=port, baud=baud)
|
|
151
|
+
if kind == "tcp":
|
|
152
|
+
host = settings.get("host")
|
|
153
|
+
tcp_port = settings.get("tcp_port")
|
|
154
|
+
if not host or tcp_port is None:
|
|
155
|
+
raise ValueError("tcp transport requires 'host' and 'tcp_port'")
|
|
156
|
+
return await TcpPort.open(host=host, port=int(tcp_port))
|
|
157
|
+
if kind == "loopback":
|
|
158
|
+
lb = settings.get("loopback_port")
|
|
159
|
+
if not isinstance(lb, LoopbackPort):
|
|
160
|
+
raise ValueError("loopback transport requires 'loopback_port'")
|
|
161
|
+
return lb
|
|
162
|
+
raise ValueError(f"unknown DCC-EX transport kind: {kind!r}")
|