jmri_server.push

WebSocket push fan-out.

When a bean changes state, every connected WebSocket client should receive an unsolicited message with the bean's current data. PushManager wires this up by:

  1. Tracking the set of active WebSocket connections.
  2. Installing a listener on every bean's state_changed Signal — for both beans that already exist and beans created later (via the manager's added signal).
  3. On any change, formatting the bean through the appropriate service and broadcasting the JMRI envelope to every active client.
  1"""WebSocket push fan-out.
  2
  3When a bean changes state, every connected WebSocket client should receive an
  4unsolicited message with the bean's current data. :class:`PushManager` wires
  5this up by:
  6
  71. Tracking the set of active WebSocket connections.
  82. Installing a listener on every bean's ``state_changed`` Signal — for both
  9   beans that already exist and beans created later (via the manager's
 10   ``added`` signal).
 113. On any change, formatting the bean through the appropriate service and
 12   broadcasting the JMRI envelope to every active client.
 13"""
 14from __future__ import annotations
 15
 16import asyncio
 17import json
 18from collections.abc import Awaitable, Callable
 19from typing import TYPE_CHECKING, Any
 20
 21from jmri_core.bean import AbstractNamedBean
 22
 23if TYPE_CHECKING:
 24    from fastapi import WebSocket
 25
 26    from jmri_server.context import ServerContext
 27    from jmri_server.registry import ServiceRegistry
 28    from jmri_server.services.beans import _BeanService
 29
 30
 31class PushManager:
 32    """Owns the set of WebSocket connections and the listener hookups."""
 33
 34    def __init__(self, ctx: ServerContext, registry: ServiceRegistry) -> None:
 35        self._ctx = ctx
 36        self._registry = registry
 37        self._clients: set[WebSocket] = set()
 38        # disconnect callbacks for everything we've subscribed to:
 39        self._unsubscribes: list[Callable[[], None]] = []
 40        self._wired = False
 41
 42    # ---- WebSocket lifecycle ----------------------------------------------
 43
 44    async def attach(self, ws: WebSocket) -> None:
 45        """Register a newly accepted WebSocket. Wires up listeners on first attach."""
 46        if not self._wired:
 47            self._wire_managers()
 48            self._wired = True
 49        self._clients.add(ws)
 50
 51    async def detach(self, ws: WebSocket) -> None:
 52        self._clients.discard(ws)
 53
 54    async def broadcast(self, message: dict[str, Any]) -> None:
 55        """Send *message* to every active client. Dead sockets are dropped."""
 56        if not self._clients:
 57            return
 58        payload = json.dumps(message)
 59        dead: list[WebSocket] = []
 60        for ws in list(self._clients):
 61            try:
 62                await ws.send_text(payload)
 63            except Exception:  # noqa: BLE001 — socket dead
 64                dead.append(ws)
 65        for ws in dead:
 66            self._clients.discard(ws)
 67
 68    # ---- listener installation --------------------------------------------
 69
 70    def _wire_managers(self) -> None:
 71        """Subscribe to added/removed/state_changed for every bean manager."""
 72        bean_topics = (
 73            "turnout", "sensor", "light", "signalHead", "signalMast",
 74            "memory", "reporter", "block", "route",
 75        )
 76        for topic in bean_topics:
 77            svc = self._registry.get(topic)
 78            if svc is None:
 79                continue
 80            self._wire_bean_service(topic, svc)  # type: ignore[arg-type]
 81
 82        # Power is a singleton, not a Manager.
 83        power = self._registry.get("power")
 84        if power is not None:
 85            self._unsubscribes.append(
 86                self._ctx.connection.power.state_changed.connect(
 87                    self._make_power_listener(power)
 88                )
 89            )
 90
 91        # Command-station power. Drivers with heartbeat detection emit
 92        # ``command_station_power_changed`` when reachability flips;
 93        # broadcast so the /home CS badge updates live without polling.
 94        conn = self._ctx.connection
 95        cs_signal = getattr(conn, "command_station_power_changed", None)
 96        if cs_signal is not None:
 97            async def on_cs_change(on: bool) -> None:
 98                await self.broadcast({
 99                    "type": "commandStationPower",
100                    "data": {"on": bool(on)},
101                })
102            self._unsubscribes.append(cs_signal.connect(on_cs_change))
103
104        # Consist manager exposes ``created`` / ``updated`` / ``deleted``
105        # signals rather than the added/removed/state_changed trio, and
106        # its beans aren't NamedBeans — so wire it here explicitly.
107        # Broadcasts trigger the /consists page's htmx swap and any
108        # scripting client subscribed to the ``consist`` topic.
109        consist_svc = self._registry.get("consist")
110        if consist_svc is not None:
111            self._wire_consist_manager()
112
113        # Throttle manager exposes ``acquired`` / ``released`` and each
114        # ``Throttle`` has its own ``state_changed`` — Throttles are
115        # NOT NamedBeans so they don't fit ``_wire_bean_service``. Wire
116        # them explicitly so that ANY mutation (from the /throttle page,
117        # from the consist fan-out, from the WiThrottle bridge, or
118        # from a script) broadcasts to every open client. Without this,
119        # a consist Forward-button click updates the underlying
120        # throttle beans but the /throttle page for a member loco
121        # never sees the change.
122        throttle_svc = self._registry.get("throttle")
123        if throttle_svc is not None:
124            self._wire_throttle_manager()
125
126    def _wire_throttle_manager(self) -> None:
127        """Broadcast ``{type: "throttle", ...}`` on every throttle mutation.
128
129        Two-tier subscription:
130        * ``throttles.acquired`` fires when a new handle appears — we
131          install a per-throttle listener AND broadcast the initial
132          state so anyone watching sees the fresh throttle immediately.
133        * ``throttles.released`` clears the handle — broadcast a
134          release marker so watchers can drop their local mirror.
135        * Each ``Throttle.state_changed`` fans out any speed / direction
136          / function change through :func:`_project_throttle`.
137        """
138        from jmri_core.beans.throttle import Throttle  # noqa: PLC0415
139        from jmri_server.services.throttle import _project as _project_throttle  # noqa: PLC0415
140
141        throttles = self._ctx.connection.throttles
142
143        def _install_listener(throttle: Throttle) -> None:
144            async def on_change(_payload: Any) -> None:
145                await self.broadcast({
146                    "type": "throttle",
147                    "data": _project_throttle(throttle),
148                })
149            self._unsubscribes.append(throttle.state_changed.connect(on_change))
150
151        # Cover throttles already acquired before the first client
152        # connected (rare — normally acquire is client-driven — but
153        # possible if a script or the consist fan-out ran during startup).
154        for throttle in throttles:
155            _install_listener(throttle)
156
157        async def on_acquired(throttle: Throttle) -> None:
158            _install_listener(throttle)
159            await self.broadcast({
160                "type": "throttle",
161                "data": _project_throttle(throttle),
162            })
163
164        async def on_released(throttle: Throttle) -> None:
165            await self.broadcast({
166                "type": "throttle",
167                "data": _project_throttle(throttle, released=True),
168            })
169
170        self._unsubscribes.append(throttles.acquired.connect(on_acquired))
171        self._unsubscribes.append(throttles.released.connect(on_released))
172
173    def _wire_consist_manager(self) -> None:
174        """Broadcast ``{type: "consist", ...}`` on every create / update /
175        delete so the ``/consists`` page's htmx swap fires and any
176        scripting client subscribed to the ``consist`` topic is
177        notified without polling."""
178        from jmri_core.beans.consist import Consist  # noqa: PLC0415
179        from jmri_server.services.consist import _project  # noqa: PLC0415
180
181        cm = self._ctx.connection.consists
182
183        async def _emit(method: str, consist: Consist) -> None:
184            await self.broadcast({
185                "type": "consist", "method": method,
186                "data": _project(consist, cm),
187            })
188
189        async def on_created(c: Consist) -> None:
190            await _emit("put", c)
191
192        async def on_updated(c: Consist) -> None:
193            await _emit("post", c)
194
195        async def on_deleted(c: Consist) -> None:
196            await _emit("delete", c)
197
198        self._unsubscribes.append(cm.created.connect(on_created))
199        self._unsubscribes.append(cm.updated.connect(on_updated))
200        self._unsubscribes.append(cm.deleted.connect(on_deleted))
201
202    def _wire_bean_service(self, topic: str, svc: _BeanService) -> None:
203        manager = svc.manager()
204
205        # Subscribe state_changed on every bean already in the manager.
206        for bean in manager:
207            self._subscribe_bean(topic, svc, bean)
208
209        # And subscribe to future additions / removals on the manager itself.
210        self._unsubscribes.append(
211            manager.added.connect(self._make_added_listener(topic, svc))
212        )
213        self._unsubscribes.append(
214            manager.removed.connect(self._make_removed_listener(topic))
215        )
216
217    def _subscribe_bean(
218        self, topic: str, svc: _BeanService, bean: AbstractNamedBean
219    ) -> None:
220        listener = self._make_bean_listener(topic, svc, bean)
221        self._unsubscribes.append(bean.state_changed.connect(listener))
222
223    def _make_bean_listener(
224        self, topic: str, svc: _BeanService, bean: AbstractNamedBean
225    ) -> Callable[[Any], Awaitable[None]]:
226        async def listener(_payload: Any) -> None:
227            await self.broadcast({
228                "type": topic,
229                "data": svc.project(bean),
230            })
231        return listener
232
233    def _make_added_listener(
234        self, topic: str, svc: _BeanService
235    ) -> Callable[[AbstractNamedBean], Awaitable[None]]:
236        async def listener(bean: AbstractNamedBean) -> None:
237            self._subscribe_bean(topic, svc, bean)
238            await self.broadcast({
239                "type": topic,
240                "method": "put",
241                "data": svc.project(bean),
242            })
243        return listener
244
245    def _make_removed_listener(
246        self, topic: str
247    ) -> Callable[[AbstractNamedBean], Awaitable[None]]:
248        async def listener(bean: AbstractNamedBean) -> None:
249            await self.broadcast({
250                "type": topic,
251                "method": "delete",
252                "data": {"name": bean.system_name},
253            })
254        return listener
255
256    def _make_power_listener(
257        self, svc: JsonService
258    ) -> Callable[[Any], Awaitable[None]]:
259        async def listener(_payload: Any) -> None:
260            data = await svc.on_get(None)
261            await self.broadcast({"type": "power", "data": data})
262        return listener
263
264    # ---- shutdown ----------------------------------------------------------
265
266    async def aclose(self) -> None:
267        for unsub in self._unsubscribes:
268            unsub()
269        self._unsubscribes.clear()
270        # Best-effort: close any lingering sockets.
271        import contextlib  # noqa: PLC0415
272        for ws in list(self._clients):
273            with contextlib.suppress(Exception):
274                await ws.close()
275        self._clients.clear()
276
277
278# Forward import for type checker on Python 3.12.
279from jmri_server.service import JsonService  # noqa: E402, I001
280
281asyncio  # re-export marker — module name kept available for tests  # noqa: B018
class PushManager:
 32class PushManager:
 33    """Owns the set of WebSocket connections and the listener hookups."""
 34
 35    def __init__(self, ctx: ServerContext, registry: ServiceRegistry) -> None:
 36        self._ctx = ctx
 37        self._registry = registry
 38        self._clients: set[WebSocket] = set()
 39        # disconnect callbacks for everything we've subscribed to:
 40        self._unsubscribes: list[Callable[[], None]] = []
 41        self._wired = False
 42
 43    # ---- WebSocket lifecycle ----------------------------------------------
 44
 45    async def attach(self, ws: WebSocket) -> None:
 46        """Register a newly accepted WebSocket. Wires up listeners on first attach."""
 47        if not self._wired:
 48            self._wire_managers()
 49            self._wired = True
 50        self._clients.add(ws)
 51
 52    async def detach(self, ws: WebSocket) -> None:
 53        self._clients.discard(ws)
 54
 55    async def broadcast(self, message: dict[str, Any]) -> None:
 56        """Send *message* to every active client. Dead sockets are dropped."""
 57        if not self._clients:
 58            return
 59        payload = json.dumps(message)
 60        dead: list[WebSocket] = []
 61        for ws in list(self._clients):
 62            try:
 63                await ws.send_text(payload)
 64            except Exception:  # noqa: BLE001 — socket dead
 65                dead.append(ws)
 66        for ws in dead:
 67            self._clients.discard(ws)
 68
 69    # ---- listener installation --------------------------------------------
 70
 71    def _wire_managers(self) -> None:
 72        """Subscribe to added/removed/state_changed for every bean manager."""
 73        bean_topics = (
 74            "turnout", "sensor", "light", "signalHead", "signalMast",
 75            "memory", "reporter", "block", "route",
 76        )
 77        for topic in bean_topics:
 78            svc = self._registry.get(topic)
 79            if svc is None:
 80                continue
 81            self._wire_bean_service(topic, svc)  # type: ignore[arg-type]
 82
 83        # Power is a singleton, not a Manager.
 84        power = self._registry.get("power")
 85        if power is not None:
 86            self._unsubscribes.append(
 87                self._ctx.connection.power.state_changed.connect(
 88                    self._make_power_listener(power)
 89                )
 90            )
 91
 92        # Command-station power. Drivers with heartbeat detection emit
 93        # ``command_station_power_changed`` when reachability flips;
 94        # broadcast so the /home CS badge updates live without polling.
 95        conn = self._ctx.connection
 96        cs_signal = getattr(conn, "command_station_power_changed", None)
 97        if cs_signal is not None:
 98            async def on_cs_change(on: bool) -> None:
 99                await self.broadcast({
100                    "type": "commandStationPower",
101                    "data": {"on": bool(on)},
102                })
103            self._unsubscribes.append(cs_signal.connect(on_cs_change))
104
105        # Consist manager exposes ``created`` / ``updated`` / ``deleted``
106        # signals rather than the added/removed/state_changed trio, and
107        # its beans aren't NamedBeans — so wire it here explicitly.
108        # Broadcasts trigger the /consists page's htmx swap and any
109        # scripting client subscribed to the ``consist`` topic.
110        consist_svc = self._registry.get("consist")
111        if consist_svc is not None:
112            self._wire_consist_manager()
113
114        # Throttle manager exposes ``acquired`` / ``released`` and each
115        # ``Throttle`` has its own ``state_changed`` — Throttles are
116        # NOT NamedBeans so they don't fit ``_wire_bean_service``. Wire
117        # them explicitly so that ANY mutation (from the /throttle page,
118        # from the consist fan-out, from the WiThrottle bridge, or
119        # from a script) broadcasts to every open client. Without this,
120        # a consist Forward-button click updates the underlying
121        # throttle beans but the /throttle page for a member loco
122        # never sees the change.
123        throttle_svc = self._registry.get("throttle")
124        if throttle_svc is not None:
125            self._wire_throttle_manager()
126
127    def _wire_throttle_manager(self) -> None:
128        """Broadcast ``{type: "throttle", ...}`` on every throttle mutation.
129
130        Two-tier subscription:
131        * ``throttles.acquired`` fires when a new handle appears — we
132          install a per-throttle listener AND broadcast the initial
133          state so anyone watching sees the fresh throttle immediately.
134        * ``throttles.released`` clears the handle — broadcast a
135          release marker so watchers can drop their local mirror.
136        * Each ``Throttle.state_changed`` fans out any speed / direction
137          / function change through :func:`_project_throttle`.
138        """
139        from jmri_core.beans.throttle import Throttle  # noqa: PLC0415
140        from jmri_server.services.throttle import _project as _project_throttle  # noqa: PLC0415
141
142        throttles = self._ctx.connection.throttles
143
144        def _install_listener(throttle: Throttle) -> None:
145            async def on_change(_payload: Any) -> None:
146                await self.broadcast({
147                    "type": "throttle",
148                    "data": _project_throttle(throttle),
149                })
150            self._unsubscribes.append(throttle.state_changed.connect(on_change))
151
152        # Cover throttles already acquired before the first client
153        # connected (rare — normally acquire is client-driven — but
154        # possible if a script or the consist fan-out ran during startup).
155        for throttle in throttles:
156            _install_listener(throttle)
157
158        async def on_acquired(throttle: Throttle) -> None:
159            _install_listener(throttle)
160            await self.broadcast({
161                "type": "throttle",
162                "data": _project_throttle(throttle),
163            })
164
165        async def on_released(throttle: Throttle) -> None:
166            await self.broadcast({
167                "type": "throttle",
168                "data": _project_throttle(throttle, released=True),
169            })
170
171        self._unsubscribes.append(throttles.acquired.connect(on_acquired))
172        self._unsubscribes.append(throttles.released.connect(on_released))
173
174    def _wire_consist_manager(self) -> None:
175        """Broadcast ``{type: "consist", ...}`` on every create / update /
176        delete so the ``/consists`` page's htmx swap fires and any
177        scripting client subscribed to the ``consist`` topic is
178        notified without polling."""
179        from jmri_core.beans.consist import Consist  # noqa: PLC0415
180        from jmri_server.services.consist import _project  # noqa: PLC0415
181
182        cm = self._ctx.connection.consists
183
184        async def _emit(method: str, consist: Consist) -> None:
185            await self.broadcast({
186                "type": "consist", "method": method,
187                "data": _project(consist, cm),
188            })
189
190        async def on_created(c: Consist) -> None:
191            await _emit("put", c)
192
193        async def on_updated(c: Consist) -> None:
194            await _emit("post", c)
195
196        async def on_deleted(c: Consist) -> None:
197            await _emit("delete", c)
198
199        self._unsubscribes.append(cm.created.connect(on_created))
200        self._unsubscribes.append(cm.updated.connect(on_updated))
201        self._unsubscribes.append(cm.deleted.connect(on_deleted))
202
203    def _wire_bean_service(self, topic: str, svc: _BeanService) -> None:
204        manager = svc.manager()
205
206        # Subscribe state_changed on every bean already in the manager.
207        for bean in manager:
208            self._subscribe_bean(topic, svc, bean)
209
210        # And subscribe to future additions / removals on the manager itself.
211        self._unsubscribes.append(
212            manager.added.connect(self._make_added_listener(topic, svc))
213        )
214        self._unsubscribes.append(
215            manager.removed.connect(self._make_removed_listener(topic))
216        )
217
218    def _subscribe_bean(
219        self, topic: str, svc: _BeanService, bean: AbstractNamedBean
220    ) -> None:
221        listener = self._make_bean_listener(topic, svc, bean)
222        self._unsubscribes.append(bean.state_changed.connect(listener))
223
224    def _make_bean_listener(
225        self, topic: str, svc: _BeanService, bean: AbstractNamedBean
226    ) -> Callable[[Any], Awaitable[None]]:
227        async def listener(_payload: Any) -> None:
228            await self.broadcast({
229                "type": topic,
230                "data": svc.project(bean),
231            })
232        return listener
233
234    def _make_added_listener(
235        self, topic: str, svc: _BeanService
236    ) -> Callable[[AbstractNamedBean], Awaitable[None]]:
237        async def listener(bean: AbstractNamedBean) -> None:
238            self._subscribe_bean(topic, svc, bean)
239            await self.broadcast({
240                "type": topic,
241                "method": "put",
242                "data": svc.project(bean),
243            })
244        return listener
245
246    def _make_removed_listener(
247        self, topic: str
248    ) -> Callable[[AbstractNamedBean], Awaitable[None]]:
249        async def listener(bean: AbstractNamedBean) -> None:
250            await self.broadcast({
251                "type": topic,
252                "method": "delete",
253                "data": {"name": bean.system_name},
254            })
255        return listener
256
257    def _make_power_listener(
258        self, svc: JsonService
259    ) -> Callable[[Any], Awaitable[None]]:
260        async def listener(_payload: Any) -> None:
261            data = await svc.on_get(None)
262            await self.broadcast({"type": "power", "data": data})
263        return listener
264
265    # ---- shutdown ----------------------------------------------------------
266
267    async def aclose(self) -> None:
268        for unsub in self._unsubscribes:
269            unsub()
270        self._unsubscribes.clear()
271        # Best-effort: close any lingering sockets.
272        import contextlib  # noqa: PLC0415
273        for ws in list(self._clients):
274            with contextlib.suppress(Exception):
275                await ws.close()
276        self._clients.clear()

