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)
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.
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.
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):
- connection — already-constructed instance (tests use this).
- connection_name + connection_settings — entry-point name.
JMRI_CONNECTION/JMRI_CONNECTION_SETTINGSenv vars.InternalConnectionfallback.
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).
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.
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.
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 onctx.profile_registry. Users editing JMRI'sprofiles.xmlcan use this server against their existing setup. - Resolves the active profile (
.active_profilemarker → first profile → fallbackdefault) and attaches aConfigStoreso user-created beans persist into the chosen profile'sPanelPro.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.
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.