jmri_dccex.beans

DCC-EX-aware beans + their managers + power manager.

Wire-protocol summary (CommandStation-EX docs):

  • Turnouts: <T id state> where state ∈ {0,1}. Response: <H id state>.
  • Sensors: <Q id> = active, <q id> = inactive (server → client).
  • Power: <1> = on, <0> = off. Response: <p state>.
  1"""DCC-EX-aware beans + their managers + power manager.
  2
  3Wire-protocol summary (CommandStation-EX docs):
  4
  5* Turnouts: ``<T id state>`` where state ∈ {0,1}. Response: ``<H id state>``.
  6* Sensors:  ``<Q id>`` = active, ``<q id>`` = inactive (server → client).
  7* Power:    ``<1>`` = on, ``<0>`` = off. Response: ``<p state>``.
  8"""
  9from __future__ import annotations
 10
 11import contextlib
 12
 13from jmri_core.beans.power import PowerManager
 14from jmri_core.beans.sensor import Sensor
 15from jmri_core.beans.turnout import Turnout
 16from jmri_core.manager import Manager
 17from jmri_core.state import PowerState, SensorState, TurnoutState
 18
 19from jmri_dccex.codec import DccexMessage
 20from jmri_dccex.traffic import DccexTrafficController
 21
 22# ----- Turnout -------------------------------------------------------------
 23
 24
 25class DccexTurnout(Turnout):
 26    """Sends ``<T id state>`` on commanded-state change."""
 27
 28    __slots__ = ("_address", "_tc")
 29
 30    def __init__(self, system_name: str, address: int, tc: DccexTrafficController) -> None:
 31        super().__init__(system_name)
 32        self._address = address
 33        self._tc = tc
 34
 35    @property
 36    def address(self) -> int:
 37        return self._address
 38
 39    async def set_commanded_state(self, state: TurnoutState) -> None:
 40        wire = 1 if state == TurnoutState.CLOSED else 0
 41        await self._tc.send(DccexMessage(command="T", args=(str(self._address), str(wire))))
 42        await super().set_commanded_state(state)
 43
 44
 45class DccexTurnoutManager(Manager[DccexTurnout]):
 46    type_letter = "T"
 47
 48    def __init__(self, system_prefix: str, *, tc: DccexTrafficController) -> None:
 49        super().__init__(system_prefix)
 50        self._tc = tc
 51        self._by_address: dict[int, DccexTurnout] = {}
 52        self._unsub = tc.inbound.connect(self._on_message)
 53
 54    def _create(self, system_name: str) -> DccexTurnout:
 55        address = _parse_address(system_name, self.full_prefix)
 56        bean = DccexTurnout(system_name, address, self._tc)
 57        self._by_address[address] = bean
 58        return bean
 59
 60    def get_by_address(self, address: int) -> DccexTurnout | None:
 61        return self._by_address.get(address)
 62
 63    async def _on_message(self, msg: DccexMessage) -> None:
 64        # <H id state> — turnout state echo from CS.
 65        if msg.command != "H" or len(msg.args) < 2:
 66            return
 67        try:
 68            addr = int(msg.args[0])
 69            state_int = int(msg.args[1])
 70        except ValueError:
 71            return
 72        bean = self._by_address.get(addr)
 73        if bean is None:
 74            return
 75        state = TurnoutState.CLOSED if state_int else TurnoutState.THROWN
 76        await bean._set_known_state(state)
 77
 78
 79# ----- Sensor --------------------------------------------------------------
 80
 81
 82class DccexSensor(Sensor):
 83    __slots__ = ("_address",)
 84
 85    def __init__(self, system_name: str, address: int) -> None:
 86        super().__init__(system_name)
 87        self._address = address
 88
 89    @property
 90    def address(self) -> int:
 91        return self._address
 92
 93
 94class DccexSensorManager(Manager[DccexSensor]):
 95    type_letter = "S"
 96
 97    def __init__(self, system_prefix: str, *, tc: DccexTrafficController) -> None:
 98        super().__init__(system_prefix)
 99        self._by_address: dict[int, DccexSensor] = {}
