jmri_server

jmri-server — Stage 3 of the JMRI Python rewrite.

A FastAPI + WebSocket server speaking JMRI's JSON wire protocol on top of jmri_core's in-memory InternalConnection. The HTTP and WebSocket endpoints share one dispatcher; every reply validates against the JSON schemas extracted from JMRI's jar in Stage 0.

 1"""jmri-server — Stage 3 of the JMRI Python rewrite.
 2
 3A FastAPI + WebSocket server speaking JMRI's JSON wire protocol on top of
 4:mod:`jmri_core`'s in-memory `InternalConnection`. The HTTP and WebSocket
 5endpoints share one dispatcher; every reply validates against the JSON
 6schemas extracted from JMRI's jar in Stage 0.
 7"""
 8from jmri_server.app import build_app, create_app
 9
10__version__ = "0.1.0"
11
12__all__ = ["build_app", "create_app", "__version__"]
def build_app( *, connection: jmri_core.hal.SystemConnection | None = None, connection_name: str | None = None, connection_settings: dict[str, typing.Any] | None = None, metadata: jmri_server.context.ServerMetadata | None = None) -> jmri_server.app.AppHandle:
190def build_app(
191    *,
192    connection: SystemConnection | None = None,
193    connection_name: str | None = None,
194    connection_settings: ConnectionSettings | None = None,
195    metadata: ServerMetadata | None = None,
196) -> AppHandle:
197    """Build a JMRI server.
198
199    Connection resolution (first match wins):
200
201    1. *connection* — already-constructed instance (tests use this).
202    2. *connection_name* + *connection_settings* — entry-point name.
203    3. ``JMRI_CONNECTION`` / ``JMRI_CONNECTION_SETTINGS`` env vars.
204    4. ``InternalConnection`` fallback.
205    """
206    if connection is None:
207        if connection_name is None:
208            connection_name, env_settings = _env_connection()
209            if connection_settings is None:
210                connection_settings = env_settings
211        connection = select_connection(connection_name, connection_settings)
212    conn = connection
213    ctx = ServerContext(connection=conn, metadata=metadata or ServerMetadata())
214    # Every context gets an empty LogixNGManager so services can rely
215    # on it being present. ``create_app`` (with a real profile) loads
216    # YAML programs into it during profile-persistence attachment.
217    try:
218        from jmri_logixng import LogixNGManager  # noqa: PLC0415
219        ctx.logix_ng = LogixNGManager()
220    except ImportError:
221        # jmri-logixng is an optional install; leave ctx.logix_ng None
222        # and the service will just report "not available" on any
223        # request.
224        pass
225
226    @asynccontextmanager
227    async def lifespan(_app: FastAPI):  # type: ignore[no-untyped-def]
228        # Configure logging FIRST — uvicorn installs its own root
229        # handlers during startup before our lifespan runs, and if we
230        # configure before uvicorn does, uvicorn's ``dictConfig`` call
231        # wipes our file handler. Doing it inside lifespan is the one
232        # spot that survives that reset. The resolved log-file path
233        # (or ``None`` when ``JMRI_LOG_FILE`` isn't set) also lands on
234        # the context here so the /logs endpoint and the home widget
235        # see it after the first request.
236        from jmri_core.log import configure as _configure_log  # noqa: PLC0415
237        try:
238            resolved = _configure_log()
239        except Exception:  # noqa: BLE001
240            _log.exception("logging bootstrap failed")
241            resolved = None
242        ctx._log_file = resolved  # type: ignore[attr-defined]
243        if resolved is not None:
244            _log.info("JMRI logs writing to %s", resolved)
245        else:
246            _log.info(
247                "JMRI file logging disabled (JMRI_LOG_FILE unset); "
248                "console only"
249            )
250        if not conn.is_open:
251            settings = conn.__dict__.pop("_open_settings", None)
252            await conn.open(settings or {})
253        # Pre-populate beans from disk if a config store has been attached.
254        # The store is opt-in (set by ``create_app()`` for the production
255        # entry point; tests skip it via ``build_app()`` directly).
256        if isinstance(ctx.config_store, ConfigStore):
257            await _restore_beans_from_config(conn, ctx.config_store)
258        # Load LogixNG programs from the profile's logixng dir if
259        # ``attach_profile_persistence`` recorded one.
260        logixng_dir = getattr(ctx, "_logixng_dir", None)
261        if (
262            ctx.logix_ng is not None
263            and logixng_dir is not None
264            and hasattr(logixng_dir, "is_dir")
265            and logixng_dir.is_dir()
266        ):
267            try:
268                loaded = await ctx.logix_ng.load_all(logixng_dir)
269            except Exception:  # noqa: BLE001
270                _log.exception("failed to load LogixNG programs")
271            else:
272                if loaded:
273                    _log.info("loaded %d LogixNG program(s)", loaded)
274        # Auto-launch profile scripts (jython/*.py) — alpha tester's
275        # explicit request that profile Load also fires up the
276        # railroad's startup scripts.
277        await _autolaunch_profile_scripts(ctx)
278        # Start the WiThrottle TCP server if it was attached.
279        withrottle = getattr(ctx, "withrottle", None)
280        if withrottle is not None:
281            host = getattr(ctx, "_withrottle_host", "0.0.0.0")
282            port = getattr(ctx, "_withrottle_port", 12090)
283            try:
284                await withrottle.start(host=host, port=port)
285            except OSError as e:
286                _log.warning("WiThrottle could not bind %s:%d (%s)", host, port, e)
287        # Periodic hardware-clock sync — see :mod:`jmri_server.clock_sync`.
288        # A no-op when the active connection has no ``read_hardware_clock``.
289        from jmri_server.clock_sync import start_sync_task  # noqa: PLC0415
290        ctx._clock_sync_task = start_sync_task(ctx)  # type: ignore[attr-defined]
291        try:
292            yield
293        finally:
294            from jmri_server.clock_sync import stop_sync_task  # noqa: PLC0415
295            await stop_sync_task(getattr(ctx, "_clock_sync_task", None))
296            if withrottle is not None and withrottle.is_running:
297                await withrottle.stop()
298            await push.aclose()
299            if conn.is_open:
300                await conn.close()
301
302    app = FastAPI(
303        title="jmri-server",
304        version=ctx.metadata.jmri_version,
305        lifespan=lifespan,
306    )
307    # Auth middleware wraps every request. Its dispatch is a no-op
308    # when ``ctx.auth is None`` (auth disabled) so registering it
309    # unconditionally keeps the code path uniform. Add BEFORE
310    # ``install_routes`` so it wraps them all; FastAPI applies
311    # middleware in reverse-registration order (last-in, first-out),
312    # so ``AuthMiddleware`` here means it's the OUTERMOST layer.
313    from jmri_server.auth.middleware import AuthMiddleware  # noqa: PLC0415
314    app.add_middleware(AuthMiddleware, ctx=ctx)
315
316    registry = ServiceRegistry(ctx)
317    register_all(registry)
318    push = PushManager(ctx, registry)
319    install_routes(app, ctx, registry, push)
320    return AppHandle(app=app, ctx=ctx, registry=registry, push=push)

