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
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.
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().
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.
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.
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.
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.
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.