100        self._unsub = tc.inbound.connect(self._on_message)
101
102    def _create(self, system_name: str) -> DccexSensor:
103        address = _parse_address(system_name, self.full_prefix)
104        bean = DccexSensor(system_name, address)
105        self._by_address[address] = bean
106        return bean
107
108    def get_by_address(self, address: int) -> DccexSensor | None:
109        return self._by_address.get(address)
110
111    async def _on_message(self, msg: DccexMessage) -> None:
112        # <Q id> active, <q id> inactive.
113        if msg.command not in ("Q", "q") or not msg.args:
114            return
115        with contextlib.suppress(ValueError):
116            addr = int(msg.args[0])
117            bean = self._by_address.get(addr)
118            if bean is not None:
119                state = SensorState.ACTIVE if msg.command == "Q" else SensorState.INACTIVE
120                await bean._set_known_state(state)
121
122
123# ----- Power ---------------------------------------------------------------
124
125
126class DccexPowerManager(PowerManager):
127    """``<1>`` on, ``<0>`` off."""
128
129    def __init__(self, tc: DccexTrafficController) -> None:
130        super().__init__()
131        self._tc = tc
132        self._unsub = tc.inbound.connect(self._on_message)
133
134    async def set_power(self, state: PowerState) -> None:
135        cmd = "1" if state == PowerState.ON else "0"
136        await self._tc.send(DccexMessage(command=cmd, args=()))
137        await self._set_state(state)
138
139    async def _on_message(self, msg: DccexMessage) -> None:
140        # <p state> — power state echo. state=1 means on, 0 off.
141        if msg.command != "p" or not msg.args:
142            return
143        with contextlib.suppress(ValueError):
144            wire = int(msg.args[0])
145            await self._set_state(PowerState.ON if wire else PowerState.OFF)
146
147
148# ----- helpers --------------------------------------------------------------
149
150
151def _parse_address(system_name: str, full_prefix: str) -> int:
152    rest = system_name.removeprefix(full_prefix)
153    try:
154        return int(rest)
155    except ValueError as e:
156        raise ValueError(
157            f"DCC-EX system name {system_name!r} must end in a numeric address"
158        ) from e
class DccexTurnout(jmri_core.beans.turnout.Turnout):
26class DccexTurnout(Turnout):
27    """Sends ``<T id state>`` on commanded-state change."""
28
29    __slots__ = ("_address", "_tc")
30
31    def __init__(self, system_name: str, address: int, tc: DccexTrafficController) -> None:
32        super().__init__(system_name)
33        self._address = address
34        self._tc = tc
35
36    @property
37    def address(self) -> int:
38        return self._address
39
40    async def set_commanded_state(self, state: TurnoutState) -> None:
41        wire = 1 if state == TurnoutState.CLOSED else 0
42        await self._tc.send(DccexMessage(command="T", args=(str(self._address), str(wire))))
43        await super().set_commanded_state(state)

Sends <T id state> on commanded-state change.

DccexTurnout( system_name: str, address: int, tc: jmri_dccex.DccexTrafficController)
31    def __init__(self, system_name: str, address: int, tc: DccexTrafficController) -> None:
32        super().__init__(system_name)
33        self._address = address
34        self._tc = tc
address: int
36    @property
37    def address(self) -> int:
38        return self._address
async def set_commanded_state(self, state: jmri_core.state.TurnoutState) -> None:
40    async def set_commanded_state(self, state: TurnoutState) -> None:
41        wire = 1 if state == TurnoutState.CLOSED else 0
42        await self._tc.send(DccexMessage(command="T", args=(str(self._address), str(wire))))
43        await super().set_commanded_state(state)

Ask the layout to throw or close the switch.

The base implementation only updates the in-memory commanded state and emits the signal. Driver subclasses override this to push a hardware message and then call super().set_commanded_state().

class DccexTurnoutManager(jmri_core.manager.Manager[jmri_dccex.beans.DccexTurnout]):
46class DccexTurnoutManager(Manager[DccexTurnout]):
47    type_letter = "T"
48
49    def __init__(self, system_prefix: str, *, tc: DccexTrafficController) -> None:
50        super().__init__(system_prefix)
51        self._tc = tc
52        self._by_address: dict[int, DccexTurnout] = {}
53        self._unsub = tc.inbound.connect(self._on_message)
54
55    def _create(self, system_name: str) -> DccexTurnout:
56        address = _parse_address(system_name, self.full_prefix)
57        bean = DccexTurnout(system_name, address, self._tc)
58        self._by_address[address] = bean
59        return bean
60
61    def get_by_address(self, address: int) -> DccexTurnout | None:
62        return self._by_address.get(address)
63
64    async def _on_message(self, msg: DccexMessage) -> None:
65        # <H id state> — turnout state echo from CS.
66        if msg.command != "H" or len(msg.args) < 2:
67            return
68        try:
69            addr = int(msg.args[0])
70            state_int = int(msg.args[1])
71        except ValueError:
72            return
73        bean = self._by_address.get(addr)
74        if bean is None:
75            return
76        state = TurnoutState.CLOSED if state_int else TurnoutState.THROWN
77        await bean._set_known_state(state)

Per-type bean registry. Subclass and override _create().

Attributes set on the class describe the bean type:

  • type_letter — single uppercase letter the bean class owns (e.g. "T" for Turnout). Must be set by subclass.
  • system_prefix — connection prefix (e.g. "L"). Set per-instance because one bean class can have many connection-specific managers.