Build a JMRI server.

Connection resolution (first match wins):

  1. connection — already-constructed instance (tests use this).
  2. connection_name + connection_settings — entry-point name.
  3. JMRI_CONNECTION / JMRI_CONNECTION_SETTINGS env vars.
  4. InternalConnection fallback.
def create_app() -> fastapi.applications.FastAPI:
519def create_app() -> FastAPI:
520    """Default ASGI entry point. ``uvicorn jmri_server:create_app --factory``.
521
522    Bootstraps a multi-profile installation:
523
524    * Reads ``<settings>/profiles.xml`` (if present) and exposes the
525      registry on ``ctx.profile_registry``. Users editing JMRI's
526      ``profiles.xml`` can use this server against their existing setup.
527    * Resolves the active profile (``.active_profile`` marker → first
528      profile → fallback ``default``) and attaches a :class:`ConfigStore`
529      so user-created beans persist into the chosen profile's
530      ``PanelPro.xml``.
531
532    Tests that build the app via :func:`build_app` directly skip this
533    setup for hermeticity.
534
535    Logging is configured inside the lifespan (see :func:`build_app`)
536    rather than here — uvicorn's own ``dictConfig`` call happens
537    between ``create_app`` returning and the lifespan startup, and it
538    would wipe any handlers we installed pre-lifespan. Configuring in
539    lifespan is the only spot that survives that reset.
540    """
541    handle = build_app()
542    attach_profile_persistence(handle)
543    attach_withrottle_server(handle)
544    # Wire auth last so it sees the fully-populated context.
545    # ``attach_auth`` is a no-op unless ``JMRI_AUTH_ENABLED=1``.
546    attach_auth(handle, default_settings_dir())
547    return handle.app

Default ASGI entry point. uvicorn jmri_server:create_app --factory.

Bootstraps a multi-profile installation:

  • Reads <settings>/profiles.xml (if present) and exposes the registry on ctx.profile_registry. Users editing JMRI's profiles.xml can use this server against their existing setup.
  • Resolves the active profile (.active_profile marker → first profile → fallback default) and attaches a ConfigStore so user-created beans persist into the chosen profile's PanelPro.xml.

Tests that build the app via build_app() directly skip this setup for hermeticity.

Logging is configured inside the lifespan (see build_app()) rather than here — uvicorn's own dictConfig call happens between create_app returning and the lifespan startup, and it would wipe any handlers we installed pre-lifespan. Configuring in lifespan is the only spot that survives that reset.

__version__ = '0.1.0'