jmri_nce.monitors

Polling tasks that turn NCE's request/response wire into push-style state-change signals.

NCE doesn't push state changes from the layout — clients must poll. This module owns two background tasks:

  • NceTurnoutMonitor reads the command-station's turnout-status memory in 16-byte chunks every ~1.5s, diffs against the previous snapshot, and emits state changes through any registered turnout.
  • NceAiuPoller reads each known AIU module's input word at a faster cadence (~250ms) to catch occupancy changes promptly.

Both tasks run inside the connection's lifetime — started in NceConnection.open() and cancelled in NceConnection.close().

  1"""Polling tasks that turn NCE's request/response wire into push-style
  2state-change signals.
  3
  4NCE doesn't push state changes from the layout — clients must poll. This
  5module owns two background tasks:
  6
  7* :class:`NceTurnoutMonitor` reads the command-station's turnout-status
  8  memory in 16-byte chunks every ~1.5s, diffs against the previous
  9  snapshot, and emits state changes through any registered turnout.
 10* :class:`NceAiuPoller` reads each known AIU module's input word at a
 11  faster cadence (~250ms) to catch occupancy changes promptly.
 12
 13Both tasks run inside the connection's lifetime — started in
 14:meth:`NceConnection.open` and cancelled in :meth:`NceConnection.close`.
 15"""
 16from __future__ import annotations
 17
 18import asyncio
 19import logging
 20from typing import TYPE_CHECKING
 21
 22from jmri_core.state import SensorState, TurnoutState
 23
 24from jmri_nce.messages import read_accessory_memory, read_aiu
 25
 26if TYPE_CHECKING:
 27    from jmri_nce.beans import NceSensorManager, NceTurnoutManager
 28    from jmri_nce.command_set import NceCommandSet
 29    from jmri_nce.traffic import NceTrafficController
 30
 31_log = logging.getLogger(__name__)
 32
 33
 34class NceTurnoutMonitor:
 35    """Periodically poll the command-station turnout-status memory."""
 36
 37    POLL_INTERVAL_S = 1.5
 38    CHUNK_BYTES = 16          # 16 bytes × 8 turnouts/byte = 128 per chunk
 39
 40    def __init__(
 41        self,
 42        tc: NceTrafficController,
 43        mgr: NceTurnoutManager,
 44        cmd_set: NceCommandSet,
 45    ) -> None:
 46        self._tc = tc
 47        self._mgr = mgr
 48        self._cs = cmd_set
 49        # Pre-allocated snapshot buffer: the bytes we most recently read.
 50        self._snapshot = bytearray(cmd_set.turnout_table_bytes)
 51        self._stop = asyncio.Event()
 52
 53    async def run(self) -> None:
 54        """Run forever until stopped via :meth:`stop`."""
 55        import contextlib  # noqa: PLC0415
 56        try:
 57            while not self._stop.is_set():
 58                await self._poll_once()
 59                with contextlib.suppress(TimeoutError):
 60                    await asyncio.wait_for(
 61                        self._stop.wait(), timeout=self.POLL_INTERVAL_S
 62                    )
 63        except asyncio.CancelledError:
 64            return
 65
 66    def stop(self) -> None:
 67        self._stop.set()
 68
 69    async def _poll_once(self) -> None:
 70        for offset in range(0, self._cs.turnout_table_bytes, self.CHUNK_BYTES):
 71            addr = self._cs.turnout_memory_base + offset
 72            try:
 73                reply = await self._tc.send_command(read_accessory_memory(addr))
 74            except (TimeoutError, Exception) as e:  # noqa: BLE001
 75                _log.warning("NCE turnout-monitor poll failed at %#06x: %s", addr, e)
 76                return
 77            await self._diff_and_emit(offset, reply.data)
 78
 79    async def _diff_and_emit(self, offset: int, fresh: bytes) -> None:
 80        """Compare *fresh* against the snapshot at *offset*; emit per flipped bit."""
 81        old_chunk = self._snapshot[offset : offset + len(fresh)]
 82        for i, (old, new) in enumerate(zip(old_chunk, fresh, strict=False)):
 83            if old == new:
 84                continue
 85            changed = old ^ new
 86            for bit in range(8):
 87                if not (changed & (1 << bit)):
 88                    continue
 89                address = (offset + i) * 8 + bit + 1
 90                turnout = self._mgr.get_by_address(address)
 91                if turnout is None:
 92                    continue
 93                state = TurnoutState.CLOSED if (new & (1 << bit)) else TurnoutState.THROWN
 94                await turnout._set_known_state(state)
 95        self._snapshot[offset : offset + len(fresh)] = fresh
 96
 97
 98class NceAiuPoller:
 99    """Periodically poll the configured AIU modules and emit sensor state."""
