jmri_ncemonitor.beans
Read-only NamedBean views for the passive Cab Bus monitor.
The monitor doesn't control the layout — it only observes it. The beans this module exposes follow that constraint:
PassiveSensor— same shape as a normal Sensor but only the AIU poller (via~jmri_ncemonitor.monitor.CabBusMonitor) updates its state.PassiveTurnout— set_commanded_state raises; the monitor sets known state from accessory traffic it observes on the bus.PassivePowerManager— set_power raises; power state is observed-only.
All three demonstrate that a fully-passive driver fits inside the
existing SystemConnection ABC without requiring API changes —
clients are free to query state but commands fail loudly.
1"""Read-only NamedBean views for the passive Cab Bus monitor. 2 3The monitor doesn't *control* the layout — it only *observes* it. The 4beans this module exposes follow that constraint: 5 6* :class:`PassiveSensor` — same shape as a normal Sensor but only the 7 AIU poller (via :class:`~jmri_ncemonitor.monitor.CabBusMonitor`) 8 updates its state. 9* :class:`PassiveTurnout` — set_commanded_state raises; the monitor sets 10 *known* state from accessory traffic it observes on the bus. 11* :class:`PassivePowerManager` — set_power raises; power state is 12 observed-only. 13 14All three demonstrate that a fully-passive driver fits inside the 15existing :class:`SystemConnection` ABC without requiring API changes — 16clients are free to query state but commands fail loudly. 17""" 18from __future__ import annotations 19 20from jmri_core.beans.power import PowerManager 21from jmri_core.beans.sensor import Sensor 22from jmri_core.beans.turnout import Turnout 23from jmri_core.manager import Manager 24from jmri_core.state import PowerState, SensorState, TurnoutState 25 26from jmri_ncemonitor.messages import ( 27 AccessoryObserved, 28 AiuUpdate, 29 CabBusMessage, 30) 31from jmri_ncemonitor.monitor import CabBusMonitor 32 33# --- helper ---------------------------------------------------------------- 34 35 36PASSIVE_REASON = "NCE Cab Bus monitor is passive — commands are not supported" 37 38 39# --- Sensor ---------------------------------------------------------------- 40 41 42class PassiveSensor(Sensor): 43 """Read-only sensor — its known state is observed off the bus.""" 44 45 __slots__ = ("_address",) 46 47 def __init__(self, system_name: str, address: int) -> None: 48 super().__init__(system_name) 49 self._address = address 50 51 @property 52 def address(self) -> int: 53 return self._address 54 55 56class PassiveSensorManager(Manager[PassiveSensor]): 57 """Sensor manager driven by observed :class:`AiuUpdate` events.""" 58 59 type_letter = "S" 60 61 def __init__(self, system_prefix: str, monitor: CabBusMonitor) -> None: 62 super().__init__(system_prefix) 63 self._by_address: dict[int, PassiveSensor] = {} 64 self._unsub = monitor.inbound.connect(self._on_message) 65 66 def _create(self, system_name: str) -> PassiveSensor: 67 address = _parse_address(system_name, self.full_prefix) 68 bean = PassiveSensor(system_name, address) 69 self._by_address[address] = bean 70 return bean 71 72 def get_by_address(self, address: int) -> PassiveSensor | None: 73 return self._by_address.get(address) 74 75 async def _on_message(self, msg: CabBusMessage) -> None: 76 if not isinstance(msg, AiuUpdate): 77 return 78 for bit in range(14): 79 sensor_addr = (msg.aiu_module - 1) * 14 + bit + 1 80 sensor = self._by_address.get(sensor_addr) 81 if sensor is None: 82 continue 83 state = ( 84 SensorState.ACTIVE 85 if (msg.input_word & (1 << bit)) 86 else SensorState.INACTIVE 87 ) 88 await sensor._set_known_state(state) 89 90 91# --- Turnout --------------------------------------------------------------- 92 93 94class PassiveTurnout(Turnout): 95 """Read-only turnout — commanded state cannot be set from this side.""" 96 97 __slots__ = ("_address",) 98 99 def __init__(self, system_name: str, address: int) -> None: 100 super().__init__(system_name) 101 self._address = address 102 103 @property 104 def address(self) -> int: 105 return self._address 106 107 async def set_commanded_state(self, state: TurnoutState) -> None: 108 raise RuntimeError(PASSIVE_REASON) 109 110 111class PassiveTurnoutManager(Manager[PassiveTurnout]): 112 """Turnout manager driven by observed :class:`AccessoryObserved` events.""" 113 114 type_letter = "T" 115 116 def __init__(self, system_prefix: str, monitor: CabBusMonitor) -> None: 117 super().__init__(system_prefix) 118 self._by_address: dict[int, PassiveTurnout] = {} 119 self._unsub = monitor.inbound.connect(self._on_message) 120 121 def _create(self, system_name: str) -> PassiveTurnout: 122 address = _parse_address(system_name, self.full_prefix) 123 bean = PassiveTurnout(system_name, address) 124 self._by_address[address] = bean 125 return bean 126 127 def get_by_address(self, address: int) -> PassiveTurnout | None: 128 return self._by_address.get(address) 129 130 async def _on_message(self, msg: CabBusMessage) -> None: 131 if not isinstance(msg, AccessoryObserved): 132 return 133 bean = self._by_address.get(msg.accessory_address) 134 if bean is None: 135 return 136 state = TurnoutState.CLOSED if msg.closed else TurnoutState.THROWN 137 await bean._set_known_state(state) 138 139 140# --- Power ----------------------------------------------------------------- 141 142 143class PassivePowerManager(PowerManager): 144 """Power state is observed-only; ``set_power`` raises.""" 145 146 async def set_power(self, state: PowerState) -> None: 147 raise RuntimeError(PASSIVE_REASON) 148 149 150# --- helpers --------------------------------------------------------------- 151 152 153def _parse_address(system_name: str, full_prefix: str) -> int: 154 rest = system_name.removeprefix(full_prefix) 155 try: 156 return int(rest) 157 except ValueError as e: 158 raise ValueError( 159 f"NCE Cab Bus monitor system name {system_name!r} " 160 f"must end in a numeric address" 161 ) from e
43class PassiveSensor(Sensor): 44 """Read-only sensor — its known state is observed off the bus.""" 45 46 __slots__ = ("_address",) 47 48 def __init__(self, system_name: str, address: int) -> None: 49 super().__init__(system_name) 50 self._address = address 51 52 @property 53 def address(self) -> int: 54 return self._address
Read-only sensor — its known state is observed off the bus.
57class PassiveSensorManager(Manager[PassiveSensor]): 58 """Sensor manager driven by observed :class:`AiuUpdate` events.""" 59 60 type_letter = "S" 61 62 def __init__(self, system_prefix: str, monitor: CabBusMonitor) -> None: 63 super().__init__(system_prefix) 64 self._by_address: dict[int, PassiveSensor] = {} 65 self._unsub = monitor.inbound.connect(self._on_message) 66 67 def _create(self, system_name: str) -> PassiveSensor: 68 address = _parse_address(system_name, self.full_prefix) 69 bean = PassiveSensor(system_name, address) 70 self._by_address[address] = bean 71 return bean 72 73 def get_by_address(self, address: int) -> PassiveSensor | None: 74 return self._by_address.get(address) 75 76 async def _on_message(self, msg: CabBusMessage) -> None: 77 if not isinstance(msg, AiuUpdate): 78 return 79 for bit in range(14): 80 sensor_addr = (msg.aiu_module - 1) * 14 + bit + 1 81 sensor = self._by_address.get(sensor_addr) 82 if sensor is None: 83 continue 84 state = ( 85 SensorState.ACTIVE 86 if (msg.input_word & (1 << bit)) 87 else SensorState.INACTIVE 88 ) 89 await sensor._set_known_state(state)
Sensor manager driven by observed AiuUpdate events.
95class PassiveTurnout(Turnout): 96 """Read-only turnout — commanded state cannot be set from this side.""" 97 98 __slots__ = ("_address",) 99 100 def __init__(self, system_name: str, address: int) -> None: 101 super().__init__(system_name) 102 self._address = address 103 104 @property 105 def address(self) -> int: 106 return self._address 107 108 async def set_commanded_state(self, state: TurnoutState) -> None: 109 raise RuntimeError(PASSIVE_REASON)
Read-only turnout — commanded state cannot be set from this side.
108 async def set_commanded_state(self, state: TurnoutState) -> None: 109 raise RuntimeError(PASSIVE_REASON)
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().
112class PassiveTurnoutManager(Manager[PassiveTurnout]): 113 """Turnout manager driven by observed :class:`AccessoryObserved` events.""" 114 115 type_letter = "T" 116 117 def __init__(self, system_prefix: str, monitor: CabBusMonitor) -> None: 118 super().__init__(system_prefix) 119 self._by_address: dict[int, PassiveTurnout] = {} 120 self._unsub = monitor.inbound.connect(self._on_message) 121 122 def _create(self, system_name: str) -> PassiveTurnout: 123 address = _parse_address(system_name, self.full_prefix) 124 bean = PassiveTurnout(system_name, address) 125 self._by_address[address] = bean 126 return bean 127 128 def get_by_address(self, address: int) -> PassiveTurnout | None: 129 return self._by_address.get(address) 130 131 async def _on_message(self, msg: CabBusMessage) -> None: 132 if not isinstance(msg, AccessoryObserved): 133 return 134 bean = self._by_address.get(msg.accessory_address) 135 if bean is None: 136 return 137 state = TurnoutState.CLOSED if msg.closed else TurnoutState.THROWN 138 await bean._set_known_state(state)
Turnout manager driven by observed AccessoryObserved events.
144class PassivePowerManager(PowerManager): 145 """Power state is observed-only; ``set_power`` raises.""" 146 147 async def set_power(self, state: PowerState) -> None: 148 raise RuntimeError(PASSIVE_REASON)
Power state is observed-only; set_power raises.