jmri_server.services.beans

Per-bean JSON services — turnout, sensor, light, signal heads/masts, memory, reporter, block, route.

Each service follows the same pattern:

  • on_get(data) — look up by data["name"], project to JSON shape
  • on_list(data) — project every bean of the type
  • on_put(data) — get-or-create + apply mutations
  • on_post(data) — find + apply mutations
  • on_delete(data) — deregister

The JSON shapes match the schemas in stage-0/contracts/json-schema/<topic>/.

  1"""Per-bean JSON services — turnout, sensor, light, signal heads/masts, memory,
  2reporter, block, route.
  3
  4Each service follows the same pattern:
  5
  6* ``on_get(data)``    — look up by ``data["name"]``, project to JSON shape
  7* ``on_list(data)``   — project every bean of the type
  8* ``on_put(data)``    — get-or-create + apply mutations
  9* ``on_post(data)``   — find + apply mutations
 10* ``on_delete(data)`` — deregister
 11
 12The JSON shapes match the schemas in ``stage-0/contracts/json-schema/<topic>/``.
 13"""
 14from __future__ import annotations
 15
 16from collections.abc import Awaitable, Callable
 17from typing import Any
 18
 19from jmri_core.bean import AbstractNamedBean
 20from jmri_core.beans.block import Block
 21from jmri_core.beans.light import Light
 22from jmri_core.beans.memory import Memory
 23from jmri_core.beans.reporter import Reporter
 24from jmri_core.beans.route import Route
 25from jmri_core.beans.sensor import Sensor
 26from jmri_core.beans.signal_head import SignalHead
 27from jmri_core.beans.signal_mast import SignalMast
 28from jmri_core.beans.turnout import Turnout
 29from jmri_core.manager import Manager
 30from jmri_core.state import SignalAppearance, TurnoutFeedback
 31
 32from jmri_server.service import JsonService, JsonServiceError
 33from jmri_server.wire_states import (
 34    light_from_wire,
 35    sensor_from_wire,
 36    to_wire,
 37    turnout_from_wire,
 38)
 39
 40# --- shared helpers --------------------------------------------------------
 41
 42
 43def _common_named_bean(bean: AbstractNamedBean) -> dict[str, Any]:
 44    """The four required fields on every NamedBean JSON payload."""
 45    return {
 46        "name": bean.system_name,
 47        "userName": bean.user_name,
 48        "comment": bean.comment,
 49        "properties": [
 50            {"key": k, "value": v if v is None or isinstance(v, str) else str(v)}
 51            for k, v in bean.properties.items()
 52        ],
 53    }
 54
 55
 56def _require_name(data: dict[str, Any] | None) -> str:
 57    if not data or "name" not in data:
 58        raise JsonServiceError(400, "request data missing required 'name'")
 59    return str(data["name"])
 60
 61
 62class _BeanService(JsonService):
 63    """Base class encapsulating get/list/put/post/delete plumbing.
 64
 65    Concrete bean services set :attr:`topic` and override :meth:`manager`,
 66    :meth:`project`, and (when mutations are supported) :meth:`apply`.
 67    """
 68
 69    def manager(self) -> Manager[Any]:
 70        raise NotImplementedError(
 71            f"{type(self).__name__}.manager must return the bean manager"
 72        )
 73
 74    def project(self, bean: Any) -> dict[str, Any]:
 75        raise NotImplementedError(
 76            f"{type(self).__name__}.project must convert a bean to its wire shape"
 77        )
 78
 79    async def apply(self, bean: Any, data: dict[str, Any]) -> None:
 80        """Apply mutations from a client request. Default: no-op."""
 81
 82    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
 83        name = _require_name(data)
 84        bean = self.manager().get(name)
 85        if bean is None:
 86            raise JsonServiceError(404, f"no {self.topic} with name {name!r}")
 87        return self.project(bean)
 88
 89    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
 90        return [self.project(b) for b in self.manager()]
 91
 92    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
 93        name = _require_name(data or {})
 94        bean = await self.manager().provide(name)
 95        await self._apply_common(bean, data or {})
 96        await self.apply(bean, data or {})
 97        self._persist(bean)
 98        return self.project(bean)
 99
