jmri_server.services.util
Introspection JSON services — metadata, networkService,
systemConnection.
These three sit under jmri.server.json.util in the Java source
(JsonUtilHttpService). They're grouped here for the same reason
they're grouped there — none of them own beans, none of them mutate
state, and every JMRI mobile client probes them on connect to decide
what the server can do. Reference material:
- metadata schema:
jmri/server/json/util/metadata-server.json - networkService schema:
jmri/server/json/util/networkService-server.json - systemConnection schema:
jmri/server/json/util/systemConnection-server.json
All three support get (by name) and list; none support
put / post / delete.
1"""Introspection JSON services — ``metadata``, ``networkService``, 2``systemConnection``. 3 4These three sit under ``jmri.server.json.util`` in the Java source 5(``JsonUtilHttpService``). They're grouped here for the same reason 6they're grouped there — none of them own beans, none of them mutate 7state, and every JMRI mobile client probes them on connect to decide 8what the server can do. Reference material: 9 10* metadata schema: ``jmri/server/json/util/metadata-server.json`` 11* networkService schema: ``jmri/server/json/util/networkService-server.json`` 12* systemConnection schema: ``jmri/server/json/util/systemConnection-server.json`` 13 14All three support ``get`` (by name) and ``list``; none support 15``put`` / ``post`` / ``delete``. 16""" 17from __future__ import annotations 18 19import os 20import platform 21import sys 22from typing import Any 23 24from jmri_server.service import JsonService, JsonServiceError 25 26 27# --------------------------------------------------------------------- 28# metadata 29# --------------------------------------------------------------------- 30# 31# Java's ``Metadata`` interface enumerates eight names plus a passthrough 32# to system properties for anything else. We honour the eight and 404 on 33# unknown names — the passthrough would leak arbitrary process env, which 34# isn't a shape mobile clients depend on. 35 36_METADATA_NAMES = ( 37 "JMRIVERSION", 38 "JMRIVERCANON", 39 "JMRIVERMAJOR", 40 "JMRIVERMINOR", 41 "JMRIVERTEST", 42 "JVMVERSION", 43 "JVMVENDOR", 44 "activeProfile", 45 "copyright", 46) 47 48_COPYRIGHT = ( 49 "Copyright © 1997-2026 JMRI Community. Python port " 50 "Copyright © 2026." 51) 52 53 54def _resolve_metadata(ctx: Any, name: str) -> str | None: 55 """Return the value for a metadata name, or ``None`` if unknown. 56 57 The JMRI simple-version string (e.g. ``5.14.0-py``) lives on 58 :class:`ServerMetadata`. Version parts are parsed lazily since the 59 string shape is stable and we've never needed the components at 60 runtime before now. 61 """ 62 m = ctx.metadata 63 version = str(m.jmri_version) 64 if name == "JMRIVERSION": 65 return version 66 if name == "JMRIVERCANON": 67 # Canonical == strip any suffix (``-py``, ``-alpha3``, ``+build.42``). 68 return _canonical_version(version) 69 if name in ("JMRIVERMAJOR", "JMRIVERMINOR", "JMRIVERTEST"): 70 major, minor, test = _split_version(version) 71 return { 72 "JMRIVERMAJOR": str(major), 73 "JMRIVERMINOR": str(minor), 74 "JMRIVERTEST": str(test), 75 }[name] 76 if name == "JVMVERSION": 77 return platform.python_version() 78 if name == "JVMVENDOR": 79 return sys.implementation.name # "cpython", "pypy", ... 80 if name == "activeProfile": 81 return str(m.active_profile) if m.active_profile is not None else None 82 if name == "copyright": 83 return _COPYRIGHT 84 return None 85 86 87def _canonical_version(version: str) -> str: 88 """Strip a prerelease / build suffix from *version*. 89 90 ``5.14.0-py`` → ``5.14.0``. ``5.14.0`` → ``5.14.0``. 91 """ 92 for sep in ("-", "+"): 93 if sep in version: 94 return version.split(sep, 1)[0] 95 return version 96 97 98def _split_version(version: str) -> tuple[int, int, int]: 99 """Split a semver-ish string into ``(major, minor, test)``. 100 101 Non-numeric parts default to 0. Absent components default to 0. 102 Callers pre-strip the suffix via :func:`_canonical_version`. 103 """ 104 parts = _canonical_version(version).split(".") 105 106 def as_int(index: int) -> int: 107 if index >= len(parts): 108 return 0 109 try: 110 return int(parts[index]) 111 except ValueError: 112 return 0 113 114 return as_int(0), as_int(1), as_int(2) 115 116 117class MetadataService(JsonService): 118 """Server-identity strings the JMRI mobile clients query on connect.""" 119 120 topic = "metadata" 121 122 async def on_list( 123 self, data: dict[str, Any] | None, 124 ) -> list[dict[str, Any]]: 125 return [ 126 {"name": name, "value": _resolve_metadata(self.ctx, name) or ""} 127 for name in _METADATA_NAMES 128 ] 129 130 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 131 data = data or {} 132 name = data.get("name") 133 if not isinstance(name, str) or not name: 134 raise JsonServiceError(400, "metadata.get requires 'name'") 135 value = _resolve_metadata(self.ctx, name) 136 if value is None: 137 raise JsonServiceError(404, f"no metadata for {name!r}") 138 return {"name": name, "value": value} 139 140 141# --------------------------------------------------------------------- 142# networkService 143# --------------------------------------------------------------------- 144# 145# Java lists real Zeroconf/mDNS advertisements. We don't run an mDNS 146# responder yet, but mobile clients still expect to see the JSON server 147# and (if enabled) the WiThrottle server here so they know where to 148# reach us. We synthesise those two entries from the server metadata 149# and the ports the operator started on. This is compatible with the 150# schema (``additionalProperties: true``) — mDNS-savvy clients see the 151# entries as if they'd been discovered. 152 153_JSON_SERVICE_TYPE = "_jmri-json._tcp.local." 154_WITHROTTLE_SERVICE_TYPE = "_withrottle._tcp.local." 155 156# Standard JMRI ports. Overridable at server start via env vars — same 157# convention the rest of the codebase uses for host / port config. 158_DEFAULT_JSON_PORT = 12080 159_DEFAULT_WITHROTTLE_PORT = 12090 160 161 162def _json_port(ctx: Any) -> int: 163 """Port the JSON HTTP + WebSocket service is bound to. 164 165 We can't inspect uvicorn's live bindings from inside a request, so 166 fall back through explicit ctx attribute → env var → default 12080. 167 The env-var read goes through :mod:`jmri_core.env` for consistency 168 with the rest of the ``JMRI_*`` knobs. 169 """ 170 stored = getattr(ctx, "_json_port", None) 171 if isinstance(stored, int): 172 return stored 173 from jmri_core.env import http_port # noqa: PLC0415 174 return http_port(default=_DEFAULT_JSON_PORT) 175 176 177def _withrottle_port(ctx: Any) -> int | None: 178 """Port the WiThrottle server is bound to, or ``None`` if it isn't 179 running. ``attach_withrottle_server`` stores this on the ctx after 180 a successful bind.""" 181 stored = getattr(ctx, "_withrottle_port", None) 182 if isinstance(stored, int): 183 return stored 184 return None 185 186 187def _service_entry( 188 *, 189 type_: str, 190 ctx: Any, 191 port: int, 192) -> dict[str, Any]: 193 m = ctx.metadata 194 return { 195 "name": type_, 196 "userName": str(m.railroad), 197 "port": port, 198 "type": type_, 199 "version": str(m.jmri_version), 200 "jmri": str(m.jmri_version), 201 "json": str(m.json_protocol_version), 202 "node": str(m.node), 203 } 204 205 206def _all_services(ctx: Any) -> list[dict[str, Any]]: 207 services = [ 208 _service_entry( 209 type_=_JSON_SERVICE_TYPE, ctx=ctx, port=_json_port(ctx), 210 ) 211 ] 212 withrottle = _withrottle_port(ctx) 213 if withrottle is not None: 214 services.append(_service_entry( 215 type_=_WITHROTTLE_SERVICE_TYPE, ctx=ctx, port=withrottle, 216 )) 217 return services 218 219 220class NetworkServiceService(JsonService): 221 """Advertised network endpoints. Mimics the Zeroconf list JMRI Java 222 produces so mobile clients see JSON + WiThrottle in one place.""" 223 224 topic = "networkService" 225 226 async def on_list( 227 self, data: dict[str, Any] | None, 228 ) -> list[dict[str, Any]]: 229 return _all_services(self.ctx) 230 231 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 232 data = data or {} 233 name = data.get("name") 234 if not isinstance(name, str) or not name: 235 raise JsonServiceError(400, "networkService.get requires 'name'") 236 for svc in _all_services(self.ctx): 237 if svc["name"] == name: 238 return svc 239 raise JsonServiceError(404, f"no networkService named {name!r}") 240 241 242# --------------------------------------------------------------------- 243# systemConnection 244# --------------------------------------------------------------------- 245# 246# Java iterates over ``ConnectionConfigManager`` (configured drivers) 247# plus any additional ``SystemConnectionMemo`` instances, always 248# including the ``Internal`` connection so the client knows the prefix 249# for software-only beans. We ship a single active connection at a 250# time; report it, plus the internal-connection entry so any client 251# expecting "I" in the list still finds it. 252 253_INTERNAL_PREFIX = "I" 254_INTERNAL_NAME = "Internal" 255 256 257def _project_connection(conn: Any) -> dict[str, Any]: 258 """Project a :class:`SystemConnection` (or the internal marker) to 259 the wire shape. ``mfg`` and ``description`` are ``null`` — our 260 connections don't yet carry either field, and the schema allows 261 both to be null.""" 262 return { 263 "name": getattr(conn, "name", None) or None, 264 "prefix": getattr(conn, "prefix", ""), 265 "mfg": None, 266 "description": None, 267 } 268 269 270def _all_connections(ctx: Any) -> list[dict[str, Any]]: 271 active = ctx.connection 272 projections = [_project_connection(active)] 273 # Always include the internal connection so clients that expect an 274 # "I" prefix in the list find it, even when the active hardware 275 # connection is something else (NCE, LocoNet, DCC-EX). No duplicate 276 # if the active is already Internal. 277 if getattr(active, "prefix", "") != _INTERNAL_PREFIX: 278 projections.append({ 279 "name": _INTERNAL_NAME, 280 "prefix": _INTERNAL_PREFIX, 281 "mfg": None, 282 "description": None, 283 }) 284 return projections 285 286 287class SystemConnectionService(JsonService): 288 """Active hardware connection(s). Enough for mobile clients to 289 build system-name pickers and route bean commands.""" 290 291 topic = "systemConnection" 292 293 async def on_list( 294 self, data: dict[str, Any] | None, 295 ) -> list[dict[str, Any]]: 296 return _all_connections(self.ctx) 297 298 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 299 data = data or {} 300 name = data.get("name") 301 if not isinstance(name, str) or not name: 302 raise JsonServiceError(400, "systemConnection.get requires 'name'") 303 for conn in _all_connections(self.ctx): 304 if conn["name"] == name: 305 return conn 306 raise JsonServiceError( 307 404, f"no systemConnection named {name!r}", 308 )
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
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 )