jmri_server
jmri-server — Stage 3 of the JMRI Python rewrite.
A FastAPI + WebSocket server speaking JMRI's JSON wire protocol on top of
jmri_core's in-memory InternalConnection. The HTTP and WebSocket
endpoints share one dispatcher; every reply validates against the JSON
schemas extracted from JMRI's jar in Stage 0.
1"""jmri-server — Stage 3 of the JMRI Python rewrite. 2 3A FastAPI + WebSocket server speaking JMRI's JSON wire protocol on top of 4:mod:`jmri_core`'s in-memory `InternalConnection`. The HTTP and WebSocket 5endpoints share one dispatcher; every reply validates against the JSON 6schemas extracted from JMRI's jar in Stage 0. 7""" 8from jmri_server.app import build_app, create_app 9 10__version__ = "0.1.0" 11 12__all__ = ["build_app", "create_app", "__version__"]
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.
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.