diff --git a/.planning/phases/01-foundation/01-RESEARCH.md b/.planning/phases/01-foundation/01-RESEARCH.md new file mode 100644 index 0000000..ed3be56 --- /dev/null +++ b/.planning/phases/01-foundation/01-RESEARCH.md @@ -0,0 +1,1057 @@ +# Phase 1: Foundation - Research + +**Researched:** 2026-04-05 +**Domain:** FastAPI + Pydantic v2 + Vanilla JS terminal aesthetic (Python 3.11, Windows 11 primary) +**Confidence:** HIGH (FastAPI/Pydantic patterns), MEDIUM (CSS terminal aesthetic), HIGH (device ABC pattern) + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Flat package layout — `lightsync/` directory at project root (not `src/` layout) +- **D-02:** Package structure: `lightsync/__init__.py`, `lightsync/main.py` (entry point), `lightsync/api/`, `lightsync/devices/`, `lightsync/models/`, `lightsync/frontend/` +- **D-03:** Frontend (HTML/CSS/JS) lives inside `lightsync/frontend/` — served via FastAPI `StaticFiles`, no separate frontend directory +- **D-04:** Start command: `python -m lightsync` — standard module invocation, cross-platform, no install step required +- **D-05:** `pyproject.toml` at root for dependency management +- **D-06:** Full DAW skeleton from Phase 1 — all final panels exist, most as dark labeled placeholders +- **D-07:** Header: ASCII art / stylized `LIGHTSYNC` title + system status indicator (API connected / offline) + current show name +- **D-08:** Left sidebar: DEVICES panel stacked above ANIMATIONS panel (placeholder label "Phase 3+") +- **D-09:** Main center area: large TIMELINE placeholder panel labeled `[ TIMELINE — Phase 2+ ]` +- **D-10:** Bottom bar: TRANSPORT placeholder `[ TRANSPORT — Phase 2+ ]` spanning full width +- **D-11:** Color accent: cyan/teal (`#00ffff` or close variant) on a dark background — terminal/hacker aesthetic +- **D-12:** Dark background (`#0a0a0a` or `#111`), monospace font (JetBrains Mono, Fira Code, or system monospace fallback) +- **D-13:** Panel borders: `border: 1px solid` with dimmed cyan/teal — terminal window panes +- **D-14:** Uppercase panel headers (`DEVICES`, `ANIMATIONS`, `TIMELINE`) — terminal convention +- **D-15:** No drop shadows, gradients, or rounded corners — flat, sharp edges + +### Claude's Discretion + +- Exact Pydantic model field names and nesting for show files and devices (follow ARCHITECTURE.md conventions) +- Device registry storage format (separate `devices.json` vs embedded — choose cleanest) +- REST API route naming conventions +- WebSocket message format stub (just needs to exist, Phase 2 defines real protocol) +- Exact CSS grid/flexbox layout implementation + +### Deferred Ideas (OUT OF SCOPE) + +- Audio transport panel content — Phase 2 +- Animation library panel content — Phase 3 +- Timeline canvas implementation — Phase 4 +- AI sync — Phase 6 + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| INF-01 | Python 3.11 backend (FastAPI + uvicorn) | pyproject.toml pattern, uvicorn.run(), lifespan hook | +| INF-02 | Modular device abstraction — new strip type = new class only | BaseDevice ABC pattern with register dict in registry.py | +| DEV-01 | Register device with name, strip type, LED count, IP, port | Pydantic DeviceModel, POST /devices endpoint | +| DEV-02 | Devices persist across sessions | devices.json load/save in lifespan hook | +| DEV-03 | Device list shown in UI — enable/disable per show | DeviceRegistry GET endpoint + JS panel | +| SHW-01 | Show files saved/loaded as JSON (schema_version required) | Pydantic ShowModel, show store load/save | +| SHW-02 | Show file includes audio path/URL, device snapshot, animation blocks, beat data | Full ShowModel schema from ARCHITECTURE.md | +| UI-01 | Web app served by Python backend — no external hosting | FastAPI StaticFiles mount with html=True | +| UI-02 | Terminal/hacker aesthetic — dark theme, monospace, CRT vibe | CSS custom properties, grid layout, no CSS framework | + + +--- + +## Summary + +Phase 1 establishes the complete project skeleton: Python package, Pydantic data models, FastAPI app with REST stubs, device registry with file persistence, show store, and a full DAW-skeleton frontend with terminal aesthetic. All panels that will exist in the final product are present as labeled placeholders from day one. + +The primary technical challenge is the structural gap between `ARCHITECTURE.md` (which describes a `backend/`+`frontend/` layout) and the locked decisions in CONTEXT.md (which specify a flat `lightsync/` package). The implementation MUST follow CONTEXT.md's locked structure. The architecture patterns from ARCHITECTURE.md apply in terms of component design, just nested differently. + +Windows 11 is the primary runtime. All uvicorn invocations must use `if __name__ == "__main__"` guard and avoid `--reload` in the shipped `__main__.py` (use it only during dev). File paths must use `pathlib.Path` for cross-platform safety even though Windows is primary. + +**Primary recommendation:** Build in this order within the phase: Pydantic models first (everything depends on them), then show store + device registry, then FastAPI app with lifespan, then REST endpoints, then frontend shell, then integration smoke test. + +--- + +## Standard Stack + +### Core (Phase 1 only — no audio, no UDP) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Python | 3.11 | Runtime | 3.11 avoids madmom incompatibility (3.12+), good Windows support, target per INF-01 | +| FastAPI | 0.115.x | Web framework + REST + WebSocket + static file serving | Native async, Pydantic v2 built-in, StaticFiles mounting, single process covers all | +| uvicorn | 0.34.x | ASGI server | Standard FastAPI runner, ProactorEventLoop on Windows by default | +| Pydantic | 2.x (FastAPI dep) | Show file and device model validation + JSON serialization | Already required by FastAPI; v2 is significantly faster than v1 | +| aiofiles | 24.x | Async file I/O for show JSON read/write | Keeps FastAPI async handlers non-blocking during file operations | + +### Supporting (Phase 1) + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| python-dotenv | 1.x | Host/port config via .env file | On startup to allow port override without code changes | +| structlog | 24.x | Structured logging | For backend log messages — easier to read than stdlib logging | + +### Not Needed in Phase 1 +librosa, python-mpv, aiosqlite, numpy — these are Phase 2+ dependencies. Phase 1 persists devices as JSON, no SQLite needed. + +### Installation (pyproject.toml) + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "lightsync" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "pydantic>=2.0.0", + "aiofiles>=24.0.0", + "python-dotenv>=1.0.0", + "structlog>=24.0.0", +] + +[project.optional-dependencies] +dev = [ + "httpx>=0.27.0", # for TestClient in smoke tests +] +``` + +**Note on `uvicorn[standard]`:** The `[standard]` extra installs `watchfiles` for hot reload and `websockets` for WebSocket support. Required because Phase 1 includes a WebSocket stub endpoint. + +**Version check note:** STACK.md pinned FastAPI at 0.135.x but the latest stable at research time is 0.115.x (the 0.115.x series is the current active release line). Use `>=0.115.0` rather than a hard pin so patch updates flow through. + +--- + +## Architecture Patterns + +### Project Structure (locked by CONTEXT.md decisions D-01 through D-05) + +``` +lightsync/ # Python package (D-01, D-02) +├── __init__.py +├── __main__.py # python -m lightsync entry point (D-04) +├── main.py # FastAPI app factory, lifespan, route registration +├── api/ +│ ├── __init__.py +│ ├── shows.py # REST: CRUD shows +│ ├── devices.py # REST: CRUD devices +│ └── ws.py # WebSocket stub endpoint +├── devices/ +│ ├── __init__.py +│ ├── base.py # BaseDevice ABC +│ ├── sk6812.py # SK6812 RGBW device +│ ├── ws2801.py # WS2801 RGB device +│ └── registry.py # DeviceRegistry (CRUD + JSON persistence) +├── models/ +│ ├── __init__.py +│ ├── show.py # ShowModel, TrackModel, CueModel Pydantic models +│ └── device.py # DeviceConfig Pydantic model +├── store/ +│ ├── __init__.py +│ └── show_store.py # Load/save shows from shows/ directory +└── frontend/ # Static files served via StaticFiles (D-03) + ├── index.html + ├── style.css + └── app.js + +pyproject.toml # Dependencies (D-05) +shows/ # Show JSON files (created at runtime) +devices.json # Device registry (created at runtime) +.env # PORT=8000, HOST=127.0.0.1 +``` + +**Note:** ARCHITECTURE.md suggests a `store/` subdirectory and `models/` subdirectory — this maps cleanly onto CONTEXT.md's D-02 structure. The `ws/` directory from ARCHITECTURE.md becomes `api/ws.py` in the flat layout. + +### Pattern 1: FastAPI App with Lifespan Hook + +**What:** The lifespan context manager handles startup (load devices.json, initialize stores) and shutdown (save devices.json). This replaces deprecated `@app.on_event("startup")`. + +**When to use:** All state initialization goes in lifespan — not at module-level globals, and not in route handlers. + +```python +# lightsync/main.py +# Source: https://fastapi.tiangolo.com/advanced/events/ +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from pathlib import Path + +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) + 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() +``` + +```python +# lightsync/__main__.py +import uvicorn +from dotenv import load_dotenv +import os + +if __name__ == "__main__": + load_dotenv() + uvicorn.run( + "lightsync.main:app", + host=os.getenv("HOST", "127.0.0.1"), + port=int(os.getenv("PORT", "8000")), + reload=False, # set True manually during dev only + ) +``` + +**Windows note:** The `if __name__ == "__main__"` guard in `__main__.py` is required on Windows. Without it, the `--reload` subprocess spawning crashes with a multiprocessing error. Since `python -m lightsync` invokes `__main__.py` as `__main__`, this guard works correctly. + +### Pattern 2: Pydantic v2 Show and Device Models + +**What:** All JSON data structures use Pydantic BaseModel. Show files serialize to JSON via `model_dump_json()`. Devices use a flat model matching the JSON storage format. + +```python +# lightsync/models/device.py +# Source: https://docs.pydantic.dev/latest/concepts/models/ +from uuid import UUID, uuid4 +from typing import Literal +from pydantic import BaseModel, Field + +StripType = Literal["sk6812", "ws2801", "generic"] + +class DeviceConfig(BaseModel): + id: UUID = Field(default_factory=uuid4) + name: str + strip_type: StripType + led_count: int = Field(gt=0, le=1000) + ip: str + port: int = Field(ge=1, le=65535) + enabled: bool = True + + model_config = {"populate_by_name": True} +``` + +```python +# lightsync/models/show.py +# Source: https://docs.pydantic.dev/latest/concepts/models/ +from uuid import UUID, uuid4 +from datetime import datetime, timezone +from typing import Any, Literal +from pydantic import BaseModel, Field + +class AudioRef(BaseModel): + source_type: Literal["file", "youtube"] = "file" + path: str | None = None + yt_url: str | None = None + duration_seconds: float | None = None + +class CueModel(BaseModel): + id: UUID = Field(default_factory=uuid4) + timestamp: float # seconds from start + mode: Literal["animation", "frame_sequence"] = "animation" + animation: str | None = None + params: dict[str, Any] = Field(default_factory=dict) + +class TrackModel(BaseModel): + device_id: UUID + cues: list[CueModel] = Field(default_factory=list) + +class AnalysisBlock(BaseModel): + analysed_at: datetime | None = None + tempo_bpm: float | None = None + beat_times: list[float] = Field(default_factory=list) + onset_times: list[float] = Field(default_factory=list) + +class ShowModel(BaseModel): + schema_version: int = 1 # SHW-01: required field + id: UUID = Field(default_factory=uuid4) + name: str + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + audio: AudioRef = Field(default_factory=AudioRef) + devices: list[DeviceConfig] = Field(default_factory=list) # SHW-02: device snapshot + analysis: AnalysisBlock = Field(default_factory=AnalysisBlock) + tracks: list[TrackModel] = Field(default_factory=list) # SHW-02: animation blocks + ai_sequences: list[Any] = Field(default_factory=list) +``` + +**Key Pydantic v2 notes:** +- Use `model_dump_json()` for JSON serialization (replaces v1 `.json()`) +- Use `model_dump()` for dict output +- Use `Model.model_validate(data)` for deserialization (replaces v1 `.parse_obj()`) +- `Field(default_factory=uuid4)` is the correct v2 idiom for UUID defaults +- `model_config = {"populate_by_name": True}` allows both alias and field name on input + +### Pattern 3: BaseDevice ABC for INF-02 + +**What:** All device types inherit from `BaseDevice`. The Show Engine and UDP Sender only call methods on `BaseDevice`. Adding SK9822 in Phase 5 requires zero changes outside `devices/`. + +```python +# lightsync/devices/base.py +# Source: https://docs.python.org/3/library/abc.html +from abc import ABC, abstractmethod +from lightsync.models.device import DeviceConfig + +class BaseDevice(ABC): + def __init__(self, config: DeviceConfig): + self.config = config + + @property + def id(self): + return self.config.id + + @property + def led_count(self) -> int: + return self.config.led_count + + @abstractmethod + def encode_frame(self, pixels: list[tuple]) -> bytes: + """Encode a full pixel frame to UDP payload bytes. + pixels: list of (R, G, B) or (R, G, B, W) tuples. + """ + ... + + @abstractmethod + def encode_animation_cmd(self, animation: str, params: dict) -> bytes: + """Encode an animation+params command packet.""" + ... + + @property + @abstractmethod + def bytes_per_pixel(self) -> int: + """Number of bytes per LED in frame mode (3 for RGB, 4 for RGBW).""" + ... +``` + +```python +# lightsync/devices/registry.py +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 -> 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) +``` + +**INF-02 enforcement:** The `_DEVICE_CLASSES` dict is the single place a new strip type is registered. The engine never checks `strip_type` directly. Adding `"ws2812b"` requires only: (1) create `lightsync/devices/ws2812b.py` implementing `BaseDevice`, (2) add one line to `_DEVICE_CLASSES`. + +### Pattern 4: Show Store (Load/Save) + +```python +# lightsync/store/show_store.py +import json +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")] +``` + +**Note on `model_dump_json()`:** Pydantic v2's native JSON serialization handles UUID, datetime, and nested models automatically — no custom JSON encoder needed. + +### Pattern 5: REST Route Stubs + +```python +# lightsync/api/devices.py +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() +``` + +**Dependency injection note:** For Phase 1, passing registry via module-level state in `main.py` is acceptable (single process, no tests). Phase 2 should migrate to FastAPI's `Depends()` pattern. + +### Pattern 6: WebSocket Stub + +```python +# lightsync/api/ws.py +# Source: https://fastapi.tiangolo.com/advanced/websockets/ +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: + 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: + 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) +``` + +**Dead connection handling:** The broadcast method catches send failures and removes dead connections. Without this, a stale connection blocks the entire broadcast. + +### Pattern 7: StaticFiles Mount — Critical Ordering + +```python +# API routes MUST be registered before the StaticFiles mount +# StaticFiles at "/" is a catch-all — it intercepts any path not matched above +app.include_router(shows.router, prefix="/api/shows") +app.include_router(devices.router, prefix="/api/devices") +app.include_router(ws.router) +# StaticFiles LAST +app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend") +``` + +If `StaticFiles` is mounted before API routes, all `/api/*` requests return 404 from the static file handler. + +### Pattern 8: Terminal Aesthetic CSS + +**What:** Pure CSS with custom properties, CSS Grid for the DAW layout, no framework. + +```css +/* lightsync/frontend/style.css */ +/* Source: CSS-Tricks terminal aesthetic, terminal.css patterns */ + +:root { + --bg-primary: #0a0a0a; + --bg-panel: #0f0f0f; + --bg-panel-dark: #080808; + --border-dim: #1a4a4a; /* dimmed cyan for panel borders */ + --border-bright: #00ffff; /* full cyan for active/focus */ + --text-primary: #cccccc; + --text-dim: #555555; + --text-accent: #00ffff; /* cyan accent */ + --text-warning: #ff6600; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + --font-size: 13px; + --panel-gap: 1px; /* tight packing, terminal feel */ +} + +*, *::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: no scroll, everything fits viewport */ +} + +/* DAW layout — matches D-06 through D-10 panel arrangement */ +.app-shell { + display: grid; + grid-template-rows: 40px 1fr 48px; /* header, main, transport */ + grid-template-columns: 280px 1fr; /* sidebar, timeline */ + 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: 24px; +} + +.header-title { + color: var(--text-accent); + font-size: 16px; + 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; +} + +.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; /* D-14 */ + flex-shrink: 0; +} + +.panel-content { + flex: 1; + padding: 8px 12px; + overflow-y: auto; +} + +/* Scrollbar styling — 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; +} + +/* Form elements — terminal style */ +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; +} + +/* Status indicator */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; /* small exception — status dots are conventionally round */ + background: var(--text-dim); +} +.status-dot.connected { background: #00ff88; } +.status-dot.error { background: #ff3333; } +``` + +**Key CSS decisions:** +- `border-radius: 0` everywhere (D-15) — `border-radius: 50%` only for status dot (functional exception) +- Grid layout for the outer shell locks all panel positions +- `overflow: hidden` on body prevents any scroll; each panel manages its own overflow +- Custom properties at `:root` level — planner should avoid hardcoding any color values + +### Pattern 9: Frontend HTML Shell + +```html + + + + + + + LIGHTSYNC + + + + + + +
+ +
+ ⬛ LIGHTSYNC +
+ OFFLINE + — no show loaded — +
+ + + +
+ [ TIMELINE — PHASE 2+ ] +
+ + + +
+ + + +``` + +**Font fallback chain:** If the Google Fonts request fails (offline dev), the CSS variable `--font-mono` falls back to `'Fira Code'`, then `'Consolas'` (Windows default monospace), then `monospace`. + +### Pattern 10: WebSocket Client Stub (JS) + +```javascript +// lightsync/frontend/app.js +// Minimal WebSocket stub — Phase 2 expands protocol handling + +const WS_URL = `ws://${location.host}/ws`; + +class LightSyncClient { + constructor() { + this.ws = null; + this.reconnectDelay = 2000; + } + + connect() { + 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"; + setTimeout(() => this.connect(), this.reconnectDelay); + }; + + 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() { + const res = await fetch("/api/devices/"); + 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 => + `
+ ${d.name} + ${d.strip_type} / ${d.led_count} LEDs +
` + ).join(""); +} + +loadDevices(); +``` + +### Anti-Patterns to Avoid + +- **StaticFiles before API routes:** Mount static files last or `/api/*` paths return 404 +- **Blocking file I/O in async handlers:** Always use `aiofiles` for JSON reads/writes — stdlib `open()` blocks the event loop +- **Module-level device instantiation:** Don't call `DeviceRegistry.load()` at import time — put it in lifespan +- **Pydantic v1 `.json()` and `.parse_obj()`:** These are removed in v2; use `.model_dump_json()` and `.model_validate()` +- **`@app.on_event("startup")`:** Deprecated; use the lifespan context manager +- **Hardcoded paths:** Use `Path(__file__).parent` for paths relative to the package, not hardcoded strings like `"lightsync/frontend"` + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| JSON validation on input | Manual type checks in route handlers | Pydantic BaseModel as route body type | FastAPI auto-validates, auto-returns 422 on bad input | +| File serving | Custom route that reads files | `StaticFiles(html=True)` | Handles 404, directory index, cache headers, MIME types | +| UUID generation | `str(random.randint(...))` | `uuid4()` as Pydantic `default_factory` | Collision-proof, standard format | +| Async file write | Threads + stdlib `open()` | `aiofiles.open()` | Non-blocking; stdlib blocks the event loop | +| WebSocket connection tracking | Raw dict with manual cleanup | `ConnectionManager` pattern (see above) | Handles disconnects, dead connection removal | +| CSS terminal font stack | Single hardcoded font | CSS custom property `--font-mono` with fallbacks | Survives font load failure on offline dev | + +**Key insight:** FastAPI + Pydantic v2 handles about 60% of what you'd otherwise write by hand (validation, serialization, 422 errors, OpenAPI docs). Don't add logic that duplicates what the framework already does. + +--- + +## Common Pitfalls + +### Pitfall 1: StaticFiles Intercepts API Routes +**What goes wrong:** `/api/devices/` returns 404 from the static file handler instead of the router. +**Why it happens:** `app.mount()` is processed in definition order. If `StaticFiles` at `"/"` is mounted before `include_router()`, it matches everything. +**How to avoid:** Always mount StaticFiles after all `include_router()` calls. +**Warning signs:** All API routes return 404 with `Content-Type: text/html` (the 404.html from StaticFiles). + +### Pitfall 2: Pydantic v2 Breaking Changes from v1 +**What goes wrong:** Code works during local dev but breaks on a clean install because of v1/v2 API differences. +**Why it happens:** STACK.md and ARCHITECTURE.md use some v1 patterns (`.json()`, `.parse_obj()`). +**How to avoid:** Use only v2 APIs: `model_dump()`, `model_dump_json()`, `model_validate()`, `model_validate_json()`. Never call `.dict()` or `.json()`. +**Warning signs:** `AttributeError: 'ShowModel' object has no attribute 'json'` + +### Pitfall 3: uvicorn Hot Reload on Windows +**What goes wrong:** `uvicorn.run(..., reload=True)` crashes with a multiprocessing error on Windows when called without `if __name__ == "__main__"` guard. +**Why it happens:** Windows uses `spawn` (not `fork`) for multiprocessing. Without the guard, the worker process re-executes the module and tries to start another server. +**How to avoid:** Always use `if __name__ == "__main__"` in `__main__.py`. Do NOT enable reload by default; document it as a dev-only flag. +**Warning signs:** `RuntimeError: This event loop is already running` or `AssertionError` on Windows startup. + +### Pitfall 4: Path Separator Bugs on Windows +**What goes wrong:** Show file paths stored as `/shows/my_show.json` fail on Windows because Windows uses backslash. +**Why it happens:** Hardcoded forward slashes in path strings. +**How to avoid:** Use `pathlib.Path` everywhere. `Path("shows") / show_id` handles separators automatically. Never concatenate path strings. +**Warning signs:** `FileNotFoundError` with forward-slash paths on Windows. + +### Pitfall 5: DeviceConfig id as UUID vs str +**What goes wrong:** `device_id` parameter in URL routes is typed as `str` but the registry stores keys as `str(UUID)`. Mismatch causes lookups to silently fail. +**Why it happens:** UUID `__str__` uses lowercase hex with dashes; manual construction may differ. +**How to avoid:** Store registry keys as `str(config.id)` explicitly. In route handlers, accept `device_id: str` and pass directly to `registry.get(device_id)`. +**Warning signs:** DELETE /devices/{id} returns 404 for a valid device. + +### Pitfall 6: Frontend Google Fonts on Offline Dev +**What goes wrong:** The UI uses a raw system monospace font instead of JetBrains Mono during offline development. +**Why it happens:** Google Fonts CDN is unavailable without network. +**How to avoid:** This is acceptable behavior — CSS fallback chain handles it. Alternatively, bundle the font file in `lightsync/frontend/fonts/`. Document the expected behavior. +**Warning signs:** UI renders in Courier New instead of JetBrains Mono — cosmetic only, not a bug. + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `@app.on_event("startup")` | `lifespan=` context manager | FastAPI 0.93 (2023) | Old events deprecated; lifespan is now the only supported pattern | +| `model.json()` (Pydantic v1) | `model.model_dump_json()` | Pydantic v2 (2023) | v1 methods raise AttributeError in v2 | +| `Model.parse_obj(data)` (v1) | `Model.model_validate(data)` | Pydantic v2 (2023) | Same — v1 API removed | +| `uvicorn[all]` extra | `uvicorn[standard]` extra | ~2023 | `[all]` was removed; `[standard]` is the correct WebSocket + watchfiles bundle | + +**Deprecated/outdated:** +- `@app.on_event`: Deprecated since FastAPI 0.93; still works but emits deprecation warnings +- `pydantic.validator`: Replaced by `@field_validator` in v2; old decorator removed + +--- + +## Environment Availability + +> Step 2.6: The project runs on Windows 11 (primary runtime). Research machine is Linux. Environment audit is for the Windows 11 target. + +| Dependency | Required By | Available (Windows 11) | Notes | Fallback | +|------------|------------|------------------------|-------|----------| +| Python 3.11 | INF-01 | Must verify | Standard install from python.org or winget | — | +| uv | Dependency mgmt | Recommended | `winget install astral-sh.uv` or `pip install uv` | pip directly | +| FastAPI + uvicorn | INF-01 | Install via uv | No OS-level dependencies | — | +| Pydantic v2 | Models | FastAPI dependency | Installed automatically | — | +| aiofiles | Show store | Install via uv | Pure Python, no OS deps | — | +| JetBrains Mono font | UI-02 | Served via Google Fonts CDN | Requires internet; fallback to system monospace | Consolas (Windows built-in) | +| Browser | UI-01 | Chrome/Edge/Firefox on Win11 | Any modern browser works | — | + +**Missing dependencies with no fallback:** +- Python 3.11 must be present on the Windows 11 machine before any installation step + +**Missing dependencies with fallback:** +- Google Fonts CDN: fallback to Consolas (Windows built-in monospace) +- uv: fallback to pip + +**Windows-specific notes:** +- `asyncio.ProactorEventLoop` is the Windows default since Python 3.8 — no configuration needed for UDP and subprocess (Phase 2+) +- `uvicorn.run()` uses `WindowsSelectorEventLoopPolicy` internally when `--reload` is active — don't fight this +- File paths: use `pathlib.Path` throughout; `show_dir = Path("shows")` resolves relative to CWD on Windows correctly + +--- + +## Open Questions + +1. **Device registry storage: separate `devices.json` vs embedded in each show** + - What we know: ARCHITECTURE.md says "Config in show JSON or separate devices.json" + - What's unclear: The show file embeds a device snapshot (for portability), but the registry for the UI list needs a separate source of truth + - Recommendation (Claude's discretion): Use **both** — `devices.json` is the live registry (what the UI CRUD panel manages), and each show file embeds a snapshot of the devices at the time of show creation. This is consistent with ARCHITECTURE.md's "self-contained show file" principle. + +2. **Dependency injection for registry/store in route handlers** + - What we know: Module-level globals in `main.py` work for Phase 1 (single process) + - What's unclear: FastAPI's `Depends()` is cleaner but adds boilerplate for a solo project + - Recommendation: Use module-level state for Phase 1. Add a TODO comment to migrate to `Depends()` when tests are added in Phase 3. + +3. **Show name vs show ID in URL routes** + - What we know: Show IDs are UUIDs (hard to type in browser), names are user-defined + - Recommendation: `/api/shows/` returns list with both; `/api/shows/{show_id}` uses UUID. Frontend always uses the UUID from the API response. + +--- + +## Code Examples + +### Full pyproject.toml + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "lightsync" +version = "0.1.0" +description = "Music-to-light synchronization system" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "aiofiles>=24.0.0", + "python-dotenv>=1.0.0", + "structlog>=24.0.0", +] + +[project.optional-dependencies] +dev = [ + "httpx>=0.27.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["lightsync"] +``` + +**Note:** Pydantic is not listed separately — it is pulled in as a FastAPI dependency. If a specific version pin is needed: add `"pydantic>=2.0.0"` to dependencies. + +### SK6812 Device Implementation Example + +```python +# lightsync/devices/sk6812.py +from lightsync.devices.base import BaseDevice + +class SK6812Device(BaseDevice): + """RGBW strip — 4 bytes per LED.""" + + @property + def bytes_per_pixel(self) -> int: + return 4 + + def encode_frame(self, pixels: list[tuple]) -> bytes: + # WLED DRGBW protocol: header [3, timeout_seconds] + payload = bytearray([3, 2]) + for pixel in pixels: + r, g, b = pixel[:3] + w = pixel[3] if len(pixel) > 3 else 0 + payload.extend([r, g, b, w]) + return bytes(payload) + + def encode_animation_cmd(self, animation: str, params: dict) -> bytes: + import json + cmd = {"type": "animation", "anim": animation, "params": params} + return json.dumps(cmd).encode("utf-8") +``` + +--- + +## Sources + +### Primary (HIGH confidence) +- [FastAPI Lifespan Events](https://fastapi.tiangolo.com/advanced/events/) — lifespan context manager pattern, startup/shutdown +- [FastAPI Static Files](https://fastapi.tiangolo.com/tutorial/static-files/) — StaticFiles mounting, html=True for SPA serving +- [FastAPI WebSockets](https://fastapi.tiangolo.com/advanced/websockets/) — ConnectionManager pattern, broadcast +- [Pydantic v2 Models](https://docs.pydantic.dev/latest/concepts/models/) — BaseModel, Field, default_factory +- [Pydantic v2 Serialization](https://docs.pydantic.dev/latest/concepts/serialization/) — model_dump_json, model_validate +- [Python ABC docs](https://docs.python.org/3/library/abc.html) — abstract base class infrastructure +- [uvicorn Settings](https://uvicorn.dev/settings/) — ProactorEventLoop, Windows behavior, reload guard + +### Secondary (MEDIUM confidence) +- [WebSearch: uvicorn Windows ProactorEventLoop](https://github.com/fastapi/fastapi/discussions/13549) — confirmed if __name__ == "__main__" guard required on Windows +- [terminal.css](https://terminalcss.xyz/) — terminal CSS aesthetic reference patterns +- [CSS-Tricks: Old Timey Terminal Styling](https://css-tricks.com/old-timey-terminal-styling/) — terminal CSS techniques + +### Tertiary (LOW confidence — from STACK.md/ARCHITECTURE.md, accepted as project-internal decisions) +- `.planning/research/STACK.md` — version recommendations, technology decisions +- `.planning/research/ARCHITECTURE.md` — show file JSON schema, component boundaries, data flow + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — FastAPI/Pydantic v2/uvicorn are stable, well-documented, official docs verified +- Architecture: HIGH — patterns from official FastAPI docs; device ABC is standard Python +- CSS terminal aesthetic: MEDIUM — no official spec; based on CSS custom properties patterns + terminal.css reference +- Windows pitfalls: HIGH — uvicorn/Windows reload issue is documented in FastAPI discussions +- Pydantic v2 API: HIGH — official docs consulted, v1→v2 migration changes verified + +**Research date:** 2026-04-05 +**Valid until:** 2026-10-05 (stable libraries; Pydantic v2 and FastAPI 0.115.x are not fast-moving)