initial Commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Local app data
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
frontend/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
!button_log.txt
|
||||||
|
|
||||||
|
# OS / editors
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
BIN
Insert (first test).f3d
Normal file
BIN
Insert (first test).f3d
Normal file
Binary file not shown.
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Custom Streamdeck Button Prototype
|
||||||
|
|
||||||
|
This project is now a local Stream Deck-style control app for the 10-button Pico prototype.
|
||||||
|
|
||||||
|
The Pico firmware still prints the same USB serial JSON events. The new backend consumes those events, stores configuration in SQLite, executes actions, loads backend plugins, and serves the built React UI.
|
||||||
|
|
||||||
|
## Button Wiring
|
||||||
|
|
||||||
|
Button order is the order you gave:
|
||||||
|
|
||||||
|
1. GP28
|
||||||
|
2. GP27
|
||||||
|
3. GP26
|
||||||
|
4. GP22
|
||||||
|
5. GP21
|
||||||
|
6. GP20
|
||||||
|
7. GP18
|
||||||
|
8. GP19
|
||||||
|
9. GP17
|
||||||
|
10. GP16
|
||||||
|
|
||||||
|
The other leg of every button goes to GND. The Pico script uses internal pull-ups, so a pressed button reads as LOW.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `pico/main.py`: MicroPython code that runs on the Pico and prints button events over USB serial.
|
||||||
|
- `pc/listen_buttons.py`: PC listener that reads serial events and appends them to `button_log.txt`.
|
||||||
|
- `backend/`: FastAPI backend, WebSocket events, SQLite state, action engine, app discovery, plugin loading.
|
||||||
|
- `frontend/`: React + Vite + Tailwind UI.
|
||||||
|
- `plugins/`: Backend Python plugins. Each plugin exposes a top-level `PLUGIN` object.
|
||||||
|
|
||||||
|
## Run The App
|
||||||
|
|
||||||
|
Install Python dependencies:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Install and build the UI:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the backend and open `http://127.0.0.1:8000/`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m uvicorn backend.main:app --host 127.0.0.1 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
For frontend dev mode, run the backend above and then:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Legacy Listener
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\pc\listen_buttons.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If auto-detect fails:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .\pc\listen_buttons.py --port COM5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugin Shape
|
||||||
|
|
||||||
|
Backend plugins live in `plugins/` and expose `PLUGIN`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyPlugin:
|
||||||
|
name = "My Plugin"
|
||||||
|
desc = "Does a thing"
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"id": "do_thing",
|
||||||
|
"name": "Do Thing",
|
||||||
|
"fields": [{"id": "value", "label": "Value", "type": "text"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def on_load(self, ctx):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_event(self, ctx, event):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def execute_action(self, ctx, action_id, config, event):
|
||||||
|
pass
|
||||||
|
|
||||||
|
PLUGIN = MyPlugin()
|
||||||
|
```
|
||||||
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)
|
||||||
|
|
||||||
2068
button_log.txt
Normal file
2068
button_log.txt
Normal file
File diff suppressed because it is too large
Load Diff
702
exports/box.stl
Normal file
702
exports/box.stl
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
solid ASCII
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 2.000000e+00
|
||||||
|
vertex -1.434998e+02 2.535000e+01 2.000000e+00
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 2.000000e+00
|
||||||
|
vertex -1.434998e+02 2.535000e+01 2.000000e+00
|
||||||
|
vertex 9.150000e+01 2.535000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 2.000000e+00
|
||||||
|
vertex 9.150000e+01 2.535000e+01 2.000000e+00
|
||||||
|
vertex 9.150000e+01 -1.235000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.410000e+02 -1.235000e+01 2.000000e+00
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 2.000000e+00
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
vertex -1.434998e+02 2.535000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 9.150000e+01 -1.235000e+01 2.000000e+00
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 5.000000e+00
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 5.000000e+00
|
||||||
|
vertex -1.409998e+02 2.285000e+01 5.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 2.285000e+01 5.000000e+00
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 5.000000e+00
|
||||||
|
vertex 8.900000e+01 2.285000e+01 5.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 -0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 1.215000e+01 1.270000e+01
|
||||||
|
vertex -1.409998e+02 8.499998e-01 1.270000e+01
|
||||||
|
vertex -1.434998e+02 1.215000e+01 1.270000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 1.215000e+01 1.270000e+01
|
||||||
|
vertex -1.409998e+02 8.499998e-01 1.270000e+01
|
||||||
|
vertex -1.434998e+02 8.499998e-01 1.270000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 1.215000e+01 6.000000e+00
|
||||||
|
vertex -1.409998e+02 1.215000e+01 1.270000e+01
|
||||||
|
vertex -1.434998e+02 1.215000e+01 6.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 1.215000e+01 6.000000e+00
|
||||||
|
vertex -1.409998e+02 1.215000e+01 1.270000e+01
|
||||||
|
vertex -1.434998e+02 1.215000e+01 1.270000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 8.499998e-01 6.000000e+00
|
||||||
|
vertex -1.409998e+02 1.215000e+01 6.000000e+00
|
||||||
|
vertex -1.434998e+02 8.499998e-01 6.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 8.499998e-01 6.000000e+00
|
||||||
|
vertex -1.409998e+02 1.215000e+01 6.000000e+00
|
||||||
|
vertex -1.434998e+02 1.215000e+01 6.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 8.499998e-01 1.270000e+01
|
||||||
|
vertex -1.409998e+02 8.499998e-01 6.000000e+00
|
||||||
|
vertex -1.434998e+02 8.499998e-01 1.270000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 8.499998e-01 1.270000e+01
|
||||||
|
vertex -1.409998e+02 8.499998e-01 6.000000e+00
|
||||||
|
vertex -1.434998e+02 8.499998e-01 6.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.748780e+01
|
||||||
|
vertex 8.600016e+01 -5.000000e+00 3.748780e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.748780e+01
|
||||||
|
vertex 8.600016e+01 -5.000000e+00 3.748780e+01
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.748780e+01
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.748780e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.748780e+01
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.748780e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.748780e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.748780e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 2.285000e+01 3.748780e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.748780e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 2.285000e+01 3.748780e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.748780e+01
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 2.285000e+01 3.748780e+01
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.748780e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.600016e+01 -5.000000e+00 3.748780e+01
|
||||||
|
vertex 8.600016e+01 -5.000000e+00 3.652193e+01
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.748780e+01
|
||||||
|
vertex 8.600016e+01 -5.000000e+00 3.652193e+01
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 -1.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.748780e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.748780e+01
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.652193e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.748780e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.652193e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.748780e+01
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.652193e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.748780e+01
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 -0.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.748780e+01
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.652193e+01
|
||||||
|
vertex 8.600016e+01 -5.000000e+00 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.600016e+01 -5.000000e+00 3.748780e+01
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.652193e+01
|
||||||
|
vertex 8.600016e+01 -5.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.652193e+01
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.652193e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 2.285000e+01 3.652193e+01
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.652193e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 2.285000e+01 3.652193e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.652193e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 2.285000e+01 3.652193e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.652193e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
vertex -1.379998e+02 1.885000e+01 3.652193e+01
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.652193e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.652193e+01
|
||||||
|
vertex -1.379998e+02 -5.000000e+00 3.652193e+01
|
||||||
|
vertex 8.600016e+01 -5.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.652193e+01
|
||||||
|
vertex 8.600016e+01 -5.000000e+00 3.652193e+01
|
||||||
|
vertex 8.600016e+01 1.885000e+01 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 1.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.748780e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.748780e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.748780e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 2.285000e+01 3.748780e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.748780e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 -0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 2.285000e+01 4.000000e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.748780e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 -0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 2.285000e+01 3.652193e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 5.000000e+00
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.652193e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 5.000000e+00
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 5.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -1.000000e+00 1.181809e-08
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 2.285000e+01 3.652193e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 3.652193e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 5.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -1.000000e+00 1.181809e-08
|
||||||
|
outer loop
|
||||||
|
vertex 8.900000e+01 2.285000e+01 5.000000e+00
|
||||||
|
vertex -1.409998e+02 2.285000e+01 3.652193e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 5.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 2.285000e+01 3.748780e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.748780e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 -1.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.748780e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 -1.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.748780e+01
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 -1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -1.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 2.000000e+00
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 5.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -1.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 5.000000e+00
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 2.000000e+00
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 -0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 8.499998e-01 6.000000e+00
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 5.000000e+00
|
||||||
|
vertex -1.409998e+02 1.215000e+01 6.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 1.215000e+01 6.000000e+00
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 5.000000e+00
|
||||||
|
vertex -1.409998e+02 2.285000e+01 5.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 -0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 1.215000e+01 6.000000e+00
|
||||||
|
vertex -1.409998e+02 2.285000e+01 5.000000e+00
|
||||||
|
vertex -1.409998e+02 1.215000e+01 1.270000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 -0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 1.215000e+01 1.270000e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 5.000000e+00
|
||||||
|
vertex -1.409998e+02 2.285000e+01 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 1.215000e+01 1.270000e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 3.652193e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 8.499998e-01 6.000000e+00
|
||||||
|
vertex -1.409998e+02 8.499998e-01 1.270000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 5.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 5.000000e+00
|
||||||
|
vertex -1.409998e+02 8.499998e-01 1.270000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
vertex -1.409998e+02 8.499998e-01 1.270000e+01
|
||||||
|
vertex -1.409998e+02 1.215000e+01 1.270000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -1.000000e+00 -1.482882e-07
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 2.285000e+01 3.748780e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 3.748780e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -1.000000e+00 -1.482882e-07
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 2.285000e+01 4.000000e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 3.748780e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.652193e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 5.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 5.000000e+00
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 3.652193e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 5.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 -0.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.748780e+01
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.748780e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 3.652193e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 5.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 5.000000e+00
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -2.292075e-17 -1.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 9.150000e+01 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex 9.150000e+01 -1.235000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -2.292075e-17 -1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 9.150000e+01 -1.235000e+01 2.000000e+00
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 9.150000e+01 2.535000e+01 4.000000e+01
|
||||||
|
vertex 9.150000e+01 -1.235000e+01 4.000000e+01
|
||||||
|
vertex 9.150000e+01 2.535000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 9.150000e+01 2.535000e+01 2.000000e+00
|
||||||
|
vertex 9.150000e+01 -1.235000e+01 4.000000e+01
|
||||||
|
vertex 9.150000e+01 -1.235000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 2.535000e+01 4.000000e+01
|
||||||
|
vertex 9.150000e+01 2.535000e+01 4.000000e+01
|
||||||
|
vertex -1.434998e+02 2.535000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 2.535000e+01 2.000000e+00
|
||||||
|
vertex 9.150000e+01 2.535000e+01 4.000000e+01
|
||||||
|
vertex 9.150000e+01 2.535000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 8.499998e-01 1.270000e+01
|
||||||
|
vertex -1.434998e+02 8.499998e-01 6.000000e+00
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
vertex -1.434998e+02 8.499998e-01 6.000000e+00
|
||||||
|
vertex -1.434998e+02 1.215000e+01 6.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
vertex -1.434998e+02 1.215000e+01 6.000000e+00
|
||||||
|
vertex -1.434998e+02 2.535000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 2.535000e+01 2.000000e+00
|
||||||
|
vertex -1.434998e+02 1.215000e+01 6.000000e+00
|
||||||
|
vertex -1.434998e+02 1.215000e+01 1.270000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 2.535000e+01 2.000000e+00
|
||||||
|
vertex -1.434998e+02 1.215000e+01 1.270000e+01
|
||||||
|
vertex -1.434998e+02 2.535000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 2.535000e+01 4.000000e+01
|
||||||
|
vertex -1.434998e+02 1.215000e+01 1.270000e+01
|
||||||
|
vertex -1.434998e+02 8.499998e-01 1.270000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 -0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 2.535000e+01 4.000000e+01
|
||||||
|
vertex -1.434998e+02 8.499998e-01 1.270000e+01
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.434998e+02 8.499998e-01 1.270000e+01
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 -1.000000e+00 -0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.410000e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.410000e+02 -1.235000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -1.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.410000e+02 -1.235000e+01 2.000000e+00
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.410000e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 1.000000e+00 0.000000e+00 0.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 2.000000e+00
|
||||||
|
vertex -1.410000e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.410000e+02 -1.235000e+01 2.000000e+00
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 -0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.410000e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.434998e+02 2.535000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 2.535000e+01 4.000000e+01
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 2.535000e+01 4.000000e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 4.000000e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.410000e+02 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.409998e+02 2.285000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal -0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.434998e+02 2.535000e+01 4.000000e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 4.000000e+01
|
||||||
|
vertex 9.150000e+01 2.535000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 9.150000e+01 2.535000e+01 4.000000e+01
|
||||||
|
vertex 8.900000e+01 2.285000e+01 4.000000e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 9.150000e+01 2.535000e+01 4.000000e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 4.000000e+01
|
||||||
|
vertex 9.150000e+01 -1.235000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex 9.150000e+01 -1.235000e+01 4.000000e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
facet normal 0.000000e+00 0.000000e+00 1.000000e+00
|
||||||
|
outer loop
|
||||||
|
vertex -1.409998e+02 -1.235000e+01 4.000000e+01
|
||||||
|
vertex 8.900000e+01 -9.000000e+00 4.000000e+01
|
||||||
|
vertex -1.409998e+02 -9.000000e+00 4.000000e+01
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
endsolid
|
||||||
BIN
exports/insert first test .stl
Normal file
BIN
exports/insert first test .stl
Normal file
Binary file not shown.
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Custom Streamdeck</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
1901
frontend/package-lock.json
generated
Normal file
1901
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "custom-streamdeck-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"lucide-react": "^0.555.0",
|
||||||
|
"react": "^19.2.1",
|
||||||
|
"react-dom": "^19.2.1",
|
||||||
|
"tailwindcss": "^4.1.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^8.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
970
frontend/src/App.tsx
Normal file
970
frontend/src/App.tsx
Normal file
@@ -0,0 +1,970 @@
|
|||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
AppWindow,
|
||||||
|
Boxes,
|
||||||
|
Cable,
|
||||||
|
Keyboard,
|
||||||
|
Layers3,
|
||||||
|
Loader2,
|
||||||
|
MoreVertical,
|
||||||
|
MousePointerClick,
|
||||||
|
Pencil,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Power,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCw,
|
||||||
|
Route,
|
||||||
|
Save,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
Unplug
|
||||||
|
} from "lucide-react";
|
||||||
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { api } from "./api";
|
||||||
|
import type { ActionType, AppEntry, ButtonConfig, DeckState, Folder, PluginAction, PluginField, PluginInfo } from "./types";
|
||||||
|
|
||||||
|
const emptyState: DeckState = {
|
||||||
|
settings: { serial_port: null, click_check: false, active_profile_id: "", active_folder_id: "" },
|
||||||
|
profiles: [],
|
||||||
|
folders: [],
|
||||||
|
buttons: [],
|
||||||
|
apps: [],
|
||||||
|
plugins: [],
|
||||||
|
device: { connected_port: null }
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionLabels: Record<ActionType, string> = {
|
||||||
|
noop: "No Action",
|
||||||
|
key_combo: "Key Press",
|
||||||
|
chain: "Action Chain",
|
||||||
|
app_launch: "App Launch",
|
||||||
|
folder: "Folder",
|
||||||
|
folder_rotation: "Folder Rotation",
|
||||||
|
plugin: "Plugin"
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionIcons: Record<ActionType, ReactNode> = {
|
||||||
|
noop: <Power size={16} />,
|
||||||
|
key_combo: <Keyboard size={16} />,
|
||||||
|
chain: <Route size={16} />,
|
||||||
|
app_launch: <AppWindow size={16} />,
|
||||||
|
folder: <Layers3 size={16} />,
|
||||||
|
folder_rotation: <RotateCw size={16} />,
|
||||||
|
plugin: <Boxes size={16} />
|
||||||
|
};
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [state, setState] = useState<DeckState>(emptyState);
|
||||||
|
const [selectedButtonId, setSelectedButtonId] = useState<string>("");
|
||||||
|
const [pressedButtons, setPressedButtons] = useState<Record<number, boolean>>({});
|
||||||
|
const [status, setStatus] = useState("Loading");
|
||||||
|
const [wsOpen, setWsOpen] = useState(false);
|
||||||
|
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
||||||
|
const [manageMenuOpen, setManageMenuOpen] = useState(false);
|
||||||
|
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||||
|
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
||||||
|
const [renameProfileDialogOpen, setRenameProfileDialogOpen] = useState(false);
|
||||||
|
const [renameFolderDialogOpen, setRenameFolderDialogOpen] = useState(false);
|
||||||
|
const [deleteProfileDialogOpen, setDeleteProfileDialogOpen] = useState(false);
|
||||||
|
const [deleteFolderDialogOpen, setDeleteFolderDialogOpen] = useState(false);
|
||||||
|
const [manualAppDialogOpen, setManualAppDialogOpen] = useState(false);
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.state().then(next => {
|
||||||
|
setState(next);
|
||||||
|
setSelectedButtonId(next.buttons.find(button => button.folder_id === next.settings.active_folder_id)?.id ?? "");
|
||||||
|
setStatus("Ready");
|
||||||
|
}).catch(error => setStatus(error.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
|
||||||
|
socketRef.current = socket;
|
||||||
|
socket.onopen = () => setWsOpen(true);
|
||||||
|
socket.onclose = () => setWsOpen(false);
|
||||||
|
socket.onmessage = event => {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
if (message.type === "state.updated") {
|
||||||
|
setState(message.payload);
|
||||||
|
setStatus("Synced");
|
||||||
|
}
|
||||||
|
if (message.type === "button.down" || message.type === "button.up") {
|
||||||
|
const physical = Number(message.payload?.event?.button);
|
||||||
|
setPressedButtons(current => ({ ...current, [physical]: message.type === "button.down" }));
|
||||||
|
}
|
||||||
|
if (message.type === "action.failed") {
|
||||||
|
setStatus(message.payload?.error ?? "Action failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
socketRef.current = null;
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeProfile = state.profiles.find(profile => profile.id === state.settings.active_profile_id);
|
||||||
|
const activeFolder = state.folders.find(folder => folder.id === state.settings.active_folder_id);
|
||||||
|
const activeProfileFolders = state.folders.filter(folder => folder.profile_id === activeProfile?.id);
|
||||||
|
const firstProfile = state.profiles[0];
|
||||||
|
const canEditLayout = state.layout?.canonical_folder_id === state.settings.active_folder_id;
|
||||||
|
const currentButtons = useMemo(
|
||||||
|
() => state.buttons.filter(button => button.folder_id === state.settings.active_folder_id).sort((a, b) => a.position - b.position),
|
||||||
|
[state.buttons, state.settings.active_folder_id]
|
||||||
|
);
|
||||||
|
const selectedButton = state.buttons.find(button => button.id === selectedButtonId) ?? currentButtons[0];
|
||||||
|
const folderPath = useMemo(() => buildFolderPath(state.folders, activeFolder), [state.folders, activeFolder]);
|
||||||
|
const manualApps = state.apps.filter(app => app.source === "manual");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentButtons.length) return;
|
||||||
|
if (!currentButtons.some(button => button.id === selectedButtonId)) {
|
||||||
|
setSelectedButtonId(currentButtons[0].id);
|
||||||
|
}
|
||||||
|
}, [currentButtons, selectedButtonId]);
|
||||||
|
|
||||||
|
async function sync<T>(operation: Promise<T>, label = "Saved") {
|
||||||
|
setStatus("Saving");
|
||||||
|
try {
|
||||||
|
const result = await operation;
|
||||||
|
setStatus(label);
|
||||||
|
if (looksLikeState(result)) {
|
||||||
|
setState(result);
|
||||||
|
} else {
|
||||||
|
const next = await api.state();
|
||||||
|
setState(next);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : "Request failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelected(payload: Partial<ButtonConfig>) {
|
||||||
|
if (!selectedButton) return;
|
||||||
|
const buttonId = selectedButton.id;
|
||||||
|
setState(current => ({
|
||||||
|
...current,
|
||||||
|
buttons: current.buttons.map(button => (
|
||||||
|
button.id === buttonId
|
||||||
|
? { ...button, ...payload, action_config: payload.action_config ?? button.action_config }
|
||||||
|
: button
|
||||||
|
))
|
||||||
|
}));
|
||||||
|
setStatus("Saving");
|
||||||
|
api.updateButton(buttonId, payload)
|
||||||
|
.then(saved => {
|
||||||
|
setState(current => ({
|
||||||
|
...current,
|
||||||
|
buttons: current.buttons.map(button => button.id === saved.id ? saved : button)
|
||||||
|
}));
|
||||||
|
setStatus("Saved");
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
setStatus(error instanceof Error ? error.message : "Save failed");
|
||||||
|
api.state().then(setState).catch(() => undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
if (!canEditLayout) {
|
||||||
|
setStatus("Layout is locked here");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const physical = Number(event.active.data.current?.physical);
|
||||||
|
const position = Number(event.over?.data.current?.position);
|
||||||
|
if (!physical || !position) return;
|
||||||
|
setStatus("Saving mapping");
|
||||||
|
const command = JSON.stringify({ type: "move_button", payload: { folder_id: state.settings.active_folder_id, physical_button: physical, position } });
|
||||||
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
socketRef.current.send(command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const socket = new WebSocket(`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ws`);
|
||||||
|
socket.onopen = () => {
|
||||||
|
socket.send(command);
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#090b10] text-slate-100">
|
||||||
|
<header className="flex h-16 items-center justify-between border-b border-slate-800 bg-[#0d1119] px-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="grid h-9 w-9 place-items-center rounded bg-cyan-500 text-slate-950">
|
||||||
|
<MousePointerClick size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold tracking-normal">Custom Streamdeck</h1>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
{state.device.connected_port ? <Cable size={13} /> : <Unplug size={13} />}
|
||||||
|
<span>{state.device.connected_port ?? "Pico not connected"}</span>
|
||||||
|
<span className="text-slate-600">/</span>
|
||||||
|
<span>{wsOpen ? "Live" : "Offline"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="nav-select">
|
||||||
|
<Layers3 size={15} />
|
||||||
|
<select
|
||||||
|
title="Profile"
|
||||||
|
value={state.settings.active_profile_id}
|
||||||
|
onChange={event => sync(api.updateProfile(event.target.value, { active: true }), "Profile switched")}
|
||||||
|
>
|
||||||
|
{state.profiles.map(profile => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="nav-select">
|
||||||
|
<Route size={15} />
|
||||||
|
<select
|
||||||
|
title="Folder"
|
||||||
|
value={state.settings.active_folder_id}
|
||||||
|
onChange={event => sync(api.settings({ active_folder_id: event.target.value }), "Folder opened")}
|
||||||
|
>
|
||||||
|
{activeProfileFolders.map(folder => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="create-menu-wrap">
|
||||||
|
<button className="icon-button" title="Create" onClick={() => setCreateMenuOpen(open => !open)}>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
{createMenuOpen && (
|
||||||
|
<div className="create-menu">
|
||||||
|
<button onClick={() => { setCreateMenuOpen(false); setProfileDialogOpen(true); }}>New profile</button>
|
||||||
|
<button onClick={() => { setCreateMenuOpen(false); setFolderDialogOpen(true); }}>New folder</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="create-menu-wrap">
|
||||||
|
<button className="icon-button" title="Manage profile and folder" onClick={() => setManageMenuOpen(open => !open)}>
|
||||||
|
<MoreVertical size={16} />
|
||||||
|
</button>
|
||||||
|
{manageMenuOpen && (
|
||||||
|
<div className="create-menu">
|
||||||
|
<button onClick={() => { setManageMenuOpen(false); setRenameProfileDialogOpen(true); }}>
|
||||||
|
<Pencil size={14} />
|
||||||
|
<span>Rename profile</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={!activeProfile || activeProfile.id === firstProfile?.id}
|
||||||
|
title={activeProfile?.id === firstProfile?.id ? "The first profile owns the hardware layout" : "Delete profile"}
|
||||||
|
onClick={() => { setManageMenuOpen(false); setDeleteProfileDialogOpen(true); }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
<span>Delete profile</span>
|
||||||
|
</button>
|
||||||
|
<div className="menu-separator" />
|
||||||
|
<button onClick={() => { setManageMenuOpen(false); setRenameFolderDialogOpen(true); }}>
|
||||||
|
<Pencil size={14} />
|
||||||
|
<span>Rename folder</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={!activeFolder || Boolean(activeFolder.is_root)}
|
||||||
|
title={activeFolder?.is_root ? "Root folders cannot be deleted" : "Delete folder"}
|
||||||
|
onClick={() => { setManageMenuOpen(false); setDeleteFolderDialogOpen(true); }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
<span>Delete folder</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className={`toolbar-button ${state.settings.click_check ? "active" : ""}`} onClick={() => sync(api.settings({ click_check: !state.settings.click_check }), "Click-check updated")}>
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
<span>Click-check</span>
|
||||||
|
</button>
|
||||||
|
<button className="icon-button" title="Reload plugins" onClick={() => sync(api.reloadPlugins(), "Plugins reloaded")}>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="nav-stats">
|
||||||
|
<StatPill label="Profiles" value={state.profiles.length} />
|
||||||
|
<StatPill label="Folders" value={activeProfileFolders.length} />
|
||||||
|
<StatPill label="Apps" value={state.apps.length} />
|
||||||
|
</div>
|
||||||
|
<div className="status-pill">
|
||||||
|
{status === "Saving" ? <Loader2 className="animate-spin" size={14} /> : <Save size={14} />}
|
||||||
|
<span>{status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="grid min-h-[calc(100vh-4rem)] grid-cols-[minmax(0,1fr)_380px]">
|
||||||
|
<section className="flex flex-col gap-5 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-slate-400">{activeProfile?.name ?? "No Profile"}</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-2xl font-semibold">
|
||||||
|
{folderPath.map((folder, index) => (
|
||||||
|
<span className="flex items-center gap-2" key={folder.id}>
|
||||||
|
{index > 0 && <span className="text-slate-600">/</span>}
|
||||||
|
<button className="breadcrumb" onClick={() => sync(api.settings({ active_folder_id: folder.id }), "Folder opened")}>{folder.name}</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-slate-500">
|
||||||
|
{canEditLayout ? "Hardware layout is editable here and syncs everywhere." : "Hardware layout is mirrored from the first profile's root folder."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<div className="deck-strip">
|
||||||
|
{currentButtons.map(button => (
|
||||||
|
<DeckButton
|
||||||
|
key={button.id}
|
||||||
|
button={button}
|
||||||
|
selected={button.id === selectedButton?.id}
|
||||||
|
pressed={Boolean(pressedButtons[button.physical_button])}
|
||||||
|
clickCheck={state.settings.click_check}
|
||||||
|
canEditLayout={canEditLayout}
|
||||||
|
onSelect={() => setSelectedButtonId(button.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="panel-title">Manual Launch Targets</h2>
|
||||||
|
<button className="secondary-button" onClick={() => setManualAppDialogOpen(true)}>
|
||||||
|
<Plus size={15} />
|
||||||
|
<span>Add Target</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="app-list">
|
||||||
|
{manualApps.map(app => (
|
||||||
|
<button key={`${app.source}:${app.path}`} className="app-row" onClick={() => selectedButton && updateSelected({ action_type: "app_launch", action_config: { path: app.path, args: app.args ?? "" } })}>
|
||||||
|
<span>{app.name}</span>
|
||||||
|
<small>{app.path}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!manualApps.length && (
|
||||||
|
<div className="empty-panel">No manual launch targets yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Inspector
|
||||||
|
button={selectedButton}
|
||||||
|
folders={state.folders.filter(folder => folder.profile_id === activeProfile?.id)}
|
||||||
|
apps={state.apps}
|
||||||
|
plugins={state.plugins}
|
||||||
|
clickCheck={state.settings.click_check}
|
||||||
|
canEditLayout={canEditLayout}
|
||||||
|
onChange={updateSelected}
|
||||||
|
onTest={() => selectedButton && sync(api.testAction(selectedButton.action_type, selectedButton.action_config), "Action test sent")}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<NameDialog
|
||||||
|
open={profileDialogOpen}
|
||||||
|
title="New Profile"
|
||||||
|
label="Profile name"
|
||||||
|
defaultValue="New Profile"
|
||||||
|
submitLabel="Create"
|
||||||
|
onClose={() => setProfileDialogOpen(false)}
|
||||||
|
onSubmit={name => sync(api.createProfile(name), "Profile created")}
|
||||||
|
/>
|
||||||
|
<NameDialog
|
||||||
|
open={folderDialogOpen}
|
||||||
|
title="New Folder"
|
||||||
|
label="Folder name"
|
||||||
|
defaultValue="New Folder"
|
||||||
|
submitLabel="Create"
|
||||||
|
onClose={() => setFolderDialogOpen(false)}
|
||||||
|
onSubmit={name => {
|
||||||
|
if (!activeProfile) return;
|
||||||
|
sync(api.createFolder(activeProfile.id, state.settings.active_folder_id, name), "Folder created");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NameDialog
|
||||||
|
open={renameProfileDialogOpen}
|
||||||
|
title="Rename Profile"
|
||||||
|
label="Profile name"
|
||||||
|
defaultValue={activeProfile?.name ?? ""}
|
||||||
|
submitLabel="Save"
|
||||||
|
onClose={() => setRenameProfileDialogOpen(false)}
|
||||||
|
onSubmit={name => {
|
||||||
|
if (!activeProfile) return;
|
||||||
|
sync(api.updateProfile(activeProfile.id, { name }), "Profile renamed");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NameDialog
|
||||||
|
open={renameFolderDialogOpen}
|
||||||
|
title="Rename Folder"
|
||||||
|
label="Folder name"
|
||||||
|
defaultValue={activeFolder?.name ?? ""}
|
||||||
|
submitLabel="Save"
|
||||||
|
onClose={() => setRenameFolderDialogOpen(false)}
|
||||||
|
onSubmit={name => {
|
||||||
|
if (!activeFolder) return;
|
||||||
|
sync(api.updateFolder(activeFolder.id, { name }), "Folder renamed");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteProfileDialogOpen}
|
||||||
|
title="Delete Profile"
|
||||||
|
body={`Delete "${activeProfile?.name ?? "this profile"}"? This removes its folders and button setup.`}
|
||||||
|
confirmLabel="Delete Profile"
|
||||||
|
destructive
|
||||||
|
onClose={() => setDeleteProfileDialogOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!activeProfile) return;
|
||||||
|
sync(api.deleteProfile(activeProfile.id), "Profile deleted");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteFolderDialogOpen}
|
||||||
|
title="Delete Folder"
|
||||||
|
body={`Delete "${activeFolder?.name ?? "this folder"}"? This removes the buttons configured inside it.`}
|
||||||
|
confirmLabel="Delete Folder"
|
||||||
|
destructive
|
||||||
|
onClose={() => setDeleteFolderDialogOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!activeFolder) return;
|
||||||
|
sync(api.deleteFolder(activeFolder.id), "Folder deleted");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ManualAppDialog
|
||||||
|
open={manualAppDialogOpen}
|
||||||
|
onClose={() => setManualAppDialogOpen(false)}
|
||||||
|
onSubmit={(name, path, args) => sync(api.addManualApp(name, path, args), "Manual app added")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NameDialog(props: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
defaultValue: string;
|
||||||
|
submitLabel: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(props.defaultValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.open) setValue(props.defaultValue);
|
||||||
|
}, [props.open, props.defaultValue]);
|
||||||
|
|
||||||
|
if (!props.open) return null;
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" role="presentation" onMouseDown={props.onClose}>
|
||||||
|
<form className="modal" onMouseDown={event => event.stopPropagation()} onSubmit={event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
props.onSubmit(trimmed);
|
||||||
|
props.onClose();
|
||||||
|
}}>
|
||||||
|
<h2>{props.title}</h2>
|
||||||
|
<Field label={props.label}>
|
||||||
|
<input className="input" autoFocus value={value} onChange={event => setValue(event.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="secondary-button" onClick={props.onClose}>Cancel</button>
|
||||||
|
<button type="submit" className="primary-button">{props.submitLabel}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmDialog(props: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!props.open) return null;
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" role="presentation" onMouseDown={props.onClose}>
|
||||||
|
<div className="modal" role="dialog" aria-modal="true" onMouseDown={event => event.stopPropagation()}>
|
||||||
|
<h2>{props.title}</h2>
|
||||||
|
<p className="modal-body">{props.body}</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="secondary-button" onClick={props.onClose}>Cancel</button>
|
||||||
|
<button type="button" className={props.destructive ? "danger-button" : "primary-button"} onClick={() => {
|
||||||
|
props.onConfirm();
|
||||||
|
props.onClose();
|
||||||
|
}}>
|
||||||
|
{props.confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManualAppDialog(props: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (name: string, path: string, args?: string) => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [path, setPath] = useState("");
|
||||||
|
const [args, setArgs] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.open) return;
|
||||||
|
setName("");
|
||||||
|
setPath("");
|
||||||
|
setArgs("");
|
||||||
|
}, [props.open]);
|
||||||
|
|
||||||
|
if (!props.open) return null;
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" role="presentation" onMouseDown={props.onClose}>
|
||||||
|
<form className="modal" onMouseDown={event => event.stopPropagation()} onSubmit={event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const trimmedPath = path.trim();
|
||||||
|
if (!trimmedName || !trimmedPath) return;
|
||||||
|
props.onSubmit(trimmedName, trimmedPath, args.trim() || undefined);
|
||||||
|
props.onClose();
|
||||||
|
}}>
|
||||||
|
<h2>Add Manual App</h2>
|
||||||
|
<Field label="App name">
|
||||||
|
<input className="input" autoFocus value={name} onChange={event => setName(event.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Executable, shortcut, or file path">
|
||||||
|
<input className="input" value={path} onChange={event => setPath(event.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Arguments">
|
||||||
|
<input className="input" value={args} onChange={event => setArgs(event.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="secondary-button" onClick={props.onClose}>Cancel</button>
|
||||||
|
<button type="submit" className="primary-button">Add App</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatPill(props: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="stat-pill">
|
||||||
|
<span>{props.label}</span>
|
||||||
|
<strong>{props.value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeckButton(props: { button: ButtonConfig; selected: boolean; pressed: boolean; clickCheck: boolean; canEditLayout: boolean; onSelect: () => void }) {
|
||||||
|
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
||||||
|
id: `position-${props.button.position}`,
|
||||||
|
data: { position: props.button.position }
|
||||||
|
});
|
||||||
|
const { attributes, listeners, setNodeRef: setDragRef, transform, isDragging } = useDraggable({
|
||||||
|
id: `physical-${props.button.physical_button}`,
|
||||||
|
data: { physical: props.button.physical_button },
|
||||||
|
disabled: !props.canEditLayout
|
||||||
|
});
|
||||||
|
const style: CSSProperties = {
|
||||||
|
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
|
||||||
|
background: `linear-gradient(145deg, ${props.button.color}, #05070b)`
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={setDropRef}
|
||||||
|
className={`deck-button ${props.selected ? "selected" : ""} ${props.pressed ? "pressed" : ""} ${isOver ? "over" : ""}`}
|
||||||
|
onClick={props.onSelect}
|
||||||
|
>
|
||||||
|
<div ref={setDragRef} style={style} className={`deck-face ${isDragging ? "dragging" : ""} ${props.canEditLayout ? "" : "layout-locked"}`} {...attributes} {...listeners}>
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Slot {props.button.position}</span>
|
||||||
|
<span>#{props.button.physical_button}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 place-items-center">
|
||||||
|
<div className="grid h-10 w-10 place-items-center rounded bg-white/10 text-cyan-200">
|
||||||
|
{actionIcons[props.button.action_type]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-sm font-medium">{props.button.label || "Untitled"}</div>
|
||||||
|
{props.clickCheck && <div className="click-check-badge">Check</div>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inspector(props: {
|
||||||
|
button?: ButtonConfig;
|
||||||
|
folders: Folder[];
|
||||||
|
apps: AppEntry[];
|
||||||
|
plugins: PluginInfo[];
|
||||||
|
clickCheck: boolean;
|
||||||
|
canEditLayout: boolean;
|
||||||
|
onChange: (payload: Partial<ButtonConfig>) => void;
|
||||||
|
onTest: () => void;
|
||||||
|
}) {
|
||||||
|
const button = props.button;
|
||||||
|
if (!button) {
|
||||||
|
return <aside className="border-l border-slate-800 bg-[#0b0f16] p-5">Select a button</aside>;
|
||||||
|
}
|
||||||
|
const updateConfig = (patch: Record<string, any>) => props.onChange({ action_config: { ...button.action_config, ...patch } });
|
||||||
|
return (
|
||||||
|
<aside className="overflow-y-auto border-l border-slate-800 bg-[#0b0f16] p-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-xs uppercase text-slate-500">Inspector</div>
|
||||||
|
<h2 className="mt-1 text-xl font-semibold">Slot {button.position}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label="Label">
|
||||||
|
<DraftInput value={button.label} onCommit={value => props.onChange({ label: value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Color">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input className="h-10 w-14 rounded border border-slate-700 bg-transparent" type="color" value={button.color} onChange={event => props.onChange({ color: event.target.value })} />
|
||||||
|
<DraftInput value={button.color} onCommit={value => props.onChange({ color: value })} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Physical">
|
||||||
|
<select className="input" value={button.physical_button} disabled={!props.canEditLayout} onChange={event => props.onChange({ physical_button: Number(event.target.value) })}>
|
||||||
|
{Array.from({ length: 10 }, (_, index) => index + 1).map(number => <option key={number} value={number}>Button {number}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Trigger">
|
||||||
|
<select className="input" value={button.trigger_mode} onChange={event => props.onChange({ trigger_mode: event.target.value as any })}>
|
||||||
|
<option value="down">Press down</option>
|
||||||
|
<option value="up">Release up</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Action">
|
||||||
|
<select className="input" value={button.action_type} onChange={event => props.onChange({ action_type: event.target.value as ActionType, action_config: {} })}>
|
||||||
|
{(Object.keys(actionLabels) as ActionType[]).map(type => <option key={type} value={type}>{actionLabels[type]}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<ActionForm button={button} folders={props.folders} apps={props.apps} plugins={props.plugins} updateConfig={updateConfig} onChange={props.onChange} />
|
||||||
|
|
||||||
|
<button className="primary-button w-full" disabled={props.clickCheck} onClick={props.onTest}>
|
||||||
|
<Play size={16} />
|
||||||
|
<span>{props.clickCheck ? "Blocked by click-check" : "Test Action"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionForm(props: {
|
||||||
|
button: ButtonConfig;
|
||||||
|
folders: Folder[];
|
||||||
|
apps: AppEntry[];
|
||||||
|
plugins: PluginInfo[];
|
||||||
|
updateConfig: (patch: Record<string, any>) => void;
|
||||||
|
onChange: (payload: Partial<ButtonConfig>) => void;
|
||||||
|
}) {
|
||||||
|
const { button } = props;
|
||||||
|
if (button.action_type === "noop") return <p className="hint">This button only shows live press state.</p>;
|
||||||
|
if (button.action_type === "key_combo") {
|
||||||
|
return <Field label="Key combo"><DraftInput placeholder="ctrl+shift+s" value={button.action_config.combo ?? ""} onCommit={value => props.updateConfig({ combo: value })} /></Field>;
|
||||||
|
}
|
||||||
|
if (button.action_type === "app_launch") {
|
||||||
|
return (
|
||||||
|
<Field label="Launch app">
|
||||||
|
<select className="input" value={button.action_config.path ?? ""} onChange={event => props.updateConfig({ path: event.target.value })}>
|
||||||
|
<option value="">Choose an app</option>
|
||||||
|
{props.apps.map(app => <option key={`${app.source}:${app.path}`} value={app.path}>{app.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (button.action_type === "folder") {
|
||||||
|
return (
|
||||||
|
<Field label="Open folder">
|
||||||
|
<select className="input" value={button.action_config.folder_id ?? ""} onChange={event => props.updateConfig({ folder_id: event.target.value })}>
|
||||||
|
<option value="">Choose a folder</option>
|
||||||
|
{props.folders.map(folder => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (button.action_type === "folder_rotation") {
|
||||||
|
return (
|
||||||
|
<Field label="Rotation direction">
|
||||||
|
<select className="input" value={button.action_config.direction ?? "next"} onChange={event => props.updateConfig({ direction: event.target.value })}>
|
||||||
|
<option value="next">Next folder</option>
|
||||||
|
<option value="previous">Previous folder</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (button.action_type === "chain") {
|
||||||
|
const steps = Array.isArray(button.action_config.steps) ? button.action_config.steps : [];
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="label">Steps</label>
|
||||||
|
<button className="secondary-button" onClick={() => props.updateConfig({ steps: [...steps, { action_type: "key_combo", action_config: { combo: "" }, delay_ms: 100 }] })}>
|
||||||
|
<Plus size={14} />
|
||||||
|
<span>Add</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{steps.map((step: any, index: number) => (
|
||||||
|
<div key={index} className="chain-step">
|
||||||
|
<select className="input" value={step.action_type} onChange={event => {
|
||||||
|
const next = [...steps];
|
||||||
|
next[index] = { ...step, action_type: event.target.value, action_config: {} };
|
||||||
|
props.updateConfig({ steps: next });
|
||||||
|
}}>
|
||||||
|
<option value="key_combo">Key Press</option>
|
||||||
|
<option value="app_launch">App Launch</option>
|
||||||
|
<option value="noop">No-op</option>
|
||||||
|
</select>
|
||||||
|
{step.action_type === "app_launch" ? (
|
||||||
|
<select className="input" value={step.action_config?.path ?? ""} onChange={event => {
|
||||||
|
const next = [...steps];
|
||||||
|
next[index] = { ...step, action_config: { path: event.target.value } };
|
||||||
|
props.updateConfig({ steps: next });
|
||||||
|
}}>
|
||||||
|
<option value="">App</option>
|
||||||
|
{props.apps.map(app => <option key={`${index}:${app.path}`} value={app.path}>{app.name}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<DraftInput placeholder="ctrl+c" value={step.action_config?.combo ?? ""} onCommit={value => {
|
||||||
|
const next = [...steps];
|
||||||
|
next[index] = { ...step, action_config: { combo: value } };
|
||||||
|
props.updateConfig({ steps: next });
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
<DraftInput type="number" value={step.delay_ms ?? 0} onCommit={value => {
|
||||||
|
const next = [...steps];
|
||||||
|
next[index] = { ...step, delay_ms: Number(value) };
|
||||||
|
props.updateConfig({ steps: next });
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <PluginForm button={button} plugins={props.plugins} updateConfig={props.updateConfig} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginForm(props: { button: ButtonConfig; plugins: PluginInfo[]; updateConfig: (patch: Record<string, any>) => void }) {
|
||||||
|
const plugin = props.plugins.find(item => item.id === props.button.action_config.plugin_id);
|
||||||
|
const action = plugin?.actions.find(item => item.id === props.button.action_config.action_id);
|
||||||
|
const enabledPlugins = props.plugins.filter(item => item.enabled);
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label="Plugin">
|
||||||
|
<select className="input" value={props.button.action_config.plugin_id ?? ""} onChange={event => props.updateConfig({ plugin_id: event.target.value, action_id: "", fields: {} })}>
|
||||||
|
<option value="">Choose plugin</option>
|
||||||
|
{enabledPlugins.map(plugin => <option key={plugin.id} value={plugin.id}>{plugin.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{plugin && (
|
||||||
|
<Field label="Plugin action">
|
||||||
|
<select className="input" value={props.button.action_config.action_id ?? ""} onChange={event => props.updateConfig({ action_id: event.target.value, fields: {} })}>
|
||||||
|
<option value="">Choose action</option>
|
||||||
|
{plugin.actions.map(action => <option key={action.id} value={action.id}>{action.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{action?.fields.map(field => (
|
||||||
|
<PluginFieldInput
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
value={props.button.action_config.fields?.[field.id] ?? field.default ?? ""}
|
||||||
|
onChange={value => props.updateConfig({ fields: { ...(props.button.action_config.fields ?? {}), [field.id]: value } })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{props.plugins.some(plugin => !plugin.enabled) && <p className="hint">Some plugins failed to load. Reload after fixing their backend code.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginFieldInput(props: { field: PluginField; value: any; onChange: (value: any) => void }) {
|
||||||
|
if (props.field.type === "boolean") {
|
||||||
|
return <label className="toggle-row"><input type="checkbox" checked={Boolean(props.value)} onChange={event => props.onChange(event.target.checked)} />{props.field.label}</label>;
|
||||||
|
}
|
||||||
|
if (props.field.type === "key_value") {
|
||||||
|
return <KeyValueField field={props.field} value={props.value} onChange={props.onChange} />;
|
||||||
|
}
|
||||||
|
if (props.field.type === "select") {
|
||||||
|
return (
|
||||||
|
<Field label={props.field.label}>
|
||||||
|
<select className="input" value={props.value} onChange={event => props.onChange(event.target.value)}>
|
||||||
|
{(props.field.options ?? []).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (props.field.type === "textarea" || props.field.type === "json") {
|
||||||
|
return (
|
||||||
|
<Field label={props.field.label}>
|
||||||
|
<DraftTextArea
|
||||||
|
placeholder={props.field.placeholder}
|
||||||
|
value={props.value}
|
||||||
|
onCommit={value => props.onChange(value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Field label={props.field.label}>
|
||||||
|
<DraftInput
|
||||||
|
placeholder={props.field.placeholder}
|
||||||
|
type={inputTypeForPluginField(props.field)}
|
||||||
|
value={props.value}
|
||||||
|
onCommit={value => props.onChange(props.field.type === "number" ? Number(value) : value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyValueField(props: { field: PluginField; value: any; onChange: (value: any) => void }) {
|
||||||
|
const rows = normalizeKeyValueRows(props.value);
|
||||||
|
const updateRow = (index: number, patch: Partial<{ key: string; value: string }>) => {
|
||||||
|
const next = rows.map((row, rowIndex) => rowIndex === index ? { ...row, ...patch } : row);
|
||||||
|
props.onChange(next);
|
||||||
|
};
|
||||||
|
const removeRow = (index: number) => props.onChange(rows.filter((_, rowIndex) => rowIndex !== index));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="label">{props.field.label}</span>
|
||||||
|
<button type="button" className="secondary-button compact-button" onClick={() => props.onChange([...rows, { key: "", value: "" }])}>
|
||||||
|
<Plus size={13} />
|
||||||
|
<span>Add</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="key-value-list">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<div className="key-value-row" key={index}>
|
||||||
|
<input className="input" placeholder="Header" value={row.key} onChange={event => updateRow(index, { key: event.target.value })} />
|
||||||
|
<input className="input" placeholder="Value" value={row.value} onChange={event => updateRow(index, { value: event.target.value })} />
|
||||||
|
<button type="button" className="icon-button" title="Remove header" onClick={() => removeRow(index)}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!rows.length && <div className="empty-inline">No headers configured.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKeyValueRows(value: any): { key: string; value: string }[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(row => ({ key: String(row?.key ?? ""), value: String(row?.value ?? "") }));
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.entries(value).map(([key, rowValue]) => ({ key, value: String(rowValue ?? "") }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputTypeForPluginField(field: PluginField): "text" | "number" | "url" | "password" {
|
||||||
|
if (field.type === "number" || field.type === "url" || field.type === "password") return field.type;
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraftTextArea(props: {
|
||||||
|
value: string | number;
|
||||||
|
placeholder?: string;
|
||||||
|
onCommit: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(String(props.value ?? ""));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(String(props.value ?? ""));
|
||||||
|
}, [props.value]);
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const current = String(props.value ?? "");
|
||||||
|
if (draft !== current) {
|
||||||
|
props.onCommit(draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className="input textarea-input"
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
value={draft}
|
||||||
|
onBlur={commit}
|
||||||
|
onChange={event => setDraft(event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraftInput(props: {
|
||||||
|
value: string | number;
|
||||||
|
type?: "text" | "number" | "url" | "password";
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onCommit: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(String(props.value ?? ""));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(String(props.value ?? ""));
|
||||||
|
}, [props.value]);
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const current = String(props.value ?? "");
|
||||||
|
if (draft !== current) {
|
||||||
|
props.onCommit(draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
disabled={props.disabled}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
type={props.type ?? "text"}
|
||||||
|
value={draft}
|
||||||
|
onBlur={commit}
|
||||||
|
onChange={event => setDraft(event.target.value)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
commit();
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field(props: { label: string; children: ReactNode }) {
|
||||||
|
return <label className="block"><span className="label">{props.label}</span>{props.children}</label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFolderPath(folders: Folder[], active?: Folder): Folder[] {
|
||||||
|
if (!active) return [];
|
||||||
|
const byId = new Map(folders.map(folder => [folder.id, folder]));
|
||||||
|
const path: Folder[] = [];
|
||||||
|
let cursor: Folder | undefined = active;
|
||||||
|
while (cursor) {
|
||||||
|
path.unshift(cursor);
|
||||||
|
cursor = cursor.parent_id ? byId.get(cursor.parent_id) : undefined;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeState(value: unknown): value is DeckState {
|
||||||
|
return Boolean(value && typeof value === "object" && "settings" in value && "buttons" in value);
|
||||||
|
}
|
||||||
36
frontend/src/api.ts
Normal file
36
frontend/src/api.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ActionType, ButtonConfig, DeckState } from "./types";
|
||||||
|
|
||||||
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||||
|
...init
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail ?? response.statusText);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
state: () => request<DeckState>("/api/state"),
|
||||||
|
settings: (payload: Record<string, unknown>) =>
|
||||||
|
request<DeckState>("/api/settings", { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
|
createProfile: (name: string) =>
|
||||||
|
request<DeckState>("/api/profiles", { method: "POST", body: JSON.stringify({ name }) }),
|
||||||
|
updateProfile: (id: string, payload: Record<string, unknown>) =>
|
||||||
|
request<DeckState>(`/api/profiles/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
|
deleteProfile: (id: string) => request<DeckState>(`/api/profiles/${id}`, { method: "DELETE" }),
|
||||||
|
createFolder: (profile_id: string, parent_id: string | null, name: string) =>
|
||||||
|
request<DeckState>("/api/folders", { method: "POST", body: JSON.stringify({ profile_id, parent_id, name }) }),
|
||||||
|
updateFolder: (id: string, payload: Record<string, unknown>) =>
|
||||||
|
request<DeckState>(`/api/folders/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
|
deleteFolder: (id: string) => request<DeckState>(`/api/folders/${id}`, { method: "DELETE" }),
|
||||||
|
updateButton: (id: string, payload: Partial<ButtonConfig>) =>
|
||||||
|
request<ButtonConfig>(`/api/buttons/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
|
addManualApp: (name: string, path: string, args?: string) =>
|
||||||
|
request("/api/apps/manual", { method: "POST", body: JSON.stringify({ name, path, args }) }),
|
||||||
|
reloadPlugins: () => request("/api/plugins/reload", { method: "POST" }),
|
||||||
|
testAction: (action_type: ActionType, action_config: Record<string, unknown>) =>
|
||||||
|
request("/api/actions/test", { method: "POST", body: JSON.stringify({ action_type, action_config }) })
|
||||||
|
};
|
||||||
26
frontend/src/lucide-react.d.ts
vendored
Normal file
26
frontend/src/lucide-react.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
declare module "lucide-react" {
|
||||||
|
import type { ComponentType, SVGProps } from "react";
|
||||||
|
|
||||||
|
export type Icon = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
|
||||||
|
export const AppWindow: Icon;
|
||||||
|
export const Boxes: Icon;
|
||||||
|
export const Cable: Icon;
|
||||||
|
export const CheckCircle2: Icon;
|
||||||
|
export const FolderPlus: Icon;
|
||||||
|
export const Keyboard: Icon;
|
||||||
|
export const Layers3: Icon;
|
||||||
|
export const Loader2: Icon;
|
||||||
|
export const MoreVertical: Icon;
|
||||||
|
export const MousePointerClick: Icon;
|
||||||
|
export const Pencil: Icon;
|
||||||
|
export const Play: Icon;
|
||||||
|
export const Plus: Icon;
|
||||||
|
export const Power: Icon;
|
||||||
|
export const RefreshCw: Icon;
|
||||||
|
export const RotateCw: Icon;
|
||||||
|
export const Route: Icon;
|
||||||
|
export const Save: Icon;
|
||||||
|
export const ShieldCheck: Icon;
|
||||||
|
export const Trash2: Icon;
|
||||||
|
export const Unplug: Icon;
|
||||||
|
}
|
||||||
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
469
frontend/src/styles.css
Normal file
469
frontend/src/styles.css
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #090b10;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button,
|
||||||
|
.secondary-button,
|
||||||
|
.primary-button,
|
||||||
|
.danger-button,
|
||||||
|
.icon-button,
|
||||||
|
.status-pill,
|
||||||
|
.stat-pill,
|
||||||
|
.nav-select {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #263244;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button,
|
||||||
|
.secondary-button,
|
||||||
|
.icon-button,
|
||||||
|
.status-pill,
|
||||||
|
.nav-select {
|
||||||
|
background: #121824;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-stats {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-pill {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #cbd5e1;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-pill span {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-pill strong {
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-select {
|
||||||
|
max-width: 230px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-select select {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-width: 120px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-select option {
|
||||||
|
background: #111827;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-menu {
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 6px;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-menu button {
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-menu button:hover {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-menu button:disabled {
|
||||||
|
color: #64748b;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-menu button:disabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-separator {
|
||||||
|
background: #263244;
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button.active,
|
||||||
|
.primary-button {
|
||||||
|
background: #06b6d4;
|
||||||
|
border-color: #67e8f9;
|
||||||
|
color: #061016;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #f87171;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:disabled {
|
||||||
|
background: #1f2937;
|
||||||
|
border-color: #334155;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title,
|
||||||
|
.panel-title,
|
||||||
|
.label {
|
||||||
|
color: #94a3b8;
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-row {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-row:hover,
|
||||||
|
.nav-row.active {
|
||||||
|
background: #111827;
|
||||||
|
border-color: #263244;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-strip {
|
||||||
|
align-items: stretch;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(10, minmax(86px, 1fr));
|
||||||
|
min-height: 150px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 142px;
|
||||||
|
min-width: 86px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-button.selected {
|
||||||
|
border-color: #22d3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-button.over {
|
||||||
|
background: rgba(34, 211, 238, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-face {
|
||||||
|
border: 1px solid #263244;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 12px 30px rgba(0,0,0,0.35);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
position: relative;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-button.pressed .deck-face {
|
||||||
|
border-color: #67e8f9;
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(103,232,249,0.35), 0 0 28px rgba(34,211,238,0.25);
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-face.dragging {
|
||||||
|
opacity: 0.75;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-face.layout-locked {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.click-check-badge {
|
||||||
|
background: #22d3ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
bottom: 8px;
|
||||||
|
color: #061016;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 2px 6px;
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #0d1119;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-height: 40px;
|
||||||
|
outline: none;
|
||||||
|
padding: 0 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: #22d3ee;
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled {
|
||||||
|
color: #64748b;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-input {
|
||||||
|
line-height: 1.45;
|
||||||
|
min-height: 130px;
|
||||||
|
padding: 10px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-value-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-value-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr) 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-inline {
|
||||||
|
border: 1px dashed #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
border: 1px dashed #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
max-height: 230px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row {
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 56px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row small {
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-panel {
|
||||||
|
border: 1px dashed #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #64748b;
|
||||||
|
padding: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-step {
|
||||||
|
background: #0d1119;
|
||||||
|
border: 1px solid #263244;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 1fr 1fr 92px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
align-items: center;
|
||||||
|
color: #cbd5e1;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(2, 6, 23, 0.72);
|
||||||
|
display: flex;
|
||||||
|
inset: 0;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #0d1119;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 460px;
|
||||||
|
padding: 18px;
|
||||||
|
width: min(100%, 460px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
color: #cbd5e1;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
main {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
main > aside:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid #1f2937;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
height: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
frontend/src/types.ts
Normal file
89
frontend/src/types.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
export type TriggerMode = "down" | "up";
|
||||||
|
export type ActionType = "noop" | "key_combo" | "chain" | "app_launch" | "folder" | "folder_rotation" | "plugin";
|
||||||
|
|
||||||
|
export type Settings = {
|
||||||
|
serial_port: string | null;
|
||||||
|
click_check: boolean;
|
||||||
|
active_profile_id: string;
|
||||||
|
active_folder_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Profile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Folder = {
|
||||||
|
id: string;
|
||||||
|
profile_id: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
name: string;
|
||||||
|
is_root: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ButtonConfig = {
|
||||||
|
id: string;
|
||||||
|
profile_id: string;
|
||||||
|
folder_id: string;
|
||||||
|
position: number;
|
||||||
|
physical_button: number;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
trigger_mode: TriggerMode;
|
||||||
|
action_type: ActionType;
|
||||||
|
action_config: Record<string, any>;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppEntry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
args?: string | null;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginField = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: "text" | "number" | "boolean" | "select" | "app" | "key_combo" | "url" | "password" | "textarea" | "json" | "key_value";
|
||||||
|
required?: boolean;
|
||||||
|
default?: any;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginAction = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
desc?: string;
|
||||||
|
fields: PluginField[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginInfo = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
version: string;
|
||||||
|
actions: PluginAction[];
|
||||||
|
enabled: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeckState = {
|
||||||
|
settings: Settings;
|
||||||
|
profiles: Profile[];
|
||||||
|
folders: Folder[];
|
||||||
|
buttons: ButtonConfig[];
|
||||||
|
apps: AppEntry[];
|
||||||
|
plugins: PluginInfo[];
|
||||||
|
device: { connected_port: string | null };
|
||||||
|
layout?: {
|
||||||
|
canonical_profile_id: string;
|
||||||
|
canonical_folder_id: string;
|
||||||
|
mapping: Record<string, number>;
|
||||||
|
};
|
||||||
|
};
|
||||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
|
|
||||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:8000",
|
||||||
|
"/ws": {
|
||||||
|
target: "ws://127.0.0.1:8000",
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
69
pc/listen_buttons.py
Normal file
69
pc/listen_buttons.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""PC-side serial logger for the custom streamdeck Pico."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import serial
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
DEFAULT_BAUD = 115200
|
||||||
|
DEFAULT_LOG = Path(__file__).resolve().parent.parent / "button_log.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def find_pico_port() -> str | None:
|
||||||
|
"""Return the first likely Raspberry Pi Pico serial port."""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def format_event(raw_line: str) -> str:
|
||||||
|
timestamp = datetime.now().isoformat(timespec="seconds")
|
||||||
|
try:
|
||||||
|
event = json.loads(raw_line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return f"{timestamp} raw {raw_line}"
|
||||||
|
|
||||||
|
button = event.get("button", "?")
|
||||||
|
pin = event.get("pin", "?")
|
||||||
|
action = event.get("event", "?")
|
||||||
|
return f"{timestamp} button={button} pin=GP{pin} event={action}"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Listen for Pico button events and log them.")
|
||||||
|
parser.add_argument("--port", help="Serial port, for example COM5. Auto-detects Pico if omitted.")
|
||||||
|
parser.add_argument("--baud", type=int, default=DEFAULT_BAUD, help=f"Serial baud rate. Default: {DEFAULT_BAUD}")
|
||||||
|
parser.add_argument("--log", type=Path, default=DEFAULT_LOG, help=f"Log file. Default: {DEFAULT_LOG}")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
port = args.port or find_pico_port()
|
||||||
|
if not port:
|
||||||
|
print("Could not find a Pico serial port. Try: python pc/listen_buttons.py --port COM5")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
args.log.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"Listening on {port} at {args.baud} baud. Logging to {args.log}")
|
||||||
|
print("Press Ctrl+C to stop.")
|
||||||
|
|
||||||
|
with serial.Serial(port, args.baud, timeout=1) as ser, args.log.open("a", encoding="utf-8") as log_file:
|
||||||
|
while True:
|
||||||
|
raw = ser.readline().decode("utf-8", errors="replace").strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
line = format_event(raw)
|
||||||
|
print(line, flush=True)
|
||||||
|
log_file.write(line + "\n")
|
||||||
|
log_file.flush()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
43
pico/main.py
Normal file
43
pico/main.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# MicroPython firmware for the 10-button custom streamdeck Pico.
|
||||||
|
# Copy this file to the Pico as /main.py.
|
||||||
|
|
||||||
|
from machine import Pin
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Button index 1..10 maps to the physical order provided by the wiring list.
|
||||||
|
BUTTON_PINS = [28, 27, 26, 22, 21, 20, 18, 19, 17, 16]
|
||||||
|
DEBOUNCE_MS = 35
|
||||||
|
POLL_MS = 5
|
||||||
|
|
||||||
|
buttons = [Pin(pin, Pin.IN, Pin.PULL_UP) for pin in BUTTON_PINS]
|
||||||
|
last_raw = [button.value() for button in buttons]
|
||||||
|
stable_state = last_raw[:]
|
||||||
|
last_change = [time.ticks_ms() for _ in buttons]
|
||||||
|
|
||||||
|
print("streamdeck-pico ready")
|
||||||
|
print("pins=" + ",".join(str(pin) for pin in BUTTON_PINS))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
now = time.ticks_ms()
|
||||||
|
|
||||||
|
for index, button in enumerate(buttons):
|
||||||
|
raw = button.value()
|
||||||
|
|
||||||
|
if raw != last_raw[index]:
|
||||||
|
last_raw[index] = raw
|
||||||
|
last_change[index] = now
|
||||||
|
|
||||||
|
if raw != stable_state[index] and time.ticks_diff(now, last_change[index]) >= DEBOUNCE_MS:
|
||||||
|
stable_state[index] = raw
|
||||||
|
pressed = raw == 0
|
||||||
|
event = "down" if pressed else "up"
|
||||||
|
button_number = index + 1
|
||||||
|
gp_pin = BUTTON_PINS[index]
|
||||||
|
print('{{"button":{},"pin":{},"event":"{}","pressed":{}}}'.format(
|
||||||
|
button_number,
|
||||||
|
gp_pin,
|
||||||
|
event,
|
||||||
|
"true" if pressed else "false",
|
||||||
|
))
|
||||||
|
|
||||||
|
time.sleep_ms(POLL_MS)
|
||||||
279
plugins/clipboard_tools.py
Normal file
279
plugins/clipboard_tools.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_.-]+)\s*}}")
|
||||||
|
|
||||||
|
|
||||||
|
class ClipboardToolsPlugin:
|
||||||
|
name = "Clipboard Tools"
|
||||||
|
desc = "Copy preset text, paste snippets, and transform clipboard text."
|
||||||
|
version = "0.1.0"
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"id": "copy_text",
|
||||||
|
"name": "Copy Preset Text",
|
||||||
|
"desc": "Put preset text on the system clipboard.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "text",
|
||||||
|
"label": "Text",
|
||||||
|
"type": "textarea",
|
||||||
|
"required": True,
|
||||||
|
"placeholder": "Paste the text or template to copy.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "paste_snippet",
|
||||||
|
"name": "Paste Snippet",
|
||||||
|
"desc": "Copy text to the clipboard, send Ctrl+V, and optionally restore the previous clipboard value.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "text",
|
||||||
|
"label": "Snippet",
|
||||||
|
"type": "textarea",
|
||||||
|
"required": True,
|
||||||
|
"placeholder": "Snippet to paste into the active app.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "restore_clipboard",
|
||||||
|
"label": "Restore previous clipboard",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "restore_delay_ms",
|
||||||
|
"label": "Restore Delay (ms)",
|
||||||
|
"type": "number",
|
||||||
|
"default": 120,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "transform_clipboard",
|
||||||
|
"name": "Transform Clipboard Text",
|
||||||
|
"desc": "Read text from the clipboard, transform it, then write it back.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "transform",
|
||||||
|
"label": "Transform",
|
||||||
|
"type": "select",
|
||||||
|
"default": "uppercase",
|
||||||
|
"options": [
|
||||||
|
{"label": "UPPERCASE", "value": "uppercase"},
|
||||||
|
{"label": "lowercase", "value": "lowercase"},
|
||||||
|
{"label": "Title Case", "value": "titlecase"},
|
||||||
|
{"label": "Sentence case", "value": "sentencecase"},
|
||||||
|
{"label": "Trim edges", "value": "trim"},
|
||||||
|
{"label": "Collapse whitespace", "value": "collapse_whitespace"},
|
||||||
|
{"label": "snake_case", "value": "snake_case"},
|
||||||
|
{"label": "kebab-case", "value": "kebab_case"},
|
||||||
|
{"label": "camelCase", "value": "camel_case"},
|
||||||
|
{"label": "Replace text", "value": "replace"},
|
||||||
|
{"label": "Add prefix", "value": "prefix"},
|
||||||
|
{"label": "Add suffix", "value": "suffix"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "find",
|
||||||
|
"label": "Find",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "Used by Replace text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "replace",
|
||||||
|
"label": "Replace With",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "Used by Replace text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "extra_text",
|
||||||
|
"label": "Prefix / Suffix Text",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "Used by Add prefix or Add suffix",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "auto_paste",
|
||||||
|
"label": "Paste after transform",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def on_load(self, ctx):
|
||||||
|
ctx.db.add_event("plugin.loaded", {"plugin": self.name})
|
||||||
|
|
||||||
|
async def execute_action(self, ctx, action_id, config, event):
|
||||||
|
if action_id == "copy_text":
|
||||||
|
text = self._render_text(config.get("text", ""), event)
|
||||||
|
self._set_clipboard_text(text)
|
||||||
|
ctx.db.add_event("plugin.clipboard.copy", {"plugin": self.name, "chars": len(text)})
|
||||||
|
return
|
||||||
|
|
||||||
|
if action_id == "paste_snippet":
|
||||||
|
text = self._render_text(config.get("text", ""), event)
|
||||||
|
restore_clipboard = bool(config.get("restore_clipboard", True))
|
||||||
|
restore_delay_ms = max(0, min(5000, int(config.get("restore_delay_ms", 120) or 0)))
|
||||||
|
previous = self._get_clipboard_text() if restore_clipboard else None
|
||||||
|
self._set_clipboard_text(text)
|
||||||
|
await self._paste_via_shortcut(ctx)
|
||||||
|
if restore_clipboard:
|
||||||
|
if restore_delay_ms:
|
||||||
|
await asyncio.sleep(restore_delay_ms / 1000)
|
||||||
|
self._set_clipboard_text(previous or "")
|
||||||
|
ctx.db.add_event(
|
||||||
|
"plugin.clipboard.paste",
|
||||||
|
{"plugin": self.name, "chars": len(text), "restore_clipboard": restore_clipboard},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if action_id == "transform_clipboard":
|
||||||
|
original = self._get_clipboard_text()
|
||||||
|
if not original:
|
||||||
|
raise ValueError("Clipboard does not contain text to transform.")
|
||||||
|
transformed = self._transform_text(original, config)
|
||||||
|
self._set_clipboard_text(transformed)
|
||||||
|
if config.get("auto_paste"):
|
||||||
|
await self._paste_via_shortcut(ctx)
|
||||||
|
ctx.db.add_event(
|
||||||
|
"plugin.clipboard.transform",
|
||||||
|
{
|
||||||
|
"plugin": self.name,
|
||||||
|
"transform": str(config.get("transform", "uppercase")),
|
||||||
|
"before_chars": len(original),
|
||||||
|
"after_chars": len(transformed),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
raise ValueError(f"Unknown clipboard action '{action_id}'.")
|
||||||
|
|
||||||
|
def _render_text(self, value: Any, event: dict[str, Any] | None) -> str:
|
||||||
|
template = str(value or "")
|
||||||
|
context = {"event": event or {}}
|
||||||
|
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
current: Any = context
|
||||||
|
for part in match.group(1).split("."):
|
||||||
|
if isinstance(current, dict):
|
||||||
|
current = current.get(part, "")
|
||||||
|
else:
|
||||||
|
current = getattr(current, part, "")
|
||||||
|
return "" if current is None else str(current)
|
||||||
|
|
||||||
|
return TOKEN_RE.sub(replace, template)
|
||||||
|
|
||||||
|
def _transform_text(self, text: str, config: dict[str, Any]) -> str:
|
||||||
|
transform = str(config.get("transform", "uppercase") or "uppercase")
|
||||||
|
if transform == "uppercase":
|
||||||
|
return text.upper()
|
||||||
|
if transform == "lowercase":
|
||||||
|
return text.lower()
|
||||||
|
if transform == "titlecase":
|
||||||
|
return text.title()
|
||||||
|
if transform == "sentencecase":
|
||||||
|
stripped = text.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
return stripped[:1].upper() + stripped[1:].lower()
|
||||||
|
if transform == "trim":
|
||||||
|
return text.strip()
|
||||||
|
if transform == "collapse_whitespace":
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
if transform == "snake_case":
|
||||||
|
return self._to_delimited_case(text, "_")
|
||||||
|
if transform == "kebab_case":
|
||||||
|
return self._to_delimited_case(text, "-")
|
||||||
|
if transform == "camel_case":
|
||||||
|
words = self._split_words(text)
|
||||||
|
if not words:
|
||||||
|
return ""
|
||||||
|
return words[0] + "".join(word[:1].upper() + word[1:] for word in words[1:])
|
||||||
|
if transform == "replace":
|
||||||
|
find = str(config.get("find", ""))
|
||||||
|
if not find:
|
||||||
|
raise ValueError("Replace text requires a Find value.")
|
||||||
|
return text.replace(find, str(config.get("replace", "")))
|
||||||
|
if transform == "prefix":
|
||||||
|
return f"{str(config.get('extra_text', ''))}{text}"
|
||||||
|
if transform == "suffix":
|
||||||
|
return f"{text}{str(config.get('extra_text', ''))}"
|
||||||
|
raise ValueError(f"Unknown transform '{transform}'.")
|
||||||
|
|
||||||
|
def _split_words(self, text: str) -> list[str]:
|
||||||
|
normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
|
||||||
|
tokens = re.split(r"[^a-zA-Z0-9]+", normalized)
|
||||||
|
return [token.lower() for token in tokens if token]
|
||||||
|
|
||||||
|
def _to_delimited_case(self, text: str, delimiter: str) -> str:
|
||||||
|
return delimiter.join(self._split_words(text))
|
||||||
|
|
||||||
|
async def _paste_via_shortcut(self, ctx) -> None:
|
||||||
|
keys = getattr(getattr(ctx.app, "actions", None), "keys", None)
|
||||||
|
if keys is None:
|
||||||
|
raise RuntimeError("Action engine is not available for paste shortcuts.")
|
||||||
|
result = keys.press_combo("ctrl+v")
|
||||||
|
if hasattr(result, "__await__"):
|
||||||
|
await result
|
||||||
|
|
||||||
|
def _get_clipboard_text(self) -> str:
|
||||||
|
try:
|
||||||
|
import win32clipboard
|
||||||
|
import win32con
|
||||||
|
except ImportError:
|
||||||
|
return self._get_clipboard_text_tk()
|
||||||
|
|
||||||
|
win32clipboard.OpenClipboard()
|
||||||
|
try:
|
||||||
|
if not win32clipboard.IsClipboardFormatAvailable(win32con.CF_UNICODETEXT):
|
||||||
|
return ""
|
||||||
|
data = win32clipboard.GetClipboardData(win32con.CF_UNICODETEXT)
|
||||||
|
return str(data or "")
|
||||||
|
finally:
|
||||||
|
win32clipboard.CloseClipboard()
|
||||||
|
|
||||||
|
def _set_clipboard_text(self, text: str) -> None:
|
||||||
|
try:
|
||||||
|
import win32clipboard
|
||||||
|
import win32con
|
||||||
|
except ImportError:
|
||||||
|
self._set_clipboard_text_tk(text)
|
||||||
|
return
|
||||||
|
|
||||||
|
win32clipboard.OpenClipboard()
|
||||||
|
try:
|
||||||
|
win32clipboard.EmptyClipboard()
|
||||||
|
win32clipboard.SetClipboardData(win32con.CF_UNICODETEXT, text)
|
||||||
|
finally:
|
||||||
|
win32clipboard.CloseClipboard()
|
||||||
|
|
||||||
|
def _get_clipboard_text_tk(self) -> str:
|
||||||
|
import tkinter
|
||||||
|
|
||||||
|
root = tkinter.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
try:
|
||||||
|
return root.clipboard_get()
|
||||||
|
except tkinter.TclError:
|
||||||
|
return ""
|
||||||
|
finally:
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
def _set_clipboard_text_tk(self, text: str) -> None:
|
||||||
|
import tkinter
|
||||||
|
|
||||||
|
root = tkinter.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
root.clipboard_clear()
|
||||||
|
root.clipboard_append(text)
|
||||||
|
root.update()
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN = ClipboardToolsPlugin()
|
||||||
134
plugins/http_requests.py
Normal file
134
plugins/http_requests.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_.-]+)\s*}}")
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPRequestsPlugin:
|
||||||
|
name = "HTTP Requests"
|
||||||
|
desc = "Send configurable HTTP requests to webhooks, local services, and automation tools."
|
||||||
|
version = "0.1.0"
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"id": "send_request",
|
||||||
|
"name": "Send Request",
|
||||||
|
"desc": "Send a GET, POST, PUT, PATCH, or DELETE request.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "method",
|
||||||
|
"label": "Method",
|
||||||
|
"type": "select",
|
||||||
|
"default": "POST",
|
||||||
|
"options": [
|
||||||
|
{"label": "GET", "value": "GET"},
|
||||||
|
{"label": "POST", "value": "POST"},
|
||||||
|
{"label": "PUT", "value": "PUT"},
|
||||||
|
{"label": "PATCH", "value": "PATCH"},
|
||||||
|
{"label": "DELETE", "value": "DELETE"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "url",
|
||||||
|
"label": "URL",
|
||||||
|
"type": "url",
|
||||||
|
"required": True,
|
||||||
|
"placeholder": "http://127.0.0.1:8080/webhook",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "headers",
|
||||||
|
"label": "Headers",
|
||||||
|
"type": "key_value",
|
||||||
|
"default": [{"key": "Content-Type", "value": "application/json"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "body",
|
||||||
|
"label": "Body",
|
||||||
|
"type": "json",
|
||||||
|
"default": '{\n "button": "{{event.button}}",\n "event": "{{event.event}}"\n}',
|
||||||
|
},
|
||||||
|
{"id": "timeout", "label": "Timeout Seconds", "type": "number", "default": 5},
|
||||||
|
{"id": "log_response", "label": "Log response", "type": "boolean", "default": True},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def on_load(self, ctx):
|
||||||
|
ctx.db.add_event("plugin.loaded", {"plugin": self.name})
|
||||||
|
|
||||||
|
def execute_action(self, ctx, action_id, config, event):
|
||||||
|
if action_id != "send_request":
|
||||||
|
raise ValueError(f"Unknown HTTP action '{action_id}'.")
|
||||||
|
|
||||||
|
method = str(config.get("method", "POST")).upper()
|
||||||
|
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
|
||||||
|
raise ValueError(f"Unsupported HTTP method '{method}'.")
|
||||||
|
|
||||||
|
context = {"event": event or {}}
|
||||||
|
url = self._render(str(config.get("url", "")).strip(), context)
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("HTTP Request URL must start with http:// or https://.")
|
||||||
|
|
||||||
|
timeout = max(1.0, min(30.0, float(config.get("timeout", 5) or 5)))
|
||||||
|
headers = self._headers(config.get("headers"), context)
|
||||||
|
body_text = self._render(str(config.get("body", "") or ""), context).strip()
|
||||||
|
data = None
|
||||||
|
if method not in {"GET", "DELETE"} and body_text:
|
||||||
|
data = body_text.encode("utf-8")
|
||||||
|
headers.setdefault("Content-Type", "application/json")
|
||||||
|
|
||||||
|
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
response_body = response.read(4096).decode("utf-8", errors="replace")
|
||||||
|
status = response.status
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
response_body = exc.read(4096).decode("utf-8", errors="replace")
|
||||||
|
raise RuntimeError(f"HTTP request failed with {exc.code}: {response_body[:300]}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise RuntimeError(f"HTTP request failed: {exc.reason}") from exc
|
||||||
|
|
||||||
|
if config.get("log_response", True):
|
||||||
|
ctx.db.add_event(
|
||||||
|
"plugin.http_response",
|
||||||
|
{"status": status, "url": url, "method": method, "body": response_body[:1000]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _headers(self, raw_headers: Any, context: dict[str, Any]) -> dict[str, str]:
|
||||||
|
if isinstance(raw_headers, dict):
|
||||||
|
items = raw_headers.items()
|
||||||
|
elif isinstance(raw_headers, list):
|
||||||
|
items = ((item.get("key"), item.get("value")) for item in raw_headers if isinstance(item, dict))
|
||||||
|
else:
|
||||||
|
items = []
|
||||||
|
headers = {}
|
||||||
|
for key, value in items:
|
||||||
|
rendered_key = self._render(str(key or "").strip(), context)
|
||||||
|
if not rendered_key:
|
||||||
|
continue
|
||||||
|
headers[rendered_key] = self._render(str(value or ""), context)
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def _render(self, template: str, context: dict[str, Any]) -> str:
|
||||||
|
def replace(match):
|
||||||
|
value: Any = context
|
||||||
|
for part in match.group(1).split("."):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = value.get(part, "")
|
||||||
|
else:
|
||||||
|
value = getattr(value, part, "")
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
return json.dumps(value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
return TOKEN_RE.sub(replace, template)
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN = HTTPRequestsPlugin()
|
||||||
90
plugins/media_controls.py
Normal file
90
plugins/media_controls.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class MediaControlsPlugin:
|
||||||
|
name = "Media Controls"
|
||||||
|
desc = "Send system media keys through the backend plugin action path."
|
||||||
|
version = "0.1.0"
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"id": "media_key",
|
||||||
|
"name": "Media Key",
|
||||||
|
"desc": "Play/pause, skip, stop, mute, or adjust volume.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "command",
|
||||||
|
"label": "Command",
|
||||||
|
"type": "select",
|
||||||
|
"default": "play_pause",
|
||||||
|
"options": [
|
||||||
|
{"label": "Play / Pause", "value": "play_pause"},
|
||||||
|
{"label": "Next Track", "value": "next"},
|
||||||
|
{"label": "Previous Track", "value": "previous"},
|
||||||
|
{"label": "Stop", "value": "stop"},
|
||||||
|
{"label": "Volume Up", "value": "volume_up"},
|
||||||
|
{"label": "Volume Down", "value": "volume_down"},
|
||||||
|
{"label": "Mute", "value": "mute"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "volume_repeat",
|
||||||
|
"name": "Volume Repeat",
|
||||||
|
"desc": "Send volume up or down multiple times.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "direction",
|
||||||
|
"label": "Direction",
|
||||||
|
"type": "select",
|
||||||
|
"default": "volume_up",
|
||||||
|
"options": [
|
||||||
|
{"label": "Volume Up", "value": "volume_up"},
|
||||||
|
{"label": "Volume Down", "value": "volume_down"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"id": "steps", "label": "Steps", "type": "number", "default": 3},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def on_load(self, ctx):
|
||||||
|
ctx.db.add_event("plugin.loaded", {"plugin": self.name})
|
||||||
|
|
||||||
|
def execute_action(self, ctx, action_id, config, event):
|
||||||
|
if action_id == "media_key":
|
||||||
|
self._press(config.get("command", "play_pause"))
|
||||||
|
return
|
||||||
|
if action_id == "volume_repeat":
|
||||||
|
steps = max(1, min(20, int(config.get("steps", 3) or 3)))
|
||||||
|
command = config.get("direction", "volume_up")
|
||||||
|
for _ in range(steps):
|
||||||
|
self._press(command)
|
||||||
|
return
|
||||||
|
raise ValueError(f"Unknown media action '{action_id}'.")
|
||||||
|
|
||||||
|
def _press(self, command):
|
||||||
|
from pynput.keyboard import Controller, Key
|
||||||
|
|
||||||
|
keys = {
|
||||||
|
"play_pause": "media_play_pause",
|
||||||
|
"next": "media_next",
|
||||||
|
"previous": "media_previous",
|
||||||
|
"stop": "media_stop",
|
||||||
|
"volume_up": "media_volume_up",
|
||||||
|
"volume_down": "media_volume_down",
|
||||||
|
"mute": "media_volume_mute",
|
||||||
|
}
|
||||||
|
key_name = keys.get(command)
|
||||||
|
if not key_name:
|
||||||
|
raise ValueError(f"Unknown media command '{command}'.")
|
||||||
|
key = getattr(Key, key_name, None)
|
||||||
|
if key is None:
|
||||||
|
raise RuntimeError(f"pynput does not expose Key.{key_name} on this platform.")
|
||||||
|
keyboard = Controller()
|
||||||
|
keyboard.press(key)
|
||||||
|
keyboard.release(key)
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN = MediaControlsPlugin()
|
||||||
|
|
||||||
40
plugins/obs_integration.py
Normal file
40
plugins/obs_integration.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class OBSIntegrationPlugin:
|
||||||
|
name = "OBS Integration"
|
||||||
|
desc = "Sample backend plugin showing typed actions for future OBS control."
|
||||||
|
version = "0.1.0"
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"id": "switch_scene",
|
||||||
|
"name": "Switch Scene",
|
||||||
|
"desc": "Switch OBS to a named scene. This sample logs the intended action.",
|
||||||
|
"fields": [
|
||||||
|
{"id": "scene", "label": "Scene Name", "type": "text", "required": True, "default": "Starting Soon"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "toggle_stream",
|
||||||
|
"name": "Toggle Stream",
|
||||||
|
"desc": "Toggle stream state. This sample logs the intended action.",
|
||||||
|
"fields": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def on_load(self, ctx):
|
||||||
|
ctx.db.add_event("plugin.loaded", {"plugin": self.name})
|
||||||
|
|
||||||
|
def on_event(self, ctx, event):
|
||||||
|
if event["type"].startswith("button."):
|
||||||
|
ctx.db.add_event("plugin.event", {"plugin": self.name, "event": event["type"]})
|
||||||
|
|
||||||
|
def execute_action(self, ctx, action_id, config, event):
|
||||||
|
ctx.db.add_event(
|
||||||
|
"plugin.action",
|
||||||
|
{"plugin": self.name, "action_id": action_id, "config": config, "source_event": event},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN = OBSIntegrationPlugin()
|
||||||
|
|
||||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.135.3
|
||||||
|
uvicorn[standard]>=0.35.0
|
||||||
|
pyserial==3.5
|
||||||
|
pynput>=1.8.1
|
||||||
|
pydantic>=2.11.0
|
||||||
|
python-multipart>=0.0.20
|
||||||
|
pywin32>=311; platform_system == "Windows"
|
||||||
|
pytest>=9.0.0
|
||||||
286
tests/test_backend.py
Normal file
286
tests/test_backend.py
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.database import Database
|
||||||
|
from backend.services.actions import ActionEngine
|
||||||
|
from backend.services.pico import parse_pico_line
|
||||||
|
from backend.services.plugins import PluginContext, PluginManager
|
||||||
|
from plugins.clipboard_tools import ClipboardToolsPlugin
|
||||||
|
from plugins.http_requests import HTTPRequestsPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class DummyWs:
|
||||||
|
async def broadcast(self, *_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class DummyApp:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
self.ws = DummyWs()
|
||||||
|
self.actions = type("Actions", (), {"keys": type("Keys", (), {"press_combo": staticmethod(_noop_press_combo)})()})()
|
||||||
|
|
||||||
|
def public_state(self):
|
||||||
|
return self.db.state()
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_press_combo(_combo):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_pico_event_accepts_current_firmware_json():
|
||||||
|
event = parse_pico_line('{"button":2,"pin":27,"event":"down","pressed":true}')
|
||||||
|
assert event is not None
|
||||||
|
assert event.button == 2
|
||||||
|
assert event.pin == 27
|
||||||
|
assert event.event == "down"
|
||||||
|
assert event.pressed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_pico_event_ignores_startup_text():
|
||||||
|
assert parse_pico_line("streamdeck-pico ready") is None
|
||||||
|
assert parse_pico_line("pins=28,27,26") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_seeds_profiles_folders_and_buttons(tmp_path: Path):
|
||||||
|
db = Database(tmp_path / "streamdeck.sqlite")
|
||||||
|
state = db.state()
|
||||||
|
assert len(state["profiles"]) == 1
|
||||||
|
assert len(state["folders"]) == 1
|
||||||
|
assert len(state["buttons"]) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_physical_button_swaps_mapping(tmp_path: Path):
|
||||||
|
db = Database(tmp_path / "streamdeck.sqlite")
|
||||||
|
folder_id = db.get_setting("active_folder_id")
|
||||||
|
db.move_physical_button(folder_id, position=1, physical_button=2)
|
||||||
|
buttons = [button for button in db.state()["buttons"] if button["folder_id"] == folder_id]
|
||||||
|
by_pos = {button["position"]: button["physical_button"] for button in buttons}
|
||||||
|
assert by_pos[1] == 2
|
||||||
|
assert by_pos[2] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_physical_layout_syncs_to_all_profiles_and_folders_without_actions(tmp_path: Path):
|
||||||
|
db = Database(tmp_path / "streamdeck.sqlite")
|
||||||
|
first_profile_id = db.get_setting("active_profile_id")
|
||||||
|
canonical_folder_id = db.get_setting("active_folder_id")
|
||||||
|
extra_folder_id = db.create_folder(first_profile_id, canonical_folder_id, "Extra")
|
||||||
|
second_profile_id = db.create_profile("Second")
|
||||||
|
second_root_id = db.get_setting("active_folder_id")
|
||||||
|
|
||||||
|
second_button = next(
|
||||||
|
button for button in db.state()["buttons"]
|
||||||
|
if button["folder_id"] == second_root_id and button["position"] == 1
|
||||||
|
)
|
||||||
|
db.update_button(second_button["id"], {"action_type": "key_combo", "action_config": {"combo": "ctrl+s"}})
|
||||||
|
|
||||||
|
db.move_physical_button(canonical_folder_id, position=1, physical_button=2)
|
||||||
|
state = db.state()
|
||||||
|
for folder_id in {canonical_folder_id, extra_folder_id, second_root_id}:
|
||||||
|
buttons = [button for button in state["buttons"] if button["folder_id"] == folder_id]
|
||||||
|
by_pos = {button["position"]: button["physical_button"] for button in buttons}
|
||||||
|
assert by_pos[1] == 2
|
||||||
|
assert by_pos[2] == 1
|
||||||
|
|
||||||
|
updated_second_button = db.get_button(second_button["id"])
|
||||||
|
assert updated_second_button["action_type"] == "key_combo"
|
||||||
|
assert updated_second_button["action_config"] == {"combo": "ctrl+s"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_physical_layout_cannot_be_changed_outside_canonical_folder(tmp_path: Path):
|
||||||
|
db = Database(tmp_path / "streamdeck.sqlite")
|
||||||
|
profile_id = db.get_setting("active_profile_id")
|
||||||
|
root_id = db.get_setting("active_folder_id")
|
||||||
|
folder_id = db.create_folder(profile_id, root_id, "Locked")
|
||||||
|
button = next(button for button in db.state()["buttons"] if button["folder_id"] == folder_id and button["position"] == 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.move_physical_button(folder_id, position=1, physical_button=2)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "first profile's root folder" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected non-canonical folder move to fail.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.update_button(button["id"], {"physical_button": 2})
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "first profile's root folder" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected non-canonical physical update to fail.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_rotation_moves_to_next_folder_and_wraps(tmp_path: Path):
|
||||||
|
db = Database(tmp_path / "streamdeck.sqlite")
|
||||||
|
profile_id = db.get_setting("active_profile_id")
|
||||||
|
root_id = db.get_setting("active_folder_id")
|
||||||
|
first_id = db.create_folder(profile_id, root_id, "First")
|
||||||
|
second_id = db.create_folder(profile_id, root_id, "Second")
|
||||||
|
db.set_setting("active_folder_id", root_id)
|
||||||
|
|
||||||
|
engine = ActionEngine(DummyApp(db))
|
||||||
|
asyncio.run(engine.execute("folder_rotation", {"direction": "next"}))
|
||||||
|
assert db.get_setting("active_folder_id") == first_id
|
||||||
|
|
||||||
|
asyncio.run(engine.execute("folder_rotation", {"direction": "next"}))
|
||||||
|
assert db.get_setting("active_folder_id") == second_id
|
||||||
|
|
||||||
|
asyncio.run(engine.execute("folder_rotation", {"direction": "next"}))
|
||||||
|
assert db.get_setting("active_folder_id") == root_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_profiles_and_folders_can_be_renamed_and_deleted(tmp_path: Path):
|
||||||
|
db = Database(tmp_path / "streamdeck.sqlite")
|
||||||
|
profile_id = db.get_setting("active_profile_id")
|
||||||
|
root_id = db.get_setting("active_folder_id")
|
||||||
|
db.update_profile(profile_id, name="Renamed Profile")
|
||||||
|
db.update_folder(root_id, name="Renamed Root")
|
||||||
|
folder_id = db.create_folder(profile_id, root_id, "Temporary")
|
||||||
|
|
||||||
|
state = db.state()
|
||||||
|
assert state["profiles"][0]["name"] == "Renamed Profile"
|
||||||
|
assert next(folder for folder in state["folders"] if folder["id"] == root_id)["name"] == "Renamed Root"
|
||||||
|
|
||||||
|
db.delete_folder(folder_id)
|
||||||
|
assert all(folder["id"] != folder_id for folder in db.state()["folders"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_profile_and_root_folder_are_protected(tmp_path: Path):
|
||||||
|
db = Database(tmp_path / "streamdeck.sqlite")
|
||||||
|
first_profile_id = db.get_setting("active_profile_id")
|
||||||
|
root_id = db.get_setting("active_folder_id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.delete_profile(first_profile_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "first profile" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected first profile delete to fail.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.delete_folder(root_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "root folder" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected root folder delete to fail.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_failure_is_isolated(tmp_path: Path):
|
||||||
|
plugin_dir = tmp_path / "plugins"
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
(plugin_dir / "bad.py").write_text("raise RuntimeError('boom')", encoding="utf-8")
|
||||||
|
manager = PluginManager(plugin_dir)
|
||||||
|
db = Database(tmp_path / "streamdeck.sqlite")
|
||||||
|
manager.load_all(PluginContext(DummyApp(db)))
|
||||||
|
plugins = manager.public_plugins()
|
||||||
|
assert plugins[0]["enabled"] is False
|
||||||
|
assert "boom" in plugins[0]["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_request_plugin_posts_templated_json(tmp_path: Path):
|
||||||
|
received = {}
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
received["path"] = self.path
|
||||||
|
received["token"] = self.headers.get("X-Test-Token")
|
||||||
|
received["body"] = self.rfile.read(length).decode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"ok":true}')
|
||||||
|
|
||||||
|
def log_message(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
db = Database(tmp_path / "streamdeck.sqlite")
|
||||||
|
plugin = HTTPRequestsPlugin()
|
||||||
|
plugin.execute_action(
|
||||||
|
PluginContext(DummyApp(db)),
|
||||||
|
"send_request",
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": f"http://127.0.0.1:{server.server_port}/webhook/{{{{event.button}}}}",
|
||||||
|
"headers": [{"key": "X-Test-Token", "value": "button-{{event.button}}"}],
|
||||||
|
"body": '{"button":"{{event.button}}","event":"{{event.event}}"}',
|
||||||
|
"timeout": 3,
|
||||||
|
"log_response": True,
|
||||||
|
},
|
||||||
|
{"button": 4, "event": "down"},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
assert received["path"] == "/webhook/4"
|
||||||
|
assert received["token"] == "button-4"
|
||||||
|
assert json.loads(received["body"]) == {"button": "4", "event": "down"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_clipboard_plugin_copy_renders_event_tokens(tmp_path: Path):
|
||||||
|
db = Database(tmp_path / "streamdeck.sqlite")
|
||||||
|
plugin = ClipboardToolsPlugin()
|
||||||
|
writes = []
|
||||||
|
plugin._set_clipboard_text = writes.append
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
plugin.execute_action(
|
||||||
|
PluginContext(DummyApp(db)),
|
||||||
|
"copy_text",
|
||||||
|
{"text": "Button {{event.button}} went {{event.event}}"},
|
||||||
|
{"button": 7, "event": "up"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert writes == ["Button 7 went up"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clipboard_plugin_paste_restores_previous_value(tmp_path: Path):
|
||||||
|
db = Database(tmp_path / "streamdeck.sqlite")
|
||||||
|
plugin = ClipboardToolsPlugin()
|
||||||
|
writes = []
|
||||||
|
presses = []
|
||||||
|
plugin._get_clipboard_text = lambda: "original clipboard"
|
||||||
|
plugin._set_clipboard_text = writes.append
|
||||||
|
|
||||||
|
app = DummyApp(db)
|
||||||
|
app.actions = type(
|
||||||
|
"Actions",
|
||||||
|
(),
|
||||||
|
{"keys": type("Keys", (), {"press_combo": staticmethod(lambda combo: _record_combo(presses, combo))})()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
plugin.execute_action(
|
||||||
|
PluginContext(app),
|
||||||
|
"paste_snippet",
|
||||||
|
{"text": "Snippet {{event.button}}", "restore_clipboard": True, "restore_delay_ms": 0},
|
||||||
|
{"button": 2},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert writes == ["Snippet 2", "original clipboard"]
|
||||||
|
assert presses == ["ctrl+v"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clipboard_plugin_transform_modes_cover_common_cases():
|
||||||
|
plugin = ClipboardToolsPlugin()
|
||||||
|
|
||||||
|
assert plugin._transform_text("Mixed Value", {"transform": "uppercase"}) == "MIXED VALUE"
|
||||||
|
assert plugin._transform_text(" Mixed Value ", {"transform": "collapse_whitespace"}) == "Mixed Value"
|
||||||
|
assert plugin._transform_text("Mixed Value", {"transform": "snake_case"}) == "mixed_value"
|
||||||
|
assert plugin._transform_text("mixed value", {"transform": "camel_case"}) == "mixedValue"
|
||||||
|
assert plugin._transform_text("hello world", {"transform": "replace", "find": "world", "replace": "deck"}) == "hello deck"
|
||||||
|
assert plugin._transform_text("world", {"transform": "prefix", "extra_text": "hello "}) == "hello world"
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_combo(store, combo):
|
||||||
|
store.append(combo)
|
||||||
Reference in New Issue
Block a user