from __future__ import annotations from contextlib import asynccontextmanager from pathlib import Path from typing import Any from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from backend.database import Database from backend.models import ( ActionTest, ButtonUpdate, FolderCreate, FolderUpdate, ManualAppCreate, ProfileCreate, ProfileUpdate, SettingsUpdate, WebSocketCommand, ) from backend.services.actions import ActionEngine from backend.services.apps import AppDiscovery from backend.services.plugins import PluginContext, PluginManager from backend.services.serial_service import SerialService from backend.services.websocket_manager import WebSocketManager ROOT = Path(__file__).resolve().parent.parent FRONTEND_DIST = ROOT / "frontend" / "dist" PLUGINS_ROOT = ROOT / "plugins" class Runtime: def __init__(self) -> None: self.db = Database() self.ws = WebSocketManager() self.plugins = PluginManager(PLUGINS_ROOT) self.actions = ActionEngine(self) self.serial = SerialService(self) self.apps = AppDiscovery(self.db) def load_plugins(self) -> None: self.plugins.load_all(PluginContext(self)) def public_state(self) -> dict[str, Any]: state = self.db.state() state["plugins"] = self.plugins.public_plugins() state["apps"] = self.apps.discover() state["device"] = {"connected_port": self.serial.connected_port} return state runtime = Runtime() @asynccontextmanager async def lifespan(app: FastAPI): runtime.load_plugins() runtime.serial.start() await runtime.ws.broadcast("plugins.loaded", {"plugins": runtime.plugins.public_plugins()}) try: yield finally: await runtime.serial.stop() app = FastAPI(title="Custom Streamdeck", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/api/state") def get_state() -> dict[str, Any]: return runtime.public_state() @app.put("/api/settings") async def update_settings(payload: SettingsUpdate) -> dict[str, Any]: restart_serial = False for key, value in payload.model_dump(exclude_unset=True).items(): if value is not None: runtime.db.set_setting(key, value) if key == "serial_port": restart_serial = True if restart_serial: await runtime.serial.restart() state = runtime.public_state() await runtime.ws.broadcast("state.updated", state) return state @app.get("/api/apps") def get_apps() -> list[dict[str, Any]]: return runtime.apps.discover() @app.post("/api/apps/manual") async def add_manual_app(payload: ManualAppCreate) -> dict[str, Any]: app_entry = runtime.db.add_manual_app(payload.name, payload.path, payload.args) await runtime.ws.broadcast("state.updated", runtime.public_state()) return app_entry @app.get("/api/plugins") def get_plugins() -> list[dict[str, Any]]: return runtime.plugins.public_plugins() @app.post("/api/plugins/reload") async def reload_plugins() -> list[dict[str, Any]]: runtime.load_plugins() plugins = runtime.plugins.public_plugins() await runtime.ws.broadcast("plugins.loaded", {"plugins": plugins}) await runtime.ws.broadcast("state.updated", runtime.public_state()) return plugins @app.post("/api/profiles") async def create_profile(payload: ProfileCreate) -> dict[str, Any]: runtime.db.create_profile(payload.name) state = runtime.public_state() await runtime.ws.broadcast("state.updated", state) return state @app.put("/api/profiles/{profile_id}") async def update_profile(profile_id: str, payload: ProfileUpdate) -> dict[str, Any]: runtime.db.update_profile(profile_id, payload.name, payload.active) state = runtime.public_state() await runtime.ws.broadcast("state.updated", state) return state @app.delete("/api/profiles/{profile_id}") async def delete_profile(profile_id: str) -> dict[str, Any]: try: runtime.db.delete_profile(profile_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc state = runtime.public_state() await runtime.ws.broadcast("state.updated", state) return state @app.post("/api/folders") async def create_folder(payload: FolderCreate) -> dict[str, Any]: runtime.db.create_folder(payload.profile_id, payload.parent_id, payload.name) state = runtime.public_state() await runtime.ws.broadcast("state.updated", state) return state @app.put("/api/folders/{folder_id}") async def update_folder(folder_id: str, payload: FolderUpdate) -> dict[str, Any]: runtime.db.update_folder(folder_id, payload.name, payload.parent_id) state = runtime.public_state() await runtime.ws.broadcast("state.updated", state) return state @app.delete("/api/folders/{folder_id}") async def delete_folder(folder_id: str) -> dict[str, Any]: try: runtime.db.delete_folder(folder_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc state = runtime.public_state() await runtime.ws.broadcast("state.updated", state) return state @app.put("/api/buttons/{button_id}") async def update_button(button_id: str, payload: ButtonUpdate) -> dict[str, Any]: try: button = runtime.db.update_button(button_id, payload.model_dump(exclude_unset=True)) except Exception as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc await runtime.ws.broadcast("state.updated", runtime.public_state()) return button @app.post("/api/actions/test") async def test_action(payload: ActionTest) -> dict[str, str]: if runtime.db.get_setting("click_check"): raise HTTPException(status_code=409, detail="Click-check mode is enabled; actions are blocked.") await runtime.actions.execute(payload.action_type, payload.action_config) return {"status": "ok"} @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket) -> None: await runtime.ws.connect(websocket) await websocket.send_json({"type": "state.updated", "payload": runtime.public_state()}) try: while True: command = WebSocketCommand.model_validate(await websocket.receive_json()) await handle_ws_command(command) except WebSocketDisconnect: await runtime.ws.disconnect(websocket) async def handle_ws_command(command: WebSocketCommand) -> None: payload = command.payload if command.type == "set_active_profile": runtime.db.update_profile(str(payload["profile_id"]), active=True) elif command.type == "set_active_folder": runtime.db.set_setting("active_folder_id", str(payload["folder_id"])) elif command.type == "move_button": folder_id = str(payload.get("folder_id") or runtime.db.get_setting("active_folder_id")) try: runtime.db.move_physical_button(folder_id, int(payload["position"]), int(payload["physical_button"])) except ValueError as exc: await runtime.ws.broadcast("action.failed", {"error": str(exc)}) else: await runtime.ws.broadcast("button.mapped", payload) elif command.type == "toggle_click_check": runtime.db.set_setting("click_check", bool(payload["enabled"])) elif command.type == "test_action": if runtime.db.get_setting("click_check"): await runtime.ws.broadcast("action.failed", {"error": "Click-check mode is enabled; actions are blocked."}) else: await runtime.actions.execute(str(payload["action_type"]), payload.get("action_config", {})) await runtime.ws.broadcast("state.updated", runtime.public_state()) if FRONTEND_DIST.exists(): assets = FRONTEND_DIST / "assets" if assets.exists(): app.mount("/assets", StaticFiles(directory=assets), name="assets") @app.get("/{full_path:path}") def serve_frontend(full_path: str): index = FRONTEND_DIST / "index.html" target = FRONTEND_DIST / full_path if full_path and target.is_file(): return FileResponse(target) if index.exists(): return FileResponse(index) return { "message": "Frontend has not been built yet. Run npm install and npm run build in frontend/.", "api": "/api/state", }