100    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
101        name = _require_name(data or {})
102        bean = self.manager().get(name)
103        if bean is None:
104            raise JsonServiceError(404, f"no {self.topic} with name {name!r}")
105        await self._apply_common(bean, data or {})
106        await self.apply(bean, data or {})
107        self._persist(bean)
108        return self.project(bean)
109
110    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
111        name = _require_name(data)
112        bean = self.manager().get(name)
113        if bean is None:
114            raise JsonServiceError(404, f"no {self.topic} with name {name!r}")
115        await self.manager().deregister(bean)
116        self._persist_delete(name)
117        return {"name": name}
118
119    def _persist(self, bean: AbstractNamedBean) -> None:
120        """Hook: subclasses override to write the bean back to ConfigStore."""
121
122    def _persist_delete(self, system_name: str) -> None:
123        """Hook: subclasses override to remove the bean from ConfigStore."""
124
125    async def _apply_common(self, bean: AbstractNamedBean, data: dict[str, Any]) -> None:
126        if "userName" in data:
127            self.manager()._set_user_name(bean, data["userName"])
128        if "comment" in data:
129            bean.comment = data["comment"]
130
131
132# --- Turnout ----------------------------------------------------------------
133
134
135class TurnoutService(_BeanService):
136    topic = "turnout"
137
138    def manager(self) -> Manager[Turnout]:
139        return self.ctx.connection.turnouts
140
141    def project(self, bean: Turnout) -> dict[str, Any]:
142        return _common_named_bean(bean) | {
143            "inverted": bean.inverted,
144            "state": to_wire(int(bean.known_state)),
145            "feedbackMode": int(bean.feedback_mode),
146            "feedbackModes": [int(f) for f in TurnoutFeedback],
147        }
148
149    async def apply(self, bean: Turnout, data: dict[str, Any]) -> None:
150        if "inverted" in data:
151            bean.inverted = bool(data["inverted"])
152        if "feedbackMode" in data:
153            bean.feedback_mode = TurnoutFeedback(int(data["feedbackMode"]))
154        if "state" in data:
155            await bean.set_commanded_state(turnout_from_wire(int(data["state"])))
156
157    def _persist(self, bean: AbstractNamedBean) -> None:
158        store = getattr(self.ctx, "config_store", None)
159        if store is None:
160            return
161        assert isinstance(bean, Turnout)
162        from jmri_core.persistence import TurnoutRow  # noqa: PLC0415
163        store.upsert_turnout(TurnoutRow(
164            system_name=bean.system_name,
165            user_name=bean.user_name or None,
166            feedback=bean.feedback_mode.name if bean.feedback_mode else None,
167            inverted=bool(bean.inverted),
168        ))
169
170    def _persist_delete(self, system_name: str) -> None:
171        store = getattr(self.ctx, "config_store", None)
172        if store is not None:
173            store.remove_turnout(system_name)
174
175
176# --- Sensor -----------------------------------------------------------------
177
178
179class SensorService(_BeanService):
180    topic = "sensor"
181
182    def manager(self) -> Manager[Sensor]:
183        return self.ctx.connection.sensors
184
185    def project(self, bean: Sensor) -> dict[str, Any]:
186        return _common_named_bean(bean) | {
187            "inverted": bean.inverted,
188            "state": to_wire(int(bean.known_state)),
189        }
190
191    async def apply(self, bean: Sensor, data: dict[str, Any]) -> None:
192        if "inverted" in data:
193            bean.inverted = bool(data["inverted"])
194        if "state" in data:
195            await bean._set_known_state(sensor_from_wire(int(data["state"])))
196
197    def _persist(self, bean: AbstractNamedBean) -> None:
198        store = getattr(self.ctx, "config_store", None)
199        if store is None:
200            return
201        assert isinstance(bean, Sensor)
202        from jmri_core.persistence import SensorRow  # noqa: PLC0415
203        store.upsert_sensor(SensorRow(
204            system_name=bean.system_name,
205            user_name=bean.user_name or None,
206            inverted=bool(bean.inverted),
207        ))
208
209    def _persist_delete(self, system_name: str) -> None:
210        store = getattr(self.ctx, "config_store", None)
211        if store is not None:
212            store.remove_sensor(system_name)
213
214
215# --- Light ------------------------------------------------------------------
216
217
218class LightService(_BeanService):
219    topic = "light"
220
221    def manager(self) -> Manager[Light]:
222        return self.ctx.connection.lights
223
224    def project(self, bean: Light) -> dict[str, Any]:
225        return _common_named_bean(bean) | {"state": to_wire(int(bean.state))}
226
227    async def apply(self, bean: Light, data: dict[str, Any]) -> None:
228        if "state" in data:
229            await bean.set_state(light_from_wire(int(data["state"])))
230
231    def _persist(self, bean: AbstractNamedBean) -> None:
232        store = getattr(self.ctx, "config_store", None)
233        if store is None:
234            return
235        assert isinstance(bean, Light)
236        from jmri_core.persistence import LightRow  # noqa: PLC0415
237        store.upsert_light(LightRow(
238            system_name=bean.system_name,
239            user_name=bean.user_name or None,
240        ))
241
242    def _persist_delete(self, system_name: str) -> None:
243        store = getattr(self.ctx, "config_store", None)
244        if store is not None:
245            store.remove_light(system_name)
246
247
248# --- Signal Head ------------------------------------------------------------
249
250
251_HEAD_APPEARANCE_NAMES = {
252    SignalAppearance.DARK: "Dark",
253    SignalAppearance.RED: "Red",
254    SignalAppearance.YELLOW: "Yellow",
255    SignalAppearance.GREEN: "Green",
256    SignalAppearance.LUNAR: "Lunar",
257    SignalAppearance.FLASHRED: "Flashing Red",
258    SignalAppearance.FLASHYELLOW: "Flashing Yellow",
259    SignalAppearance.FLASHGREEN: "Flashing Green",
260    SignalAppearance.FLASHLUNAR: "Flashing Lunar",
261}
262HELD_WIRE_STATE = 100  # per JMRI's signalHead schema
263
264
265class SignalHeadService(_BeanService):
266    topic = "signalHead"
267
268    def manager(self) -> Manager[SignalHead]:
269        return self.ctx.connection.signal_heads
270
271    def project(self, bean: SignalHead) -> dict[str, Any]:
272        appearance = bean.commanded_appearance
273        state = HELD_WIRE_STATE if bean.held else int(appearance)
274        return _common_named_bean(bean) | {
275            "userName": bean.user_name or "",  # schema says string, not nullable
276            "lit": appearance != SignalAppearance.DARK,
277            "held": bean.held,
278            "state": state,
279            "appearance": int(appearance),
280            "appearanceName": _HEAD_APPEARANCE_NAMES.get(appearance, "Unknown"),
281        }
282
283    async def apply(self, bean: SignalHead, data: dict[str, Any]) -> None:
284        if "held" in data:
285            await bean.set_held(bool(data["held"]))
286        if "appearance" in data:
287            await bean.set_appearance(SignalAppearance(int(data["appearance"])))
288
289
290# --- Signal Mast ------------------------------------------------------------
291
292
293class SignalMastService(_BeanService):
294    topic = "signalMast"
295
296    def manager(self) -> Manager[SignalMast]:
297        return self.ctx.connection.signal_masts
298
299    def project(self, bean: SignalMast) -> dict[str, Any]:
300        # state per schema: "dark" if not lit, "held" if held, else the aspect name.
301        if bean.held:
302            state = "Held"
303        elif not bean.aspect:
304            state = "Dark"
305        else:
306            state = bean.aspect
307        projection = _common_named_bean(bean) | {
308            "aspect": bean.aspect,
309            "lit": bool(bean.aspect) and not bean.held,
310            "held": bean.held,
311            "state": state,
312            "signalSystem": bean.signal_system or None,
313        }
314        # Best-effort valid-aspect list from the signal-system loader.
315        # Adds ``validAspects: [str, ...]`` when the mast carries a
316        # ``signal_system`` that the loader knows about; skipped when
317        # the loader hasn't been touched yet (avoids constructing it
318        # for every bean-list request when no XML root is configured).
319        loader = getattr(self.ctx, "_signal_system_loader", None)
320        if loader is not None and bean.signal_system:
321            system = loader.get(bean.signal_system)
322            if system is not None:
323                projection["validAspects"] = [
324                    a.name for a in system.aspects
325                ]
326        return projection
327
328    async def apply(self, bean: SignalMast, data: dict[str, Any]) -> None:
329        if "signalSystem" in data:
330            v = data["signalSystem"]
331            bean.signal_system = str(v) if v is not None else ""
332        if "aspect" in data:
333            await bean.set_aspect(str(data["aspect"]))
334        if "held" in data:
335            await bean.set_held(bool(data["held"]))
336
337
338# --- Memory -----------------------------------------------------------------
339
340
341class MemoryService(_BeanService):
342    topic = "memory"
343
344    def manager(self) -> Manager[Memory]:
345        return self.ctx.connection.memories
346
347    def project(self, bean: Memory) -> dict[str, Any]:
348        v = bean.value
349        return _common_named_bean(bean) | {
350            "value": v if v is None or isinstance(v, str) else str(v),
351        }
352
353    async def apply(self, bean: Memory, data: dict[str, Any]) -> None:
354        if "value" in data:
355            await bean.set_value(data["value"])
356
357
358# --- Reporter ---------------------------------------------------------------
359
360
361class ReporterService(_BeanService):
362    topic = "reporter"
363
364    def manager(self) -> Manager[Reporter]:
365        return self.ctx.connection.reporters
366
367    def project(self, bean: Reporter) -> dict[str, Any]:
368        report = bean.current_report
369        last = bean.last_report
370        # ``value`` defaults to a sentinel string per JMRI convention.
371        value = (
372            str(report) if report not in (None, "") else "<no report>"
373        )
374        # state mirrors active/inactive based on whether we have a report.
375        state = 2 if report not in (None, "") else 4  # ACTIVE / INACTIVE wire
376        return _common_named_bean(bean) | {
377            "state": state,
378            "report": None if report is None else str(report),
379            "lastReport": None if last is None else str(last),
380            "value": value,
381        }
382
383    async def apply(self, bean: Reporter, data: dict[str, Any]) -> None:
384        if "report" in data:
385            await bean.set_report(data["report"])
386
387
388# --- Block ------------------------------------------------------------------
389
390
391class BlockService(_BeanService):
392    topic = "block"
393
394    def manager(self) -> Manager[Block]:
395        return self.ctx.connection.blocks
396
397    def project(self, bean: Block) -> dict[str, Any]:
398        v = bean.value
399        return _common_named_bean(bean) | {
400            "state": to_wire(int(bean.state)),
401            "value": v if v is None or isinstance(v, str) else str(v),
402            "sensor": bean.sensor.system_name if bean.sensor is not None else None,
403            "reporter": None,
404            "speed": "",
405            "curvature": 0,
406            "direction": 0,
407            "length": 0.0,
408            "permissive": False,
409            "speedLimit": -1.0,
410            "denied": [],
411        }
412
413    async def apply(self, bean: Block, data: dict[str, Any]) -> None:
414        if "value" in data:
415            await bean.set_value(data["value"])
416        if "sensor" in data:
417            sensor_name = data["sensor"]
418            sensor = (
419                self.ctx.connection.sensors.get(sensor_name) if sensor_name else None
420            )
421            bean.attach_sensor(sensor)
422
423
424# --- Route ------------------------------------------------------------------
425
426
427class RouteService(_BeanService):
428    topic = "route"
429
430    def manager(self) -> Manager[Route]:
431        return self.ctx.connection.routes
432
433    def project(self, bean: Route) -> dict[str, Any]:
434        return _common_named_bean(bean) | {"state": to_wire(int(bean.state))}
435
436    async def apply(self, bean: Route, data: dict[str, Any]) -> None:
437        # JMRI's wire convention: posting state=8 (INCONSISTENT) triggers a fire.
438        # We accept either state == any non-zero or an explicit "fire" boolean.
439        if data.get("fire") or int(data.get("state", 0)) != 0:
440            try:
441                await bean.fire()
442            except RuntimeError as e:
443                raise JsonServiceError(409, str(e)) from None
444
445
446# Suppress unused-import warning — Callable kept for future hook fields.
447_RESERVED: tuple[type, ...] = (Awaitable, Callable)  # type: ignore[assignment]
class TurnoutService(_BeanService):
136class TurnoutService(_BeanService):
137    topic = "turnout"
138
139    def manager(self) -> Manager[Turnout]:
140        return self.ctx.connection.turnouts
141
142    def project(self, bean: Turnout) -> dict[str, Any]:
143        return _common_named_bean(bean) | {
144            "inverted": bean.inverted,
145            "state": to_wire(int(bean.known_state)),
146            "feedbackMode": int(bean.feedback_mode),
147            "feedbackModes": [int(f) for f in TurnoutFeedback],
148        }
149
150    async def apply(self, bean: Turnout, data: dict[str, Any]) -> None:
151        if "inverted" in data:
152            bean.inverted = bool(data["inverted"])
153        if "feedbackMode" in data:
154            bean.feedback_mode = TurnoutFeedback(int(data["feedbackMode"]))
155        if "state" in data:
156            await bean.set_commanded_state(turnout_from_wire(int(data["state"])))
157
158    def _persist(self, bean: AbstractNamedBean) -> None:
159        store = getattr(self.ctx, "config_store", None)
160        if store is None:
161            return
162        assert isinstance(bean, Turnout)
163        from jmri_core.persistence import TurnoutRow  # noqa: PLC0415
164        store.upsert_turnout(TurnoutRow(
165            system_name=bean.system_name,
166            user_name=bean.user_name or None,
167            feedback=bean.feedback_mode.name if bean.feedback_mode else None,
168            inverted=bool(bean.inverted),
169        ))
170
171    def _persist_delete(self, system_name: str) -> None:
172        store = getattr(self.ctx, "config_store", None)
173        if store is not None:
174            store.remove_turnout(system_name)

