initial Commit
This commit is contained in:
251
backend/main.py
Normal file
251
backend/main.py
Normal 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",
|
||||
}
|
||||
Reference in New Issue
Block a user