jmri_server.app

FastAPI app factory.

  1"""FastAPI app factory."""
  2from __future__ import annotations
  3
  4import json
  5import logging
  6import os
  7import pathlib
  8from contextlib import asynccontextmanager
  9from dataclasses import dataclass
 10
 11from fastapi import FastAPI
 12from jmri_core.hal.connection import ConnectionSettings, SystemConnection
 13from jmri_core.hal.internal import InternalConnection
 14from jmri_core.persistence import (
 15    Profile,
 16    ProfileRegistry,
 17    default_settings_dir,
 18    load_profile_registry,
 19)
 20from jmri_core.plugins import load_connections
 21from jmri_core.state import LightState, SensorState, TurnoutFeedback
 22
 23from jmri_server.config_store import ConfigStore
 24from jmri_server.context import ServerContext, ServerMetadata
 25from jmri_server.push import PushManager
 26from jmri_server.registry import ServiceRegistry
 27from jmri_server.routes import install_routes
 28from jmri_server.services import register_all
 29
 30_log = logging.getLogger(__name__)
 31
 32
 33def _autolaunch_python_dirs(ctx: ServerContext) -> list[pathlib.Path]:
 34    """Return the list of directories the autoloader scans, in order.
 35
 36    Order matters — files loaded later can rely on names/globals set
 37    up by files loaded earlier. Shared first because those are the
 38    operator-provided helpers; per-profile second so a profile's
 39    scripts can build on top.
 40    """
 41    p = getattr(ctx, "_roster_profile", None)
 42    if not isinstance(p, Profile):
 43        return []
 44    return [p.shared_python_dir, p.python_dir]
 45
 46
 47async def _autolaunch_profile_scripts(ctx: ServerContext) -> None:
 48    """Scan the python autoload dirs and run each ``*.py`` via the runner.
 49
 50    Called on initial startup (after beans restore in the lifespan) and
 51    again on every runtime profile switch. Two dirs are scanned in
 52    order:
 53
 54    1. ``<settings-root>/python/`` — cross-profile shared scripts.
 55    2. ``<profile>/python/`` — per-profile scripts.
 56
 57    Within each dir, files run in alphabetical order so users can
 58    prefix filenames (``00_init.py``, ``10_signals.py``) to control
 59    ordering. Files starting with ``_`` are skipped so shared helper
 60    modules that other scripts import stay importable but don't
 61    auto-run themselves. Failures are surfaced on the script's status
 62    entry (visible in the /scripts UI) — one bad script must not
 63    block the rest of the boot.
 64    """
 65    dirs = [d for d in _autolaunch_python_dirs(ctx) if d.is_dir()]
 66    if not dirs:
 67        return
 68    # Lazy-create the runner (same pattern as ScriptsService).
 69    runner = getattr(ctx, "_script_runner", None)
 70    if runner is None:
 71        import asyncio  # noqa: PLC0415
 72
 73        from jmri_scripting import ScriptRunner  # noqa: PLC0415
 74        runner = ScriptRunner.for_connection(
 75            asyncio.get_event_loop(), ctx.connection,
 76        )
 77        ctx._script_runner = runner  # type: ignore[attr-defined]
 78    for d in dirs:
 79        started = runner.load_directory(d)
 80        for status in started:
 81            _log.info("autolaunched %s script: %s", d.name, status.name)
 82
 83
 84async def _restore_beans_from_config(
 85    conn: SystemConnection, store: ConfigStore
 86) -> None:
 87    """Pre-populate the connection's bean managers from PanelPro.xml.
 88
 89    Restores only beans whose system-prefix matches the active connection
 90    (so an NCE driver picks up ``N``-prefixed beans, an internal
 91    connection picks up ``I``-prefixed, etc.). Beans from other prefixes
 92    persist in the config file but aren't materialized — there's no
 93    runtime manager that knows how to drive them. They round-trip
 94    untouched on the next save.
 95    """
 96    accepted_prefixes = {conn.prefix}
 97
 98    for row in store.turnouts():
 99        if not row.system_name or row.system_name[0] not in accepted_prefixes:
100            continue
101        try:
102            bean = await conn.turnouts.provide(row.system_name)
103        except (ValueError, NotImplementedError) as e:
104            _log.warning("restore turnout %s skipped: %s", row.system_name, e)
105            continue
106        if row.user_name:
107            conn.turnouts._set_user_name(bean, row.user_name)
108        if row.inverted:
109            bean.inverted = True
110        if row.feedback:
111            try:
112                bean.feedback_mode = TurnoutFeedback[row.feedback]
113            except KeyError:
114                pass
115
116    for srow in store.sensors():
117        if not srow.system_name or srow.system_name[0] not in accepted_prefixes:
118            continue
119        try:
120            sbean = await conn.sensors.provide(srow.system_name)
121        except (ValueError, NotImplementedError) as e:
122            _log.warning("restore sensor %s skipped: %s", srow.system_name, e)
123            continue
124        if srow.user_name:
125            conn.sensors._set_user_name(sbean, srow.user_name)
126        if srow.inverted:
127            sbean.inverted = True
128
129    for lrow in store.lights():
130        if not lrow.system_name or lrow.system_name[0] not in accepted_prefixes:
131            continue
132        try:
133            lbean = await conn.lights.provide(lrow.system_name)
134        except (ValueError, NotImplementedError) as e:
135            _log.warning("restore light %s skipped: %s", lrow.system_name, e)
136            continue
137        if lrow.user_name:
138            conn.lights._set_user_name(lbean, lrow.user_name)
139
140    # Silence unused-import warnings for state enums kept around for
141    # future restore extensions (light state, sensor state, ...).
142    _ = (LightState, SensorState)
143
144
145@dataclass
146class AppHandle:
147    """Bundle exposed by :func:`build_app` — useful for tests/dev."""
148
149    app: FastAPI
150    ctx: ServerContext
151    registry: ServiceRegistry
152    push: PushManager
153
154
155def select_connection(
156    name: str | None, settings: ConnectionSettings | None = None
157) -> SystemConnection:
158    """Construct a :class:`SystemConnection` by its entry-point *name*.
159
160    *name* must match an installed ``jmri.connection`` entry point. The
161    returned connection is **not** opened; :func:`build_app` opens it via
162    the FastAPI lifespan, passing the stashed *settings* through.
163    """
164    if name is None:
165        return InternalConnection()
166    connections = load_connections()
167    if name not in connections:
168        raise ValueError(
169            f"unknown connection {name!r}; installed: {sorted(connections)}"
170        )
171    instance = connections[name]()
172    instance.__dict__["_open_settings"] = settings or {}
173    return instance
174
175
176def _env_connection() -> tuple[str | None, ConnectionSettings | None]:
177    """Resolve the connection from environment variables.
178
179    Reads through :mod:`jmri_core.env` so operator-facing names
180    (``JMRI_CONNECTION`` / ``JMRI_CONNECTION_SETTINGS``) stay in one
181    registry with the rest of the JMRI env vars.
182    """
183    from jmri_core import env as jenv  # noqa: PLC0415
184    name = jenv.connection_name()
185    settings_dict = jenv.connection_settings()
186    return name, (settings_dict or None)
187
188
189def build_app(
190    *,
191    connection: SystemConnection | None = None,
192    connection_name: str | None = None,
193    connection_settings: ConnectionSettings | None = None,
194    metadata: ServerMetadata | None = None,
195) -> AppHandle:
196    """Build a JMRI server.
197
198    Connection resolution (first match wins):
199
200    1. *connection* — already-constructed instance (tests use this).
201    2. *connection_name* + *connection_settings* — entry-point name.
202    3. ``JMRI_CONNECTION`` / ``JMRI_CONNECTION_SETTINGS`` env vars.
203    4. ``InternalConnection`` fallback.
204    """
205    if connection is None:
206        if connection_name is None:
207            connection_name, env_settings = _env_connection()
208            if connection_settings is None:
209                connection_settings = env_settings
210        connection = select_connection(connection_name, connection_settings)
211    conn = connection
212    ctx = ServerContext(connection=conn, metadata=metadata or ServerMetadata())
213    # Every context gets an empty LogixNGManager so services can rely
214    # on it being present. ``create_app`` (with a real profile) loads
215    # YAML programs into it during profile-persistence attachment.
216    try:
217        from jmri_logixng import LogixNGManager  # noqa: PLC0415
218        ctx.logix_ng = LogixNGManager()
219    except ImportError:
220        # jmri-logixng is an optional install; leave ctx.logix_ng None
221        # and the service will just report "not available" on any
222        # request.
223        pass
224
225    @asynccontextmanager
226    async def lifespan(_app: FastAPI):  # type: ignore[no-untyped-def]
227        # Configure logging FIRST — uvicorn installs its own root
228        # handlers during startup before our lifespan runs, and if we
229        # configure before uvicorn does, uvicorn's ``dictConfig`` call
230        # wipes our file handler. Doing it inside lifespan is the one
231        # spot that survives that reset. The resolved log-file path
232        # (or ``None`` when ``JMRI_LOG_FILE`` isn't set) also lands on
233        # the context here so the /logs endpoint and the home widget
234        # see it after the first request.
235        from jmri_core.log import configure as _configure_log  # noqa: PLC0415
236        try:
237            resolved = _configure_log()
238        except Exception:  # noqa: BLE001
239            _log.exception("logging bootstrap failed")
240            resolved = None
241        ctx._log_file = resolved  # type: ignore[attr-defined]
242        if resolved is not None:
243            _log.info("JMRI logs writing to %s", resolved)
244        else:
245            _log.info(
246                "JMRI file logging disabled (JMRI_LOG_FILE unset); "
247                "console only"
248            )
249        if not conn.is_open:
250            settings = conn.__dict__.pop("_open_settings", None)
251            await conn.open(settings or {})
252        # Pre-populate beans from disk if a config store has been attached.
253        # The store is opt-in (set by ``create_app()`` for the production
254        # entry point; tests skip it via ``build_app()`` directly).
255        if isinstance(ctx.config_store, ConfigStore):
256            await _restore_beans_from_config(conn, ctx.config_store)
257        # Load LogixNG programs from the profile's logixng dir if
258        # ``attach_profile_persistence`` recorded one.
259        logixng_dir = getattr(ctx, "_logixng_dir", None)
260        if (
261            ctx.logix_ng is not None
262            and logixng_dir is not None
263            and hasattr(logixng_dir, "is_dir")
264            and logixng_dir.is_dir()
265        ):
266            try:
267                loaded = await ctx.logix_ng.load_all(logixng_dir)
268            except Exception:  # noqa: BLE001
269                _log.exception("failed to load LogixNG programs")
270            else:
271                if loaded:
272                    _log.info("loaded %d LogixNG program(s)", loaded)
273        # Auto-launch profile scripts (jython/*.py) — alpha tester's
274        # explicit request that profile Load also fires up the
275        # railroad's startup scripts.
276        await _autolaunch_profile_scripts(ctx)
277        # Start the WiThrottle TCP server if it was attached.
278        withrottle = getattr(ctx, "withrottle", None)
279        if withrottle is not None:
280            host = getattr(ctx, "_withrottle_host", "0.0.0.0")
281            port = getattr(ctx, "_withrottle_port", 12090)
282            try:
283                await withrottle.start(host=host, port=port)
284            except OSError as e:
285                _log.warning("WiThrottle could not bind %s:%d (%s)", host, port, e)
286        # Periodic hardware-clock sync — see :mod:`jmri_server.clock_sync`.
287        # A no-op when the active connection has no ``read_hardware_clock``.
288        from jmri_server.clock_sync import start_sync_task  # noqa: PLC0415
289        ctx._clock_sync_task = start_sync_task(ctx)  # type: ignore[attr-defined]
290        try:
291            yield
292        finally:
293            from jmri_server.clock_sync import stop_sync_task  # noqa: PLC0415
294            await stop_sync_task(getattr(ctx, "_clock_sync_task", None))
295            if withrottle is not None and withrottle.is_running:
296                await withrottle.stop()
297            await push.aclose()
298            if conn.is_open:
299                await conn.close()
300
301    app = FastAPI(
302        title="jmri-server",
303        version=ctx.metadata.jmri_version,
304        lifespan=lifespan,
305    )
306    # Auth middleware wraps every request. Its dispatch is a no-op
307    # when ``ctx.auth is None`` (auth disabled) so registering it
308    # unconditionally keeps the code path uniform. Add BEFORE
309    # ``install_routes`` so it wraps them all; FastAPI applies
310    # middleware in reverse-registration order (last-in, first-out),
311    # so ``AuthMiddleware`` here means it's the OUTERMOST layer.
312    from jmri_server.auth.middleware import AuthMiddleware  # noqa: PLC0415
313    app.add_middleware(AuthMiddleware, ctx=ctx)
314
315    registry = ServiceRegistry(ctx)
316    register_all(registry)
317    push = PushManager(ctx, registry)
318    install_routes(app, ctx, registry, push)
319    return AppHandle(app=app, ctx=ctx, registry=registry, push=push)
320
321
322def attach_auth(handle: AppHandle, settings_root: pathlib.Path) -> None:
323    """Enable auth when ``JMRI_AUTH_ENABLED=1``.
324
325    Called by :func:`create_app` after the settings root is
326    known. If the env var isn't set, this is a no-op and
327    :attr:`ServerContext.auth` stays ``None`` — every request
328    then runs as admin-equivalent (see :class:`AuthMiddleware`).
329
330    When enabled, loads or bootstraps ``users.yaml`` under
331    *settings_root* and hands the manager to the context. On
332    first run, the operator will find bootstrap credentials in
333    ``<settings-root>/admin_password.txt`` (mode 0600).
334    """
335    from jmri_core import env as jenv  # noqa: PLC0415
336    from jmri_server.auth import AUTH_ENABLED_ENV, build_auth_manager  # noqa: PLC0415
337    from jmri_core.env import _get_bool as _bool  # noqa: PLC0415
338
339    if not _bool(None, AUTH_ENABLED_ENV):
340        _log.info("auth disabled (%s not set); every request runs as admin",
341                  AUTH_ENABLED_ENV)
342        return
343    handle.ctx.auth = build_auth_manager(pathlib.Path(settings_root))
344    _log.warning(
345        "auth ENABLED — %d user(s) loaded. First-run bootstrap credentials "
346        "(if any) are at %s/admin_password.txt.",
347        len(handle.ctx.auth.users),
348        settings_root,
349    )
350    _ = jenv  # silence unused warning; kept for future expansion
351
352
353def _resolve_initial_profile(registry: ProfileRegistry) -> Profile:
354    """Active profile per the registry, falling back to a single 'default'."""
355    p = registry.active_profile()
356    if p is not None:
357        return p
358    return Profile(root=registry.settings_root, name="default")
359
360
361def attach_withrottle_server(
362    handle: AppHandle, *, host: str = "0.0.0.0", port: int = 12090,
363) -> None:
364    """Wire up a :class:`WiThrottleServer` on the handle's lifespan.
365
366    Engine Driver / WiThrottle iOS clients connect to ``host:port``
367    after the FastAPI lifespan opens the connection. The server shares
368    the active connection's :class:`ThrottleManager`, so any throttle
369    acquired over WiThrottle is the same bean the JSON/web layer drives.
370
371    Roster is fetched lazily from the roster service so profile
372    switches are picked up automatically.
373    """
374    try:
375        from jmri_withrottle import WiThrottleServer  # noqa: PLC0415
376    except ImportError:
377        _log.info("jmri_withrottle not installed; WiThrottle disabled")
378        return
379
380    def roster_provider() -> list[dict]:
381        """Return the active profile's roster as WiThrottle-shaped dicts.
382
383        The RosterService builds its store lazily, but a WiThrottle
384        client can connect *before* any HTTP request triggers that
385        construction. Build the store directly here so the very first
386        Engine Driver connection sees the full roster.
387        """
388        store = getattr(handle.ctx, "_roster_store", None)
389        if store is None:
390            from jmri_core.persistence.profile import Profile  # noqa: PLC0415
391
392            from jmri_server.roster_store import RosterStore  # noqa: PLC0415
393            profile = (
394                getattr(handle.ctx, "_roster_profile", None) or Profile.default()
395            )
396            store = RosterStore(profile=profile)
397            handle.ctx._roster_store = store  # type: ignore[attr-defined]
398        return [
399            {
400                "id": entry.id,
401                "dccAddress": entry.dcc_address,
402                "longAddress": entry.long_address,
403            }
404            for entry in store
405        ]
406
407    def turnout_provider() -> list[dict]:
408        """Return the connection's turnout inventory for the Turnouts tab."""
409        turnouts = handle.ctx.connection.turnouts
410        return [
411            {
412                "system_name": t.system_name,
413                "user_name": t.user_name or "",
414                "state": int(t.known_state),
415            }
416            for t in turnouts
417        ]
418
419    async def turnout_action_handler(system_name: str, action: str) -> None:
420        """Apply a WiThrottle client's turnout throw/close/toggle to the bean."""
421        from jmri_core.state import TurnoutState  # noqa: PLC0415
422        turnouts = handle.ctx.connection.turnouts
423        bean = turnouts.get_by_system_name(system_name)
424        if bean is None:
425            _log.info("WiThrottle turnout %s not found", system_name)
426            return
427        if action == "T":
428            target = TurnoutState.THROWN
429        elif action == "C":
430            target = TurnoutState.CLOSED
431        elif action == "2":
432            target = (
433                TurnoutState.THROWN
434                if bean.commanded_state == TurnoutState.CLOSED
435                else TurnoutState.CLOSED
436            )
437        else:
438            _log.info("WiThrottle unknown turnout action %r", action)
439            return
440        await bean.set_commanded_state(target)
441
442    def fast_clock_provider() -> str | None:
443        """Return the current fast-clock PFT line, or None if no clock."""
444        clock = getattr(handle.ctx, "fast_clock", None)
445        if clock is None:
446            return None
447        from jmri_withrottle import protocol as wp  # noqa: PLC0415
448        # Include seconds so the client's initial extrapolation starts
449        # from the correct point — without this we lose up to 59
450        # fast-seconds, which at 8x rate maps to ~7 real seconds of
451        # visible drift between broadcasts.
452        total = int(clock.time_seconds())
453        h = total // 3600
454        m = (total % 3600) // 60
455        s = total % 60
456        unix_seconds = wp.fast_clock_local_unix_seconds(h, m, s)
457        # JMRI signals "stopped" by broadcasting a follow-up ``PFT`` with
458        # rate 0.0; for a stopped clock we send rate 0.0 directly on the
459        # single sync line — Engine Driver reads the rate to know whether
460        # to advance its local display.
461        rate = clock.rate if clock.running else 0.0
462        return wp.fast_clock(unix_seconds, rate)
463
464    withrottle = WiThrottleServer(
465        connection=handle.ctx.connection,
466        roster_provider=roster_provider,
467        fast_clock_provider=fast_clock_provider,
468        turnout_provider=turnout_provider,
469        turnout_action_handler=turnout_action_handler,
470        railroad=handle.ctx.metadata.railroad,
471    )
472    handle.ctx.withrottle = withrottle  # type: ignore[attr-defined]
473    # Turnout ``state_changed`` subscription is wired inside
474    # ``WiThrottleServer.start()`` — must happen post-``conn.open()``
475    # because ``open()`` swaps ``conn.turnouts`` for the driver-specific
476    # manager. Subscribing here would leak to the old base manager.
477    handle.ctx._withrottle_host = host  # type: ignore[attr-defined]
478    handle.ctx._withrottle_port = port  # type: ignore[attr-defined]
479
480
481def attach_profile_persistence(handle: AppHandle) -> None:
482    """Attach the multi-profile registry + ConfigStore to *handle*.
483
484    Reads ``<settings>/profiles.xml`` from :func:`default_settings_dir`
485    (honouring ``JMRI_SETTINGS_DIR``), exposes the registry on
486    ``ctx.profile_registry``, and points :class:`ConfigStore` **and**
487    the (lazy) roster store at the active profile. Idempotent — calling
488    twice replaces what's there.
489
490    Used by both :func:`create_app` and by ``jmri-web``'s app factory so
491    every production entry point gets profile-driven persistence.
492    """
493    settings_root = default_settings_dir()
494    try:
495        registry = load_profile_registry(settings_root)
496    except (OSError, ValueError) as e:
497        _log.warning("could not load profiles.xml at %s: %s", settings_root, e)
498        registry = ProfileRegistry(settings_root=settings_root, profiles=[])
499    handle.ctx.profile_registry = registry
500    active_profile = _resolve_initial_profile(registry)
501    handle.ctx.config_store = ConfigStore(profile=active_profile)
502    # The roster service constructs its store lazily off ``_roster_profile``;
503    # point it at the same profile so the roster + bean tables stay in
504    # sync. Drop any pre-existing lazy store so the next request rebuilds
505    # it against the new path.
506    handle.ctx._roster_profile = active_profile  # type: ignore[attr-defined]
507    if hasattr(handle.ctx, "_roster_store"):
508        delattr(handle.ctx, "_roster_store")
509    # LogixNG programs live at ``<profile>/logixng/*.yaml``. Load them
510    # onto the manager built in ``build_app`` so the JSON service and
511    # the /logixng page see the active profile's programs immediately.
512    # We schedule the load on the app's lifespan startup rather than
513    # doing it inline here so the async loader doesn't need a running
514    # event loop yet (this function is sync).
515    handle.ctx._logixng_dir = active_profile.logixng_dir  # type: ignore[attr-defined]
516
517
518def create_app() -> FastAPI:
519    """Default ASGI entry point. ``uvicorn jmri_server:create_app --factory``.
520
521    Bootstraps a multi-profile installation:
522
523    * Reads ``<settings>/profiles.xml`` (if present) and exposes the
524      registry on ``ctx.profile_registry``. Users editing JMRI's
525      ``profiles.xml`` can use this server against their existing setup.
526    * Resolves the active profile (``.active_profile`` marker → first
527      profile → fallback ``default``) and attaches a :class:`ConfigStore`
528      so user-created beans persist into the chosen profile's
529      ``PanelPro.xml``.
530
531    Tests that build the app via :func:`build_app` directly skip this
532    setup for hermeticity.
533
534    Logging is configured inside the lifespan (see :func:`build_app`)
535    rather than here — uvicorn's own ``dictConfig`` call happens
536    between ``create_app`` returning and the lifespan startup, and it
537    would wipe any handlers we installed pre-lifespan. Configuring in
538    lifespan is the only spot that survives that reset.
539    """
540    handle = build_app()
541    attach_profile_persistence(handle)
542    attach_withrottle_server(handle)
543    # Wire auth last so it sees the fully-populated context.
544    # ``attach_auth`` is a no-op unless ``JMRI_AUTH_ENABLED=1``.
545    attach_auth(handle, default_settings_dir())
546    return handle.app
547
548
549async def switch_active_profile(handle: AppHandle, profile_id: str) -> None:
550    """Switch the active profile at runtime.
551
552    Clears the in-memory bean managers, repoints :class:`ConfigStore`
553    and the (lazy) roster store at the new profile, then re-populates
554    the connection's beans from the new profile's ``PanelPro.xml``.
555
556    The hardware connection is **not** reopened — driver state (open
557    serial port, traffic controller, monitor task) survives the switch.
558    """
559    registry = handle.ctx.profile_registry
560    if not isinstance(registry, ProfileRegistry):
561        raise RuntimeError("server has no profile registry attached")
562    ref = registry.by_id(profile_id)
563    if ref is None:
564        raise KeyError(f"no profile with id {profile_id!r}")
565
566    # Persist the choice so the next startup uses it.
567    registry.set_active(profile_id)
568
569    new_profile = ref.to_profile(registry.settings_root)
570    conn = handle.ctx.connection
571
572    # 1. Deregister every existing bean so a profile with disjoint names
573    #    doesn't accumulate stale entries.
574    for mgr in (conn.turnouts, conn.sensors, conn.lights):
575        for bean in list(mgr):
576            await mgr.deregister(bean)
577
578    # 2. Re-point the persistence stores.
579    handle.ctx.config_store = ConfigStore(profile=new_profile)
580    handle.ctx._roster_profile = new_profile  # type: ignore[attr-defined]
581    # Force the roster service to re-create its lazy store against the new path.
582    if hasattr(handle.ctx, "_roster_store"):
583        delattr(handle.ctx, "_roster_store")
584
585    # 3. Re-restore beans from the new profile's PanelPro.xml.
586    await _restore_beans_from_config(conn, handle.ctx.config_store)
587
588    # 4. Auto-launch any jython/*.py for the new profile.
589    await _autolaunch_profile_scripts(handle.ctx)
@dataclass
class AppHandle:
146@dataclass
147class AppHandle:
148    """Bundle exposed by :func:`build_app` — useful for tests/dev."""
149
150    app: FastAPI
151    ctx: ServerContext
152    registry: ServiceRegistry
153    push: PushManager