Base class encapsulating get/list/put/post/delete plumbing.

Concrete bean services set topic and override manager(), project(), and (when mutations are supported) apply().

topic = 'turnout'
def manager(self) -> jmri_core.Manager[jmri_core.beans.Turnout]:
139    def manager(self) -> Manager[Turnout]:
140        return self.ctx.connection.turnouts
def project(self, bean: jmri_core.beans.Turnout) -> dict[str, typing.Any]:
142    def project(self, bean: Turnout) -> dict[str, Any]:
143        return _common_named_bean(bean) | {
144            "inverted": bean.inverted,
145            "state": to_wire(int(bean.known_state)),
146            "feedbackMode": int(bean.feedback_mode),
147            "feedbackModes": [int(f) for f in TurnoutFeedback],
148        }
async def apply( self, bean: jmri_core.beans.Turnout, data: dict[str, typing.Any]) -> None:
150    async def apply(self, bean: Turnout, data: dict[str, Any]) -> None:
151        if "inverted" in data:
152            bean.inverted = bool(data["inverted"])
153        if "feedbackMode" in data:
154            bean.feedback_mode = TurnoutFeedback(int(data["feedbackMode"]))
155        if "state" in data:
156            await bean.set_commanded_state(turnout_from_wire(int(data["state"])))