DccexTurnoutManager(system_prefix: str, *, tc: jmri_dccex.DccexTrafficController)
49    def __init__(self, system_prefix: str, *, tc: DccexTrafficController) -> None:
50        super().__init__(system_prefix)
51        self._tc = tc
52        self._by_address: dict[int, DccexTurnout] = {}
53        self._unsub = tc.inbound.connect(self._on_message)
type_letter = 'T'
def get_by_address(self, address: int) -> DccexTurnout | None:
61    def get_by_address(self, address: int) -> DccexTurnout | None:
62        return self._by_address.get(address)
class DccexSensor(jmri_core.beans.sensor.Sensor):
83class DccexSensor(Sensor):
84    __slots__ = ("_address",)
85
86    def __init__(self, system_name: str, address: int) -> None:
87        super().__init__(system_name)
88        self._address = address
89
90    @property
91    def address(self) -> int:
92        return self._address

Generic layout input. Drivers call _set_known_state() on receipt.

Sensors are write-only from the layout's perspective — the application doesn't command them, only observes. The inverted flag flips the semantic meaning of the raw hardware bit.

DccexSensor(system_name: str, address: int)
86    def __init__(self, system_name: str, address: int) -> None:
87        super().__init__(system_name)
88        self._address = address
address: int
90    @property
91    def address(self) -> int:
92        return self._address
class DccexSensorManager(jmri_core.manager.Manager[jmri_dccex.beans.DccexSensor]):
 95class DccexSensorManager(Manager[DccexSensor]):
 96    type_letter = "S"
 97
 98    def __init__(self, system_prefix: str, *, tc: DccexTrafficController) -> None:
 99        super().__init__(system_prefix)
100        self._by_address: dict[int, DccexSensor] = {}
101        self._unsub = tc.inbound.connect(self._on_message)
102
103    def _create(self, system_name: str) -> DccexSensor:
104        address = _parse_address(system_name, self.full_prefix)
105        bean = DccexSensor(system_name, address)
106        self._by_address[address] = bean
107        return bean
108
109    def get_by_address(self, address: int) -> DccexSensor | None:
110        return self._by_address.get(address)
111
112    async def _on_message(self, msg: DccexMessage) -> None:
113        # <Q id> active, <q id> inactive.
114        if msg.command not in ("Q", "q") or not msg.args:
115            return
116        with contextlib.suppress(ValueError):
117            addr = int(msg.args[0])
118            bean = self._by_address.get(addr)
119            if bean is not None:
120                state = SensorState.ACTIVE if msg.command == "Q" else SensorState.INACTIVE
121                await bean._set_known_state(state)

Per-type bean registry. Subclass and override _create().

Attributes set on the class describe the bean type:

  • type_letter — single uppercase letter the bean class owns (e.g. "T" for Turnout). Must be set by subclass.
  • system_prefix — connection prefix (e.g. "L"). Set per-instance because one bean class can have many connection-specific managers.
DccexSensorManager(system_prefix: str, *, tc: jmri_dccex.DccexTrafficController)
 98    def __init__(self, system_prefix: str, *, tc: DccexTrafficController) -> None:
 99        super().__init__(system_prefix)
100        self._by_address: dict[int, DccexSensor] = {}
101        self._unsub = tc.inbound.connect(self._on_message)
type_letter = 'S'
def get_by_address(self, address: int) -> DccexSensor | None:
109    def get_by_address(self, address: int) -> DccexSensor | None:
110        return self._by_address.get(address)
class DccexPowerManager(jmri_core.beans.power.PowerManager):
127class DccexPowerManager(PowerManager):
128    """``<1>`` on, ``<0>`` off."""
129
130    def __init__(self, tc: DccexTrafficController) -> None:
131        super().__init__()
132        self._tc = tc
133        self._unsub = tc.inbound.connect(self._on_message)
134
135    async def set_power(self, state: PowerState) -> None:
136        cmd = "1" if state == PowerState.ON else "0"
137        await self._tc.send(DccexMessage(command=cmd, args=()))
138        await self._set_state(state)
139
140    async def _on_message(self, msg: DccexMessage) -> None:
141        # <p state> — power state echo. state=1 means on, 0 off.
142        if msg.command != "p" or not msg.args:
143            return
144        with contextlib.suppress(ValueError):
145            wire = int(msg.args[0])
146            await self._set_state(PowerState.ON if wire else PowerState.OFF)

<1> on, <0> off.

DccexPowerManager(tc: jmri_dccex.DccexTrafficController)
130    def __init__(self, tc: DccexTrafficController) -> None:
131        super().__init__()
132        self._tc = tc
133        self._unsub = tc.inbound.connect(self._on_message)
async def set_power(self, state: jmri_core.state.PowerState) -> None:
135    async def set_power(self, state: PowerState) -> None:
136        cmd = "1" if state == PowerState.ON else "0"
137        await self._tc.send(DccexMessage(command=cmd, args=()))
138        await self._set_state(state)

Command the layout to state. Base impl just updates in-memory.