import os import shutil import sys import threading import webbrowser from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from freedata_server.log_handler import setup_logging from freedata_server.constants import CONFIG_ENV_VAR, DEFAULT_CONFIG_FILE, DEFAULT_APP_DIR, API_VERSION from freedata_server.context import AppContext from freedata_server.api.general import router as general_router from freedata_server.api.config import router as config_router from freedata_server.api.devices import router as devices_router from freedata_server.api.radio import router as radio_router from freedata_server.api.modem import router as modem_router from freedata_server.api.freedata import router as freedata_router from freedata_server.api.websocket import router as websocket_router import uvicorn # --- Resolve config path FIRST (no logger needed yet) --- def resolve_config_path() -> str: """ Determine the configuration file to use. Uses FREEDATA_CONFIG if set, otherwise defaults to a per-user config directory (DEFAULT_APP_DIR). If no config file exists yet at that location, a fresh one is bootstrapped from the bundled config.ini.example template so a plain `pip install freedata` followed by `freedata` works out of the box without any manual setup. """ candidate = os.path.abspath( os.getenv( CONFIG_ENV_VAR, os.path.join(DEFAULT_APP_DIR, DEFAULT_CONFIG_FILE), ) ) if not os.path.exists(candidate): # We cannot log to file yet since we don't know the directory; write to stderr. template = os.path.join(os.path.dirname(__file__), "config.ini.example") try: os.makedirs(os.path.dirname(candidate), exist_ok=True) if os.path.isfile(template): shutil.copyfile(template, candidate) sys.stderr.write(f"[INFO] No config found - created a default one at: {candidate}\n") else: sys.stderr.write( f"[FATAL] Config file not found and no template available to create one: {candidate}\n" ) sys.exit(1) except OSError as e: sys.stderr.write(f"[FATAL] Could not create config file at {candidate}: {e}\n") sys.exit(1) return candidate config_file = resolve_config_path() # --- Logging setup: log file MUST be in the same directory as the config file --- config_dir = os.path.dirname(config_file) log_file = os.path.join(config_dir, "freedata_server.log") logger = setup_logging(filename=log_file, level="INFO") logger.info("Using config", file=config_file, log_file=log_file) # --- AppContext startup --- ctx = AppContext(config_file) ctx.startup() @asynccontextmanager async def lifespan(app: FastAPI): # nothing to do on startup here, ctx already initialized yield # shutdown on exit ctx.shutdown() logger.info("Shutdown complete") # Create FastAPI app with unified lifespan app = FastAPI( title="FreeDATA API", version=str(API_VERSION), lifespan=lifespan, ) # store ctx for dependency injection app.state.ctx = ctx # CORS configuration app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # HTTP middleware: disable caching and logging @app.middleware("http") async def nocache(request: Request, call_next): response = await call_next(request) response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private" response.headers["Pragma"] = "no-cache" # structlog stdlib logger akzeptiert %-Formatierung; alternativ key/value: logger.info("[API] %s %s -> %d", request.method, request.url.path, response.status_code) return response # Static GUI mounting # Order matters: prefer paths anchored to this file's location (work no # matter what the current working directory is) over cwd-relative # fallbacks kept for backwards compatibility with older layouts. potential_gui_dirs = [ os.path.join(os.path.dirname(__file__), "gui"), # nuitka standalone build os.path.join(os.path.dirname(os.path.dirname(__file__)), "freedata_gui", "dist"), # pip install (sibling package) "../freedata_gui/dist", "freedata_gui/dist", "FreeDATA/freedata_gui/dist", ] gui_dir = next((d for d in potential_gui_dirs if os.path.isdir(d)), None) if gui_dir: app.mount("/gui", StaticFiles(directory=gui_dir, html=True), name="static") else: logger.warning("GUI directory not found: %s", gui_dir) # Register routers app.include_router(general_router, prefix="", tags=["General"]) app.include_router(config_router, prefix="/config", tags=["Configuration"]) app.include_router(devices_router, prefix="/devices", tags=["Devices"]) app.include_router(radio_router, prefix="/radio", tags=["Radio"]) app.include_router(modem_router, prefix="/modem", tags=["Modem"]) app.include_router(freedata_router, prefix="/freedata", tags=["FreeDATA"]) app.include_router(websocket_router, prefix="", tags=["WebSocket"]) def open_browser_after_delay(url, delay=2): """Opens the specified URL in a web browser after a delay. This function opens the given URL in a new browser window or tab after a specified delay. It is used to automatically open the FreeDATA GUI in a browser after the server has started. Args: url (str): The URL to open. delay (int, optional): The delay in seconds before opening the browser. Defaults to 2. """ threading.Event().wait(delay) webbrowser.open(url, new=0, autoraise=True) def main(): host = ctx.config_manager.config.get("NETWORK", {}).get("modemaddress") or "0.0.0.0" port = int(ctx.config_manager.config.get("NETWORK", {}).get("modemport", 5000)) # Launch GUI if available if gui_dir and os.path.isdir(gui_dir): url = f"http://{host}:{port}/gui" logger.info("---------------------------------------------------") logger.info(" ") logger.info(f"[GUI] AVAILABLE ON {url}") logger.info("just open it in your browser") logger.info(" ") logger.info("---------------------------------------------------") if ctx.config_manager.config.get("GUI", {}).get("auto_run_browser", True): threading.Thread(target=open_browser_after_delay, args=(url, 2), daemon=True).start() uvicorn.run(app, host=host, port=port, log_config=None, log_level="info") if __name__ == "__main__": sys.exit(main())