Apply mutations from a client request. Default: no-op.

class SensorService(_BeanService):
180class SensorService(_BeanService):
181    topic = "sensor"
182
183    def manager(self) -> Manager[Sensor]:
184        return self.ctx.connection.sensors
185
186    def project(self, bean: Sensor) -> dict[str, Any]:
187        return _common_named_bean(bean) | {
188            "inverted": bean.inverted,
189            "state": to_wire(int(bean.known_state)),
190        }
191
192    async def apply(self, bean: Sensor, data: dict[str, Any]) -> None:
193        if "inverted" in data:
194            bean.inverted = bool(data["inverted"])
195        if "state" in data:
196            await bean._set_known_state(sensor_from_wire(int(data["state"])))
197
198    def _persist(self, bean: AbstractNamedBean) -> None:
199        store = getattr(self.ctx, "config_store", None)
200        if store is None:
201            return
202        assert isinstance(bean, Sensor)
203        from jmri_core.persistence import SensorRow  # noqa: PLC0415
204        store.upsert_sensor(SensorRow(
205            system_name=bean.system_name,
206            user_name=bean.user_name or None,
207            inverted=bool(bean.inverted),
208        ))
209
210    def _persist_delete(self, system_name: str) -> None:
211        store = getattr(self.ctx, "config_store", None)
212        if store is not None:
213            store.remove_sensor(system_name)