Bundle exposed by build_app() — useful for tests/dev.

AppHandle( app: fastapi.applications.FastAPI, ctx: jmri_server.context.ServerContext, registry: jmri_server.registry.ServiceRegistry, push: jmri_server.push.PushManager)
app: fastapi.applications.FastAPI
def select_connection( name: str | None, settings: dict[str, typing.Any] | None = None) -> jmri_core.hal.SystemConnection:
156def select_connection(
157    name: str | None, settings: ConnectionSettings | None = None
158) -> SystemConnection:
159    """Construct a :class:`SystemConnection` by its entry-point *name*.
160
161    *name* must match an installed ``jmri.connection`` entry point. The
162    returned connection is **not** opened; :func:`build_app` opens it via
163    the FastAPI lifespan, passing the stashed *settings* through.
164    """
165    if name is None:
166        return InternalConnection()
167    connections = load_connections()
168    if name not in connections:
169        raise ValueError(
170            f"unknown connection {name!r}; installed: {sorted(connections)}"
171        )
172    instance = connections[name]()
173    instance.__dict__["_open_settings"] = settings or {}
174    return instance

Construct a SystemConnection by its entry-point name.

name must match an installed jmri.connection entry point. The returned connection is not opened; build_app() opens it via the FastAPI lifespan, passing the stashed settings through.

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) -> 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 attach_auth( handle: AppHandle, settings_root: pathlib._local.Path) -> None:
323def attach_auth(handle: AppHandle, settings_root: pathlib.Path) -> None:
324    """Enable auth when ``JMRI_AUTH_ENABLED=1``.
325
326    Called by :func:`create_app` after the settings root is
327    known. If the env var isn't set, this is a no-op and
328    :attr:`ServerContext.auth` stays ``None`` — every request
329    then runs as admin-equivalent (see :class:`AuthMiddleware`).
330
331    When enabled, loads or bootstraps ``users.yaml`` under
332    *settings_root* and hands the manager to the context. On
333    first run, the operator will find bootstrap credentials in
334    ``<settings-root>/admin_password.txt`` (mode 0600).
335    """
336    from jmri_core import env as jenv  # noqa: PLC0415
337    from jmri_server.auth import AUTH_ENABLED_ENV, build_auth_manager  # noqa: PLC0415
338    from jmri_core.env import _get_bool as _bool  # noqa: PLC0415
339
340    if not _bool(None, AUTH_ENABLED_ENV):
341        _log.info("auth disabled (%s not set); every request runs as admin",
342                  AUTH_ENABLED_ENV)
343        return
344    handle.ctx.auth = build_auth_manager(pathlib.Path(settings_root))
345    _log.warning(
346        "auth ENABLED — %d user(s) loaded. First-run bootstrap credentials "
347        "(if any) are at %s/admin_password.txt.",
348        len(handle.ctx.auth.users),
349        settings_root,
350    )
351    _ = jenv  # silence unused warning; kept for future expansion

