From 5e10272c69cdb7848c517a9b81784db7997dd417 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 18:47:25 +0000 Subject: [PATCH] feat(01-01): create device registry, show store, FastAPI app, API routes, and frontend shell - DeviceRegistry with load/save/add/remove/instantiate and _DEVICE_CLASSES dict - ShowStore with save/load/list_ids using aiofiles for async I/O - FastAPI app with lifespan hook (startup: load registry/store, shutdown: save registry) - API routes: GET/POST /api/devices/, DELETE /api/devices/{id}, GET/POST /api/shows/, GET /api/shows/{id} - WebSocket stub at /ws with ConnectionManager and ack echo - Frontend shell: DAW layout with header, sidebar (DEVICES + ANIMATIONS), TIMELINE, TRANSPORT - Terminal aesthetic: dark theme, cyan accents, monospace fonts, no drop shadows - .gitignore for runtime data (devices.json, shows/, __pycache__, .venv) --- .gitignore | 19 ++++ lightsync/__main__.py | 12 +++ lightsync/api/__init__.py | 0 lightsync/api/devices.py | 24 +++++ lightsync/api/shows.py | 34 +++++++ lightsync/api/ws.py | 45 +++++++++ lightsync/devices/registry.py | 55 +++++++++++ lightsync/frontend/app.js | 88 ++++++++++++++++++ lightsync/frontend/index.html | 46 +++++++++ lightsync/frontend/style.css | 169 ++++++++++++++++++++++++++++++++++ lightsync/main.py | 43 +++++++++ lightsync/store/__init__.py | 0 lightsync/store/show_store.py | 29 ++++++ 13 files changed, 564 insertions(+) create mode 100644 .gitignore create mode 100644 lightsync/__main__.py create mode 100644 lightsync/api/__init__.py create mode 100644 lightsync/api/devices.py create mode 100644 lightsync/api/shows.py create mode 100644 lightsync/api/ws.py create mode 100644 lightsync/devices/registry.py create mode 100644 lightsync/frontend/app.js create mode 100644 lightsync/frontend/index.html create mode 100644 lightsync/frontend/style.css create mode 100644 lightsync/main.py create mode 100644 lightsync/store/__init__.py create mode 100644 lightsync/store/show_store.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1a410d --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ +*.egg-info/ +dist/ +build/ + +# Runtime data (created by lightsync at startup) +devices.json +shows/ + +# Environment +.env.local + +# Editor +.vscode/ +.idea/ diff --git a/lightsync/__main__.py b/lightsync/__main__.py new file mode 100644 index 0000000..23f2aab --- /dev/null +++ b/lightsync/__main__.py @@ -0,0 +1,12 @@ +import os +import uvicorn +from dotenv import load_dotenv + +if __name__ == "__main__": + load_dotenv() + uvicorn.run( + "lightsync.main:app", + host=os.getenv("HOST", "0.0.0.0"), + port=int(os.getenv("PORT", "8000")), + reload=False, # set True manually during dev only + ) diff --git a/lightsync/api/__init__.py b/lightsync/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lightsync/api/devices.py b/lightsync/api/devices.py new file mode 100644 index 0000000..2130254 --- /dev/null +++ b/lightsync/api/devices.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, HTTPException +from lightsync.models.device import DeviceConfig +import lightsync.main as state # access registry via module-level ref + +router = APIRouter(tags=["devices"]) + + +@router.get("/") +async def list_devices() -> list[DeviceConfig]: + return state.registry.list_all() + + +@router.post("/", status_code=201) +async def add_device(config: DeviceConfig) -> DeviceConfig: + state.registry.add(config) + await state.registry.save() + return config + + +@router.delete("/{device_id}", status_code=204) +async def remove_device(device_id: str): + if not state.registry.remove(device_id): + raise HTTPException(404, "Device not found") + await state.registry.save() diff --git a/lightsync/api/shows.py b/lightsync/api/shows.py new file mode 100644 index 0000000..2f67f32 --- /dev/null +++ b/lightsync/api/shows.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, HTTPException +from lightsync.models.show import ShowModel +import lightsync.main as state # access show_store via module-level ref + +router = APIRouter(tags=["shows"]) + + +@router.get("/") +async def list_shows(): + """Return summary list of all shows.""" + summaries = [] + for show_id in state.show_store.list_ids(): + show = await state.show_store.load(show_id) + if show is not None: + summaries.append({ + "id": str(show.id), + "name": show.name, + "created_at": show.created_at.isoformat(), + }) + return summaries + + +@router.post("/", status_code=201) +async def create_show(show: ShowModel) -> ShowModel: + await state.show_store.save(show) + return show + + +@router.get("/{show_id}") +async def get_show(show_id: str) -> ShowModel: + show = await state.show_store.load(show_id) + if show is None: + raise HTTPException(404, "Show not found") + return show diff --git a/lightsync/api/ws.py b/lightsync/api/ws.py new file mode 100644 index 0000000..9d1cbf8 --- /dev/null +++ b/lightsync/api/ws.py @@ -0,0 +1,45 @@ +import json +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +router = APIRouter() + + +class ConnectionManager: + def __init__(self): + self.active_connections: list[WebSocket] = [] + + async def connect(self, ws: WebSocket) -> None: + await ws.accept() + self.active_connections.append(ws) + + def disconnect(self, ws: WebSocket) -> None: + if ws in self.active_connections: + self.active_connections.remove(ws) + + async def broadcast(self, message: dict) -> None: + dead = [] + for connection in self.active_connections: + try: + await connection.send_text(json.dumps(message)) + except Exception: + dead.append(connection) + for d in dead: + if d in self.active_connections: + self.active_connections.remove(d) + + +manager = ConnectionManager() + + +@router.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + try: + while True: + data = await websocket.receive_text() + msg = json.loads(data) + # Phase 2 will dispatch msg["type"] to handlers + # For now: echo back with type=ack + await websocket.send_text(json.dumps({"type": "ack", "echo": msg})) + except WebSocketDisconnect: + manager.disconnect(websocket) diff --git a/lightsync/devices/registry.py b/lightsync/devices/registry.py new file mode 100644 index 0000000..b1555b2 --- /dev/null +++ b/lightsync/devices/registry.py @@ -0,0 +1,55 @@ +import json +import aiofiles +from pathlib import Path +from lightsync.models.device import DeviceConfig +from lightsync.devices.base import BaseDevice +from lightsync.devices.sk6812 import SK6812Device +from lightsync.devices.ws2801 import WS2801Device + +# Registry of strip_type -> device class (INF-02: add new type here only) +_DEVICE_CLASSES: dict[str, type[BaseDevice]] = { + "sk6812": SK6812Device, + "ws2801": WS2801Device, +} + + +class DeviceRegistry: + def __init__(self, path: Path): + self._path = path + self._devices: dict[str, DeviceConfig] = {} # id (str) -> config + + async def load(self) -> None: + if self._path.exists(): + async with aiofiles.open(self._path) as f: + raw = json.loads(await f.read()) + self._devices = { + d["id"]: DeviceConfig.model_validate(d) + for d in raw.get("devices", []) + } + + async def save(self) -> None: + data = {"devices": [d.model_dump(mode="json") for d in self._devices.values()]} + async with aiofiles.open(self._path, "w") as f: + await f.write(json.dumps(data, indent=2)) + + def add(self, config: DeviceConfig) -> None: + self._devices[str(config.id)] = config + + def remove(self, device_id: str) -> bool: + return self._devices.pop(device_id, None) is not None + + def get(self, device_id: str) -> DeviceConfig | None: + return self._devices.get(device_id) + + def list_all(self) -> list[DeviceConfig]: + return list(self._devices.values()) + + def instantiate(self, device_id: str) -> BaseDevice | None: + """Create a live BaseDevice instance from stored config.""" + config = self.get(device_id) + if config is None: + return None + cls = _DEVICE_CLASSES.get(config.strip_type) + if cls is None: + raise ValueError(f"Unknown strip_type: {config.strip_type}") + return cls(config) diff --git a/lightsync/frontend/app.js b/lightsync/frontend/app.js new file mode 100644 index 0000000..f81ffc5 --- /dev/null +++ b/lightsync/frontend/app.js @@ -0,0 +1,88 @@ +// LightSync frontend — Phase 1 shell +// WebSocket stub + device list loader +// Phase 2 expands audio, transport, and WS protocol + +const WS_URL = `ws://${location.host}/ws`; + +class LightSyncClient { + constructor() { + this.ws = null; + this.reconnectDelay = 2000; + this._reconnectTimer = null; + } + + connect() { + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + this._reconnectTimer = null; + } + + this.ws = new WebSocket(WS_URL); + + this.ws.onopen = () => { + document.getElementById("status-dot").className = "status-dot connected"; + document.getElementById("status-text").textContent = "CONNECTED"; + }; + + this.ws.onclose = () => { + document.getElementById("status-dot").className = "status-dot"; + document.getElementById("status-text").textContent = "OFFLINE"; + this._reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay); + }; + + this.ws.onerror = () => { + document.getElementById("status-dot").className = "status-dot error"; + }; + + this.ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + this.handleMessage(msg); + }; + } + + handleMessage(msg) { + // Phase 2 will dispatch on msg.type + console.debug("[ws]", msg); + } + + send(msg) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } +} + +const client = new LightSyncClient(); +client.connect(); + +// Load device list on startup +async function loadDevices() { + try { + const res = await fetch("/api/devices/"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const devices = await res.json(); + const list = document.getElementById("device-list"); + if (devices.length === 0) { + list.innerHTML = 'no devices registered'; + return; + } + list.innerHTML = devices.map(d => + `
+ ${escapeHtml(d.name)} + ${escapeHtml(d.strip_type)} / ${d.led_count} LEDs / ${escapeHtml(d.ip)}:${d.port} +
` + ).join(""); + } catch (err) { + const list = document.getElementById("device-list"); + list.innerHTML = `error loading devices`; + console.error("[devices]", err); + } +} + +function escapeHtml(str) { + const div = document.createElement("div"); + div.appendChild(document.createTextNode(String(str))); + return div.innerHTML; +} + +loadDevices(); diff --git a/lightsync/frontend/index.html b/lightsync/frontend/index.html new file mode 100644 index 0000000..2720f65 --- /dev/null +++ b/lightsync/frontend/index.html @@ -0,0 +1,46 @@ + + + + + + LIGHTSYNC + + + + + + +
+ +
+ ■ LIGHTSYNC +
+ OFFLINE + — no show loaded — +
+ + + +
+ [ TIMELINE — PHASE 2+ ] +
+ +
+ [ TRANSPORT — PHASE 2+ ] +
+ +
+ + + diff --git a/lightsync/frontend/style.css b/lightsync/frontend/style.css new file mode 100644 index 0000000..250408b --- /dev/null +++ b/lightsync/frontend/style.css @@ -0,0 +1,169 @@ +:root { + --bg-primary: #0a0a0a; + --bg-panel: #0f0f0f; + --bg-panel-dark: #080808; + --border-dim: #1a4a4a; + --border-bright: #00ffff; + --text-primary: #cccccc; + --text-dim: #555555; + --text-accent: #00ffff; + --text-warning: #ff6600; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + --font-size: 13px; + --panel-gap: 1px; +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: var(--font-size); + line-height: 1.4; + height: 100vh; + overflow: hidden; +} + +/* DAW layout — header / (sidebar + main) / transport */ +.app-shell { + display: grid; + grid-template-rows: 40px 1fr 48px; + grid-template-columns: 280px 1fr; + grid-template-areas: + "header header" + "sidebar main" + "transport transport"; + height: 100vh; + gap: var(--panel-gap); +} + +.header { + grid-area: header; + background: var(--bg-panel-dark); + border-bottom: 1px solid var(--border-dim); + display: flex; + align-items: center; + padding: 0 16px; + gap: 16px; +} + +.header-title { + color: var(--text-accent); + font-size: 16px; + font-weight: 600; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +.sidebar { + grid-area: sidebar; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border-dim); + overflow: hidden; + gap: var(--panel-gap); +} + +.panel { + border: 1px solid var(--border-dim); + background: var(--bg-panel); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + background: var(--bg-panel-dark); + border-bottom: 1px solid var(--border-dim); + padding: 6px 12px; + font-size: 11px; + letter-spacing: 0.12em; + color: var(--text-accent); + text-transform: uppercase; + flex-shrink: 0; +} + +.panel-content { + flex: 1; + padding: 8px 12px; + overflow-y: auto; + min-height: 0; +} + +/* Scrollbar — terminal feel */ +::-webkit-scrollbar { width: 4px; } +::-webkit-scrollbar-track { background: var(--bg-primary); } +::-webkit-scrollbar-thumb { background: var(--border-dim); } + +.main-area { + grid-area: main; + background: var(--bg-panel-dark); + border: 1px solid var(--border-dim); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); + font-size: 14px; + letter-spacing: 0.1em; +} + +.transport-bar { + grid-area: transport; + background: var(--bg-panel-dark); + border-top: 1px solid var(--border-dim); + display: flex; + align-items: center; + padding: 0 16px; + color: var(--text-dim); + font-size: 12px; + letter-spacing: 0.08em; +} + +/* Status dot */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-dim); + flex-shrink: 0; +} +.status-dot.connected { background: #00ff88; } +.status-dot.error { background: #ff3333; } + +/* Text helpers */ +.text-dim { color: var(--text-dim); } +.text-accent { color: var(--text-accent); } + +/* Device list rows */ +.device-row { + display: flex; + flex-direction: column; + padding: 6px 0; + border-bottom: 1px solid var(--bg-panel-dark); + gap: 2px; +} +.device-row:last-child { + border-bottom: none; +} + +/* Form elements */ +input, select, button { + background: var(--bg-panel); + border: 1px solid var(--border-dim); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: var(--font-size); + padding: 4px 8px; + outline: none; +} + +button:hover { + border-color: var(--border-bright); + color: var(--text-accent); + cursor: pointer; +} diff --git a/lightsync/main.py b/lightsync/main.py new file mode 100644 index 0000000..50f2a78 --- /dev/null +++ b/lightsync/main.py @@ -0,0 +1,43 @@ +from contextlib import asynccontextmanager +from pathlib import Path +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from lightsync.devices.registry import DeviceRegistry +from lightsync.store.show_store import ShowStore + +# Module-level containers populated in lifespan +registry: DeviceRegistry | None = None +show_store: ShowStore | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global registry, show_store + # Startup + registry = DeviceRegistry(Path("devices.json")) + await registry.load() + show_store = ShowStore(Path("shows")) + show_store.ensure_dir() + yield + # Shutdown + await registry.save() + + +def create_app() -> FastAPI: + app = FastAPI(title="LightSync", lifespan=lifespan) + + # API routes (registered before static mount — see Pattern 7) + from lightsync.api import shows, devices, ws + app.include_router(shows.router, prefix="/api/shows") + app.include_router(devices.router, prefix="/api/devices") + app.include_router(ws.router) + + # Static file serving — MUST be last (catches all unmatched paths) + frontend_dir = Path(__file__).parent / "frontend" + app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend") + + return app + + +app = create_app() diff --git a/lightsync/store/__init__.py b/lightsync/store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lightsync/store/show_store.py b/lightsync/store/show_store.py new file mode 100644 index 0000000..a2039a5 --- /dev/null +++ b/lightsync/store/show_store.py @@ -0,0 +1,29 @@ +import aiofiles +from pathlib import Path +from lightsync.models.show import ShowModel + + +class ShowStore: + def __init__(self, shows_dir: Path): + self._dir = shows_dir + + def ensure_dir(self) -> None: + self._dir.mkdir(parents=True, exist_ok=True) + + def _path_for(self, show_id: str) -> Path: + return self._dir / f"{show_id}.json" + + async def save(self, show: ShowModel) -> None: + path = self._path_for(str(show.id)) + async with aiofiles.open(path, "w") as f: + await f.write(show.model_dump_json(indent=2)) + + async def load(self, show_id: str) -> ShowModel | None: + path = self._path_for(show_id) + if not path.exists(): + return None + async with aiofiles.open(path) as f: + return ShowModel.model_validate_json(await f.read()) + + def list_ids(self) -> list[str]: + return [p.stem for p in self._dir.glob("*.json")]