jmri_server.services

Per-topic JSON services.

Importing this package returns the canonical list of services. Use register_all() to register them on a ServiceRegistry.

  1"""Per-topic JSON services.
  2
  3Importing this package returns the canonical list of services. Use
  4:func:`register_all` to register them on a :class:`ServiceRegistry`.
  5"""
  6from jmri_server.services.beans import (
  7    BlockService,
  8    LightService,
  9    MemoryService,
 10    ReporterService,
 11    RouteService,
 12    SensorService,
 13    SignalHeadService,
 14    SignalMastService,
 15    TurnoutService,
 16)
 17from jmri_server.services.audio import AudioService
 18from jmri_server.services.bean_transfer import BeanTransferService
 19from jmri_server.services.config_profile import ConfigProfileService
 20from jmri_server.services.config_user import ConfigUserService
 21from jmri_server.services.consist import ConsistService
 22from jmri_server.services.fast_clock import TimeService
 23from jmri_server.services.id_tag import IdTagService
 24from jmri_server.services.layout_block import LayoutBlockService
 25from jmri_server.services.logixng import LogixNGService
 26from jmri_server.services.logs import LogsService
 27from jmri_server.services.meta import (
 28    HelloService,
 29    NodeService,
 30    PingService,
 31    PowerService,
 32    RailroadService,
 33    VersionService,
 34)
 35from jmri_server.services.oblock import OBlockService
 36from jmri_server.services.panel import PanelService
 37from jmri_server.services.programmer import ProgrammerService
 38from jmri_server.services.roster import RosterService
 39from jmri_server.services.scripts import ScriptsService
 40from jmri_server.services.signal_system import SignalSystemService
 41from jmri_server.services.throttle import ThrottleService
 42from jmri_server.services.util import (
 43    MetadataService,
 44    NetworkServiceService,
 45    SystemConnectionService,
 46)
 47
 48ALL_SERVICES = [
 49    HelloService,
 50    NodeService,
 51    PingService,
 52    VersionService,
 53    RailroadService,
 54    PowerService,
 55    TurnoutService,
 56    SensorService,
 57    LightService,
 58    SignalHeadService,
 59    SignalMastService,
 60    MemoryService,
 61    ReporterService,
 62    BlockService,
 63    RouteService,
 64    RosterService,
 65    ThrottleService,
 66    ProgrammerService,
 67    ScriptsService,
 68    TimeService,
 69    ConsistService,
 70    MetadataService,
 71    NetworkServiceService,
 72    SystemConnectionService,
 73    AudioService,
 74    IdTagService,
 75    LayoutBlockService,
 76    OBlockService,
 77    PanelService,
 78    LogixNGService,
 79    LogsService,
 80    SignalSystemService,
 81    BeanTransferService,
 82    ConfigProfileService,
 83    ConfigUserService,
 84]
 85
 86
 87def register_all(registry: "ServiceRegistry") -> list[object]:
 88    return [registry.register(s) for s in ALL_SERVICES]
 89
 90
 91from typing import TYPE_CHECKING  # noqa: E402
 92
 93if TYPE_CHECKING:
 94    from jmri_server.registry import ServiceRegistry
 95
 96
 97__all__ = [
 98    "ALL_SERVICES",
 99    "AudioService",
100    "BeanTransferService",
101    "BlockService",
102    "ConfigProfileService",
103    "ConfigUserService",
104    "ConsistService",
105    "HelloService",
106    "IdTagService",
107    "LayoutBlockService",
108    "LogixNGService",
109    "LogsService",
110    "LightService",
111    "MemoryService",
112    "MetadataService",
113    "NetworkServiceService",
114    "NodeService",
115    "OBlockService",
116    "PanelService",
117    "PingService",
118    "PowerService",
119    "ProgrammerService",
120    "RailroadService",
121    "ReporterService",
122    "RosterService",
123    "RouteService",
124    "ScriptsService",
125    "SensorService",
126    "SignalHeadService",
127    "SignalMastService",
128    "SignalSystemService",
129    "SystemConnectionService",
130    "ThrottleService",
131    "TimeService",
132    "TurnoutService",
133    "VersionService",
134    "register_all",
135]
ALL_SERVICES = [<class 'HelloService'>, <class 'NodeService'>, <class 'PingService'>, <class 'VersionService'>, <class 'RailroadService'>, <class 'PowerService'>, <class 'TurnoutService'>, <class 'SensorService'>, <class 'LightService'>, <class 'SignalHeadService'>, <class 'SignalMastService'>, <class 'MemoryService'>, <class 'ReporterService'>, <class 'BlockService'>, <class 'RouteService'>, <class 'RosterService'>, <class 'ThrottleService'>, <class 'ProgrammerService'>, <class 'ScriptsService'>, <class 'TimeService'>, <class 'ConsistService'>, <class 'MetadataService'>, <class 'NetworkServiceService'>, <class 'SystemConnectionService'>, <class 'AudioService'>, <class 'IdTagService'>, <class 'LayoutBlockService'>, <class 'OBlockService'>, <class 'PanelService'>, <class 'LogixNGService'>, <class 'LogsService'>, <class 'SignalSystemService'>, <class 'BeanTransferService'>, <class 'ConfigProfileService'>, <class 'ConfigUserService'>]
class AudioService(jmri_server.services.beans._BeanService):
23class AudioService(_BeanService):
24    topic = "audio"
25
26    def manager(self) -> AudioManager:
27        return self.ctx.connection.audios
28
29    def project(self, bean: Audio) -> dict[str, Any]:
30        return _common_named_bean(bean) | {
31            "state": bean.state,
32            "kind": bean.kind,
33            "url": bean.url,
34            "gain": bean.gain,
35        }
36
37    async def apply(self, bean: Audio, data: dict[str, Any]) -> None:
38        if "kind" in data:
39            kind = data["kind"]
40            if kind not in ("source", "buffer", "listener"):
41                raise JsonServiceError(400, f"invalid audio kind: {kind!r}")
42            bean.kind = kind
43        if "url" in data:
44            bean.url = str(data["url"]) if data["url"] is not None else None
45        if "gain" in data:
46            try:
47                gain = float(data["gain"])
48            except (TypeError, ValueError):
49                raise JsonServiceError(400, "audio.gain must be numeric") from None
50            if not 0.0 <= gain <= 1.0:
51                raise JsonServiceError(400, "audio.gain must be in [0.0, 1.0]")
52            bean.gain = gain
53        # Explicit state override (rare — clients normally send
54        # ``command`` instead) but scripts sometimes set state directly.
55        if "state" in data:
56            state = int(data["state"])
57            if state not in (AUDIO_STATE_STOPPED, AUDIO_STATE_PLAYING):
58                raise JsonServiceError(
59                    400,
60                    f"audio.state must be {AUDIO_STATE_STOPPED} (stopped) or "
61                    f"{AUDIO_STATE_PLAYING} (playing)",
62                )
63            if state == AUDIO_STATE_PLAYING:
64                await bean.play()
65            else:
66                await bean.stop()
67        # JMRI Java's Play / Stop verbs. Only meaningful on sources; a
68        # buffer or listener silently no-ops the command rather than
69        # 400-ing to keep the API forgiving for polymorphic clients.
70        command = data.get("command")
71        if command == "Play":
72            if bean.kind == "source":
73                await bean.play()
74        elif command == "Stop":
75            if bean.kind == "source":
76                await bean.stop()
77        elif command is not None:
78            raise JsonServiceError(400, f"unknown audio command: {command!r}")

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 = 'audio'
def manager(self) -> jmri_core.beans.audio.AudioManager:
26    def manager(self) -> AudioManager:
27        return self.ctx.connection.audios
def project(self, bean: jmri_core.beans.audio.Audio) -> dict[str, typing.Any]:
29    def project(self, bean: Audio) -> dict[str, Any]:
30        return _common_named_bean(bean) | {
31            "state": bean.state,
32            "kind": bean.kind,
33            "url": bean.url,
34            "gain": bean.gain,
35        }
async def apply( self, bean: jmri_core.beans.audio.Audio, data: dict[str, typing.Any]) -> None:
37    async def apply(self, bean: Audio, data: dict[str, Any]) -> None:
38        if "kind" in data:
39            kind = data["kind"]
40            if kind not in ("source", "buffer", "listener"):
41                raise JsonServiceError(400, f"invalid audio kind: {kind!r}")
42            bean.kind = kind
43        if "url" in data:
44            bean.url = str(data["url"]) if data["url"] is not None else None
45        if "gain" in data:
46            try:
47                gain = float(data["gain"])
48            except (TypeError, ValueError):
49                raise JsonServiceError(400, "audio.gain must be numeric") from None
50            if not 0.0 <= gain <= 1.0:
51                raise JsonServiceError(400, "audio.gain must be in [0.0, 1.0]")
52            bean.gain = gain
53        # Explicit state override (rare — clients normally send
54        # ``command`` instead) but scripts sometimes set state directly.
55        if "state" in data:
56            state = int(data["state"])
57            if state not in (AUDIO_STATE_STOPPED, AUDIO_STATE_PLAYING):
58                raise JsonServiceError(
59                    400,
60                    f"audio.state must be {AUDIO_STATE_STOPPED} (stopped) or "
61                    f"{AUDIO_STATE_PLAYING} (playing)",
62                )
63            if state == AUDIO_STATE_PLAYING:
64                await bean.play()
65            else:
66                await bean.stop()
67        # JMRI Java's Play / Stop verbs. Only meaningful on sources; a
68        # buffer or listener silently no-ops the command rather than
69        # 400-ing to keep the API forgiving for polymorphic clients.
70        command = data.get("command")
71        if command == "Play":
72            if bean.kind == "source":
73                await bean.play()
74        elif command == "Stop":
75            if bean.kind == "source":
76                await bean.stop()
77        elif command is not None:
78            raise JsonServiceError(400, f"unknown audio command: {command!r}")

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

class BeanTransferService(jmri_server.service.JsonService):
107class BeanTransferService(JsonService):
108    """Cross-profile bean transfer. Read-only ``list`` +
109    ``post`` with ``action=preview|apply``."""
110
111    topic = "beanTransfer"
112    # ``list`` is safe for any role. ``post`` writes across
113    # profiles — admin-only, since a compromised operator token
114    # shouldn't be able to leak or overwrite the production
115    # profile's beans from a staging session.
116    REQUIRED_ROLE = {
117        "list": Role.VIEWER,
118        "post": Role.ADMIN,
119    }
120
121    async def on_list(
122        self, data: dict[str, Any] | None,
123    ) -> dict[str, Any]:
124        registry = self._registry_or_none()
125        profiles_out: list[dict[str, Any]] = []
126        if registry is not None:
127            active = registry.active()
128            active_id = active.id if active else None
129            for ref in registry.profiles:
130                profile = _profile_from_ref(registry, ref)
131                store = _open_store(profile)
132                profiles_out.append({
133                    "id": ref.id,
134                    "name": ref.name,
135                    "path": str(profile.profile_dir),
136                    "isActive": ref.id == active_id,
137                    "counts": {
138                        kind: _count(store, kind)
139                        for kind in SUPPORTED_TYPES
140                    },
141                })
142        return {
143            "profiles": profiles_out,
144            "types": list(SUPPORTED_TYPES),
145        }
146
147    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
148        data = data or {}
149        action = data.get("action")
150        if action == "preview":
151            return await self._preview(data)
152        if action == "apply":
153            return await self._apply(data)
154        raise JsonServiceError(
155            400, f"unknown beanTransfer action: {action!r}",
156        )
157
158    # ---- helpers -----------------------------------------------------
159
160    def _registry_or_none(self) -> ProfileRegistry | None:
161        registry = getattr(self.ctx, "profile_registry", None)
162        return registry if isinstance(registry, ProfileRegistry) else None
163
164    def _require_registry(self) -> ProfileRegistry:
165        registry = self._registry_or_none()
166        if registry is None:
167            raise JsonServiceError(
168                503,
169                "no profile registry loaded (no profiles.xml at settings root)",
170            )
171        return registry
172
173    def _resolve_pair(
174        self, data: dict[str, Any],
175    ) -> tuple[Profile, Profile, ProfileRef, ProfileRef, list[str]]:
176        registry = self._require_registry()
177        src_id = data.get("from")
178        dst_id = data.get("to")
179        if not isinstance(src_id, str) or not isinstance(dst_id, str):
180            raise JsonServiceError(
181                400, "beanTransfer requires 'from' and 'to' profile ids",
182            )
183        if src_id == dst_id:
184            raise JsonServiceError(
185                400, "beanTransfer 'from' and 'to' must differ",
186            )
187        src_ref = registry.by_id(src_id)
188        if src_ref is None:
189            raise JsonServiceError(
190                404, f"no source profile with id {src_id!r}",
191            )
192        dst_ref = registry.by_id(dst_id)
193        if dst_ref is None:
194            raise JsonServiceError(
195                404, f"no target profile with id {dst_id!r}",
196            )
197        raw_types = data.get("types")
198        if raw_types is None:
199            types = list(SUPPORTED_TYPES)
200        elif isinstance(raw_types, list):
201            types = [t for t in raw_types if isinstance(t, str)]
202        else:
203            raise JsonServiceError(
204                400, "'types' must be an array of bean-kind strings",
205            )
206        unknown = [t for t in types if t not in SUPPORTED_TYPES]
207        if unknown:
208            raise JsonServiceError(
209                400,
210                f"unsupported bean types: {unknown!r}. "
211                f"Supported: {list(SUPPORTED_TYPES)}",
212            )
213        if not types:
214            raise JsonServiceError(
215                400, "'types' must not be empty",
216            )
217        return (
218            _profile_from_ref(registry, src_ref),
219            _profile_from_ref(registry, dst_ref),
220            src_ref,
221            dst_ref,
222            types,
223        )
224
225    async def _preview(self, data: dict[str, Any]) -> dict[str, Any]:
226        src, dst, _sref, _dref, types = self._resolve_pair(data)
227        src_store = _open_store(src)
228        dst_store = _open_store(dst)
229        counts: dict[str, int] = {}
230        will_overwrite: dict[str, int] = {}
231        for kind in types:
232            src_names = _existing_names(src_store, kind)
233            counts[kind] = len(src_names)
234            will_overwrite[kind] = len(
235                src_names & _existing_names(dst_store, kind)
236            )
237        return {"counts": counts, "willOverwrite": will_overwrite}
238
239    async def _apply(self, data: dict[str, Any]) -> dict[str, Any]:
240        registry = self._require_registry()
241        src, dst, _sref, dst_ref, types = self._resolve_pair(data)
242        src_store = _open_store(src)
243        active = registry.active()
244        target_is_active = active is not None and dst_ref.id == active.id
245
246        # If the target is the active profile, write through the
247        # ctx.config_store the live server holds — otherwise a fresh
248        # instance we throw away, since no in-memory managers are
249        # attached to the target's beans.
250        if target_is_active and isinstance(self.ctx.config_store, ConfigStore):
251            dst_store = self.ctx.config_store
252        else:
253            dst_store = _open_store(dst)
254
255        copied: dict[str, int] = {}
256        for kind in types:
257            copied[kind] = _copy_kind(src_store, dst_store, kind)
258
259        # Re-populate live managers when we've just written into the
260        # active profile. Otherwise the caller would have to switch
261        # profile (or restart) to see the new beans in the running
262        # connection.
263        if target_is_active:
264            from jmri_server.app import _restore_beans_from_config  # noqa: PLC0415
265            await _restore_beans_from_config(
266                self.ctx.connection, dst_store,
267            )
268
269        return {"copied": copied, "targetIsActive": target_is_active}