Enable auth when JMRI_AUTH_ENABLED=1.

Called by create_app() after the settings root is known. If the env var isn't set, this is a no-op and ServerContext.auth stays None — every request then runs as admin-equivalent (see AuthMiddleware).

When enabled, loads or bootstraps users.yaml under settings_root and hands the manager to the context. On first run, the operator will find bootstrap credentials in <settings-root>/admin_password.txt (mode 0600).

def attach_withrottle_server( handle: AppHandle, *, host: str = '0.0.0.0', port: int = 12090) -> None:
362def attach_withrottle_server(
363    handle: AppHandle, *, host: str = "0.0.0.0", port: int = 12090,
364) -> None:
365    """Wire up a :class:`WiThrottleServer` on the handle's lifespan.
366
367    Engine Driver / WiThrottle iOS clients connect to ``host:port``
368    after the FastAPI lifespan opens the connection. The server shares
369    the active connection's :class:`ThrottleManager`, so any throttle
370    acquired over WiThrottle is the same bean the JSON/web layer drives.
371
372    Roster is fetched lazily from the roster service so profile
373    switches are picked up automatically.
374    """
375    try:
376        from jmri_withrottle import WiThrottleServer  # noqa: PLC0415
377    except ImportError:
378        _log.info("jmri_withrottle not installed; WiThrottle disabled")
379        return
380
381    def roster_provider() -> list[dict]:
382        """Return the active profile's roster as WiThrottle-shaped dicts.
383
384        The RosterService builds its store lazily, but a WiThrottle
385        client can connect *before* any HTTP request triggers that
386        construction. Build the store directly here so the very first
387        Engine Driver connection sees the full roster.
388        """
389        store = getattr(handle.ctx, "_roster_store", None)
390        if store is None:
391            from jmri_core.persistence.profile import Profile  # noqa: PLC0415
392
393            from jmri_server.roster_store import RosterStore  # noqa: PLC0415
394            profile = (
395                getattr(handle.ctx, "_roster_profile", None) or Profile.default()
396            )
397            store = RosterStore(profile=profile)
398            handle.ctx._roster_store = store  # type: ignore[attr-defined]
399        return [
400            {
401                "id": entry.id,
402                "dccAddress": entry.dcc_address,
403                "longAddress": entry.long_address,
404            }
405            for entry in store
406        ]
407
408    def turnout_provider() -> list[dict]:
409        """Return the connection's turnout inventory for the Turnouts tab."""
410        turnouts = handle.ctx.connection.turnouts
411        return [
412            {
413                "system_name": t.system_name,
414                "user_name": t.user_name or "",
415                "state": int(t.known_state),
416            }
417            for t in turnouts
418        ]
419
420    async def turnout_action_handler(system_name: str, action: str) -> None:
421        """Apply a WiThrottle client's turnout throw/close/toggle to the bean."""
422        from jmri_core.state import TurnoutState  # noqa: PLC0415
423        turnouts = handle.ctx.connection.turnouts
424        bean = turnouts.get_by_system_name(system_name)
425        if bean is None:
426            _log.info("WiThrottle turnout %s not found", system_name)
427            return
428        if action == "T":
429            target = TurnoutState.THROWN
430        elif action == "C":
431            target = TurnoutState.CLOSED
432        elif action == "2":
433            target = (
434                TurnoutState.THROWN
435                if bean.commanded_state == TurnoutState.CLOSED
436                else TurnoutState.CLOSED
437            )
438        else:
439            _log.info("WiThrottle unknown turnout action %r", action)
440            return
441        await bean.set_commanded_state(target)
442
443    def fast_clock_provider() -> str | None:
444        """Return the current fast-clock PFT line, or None if no clock."""
445        clock = getattr(handle.ctx, "fast_clock", None)
446        if clock is None:
447            return None
448        from jmri_withrottle import protocol as wp  # noqa: PLC0415
449        # Include seconds so the client's initial extrapolation starts
450        # from the correct point — without this we lose up to 59
451        # fast-seconds, which at 8x rate maps to ~7 real seconds of
452        # visible drift between broadcasts.
453        total = int(clock.time_seconds())
454        h = total // 3600
455        m = (total % 3600) // 60
456        s = total % 60
457        unix_seconds = wp.fast_clock_local_unix_seconds(h, m, s)
458        # JMRI signals "stopped" by broadcasting a follow-up ``PFT`` with
459        # rate 0.0; for a stopped clock we send rate 0.0 directly on the
460        # single sync line — Engine Driver reads the rate to know whether
461        # to advance its local display.
462        rate = clock.rate if clock.running else 0.0
463        return wp.fast_clock(unix_seconds, rate)
464
465    withrottle = WiThrottleServer(
466        connection=handle.ctx.connection,
467        roster_provider=roster_provider,
468        fast_clock_provider=fast_clock_provider,
469        turnout_provider=turnout_provider,
470        turnout_action_handler=turnout_action_handler,
471        railroad=handle.ctx.metadata.railroad,
472    )
473    handle.ctx.withrottle = withrottle  # type: ignore[attr-defined]
474    # Turnout ``state_changed`` subscription is wired inside
475    # ``WiThrottleServer.start()`` — must happen post-``conn.open()``
476    # because ``open()`` swaps ``conn.turnouts`` for the driver-specific
477    # manager. Subscribing here would leak to the old base manager.
478    handle.ctx._withrottle_host = host  # type: ignore[attr-defined]
479    handle.ctx._withrottle_port = port  # type: ignore[attr-defined]

