initial Commit
This commit is contained in:
2
backend/__init__.py
Normal file
2
backend/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Custom Streamdeck backend package."""
|
||||
|
||||
366
backend/database.py
Normal file
366
backend/database.py
Normal 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
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",
|
||||
}
|
||||
62
backend/models.py
Normal file
62
backend/models.py
Normal 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
155
backend/services/actions.py
Normal 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
141
backend/services/apps.py
Normal 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
47
backend/services/pico.py
Normal 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
119
backend/services/plugins.py
Normal 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),
|
||||
)
|
||||
|
||||
78
backend/services/serial_service.py
Normal file
78
backend/services/serial_service.py
Normal 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)
|
||||
|
||||
37
backend/services/websocket_manager.py
Normal file
37
backend/services/websocket_manager.py
Normal 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)
|
||||
|
||||
Reference in New Issue
Block a user