42 KiB
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>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01: Flat package layout —
lightsync/directory at project root (notsrc/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 FastAPIStaticFiles, no separate frontend directory - D-04: Start command:
python -m lightsync— standard module invocation, cross-platform, no install step required - D-05:
pyproject.tomlat 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
LIGHTSYNCtitle + 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 (
#00ffffor close variant) on a dark background — terminal/hacker aesthetic - D-12: Dark background (
#0a0a0aor#111), monospace font (JetBrains Mono, Fira Code, or system monospace fallback) - D-13: Panel borders:
border: 1px solidwith 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.jsonvs 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 </user_constraints>
<phase_requirements>
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 |
| </phase_requirements> |
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)
[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.
# 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()
# 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.
# 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}
# 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 defaultsmodel_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/.
# 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)."""
...
# 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)
# 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
# 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
# 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
# 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.
/* 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: 0everywhere (D-15) —border-radius: 50%only for status dot (functional exception)- Grid layout for the outer shell locks all panel positions
overflow: hiddenon body prevents any scroll; each panel manages its own overflow- Custom properties at
:rootlevel — planner should avoid hardcoding any color values
Pattern 9: Frontend HTML Shell
<!-- lightsync/frontend/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LIGHTSYNC</title>
<link rel="stylesheet" href="/style.css">
<!-- Google Fonts — JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
<div class="app-shell">
<header class="header">
<span class="header-title">⬛ LIGHTSYNC</span>
<div class="status-dot" id="status-dot"></div>
<span id="status-text" class="text-dim">OFFLINE</span>
<span id="show-name" class="text-dim">— no show loaded —</span>
</header>
<aside class="sidebar">
<div class="panel" style="flex: 1">
<div class="panel-header">DEVICES</div>
<div class="panel-content" id="device-list">
<!-- Populated by app.js -->
</div>
</div>
<div class="panel" style="flex: 0 0 120px">
<div class="panel-header">ANIMATIONS</div>
<div class="panel-content text-dim">[ PHASE 3+ ]</div>
</div>
</aside>
<main class="main-area">
<span>[ TIMELINE — PHASE 2+ ]</span>
</main>
<footer class="transport-bar">
<span>[ TRANSPORT — PHASE 2+ ]</span>
</footer>
</div>
<script src="/app.js" type="module"></script>
</body>
</html>
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)
// 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 = '<span class="text-dim">no devices registered</span>';
return;
}
list.innerHTML = devices.map(d =>
`<div class="device-row">
<span class="text-accent">${d.name}</span>
<span class="text-dim">${d.strip_type} / ${d.led_count} LEDs</span>
</div>`
).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
aiofilesfor JSON reads/writes — stdlibopen()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__).parentfor 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 warningspydantic.validator: Replaced by@field_validatorin 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.ProactorEventLoopis the Windows default since Python 3.8 — no configuration needed for UDP and subprocess (Phase 2+)uvicorn.run()usesWindowsSelectorEventLoopPolicyinternally when--reloadis active — don't fight this- File paths: use
pathlib.Paththroughout;show_dir = Path("shows")resolves relative to CWD on Windows correctly
Open Questions
-
Device registry storage: separate
devices.jsonvs 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.jsonis 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.
-
Dependency injection for registry/store in route handlers
- What we know: Module-level globals in
main.pywork 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.
- What we know: Module-level globals in
-
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
[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
# 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 — lifespan context manager pattern, startup/shutdown
- FastAPI Static Files — StaticFiles mounting, html=True for SPA serving
- FastAPI WebSockets — ConnectionManager pattern, broadcast
- Pydantic v2 Models — BaseModel, Field, default_factory
- Pydantic v2 Serialization — model_dump_json, model_validate
- Python ABC docs — abstract base class infrastructure
- uvicorn Settings — ProactorEventLoop, Windows behavior, reload guard
Secondary (MEDIUM confidence)
- WebSearch: uvicorn Windows ProactorEventLoop — confirmed if name == "main" guard required on Windows
- terminal.css — terminal CSS aesthetic reference patterns
- CSS-Tricks: 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)