#! /usr/bin/env python """FastAPI entrypoint and CLI tooling for the TRMNL local server.""" from __future__ import annotations import argparse import asyncio import os import sys from contextlib import asynccontextmanager from typing import Awaitable, Callable, Dict, List, Optional, Tuple import uvicorn from fastapi import FastAPI, Request, Response from fastapi.staticfiles import StaticFiles from . import config, models, utils from .routes import api_router, image_router, page_router from .services import plugins, state ################################################################################################### logger = config.logger logger.info('[Main] Starting trmnlServer') API_LOG_PATH_PREFIXES = ('/api',) MAX_REQUEST_LOG_BODY = 2048 MAX_RESPONSE_LOG_BODY = 2048 BINARY_CONTENT_PREFIXES = ( 'application/octet-stream', 'application/pdf', 'application/zip', 'image/', 'audio/', 'video/' ) BASE_PATH = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) STATIC_MOUNT_PATHS: Optional[Tuple[str, str]] = None def should_log_request(path: str) -> bool: return any(path.startswith(prefix) for prefix in API_LOG_PATH_PREFIXES) def format_request_body(body: bytes, limit: int = MAX_REQUEST_LOG_BODY) -> str: if not body: return '' body_text = body.decode('utf-8', errors='replace') if len(body_text) > limit: return f"{body_text[:limit]}..." return body_text def is_binary_content_type(content_type: str) -> bool: lowered = (content_type or '').lower() return any(lowered.startswith(prefix) for prefix in BINARY_CONTENT_PREFIXES) def format_response_body(body: bytes, limit: int = MAX_RESPONSE_LOG_BODY) -> str: if not body: return '' text = body.decode('utf-8', errors='replace') if len(text) > limit: return f"{text[:limit]}..." return text @asynccontextmanager async def lifespan(app: FastAPI): # noqa: ARG001 logger.info('Running initial plugin refresh') await plugins.refresh_plugin_assets() logger.info('Starting plugin refresh workers') await plugins.start_plugin_refreshers() yield logger.info('Stopping plugin refresh workers') await plugins.stop_plugin_refreshers() app = FastAPI(lifespan=lifespan) @app.middleware('http') async def log_api_request( request: Request, call_next: Callable[[Request], Awaitable[Response]] ) -> Response: log_this_request = should_log_request(request.url.path) if log_this_request: body_bytes = await request.body() logger.info( '[RequestDump] method=%s path=%s query=%s headers=%s body=%s', request.method, request.url.path, dict(request.query_params), dict(request.headers), format_request_body(body_bytes) ) response = await call_next(request) if log_this_request: content_type = response.headers.get('content-type', '') if not is_binary_content_type(content_type): response_body_chunks = [chunk async for chunk in response.body_iterator] response_body = b''.join(response_body_chunks) logger.info( '[ResponseDump] path=%s status=%s content_type=%s headers=%s body=%s', request.url.path, response.status_code, content_type, dict(response.headers), format_response_body(response_body) ) return Response( content=response_body, status_code=response.status_code, headers=dict(response.headers), media_type=response.media_type, background=response.background ) return response app.include_router(image_router) app.include_router(api_router) app.include_router(page_router) def _parse_cli_args(argv: Optional[List[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description='TRMNL local server') parser.add_argument('workdir', nargs='?', help='Runtime working directory', default=None) parser.add_argument('--list-plugins', action='store_true', help='List registered plugins and exit') parser.add_argument('--run-plugin', metavar='PLUGIN', help='Run a single plugin and exit') parser.add_argument('--plugin-output', metavar='DIR', help='Override output directory when running a plugin') parser.add_argument( '--plugin-arg', action='append', default=[], metavar='KEY=VALUE', help='Additional keyword arguments for --run-plugin' ) return parser.parse_args(argv) def _parse_plugin_kwargs(pairs: List[str]) -> Dict[str, str]: kwargs: Dict[str, str] = {} for entry in pairs: if '=' not in entry: raise ValueError(f"Invalid plugin arg '{entry}'. Expected KEY=VALUE") key, value = entry.split('=', 1) kwargs[key] = value return kwargs def _resolve_workdir(candidate: Optional[str]) -> str: if not candidate: return BASE_PATH if not os.path.isdir(candidate): print(f"Path {candidate} is not a directory. Using default path {BASE_PATH}.") return BASE_PATH return candidate def _ensure_static_mounts() -> None: global STATIC_MOUNT_PATHS desired = (config.WEB_STATIC_DIR, config.WEB_GENERATED_DIR) if STATIC_MOUNT_PATHS == desired: return # Remove existing mounts if present so new directories are reflected app.router.routes = [ route for route in app.router.routes if getattr(route, 'name', None) not in {'web-static', 'web-generated', 'generated-static'} ] app.mount('/web', StaticFiles(directory=config.WEB_STATIC_DIR), name='web-static') # Serve generated, volatile plugin outputs directly under /generated app.mount('/generated', StaticFiles(directory=config.WEB_GENERATED_DIR), name='generated-static') STATIC_MOUNT_PATHS = desired def _enforce_runtime_directory_defaults(entries: Optional[Dict[str, str]] = None) -> None: desired_static = 'web' desired_assets = 'web' desired_generated = 'var/generated' if entries is None: entries = {} persisted_static = entries.get('static_root') if persisted_static and persisted_static != desired_static: logger.info( 'Migrating static_root from %s to %s to align with reorganized web assets', persisted_static, desired_static ) config.update_config('static_root', desired_static) models.save_config_entry('static_root', desired_static) persisted_assets = entries.get('assets_root') if persisted_assets and persisted_assets != desired_assets: logger.info( 'Migrating assets_root from %s to %s to align with reorganized web assets', persisted_assets, desired_assets ) config.update_config('assets_root', desired_assets) models.save_config_entry('assets_root', desired_assets) persisted_generated = entries.get('generated_root') if persisted_generated and persisted_generated != desired_generated: logger.info( 'Migrating generated_root from %s to %s to keep volatile assets under var/', persisted_generated, desired_generated ) config.update_config('generated_root', desired_generated) models.save_config_entry('generated_root', desired_generated) def _prepare_runtime(current_dir: str) -> str: config.load_config(current_dir) os.makedirs(config.VAR_ROOT, exist_ok=True) os.makedirs(os.path.dirname(config.DATABASE_PATH), exist_ok=True) models.init_db() persisted_entries = models.load_config_entries() config.apply_persisted_config(persisted_entries) _enforce_runtime_directory_defaults(persisted_entries) state.initialize_rotation_playlists_from_storage() runtime_paths = { config.VAR_ROOT, os.path.dirname(config.DATABASE_PATH), config.LOGS_DIR, config.SSL_DIR, config.WEB_STATIC_DIR, config.WEB_GENERATED_DIR } for path in runtime_paths: os.makedirs(path, exist_ok=True) server_ip = utils.get_ip_address() server_scheme = config.SERVER_SCHEME server_base_url = f"{server_scheme}://{server_ip}:{config.SERVER_PORT}" state.set_server_base_url(server_base_url) logger.info( 'Server will be running on IP: %s and port: %s (scheme: %s)', server_ip, config.SERVER_PORT, server_scheme ) for path in (config.WEB_ROOT_DIR, config.WEB_STATIC_DIR, config.WEB_GENERATED_DIR, config.SSL_DIR): os.makedirs(path, exist_ok=True) _ensure_static_mounts() return server_ip def _print_available_plugins() -> None: print('Registered plugins:') for name in plugins.list_available_plugins(): print(f' - {name}') def _run_plugin_command(plugin_name: str, output_dir: Optional[str], plugin_kwargs: Dict[str, str]) -> None: try: result = asyncio.run( plugins.run_single_plugin_by_name( plugin_name, output_dir=output_dir, plugin_kwargs=plugin_kwargs ) ) except ValueError as exc: logger.error('%s', exc) sys.exit(2) except RuntimeError as exc: logger.error('%s', exc) sys.exit(1) logger.info( 'Plugin %s assets saved to %s and %s', plugin_name, result.monochrome_path, result.grayscale_path ) def _start_http_server(server_ip: str) -> None: with state.STATE_LOCK: state.get_device_state(state.DEFAULT_DEVICE_ID)['bmp_send_switch'] = True if config.ENABLE_SSL: cert_file = os.path.join(config.SSL_DIR, 'cert.pem') key_file = os.path.join(config.SSL_DIR, 'key.pem') if not os.path.exists(cert_file) or not os.path.exists(key_file): logger.debug('[Main] cert.pem and key.pem not found, generating new ones') os.system( f'openssl req -x509 -newkey rsa:4096 -keyout {key_file} -out {cert_file} ' f'-days 365 -nodes ' f'-subj "/C=US/ST=Georgia/L=Atlanta/O=trmnlServer/OU=webapp/CN={server_ip}"' ) logger.debug('[Main] Starting the server with uvicorn and SSL') uvicorn.run( app, host='0.0.0.0', port=config.SERVER_PORT, ssl_keyfile=key_file, ssl_certfile=cert_file, log_level='info' ) else: logger.debug('[Main] Starting the server without SSL') uvicorn.run( app, host='0.0.0.0', port=config.SERVER_PORT, log_level='info' ) _prepare_runtime(BASE_PATH) def run() -> None: args = _parse_cli_args(sys.argv[1:]) current_dir = _resolve_workdir(args.workdir) server_ip = _prepare_runtime(current_dir) if args.list_plugins: _print_available_plugins() return if args.run_plugin: try: plugin_kwargs = _parse_plugin_kwargs(args.plugin_arg) except ValueError as exc: logger.error('%s', exc) sys.exit(2) _run_plugin_command(args.run_plugin, args.plugin_output, plugin_kwargs) return _start_http_server(server_ip) if __name__ == '__main__': run()