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

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)