Owns the set of WebSocket connections and the listener hookups.

35    def __init__(self, ctx: ServerContext, registry: ServiceRegistry) -> None:
36        self._ctx = ctx
37        self._registry = registry
38        self._clients: set[WebSocket] = set()
39        # disconnect callbacks for everything we've subscribed to:
40        self._unsubscribes: list[Callable[[], None]] = []
41        self._wired = False
async def attach(self, ws: starlette.websockets.WebSocket) -> None:
45    async def attach(self, ws: WebSocket) -> None:
46        """Register a newly accepted WebSocket. Wires up listeners on first attach."""
47        if not self._wired:
48            self._wire_managers()
49            self._wired = True
50        self._clients.add(ws)

Register a newly accepted WebSocket. Wires up listeners on first attach.

async def detach(self, ws: starlette.websockets.WebSocket) -> None:
52    async def detach(self, ws: WebSocket) -> None:
53        self._clients.discard(ws)
async def broadcast(self, message: dict[str, typing.Any]) -> None:
55    async def broadcast(self, message: dict[str, Any]) -> None:
56        """Send *message* to every active client. Dead sockets are dropped."""
57        if not self._clients:
58            return
59        payload = json.dumps(message)
60        dead: list[WebSocket] = []
61        for ws in list(self._clients):
62            try:
63                await ws.send_text(payload)
64            except Exception:  # noqa: BLE001 — socket dead
65                dead.append(ws)
66        for ws in dead:
67            self._clients.discard(ws)

Send message to every active client. Dead sockets are dropped.

async def aclose(self) -> None:
267    async def aclose(self) -> None:
268        for unsub in self._unsubscribes:
269            unsub()
270        self._unsubscribes.clear()
271        # Best-effort: close any lingering sockets.
272        import contextlib  # noqa: PLC0415
273        for ws in list(self._clients):
274            with contextlib.suppress(Exception):
275                await ws.close()
276        self._clients.clear()