Base class encapsulating get/list/put/post/delete plumbing.

Concrete bean services set topic and override manager(), project(), and (when mutations are supported) apply().

topic = 'sensor'
def manager(self) -> jmri_core.Manager[jmri_core.beans.Sensor]:
183    def manager(self) -> Manager[Sensor]:
184        return self.ctx.connection.sensors
def project(self, bean: jmri_core.beans.Sensor) -> dict[str, typing.Any]:
186    def project(self, bean: Sensor) -> dict[str, Any]:
187        return _common_named_bean(bean) | {
188            "inverted": bean.inverted,
189            "state": to_wire(int(bean.known_state)),
190        }
async def apply( self, bean: jmri_core.beans.Sensor, data: dict[str, typing.Any]) -> None:
192    async def apply(self, bean: Sensor, data: dict[str, Any]) -> None:
193        if "inverted" in data:
194            bean.inverted = bool(data["inverted"])
195        if "state" in data:
196            await bean._set_known_state(sensor_from_wire(int(data["state"])))

Apply mutations from a client request. Default: no-op.

class LightService(_BeanService):
219class LightService(_BeanService):
220    topic = "light"
221
222    def manager(self) -> Manager[Light]:
223        return self.ctx.connection.lights
224
225    def project(self, bean: Light) -> dict[str, Any]:
226        return _common_named_bean(bean) | {"state": to_wire(int(bean.state))}
227
228    async def apply(self, bean: Light, data: dict[str, Any]) -> None:
229        if "state" in data:
230            await bean.set_state(light_from_wire(int(data["state"])))
231
232    def _persist(self, bean: AbstractNamedBean) -> None:
233        store = getattr(self.ctx, "config_store", None)
234        if store is None:
235            return
236        assert isinstance(bean, Light)
237        from jmri_core.persistence import LightRow  # noqa: PLC0415
238        store.upsert_light(LightRow(
239            system_name=bean.system_name,
240            user_name=bean.user_name or None,
241        ))
242
243    def _persist_delete(self, system_name: str) -> None:
244        store = getattr(self.ctx, "config_store", None)
245        if store is not None:
246            store.remove_light(system_name)

Base class encapsulating get/list/put/post/delete plumbing.

Concrete bean services set topic and override manager(), project(), and (when mutations are supported) apply().

topic = 'light'
def manager(self) -> jmri_core.Manager[jmri_core.beans.Light]:
222    def manager(self) -> Manager[Light]:
223        return self.ctx.connection.lights
def project(self, bean: jmri_core.beans.Light) -> dict[str, typing.Any]:
225    def project(self, bean: Light) -> dict[str, Any]:
226        return _common_named_bean(bean) | {"state": to_wire(int(bean.state))}
async def apply( self, bean: jmri_core.beans.Light, data: dict[str, typing.Any]) -> None:
228    async def apply(self, bean: Light, data: dict[str, Any]) -> None:
229        if "state" in data:
230            await bean.set_state(light_from_wire(int(data["state"])))

Apply mutations from a client request. Default: no-op.

