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

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

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

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

topic = 'networkService'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
227    async def on_list(
228        self, data: dict[str, Any] | None,
229    ) -> list[dict[str, Any]]:
230        return _all_services(self.ctx)
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
232    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
233        data = data or {}
234        name = data.get("name")
235        if not isinstance(name, str) or not name:
236            raise JsonServiceError(400, "networkService.get requires 'name'")
237        for svc in _all_services(self.ctx):
238            if svc["name"] == name:
239                return svc
240        raise JsonServiceError(404, f"no networkService named {name!r}")
class SystemConnectionService(jmri_server.service.JsonService):
288class SystemConnectionService(JsonService):
289    """Active hardware connection(s). Enough for mobile clients to
290    build system-name pickers and route bean commands."""
291
292    topic = "systemConnection"
293
294    async def on_list(
295        self, data: dict[str, Any] | None,
296    ) -> list[dict[str, Any]]:
297        return _all_connections(self.ctx)
298
299    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
300        data = data or {}
301        name = data.get("name")
302        if not isinstance(name, str) or not name:
303            raise JsonServiceError(400, "systemConnection.get requires 'name'")
304        for conn in _all_connections(self.ctx):
305            if conn["name"] == name:
306                return conn
307        raise JsonServiceError(
308            404, f"no systemConnection named {name!r}",
309        )

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

topic = 'systemConnection'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
294    async def on_list(
295        self, data: dict[str, Any] | None,
296    ) -> list[dict[str, Any]]:
297        return _all_connections(self.ctx)
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
299    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
300        data = data or {}
301        name = data.get("name")
302        if not isinstance(name, str) or not name:
303            raise JsonServiceError(400, "systemConnection.get requires 'name'")
304        for conn in _all_connections(self.ctx):
305            if conn["name"] == name:
306                return conn
307        raise JsonServiceError(
308            404, f"no systemConnection named {name!r}",
309        )