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:

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
PASSIVE_REASON = 'NCE Cab Bus monitor is passive — commands are not supported'
class PassiveSensor(jmri_core.beans.sensor.Sensor):
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.

PassiveSensor(system_name: str, address: int)
48    def __init__(self, system_name: str, address: int) -> None:
49        super().__init__(system_name)
50        self._address = address
address: int
52    @property
53    def address(self) -> int:
54        return self._address
class PassiveSensorManager(jmri_core.manager.Manager[jmri_ncemonitor.beans.PassiveSensor]):
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.

PassiveSensorManager(system_prefix: str, monitor: jmri_ncemonitor.CabBusMonitor)
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)
type_letter = 'S'
def get_by_address(self, address: int) -> PassiveSensor | None:
73    def get_by_address(self, address: int) -> PassiveSensor | None:
74        return self._by_address.get(address)
class PassiveTurnout(jmri_core.beans.turnout.Turnout):
 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.

PassiveTurnout(system_name: str, address: int)
100    def __init__(self, system_name: str, address: int) -> None:
101        super().__init__(system_name)
102        self._address = address
address: int
104    @property
105    def address(self) -> int:
106        return self._address
async def set_commanded_state(self, state: jmri_core.state.TurnoutState) -> None:
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().

class PassiveTurnoutManager(jmri_core.manager.Manager[jmri_ncemonitor.beans.PassiveTurnout]):
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.

PassiveTurnoutManager(system_prefix: str, monitor: jmri_ncemonitor.CabBusMonitor)
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)
type_letter = 'T'
def get_by_address(self, address: int) -> PassiveTurnout | None:
128    def get_by_address(self, address: int) -> PassiveTurnout | None:
129        return self._by_address.get(address)
class PassivePowerManager(jmri_core.beans.power.PowerManager):
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.

async def set_power(self, state: jmri_core.state.PowerState) -> None:
147    async def set_power(self, state: PowerState) -> None:
148        raise RuntimeError(PASSIVE_REASON)

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