HELD_WIRE_STATE = 100
class SignalHeadService(_BeanService):
266class SignalHeadService(_BeanService):
267    topic = "signalHead"
268
269    def manager(self) -> Manager[SignalHead]:
270        return self.ctx.connection.signal_heads
271
272    def project(self, bean: SignalHead) -> dict[str, Any]:
273        appearance = bean.commanded_appearance
274        state = HELD_WIRE_STATE if bean.held else int(appearance)
275        return _common_named_bean(bean) | {
276            "userName": bean.user_name or "",  # schema says string, not nullable
277            "lit": appearance != SignalAppearance.DARK,
278            "held": bean.held,
279            "state": state,
280            "appearance": int(appearance),
281            "appearanceName": _HEAD_APPEARANCE_NAMES.get(appearance, "Unknown"),
282        }
283
284    async def apply(self, bean: SignalHead, data: dict[str, Any]) -> None:
285        if "held" in data:
286            await bean.set_held(bool(data["held"]))
287        if "appearance" in data:
288            await bean.set_appearance(SignalAppearance(int(data["appearance"])))

Base class encapsulating get/list/put/post/delete plumbing.

Concrete bean services set topic and override manager(), project(), and (when mutations are supported) apply().

topic = 'signalHead'
def manager( self) -> jmri_core.Manager[jmri_core.beans.SignalHead]:
269    def manager(self) -> Manager[SignalHead]:
270        return self.ctx.connection.signal_heads
def project( self, bean: jmri_core.beans.SignalHead) -> dict[str, typing.Any]:
272    def project(self, bean: SignalHead) -> dict[str, Any]:
273        appearance = bean.commanded_appearance
274        state = HELD_WIRE_STATE if bean.held else int(appearance)
275        return _common_named_bean(bean) | {
276            "userName": bean.user_name or "",  # schema says string, not nullable
277            "lit": appearance != SignalAppearance.DARK,
278            "held": bean.held,
279            "state": state,
280            "appearance": int(appearance),
281            "appearanceName": _HEAD_APPEARANCE_NAMES.get(appearance, "Unknown"),
282        }
async def apply( self, bean: jmri_core.beans.SignalHead, data: dict[str, typing.Any]) -> None:
284    async def apply(self, bean: SignalHead, data: dict[str, Any]) -> None:
285        if "held" in data:
286            await bean.set_held(bool(data["held"]))
287        if "appearance" in data:
288            await bean.set_appearance(SignalAppearance(int(data["appearance"])))

Apply mutations from a client request. Default: no-op.

class SignalMastService(_BeanService):
294class SignalMastService(_BeanService):
295    topic = "signalMast"
296
297    def manager(self) -> Manager[SignalMast]:
298        return self.ctx.connection.signal_masts
299
300    def project(self, bean: SignalMast) -> dict[str, Any]:
301        # state per schema: "dark" if not lit, "held" if held, else the aspect name.
302        if bean.held:
303            state = "Held"
304        elif not bean.aspect:
305            state = "Dark"
306        else:
307            state = bean.aspect
308        projection = _common_named_bean(bean) | {
309            "aspect": bean.aspect,
310            "lit": bool(bean.aspect) and not bean.held,
311            "held": bean.held,
312            "state": state,
313            "signalSystem": bean.signal_system or None,
314        }
315        # Best-effort valid-aspect list from the signal-system loader.
316        # Adds ``validAspects: [str, ...]`` when the mast carries a
317        # ``signal_system`` that the loader knows about; skipped when
318        # the loader hasn't been touched yet (avoids constructing it
319        # for every bean-list request when no XML root is configured).
320        loader = getattr(self.ctx, "_signal_system_loader", None)
321        if loader is not None and bean.signal_system:
322            system = loader.get(bean.signal_system)
323            if system is not None:
324                projection["validAspects"] = [
325                    a.name for a in system.aspects
326                ]
327        return projection
328
329    async def apply(self, bean: SignalMast, data: dict[str, Any]) -> None:
330        if "signalSystem" in data:
331            v = data["signalSystem"]
332            bean.signal_system = str(v) if v is not None else ""
333        if "aspect" in data:
334            await bean.set_aspect(str(data["aspect"]))
335        if "held" in data:
336            await bean.set_held(bool(data["held"]))

Base class encapsulating get/list/put/post/delete plumbing.

Concrete bean services set topic and override manager(), project(), and (when mutations are supported) apply().