100
101    POLL_INTERVAL_S = 0.25
102
103    def __init__(
104        self,
105        tc: NceTrafficController,
106        mgr: NceSensorManager,
107        aiu_addresses: list[int],
108    ) -> None:
109        self._tc = tc
110        self._mgr = mgr
111        self._aius = list(aiu_addresses)
112        self._snapshots: dict[int, int] = {}  # AIU address → last raw 16-bit word
113        self._stop = asyncio.Event()
114
115    async def run(self) -> None:
116        import contextlib  # noqa: PLC0415
117        try:
118            while not self._stop.is_set():
119                for aiu in self._aius:
120                    await self._poll_one(aiu)
121                with contextlib.suppress(TimeoutError):
122                    await asyncio.wait_for(
123                        self._stop.wait(), timeout=self.POLL_INTERVAL_S
124                    )
125        except asyncio.CancelledError:
126            return
127
128    def stop(self) -> None:
129        self._stop.set()
130
131    async def _poll_one(self, aiu: int) -> None:
132        try:
133            reply = await self._tc.send_command(read_aiu(aiu))
134        except (TimeoutError, Exception) as e:  # noqa: BLE001
135            _log.warning("NCE AIU-poll failed for %d: %s", aiu, e)
136            return
137        if len(reply.data) < 2:
138            return
139        word = (reply.data[0] << 8) | reply.data[1]
140        prev = self._snapshots.get(aiu, ~word & 0xFFFF)  # force diff first time
141        changed = prev ^ word
142        for bit in range(14):                 # 14 inputs per AIU
143            if not (changed & (1 << bit)):
144                continue
145            sensor_address = (aiu - 1) * 14 + bit + 1
146            sensor = self._mgr.get_by_address(sensor_address)
147            if sensor is None:
148                continue
149            state = SensorState.ACTIVE if (word & (1 << bit)) else SensorState.INACTIVE
150            await sensor._set_known_state(state)
151        self._snapshots[aiu] = word
class NceTurnoutMonitor:
35class NceTurnoutMonitor:
36    """Periodically poll the command-station turnout-status memory."""
37
38    POLL_INTERVAL_S = 1.5
39    CHUNK_BYTES = 16          # 16 bytes × 8 turnouts/byte = 128 per chunk
40
41    def __init__(
42        self,
43        tc: NceTrafficController,
44        mgr: NceTurnoutManager,
45        cmd_set: NceCommandSet,
46    ) -> None:
47        self._tc = tc
48        self._mgr = mgr
49        self._cs = cmd_set
50        # Pre-allocated snapshot buffer: the bytes we most recently read.
51        self._snapshot = bytearray(cmd_set.turnout_table_bytes)
52        self._stop = asyncio.Event()
53
54    async def run(self) -> None:
55        """Run forever until stopped via :meth:`stop`."""
56        import contextlib  # noqa: PLC0415
57        try:
58            while not self._stop.is_set():
59                await self._poll_once()
60                with contextlib.suppress(TimeoutError):
61                    await asyncio.wait_for(
62                        self._stop.wait(), timeout=self.POLL_INTERVAL_S
63                    )
64        except asyncio.CancelledError:
65            return
66
67    def stop(self) -> None:
68        self._stop.set()
69
70    async def _poll_once(self) -> None:
71        for offset in range(0, self._cs.turnout_table_bytes, self.CHUNK_BYTES):
72            addr = self._cs.turnout_memory_base + offset
73            try:
74                reply = await self._tc.send_command(read_accessory_memory(addr))
75            except (TimeoutError, Exception) as e:  # noqa: BLE001
76                _log.warning("NCE turnout-monitor poll failed at %#06x: %s", addr, e)
77                return
78            await self._diff_and_emit(offset, reply.data)
79
80    async def _diff_and_emit(self, offset: int, fresh: bytes) -> None:
81        """Compare *fresh* against the snapshot at *offset*; emit per flipped bit."""
82        old_chunk = self._snapshot[offset : offset + len(fresh)]
83        for i, (old, new) in enumerate(zip(old_chunk, fresh, strict=False)):
84            if old == new:
85                continue
86            changed = old ^ new
87            for bit in range(8):
88                if not (changed & (1 << bit)):
89                    continue
90                address = (offset + i) * 8 + bit + 1
91                turnout = self._mgr.get_by_address(address)
92                if turnout is None:
93                    continue
94                state = TurnoutState.CLOSED if (new & (1 << bit)) else TurnoutState.THROWN
95                await turnout._set_known_state(state)
96        self._snapshot[offset : offset + len(fresh)] = fresh