Wire up a WiThrottleServer on the handle's lifespan.

Engine Driver / WiThrottle iOS clients connect to host:port after the FastAPI lifespan opens the connection. The server shares the active connection's ThrottleManager, so any throttle acquired over WiThrottle is the same bean the JSON/web layer drives.

Roster is fetched lazily from the roster service so profile switches are picked up automatically.

def attach_profile_persistence(handle: AppHandle) -> None:
482def attach_profile_persistence(handle: AppHandle) -> None:
483    """Attach the multi-profile registry + ConfigStore to *handle*.
484
485    Reads ``<settings>/profiles.xml`` from :func:`default_settings_dir`
486    (honouring ``JMRI_SETTINGS_DIR``), exposes the registry on
487    ``ctx.profile_registry``, and points :class:`ConfigStore` **and**
488    the (lazy) roster store at the active profile. Idempotent — calling
489    twice replaces what's there.
490
491    Used by both :func:`create_app` and by ``jmri-web``'s app factory so
492    every production entry point gets profile-driven persistence.
493    """
494    settings_root = default_settings_dir()
495    try:
496        registry = load_profile_registry(settings_root)
497    except (OSError, ValueError) as e:
498        _log.warning("could not load profiles.xml at %s: %s", settings_root, e)
499        registry = ProfileRegistry(settings_root=settings_root, profiles=[])
500    handle.ctx.profile_registry = registry
501    active_profile = _resolve_initial_profile(registry)
502    handle.ctx.config_store = ConfigStore(profile=active_profile)
503    # The roster service constructs its store lazily off ``_roster_profile``;
504    # point it at the same profile so the roster + bean tables stay in
505    # sync. Drop any pre-existing lazy store so the next request rebuilds
506    # it against the new path.
507    handle.ctx._roster_profile = active_profile  # type: ignore[attr-defined]
508    if hasattr(handle.ctx, "_roster_store"):
509        delattr(handle.ctx, "_roster_store")
510    # LogixNG programs live at ``<profile>/logixng/*.yaml``. Load them
511    # onto the manager built in ``build_app`` so the JSON service and
512    # the /logixng page see the active profile's programs immediately.
513    # We schedule the load on the app's lifespan startup rather than
514    # doing it inline here so the async loader doesn't need a running
515    # event loop yet (this function is sync).
516    handle.ctx._logixng_dir = active_profile.logixng_dir  # type: ignore[attr-defined]