topic = 'signalMast'
def manager( self) -> jmri_core.Manager[jmri_core.beans.SignalMast]:
297    def manager(self) -> Manager[SignalMast]:
298        return self.ctx.connection.signal_masts
def project( self, bean: jmri_core.beans.SignalMast) -> dict[str, typing.Any]:
300    def project(self, bean: SignalMast) -> dict[str, Any]:
301        # state per schema: "dark" if not lit, "held" if held, else the aspect name.
302        if bean.held:
303            state = "Held"
304        elif not bean.aspect:
305            state = "Dark"
306        else:
307            state = bean.aspect
308        projection = _common_named_bean(bean) | {
309            "aspect": bean.aspect,
310            "lit": bool(bean.aspect) and not bean.held,
311            "held": bean.held,
312            "state": state,
313            "signalSystem": bean.signal_system or None,
314        }
315        # Best-effort valid-aspect list from the signal-system loader.
316        # Adds ``validAspects: [str, ...]`` when the mast carries a
317        # ``signal_system`` that the loader knows about; skipped when
318        # the loader hasn't been touched yet (avoids constructing it
319        # for every bean-list request when no XML root is configured).
320        loader = getattr(self.ctx, "_signal_system_loader", None)
321        if loader is not None and bean.signal_system:
322            system = loader.get(bean.signal_system)
323            if system is not None:
324                projection["validAspects"] = [
325                    a.name for a in system.aspects
326                ]
327        return projection
async def apply( self, bean: jmri_core.beans.SignalMast, data: dict[str, typing.Any]) -> None:
329    async def apply(self, bean: SignalMast, data: dict[str, Any]) -> None:
330        if "signalSystem" in data:
331            v = data["signalSystem"]
332            bean.signal_system = str(v) if v is not None else ""
333        if "aspect" in data:
334            await bean.set_aspect(str(data["aspect"]))
335        if "held" in data:
336            await bean.set_held(bool(data["held"]))

Apply mutations from a client request. Default: no-op.

class MemoryService(_BeanService):
342class MemoryService(_BeanService):
343    topic = "memory"
344
345    def manager(self) -> Manager[Memory]:
346        return self.ctx.connection.memories
347
348    def project(self, bean: Memory) -> dict[str, Any]:
349        v = bean.value
350        return _common_named_bean(bean) | {
351            "value": v if v is None or isinstance(v, str) else str(v),
352        }
353
354    async def apply(self, bean: Memory, data: dict[str, Any]) -> None:
355        if "value" in data:
356            await bean.set_value(data["value"])

Base class encapsulating get/list/put/post/delete plumbing.

Concrete bean services set topic and override manager(), project(), and (when mutations are supported) apply().

topic = 'memory'
def manager(self) -> jmri_core.Manager[jmri_core.beans.Memory]:
345    def manager(self) -> Manager[Memory]:
346        return self.ctx.connection.memories
def project(self, bean: jmri_core.beans.Memory) -> dict[str, typing.Any]:
348    def project(self, bean: Memory) -> dict[str, Any]:
349        v = bean.value
350        return _common_named_bean(bean) | {
351            "value": v if v is None or isinstance(v, str) else str(v),
352        }
async def apply( self, bean: jmri_core.beans.Memory, data: dict[str, typing.Any]) -> None:
354    async def apply(self, bean: Memory, data: dict[str, Any]) -> None:
355        if "value" in data:
356            await bean.set_value(data["value"])

Apply mutations from a client request. Default: no-op.

class ReporterService(_BeanService):
362class ReporterService(_BeanService):
363    topic = "reporter"
364
365    def manager(self) -> Manager[Reporter]:
366        return self.ctx.connection.reporters
367
368    def project(self, bean: Reporter) -> dict[str, Any]:
369        report = bean.current_report
370        last = bean.last_report
371        # ``value`` defaults to a sentinel string per JMRI convention.
372        value = (
373            str(report) if report not in (None, "") else "<no report>"
374        )
375        # state mirrors active/inactive based on whether we have a report.
376        state = 2 if report not in (None, "") else 4  # ACTIVE / INACTIVE wire
377        return _common_named_bean(bean) | {
378            "state": state,
379            "report": None if report is None else str(report),
380            "lastReport": None if last is None else str(last),
381            "value": value,
382        }
383
384    async def apply(self, bean: Reporter, data: dict[str, Any]) -> None:
385        if "report" in data:
386            await bean.set_report(data["report"])

Base class encapsulating get/list/put/post/delete plumbing.

Concrete bean services set topic and override manager(), project(), and (when mutations are supported) apply().

topic = 'reporter'
def manager(self) -> jmri_core.Manager[jmri_core.beans.Reporter]:
365    def manager(self) -> Manager[Reporter]:
366        return self.ctx.connection.reporters
def project(self, bean: jmri_core.beans.Reporter) -> dict[str, typing.Any]:
368    def project(self, bean: Reporter) -> dict[str, Any]:
369        report = bean.current_report
370        last = bean.last_report
371        # ``value`` defaults to a sentinel string per JMRI convention.
372        value = (
373            str(report) if report not in (None, "") else "<no report>"
374        )
375        # state mirrors active/inactive based on whether we have a report.
376        state = 2 if report not in (None, "") else 4  # ACTIVE / INACTIVE wire
377        return _common_named_bean(bean) | {
378            "state": state,
379            "report": None if report is None else str(report),
380            "lastReport": None if last is None else str(last),
381            "value": value,
382        }
async def apply( self, bean: jmri_core.beans.Reporter, data: dict[str, typing.Any]) -> None:
384    async def apply(self, bean: Reporter, data: dict[str, Any]) -> None:
385        if "report" in data:
386            await bean.set_report(data["report"])

