initial Commit

This commit is contained in:
2026-05-10 12:46:33 +02:00
commit 108f08645c
36 changed files with 8688 additions and 0 deletions

251
backend/main.py Normal file
View File

@@ -0,0 +1,251 @@
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",
}