# 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)