Apply mutations from a client request. Default: no-op.

class BlockService(_BeanService):
392class BlockService(_BeanService):
393    topic = "block"
394
395    def manager(self) -> Manager[Block]:
396        return self.ctx.connection.blocks
397
398    def project(self, bean: Block) -> dict[str, Any]:
399        v = bean.value
400        return _common_named_bean(bean) | {
401            "state": to_wire(int(bean.state)),
402            "value": v if v is None or isinstance(v, str) else str(v),
403            "sensor": bean.sensor.system_name if bean.sensor is not None else None,
404            "reporter": None,
405            "speed": "",
406            "curvature": 0,
407            "direction": 0,
408            "length": 0.0,
409            "permissive": False,
410            "speedLimit": -1.0,
411            "denied": [],
412        }
413
414    async def apply(self, bean: Block, data: dict[str, Any]) -> None:
415        if "value" in data:
416            await bean.set_value(data["value"])
417        if "sensor" in data:
418            sensor_name = data["sensor"]
419            sensor = (
420                self.ctx.connection.sensors.get(sensor_name) if sensor_name else None
421            )
422            bean.attach_sensor(sensor)

Base class encapsulating get/list/put/post/delete plumbing.

Concrete bean services set topic and override manager(), project(), and (when mutations are supported) apply().

topic = 'block'
def manager(self) -> jmri_core.Manager[jmri_core.beans.Block]:
395    def manager(self) -> Manager[Block]:
396        return self.ctx.connection.blocks
def project(self, bean: jmri_core.beans.Block) -> dict[str, typing.Any]:
398    def project(self, bean: Block) -> dict[str, Any]:
399        v = bean.value
400        return _common_named_bean(bean) | {
401            "state": to_wire(int(bean.state)),
402            "value": v if v is None or isinstance(v, str) else str(v),
403            "sensor": bean.sensor.system_name if bean.sensor is not None else None,
404            "reporter": None,
405            "speed": "",
406            "curvature": 0,
407            "direction": 0,
408            "length": 0.0,
409            "permissive": False,
410            "speedLimit": -1.0,
411            "denied": [],
412        }
async def apply( self, bean: jmri_core.beans.Block, data: dict[str, typing.Any]) -> None:
414    async def apply(self, bean: Block, data: dict[str, Any]) -> None:
415        if "value" in data:
416            await bean.set_value(data["value"])
417        if "sensor" in data:
418            sensor_name = data["sensor"]
419            sensor = (
420                self.ctx.connection.sensors.get(sensor_name) if sensor_name else None
421            )
422            bean.attach_sensor(sensor)

Apply mutations from a client request. Default: no-op.

class RouteService(_BeanService):
428class RouteService(_BeanService):
429    topic = "route"
430
431    def manager(self) -> Manager[Route]:
432        return self.ctx.connection.routes
433
434    def project(self, bean: Route) -> dict[str, Any]:
435        return _common_named_bean(bean) | {"state": to_wire(int(bean.state))}
436
437    async def apply(self, bean: Route, data: dict[str, Any]) -> None:
438        # JMRI's wire convention: posting state=8 (INCONSISTENT) triggers a fire.
439        # We accept either state == any non-zero or an explicit "fire" boolean.
440        if data.get("fire") or int(data.get("state", 0)) != 0:
441            try:
442                await bean.fire()
443            except RuntimeError as e:
444                raise JsonServiceError(409, str(e)) from None

Base class encapsulating get/list/put/post/delete plumbing.

Concrete bean services set topic and override manager(), project(), and (when mutations are supported) apply().

topic = 'route'
def manager(self) -> jmri_core.Manager[jmri_core.beans.Route]:
431    def manager(self) -> Manager[Route]:
432        return self.ctx.connection.routes
def project(self, bean: jmri_core.beans.Route) -> dict[str, typing.Any]:
434    def project(self, bean: Route) -> dict[str, Any]:
435        return _common_named_bean(bean) | {"state": to_wire(int(bean.state))}
async def apply( self, bean: jmri_core.beans.Route, data: dict[str, typing.Any]) -> None:
437    async def apply(self, bean: Route, data: dict[str, Any]) -> None:
438        # JMRI's wire convention: posting state=8 (INCONSISTENT) triggers a fire.
439        # We accept either state == any non-zero or an explicit "fire" boolean.
440        if data.get("fire") or int(data.get("state", 0)) != 0:
441            try:
442                await bean.fire()
443            except RuntimeError as e:
444                raise JsonServiceError(409, str(e)) from None

Apply mutations from a client request. Default: no-op.