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 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)
@@ -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}")