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 bydata["name"], project to JSON shapeon_list(data)— project every bean of the typeon_put(data)— get-or-create + apply mutationson_post(data)— find + apply mutationson_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]
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().
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.
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().
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().
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().
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 }
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().
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
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.
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().
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().
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 }
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().
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 }
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.
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().
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.