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

2
backend/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Custom Streamdeck backend package."""

366
backend/database.py Normal file
View File

@@ -0,0 +1,366 @@
from __future__ import annotations
import json
import sqlite3
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DB = ROOT / "data" / "streamdeck.sqlite"
def utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
class Database:
def __init__(self, path: Path = DEFAULT_DB):
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(self.path, check_same_thread=False)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA foreign_keys = ON")
self.migrate()
self.ensure_defaults()
def migrate(self) -> None:
self.conn.executescript(
"""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
parent_id TEXT,
name TEXT NOT NULL,
is_root INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE,
FOREIGN KEY(parent_id) REFERENCES folders(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS buttons (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
folder_id TEXT NOT NULL,
position INTEGER NOT NULL,
physical_button INTEGER NOT NULL,
label TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '#111827',
icon TEXT NOT NULL DEFAULT '',
trigger_mode TEXT NOT NULL DEFAULT 'down',
action_type TEXT NOT NULL DEFAULT 'noop',
action_config TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL,
UNIQUE(folder_id, position),
UNIQUE(folder_id, physical_button),
FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE,
FOREIGN KEY(folder_id) REFERENCES folders(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS manual_apps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL,
args TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS event_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
event_type TEXT NOT NULL,
payload TEXT NOT NULL
);
"""
)
self.conn.commit()
def ensure_defaults(self) -> None:
if not self.get_setting("click_check"):
self.set_setting("click_check", False)
if not self.get_setting("serial_port"):
self.set_setting("serial_port", None)
profile = self.conn.execute("SELECT * FROM profiles LIMIT 1").fetchone()
if profile is None:
profile_id = self.create_profile("Default", make_active=False)
self.set_setting("active_profile_id", profile_id)
root_id = self.get_root_folder(profile_id)["id"]
self.set_setting("active_folder_id", root_id)
else:
active_profile = self.get_setting("active_profile_id") or profile["id"]
self.set_setting("active_profile_id", active_profile)
active_folder = self.get_setting("active_folder_id") or self.get_root_folder(active_profile)["id"]
self.set_setting("active_folder_id", active_folder)
self.sync_physical_layouts()
def row_to_dict(self, row: sqlite3.Row | None) -> dict[str, Any] | None:
return dict(row) if row else None
def get_setting(self, key: str) -> Any:
row = self.conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
return json.loads(row["value"]) if row else None
def set_setting(self, key: str, value: Any) -> None:
self.conn.execute(
"INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, json.dumps(value)),
)
self.conn.commit()
def settings(self) -> dict[str, Any]:
rows = self.conn.execute("SELECT key, value FROM settings").fetchall()
return {row["key"]: json.loads(row["value"]) for row in rows}
def create_profile(self, name: str, make_active: bool = True) -> str:
profile_id = str(uuid.uuid4())
self.conn.execute(
"INSERT INTO profiles(id, name, created_at) VALUES(?, ?, ?)",
(profile_id, name, utc_now()),
)
root_id = str(uuid.uuid4())
self.conn.execute(
"INSERT INTO folders(id, profile_id, parent_id, name, is_root, created_at) VALUES(?, ?, NULL, ?, 1, ?)",
(root_id, profile_id, "Root", utc_now()),
)
self._seed_buttons(profile_id, root_id)
self.conn.commit()
self.sync_physical_layouts()
if make_active:
self.set_setting("active_profile_id", profile_id)
self.set_setting("active_folder_id", root_id)
return profile_id
def update_profile(self, profile_id: str, name: str | None = None, active: bool | None = None) -> None:
if name is not None:
self.conn.execute("UPDATE profiles SET name = ? WHERE id = ?", (name, profile_id))
if active:
self.set_setting("active_profile_id", profile_id)
self.set_setting("active_folder_id", self.get_root_folder(profile_id)["id"])
self.conn.commit()
def delete_profile(self, profile_id: str) -> None:
if profile_id == self.first_profile()["id"]:
raise ValueError("Cannot delete the first profile because it owns the hardware layout.")
count = self.conn.execute("SELECT COUNT(*) AS count FROM profiles").fetchone()["count"]
if count <= 1:
raise ValueError("Cannot delete the only profile.")
self.conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,))
if self.get_setting("active_profile_id") == profile_id:
next_profile = self.conn.execute("SELECT id FROM profiles LIMIT 1").fetchone()["id"]
self.set_setting("active_profile_id", next_profile)
self.set_setting("active_folder_id", self.get_root_folder(next_profile)["id"])
self.conn.commit()
def create_folder(self, profile_id: str, parent_id: str | None, name: str) -> str:
parent_id = parent_id or self.get_root_folder(profile_id)["id"]
folder_id = str(uuid.uuid4())
self.conn.execute(
"INSERT INTO folders(id, profile_id, parent_id, name, is_root, created_at) VALUES(?, ?, ?, ?, 0, ?)",
(folder_id, profile_id, parent_id, name, utc_now()),
)
self._seed_buttons(profile_id, folder_id)
self.conn.commit()
self.sync_physical_layouts()
return folder_id
def update_folder(self, folder_id: str, name: str | None = None, parent_id: str | None = None) -> None:
if name is not None:
self.conn.execute("UPDATE folders SET name = ? WHERE id = ?", (name, folder_id))
if parent_id is not None:
self.conn.execute("UPDATE folders SET parent_id = ? WHERE id = ? AND is_root = 0", (parent_id, folder_id))
self.conn.commit()
def delete_folder(self, folder_id: str) -> None:
row = self.conn.execute("SELECT is_root, profile_id FROM folders WHERE id = ?", (folder_id,)).fetchone()
if not row:
return
if row["is_root"]:
raise ValueError("Cannot delete the root folder.")
self.conn.execute("DELETE FROM folders WHERE id = ?", (folder_id,))
if self.get_setting("active_folder_id") == folder_id:
self.set_setting("active_folder_id", self.get_root_folder(row["profile_id"])["id"])
self.conn.commit()
def update_button(self, button_id: str, changes: dict[str, Any]) -> dict[str, Any]:
physical_button = changes.pop("physical_button", None)
current = self.get_button(button_id)
if physical_button is not None and int(physical_button) != current["physical_button"]:
if not self.can_edit_physical_layout(current["folder_id"]):
raise ValueError("Physical button layout can only be changed in the first profile's root folder.")
allowed = {"label", "color", "icon", "trigger_mode", "action_type"}
sets: list[str] = []
values: list[Any] = []
for key in allowed:
if key in changes and changes[key] is not None:
sets.append(f"{key} = ?")
values.append(changes[key])
if "action_config" in changes and changes["action_config"] is not None:
sets.append("action_config = ?")
values.append(json.dumps(changes["action_config"]))
if sets:
sets.append("updated_at = ?")
values.append(utc_now())
values.append(button_id)
self.conn.execute(f"UPDATE buttons SET {', '.join(sets)} WHERE id = ?", values)
self.conn.commit()
if physical_button is not None and int(physical_button) != current["physical_button"]:
self.move_physical_button(current["folder_id"], current["position"], int(physical_button))
return self.get_button(button_id)
def move_physical_button(self, folder_id: str, position: int, physical_button: int) -> None:
if not self.can_edit_physical_layout(folder_id):
raise ValueError("Physical button layout can only be changed in the first profile's root folder.")
if position < 1 or position > 10 or physical_button < 1 or physical_button > 10:
raise ValueError("Position and physical button must be between 1 and 10.")
mapping = self.physical_layout_mapping()
old_physical = mapping[position]
old_position = next((slot for slot, physical in mapping.items() if physical == physical_button), None)
mapping[position] = physical_button
if old_position and old_position != position:
mapping[old_position] = old_physical
self.sync_physical_layouts(mapping)
def get_button(self, button_id: str) -> dict[str, Any]:
row = self.conn.execute("SELECT * FROM buttons WHERE id = ?", (button_id,)).fetchone()
button = dict(row)
button["action_config"] = json.loads(button["action_config"])
return button
def find_button_for_event(self, profile_id: str, folder_id: str, physical_button: int) -> dict[str, Any] | None:
row = self.conn.execute(
"SELECT * FROM buttons WHERE profile_id = ? AND folder_id = ? AND physical_button = ?",
(profile_id, folder_id, physical_button),
).fetchone()
if not row:
return None
button = dict(row)
button["action_config"] = json.loads(button["action_config"])
return button
def add_manual_app(self, name: str, path: str, args: str | None = None) -> dict[str, Any]:
app_id = str(uuid.uuid4())
self.conn.execute(
"INSERT INTO manual_apps(id, name, path, args, created_at) VALUES(?, ?, ?, ?, ?)",
(app_id, name, path, args, utc_now()),
)
self.conn.commit()
return {"id": app_id, "name": name, "path": path, "args": args, "source": "manual"}
def manual_apps(self) -> list[dict[str, Any]]:
return [dict(row) | {"source": "manual"} for row in self.conn.execute("SELECT * FROM manual_apps ORDER BY name").fetchall()]
def add_event(self, event_type: str, payload: dict[str, Any]) -> None:
self.conn.execute(
"INSERT INTO event_history(created_at, event_type, payload) VALUES(?, ?, ?)",
(utc_now(), event_type, json.dumps(payload)),
)
self.conn.commit()
def state(self) -> dict[str, Any]:
settings = self.settings()
profiles = [dict(row) for row in self.conn.execute("SELECT * FROM profiles ORDER BY created_at").fetchall()]
folders = [dict(row) for row in self.conn.execute("SELECT * FROM folders ORDER BY is_root DESC, created_at").fetchall()]
buttons = []
for row in self.conn.execute("SELECT * FROM buttons ORDER BY folder_id, position").fetchall():
button = dict(row)
button["action_config"] = json.loads(button["action_config"])
buttons.append(button)
return {
"settings": settings,
"profiles": profiles,
"folders": folders,
"buttons": buttons,
"layout": {
"canonical_profile_id": self.first_profile()["id"],
"canonical_folder_id": self.canonical_layout_folder()["id"],
"mapping": self.physical_layout_mapping(),
},
}
def get_root_folder(self, profile_id: str) -> dict[str, Any]:
row = self.conn.execute("SELECT * FROM folders WHERE profile_id = ? AND is_root = 1", (profile_id,)).fetchone()
return dict(row)
def first_profile(self) -> dict[str, Any]:
row = self.conn.execute("SELECT * FROM profiles ORDER BY created_at LIMIT 1").fetchone()
if row is None:
raise ValueError("No profiles exist.")
return dict(row)
def canonical_layout_folder(self) -> dict[str, Any]:
return self.get_root_folder(self.first_profile()["id"])
def can_edit_physical_layout(self, folder_id: str) -> bool:
return folder_id == self.canonical_layout_folder()["id"]
def physical_layout_mapping(self) -> dict[int, int]:
folder_id = self.canonical_layout_folder()["id"]
rows = self.conn.execute(
"SELECT position, physical_button FROM buttons WHERE folder_id = ? ORDER BY position",
(folder_id,),
).fetchall()
mapping = {int(row["position"]): int(row["physical_button"]) for row in rows}
for position in range(1, 11):
mapping.setdefault(position, position)
return mapping
def sync_physical_layouts(self, mapping: dict[int, int] | None = None) -> None:
if self.conn.execute("SELECT COUNT(*) AS count FROM profiles").fetchone()["count"] == 0:
return
mapping = mapping or self.physical_layout_mapping()
now = utc_now()
folders = self.conn.execute("SELECT id FROM folders").fetchall()
for folder in folders:
folder_id = folder["id"]
for position in range(1, 11):
self.conn.execute(
"UPDATE buttons SET physical_button = ?, updated_at = ? WHERE folder_id = ? AND position = ?",
(-position, now, folder_id, position),
)
for position, physical_button in mapping.items():
self.conn.execute(
"UPDATE buttons SET physical_button = ?, updated_at = ? WHERE folder_id = ? AND position = ?",
(physical_button, now, folder_id, position),
)
self.conn.commit()
def next_folder_id(self, profile_id: str, current_folder_id: str, direction: str = "next") -> str | None:
rows = self.conn.execute(
"SELECT id FROM folders WHERE profile_id = ? ORDER BY is_root DESC, created_at, name",
(profile_id,),
).fetchall()
folder_ids = [row["id"] for row in rows]
if not folder_ids:
return None
if current_folder_id not in folder_ids:
return folder_ids[0]
offset = -1 if direction == "previous" else 1
current_index = folder_ids.index(current_folder_id)
return folder_ids[(current_index + offset) % len(folder_ids)]
def _seed_buttons(self, profile_id: str, folder_id: str) -> None:
now = utc_now()
for position in range(1, 11):
self.conn.execute(
"""
INSERT INTO buttons(
id, profile_id, folder_id, position, physical_button, label,
color, icon, trigger_mode, action_type, action_config, updated_at
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 'down', 'noop', '{}', ?)
""",
(str(uuid.uuid4()), profile_id, folder_id, position, position, f"Button {position}", "#111827", "", now),
)

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",
}

62
backend/models.py Normal file
View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
TriggerMode = Literal["down", "up"]
ActionType = Literal["noop", "key_combo", "chain", "app_launch", "folder", "folder_rotation", "plugin"]
class SettingsUpdate(BaseModel):
serial_port: str | None = None
click_check: bool | None = None
active_profile_id: str | None = None
active_folder_id: str | None = None
class ProfileCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
class ProfileUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=80)
active: bool | None = None
class FolderCreate(BaseModel):
profile_id: str
parent_id: str | None = None
name: str = Field(min_length=1, max_length=80)
class FolderUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=80)
parent_id: str | None = None
class ButtonUpdate(BaseModel):
label: str | None = None
color: str | None = None
icon: str | None = None
physical_button: int | None = Field(default=None, ge=1, le=10)
trigger_mode: TriggerMode | None = None
action_type: ActionType | None = None
action_config: dict[str, Any] | None = None
class ManualAppCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
path: str = Field(min_length=1)
args: str | None = None
class ActionTest(BaseModel):
action_type: ActionType
action_config: dict[str, Any] = Field(default_factory=dict)
class WebSocketCommand(BaseModel):
type: str
payload: dict[str, Any] = Field(default_factory=dict)

155
backend/services/actions.py Normal file
View File

@@ -0,0 +1,155 @@
from __future__ import annotations
import asyncio
from typing import Any
from backend.services.apps import AppDiscovery
from backend.services.plugins import PluginContext
SPECIAL_KEYS = {
"ctrl": "ctrl",
"control": "ctrl",
"shift": "shift",
"alt": "alt",
"cmd": "cmd",
"win": "cmd",
"enter": "enter",
"return": "enter",
"esc": "esc",
"escape": "esc",
"tab": "tab",
"space": "space",
"backspace": "backspace",
"delete": "delete",
"up": "up",
"down": "down",
"left": "left",
"right": "right",
}
class KeyPresser:
def __init__(self) -> None:
self._controller = None
self._key = None
def _ensure(self) -> None:
if self._controller is not None:
return
try:
from pynput.keyboard import Controller, Key
except ImportError as exc:
raise RuntimeError("pynput is not installed. Run: python -m pip install -r requirements.txt") from exc
self._controller = Controller()
self._key = Key
async def press_combo(self, combo: str) -> None:
await asyncio.to_thread(self._press_combo_sync, combo)
def _press_combo_sync(self, combo: str) -> None:
self._ensure()
parts = [part.strip().lower() for part in combo.replace(" ", "").split("+") if part.strip()]
if not parts:
raise ValueError("Key combo is empty.")
keys = [self._resolve_key(part) for part in parts]
for key in keys[:-1]:
self._controller.press(key)
self._controller.press(keys[-1])
self._controller.release(keys[-1])
for key in reversed(keys[:-1]):
self._controller.release(key)
def _resolve_key(self, name: str) -> Any:
mapped = SPECIAL_KEYS.get(name, name)
if len(mapped) == 1:
return mapped
key = getattr(self._key, mapped, None)
if key is None:
raise ValueError(f"Unknown key '{name}'.")
return key
class ActionEngine:
def __init__(self, app: Any):
self.app = app
self.keys = KeyPresser()
self.apps = AppDiscovery(app.db)
async def handle_button_event(self, event: dict[str, Any]) -> None:
settings = self.app.db.settings()
profile_id = settings.get("active_profile_id")
folder_id = settings.get("active_folder_id")
if not profile_id or not folder_id:
return
button = self.app.db.find_button_for_event(profile_id, folder_id, int(event["button"]))
payload = {"event": event, "button": button}
await self.app.ws.broadcast(f"button.{event['event']}", payload)
await self.app.plugins.on_event(PluginContext(self.app), {"type": f"button.{event['event']}", "payload": payload})
if settings.get("click_check"):
await self.app.ws.broadcast("click_check.event", payload)
return
if not button or button["trigger_mode"] != event["event"]:
return
await self.execute(button["action_type"], button["action_config"], event, source_button=button)
async def execute(self, action_type: str, config: dict[str, Any], event: dict[str, Any] | None = None, source_button: dict[str, Any] | None = None) -> None:
action_id = config.get("id") or action_type
payload = {"action_type": action_type, "action_config": config, "button": source_button, "event": event}
await self.app.ws.broadcast("action.started", payload)
try:
await self._execute(action_type, config, event)
except Exception as exc:
await self.app.ws.broadcast("action.failed", payload | {"error": str(exc)})
raise
await self.app.ws.broadcast("action.finished", payload | {"id": action_id})
async def _execute(self, action_type: str, config: dict[str, Any], event: dict[str, Any] | None) -> None:
if action_type == "noop":
return
if action_type == "key_combo":
await self.keys.press_combo(str(config.get("combo", "")))
return
if action_type == "chain":
for step in config.get("steps", []):
await self._execute(step.get("action_type", "noop"), step.get("action_config", {}), event)
delay_ms = int(step.get("delay_ms", 0) or 0)
if delay_ms > 0:
await asyncio.sleep(delay_ms / 1000)
return
if action_type == "app_launch":
await asyncio.to_thread(self.apps.launch, config)
return
if action_type == "folder":
folder_id = config.get("folder_id")
if not folder_id:
raise ValueError("Folder action requires folder_id.")
self.app.db.set_setting("active_folder_id", folder_id)
await self.app.ws.broadcast("state.updated", self.app.public_state())
return
if action_type == "folder_rotation":
settings = self.app.db.settings()
profile_id = settings.get("active_profile_id")
folder_id = settings.get("active_folder_id")
if not profile_id or not folder_id:
raise ValueError("Folder rotation requires an active profile and folder.")
next_folder_id = self.app.db.next_folder_id(profile_id, folder_id, str(config.get("direction", "next")))
if not next_folder_id:
raise ValueError("No folders are available for rotation.")
self.app.db.set_setting("active_folder_id", next_folder_id)
await self.app.ws.broadcast("state.updated", self.app.public_state())
return
if action_type == "plugin":
await self.app.plugins.execute_action(
PluginContext(self.app),
str(config.get("plugin_id", "")),
str(config.get("action_id", "")),
config.get("fields", {}),
event,
)
return
raise ValueError(f"Unknown action type '{action_type}'.")

141
backend/services/apps.py Normal file
View File

@@ -0,0 +1,141 @@
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from typing import Any
from backend.database import Database
def _start_menu_dirs() -> list[Path]:
paths = [
Path(os.environ.get("ProgramData", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs",
Path(os.environ.get("APPDATA", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs",
]
return [path for path in paths if path.exists()]
class AppDiscovery:
def __init__(self, db: Database):
self.db = db
def discover(self) -> list[dict[str, Any]]:
apps: list[dict[str, Any]] = []
apps.extend(self._start_menu_apps())
apps.extend(self._registry_apps())
apps.extend(self.db.manual_apps())
return self._dedupe(apps)
def launch(self, config: dict[str, Any]) -> None:
path = config.get("path")
args = config.get("args") or ""
if not path:
raise ValueError("App launch action requires a path.")
if path.lower().endswith((".lnk", ".url", ".bat", ".cmd")):
os.startfile(path) # type: ignore[attr-defined]
return
if Path(path).suffix.lower() == ".exe":
subprocess.Popen([path, *self._split_args(args)], close_fds=True)
return
os.startfile(path) # type: ignore[attr-defined]
def _start_menu_apps(self) -> list[dict[str, Any]]:
apps: list[dict[str, Any]] = []
for root in _start_menu_dirs():
for shortcut in root.rglob("*.lnk"):
apps.append(
{
"id": f"shortcut:{shortcut}",
"name": shortcut.stem,
"path": str(shortcut),
"args": None,
"source": "start_menu",
}
)
return apps
def _registry_apps(self) -> list[dict[str, Any]]:
apps: list[dict[str, Any]] = []
try:
import winreg
except ImportError:
return apps
roots = [
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
]
for hive, key_path in roots:
try:
key = winreg.OpenKey(hive, key_path)
except OSError:
continue
with key:
for index in range(winreg.QueryInfoKey(key)[0]):
try:
sub_name = winreg.EnumKey(key, index)
sub_key = winreg.OpenKey(key, sub_name)
except OSError:
continue
with sub_key:
name = self._reg_value(sub_key, "DisplayName")
if not name:
continue
path = self._best_registry_path(sub_key)
if not path:
continue
apps.append(
{
"id": f"registry:{sub_name}",
"name": name,
"path": path,
"args": None,
"source": "registry",
}
)
return apps
def _best_registry_path(self, key: Any) -> str | None:
candidates = [
self._reg_value(key, "InstallLocation"),
self._reg_value(key, "DisplayIcon"),
]
for candidate in candidates:
if not candidate:
continue
cleaned = candidate.strip('"').split(",")[0]
path = Path(cleaned)
if path.is_file() and path.suffix.lower() == ".exe":
return str(path)
if path.is_dir():
exes = sorted(path.glob("*.exe"))
if exes:
return str(exes[0])
return None
def _reg_value(self, key: Any, name: str) -> str | None:
try:
value, _ = __import__("winreg").QueryValueEx(key, name)
except OSError:
return None
return str(value) if value else None
def _dedupe(self, apps: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[tuple[str, str]] = set()
result: list[dict[str, Any]] = []
for app in sorted(apps, key=lambda item: item["name"].lower()):
key = (app["name"].lower(), str(app["path"]).lower())
if key in seen:
continue
seen.add(key)
result.append(app)
return result
def _split_args(self, args: str) -> list[str]:
if not args:
return []
import shlex
return shlex.split(args, posix=False)

47
backend/services/pico.py Normal file
View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class PicoEvent:
button: int
pin: int
event: str
pressed: bool
def to_dict(self) -> dict[str, Any]:
return {
"button": self.button,
"pin": self.pin,
"event": self.event,
"pressed": self.pressed,
}
def parse_pico_line(raw_line: str) -> PicoEvent | None:
try:
payload = json.loads(raw_line)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
if not {"button", "pin", "event", "pressed"}.issubset(payload):
return None
if payload["event"] not in {"down", "up"}:
return None
try:
button = int(payload["button"])
pin = int(payload["pin"])
except (TypeError, ValueError):
return None
if button < 1 or button > 10:
return None
return PicoEvent(button=button, pin=pin, event=payload["event"], pressed=bool(payload["pressed"]))

119
backend/services/plugins.py Normal file
View File

@@ -0,0 +1,119 @@
from __future__ import annotations
import importlib.util
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class LoadedPlugin:
id: str
name: str
desc: str
version: str
actions: list[dict[str, Any]]
instance: Any | None
enabled: bool
error: str | None = None
def public(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"desc": self.desc,
"version": self.version,
"actions": self.actions,
"enabled": self.enabled,
"error": self.error,
}
class PluginContext:
def __init__(self, app: Any):
self.app = app
self.db = app.db
self.broadcast = app.ws.broadcast
class PluginManager:
def __init__(self, root: Path):
self.root = root
self.plugins: dict[str, LoadedPlugin] = {}
def load_all(self, ctx: PluginContext) -> None:
self.root.mkdir(parents=True, exist_ok=True)
self.plugins = {}
for path in sorted(self.root.iterdir()):
if path.name.startswith("_"):
continue
if path.is_file() and path.suffix == ".py":
self._load_path(path.stem, path, ctx)
elif path.is_dir() and (path / "__init__.py").exists():
self._load_path(path.name, path / "__init__.py", ctx)
def public_plugins(self) -> list[dict[str, Any]]:
return [plugin.public() for plugin in self.plugins.values()]
async def on_event(self, ctx: PluginContext, event: dict[str, Any]) -> None:
for plugin in self.plugins.values():
if not plugin.enabled or plugin.instance is None:
continue
hook = getattr(plugin.instance, "on_event", None)
if not hook:
continue
try:
result = hook(ctx, event)
if hasattr(result, "__await__"):
await result
except Exception as exc:
plugin.enabled = False
plugin.error = f"on_event failed: {exc}"
async def execute_action(self, ctx: PluginContext, plugin_id: str, action_id: str, config: dict[str, Any], event: dict[str, Any] | None) -> None:
plugin = self.plugins.get(plugin_id)
if not plugin or not plugin.enabled or plugin.instance is None:
raise ValueError(f"Plugin '{plugin_id}' is not available.")
hook = getattr(plugin.instance, "execute_action", None)
if not hook:
raise ValueError(f"Plugin '{plugin_id}' does not expose execute_action.")
result = hook(ctx, action_id, config, event)
if hasattr(result, "__await__"):
await result
def _load_path(self, plugin_id: str, path: Path, ctx: PluginContext) -> None:
try:
module_name = f"streamdeck_plugin_{plugin_id}"
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise RuntimeError("Could not create import spec.")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
instance = getattr(module, "PLUGIN")
plugin = LoadedPlugin(
id=plugin_id,
name=str(getattr(instance, "name")),
desc=str(getattr(instance, "desc", "")),
version=str(getattr(instance, "version", "0.1.0")),
actions=list(getattr(instance, "actions", [])),
instance=instance,
enabled=True,
)
on_load = getattr(instance, "on_load", None)
if on_load:
on_load(ctx)
self.plugins[plugin_id] = plugin
except Exception as exc:
self.plugins[plugin_id] = LoadedPlugin(
id=plugin_id,
name=plugin_id,
desc="Plugin failed to load.",
version="unknown",
actions=[],
instance=None,
enabled=False,
error=str(exc),
)

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
import asyncio
from typing import Any
import serial
import serial.tools.list_ports
from backend.services.pico import parse_pico_line
DEFAULT_BAUD = 115200
def find_pico_port() -> str | None:
for port in serial.tools.list_ports.comports():
vid = port.vid
manufacturer = (port.manufacturer or "").lower()
description = (port.description or "").lower()
if vid == 0x2E8A or "pico" in description or "raspberry" in manufacturer:
return port.device
return None
class SerialService:
def __init__(self, app: Any):
self.app = app
self.task: asyncio.Task[None] | None = None
self.stop_event = asyncio.Event()
self.connected_port: str | None = None
def start(self) -> None:
if self.task is None or self.task.done():
self.stop_event.clear()
self.task = asyncio.create_task(self._run())
async def stop(self) -> None:
self.stop_event.set()
if self.task:
await asyncio.wait([self.task], timeout=2)
async def restart(self) -> None:
await self.stop()
self.task = None
self.start()
async def _run(self) -> None:
while not self.stop_event.is_set():
port = self.app.db.get_setting("serial_port") or find_pico_port()
if not port:
self.connected_port = None
await self.app.ws.broadcast("serial.disconnected", {"reason": "Pico not found"})
await asyncio.sleep(2)
continue
try:
await self._read_port(port)
except Exception as exc:
self.connected_port = None
await self.app.ws.broadcast("serial.disconnected", {"port": port, "error": str(exc)})
await asyncio.sleep(2)
async def _read_port(self, port: str) -> None:
with serial.Serial(port, DEFAULT_BAUD, timeout=1) as ser:
self.connected_port = port
await self.app.ws.broadcast("serial.connected", {"port": port, "baud": DEFAULT_BAUD})
while not self.stop_event.is_set():
raw = await asyncio.to_thread(ser.readline)
line = raw.decode("utf-8", errors="replace").strip()
if not line:
continue
event = parse_pico_line(line)
if event is None:
await self.app.ws.broadcast("serial.diagnostic", {"line": line})
continue
payload = event.to_dict()
self.app.db.add_event(f"button.{payload['event']}", payload)
await self.app.actions.handle_button_event(payload)

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import asyncio
from typing import Any
from fastapi import WebSocket
class WebSocketManager:
def __init__(self) -> None:
self._clients: set[WebSocket] = set()
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket) -> None:
await websocket.accept()
async with self._lock:
self._clients.add(websocket)
async def disconnect(self, websocket: WebSocket) -> None:
async with self._lock:
self._clients.discard(websocket)
async def broadcast(self, event_type: str, payload: dict[str, Any] | None = None) -> None:
message = {"type": event_type, "payload": payload or {}}
async with self._lock:
clients = list(self._clients)
stale: list[WebSocket] = []
for client in clients:
try:
await client.send_json(message)
except Exception:
stale.append(client)
if stale:
async with self._lock:
for client in stale:
self._clients.discard(client)