Attach the multi-profile registry + ConfigStore to handle.

Reads <settings>/profiles.xml from default_settings_dir() (honouring JMRI_SETTINGS_DIR), exposes the registry on ctx.profile_registry, and points ConfigStore and the (lazy) roster store at the active profile. Idempotent — calling twice replaces what's there.

Used by both create_app() and by jmri-web's app factory so every production entry point gets profile-driven persistence.

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.

async def switch_active_profile(handle: AppHandle, profile_id: str) -> None:
550async def switch_active_profile(handle: AppHandle, profile_id: str) -> None:
551    """Switch the active profile at runtime.
552
553    Clears the in-memory bean managers, repoints :class:`ConfigStore`
554    and the (lazy) roster store at the new profile, then re-populates
555    the connection's beans from the new profile's ``PanelPro.xml``.
556
557    The hardware connection is **not** reopened — driver state (open
558    serial port, traffic controller, monitor task) survives the switch.
559    """
560    registry = handle.ctx.profile_registry
561    if not isinstance(registry, ProfileRegistry):
562        raise RuntimeError("server has no profile registry attached")
563    ref = registry.by_id(profile_id)
564    if ref is None:
565        raise KeyError(f"no profile with id {profile_id!r}")
566
567    # Persist the choice so the next startup uses it.
568    registry.set_active(profile_id)
569
570    new_profile = ref.to_profile(registry.settings_root)
571    conn = handle.ctx.connection
572
573    # 1. Deregister every existing bean so a profile with disjoint names
574    #    doesn't accumulate stale entries.
575    for mgr in (conn.turnouts, conn.sensors, conn.lights):
576        for bean in list(mgr):
577            await mgr.deregister(bean)
578
579    # 2. Re-point the persistence stores.
580    handle.ctx.config_store = ConfigStore(profile=new_profile)
581    handle.ctx._roster_profile = new_profile  # type: ignore[attr-defined]
582    # Force the roster service to re-create its lazy store against the new path.
583    if hasattr(handle.ctx, "_roster_store"):
584        delattr(handle.ctx, "_roster_store")
585
586    # 3. Re-restore beans from the new profile's PanelPro.xml.
587    await _restore_beans_from_config(conn, handle.ctx.config_store)
588
589    # 4. Auto-launch any jython/*.py for the new profile.
590    await _autolaunch_profile_scripts(handle.ctx)

Switch the active profile at runtime.

Clears the in-memory bean managers, repoints ConfigStore and the (lazy) roster store at the new profile, then re-populates the connection's beans from the new profile's PanelPro.xml.

The hardware connection is not reopened — driver state (open serial port, traffic controller, monitor task) survives the switch.