Cross-profile bean transfer. Read-only list + post with action=preview|apply.

topic = 'beanTransfer'
REQUIRED_ROLE = {'list': <Role.VIEWER: 'viewer'>, 'post': <Role.ADMIN: 'admin'>}
async def on_list(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
121    async def on_list(
122        self, data: dict[str, Any] | None,
123    ) -> dict[str, Any]:
124        registry = self._registry_or_none()
125        profiles_out: list[dict[str, Any]] = []
126        if registry is not None:
127            active = registry.active()
128            active_id = active.id if active else None
129            for ref in registry.profiles:
130                profile = _profile_from_ref(registry, ref)
131                store = _open_store(profile)
132                profiles_out.append({
133                    "id": ref.id,
134                    "name": ref.name,
135                    "path": str(profile.profile_dir),
136                    "isActive": ref.id == active_id,
137                    "counts": {
138                        kind: _count(store, kind)
139                        for kind in SUPPORTED_TYPES
140                    },
141                })
142        return {
143            "profiles": profiles_out,
144            "types": list(SUPPORTED_TYPES),
145        }
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
147    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
148        data = data or {}
149        action = data.get("action")
150        if action == "preview":
151            return await self._preview(data)
152        if action == "apply":
153            return await self._apply(data)
154        raise JsonServiceError(
155            400, f"unknown beanTransfer action: {action!r}",
156        )
class BlockService(jmri_server.services.beans._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 ConfigProfileService(jmri_server.service.JsonService):
 66class ConfigProfileService(JsonService):
 67    topic = "configProfile"
 68    # Profile CRUD is admin-only — a compromised operator token
 69    # shouldn't be able to create/delete profiles or their
 70    # backing data.
 71    REQUIRED_ROLE = {
 72        "list": Role.VIEWER,
 73        "get": Role.VIEWER,
 74        "put": Role.ADMIN,
 75        "delete": Role.ADMIN,
 76        "post": Role.ADMIN,
 77    }
 78
 79    async def on_list(
 80        self, data: dict[str, Any] | None,
 81    ) -> list[dict[str, Any]]:
 82        registry = self._registry_or_none()
 83        if registry is None:
 84            return []
 85        return [_project(ref, registry) for ref in registry.profiles]
 86
 87    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
 88        data = data or {}
 89        name = data.get("name")
 90        if not isinstance(name, str) or not name:
 91            raise JsonServiceError(400, "configProfile.put requires 'name'")
 92        if not _SAFE_NAME_RE.match(name):
 93            raise JsonServiceError(
 94                400,
 95                "profile name must match [A-Za-z0-9._-]+ "
 96                "(filesystem-safe, no colon or path separators)",
 97            )
 98        registry = self._require_registry()
 99        # Reject duplicates by name — same-name entries would collide
100        # on disk since we store per-name subdirectories.
101        for existing in registry.profiles:
102            if existing.name == name:
103                raise JsonServiceError(
104                    409, f"profile with name {name!r} already exists",
105                )
106        # Auto-generate an id if the caller didn't specify one. JMRI's
107        # own convention is ``<name>.<8-hex>``; matching that keeps
108        # profiles.xml recognizable by a JMRI Java install if the user
109        # ever imports our settings back into it.
110        raw_id = data.get("id")
111        if isinstance(raw_id, str) and raw_id:
112            if registry.by_id(raw_id) is not None:
113                raise JsonServiceError(
114                    409, f"profile id {raw_id!r} already exists",
115                )
116            profile_id = raw_id
117        else:
118            profile_id = f"{name}.{uuid.uuid4().hex[:8]}"
119        ref = ProfileRef(
120            id=profile_id,
121            path=f"settings:{name}/",
122            name=name,
123        )
124        registry.profiles.append(ref)
125        # Persist profiles.xml before touching the filesystem so a
126        # save-failure doesn't leave orphan directories. Create the
127        # profile's dir + an empty PanelPro.xml afterwards.
128        save_profile_registry(registry)
129        profile = ref.to_profile(registry.settings_root)
130        profile.ensure()
131        _write_empty_config(profile)
132        return _project(ref, registry)
133
134    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
135        data = data or {}
136        profile_id = data.get("id")
137        if not isinstance(profile_id, str) or not profile_id:
138            raise JsonServiceError(400, "configProfile.delete requires 'id'")
139        registry = self._require_registry()
140        ref = registry.by_id(profile_id)
141        if ref is None:
142            raise JsonServiceError(
143                404, f"no profile with id {profile_id!r}",
144            )
145        active = registry.active()
146        if active is not None and active.id == profile_id:
147            raise JsonServiceError(
148                409,
149                "can't delete the active profile; switch to another profile first",
150            )
151        registry.profiles = [p for p in registry.profiles if p.id != profile_id]
152        save_profile_registry(registry)
153        return {"id": profile_id, "deleted": True}
154
155    # ---- helpers ----------------------------------------------------
156
157    def _registry_or_none(self) -> ProfileRegistry | None:
158        registry = getattr(self.ctx, "profile_registry", None)
159        return registry if isinstance(registry, ProfileRegistry) else None
160
161    def _require_registry(self) -> ProfileRegistry:
162        registry = self._registry_or_none()
163        if registry is None:
164            raise JsonServiceError(
165                503,
166                "no profile registry loaded (no profiles.xml at settings root)",
167            )
168        return registry

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'configProfile'
REQUIRED_ROLE = {'list': <Role.VIEWER: 'viewer'>, 'get': <Role.VIEWER: 'viewer'>, 'put': <Role.ADMIN: 'admin'>, 'delete': <Role.ADMIN: 'admin'>, 'post': <Role.ADMIN: 'admin'>}
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
79    async def on_list(
80        self, data: dict[str, Any] | None,
81    ) -> list[dict[str, Any]]:
82        registry = self._registry_or_none()
83        if registry is None:
84            return []
85        return [_project(ref, registry) for ref in registry.profiles]
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
 87    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
 88        data = data or {}
 89        name = data.get("name")
 90        if not isinstance(name, str) or not name:
 91            raise JsonServiceError(400, "configProfile.put requires 'name'")
 92        if not _SAFE_NAME_RE.match(name):
 93            raise JsonServiceError(
 94                400,
 95                "profile name must match [A-Za-z0-9._-]+ "
 96                "(filesystem-safe, no colon or path separators)",
 97            )
 98        registry = self._require_registry()
 99        # Reject duplicates by name — same-name entries would collide
100        # on disk since we store per-name subdirectories.
101        for existing in registry.profiles:
102            if existing.name == name:
103                raise JsonServiceError(
104                    409, f"profile with name {name!r} already exists",
105                )
106        # Auto-generate an id if the caller didn't specify one. JMRI's
107        # own convention is ``<name>.<8-hex>``; matching that keeps
108        # profiles.xml recognizable by a JMRI Java install if the user
109        # ever imports our settings back into it.
110        raw_id = data.get("id")
111        if isinstance(raw_id, str) and raw_id:
112            if registry.by_id(raw_id) is not None:
113                raise JsonServiceError(
114                    409, f"profile id {raw_id!r} already exists",
115                )
116            profile_id = raw_id
117        else:
118            profile_id = f"{name}.{uuid.uuid4().hex[:8]}"
119        ref = ProfileRef(
120            id=profile_id,
121            path=f"settings:{name}/",
122            name=name,
123        )
124        registry.profiles.append(ref)
125        # Persist profiles.xml before touching the filesystem so a
126        # save-failure doesn't leave orphan directories. Create the
127        # profile's dir + an empty PanelPro.xml afterwards.
128        save_profile_registry(registry)
129        profile = ref.to_profile(registry.settings_root)
130        profile.ensure()
131        _write_empty_config(profile)
132        return _project(ref, registry)
async def on_delete(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
134    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
135        data = data or {}
136        profile_id = data.get("id")
137        if not isinstance(profile_id, str) or not profile_id:
138            raise JsonServiceError(400, "configProfile.delete requires 'id'")
139        registry = self._require_registry()
140        ref = registry.by_id(profile_id)
141        if ref is None:
142            raise JsonServiceError(
143                404, f"no profile with id {profile_id!r}",
144            )
145        active = registry.active()
146        if active is not None and active.id == profile_id:
147            raise JsonServiceError(
148                409,
149                "can't delete the active profile; switch to another profile first",
150            )
151        registry.profiles = [p for p in registry.profiles if p.id != profile_id]
152        save_profile_registry(registry)
153        return {"id": profile_id, "deleted": True}
class ConfigUserService(jmri_server.service.JsonService):
 58class ConfigUserService(JsonService):
 59    topic = "configUser"
 60
 61    # User CRUD is admin-only for read + mutate. The self-service
 62    # POST actions do their own finer-grained checks (a viewer can
 63    # change their own password, not anyone else's) so ``post``
 64    # is gated at operator — the action handler enforces the rest.
 65    REQUIRED_ROLE = {
 66        "list": Role.ADMIN,
 67        "get": Role.ADMIN,
 68        "put": Role.ADMIN,
 69        "delete": Role.ADMIN,
 70        "post": Role.VIEWER,   # self-service always available
 71    }
 72
 73    # ---- helpers -------------------------------------------------------
 74
 75    def _auth(self):
 76        auth = getattr(self.ctx, "auth", None)
 77        if auth is None:
 78            raise JsonServiceError(
 79                503,
 80                "auth is disabled on this server (JMRI_AUTH_ENABLED not set)",
 81            )
 82        return auth
 83
 84    def _current_username(self) -> str | None:
 85        return getattr(self.ctx, "_current_username", None)
 86
 87    def _current_role(self) -> Role:
 88        """The current role is passed through dispatch to the JsonService
 89        signature, not to instances — but we can infer from the
 90        current user. When auth is off the middleware sets role=admin;
 91        we mirror that here by treating "no user" as admin."""
 92        current = self._current_username()
 93        if current is None:
 94            return Role.ADMIN
 95        user = self._auth().by_username(current)
 96        return user.role if user else Role.VIEWER
 97
 98    # ---- CRUD ----------------------------------------------------------
 99
100    async def on_list(
101        self, data: dict[str, Any] | None,
102    ) -> list[dict[str, Any]]:
103        auth = self._auth()
104        current = self._current_username()
105        return [
106            _project(u, self_flag=(u.username == current))
107            for u in auth.users
108        ]
109
110    async def on_get(
111        self, data: dict[str, Any] | None,
112    ) -> dict[str, Any]:
113        data = data or {}
114        username = data.get("username")
115        if not isinstance(username, str) or not username:
116            raise JsonServiceError(400, "configUser.get requires 'username'")
117        auth = self._auth()
118        user = auth.by_username(username)
119        if user is None:
120            raise JsonServiceError(404, f"no user {username!r}")
121        return _project(
122            user, self_flag=(user.username == self._current_username()),
123        )
124
125    async def on_put(
126        self, data: dict[str, Any] | None,
127    ) -> dict[str, Any]:
128        """Create or update.
129
130        For a new user: ``password`` is required, ``role`` defaults
131        to viewer. For an existing user: only the fields supplied
132        are updated (role and/or password). Changing your own role
133        would trigger the self-demotion guard — see ``set_role``.
134        """
135        data = data or {}
136        username = data.get("username")
137        password = data.get("password")
138        role_raw = data.get("role")
139        if not isinstance(username, str) or not username:
140            raise JsonServiceError(400, "configUser.put requires 'username'")
141        role: Role | None = None
142        if role_raw is not None:
143            try:
144                role = Role(role_raw)
145            except ValueError:
146                raise JsonServiceError(
147                    400,
148                    f"invalid role {role_raw!r}; must be one of "
149                    f"{[r.value for r in Role]}",
150                ) from None
151
152        auth = self._auth()
153        current = self._current_username() or ""
154        existing = auth.by_username(username)
155        if existing is None:
156            if not isinstance(password, str) or not password:
157                raise JsonServiceError(
158                    400, "creating a user requires 'password'",
159                )
160            user = auth.add_user(
161                username, password,
162                role=role if role is not None else Role.VIEWER,
163            )
164            return _project(user, self_flag=(username == current))
165
166        # Existing user — update only what's supplied.
167        if isinstance(password, str) and password:
168            auth.set_password(username, password)
169        if role is not None and role is not existing.role:
170            try:
171                auth.set_role(
172                    username, role, current_username=current,
173                )
174            except ValueError as e:
175                raise JsonServiceError(409, str(e)) from None
176        return _project(
177            auth.by_username(username),
178            self_flag=(username == current),
179        )
180
181    async def on_delete(
182        self, data: dict[str, Any] | None,
183    ) -> dict[str, Any]:
184        data = data or {}
185        username = data.get("username")
186        if not isinstance(username, str) or not username:
187            raise JsonServiceError(400, "configUser.delete requires 'username'")
188        auth = self._auth()
189        current = self._current_username() or ""
190        try:
191            auth.remove_user(username, current_username=current)
192        except LookupError as e:
193            raise JsonServiceError(404, str(e)) from None
194        except ValueError as e:
195            raise JsonServiceError(409, str(e)) from None
196        return {"username": username, "deleted": True}
197
198    # ---- actions -------------------------------------------------------
199
200    async def on_post(
201        self, data: dict[str, Any] | None,
202    ) -> dict[str, Any]:
203        data = data or {}
204        action = data.get("action")
205        if action == "change_password":
206            return await self._change_password(data)
207        if action == "regenerate_token":
208            return await self._regenerate_token(data)
209        raise JsonServiceError(
210            400, f"unknown configUser action: {action!r}",
211        )
212
213    async def _change_password(
214        self, data: dict[str, Any],
215    ) -> dict[str, Any]:
216        username = data.get("username")
217        new_password = data.get("new_password")
218        if not isinstance(username, str) or not username:
219            raise JsonServiceError(400, "change_password requires 'username'")
220        if not isinstance(new_password, str) or not new_password:
221            raise JsonServiceError(400, "change_password requires 'new_password'")
222        current = self._current_username() or ""
223        if username != current and self._current_role() is not Role.ADMIN:
224            raise JsonServiceError(
225                403,
226                "only admins can change other users' passwords",
227            )
228        auth = self._auth()
229        try:
230            auth.set_password(username, new_password)
231        except LookupError as e:
232            raise JsonServiceError(404, str(e)) from None
233        return {"username": username, "changed": True}
234
235    async def _regenerate_token(
236        self, data: dict[str, Any],
237    ) -> dict[str, Any]:
238        username = data.get("username")
239        if not isinstance(username, str) or not username:
240            raise JsonServiceError(
241                400, "regenerate_token requires 'username'",
242            )
243        current = self._current_username() or ""
244        if username != current and self._current_role() is not Role.ADMIN:
245            raise JsonServiceError(
246                403,
247                "only admins can regenerate other users' tokens",
248            )
249        auth = self._auth()
250        try:
251            token = auth.regenerate_token(username)
252        except LookupError as e:
253            raise JsonServiceError(404, str(e)) from None
254        return {"username": username, "token": token}

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'configUser'
REQUIRED_ROLE = {'list': <Role.ADMIN: 'admin'>, 'get': <Role.ADMIN: 'admin'>, 'put': <Role.ADMIN: 'admin'>, 'delete': <Role.ADMIN: 'admin'>, 'post': <Role.VIEWER: 'viewer'>}
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
100    async def on_list(
101        self, data: dict[str, Any] | None,
102    ) -> list[dict[str, Any]]:
103        auth = self._auth()
104        current = self._current_username()
105        return [
106            _project(u, self_flag=(u.username == current))
107            for u in auth.users
108        ]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
110    async def on_get(
111        self, data: dict[str, Any] | None,
112    ) -> dict[str, Any]:
113        data = data or {}
114        username = data.get("username")
115        if not isinstance(username, str) or not username:
116            raise JsonServiceError(400, "configUser.get requires 'username'")
117        auth = self._auth()
118        user = auth.by_username(username)
119        if user is None:
120            raise JsonServiceError(404, f"no user {username!r}")
121        return _project(
122            user, self_flag=(user.username == self._current_username()),
123        )
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
125    async def on_put(
126        self, data: dict[str, Any] | None,
127    ) -> dict[str, Any]:
128        """Create or update.
129
130        For a new user: ``password`` is required, ``role`` defaults
131        to viewer. For an existing user: only the fields supplied
132        are updated (role and/or password). Changing your own role
133        would trigger the self-demotion guard — see ``set_role``.
134        """
135        data = data or {}
136        username = data.get("username")
137        password = data.get("password")
138        role_raw = data.get("role")
139        if not isinstance(username, str) or not username:
140            raise JsonServiceError(400, "configUser.put requires 'username'")
141        role: Role | None = None
142        if role_raw is not None:
143            try:
144                role = Role(role_raw)
145            except ValueError:
146                raise JsonServiceError(
147                    400,
148                    f"invalid role {role_raw!r}; must be one of "
149                    f"{[r.value for r in Role]}",
150                ) from None
151
152        auth = self._auth()
153        current = self._current_username() or ""
154        existing = auth.by_username(username)
155        if existing is None:
156            if not isinstance(password, str) or not password:
157                raise JsonServiceError(
158                    400, "creating a user requires 'password'",
159                )
160            user = auth.add_user(
161                username, password,
162                role=role if role is not None else Role.VIEWER,
163            )
164            return _project(user, self_flag=(username == current))
165
166        # Existing user — update only what's supplied.
167        if isinstance(password, str) and password:
168            auth.set_password(username, password)
169        if role is not None and role is not existing.role:
170            try:
171                auth.set_role(
172                    username, role, current_username=current,
173                )
174            except ValueError as e:
175                raise JsonServiceError(409, str(e)) from None
176        return _project(
177            auth.by_username(username),
178            self_flag=(username == current),
179        )

Create or update.

For a new user: password is required, role defaults to viewer. For an existing user: only the fields supplied are updated (role and/or password). Changing your own role would trigger the self-demotion guard — see set_role.

async def on_delete(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
181    async def on_delete(
182        self, data: dict[str, Any] | None,
183    ) -> dict[str, Any]:
184        data = data or {}
185        username = data.get("username")
186        if not isinstance(username, str) or not username:
187            raise JsonServiceError(400, "configUser.delete requires 'username'")
188        auth = self._auth()
189        current = self._current_username() or ""
190        try:
191            auth.remove_user(username, current_username=current)
192        except LookupError as e:
193            raise JsonServiceError(404, str(e)) from None
194        except ValueError as e:
195            raise JsonServiceError(409, str(e)) from None
196        return {"username": username, "deleted": True}
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
200    async def on_post(
201        self, data: dict[str, Any] | None,
202    ) -> dict[str, Any]:
203        data = data or {}
204        action = data.get("action")
205        if action == "change_password":
206            return await self._change_password(data)
207        if action == "regenerate_token":
208            return await self._regenerate_token(data)
209        raise JsonServiceError(
210            400, f"unknown configUser action: {action!r}",
211        )
class ConsistService(jmri_server.service.JsonService):
 98class ConsistService(JsonService):
 99    topic = "consist"
100
101    @property
102    def _consists(self) -> ConsistManager:
103        return self.ctx.connection.consists
104
105    def _require(self, address: int) -> Consist:
106        c = self._consists.get(address)
107        if c is None:
108            raise JsonServiceError(404, f"no consist at address {address}")
109        return c
110
111    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
112        return [_project(c, self._consists) for c in self._consists]
113
114    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
115        addr = _address_from(data or {})
116        return _project(self._require(addr), self._consists)
117
118    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
119        """Create-if-absent. Idempotent: returns the existing consist if
120        one already lives at ``address`` without overwriting its
121        members. Change comment/kind on an existing consist via a
122        follow-up ``post`` (not currently wired — the two fields are
123        immutable after create; a delete+recreate is the escape hatch).
124
125        Enforces two rules that were left implicit before:
126
127        * Consist address must be 1..127 (short only). CV19 is 7-bit,
128          so anything else is either meaningless (advanced) or
129          gratuitously inconsistent across kinds.
130        * Address must not collide with any existing roster loco. If
131          it did, the roster loco would silently participate in every
132          fan-out addressed to that consist — a footgun.
133        """
134        data = data or {}
135        addr = _address_from(data)
136        if not 1 <= addr <= CONSIST_ADDRESS_MAX:
137            raise JsonServiceError(
138                400,
139                f"consist address must be 1..{CONSIST_ADDRESS_MAX} short, "
140                f"got {addr}",
141            )
142        kind = data.get("kind")
143        comment = str(data.get("comment", ""))
144        # Validate kind against the model's Literal — raise 400 on typo.
145        if kind is not None and kind not in ("software", "command_station", "advanced"):
146            raise JsonServiceError(400, f"invalid consist kind: {kind!r}")
147        # Roster-collision check. Skip if the consist already exists
148        # at this address (idempotent create still wins).
149        if self._consists.get(addr) is None:
150            conflict = self._roster_conflict(addr)
151            if conflict is not None:
152                raise JsonServiceError(
153                    409,
154                    f"consist address {addr} collides with roster loco "
155                    f"{conflict!r} (short {addr}); pick a different "
156                    f"consist address or remove/renumber the loco.",
157                )
158        consist = await self._consists.create(addr, kind=kind, comment=comment)
159        return _project(consist, self._consists)
160
161    def _roster_conflict(self, addr: int) -> str | None:
162        """Return the id of any roster entry using *addr* as a short
163        DCC address, or ``None`` if there's no collision.
164
165        Only short addresses count — a long-address loco at the same
166        numeric value shares no wire space with a 1..127 consist. The
167        roster store is lazy-loaded here on first use (same pattern
168        as :class:`RosterService`) so the check works even when the
169        user's first action is Create-consist and no roster/throttle
170        page has been hit yet — that was a real bug where the check
171        silently short-circuited when navigation started on
172        ``/consists``.
173        """
174        store = self._roster_store()
175        if store is None:
176            return None
177        target = str(addr)
178        for entry in store:
179            if entry.long_address:
180                continue
181            if entry.dcc_address == target:
182                return entry.id
183        return None
184
185    def _roster_store(self) -> Any:
186        """Return the shared :class:`RosterStore`, constructing it on
187        first access if nothing else has touched it yet.
188
189        Uses the same context attribute (``_roster_store``) as
190        :class:`RosterService` so both see the same instance — no
191        double-load, and edits made through /roster show up in this
192        check immediately.
193        """
194        store = getattr(self.ctx, "_roster_store", None)
195        if store is not None:
196            return store
197        try:
198            from jmri_core.persistence.profile import Profile  # noqa: PLC0415
199            from jmri_server.roster_store import RosterStore  # noqa: PLC0415
200        except ImportError:
201            return None
202        profile = getattr(self.ctx, "_roster_profile", None) or Profile.default()
203        try:
204            store = RosterStore(profile=profile)
205        except Exception:  # noqa: BLE001
206            # No profile on disk, permission error, corrupt index — the
207            # conflict check is a courtesy, not a hard invariant. Bail
208            # rather than fail create.
209            return None
210        self.ctx._roster_store = store  # type: ignore[attr-defined]
211        return store
212
213    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
214        data = data or {}
215        addr = _address_from(data)
216        action = data.get("action")
217        # Every action targets an existing consist.
218        self._require(addr)
219        if action == "add_member":
220            m = data.get("member") or {}
221            member_addr = m.get("address")
222            if not isinstance(member_addr, int):
223                raise JsonServiceError(
224                    400, "consist.add_member requires 'member.address'",
225                )
226            await self._consists.add_member(
227                addr, member_addr,
228                long_address=bool(m.get("long_address", False)),
229                direction_normal=bool(m.get("direction_normal", True)),
230            )
231        elif action == "remove_member":
232            m_addr = data.get("member_address")
233            if not isinstance(m_addr, int):
234                raise JsonServiceError(
235                    400, "consist.remove_member requires int 'member_address'",
236                )
237            if not await self._consists.remove_member(addr, m_addr):
238                raise JsonServiceError(
239                    404, f"consist {addr} has no member at {m_addr}",
240                )
241        elif action == "set_speed":
242            speed = data.get("speed")
243            if not isinstance(speed, (int, float)):
244                raise JsonServiceError(400, "consist.set_speed requires 'speed'")
245            await self._consists.set_speed(addr, float(speed))
246        elif action == "set_direction":
247            direction = data.get("direction")
248            if direction not in ("forward", "reverse"):
249                raise JsonServiceError(
250                    400, "consist.set_direction requires 'forward'|'reverse'",
251                )
252            await self._consists.set_direction(
253                addr,
254                ThrottleDirection.FORWARD if direction == "forward"
255                else ThrottleDirection.REVERSE,
256            )
257        elif action == "set_function":
258            fn = data.get("function")
259            on = data.get("on")
260            if not isinstance(fn, int) or not isinstance(on, bool):
261                raise JsonServiceError(
262                    400, "consist.set_function requires int 'function' + bool 'on'",
263                )
264            await self._consists.set_function(addr, fn, on)
265        elif action == "emergency_stop":
266            await self._consists.emergency_stop(addr)
267        else:
268            raise JsonServiceError(400, f"unknown consist action: {action!r}")
269        return _project(self._require(addr), self._consists)
270
271    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
272        data = data or {}
273        addr = _address_from(data)
274        if not await self._consists.delete(addr):
275            raise JsonServiceError(404, f"no consist at address {addr}")
276        return {"address": addr, "deleted": True}

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'consist'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
111    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
112        return [_project(c, self._consists) for c in self._consists]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
114    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
115        addr = _address_from(data or {})
116        return _project(self._require(addr), self._consists)
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
118    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
119        """Create-if-absent. Idempotent: returns the existing consist if
120        one already lives at ``address`` without overwriting its
121        members. Change comment/kind on an existing consist via a
122        follow-up ``post`` (not currently wired — the two fields are
123        immutable after create; a delete+recreate is the escape hatch).
124
125        Enforces two rules that were left implicit before:
126
127        * Consist address must be 1..127 (short only). CV19 is 7-bit,
128          so anything else is either meaningless (advanced) or
129          gratuitously inconsistent across kinds.
130        * Address must not collide with any existing roster loco. If
131          it did, the roster loco would silently participate in every
132          fan-out addressed to that consist — a footgun.
133        """
134        data = data or {}
135        addr = _address_from(data)
136        if not 1 <= addr <= CONSIST_ADDRESS_MAX:
137            raise JsonServiceError(
138                400,
139                f"consist address must be 1..{CONSIST_ADDRESS_MAX} short, "
140                f"got {addr}",
141            )
142        kind = data.get("kind")
143        comment = str(data.get("comment", ""))
144        # Validate kind against the model's Literal — raise 400 on typo.
145        if kind is not None and kind not in ("software", "command_station", "advanced"):
146            raise JsonServiceError(400, f"invalid consist kind: {kind!r}")
147        # Roster-collision check. Skip if the consist already exists
148        # at this address (idempotent create still wins).
149        if self._consists.get(addr) is None:
150            conflict = self._roster_conflict(addr)
151            if conflict is not None:
152                raise JsonServiceError(
153                    409,
154                    f"consist address {addr} collides with roster loco "
155                    f"{conflict!r} (short {addr}); pick a different "
156                    f"consist address or remove/renumber the loco.",
157                )
158        consist = await self._consists.create(addr, kind=kind, comment=comment)
159        return _project(consist, self._consists)

Create-if-absent. Idempotent: returns the existing consist if one already lives at address without overwriting its members. Change comment/kind on an existing consist via a follow-up post (not currently wired — the two fields are immutable after create; a delete+recreate is the escape hatch).

Enforces two rules that were left implicit before:

  • Consist address must be 1..127 (short only). CV19 is 7-bit, so anything else is either meaningless (advanced) or gratuitously inconsistent across kinds.
  • Address must not collide with any existing roster loco. If it did, the roster loco would silently participate in every fan-out addressed to that consist — a footgun.
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
213    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
214        data = data or {}
215        addr = _address_from(data)
216        action = data.get("action")
217        # Every action targets an existing consist.
218        self._require(addr)
219        if action == "add_member":
220            m = data.get("member") or {}
221            member_addr = m.get("address")
222            if not isinstance(member_addr, int):
223                raise JsonServiceError(
224                    400, "consist.add_member requires 'member.address'",
225                )
226            await self._consists.add_member(
227                addr, member_addr,
228                long_address=bool(m.get("long_address", False)),
229                direction_normal=bool(m.get("direction_normal", True)),
230            )
231        elif action == "remove_member":
232            m_addr = data.get("member_address")
233            if not isinstance(m_addr, int):
234                raise JsonServiceError(
235                    400, "consist.remove_member requires int 'member_address'",
236                )
237            if not await self._consists.remove_member(addr, m_addr):
238                raise JsonServiceError(
239                    404, f"consist {addr} has no member at {m_addr}",
240                )
241        elif action == "set_speed":
242            speed = data.get("speed")
243            if not isinstance(speed, (int, float)):
244                raise JsonServiceError(400, "consist.set_speed requires 'speed'")
245            await self._consists.set_speed(addr, float(speed))
246        elif action == "set_direction":
247            direction = data.get("direction")
248            if direction not in ("forward", "reverse"):
249                raise JsonServiceError(
250                    400, "consist.set_direction requires 'forward'|'reverse'",
251                )
252            await self._consists.set_direction(
253                addr,
254                ThrottleDirection.FORWARD if direction == "forward"
255                else ThrottleDirection.REVERSE,
256            )
257        elif action == "set_function":
258            fn = data.get("function")
259            on = data.get("on")
260            if not isinstance(fn, int) or not isinstance(on, bool):
261                raise JsonServiceError(
262                    400, "consist.set_function requires int 'function' + bool 'on'",
263                )
264            await self._consists.set_function(addr, fn, on)
265        elif action == "emergency_stop":
266            await self._consists.emergency_stop(addr)
267        else:
268            raise JsonServiceError(400, f"unknown consist action: {action!r}")
269        return _project(self._require(addr), self._consists)
async def on_delete(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
271    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
272        data = data or {}
273        addr = _address_from(data)
274        if not await self._consists.delete(addr):
275            raise JsonServiceError(404, f"no consist at address {addr}")
276        return {"address": addr, "deleted": True}
class HelloService(jmri_server.service.JsonService):
17class HelloService(JsonService):
18    topic = "hello"
19
20    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
21        m = self.ctx.metadata
22        return {
23            "JMRI": m.jmri_version,
24            "json": m.json_protocol_version,
25            "version": ".".join(m.json_protocol_version.split(".")[:2]),
26            "heartbeat": m.heartbeat_ms,
27            "railroad": m.railroad,
28            "node": m.node,
29            "activeProfile": m.active_profile,
30        }

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'hello'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
20    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
21        m = self.ctx.metadata
22        return {
23            "JMRI": m.jmri_version,
24            "json": m.json_protocol_version,
25            "version": ".".join(m.json_protocol_version.split(".")[:2]),
26            "heartbeat": m.heartbeat_ms,
27            "railroad": m.railroad,
28            "node": m.node,
29            "activeProfile": m.active_profile,
30        }
class IdTagService(jmri_server.services.beans._BeanService):
26class IdTagService(_BeanService):
27    topic = "idTag"
28
29    def manager(self) -> IdTagManager:
30        return self.ctx.connection.id_tags
31
32    def project(self, bean: IdTag) -> dict[str, Any]:
33        return _common_named_bean(bean) | {
34            "state": bean.state,
35            "reporter": bean.reporter_name,
36            "time": bean.time,
37        }
38
39    async def apply(self, bean: IdTag, data: dict[str, Any]) -> None:
40        # Scripts + tests can set state + reporter + time directly. The
41        # reporter reference is opaque (a bean name string); the JSON
42        # layer doesn't cross-check against the reporter manager
43        # because a report can name a reporter on a different profile.
44        if "state" in data:
45            state = int(data["state"])
46            if state not in (
47                ID_TAG_STATE_UNKNOWN,
48                ID_TAG_STATE_SEEN,
49                ID_TAG_STATE_UNSEEN,
50            ):
51                raise JsonServiceError(
52                    400, f"invalid idTag state: {state}",
53                )
54            when = str(data.get("time") or "")
55            reporter = data.get("reporter")
56            if state == ID_TAG_STATE_SEEN:
57                if not isinstance(reporter, str) or not reporter:
58                    raise JsonServiceError(
59                        400,
60                        "idTag.state=SEEN requires a 'reporter' name",
61                    )
62                await bean.report_seen(reporter, when)
63            elif state == ID_TAG_STATE_UNSEEN:
64                await bean.report_unseen(when)
65            else:
66                # UNKNOWN: reset everything.
67                bean._state = state  # noqa: SLF001 — internal reset
68                bean._reporter_name = None  # noqa: SLF001
69                bean._time = None  # noqa: SLF001
70                await bean.state_changed.emit(state)

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 = 'idTag'
def manager(self) -> jmri_core.beans.id_tag.IdTagManager:
29    def manager(self) -> IdTagManager:
30        return self.ctx.connection.id_tags
def project(self, bean: jmri_core.beans.id_tag.IdTag) -> dict[str, typing.Any]:
32    def project(self, bean: IdTag) -> dict[str, Any]:
33        return _common_named_bean(bean) | {
34            "state": bean.state,
35            "reporter": bean.reporter_name,
36            "time": bean.time,
37        }
async def apply( self, bean: jmri_core.beans.id_tag.IdTag, data: dict[str, typing.Any]) -> None:
39    async def apply(self, bean: IdTag, data: dict[str, Any]) -> None:
40        # Scripts + tests can set state + reporter + time directly. The
41        # reporter reference is opaque (a bean name string); the JSON
42        # layer doesn't cross-check against the reporter manager
43        # because a report can name a reporter on a different profile.
44        if "state" in data:
45            state = int(data["state"])
46            if state not in (
47                ID_TAG_STATE_UNKNOWN,
48                ID_TAG_STATE_SEEN,
49                ID_TAG_STATE_UNSEEN,
50            ):
51                raise JsonServiceError(
52                    400, f"invalid idTag state: {state}",
53                )
54            when = str(data.get("time") or "")
55            reporter = data.get("reporter")
56            if state == ID_TAG_STATE_SEEN:
57                if not isinstance(reporter, str) or not reporter:
58                    raise JsonServiceError(
59                        400,
60                        "idTag.state=SEEN requires a 'reporter' name",
61                    )
62                await bean.report_seen(reporter, when)
63            elif state == ID_TAG_STATE_UNSEEN:
64                await bean.report_unseen(when)
65            else:
66                # UNKNOWN: reset everything.
67                bean._state = state  # noqa: SLF001 — internal reset
68                bean._reporter_name = None  # noqa: SLF001
69                bean._time = None  # noqa: SLF001
70                await bean.state_changed.emit(state)

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

class LayoutBlockService(jmri_server.services.beans._BeanService):
17class LayoutBlockService(_BeanService):
18    topic = "layoutBlock"
19
20    def manager(self) -> LayoutBlockManager:
21        return self.ctx.connection.layout_blocks
22
23    def project(self, bean: LayoutBlock) -> dict[str, Any]:
24        return _common_named_bean(bean) | {
25            "state": bean.state,
26            "block": bean.block,
27            "memory": bean.memory,
28            "occupancySensor": bean.occupancy_sensor,
29            "occupiedSense": bean.occupied_sense,
30            "blockColor": bean.block_color,
31            "trackColor": bean.track_color,
32            "occupiedColor": bean.occupied_color,
33            "extraColor": bean.extra_color,
34            "useExtraColor": bean.use_extra_color,
35        }
36
37    async def apply(self, bean: LayoutBlock, data: dict[str, Any]) -> None:
38        # Every field is a straight assignment — no signal fires here
39        # because Panel Editor isn't wired yet. When it lands, this
40        # method grows a "notify state_changed" branch for occupancy
41        # transitions.
42        for wire_field, attr in (
43            ("block", "block"),
44            ("memory", "memory"),
45            ("occupancySensor", "occupancy_sensor"),
46            ("blockColor", "block_color"),
47            ("trackColor", "track_color"),
48            ("occupiedColor", "occupied_color"),
49            ("extraColor", "extra_color"),
50        ):
51            if wire_field in data:
52                val = data[wire_field]
53                setattr(bean, attr, str(val) if val is not None else None)
54        if "occupiedSense" in data:
55            bean.occupied_sense = int(data["occupiedSense"])
56        if "useExtraColor" in data:
57            bean.use_extra_color = bool(data["useExtraColor"])
58        if "state" in data:
59            bean.state = int(data["state"])

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 = 'layoutBlock'
def manager(self) -> jmri_core.beans.layout_block.LayoutBlockManager:
20    def manager(self) -> LayoutBlockManager:
21        return self.ctx.connection.layout_blocks
def project( self, bean: jmri_core.beans.layout_block.LayoutBlock) -> dict[str, typing.Any]:
23    def project(self, bean: LayoutBlock) -> dict[str, Any]:
24        return _common_named_bean(bean) | {
25            "state": bean.state,
26            "block": bean.block,
27            "memory": bean.memory,
28            "occupancySensor": bean.occupancy_sensor,
29            "occupiedSense": bean.occupied_sense,
30            "blockColor": bean.block_color,
31            "trackColor": bean.track_color,
32            "occupiedColor": bean.occupied_color,
33            "extraColor": bean.extra_color,
34            "useExtraColor": bean.use_extra_color,
35        }
async def apply( self, bean: jmri_core.beans.layout_block.LayoutBlock, data: dict[str, typing.Any]) -> None:
37    async def apply(self, bean: LayoutBlock, data: dict[str, Any]) -> None:
38        # Every field is a straight assignment — no signal fires here
39        # because Panel Editor isn't wired yet. When it lands, this
40        # method grows a "notify state_changed" branch for occupancy
41        # transitions.
42        for wire_field, attr in (
43            ("block", "block"),
44            ("memory", "memory"),
45            ("occupancySensor", "occupancy_sensor"),
46            ("blockColor", "block_color"),
47            ("trackColor", "track_color"),
48            ("occupiedColor", "occupied_color"),
49            ("extraColor", "extra_color"),
50        ):
51            if wire_field in data:
52                val = data[wire_field]
53                setattr(bean, attr, str(val) if val is not None else None)
54        if "occupiedSense" in data:
55            bean.occupied_sense = int(data["occupiedSense"])
56        if "useExtraColor" in data:
57            bean.use_extra_color = bool(data["useExtraColor"])
58        if "state" in data:
59            bean.state = int(data["state"])

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

class LogixNGService(jmri_server.service.JsonService):
 78class LogixNGService(JsonService):
 79    topic = "logixng"
 80
 81    async def on_list(
 82        self, data: dict[str, Any] | None,
 83    ) -> list[dict[str, Any]]:
 84        mgr = _require_manager(self.ctx)
 85        return [
 86            _project(name, mgr.get(name))       # type: ignore[arg-type]
 87            for name in mgr.names()
 88        ]
 89
 90    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
 91        mgr = _require_manager(self.ctx)
 92        name = self._require_name(data or {})
 93        prog = mgr.get(name)
 94        if prog is None:
 95            raise JsonServiceError(404, f"no logixng named {name!r}")
 96        return _project(name, prog)
 97
 98    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
 99        """Create-or-replace by name. Body may be:
100
101        * an empty program: only ``name`` + optional ``userName``.
102        * a full tree: ``tree`` field carrying the nested dict the
103          YAML serializer produced.
104        """
105        mgr = _require_manager(self.ctx)
106        data = data or {}
107        name = self._require_name(data)
108        tree = data.get("tree")
109        if tree is None:
110            prog = await mgr.create(
111                name,
112                user_name=str(data.get("userName") or ""),
113                enabled=bool(data.get("enabled", True)),
114            )
115        else:
116            if not isinstance(tree, dict):
117                raise JsonServiceError(
118                    400, "logixng.put tree must be a mapping",
119                )
120            try:
121                node = from_dict(tree)
122            except (ValueError, KeyError, TypeError) as e:
123                raise JsonServiceError(
124                    400, f"logixng tree decode failed: {e}",
125                ) from None
126            if not isinstance(node, LogixNG):
127                raise JsonServiceError(
128                    400, "logixng tree must have type=logixng:root",
129                )
130            prog = await mgr.put(name, node)
131        self._persist(mgr, name)
132        return _project(name, prog)
133
134    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
135        mgr = _require_manager(self.ctx)
136        name = self._require_name(data or {})
137        if not await mgr.delete(name):
138            raise JsonServiceError(404, f"no logixng named {name!r}")
139        self._persist_delete(name)
140        return {"name": name, "deleted": True}
141
142    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
143        mgr = _require_manager(self.ctx)
144        data = data or {}
145        name = self._require_name(data)
146        prog = mgr.get(name)
147        if prog is None:
148            raise JsonServiceError(404, f"no logixng named {name!r}")
149        command = data.get("command")
150        if command == "enable":
151            prog.enabled = True
152            await mgr.updated.emit(prog)
153            self._persist(mgr, name)
154        elif command == "disable":
155            prog.enabled = False
156            await mgr.updated.emit(prog)
157            self._persist(mgr, name)
158        elif command == "run":
159            await self._run(prog)
160        elif command == "run_conditional":
161            target_id = data.get("conditional_id")
162            if not isinstance(target_id, str):
163                raise JsonServiceError(
164                    400,
165                    "logixng.run_conditional requires 'conditional_id'",
166                )
167            await self._run_one(prog, target_id)
168        else:
169            raise JsonServiceError(400, f"unknown logixng command: {command!r}")
170        return _project(name, prog)
171
172    # ---- helpers -------------------------------------------------------
173
174    async def _run(self, prog: LogixNG) -> None:
175        exec_ctx = ExecutionContext(connection=self.ctx.connection)
176        try:
177            await prog.execute_all(exec_ctx)
178        except Exception as e:  # noqa: BLE001
179            _log.exception("LogixNG %s run failed", prog.user_name or "?")
180            raise JsonServiceError(500, f"logixng run failed: {e}") from None
181
182    async def _run_one(self, prog: LogixNG, conditional_id: str) -> None:
183        target = next(
184            (c for c in prog.conditionals if c.id == conditional_id),
185            None,
186        )
187        if target is None:
188            raise JsonServiceError(
189                404, f"no conditional {conditional_id!r} in this logixng",
190            )
191        exec_ctx = ExecutionContext(connection=self.ctx.connection)
192        try:
193            await target.execute(exec_ctx)
194        except Exception as e:  # noqa: BLE001
195            _log.exception("LogixNG conditional %s run failed", conditional_id)
196            raise JsonServiceError(
197                500, f"conditional run failed: {e}",
198            ) from None
199
200    def _require_name(self, data: dict[str, Any]) -> str:
201        name = data.get("name")
202        if not isinstance(name, str) or not name:
203            raise JsonServiceError(400, "logixng request requires 'name'")
204        return name
205
206    def _persist(self, mgr: LogixNGManager, name: str) -> None:
207        root = getattr(self.ctx, "_logixng_dir", None)
208        if root is None:
209            return
210        mgr.save_one(root, name)
211
212    def _persist_delete(self, name: str) -> None:
213        root = getattr(self.ctx, "_logixng_dir", None)
214        mgr = _require_manager(self.ctx)
215        if root is not None:
216            mgr.delete_on_disk(root, name)

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'logixng'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
81    async def on_list(
82        self, data: dict[str, Any] | None,
83    ) -> list[dict[str, Any]]:
84        mgr = _require_manager(self.ctx)
85        return [
86            _project(name, mgr.get(name))       # type: ignore[arg-type]
87            for name in mgr.names()
88        ]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
90    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
91        mgr = _require_manager(self.ctx)
92        name = self._require_name(data or {})
93        prog = mgr.get(name)
94        if prog is None:
95            raise JsonServiceError(404, f"no logixng named {name!r}")
96        return _project(name, prog)
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
 98    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
 99        """Create-or-replace by name. Body may be:
100
101        * an empty program: only ``name`` + optional ``userName``.
102        * a full tree: ``tree`` field carrying the nested dict the
103          YAML serializer produced.
104        """
105        mgr = _require_manager(self.ctx)
106        data = data or {}
107        name = self._require_name(data)
108        tree = data.get("tree")
109        if tree is None:
110            prog = await mgr.create(
111                name,
112                user_name=str(data.get("userName") or ""),
113                enabled=bool(data.get("enabled", True)),
114            )
115        else:
116            if not isinstance(tree, dict):
117                raise JsonServiceError(
118                    400, "logixng.put tree must be a mapping",
119                )
120            try:
121                node = from_dict(tree)
122            except (ValueError, KeyError, TypeError) as e:
123                raise JsonServiceError(
124                    400, f"logixng tree decode failed: {e}",
125                ) from None
126            if not isinstance(node, LogixNG):
127                raise JsonServiceError(
128                    400, "logixng tree must have type=logixng:root",
129                )
130            prog = await mgr.put(name, node)
131        self._persist(mgr, name)
132        return _project(name, prog)

Create-or-replace by name. Body may be:

  • an empty program: only name + optional userName.
  • a full tree: tree field carrying the nested dict the YAML serializer produced.
async def on_delete(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
134    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
135        mgr = _require_manager(self.ctx)
136        name = self._require_name(data or {})
137        if not await mgr.delete(name):
138            raise JsonServiceError(404, f"no logixng named {name!r}")
139        self._persist_delete(name)
140        return {"name": name, "deleted": True}
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
142    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
143        mgr = _require_manager(self.ctx)
144        data = data or {}
145        name = self._require_name(data)
146        prog = mgr.get(name)
147        if prog is None:
148            raise JsonServiceError(404, f"no logixng named {name!r}")
149        command = data.get("command")
150        if command == "enable":
151            prog.enabled = True
152            await mgr.updated.emit(prog)
153            self._persist(mgr, name)
154        elif command == "disable":
155            prog.enabled = False
156            await mgr.updated.emit(prog)
157            self._persist(mgr, name)
158        elif command == "run":
159            await self._run(prog)
160        elif command == "run_conditional":
161            target_id = data.get("conditional_id")
162            if not isinstance(target_id, str):
163                raise JsonServiceError(
164                    400,
165                    "logixng.run_conditional requires 'conditional_id'",
166                )
167            await self._run_one(prog, target_id)
168        else:
169            raise JsonServiceError(400, f"unknown logixng command: {command!r}")
170        return _project(name, prog)
class LogsService(jmri_server.service.JsonService):
33class LogsService(JsonService):
34    topic = "logs"
35
36    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
37        data = data or {}
38        try:
39            requested = int(data.get("tail", _DEFAULT_TAIL))
40        except (TypeError, ValueError):
41            requested = _DEFAULT_TAIL
42        n = max(0, min(requested, _MAX_TAIL))
43
44        path = getattr(self.ctx, "_log_file", None)
45        if path is None:
46            return {"path": None, "tail": n, "lines": []}
47        return {
48            "path": str(path),
49            "tail": n,
50            "lines": _tail(path, n),
51        }

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'logs'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
36    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
37        data = data or {}
38        try:
39            requested = int(data.get("tail", _DEFAULT_TAIL))
40        except (TypeError, ValueError):
41            requested = _DEFAULT_TAIL
42        n = max(0, min(requested, _MAX_TAIL))
43
44        path = getattr(self.ctx, "_log_file", None)
45        if path is None:
46            return {"path": None, "tail": n, "lines": []}
47        return {
48            "path": str(path),
49            "tail": n,
50            "lines": _tail(path, n),
51        }
class LightService(jmri_server.services.beans._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.

class MemoryService(jmri_server.services.beans._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 MetadataService(jmri_server.service.JsonService):
118class MetadataService(JsonService):
119    """Server-identity strings the JMRI mobile clients query on connect."""
120
121    topic = "metadata"
122
123    async def on_list(
124        self, data: dict[str, Any] | None,
125    ) -> list[dict[str, Any]]:
126        return [
127            {"name": name, "value": _resolve_metadata(self.ctx, name) or ""}
128            for name in _METADATA_NAMES
129        ]
130
131    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
132        data = data or {}
133        name = data.get("name")
134        if not isinstance(name, str) or not name:
135            raise JsonServiceError(400, "metadata.get requires 'name'")
136        value = _resolve_metadata(self.ctx, name)
137        if value is None:
138            raise JsonServiceError(404, f"no metadata for {name!r}")
139        return {"name": name, "value": value}

Server-identity strings the JMRI mobile clients query on connect.

topic = 'metadata'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
123    async def on_list(
124        self, data: dict[str, Any] | None,
125    ) -> list[dict[str, Any]]:
126        return [
127            {"name": name, "value": _resolve_metadata(self.ctx, name) or ""}
128            for name in _METADATA_NAMES
129        ]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
131    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
132        data = data or {}
133        name = data.get("name")
134        if not isinstance(name, str) or not name:
135            raise JsonServiceError(400, "metadata.get requires 'name'")
136        value = _resolve_metadata(self.ctx, name)
137        if value is None:
138            raise JsonServiceError(404, f"no metadata for {name!r}")
139        return {"name": name, "value": value}
class NetworkServiceService(jmri_server.service.JsonService):
221class NetworkServiceService(JsonService):
222    """Advertised network endpoints. Mimics the Zeroconf list JMRI Java
223    produces so mobile clients see JSON + WiThrottle in one place."""
224
225    topic = "networkService"
226
227    async def on_list(
228        self, data: dict[str, Any] | None,
229    ) -> list[dict[str, Any]]:
230        return _all_services(self.ctx)
231
232    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
233        data = data or {}
234        name = data.get("name")
235        if not isinstance(name, str) or not name:
236            raise JsonServiceError(400, "networkService.get requires 'name'")
237        for svc in _all_services(self.ctx):
238            if svc["name"] == name:
239                return svc
240        raise JsonServiceError(404, f"no networkService named {name!r}")

Advertised network endpoints. Mimics the Zeroconf list JMRI Java produces so mobile clients see JSON + WiThrottle in one place.

topic = 'networkService'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
227    async def on_list(
228        self, data: dict[str, Any] | None,
229    ) -> list[dict[str, Any]]:
230        return _all_services(self.ctx)
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
232    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
233        data = data or {}
234        name = data.get("name")
235        if not isinstance(name, str) or not name:
236            raise JsonServiceError(400, "networkService.get requires 'name'")
237        for svc in _all_services(self.ctx):
238            if svc["name"] == name:
239                return svc
240        raise JsonServiceError(404, f"no networkService named {name!r}")
class NodeService(jmri_server.service.JsonService):
58class NodeService(JsonService):
59    topic = "node"
60
61    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
62        return {"node": self.ctx.metadata.node, "formerNodes": []}

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'node'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
61    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
62        return {"node": self.ctx.metadata.node, "formerNodes": []}
class OBlockService(jmri_server.services.beans._BeanService):
17class OBlockService(_BeanService):
18    topic = "oblock"
19
20    def manager(self) -> OBlockManager:
21        return self.ctx.connection.oblocks
22
23    def project(self, bean: OBlock) -> dict[str, Any]:
24        return _common_named_bean(bean) | {
25            "status": bean.status,
26            "value": bean.value,
27            "sensor": bean.sensor_name,
28            "warrant": bean.warrant_name,
29            "train": bean.train_name,
30        }
31
32    async def apply(self, bean: OBlock, data: dict[str, Any]) -> None:
33        if "value" in data:
34            v = data["value"]
35            bean.value = str(v) if v is not None else None
36        if "sensor" in data:
37            v = data["sensor"]
38            bean.sensor_name = str(v) if v is not None else None
39        if "warrant" in data:
40            v = data["warrant"]
41            bean.warrant_name = str(v) if v is not None else None
42        if "train" in data:
43            v = data["train"]
44            bean.train_name = str(v) if v is not None else None
45        if "status" in data:
46            await bean.set_status(int(data["status"]))

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 = 'oblock'
def manager(self) -> jmri_core.beans.oblock.OBlockManager:
20    def manager(self) -> OBlockManager:
21        return self.ctx.connection.oblocks
def project(self, bean: jmri_core.beans.oblock.OBlock) -> dict[str, typing.Any]:
23    def project(self, bean: OBlock) -> dict[str, Any]:
24        return _common_named_bean(bean) | {
25            "status": bean.status,
26            "value": bean.value,
27            "sensor": bean.sensor_name,
28            "warrant": bean.warrant_name,
29            "train": bean.train_name,
30        }
async def apply( self, bean: jmri_core.beans.oblock.OBlock, data: dict[str, typing.Any]) -> None:
32    async def apply(self, bean: OBlock, data: dict[str, Any]) -> None:
33        if "value" in data:
34            v = data["value"]
35            bean.value = str(v) if v is not None else None
36        if "sensor" in data:
37            v = data["sensor"]
38            bean.sensor_name = str(v) if v is not None else None
39        if "warrant" in data:
40            v = data["warrant"]
41            bean.warrant_name = str(v) if v is not None else None
42        if "train" in data:
43            v = data["train"]
44            bean.train_name = str(v) if v is not None else None
45        if "status" in data:
46            await bean.set_status(int(data["status"]))

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

class PanelService(jmri_server.service.JsonService):
108class PanelService(JsonService):
109    """List panels on disk under the active profile.
110
111    Supports ``list`` and ``get`` (by ``name``). No ``put`` / ``post``
112    / ``delete`` — panel authoring goes through Panel Editor, not this
113    service.
114    """
115
116    topic = "panel"
117
118    async def on_list(
119        self, data: dict[str, Any] | None,
120    ) -> list[dict[str, Any]]:
121        return list(self._all_panels())
122
123    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
124        data = data or {}
125        name = data.get("name")
126        if not isinstance(name, str) or not name:
127            raise JsonServiceError(400, "panel.get requires 'name'")
128        for panel in self._all_panels():
129            if panel["name"] == name:
130                return panel
131        raise JsonServiceError(404, f"no panel named {name!r}")
132
133    def _all_panels(self) -> list[dict[str, Any]]:
134        panels_dir = _panels_dir(self.ctx)
135        if panels_dir is None:
136            return []
137        out: list[dict[str, Any]] = []
138        for xml_path in sorted(panels_dir.glob("*.xml")):
139            projection = _project(xml_path)
140            if projection is not None:
141                out.append(projection)
142        return out

List panels on disk under the active profile.

Supports list and get (by name). No put / post / delete — panel authoring goes through Panel Editor, not this service.

topic = 'panel'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
118    async def on_list(
119        self, data: dict[str, Any] | None,
120    ) -> list[dict[str, Any]]:
121        return list(self._all_panels())
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
123    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
124        data = data or {}
125        name = data.get("name")
126        if not isinstance(name, str) or not name:
127            raise JsonServiceError(400, "panel.get requires 'name'")
128        for panel in self._all_panels():
129            if panel["name"] == name:
130                return panel
131        raise JsonServiceError(404, f"no panel named {name!r}")
class PingService(jmri_server.service.JsonService):
33class PingService(JsonService):
34    topic = "ping"
35
36    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
37        # JMRI returns pong as the response type at the envelope level, but
38        # the data slot for ping is empty. The dispatcher writes the envelope;
39        # we just return an empty object.
40        return {}

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'ping'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
36    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
37        # JMRI returns pong as the response type at the envelope level, but
38        # the data slot for ping is empty. The dispatcher writes the envelope;
39        # we just return an empty object.
40        return {}
class PowerService(jmri_server.service.JsonService):
65class PowerService(JsonService):
66    """Track power for the (single) active connection."""
67
68    topic = "power"
69
70    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
71        return self._snapshot()
72
73    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
74        return [self._snapshot()]
75
76    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
77        if data is None:
78            raise JsonServiceError(400, "power.post requires data")
79        if "state" in data:
80            await self.ctx.connection.power.set_power(power_from_wire(int(data["state"])))
81        return self._snapshot()
82
83    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
84        return await self.on_post(data)
85
86    def _snapshot(self) -> dict[str, Any]:
87        conn = self.ctx.connection
88        return {
89            "name": conn.prefix,
90            "default": True,
91            "state": to_wire(
92                int(conn.power.state) if conn.power.state != PowerState.UNKNOWN else 1
93            ),
94        }

Track power for the (single) active connection.

topic = 'power'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
70    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
71        return self._snapshot()
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
73    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
74        return [self._snapshot()]
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
76    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
77        if data is None:
78            raise JsonServiceError(400, "power.post requires data")
79        if "state" in data:
80            await self.ctx.connection.power.set_power(power_from_wire(int(data["state"])))
81        return self._snapshot()
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
83    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
84        return await self.on_post(data)
class ProgrammerService(jmri_server.service.JsonService):
158class ProgrammerService(JsonService):
159    topic = "programmer"
160
161    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
162        data = data or {}
163        cv = data.get("cv")
164        if not isinstance(cv, int):
165            raise JsonServiceError(400, "programmer.get requires integer 'cv'")
166        programmer = _resolve_programmer(self.ctx.connection, data)
167        try:
168            value = await programmer.read_cv(cv)
169        except TimeoutError:
170            raise JsonServiceError(504, f"timeout reading CV {cv}") from None
171        except NotImplementedError as e:
172            # e.g. NCE ops-mode CV read — no RailCom on PowerPro.
173            raise JsonServiceError(501, str(e)) from None
174        except Exception as e:  # noqa: BLE001
175            # Connection-specific errors (NceCommandError, LocoNet CS
176            # NACKs, DCC-EX parse failures). Surface as 502 so the UI
177            # can distinguish from timeouts/protocol errors upstream.
178            raise JsonServiceError(502, f"CV {cv} read failed: {e}") from None
179        # Record the freshly-read value in the in-memory cache so the
180        # next pane render (and subsequent variable_extract_from_state
181        # calls) sees it. JMRI Java treats reads as transient — no
182        # disk persistence, no ``.bak`` — until the user explicitly
183        # saves. We mirror that via ``record_read_result`` rather than
184        # ``set_cv``. A Read pane over 28 CVs used to translate into
185        # 28 atomic disk writes (rough on SD cards); now it's zero.
186        loco_id = data.get("locoId") or data.get("loco_id")
187        if isinstance(loco_id, str) and loco_id:
188            file_name = data.get("fileName") or data.get("file_name")
189            if not isinstance(file_name, str):
190                file_name = None
191            _loco_cv_store(self.ctx).record_read_result(
192                loco_id, str(cv), value, file_name,
193            )
194        return {"cv": cv, "value": value, "mode": programmer.mode.value}
195
196    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
197        data = data or {}
198        cv = data.get("cv")
199        value = data.get("value")
200        if not isinstance(cv, int):
201            raise JsonServiceError(400, "programmer.post requires integer 'cv'")
202        if not isinstance(value, int) or not CV_VALUE_MIN <= value <= CV_VALUE_MAX:
203            raise JsonServiceError(
204                400,
205                f"programmer.post requires integer 'value' {CV_VALUE_MIN}..{CV_VALUE_MAX}",
206            )
207        programmer = _resolve_programmer(self.ctx.connection, data)
208        try:
209            await programmer.write_cv(cv, value)
210        except TimeoutError:
211            raise JsonServiceError(504, f"timeout writing CV {cv}") from None
212        except NotImplementedError as e:
213            raise JsonServiceError(501, str(e)) from None
214        except Exception as e:  # noqa: BLE001
215            raise JsonServiceError(502, f"CV {cv} write failed: {e}") from None
216        # Persist the committed write to the per-loco CV file if a loco
217        # was identified — keeps the on-disk state in sync with what
218        # was actually programmed. JMRI's roster ``fileName`` attribute
219        # decouples loco-id from filename (e.g. id=459 fileName=1.xml).
220        loco_id = data.get("locoId") or data.get("loco_id")
221        if isinstance(loco_id, str) and loco_id:
222            file_name = data.get("fileName") or data.get("file_name")
223            if not isinstance(file_name, str):
224                file_name = None
225            _loco_cv_store(self.ctx).set_cv(loco_id, str(cv), value, file_name)
226        return {"cv": cv, "value": value, "mode": programmer.mode.value}
227
228    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
229        """Report what programmers are available on the active connection."""
230        out: list[dict[str, Any]] = []
231        global_prog = self.ctx.connection.programmers.global_programmer()
232        out.append({
233            "mode": ProgrammingMode.SERVICE_DIRECT.value,
234            "available": global_prog is not None,
235        })
236        # Ops-mode availability per-address; we report just the manager-level capability.
237        try:
238            ops_capable = (
239                self.ctx.connection.programmers.ops_programmer(3) is not None
240            )
241        except Exception:  # noqa: BLE001
242            ops_capable = False
243        out.append({
244            "mode": ProgrammingMode.OPS_MODE.value,
245            "available": ops_capable,
246        })
247        return out

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'programmer'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
161    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
162        data = data or {}
163        cv = data.get("cv")
164        if not isinstance(cv, int):
165            raise JsonServiceError(400, "programmer.get requires integer 'cv'")
166        programmer = _resolve_programmer(self.ctx.connection, data)
167        try:
168            value = await programmer.read_cv(cv)
169        except TimeoutError:
170            raise JsonServiceError(504, f"timeout reading CV {cv}") from None
171        except NotImplementedError as e:
172            # e.g. NCE ops-mode CV read — no RailCom on PowerPro.
173            raise JsonServiceError(501, str(e)) from None
174        except Exception as e:  # noqa: BLE001
175            # Connection-specific errors (NceCommandError, LocoNet CS
176            # NACKs, DCC-EX parse failures). Surface as 502 so the UI
177            # can distinguish from timeouts/protocol errors upstream.
178            raise JsonServiceError(502, f"CV {cv} read failed: {e}") from None
179        # Record the freshly-read value in the in-memory cache so the
180        # next pane render (and subsequent variable_extract_from_state
181        # calls) sees it. JMRI Java treats reads as transient — no
182        # disk persistence, no ``.bak`` — until the user explicitly
183        # saves. We mirror that via ``record_read_result`` rather than
184        # ``set_cv``. A Read pane over 28 CVs used to translate into
185        # 28 atomic disk writes (rough on SD cards); now it's zero.
186        loco_id = data.get("locoId") or data.get("loco_id")
187        if isinstance(loco_id, str) and loco_id:
188            file_name = data.get("fileName") or data.get("file_name")
189            if not isinstance(file_name, str):
190                file_name = None
191            _loco_cv_store(self.ctx).record_read_result(
192                loco_id, str(cv), value, file_name,
193            )
194        return {"cv": cv, "value": value, "mode": programmer.mode.value}
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
196    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
197        data = data or {}
198        cv = data.get("cv")
199        value = data.get("value")
200        if not isinstance(cv, int):
201            raise JsonServiceError(400, "programmer.post requires integer 'cv'")
202        if not isinstance(value, int) or not CV_VALUE_MIN <= value <= CV_VALUE_MAX:
203            raise JsonServiceError(
204                400,
205                f"programmer.post requires integer 'value' {CV_VALUE_MIN}..{CV_VALUE_MAX}",
206            )
207        programmer = _resolve_programmer(self.ctx.connection, data)
208        try:
209            await programmer.write_cv(cv, value)
210        except TimeoutError:
211            raise JsonServiceError(504, f"timeout writing CV {cv}") from None
212        except NotImplementedError as e:
213            raise JsonServiceError(501, str(e)) from None
214        except Exception as e:  # noqa: BLE001
215            raise JsonServiceError(502, f"CV {cv} write failed: {e}") from None
216        # Persist the committed write to the per-loco CV file if a loco
217        # was identified — keeps the on-disk state in sync with what
218        # was actually programmed. JMRI's roster ``fileName`` attribute
219        # decouples loco-id from filename (e.g. id=459 fileName=1.xml).
220        loco_id = data.get("locoId") or data.get("loco_id")
221        if isinstance(loco_id, str) and loco_id:
222            file_name = data.get("fileName") or data.get("file_name")
223            if not isinstance(file_name, str):
224                file_name = None
225            _loco_cv_store(self.ctx).set_cv(loco_id, str(cv), value, file_name)
226        return {"cv": cv, "value": value, "mode": programmer.mode.value}
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
228    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
229        """Report what programmers are available on the active connection."""
230        out: list[dict[str, Any]] = []
231        global_prog = self.ctx.connection.programmers.global_programmer()
232        out.append({
233            "mode": ProgrammingMode.SERVICE_DIRECT.value,
234            "available": global_prog is not None,
235        })
236        # Ops-mode availability per-address; we report just the manager-level capability.
237        try:
238            ops_capable = (
239                self.ctx.connection.programmers.ops_programmer(3) is not None
240            )
241        except Exception:  # noqa: BLE001
242            ops_capable = False
243        out.append({
244            "mode": ProgrammingMode.OPS_MODE.value,
245            "available": ops_capable,
246        })
247        return out

Report what programmers are available on the active connection.

class RailroadService(jmri_server.service.JsonService):
51class RailroadService(JsonService):
52    topic = "railroad"
53
54    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
55        return {"name": self.ctx.metadata.railroad}

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'railroad'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
54    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
55        return {"name": self.ctx.metadata.railroad}
class ReporterService(jmri_server.services.beans._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 RosterService(jmri_server.service.JsonService):
133class RosterService(JsonService):
134    """Full-CRUD roster service over the :class:`RosterStore` on the context."""
135
136    topic = "roster"
137
138    @property
139    def _store(self) -> Any:
140        """Return the active RosterStore. Lazily constructed on first
141        use via :func:`_lazy_roster_store`. Raises if no profile can
142        be resolved — the service can't function without one, unlike
143        the roster-media routes which just 404."""
144        store = _lazy_roster_store(self.ctx)
145        if store is None:
146            raise RuntimeError("no roster store on the context")
147        return store
148
149    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
150        profile = self._store.profile
151        return [_entry_to_json(e, profile=profile) for e in self._store]
152
153    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
154        data = data or {}
155        entry_id = data.get("id")
156        if not isinstance(entry_id, str):
157            raise JsonServiceError(400, "roster.get requires 'id'")
158        entry = self._store.get(entry_id)
159        if entry is None:
160            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
161        return _entry_to_json(entry, profile=self._store.profile)
162
163    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
164        data = data or {}
165        entry_id = data.get("id")
166        if not isinstance(entry_id, str) or not entry_id:
167            raise JsonServiceError(400, "roster.put requires a non-empty 'id'")
168        existing = self._store.get(entry_id)
169        entry = _json_to_entry(data, base=existing)
170        return _entry_to_json(self._store.put(entry), profile=self._store.profile)
171
172    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
173        data = data or {}
174        entry_id = data.get("id")
175        if not isinstance(entry_id, str):
176            raise JsonServiceError(400, "roster.post requires 'id'")
177        existing = self._store.get(entry_id)
178        if existing is None:
179            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
180        entry = _json_to_entry(data, base=existing)
181        return _entry_to_json(self._store.put(entry), profile=self._store.profile)
182
183    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
184        data = data or {}
185        entry_id = data.get("id")
186        if not isinstance(entry_id, str):
187            raise JsonServiceError(400, "roster.delete requires 'id'")
188        if not self._store.delete(entry_id):
189            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
190        return {"id": entry_id}

Full-CRUD roster service over the RosterStore on the context.

topic = 'roster'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
149    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
150        profile = self._store.profile
151        return [_entry_to_json(e, profile=profile) for e in self._store]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
153    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
154        data = data or {}
155        entry_id = data.get("id")
156        if not isinstance(entry_id, str):
157            raise JsonServiceError(400, "roster.get requires 'id'")
158        entry = self._store.get(entry_id)
159        if entry is None:
160            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
161        return _entry_to_json(entry, profile=self._store.profile)
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
163    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
164        data = data or {}
165        entry_id = data.get("id")
166        if not isinstance(entry_id, str) or not entry_id:
167            raise JsonServiceError(400, "roster.put requires a non-empty 'id'")
168        existing = self._store.get(entry_id)
169        entry = _json_to_entry(data, base=existing)
170        return _entry_to_json(self._store.put(entry), profile=self._store.profile)
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
172    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
173        data = data or {}
174        entry_id = data.get("id")
175        if not isinstance(entry_id, str):
176            raise JsonServiceError(400, "roster.post requires 'id'")
177        existing = self._store.get(entry_id)
178        if existing is None:
179            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
180        entry = _json_to_entry(data, base=existing)
181        return _entry_to_json(self._store.put(entry), profile=self._store.profile)
async def on_delete(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
183    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
184        data = data or {}
185        entry_id = data.get("id")
186        if not isinstance(entry_id, str):
187            raise JsonServiceError(400, "roster.delete requires 'id'")
188        if not self._store.delete(entry_id):
189            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
190        return {"id": entry_id}
class RouteService(jmri_server.services.beans._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.

class ScriptsService(jmri_server.service.JsonService):
 79class ScriptsService(JsonService):
 80    topic = "scripts"
 81    # Scripts execute arbitrary Python on the server — that's
 82    # remote-code-execution surface. Restrict mutations to admin
 83    # so a compromised operator token can't drop a script that
 84    # steals the settings dir or reads users.yaml. Reads (list /
 85    # get) are fine for operators — they may want to inspect
 86    # what's running.
 87    REQUIRED_ROLE = {
 88        "list": Role.OPERATOR,
 89        "get": Role.OPERATOR,
 90        "put": Role.ADMIN,
 91        "post": Role.ADMIN,
 92    }
 93
 94    @property
 95    def _runner(self) -> ScriptRunner:
 96        # Cache the runner on the context. Lazy because the event loop
 97        # isn't running at app-construction time.
 98        cached = getattr(self.ctx, "_script_runner", None)
 99        if cached is None:
100            loop = asyncio.get_event_loop()
101            cached = ScriptRunner.for_connection(loop, self.ctx.connection)
102            self.ctx._script_runner = cached  # type: ignore[attr-defined]
103        return cached
104
105    def _python_dirs(self) -> list[tuple[pathlib.Path, str]]:
106        """The two autoload/library dirs, in scan order, tagged by
107        source (``"shared"`` for the settings-root dir, ``"profile"``
108        for the active profile's dir). Missing profile → empty list."""
109        profile = getattr(self.ctx, "_roster_profile", None)
110        if not isinstance(profile, Profile):
111            return []
112        return [
113            (profile.shared_python_dir, "shared"),
114            (profile.python_dir, "profile"),
115        ]
116
117    def _profile_python_dir(self) -> pathlib.Path | None:
118        """Save target for user-submitted scripts. Shared scripts are
119        operator-managed; the UI writes to the per-profile dir only."""
120        profile = getattr(self.ctx, "_roster_profile", None)
121        return profile.python_dir if isinstance(profile, Profile) else None
122
123    def _find_script_on_disk(self, name: str) -> pathlib.Path | None:
124        """Locate ``<name>.py`` under the per-profile or shared dir.
125
126        Per-profile wins when both exist — matches the autoload
127        order that shipped in the python-dir migration. Returns
128        None when neither dir has the file; the caller surfaces
129        that as 404 so the UI can distinguish "script not on
130        disk" from "run failed"."""
131        for dir_path, _tag in self._python_dirs():
132            candidate = dir_path / f"{name}.py"
133            if candidate.is_file():
134                return candidate
135        return None
136
137    async def on_list(self, data: dict[str, Any] | None) -> Any:
138        data = data or {}
139        if data.get("library"):
140            # Library shape: everything the runner *knows about* (either
141            # running or persisted on disk) so the UI can render a
142            # single table. Scripts are keyed by name — a per-profile
143            # file with the same name as a shared one wins (last write
144            # in scan order), matching the runner's autoload behaviour.
145            statuses = {s.name: _status_to_dict(s) for s in self._runner.list()}
146            for dir_path, source_tag in self._python_dirs():
147                if not dir_path.is_dir():
148                    continue
149                for p in sorted(dir_path.glob("*.py")):
150                    if p.name.startswith("_"):
151                        continue
152                    entry = statuses.setdefault(p.stem, {
153                        "name": p.stem, "started_at": None,
154                        "finished": False, "error": None, "running": False,
155                        "stdout": "", "stderr": "",
156                    })
157                    entry["path"] = str(p)
158                    entry["source"] = source_tag
159            return list(statuses.values())
160        return [_status_to_dict(s) for s in self._runner.list()]
161
162    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
163        data = data or {}
164        name = data.get("name")
165        if not isinstance(name, str):
166            raise JsonServiceError(400, "scripts.get requires 'name'")
167        status = self._runner.get(name)
168        if status is None:
169            raise JsonServiceError(404, f"no script named {name!r}")
170        return _status_to_dict(status)
171
172    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
173        data = data or {}
174        name = data.get("name")
175        source = data.get("source")
176        save = bool(data.get("save", False))
177        if not isinstance(name, str) or not isinstance(source, str):
178            raise JsonServiceError(400, "scripts.put requires 'name' and 'source'")
179        if save:
180            # Persist to <profile>/python/<name>.py so the script
181            # autoloads on next boot. Requires an active profile.
182            # Shared scripts under <settings>/python/ are not writable
183            # via the API — operator drops them by hand.
184            _sanitize_library_name(name)
185            pdir = self._profile_python_dir()
186            if pdir is None:
187                raise JsonServiceError(
188                    409,
189                    "cannot save: no active profile has a python dir",
190                )
191            pdir.mkdir(parents=True, exist_ok=True)
192            path = pdir / f"{name}.py"
193            path.write_text(source, encoding="utf-8")
194            status = self._runner.run_file(path, name=name)
195        else:
196            status = self._runner.run_source(source, name=name)
197        return _status_to_dict(status)
198
199    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
200        data = data or {}
201        name = data.get("name")
202        action = data.get("action", "stop")
203        if not isinstance(name, str):
204            raise JsonServiceError(400, "scripts.post requires 'name'")
205        if action == "delete":
206            _sanitize_library_name(name)
207            # Stop any running instance, then remove the on-disk file.
208            # Only per-profile scripts are removable via the API —
209            # shared scripts under <settings>/python/ are operator
210            # artefacts. A shared file with the same name blocks the
211            # delete with 409 so the caller sees why.
212            pdir = self._profile_python_dir()
213            profile_path = (pdir / f"{name}.py") if pdir else None
214            shared_dir = None
215            profile = getattr(self.ctx, "_roster_profile", None)
216            if isinstance(profile, Profile):
217                shared_dir = profile.shared_python_dir
218            shared_path = (
219                shared_dir / f"{name}.py" if shared_dir else None
220            )
221            if (
222                (profile_path is None or not profile_path.is_file())
223                and shared_path is not None
224                and shared_path.is_file()
225            ):
226                raise JsonServiceError(
227                    409,
228                    f"script {name!r} lives under the shared python dir; "
229                    f"remove it from disk manually",
230                )
231            self._runner.stop(name)
232            if profile_path is not None and profile_path.is_file():
233                profile_path.unlink()
234            status = self._runner.get(name)
235            return _status_to_dict(status) if status else {"name": name, "deleted": True}
236        if action == "run_file":
237            # Re-read the on-disk file (per-profile first, then
238            # shared) and hand it to the runner. This is the "debug
239            # loop" primitive — edit the file externally, click
240            # Run in the UI, see fresh output. The runner replaces
241            # any existing status entry with the same name so
242            # re-runs stack cleanly (previous automatons get
243            # stopped first).
244            _sanitize_library_name(name)
245            path = self._find_script_on_disk(name)
246            if path is None:
247                raise JsonServiceError(
248                    404,
249                    f"no script named {name!r} on disk under the "
250                    "profile or shared python dirs",
251                )
252            status = self._runner.run_file(path, name=name)
253            return _status_to_dict(status)
254        if action != "stop":
255            raise JsonServiceError(400, f"unknown scripts action: {action!r}")
256        if not self._runner.stop(name):
257            raise JsonServiceError(404, f"no script named {name!r}")
258        status = self._runner.get(name)
259        return _status_to_dict(status) if status else {"name": name}

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'scripts'
REQUIRED_ROLE = {'list': <Role.OPERATOR: 'operator'>, 'get': <Role.OPERATOR: 'operator'>, 'put': <Role.ADMIN: 'admin'>, 'post': <Role.ADMIN: 'admin'>}
async def on_list(self, data: dict[str, typing.Any] | None) -> Any:
137    async def on_list(self, data: dict[str, Any] | None) -> Any:
138        data = data or {}
139        if data.get("library"):
140            # Library shape: everything the runner *knows about* (either
141            # running or persisted on disk) so the UI can render a
142            # single table. Scripts are keyed by name — a per-profile
143            # file with the same name as a shared one wins (last write
144            # in scan order), matching the runner's autoload behaviour.
145            statuses = {s.name: _status_to_dict(s) for s in self._runner.list()}
146            for dir_path, source_tag in self._python_dirs():
147                if not dir_path.is_dir():
148                    continue
149                for p in sorted(dir_path.glob("*.py")):
150                    if p.name.startswith("_"):
151                        continue
152                    entry = statuses.setdefault(p.stem, {
153                        "name": p.stem, "started_at": None,
154                        "finished": False, "error": None, "running": False,
155                        "stdout": "", "stderr": "",
156                    })
157                    entry["path"] = str(p)
158                    entry["source"] = source_tag
159            return list(statuses.values())
160        return [_status_to_dict(s) for s in self._runner.list()]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
162    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
163        data = data or {}
164        name = data.get("name")
165        if not isinstance(name, str):
166            raise JsonServiceError(400, "scripts.get requires 'name'")
167        status = self._runner.get(name)
168        if status is None:
169            raise JsonServiceError(404, f"no script named {name!r}")
170        return _status_to_dict(status)
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
172    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
173        data = data or {}
174        name = data.get("name")
175        source = data.get("source")
176        save = bool(data.get("save", False))
177        if not isinstance(name, str) or not isinstance(source, str):
178            raise JsonServiceError(400, "scripts.put requires 'name' and 'source'")
179        if save:
180            # Persist to <profile>/python/<name>.py so the script
181            # autoloads on next boot. Requires an active profile.
182            # Shared scripts under <settings>/python/ are not writable
183            # via the API — operator drops them by hand.
184            _sanitize_library_name(name)
185            pdir = self._profile_python_dir()
186            if pdir is None:
187                raise JsonServiceError(
188                    409,
189                    "cannot save: no active profile has a python dir",
190                )
191            pdir.mkdir(parents=True, exist_ok=True)
192            path = pdir / f"{name}.py"
193            path.write_text(source, encoding="utf-8")
194            status = self._runner.run_file(path, name=name)
195        else:
196            status = self._runner.run_source(source, name=name)
197        return _status_to_dict(status)
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
199    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
200        data = data or {}
201        name = data.get("name")
202        action = data.get("action", "stop")
203        if not isinstance(name, str):
204            raise JsonServiceError(400, "scripts.post requires 'name'")
205        if action == "delete":
206            _sanitize_library_name(name)
207            # Stop any running instance, then remove the on-disk file.
208            # Only per-profile scripts are removable via the API —
209            # shared scripts under <settings>/python/ are operator
210            # artefacts. A shared file with the same name blocks the
211            # delete with 409 so the caller sees why.
212            pdir = self._profile_python_dir()
213            profile_path = (pdir / f"{name}.py") if pdir else None
214            shared_dir = None
215            profile = getattr(self.ctx, "_roster_profile", None)
216            if isinstance(profile, Profile):
217                shared_dir = profile.shared_python_dir
218            shared_path = (
219                shared_dir / f"{name}.py" if shared_dir else None
220            )
221            if (
222                (profile_path is None or not profile_path.is_file())
223                and shared_path is not None
224                and shared_path.is_file()
225            ):
226                raise JsonServiceError(
227                    409,
228                    f"script {name!r} lives under the shared python dir; "
229                    f"remove it from disk manually",
230                )
231            self._runner.stop(name)
232            if profile_path is not None and profile_path.is_file():
233                profile_path.unlink()
234            status = self._runner.get(name)
235            return _status_to_dict(status) if status else {"name": name, "deleted": True}
236        if action == "run_file":
237            # Re-read the on-disk file (per-profile first, then
238            # shared) and hand it to the runner. This is the "debug
239            # loop" primitive — edit the file externally, click
240            # Run in the UI, see fresh output. The runner replaces
241            # any existing status entry with the same name so
242            # re-runs stack cleanly (previous automatons get
243            # stopped first).
244            _sanitize_library_name(name)
245            path = self._find_script_on_disk(name)
246            if path is None:
247                raise JsonServiceError(
248                    404,
249                    f"no script named {name!r} on disk under the "
250                    "profile or shared python dirs",
251                )
252            status = self._runner.run_file(path, name=name)
253            return _status_to_dict(status)
254        if action != "stop":
255            raise JsonServiceError(400, f"unknown scripts action: {action!r}")
256        if not self._runner.stop(name):
257            raise JsonServiceError(404, f"no script named {name!r}")
258        status = self._runner.get(name)
259        return _status_to_dict(status) if status else {"name": name}
class SensorService(jmri_server.services.beans._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 SignalHeadService(jmri_server.services.beans._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(jmri_server.services.beans._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 SignalSystemService(jmri_server.service.JsonService):
71class SignalSystemService(JsonService):
72    topic = "signalSystem"
73
74    async def on_list(
75        self, data: dict[str, Any] | None,
76    ) -> list[dict[str, Any]]:
77        return [_project(s) for s in _loader(self.ctx).systems()]
78
79    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
80        data = data or {}
81        system_id = data.get("id") or data.get("name")
82        if not isinstance(system_id, str) or not system_id:
83            raise JsonServiceError(
84                400, "signalSystem.get requires 'id' (or legacy 'name')",
85            )
86        system = _loader(self.ctx).get(system_id)
87        if system is None:
88            raise JsonServiceError(
89                404, f"no signal system with id {system_id!r}",
90            )
91        return _project(system)

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'signalSystem'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
74    async def on_list(
75        self, data: dict[str, Any] | None,
76    ) -> list[dict[str, Any]]:
77        return [_project(s) for s in _loader(self.ctx).systems()]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
79    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
80        data = data or {}
81        system_id = data.get("id") or data.get("name")
82        if not isinstance(system_id, str) or not system_id:
83            raise JsonServiceError(
84                400, "signalSystem.get requires 'id' (or legacy 'name')",
85            )
86        system = _loader(self.ctx).get(system_id)
87        if system is None:
88            raise JsonServiceError(
89                404, f"no signal system with id {system_id!r}",
90            )
91        return _project(system)
class SystemConnectionService(jmri_server.service.JsonService):
288class SystemConnectionService(JsonService):
289    """Active hardware connection(s). Enough for mobile clients to
290    build system-name pickers and route bean commands."""
291
292    topic = "systemConnection"
293
294    async def on_list(
295        self, data: dict[str, Any] | None,
296    ) -> list[dict[str, Any]]:
297        return _all_connections(self.ctx)
298
299    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
300        data = data or {}
301        name = data.get("name")
302        if not isinstance(name, str) or not name:
303            raise JsonServiceError(400, "systemConnection.get requires 'name'")
304        for conn in _all_connections(self.ctx):
305            if conn["name"] == name:
306                return conn
307        raise JsonServiceError(
308            404, f"no systemConnection named {name!r}",
309        )

Active hardware connection(s). Enough for mobile clients to build system-name pickers and route bean commands.

topic = 'systemConnection'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
294    async def on_list(
295        self, data: dict[str, Any] | None,
296    ) -> list[dict[str, Any]]:
297        return _all_connections(self.ctx)
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
299    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
300        data = data or {}
301        name = data.get("name")
302        if not isinstance(name, str) or not name:
303            raise JsonServiceError(400, "systemConnection.get requires 'name'")
304        for conn in _all_connections(self.ctx):
305            if conn["name"] == name:
306                return conn
307        raise JsonServiceError(
308            404, f"no systemConnection named {name!r}",
309        )
class ThrottleService(jmri_server.service.JsonService):
 77class ThrottleService(JsonService):
 78    topic = "throttle"
 79
 80    @property
 81    def _throttles(self) -> Any:
 82        """The ThrottleManager on the active system connection."""
 83        return self.ctx.connection.throttles
 84
 85    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
 86        throttle = _resolve_name(self._throttles, data or {})
 87        return _project(throttle)
 88
 89    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
 90        return [_project(t) for t in self._throttles]
 91
 92    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
 93        if not data:
 94            raise JsonServiceError(400, "throttle.put requires data")
 95        # Accept either ``address`` directly or ``name`` as a stringified address.
 96        address = data.get("address")
 97        if address is None:
 98            name = data.get("name") or data.get("throttle")
 99            try:
100                address = int(name) if name is not None else None
101            except (TypeError, ValueError):
102                address = None
103        if not isinstance(address, int):
104            raise JsonServiceError(400, "throttle.put requires 'address' or numeric 'name'")
105        long_address = bool(data.get("isLongAddress", False))
106        throttle = await self._throttles.acquire(address, long_address=long_address)
107        await _apply_mutations(throttle, data)
108        return _project(throttle)
109
110    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
111        if not data:
112            raise JsonServiceError(400, "throttle.post requires data")
113        throttle = _resolve_name(self._throttles, data)
114        # ``release`` field (any value, including null) triggers release.
115        if "release" in data:
116            await self._throttles.release(throttle)
117            return _project(throttle, released=True)
118        await _apply_mutations(throttle, data)
119        return _project(throttle)
120
121    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
122        throttle = _resolve_name(self._throttles, data or {})
123        await self._throttles.release(throttle)
124        return _project(throttle, released=True)

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'throttle'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
85    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
86        throttle = _resolve_name(self._throttles, data or {})
87        return _project(throttle)
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
89    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
90        return [_project(t) for t in self._throttles]
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
 92    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
 93        if not data:
 94            raise JsonServiceError(400, "throttle.put requires data")
 95        # Accept either ``address`` directly or ``name`` as a stringified address.
 96        address = data.get("address")
 97        if address is None:
 98            name = data.get("name") or data.get("throttle")
 99            try:
100                address = int(name) if name is not None else None
101            except (TypeError, ValueError):
102                address = None
103        if not isinstance(address, int):
104            raise JsonServiceError(400, "throttle.put requires 'address' or numeric 'name'")
105        long_address = bool(data.get("isLongAddress", False))
106        throttle = await self._throttles.acquire(address, long_address=long_address)
107        await _apply_mutations(throttle, data)
108        return _project(throttle)
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
110    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
111        if not data:
112            raise JsonServiceError(400, "throttle.post requires data")
113        throttle = _resolve_name(self._throttles, data)
114        # ``release`` field (any value, including null) triggers release.
115        if "release" in data:
116            await self._throttles.release(throttle)
117            return _project(throttle, released=True)
118        await _apply_mutations(throttle, data)
119        return _project(throttle)
async def on_delete(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
121    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
122        throttle = _resolve_name(self._throttles, data or {})
123        await self._throttles.release(throttle)
124        return _project(throttle, released=True)
class TimeService(jmri_server.service.JsonService):
119class TimeService(JsonService):
120    """Fast Clock JSON service. Topic: ``time``."""
121
122    topic = "time"
123
124    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
125        conn = _hardware_clock(self.ctx)
126        clock = _get_or_create_clock(self.ctx)
127        if conn is not None:
128            # When the CS cache is fresh, return the software bean —
129            # which was mirrored from the last CS read and continues to
130            # advance locally. This bounds CS-side traffic to one poll
131            # per ``HARDWARE_CLOCK_CACHE_S`` so co-tenant readers (turnout
132            # monitor, AIU poller) keep their airtime.
133            if _cache_is_fresh(self.ctx):
134                return _project(clock)
135            try:
136                state = await conn.read_hardware_clock()
137            except (TimeoutError, OSError) as e:
138                _log.warning("hardware clock read failed, using bean: %s", e)
139            else:
140                _mirror_to_bean(clock, state)
141                _record_cache(self.ctx)
142                return _project_hardware(state)
143        return _project(clock)
144
145    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
146        data = data or {}
147        clock = _get_or_create_clock(self.ctx)
148        conn = _hardware_clock(self.ctx)
149
150        # Apply each field if present. On hardware-backed connections,
151        # write through to the CS *first* — the software bean cache is
152        # then refreshed from a follow-up read. Hardware writes can time
153        # out under serial-link contention; surface those as a 504-ish
154        # JsonServiceError instead of a bare 500 so the UI can show a
155        # graceful message.
156        if "rate" in data:
157            try:
158                rate = float(data["rate"])
159            except (TypeError, ValueError):
160                raise JsonServiceError(400, "rate must be numeric") from None
161            if conn is not None:
162                try:
163                    await conn.set_hardware_clock_rate(rate)
164                except TimeoutError:
165                    raise JsonServiceError(
166                        504, "command station did not acknowledge the rate change"
167                    ) from None
168            try:
169                await clock.set_rate(rate)
170            except ValueError as e:
171                raise JsonServiceError(400, str(e)) from None
172
173        if "time" in data:
174            value = data["time"]
175            if not isinstance(value, str):
176                raise JsonServiceError(400, "time must be 'HH:MM' string")
177            hours, minutes = _parse_hhmm(value)
178            if conn is not None:
179                try:
180                    await conn.set_hardware_clock_time(hours, minutes, 0)
181                except TimeoutError:
182                    raise JsonServiceError(
183                        504, "command station did not acknowledge the time change"
184                    ) from None
185            try:
186                await clock.set_time(hours, minutes)
187            except ValueError as e:
188                raise JsonServiceError(400, str(e)) from None
189
190        if "running" in data:
191            run = bool(data["running"])
192            if conn is not None:
193                try:
194                    if run:
195                        await conn.start_hardware_clock()
196                    else:
197                        await conn.stop_hardware_clock()
198                except TimeoutError:
199                    raise JsonServiceError(
200                        504, "command station did not acknowledge start/stop"
201                    ) from None
202            if run:
203                await clock.start()
204            else:
205                await clock.stop()
206
207        # Re-project from the CS if we have one so the caller sees the
208        # authoritative state, not the (possibly stale) bean. A write
209        # always invalidates the cache — the caller needs the ground
210        # truth to know its update took effect.
211        if conn is not None:
212            _invalidate_cache(self.ctx)
213            try:
214                state = await conn.read_hardware_clock()
215            except (TimeoutError, OSError) as e:
216                _log.warning("hardware clock read after write failed: %s", e)
217            else:
218                _mirror_to_bean(clock, state)
219                _record_cache(self.ctx)
220                return _project_hardware(state)
221        return _project(clock)

Fast Clock JSON service. Topic: time.

topic = 'time'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
124    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
125        conn = _hardware_clock(self.ctx)
126        clock = _get_or_create_clock(self.ctx)
127        if conn is not None:
128            # When the CS cache is fresh, return the software bean —
129            # which was mirrored from the last CS read and continues to
130            # advance locally. This bounds CS-side traffic to one poll
131            # per ``HARDWARE_CLOCK_CACHE_S`` so co-tenant readers (turnout
132            # monitor, AIU poller) keep their airtime.
133            if _cache_is_fresh(self.ctx):
134                return _project(clock)
135            try:
136                state = await conn.read_hardware_clock()
137            except (TimeoutError, OSError) as e:
138                _log.warning("hardware clock read failed, using bean: %s", e)
139            else:
140                _mirror_to_bean(clock, state)
141                _record_cache(self.ctx)
142                return _project_hardware(state)
143        return _project(clock)
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
145    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
146        data = data or {}
147        clock = _get_or_create_clock(self.ctx)
148        conn = _hardware_clock(self.ctx)
149
150        # Apply each field if present. On hardware-backed connections,
151        # write through to the CS *first* — the software bean cache is
152        # then refreshed from a follow-up read. Hardware writes can time
153        # out under serial-link contention; surface those as a 504-ish
154        # JsonServiceError instead of a bare 500 so the UI can show a
155        # graceful message.
156        if "rate" in data:
157            try:
158                rate = float(data["rate"])
159            except (TypeError, ValueError):
160                raise JsonServiceError(400, "rate must be numeric") from None
161            if conn is not None:
162                try:
163                    await conn.set_hardware_clock_rate(rate)
164                except TimeoutError:
165                    raise JsonServiceError(
166                        504, "command station did not acknowledge the rate change"
167                    ) from None
168            try:
169                await clock.set_rate(rate)
170            except ValueError as e:
171                raise JsonServiceError(400, str(e)) from None
172
173        if "time" in data:
174            value = data["time"]
175            if not isinstance(value, str):
176                raise JsonServiceError(400, "time must be 'HH:MM' string")
177            hours, minutes = _parse_hhmm(value)
178            if conn is not None:
179                try:
180                    await conn.set_hardware_clock_time(hours, minutes, 0)
181                except TimeoutError:
182                    raise JsonServiceError(
183                        504, "command station did not acknowledge the time change"
184                    ) from None
185            try:
186                await clock.set_time(hours, minutes)
187            except ValueError as e:
188                raise JsonServiceError(400, str(e)) from None
189
190        if "running" in data:
191            run = bool(data["running"])
192            if conn is not None:
193                try:
194                    if run:
195                        await conn.start_hardware_clock()
196                    else:
197                        await conn.stop_hardware_clock()
198                except TimeoutError:
199                    raise JsonServiceError(
200                        504, "command station did not acknowledge start/stop"
201                    ) from None
202            if run:
203                await clock.start()
204            else:
205                await clock.stop()
206
207        # Re-project from the CS if we have one so the caller sees the
208        # authoritative state, not the (possibly stale) bean. A write
209        # always invalidates the cache — the caller needs the ground
210        # truth to know its update took effect.
211        if conn is not None:
212            _invalidate_cache(self.ctx)
213            try:
214                state = await conn.read_hardware_clock()
215            except (TimeoutError, OSError) as e:
216                _log.warning("hardware clock read after write failed: %s", e)
217            else:
218                _mirror_to_bean(clock, state)
219                _record_cache(self.ctx)
220                return _project_hardware(state)
221        return _project(clock)
class TurnoutService(jmri_server.services.beans._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 VersionService(jmri_server.service.JsonService):
43class VersionService(JsonService):
44    topic = "version"
45
46    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
47        m = self.ctx.metadata
48        return {"JMRI": m.jmri_version, "version": m.json_protocol_version}

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'version'
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
46    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
47        m = self.ctx.metadata
48        return {"JMRI": m.jmri_version, "version": m.json_protocol_version}
def register_all(registry: jmri_server.registry.ServiceRegistry) -> list[object]:
88def register_all(registry: "ServiceRegistry") -> list[object]:
89    return [registry.register(s) for s in ALL_SERVICES]