Periodically poll the command-station turnout-status memory.

NceTurnoutMonitor( tc: jmri_nce.NceTrafficController, mgr: jmri_nce.NceTurnoutManager, cmd_set: jmri_nce.NceCommandSet)
41    def __init__(
42        self,
43        tc: NceTrafficController,
44        mgr: NceTurnoutManager,
45        cmd_set: NceCommandSet,
46    ) -> None:
47        self._tc = tc
48        self._mgr = mgr
49        self._cs = cmd_set
50        # Pre-allocated snapshot buffer: the bytes we most recently read.
51        self._snapshot = bytearray(cmd_set.turnout_table_bytes)
52        self._stop = asyncio.Event()
POLL_INTERVAL_S = 1.5
CHUNK_BYTES = 16
async def run(self) -> None:
54    async def run(self) -> None:
55        """Run forever until stopped via :meth:`stop`."""
56        import contextlib  # noqa: PLC0415
57        try:
58            while not self._stop.is_set():
59                await self._poll_once()
60                with contextlib.suppress(TimeoutError):
61                    await asyncio.wait_for(
62                        self._stop.wait(), timeout=self.POLL_INTERVAL_S
63                    )
64        except asyncio.CancelledError:
65            return

Run forever until stopped via stop().

def stop(self) -> None:
67    def stop(self) -> None:
68        self._stop.set()
class NceAiuPoller:
 99class NceAiuPoller:
100    """Periodically poll the configured AIU modules and emit sensor state."""
101
102    POLL_INTERVAL_S = 0.25
103
104    def __init__(
105        self,
106        tc: NceTrafficController,
107        mgr: NceSensorManager,
108        aiu_addresses: list[int],
109    ) -> None:
110        self._tc = tc
111        self._mgr = mgr
112        self._aius = list(aiu_addresses)
113        self._snapshots: dict[int, int] = {}  # AIU address → last raw 16-bit word
114        self._stop = asyncio.Event()
115
116    async def run(self) -> None:
117        import contextlib  # noqa: PLC0415
118        try:
119            while not self._stop.is_set():
120                for aiu in self._aius:
121                    await self._poll_one(aiu)
122                with contextlib.suppress(TimeoutError):
123                    await asyncio.wait_for(
124                        self._stop.wait(), timeout=self.POLL_INTERVAL_S
125                    )
126        except asyncio.CancelledError:
127            return
128
129    def stop(self) -> None:
130        self._stop.set()
131
132    async def _poll_one(self, aiu: int) -> None:
133        try:
134            reply = await self._tc.send_command(read_aiu(aiu))
135        except (TimeoutError, Exception) as e:  # noqa: BLE001
136            _log.warning("NCE AIU-poll failed for %d: %s", aiu, e)
137            return
138        if len(reply.data) < 2:
139            return
140        word = (reply.data[0] << 8) | reply.data[1]
141        prev = self._snapshots.get(aiu, ~word & 0xFFFF)  # force diff first time
142        changed = prev ^ word
143        for bit in range(14):                 # 14 inputs per AIU
144            if not (changed & (1 << bit)):
145                continue
146            sensor_address = (aiu - 1) * 14 + bit + 1
147            sensor = self._mgr.get_by_address(sensor_address)
148            if sensor is None:
149                continue
150            state = SensorState.ACTIVE if (word & (1 << bit)) else SensorState.INACTIVE
151            await sensor._set_known_state(state)
152        self._snapshots[aiu] = word

Periodically poll the configured AIU modules and emit sensor state.

NceAiuPoller( tc: jmri_nce.NceTrafficController, mgr: jmri_nce.NceSensorManager, aiu_addresses: list[int])
104    def __init__(
105        self,
106        tc: NceTrafficController,
107        mgr: NceSensorManager,
108        aiu_addresses: list[int],
109    ) -> None:
110        self._tc = tc
111        self._mgr = mgr
112        self._aius = list(aiu_addresses)
113        self._snapshots: dict[int, int] = {}  # AIU address → last raw 16-bit word
114        self._stop = asyncio.Event()
POLL_INTERVAL_S = 0.25
async def run(self) -> None:
116    async def run(self) -> None:
117        import contextlib  # noqa: PLC0415
118        try:
119            while not self._stop.is_set():
120                for aiu in self._aius:
121                    await self._poll_one(aiu)
122                with contextlib.suppress(TimeoutError):
123                    await asyncio.wait_for(
124                        self._stop.wait(), timeout=self.POLL_INTERVAL_S
125                    )
126        except asyncio.CancelledError:
127            return
def stop(self) -> None:
129    def stop(self) -> None:
130        self._stop.set()