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]
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().
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.
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.
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 }
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 )
Inherited Members
392class BlockService(_BeanService): 393 topic = "block" 394 395 def manager(self) -> Manager[Block]: 396 return self.ctx.connection.blocks 397 398 def project(self, bean: Block) -> dict[str, Any]: 399 v = bean.value 400 return _common_named_bean(bean) | { 401 "state": to_wire(int(bean.state)), 402 "value": v if v is None or isinstance(v, str) else str(v), 403 "sensor": bean.sensor.system_name if bean.sensor is not None else None, 404 "reporter": None, 405 "speed": "", 406 "curvature": 0, 407 "direction": 0, 408 "length": 0.0, 409 "permissive": False, 410 "speedLimit": -1.0, 411 "denied": [], 412 } 413 414 async def apply(self, bean: Block, data: dict[str, Any]) -> None: 415 if "value" in data: 416 await bean.set_value(data["value"]) 417 if "sensor" in data: 418 sensor_name = data["sensor"] 419 sensor = ( 420 self.ctx.connection.sensors.get(sensor_name) if sensor_name else None 421 ) 422 bean.attach_sensor(sensor)
Base class encapsulating get/list/put/post/delete plumbing.
Concrete bean services set topic and override manager(),
project(), and (when mutations are supported) apply().
398 def project(self, bean: Block) -> dict[str, Any]: 399 v = bean.value 400 return _common_named_bean(bean) | { 401 "state": to_wire(int(bean.state)), 402 "value": v if v is None or isinstance(v, str) else str(v), 403 "sensor": bean.sensor.system_name if bean.sensor is not None else None, 404 "reporter": None, 405 "speed": "", 406 "curvature": 0, 407 "direction": 0, 408 "length": 0.0, 409 "permissive": False, 410 "speedLimit": -1.0, 411 "denied": [], 412 }
414 async def apply(self, bean: Block, data: dict[str, Any]) -> None: 415 if "value" in data: 416 await bean.set_value(data["value"]) 417 if "sensor" in data: 418 sensor_name = data["sensor"] 419 sensor = ( 420 self.ctx.connection.sensors.get(sensor_name) if sensor_name else None 421 ) 422 bean.attach_sensor(sensor)
Apply mutations from a client request. Default: no-op.
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.
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)
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}
Inherited Members
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.
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 )
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.
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}
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 )
Inherited Members
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.
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.
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)
Inherited Members
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.
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 }
Inherited Members
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().
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.
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().
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 }
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.
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.
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+ optionaluserName. - a full tree:
treefield carrying the nested dict the YAML serializer produced.
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}
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)
Inherited Members
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.
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 }
Inherited Members
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().
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().
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.
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}
Inherited Members
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.
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}")
Inherited Members
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.
Inherited Members
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().
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.
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.
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}")
Inherited Members
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.
Inherited Members
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.
Inherited Members
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.
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}
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}
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.
Inherited Members
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.
Inherited Members
362class ReporterService(_BeanService): 363 topic = "reporter" 364 365 def manager(self) -> Manager[Reporter]: 366 return self.ctx.connection.reporters 367 368 def project(self, bean: Reporter) -> dict[str, Any]: 369 report = bean.current_report 370 last = bean.last_report 371 # ``value`` defaults to a sentinel string per JMRI convention. 372 value = ( 373 str(report) if report not in (None, "") else "<no report>" 374 ) 375 # state mirrors active/inactive based on whether we have a report. 376 state = 2 if report not in (None, "") else 4 # ACTIVE / INACTIVE wire 377 return _common_named_bean(bean) | { 378 "state": state, 379 "report": None if report is None else str(report), 380 "lastReport": None if last is None else str(last), 381 "value": value, 382 } 383 384 async def apply(self, bean: Reporter, data: dict[str, Any]) -> None: 385 if "report" in data: 386 await bean.set_report(data["report"])
Base class encapsulating get/list/put/post/delete plumbing.
Concrete bean services set topic and override manager(),
project(), and (when mutations are supported) apply().
368 def project(self, bean: Reporter) -> dict[str, Any]: 369 report = bean.current_report 370 last = bean.last_report 371 # ``value`` defaults to a sentinel string per JMRI convention. 372 value = ( 373 str(report) if report not in (None, "") else "<no report>" 374 ) 375 # state mirrors active/inactive based on whether we have a report. 376 state = 2 if report not in (None, "") else 4 # ACTIVE / INACTIVE wire 377 return _common_named_bean(bean) | { 378 "state": state, 379 "report": None if report is None else str(report), 380 "lastReport": None if last is None else str(last), 381 "value": value, 382 }
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.
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)
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)
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)
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}
Inherited Members
428class RouteService(_BeanService): 429 topic = "route" 430 431 def manager(self) -> Manager[Route]: 432 return self.ctx.connection.routes 433 434 def project(self, bean: Route) -> dict[str, Any]: 435 return _common_named_bean(bean) | {"state": to_wire(int(bean.state))} 436 437 async def apply(self, bean: Route, data: dict[str, Any]) -> None: 438 # JMRI's wire convention: posting state=8 (INCONSISTENT) triggers a fire. 439 # We accept either state == any non-zero or an explicit "fire" boolean. 440 if data.get("fire") or int(data.get("state", 0)) != 0: 441 try: 442 await bean.fire() 443 except RuntimeError as e: 444 raise JsonServiceError(409, str(e)) from None
Base class encapsulating get/list/put/post/delete plumbing.
Concrete bean services set topic and override manager(),
project(), and (when mutations are supported) apply().
437 async def apply(self, bean: Route, data: dict[str, Any]) -> None: 438 # JMRI's wire convention: posting state=8 (INCONSISTENT) triggers a fire. 439 # We accept either state == any non-zero or an explicit "fire" boolean. 440 if data.get("fire") or int(data.get("state", 0)) != 0: 441 try: 442 await bean.fire() 443 except RuntimeError as e: 444 raise JsonServiceError(409, str(e)) from None
Apply mutations from a client request. Default: no-op.
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.
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()]
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)
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)
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}
Inherited Members
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().
266class SignalHeadService(_BeanService): 267 topic = "signalHead" 268 269 def manager(self) -> Manager[SignalHead]: 270 return self.ctx.connection.signal_heads 271 272 def project(self, bean: SignalHead) -> dict[str, Any]: 273 appearance = bean.commanded_appearance 274 state = HELD_WIRE_STATE if bean.held else int(appearance) 275 return _common_named_bean(bean) | { 276 "userName": bean.user_name or "", # schema says string, not nullable 277 "lit": appearance != SignalAppearance.DARK, 278 "held": bean.held, 279 "state": state, 280 "appearance": int(appearance), 281 "appearanceName": _HEAD_APPEARANCE_NAMES.get(appearance, "Unknown"), 282 } 283 284 async def apply(self, bean: SignalHead, data: dict[str, Any]) -> None: 285 if "held" in data: 286 await bean.set_held(bool(data["held"])) 287 if "appearance" in data: 288 await bean.set_appearance(SignalAppearance(int(data["appearance"])))
Base class encapsulating get/list/put/post/delete plumbing.
Concrete bean services set topic and override manager(),
project(), and (when mutations are supported) apply().
272 def project(self, bean: SignalHead) -> dict[str, Any]: 273 appearance = bean.commanded_appearance 274 state = HELD_WIRE_STATE if bean.held else int(appearance) 275 return _common_named_bean(bean) | { 276 "userName": bean.user_name or "", # schema says string, not nullable 277 "lit": appearance != SignalAppearance.DARK, 278 "held": bean.held, 279 "state": state, 280 "appearance": int(appearance), 281 "appearanceName": _HEAD_APPEARANCE_NAMES.get(appearance, "Unknown"), 282 }
294class SignalMastService(_BeanService): 295 topic = "signalMast" 296 297 def manager(self) -> Manager[SignalMast]: 298 return self.ctx.connection.signal_masts 299 300 def project(self, bean: SignalMast) -> dict[str, Any]: 301 # state per schema: "dark" if not lit, "held" if held, else the aspect name. 302 if bean.held: 303 state = "Held" 304 elif not bean.aspect: 305 state = "Dark" 306 else: 307 state = bean.aspect 308 projection = _common_named_bean(bean) | { 309 "aspect": bean.aspect, 310 "lit": bool(bean.aspect) and not bean.held, 311 "held": bean.held, 312 "state": state, 313 "signalSystem": bean.signal_system or None, 314 } 315 # Best-effort valid-aspect list from the signal-system loader. 316 # Adds ``validAspects: [str, ...]`` when the mast carries a 317 # ``signal_system`` that the loader knows about; skipped when 318 # the loader hasn't been touched yet (avoids constructing it 319 # for every bean-list request when no XML root is configured). 320 loader = getattr(self.ctx, "_signal_system_loader", None) 321 if loader is not None and bean.signal_system: 322 system = loader.get(bean.signal_system) 323 if system is not None: 324 projection["validAspects"] = [ 325 a.name for a in system.aspects 326 ] 327 return projection 328 329 async def apply(self, bean: SignalMast, data: dict[str, Any]) -> None: 330 if "signalSystem" in data: 331 v = data["signalSystem"] 332 bean.signal_system = str(v) if v is not None else "" 333 if "aspect" in data: 334 await bean.set_aspect(str(data["aspect"])) 335 if "held" in data: 336 await bean.set_held(bool(data["held"]))
Base class encapsulating get/list/put/post/delete plumbing.
Concrete bean services set topic and override manager(),
project(), and (when mutations are supported) apply().
300 def project(self, bean: SignalMast) -> dict[str, Any]: 301 # state per schema: "dark" if not lit, "held" if held, else the aspect name. 302 if bean.held: 303 state = "Held" 304 elif not bean.aspect: 305 state = "Dark" 306 else: 307 state = bean.aspect 308 projection = _common_named_bean(bean) | { 309 "aspect": bean.aspect, 310 "lit": bool(bean.aspect) and not bean.held, 311 "held": bean.held, 312 "state": state, 313 "signalSystem": bean.signal_system or None, 314 } 315 # Best-effort valid-aspect list from the signal-system loader. 316 # Adds ``validAspects: [str, ...]`` when the mast carries a 317 # ``signal_system`` that the loader knows about; skipped when 318 # the loader hasn't been touched yet (avoids constructing it 319 # for every bean-list request when no XML root is configured). 320 loader = getattr(self.ctx, "_signal_system_loader", None) 321 if loader is not None and bean.signal_system: 322 system = loader.get(bean.signal_system) 323 if system is not None: 324 projection["validAspects"] = [ 325 a.name for a in system.aspects 326 ] 327 return projection
329 async def apply(self, bean: SignalMast, data: dict[str, Any]) -> None: 330 if "signalSystem" in data: 331 v = data["signalSystem"] 332 bean.signal_system = str(v) if v is not None else "" 333 if "aspect" in data: 334 await bean.set_aspect(str(data["aspect"])) 335 if "held" in data: 336 await bean.set_held(bool(data["held"]))
Apply mutations from a client request. Default: no-op.
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.
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)
Inherited Members
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.
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 )
Inherited Members
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.
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)
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)
Inherited Members
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.
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)
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)
Inherited Members
136class TurnoutService(_BeanService): 137 topic = "turnout" 138 139 def manager(self) -> Manager[Turnout]: 140 return self.ctx.connection.turnouts 141 142 def project(self, bean: Turnout) -> dict[str, Any]: 143 return _common_named_bean(bean) | { 144 "inverted": bean.inverted, 145 "state": to_wire(int(bean.known_state)), 146 "feedbackMode": int(bean.feedback_mode), 147 "feedbackModes": [int(f) for f in TurnoutFeedback], 148 } 149 150 async def apply(self, bean: Turnout, data: dict[str, Any]) -> None: 151 if "inverted" in data: 152 bean.inverted = bool(data["inverted"]) 153 if "feedbackMode" in data: 154 bean.feedback_mode = TurnoutFeedback(int(data["feedbackMode"])) 155 if "state" in data: 156 await bean.set_commanded_state(turnout_from_wire(int(data["state"]))) 157 158 def _persist(self, bean: AbstractNamedBean) -> None: 159 store = getattr(self.ctx, "config_store", None) 160 if store is None: 161 return 162 assert isinstance(bean, Turnout) 163 from jmri_core.persistence import TurnoutRow # noqa: PLC0415 164 store.upsert_turnout(TurnoutRow( 165 system_name=bean.system_name, 166 user_name=bean.user_name or None, 167 feedback=bean.feedback_mode.name if bean.feedback_mode else None, 168 inverted=bool(bean.inverted), 169 )) 170 171 def _persist_delete(self, system_name: str) -> None: 172 store = getattr(self.ctx, "config_store", None) 173 if store is not None: 174 store.remove_turnout(system_name)
Base class encapsulating get/list/put/post/delete plumbing.
Concrete bean services set topic and override manager(),
project(), and (when mutations are supported) apply().
150 async def apply(self, bean: Turnout, data: dict[str, Any]) -> None: 151 if "inverted" in data: 152 bean.inverted = bool(data["inverted"]) 153 if "feedbackMode" in data: 154 bean.feedback_mode = TurnoutFeedback(int(data["feedbackMode"])) 155 if "state" in data: 156 await bean.set_commanded_state(turnout_from_wire(int(data["state"])))
Apply mutations from a client request. Default: no-op.
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.