FreeDATA/freedata_server/server.py

183 lines
6.6 KiB
Python

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())