252 lines
8.4 KiB
Python
252 lines
8.4 KiB
Python
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",
|
|
}
|