diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8f49cd0..b185545 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -32,7 +32,7 @@ Decimal phases appear between their surrounding integers in numeric order. 3. The web app loads in a browser with terminal/hacker aesthetic — dark background, monospace fonts, no generic SaaS look 4. FastAPI serves the frontend and exposes REST stubs for shows and devices 5. Adding a new strip type requires only a new device class — no changes to core engine code -**Plans**: TBD +**Plans**: 3 plans Plans: - [ ] 01-01: Backend scaffold — FastAPI app, Pydantic show/device models, show store (JSON load/save), device registry @@ -50,7 +50,7 @@ Plans: 3. The waveform is rendered in the timeline UI from the loaded audio file 4. Windows 11 timer resolution is set to 1ms at startup (timeBeginPeriod(1)) — confirmed via timing harness showing <2ms tick variance 5. MPV IPC runs in a dedicated thread — pausing or seeking does not block WebSocket message processing -**Plans**: TBD +**Plans**: 3 plans Plans: - [ ] 02-01: MPV IPC bridge — python-mpv subprocess, named pipe reader thread, shared position variable, Windows timer fix @@ -68,7 +68,7 @@ Plans: 3. The animation library contains all 7 built-in types (chase, pulse, rainbow, strobe, color wipe, fire, solid color) with configurable params 4. SK6812 (RGBW, 4-byte) and WS2801 (RGB, 3-byte) frames are encoded distinctly — a simulator can distinguish them by packet header 5. The software simulator logs all received packets — no hardware required to validate protocol behavior -**Plans**: TBD +**Plans**: 3 plans Plans: - [ ] 03-01: Device abstraction + UDP sender — BaseDevice ABC, SK6812 and WS2801 implementations, async UDP socket pool @@ -86,7 +86,7 @@ Plans: 4. Each block has a color picker and editable parameters (speed, colors, direction, length); changes persist in the show file 5. The playback cursor moves in real time across the timeline as audio plays, reflecting audio position accurately 6. A beat calibration offset control shifts all beat marks backward by a configurable amount to compensate for librosa's systematic latency bias -**Plans**: TBD +**Plans**: 3 plans Plans: - [ ] 04-01: Timeline canvas — per-device tracks, time axis, block rendering, playback cursor @@ -105,7 +105,7 @@ Plans: 3. The live preview panel shows a simulated LED strip visualization in the browser canvas, updated in sync with playback 4. All primary operations (play, pause, seek, undo, save) are accessible via keyboard shortcuts without touching the mouse 5. A show saved during one session loads completely — all blocks, devices, and analysis data — in a subsequent session -**Plans**: TBD +**Plans**: 3 plans Plans: - [ ] 05-01: Cue scheduler — position-driven scheduling loop, pre-fire offset, seek-safe cue pointer reset @@ -121,7 +121,7 @@ Plans: 1. A YouTube URL can be loaded into the app — audio plays via MPV with no manual download step required 2. Triggering AI sync fills the timeline with animation blocks matched to beats, drops, and song sections — producing a playable starting point for editing 3. Structural segmentation (verse/chorus/bridge via librosa) appears as colored region overlays on the waveform -**Plans**: TBD +**Plans**: 3 plans Plans: - [ ] 06-01: YouTube loading — yt-dlp passthrough via MPV --ytdl flag, URL input field, loading state feedback @@ -137,7 +137,7 @@ Plans: 2. A microcontroller receives DRGB/DRGBW raw frame packets and outputs correct RGB/RGBW pixel values to the strip 3. SK6812 (RGBW) and WS2801 (RGB) are both verified on physical hardware against the protocol spec from Phase 3 4. Firmware runs on at least one of: ESP32, Pico, or Raspberry Pi 4B -**Plans**: TBD +**Plans**: 3 plans Plans: - [ ] 07-01: Firmware scaffold — MicroPython UDP socket listener, packet type discriminator, device config (LED count, strip type) diff --git a/.planning/phases/01-foundation/01-01-PLAN.md b/.planning/phases/01-foundation/01-01-PLAN.md new file mode 100644 index 0000000..8b071bc --- /dev/null +++ b/.planning/phases/01-foundation/01-01-PLAN.md @@ -0,0 +1,279 @@ +--- +phase: 01-foundation +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - pyproject.toml + - lightsync/__init__.py + - lightsync/__main__.py + - lightsync/main.py + - lightsync/models/__init__.py + - lightsync/models/device.py + - lightsync/models/show.py + - lightsync/devices/__init__.py + - lightsync/devices/base.py + - lightsync/devices/sk6812.py + - lightsync/devices/ws2801.py + - lightsync/devices/registry.py + - lightsync/store/__init__.py + - lightsync/store/show_store.py + - lightsync/api/__init__.py + - lightsync/api/devices.py + - lightsync/api/shows.py + - lightsync/api/ws.py + - .env +autonomous: true +requirements: + - INF-01 + - INF-02 + - DEV-01 + - DEV-02 + - SHW-01 + - SHW-02 + +must_haves: + truths: + - "A DeviceConfig can be created with name, strip_type, led_count, ip, port and serialized to JSON" + - "A ShowModel can be created with schema_version=1, audio ref, device snapshot, empty tracks and serialized to JSON" + - "Adding a new strip type requires only a new device class file and one entry in _DEVICE_CLASSES dict" + - "DeviceRegistry loads from and saves to devices.json — data survives process restart" + - "ShowStore saves a ShowModel as JSON file and loads it back identically" + - "python -m lightsync starts the FastAPI server without error" + artifacts: + - path: "lightsync/models/device.py" + provides: "DeviceConfig Pydantic model" + contains: "class DeviceConfig(BaseModel)" + - path: "lightsync/models/show.py" + provides: "ShowModel with schema_version, AudioRef, TrackModel, CueModel, AnalysisBlock" + contains: "schema_version: int = 1" + - path: "lightsync/devices/base.py" + provides: "BaseDevice ABC with encode_frame, encode_animation_cmd, bytes_per_pixel" + contains: "class BaseDevice(ABC)" + - path: "lightsync/devices/registry.py" + provides: "DeviceRegistry with load/save/add/remove/list_all" + contains: "_DEVICE_CLASSES" + - path: "lightsync/store/show_store.py" + provides: "ShowStore with save/load/list_ids" + contains: "model_dump_json" + key_links: + - from: "lightsync/devices/registry.py" + to: "lightsync/devices/base.py" + via: "_DEVICE_CLASSES maps strip_type to BaseDevice subclass" + pattern: "_DEVICE_CLASSES.*BaseDevice" + - from: "lightsync/devices/registry.py" + to: "lightsync/models/device.py" + via: "DeviceConfig used for persistence" + pattern: "DeviceConfig.model_validate" + - from: "lightsync/store/show_store.py" + to: "lightsync/models/show.py" + via: "ShowModel serialization" + pattern: "ShowModel.model_validate_json" +--- + + +Build the complete Python backend scaffold: package structure, Pydantic data models, device abstraction (BaseDevice ABC + SK6812/WS2801), device registry with JSON persistence, show store with JSON save/load, FastAPI app with lifespan, and WebSocket stub. + +Purpose: Establishes every backend module, data model, and persistence mechanism that all future phases depend on. The models defined here are the contracts for the entire system. +Output: A runnable `python -m lightsync` that starts FastAPI with route stubs, device registry, and show store. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-foundation/01-CONTEXT.md +@.planning/phases/01-foundation/01-RESEARCH.md + + + + + + Task 1: Create package structure, Pydantic models, and pyproject.toml + + pyproject.toml + .env + lightsync/__init__.py + lightsync/models/__init__.py + lightsync/models/device.py + lightsync/models/show.py + lightsync/devices/__init__.py + lightsync/devices/base.py + lightsync/devices/sk6812.py + lightsync/devices/ws2801.py + + + .planning/phases/01-foundation/01-RESEARCH.md + .planning/phases/01-foundation/01-CONTEXT.md + + + Create pyproject.toml (per D-05) with: + - [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"] + + Create .env with HOST=0.0.0.0 and PORT=8000. + + Create lightsync/__init__.py with `__version__ = "0.1.0"`. + + Create lightsync/models/__init__.py that re-exports DeviceConfig, ShowModel. + + Create lightsync/models/device.py (per Research Pattern 2): + - 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} + + Create lightsync/models/show.py (per Research Pattern 2): + - 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, 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, 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), analysis: AnalysisBlock = Field(default_factory=AnalysisBlock), tracks: list[TrackModel] = Field(default_factory=list), ai_sequences: list[Any] = Field(default_factory=list) + + Create lightsync/devices/__init__.py (empty). + + Create lightsync/devices/base.py (per Research Pattern 3): + - class BaseDevice(ABC) with __init__(self, config: DeviceConfig), properties id and led_count + - @abstractmethod encode_frame(self, pixels: list[tuple]) -> bytes + - @abstractmethod encode_animation_cmd(self, animation: str, params: dict) -> bytes + - @property @abstractmethod bytes_per_pixel(self) -> int + + Create lightsync/devices/sk6812.py: + - class SK6812Device(BaseDevice) with bytes_per_pixel = 4 + - encode_frame: iterate pixels, for each (r, g, b, w) append 4 bytes to bytearray; return bytes + - encode_animation_cmd: stub returning b"" (Phase 3 implements real encoding) + + Create lightsync/devices/ws2801.py: + - class WS2801Device(BaseDevice) with bytes_per_pixel = 3 + - encode_frame: iterate pixels, for each (r, g, b) append 3 bytes to bytearray; return bytes + - encode_animation_cmd: stub returning b"" (Phase 3 implements real encoding) + + + cd /home/claude/led2 && python -c "from lightsync.models.device import DeviceConfig; d = DeviceConfig(name='test', strip_type='sk6812', led_count=60, ip='192.168.1.10', port=21324); print(d.model_dump_json())" && python -c "from lightsync.models.show import ShowModel; s = ShowModel(name='test'); print(s.schema_version); assert s.schema_version == 1; print(s.model_dump_json()[:100])" && python -c "from lightsync.devices.sk6812 import SK6812Device; from lightsync.models.device import DeviceConfig; d = SK6812Device(DeviceConfig(name='t', strip_type='sk6812', led_count=1, ip='0', port=1)); assert d.bytes_per_pixel == 4; print('OK')" + + + - pyproject.toml contains `name = "lightsync"` and `fastapi>=0.115.0` and `aiofiles>=24.0.0` + - lightsync/models/device.py contains `class DeviceConfig(BaseModel)` and `StripType = Literal["sk6812", "ws2801", "generic"]` + - lightsync/models/show.py contains `schema_version: int = 1` and `class ShowModel(BaseModel)` and `class AudioRef(BaseModel)` and `class TrackModel(BaseModel)` and `class CueModel(BaseModel)` and `class AnalysisBlock(BaseModel)` + - lightsync/devices/base.py contains `class BaseDevice(ABC)` and `def encode_frame` and `def encode_animation_cmd` and `def bytes_per_pixel` + - lightsync/devices/sk6812.py contains `class SK6812Device(BaseDevice)` and `bytes_per_pixel` returning 4 + - lightsync/devices/ws2801.py contains `class WS2801Device(BaseDevice)` and `bytes_per_pixel` returning 3 + - `python -c "from lightsync.models.show import ShowModel; s = ShowModel(name='x'); assert s.schema_version == 1"` exits 0 + + All Pydantic models serialize/deserialize correctly. BaseDevice ABC exists with two concrete implementations. pyproject.toml lists all Phase 1 dependencies. + + + + Task 2: Create device registry, show store, FastAPI app with lifespan, API route stubs, and WebSocket stub + + lightsync/devices/registry.py + lightsync/store/__init__.py + lightsync/store/show_store.py + lightsync/api/__init__.py + lightsync/api/devices.py + lightsync/api/shows.py + lightsync/api/ws.py + lightsync/main.py + lightsync/__main__.py + + + .planning/phases/01-foundation/01-RESEARCH.md + lightsync/models/device.py + lightsync/models/show.py + lightsync/devices/base.py + + + Create lightsync/devices/registry.py (per Research Pattern 3): + - _DEVICE_CLASSES: dict[str, type[BaseDevice]] = {"sk6812": SK6812Device, "ws2801": WS2801Device} + - class DeviceRegistry with __init__(self, path: Path), _devices: dict[str, DeviceConfig] + - async load(): read JSON from self._path, parse each entry via DeviceConfig.model_validate + - async save(): serialize all devices via model_dump(mode="json"), write JSON with indent=2 + - add(config: DeviceConfig), remove(device_id: str) -> bool, get(device_id: str) -> DeviceConfig | None, list_all() -> list[DeviceConfig] + - instantiate(device_id: str) -> BaseDevice | None: lookup config, lookup class in _DEVICE_CLASSES, return instance + + Create lightsync/store/__init__.py (empty). + + Create lightsync/store/show_store.py (per Research Pattern 4): + - class ShowStore with __init__(self, shows_dir: Path) + - ensure_dir(): self._dir.mkdir(parents=True, exist_ok=True) + - _path_for(show_id: str) -> Path: return self._dir / f"{show_id}.json" + - async save(show: ShowModel): write show.model_dump_json(indent=2) to _path_for(str(show.id)) + - async load(show_id: str) -> ShowModel | None: read file, return ShowModel.model_validate_json(content) + - list_ids() -> list[str]: return [p.stem for p in self._dir.glob("*.json")] + + Create lightsync/api/__init__.py (empty). + + Create lightsync/api/devices.py (per Research Pattern 5): + - router = APIRouter(tags=["devices"]) + - GET / -> list[DeviceConfig]: return state.registry.list_all() + - POST / status_code=201 -> DeviceConfig: registry.add(config), await registry.save(), return config + - DELETE /{device_id} status_code=204: registry.remove or raise HTTPException(404) + - Import registry via `import lightsync.main as state` and access `state.registry` + + Create lightsync/api/shows.py: + - router = APIRouter(tags=["shows"]) + - GET / -> list of show summaries: iterate show_store.list_ids(), load each, return list of {id, name, created_at} + - POST / status_code=201: accept ShowModel body, save via show_store, return show + - GET /{show_id}: load from show_store or raise 404 + - Import show_store via `import lightsync.main as state` and access `state.show_store` + + Create lightsync/api/ws.py (per Research Pattern 6): + - class ConnectionManager with active_connections list, connect/disconnect/broadcast methods + - module-level `manager = ConnectionManager()` + - @router.websocket("/ws"): accept, loop receive_text, echo back {"type": "ack", "echo": msg} + - Handle WebSocketDisconnect with manager.disconnect + + Create lightsync/main.py (per Research Pattern 1): + - Module-level: registry: DeviceRegistry | None = None, show_store: ShowStore | None = None + - @asynccontextmanager async def lifespan(app): initialize registry from Path("devices.json"), await registry.load(), initialize show_store from Path("shows"), show_store.ensure_dir(), yield, await registry.save() + - def create_app() -> FastAPI: create app with title="LightSync" and lifespan, include_router for shows (prefix="/api/shows"), devices (prefix="/api/devices"), ws; mount StaticFiles LAST at "/" with directory=Path(__file__).parent / "frontend" and html=True + - app = create_app() + + Create lightsync/__main__.py (per Research Pattern 1): + - if __name__ == "__main__": load_dotenv(), uvicorn.run("lightsync.main:app", host from env default "0.0.0.0", port from env default 8000, reload=False) + + + cd /home/claude/led2 && pip install -e ".[dev]" 2>&1 | tail -3 && python -c "from lightsync.devices.registry import DeviceRegistry; from lightsync.store.show_store import ShowStore; print('imports OK')" && timeout 5 python -m lightsync 2>&1 || true + + + - lightsync/devices/registry.py contains `_DEVICE_CLASSES` and `class DeviceRegistry` and `async def load` and `async def save` and `def instantiate` + - lightsync/store/show_store.py contains `class ShowStore` and `model_dump_json` and `model_validate_json` and `def list_ids` + - lightsync/api/devices.py contains `router = APIRouter` and `async def list_devices` and `async def add_device` and `async def remove_device` + - lightsync/api/shows.py contains `router = APIRouter` and `/api/shows` routes for GET and POST + - lightsync/api/ws.py contains `class ConnectionManager` and `@router.websocket("/ws")` + - lightsync/main.py contains `async def lifespan` and `StaticFiles` and `include_router` and routes registered BEFORE StaticFiles mount + - lightsync/__main__.py contains `if __name__ == "__main__"` and `uvicorn.run` + - `pip install -e .` completes without error + - `python -m lightsync` starts and listens on port 8000 (may fail on missing frontend dir — that is OK, plan 01-02 creates it) + + FastAPI app starts via `python -m lightsync`. Device registry persists to devices.json. Show store saves/loads JSON files. All API route stubs respond. WebSocket stub echoes messages. + + + + + +1. `pip install -e ".[dev]"` succeeds +2. `python -c "from lightsync.models.show import ShowModel; s = ShowModel(name='test'); assert s.schema_version == 1"` passes +3. `python -c "from lightsync.devices.registry import DeviceRegistry; print('OK')"` passes +4. `python -m lightsync` starts without import errors (may warn about missing frontend/ directory) + + + +- All Python modules under lightsync/ import without error +- Pydantic models serialize to and deserialize from JSON correctly +- DeviceRegistry round-trips through devices.json +- ShowStore round-trips through shows/ directory +- FastAPI app starts and registers all route prefixes +- Adding a new strip type requires only a new file in devices/ and one dict entry + + + +After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md` + diff --git a/.planning/phases/01-foundation/01-02-PLAN.md b/.planning/phases/01-foundation/01-02-PLAN.md new file mode 100644 index 0000000..2ae20e4 --- /dev/null +++ b/.planning/phases/01-foundation/01-02-PLAN.md @@ -0,0 +1,251 @@ +--- +phase: 01-foundation +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - lightsync/frontend/index.html + - lightsync/frontend/style.css + - lightsync/frontend/app.js +autonomous: true +requirements: + - UI-01 + - UI-02 + - DEV-03 + +must_haves: + truths: + - "The web app loads in a browser showing the full DAW skeleton layout" + - "The UI has terminal/hacker aesthetic — dark background, monospace fonts, cyan accents, no generic SaaS look" + - "All final panels are visible: DEVICES, ANIMATIONS, TIMELINE, TRANSPORT — most as labeled placeholders" + - "The DEVICES panel has an add-device form and a device list area" + - "Panel headers are uppercase and borders are dimmed cyan — matching terminal window pane style" + artifacts: + - path: "lightsync/frontend/index.html" + provides: "Full DAW skeleton HTML structure with all panels" + contains: "LIGHTSYNC" + - path: "lightsync/frontend/style.css" + provides: "Terminal aesthetic CSS — dark theme, monospace, cyan accents" + contains: "--accent" + - path: "lightsync/frontend/app.js" + provides: "WebSocket client stub, device panel interactions, API fetch helpers" + contains: "new WebSocket" + key_links: + - from: "lightsync/frontend/app.js" + to: "/ws" + via: "WebSocket connection for future position broadcasts" + pattern: "new WebSocket.*ws" + - from: "lightsync/frontend/app.js" + to: "/api/devices" + via: "fetch calls for device CRUD" + pattern: "fetch.*api/devices" + - from: "lightsync/frontend/index.html" + to: "lightsync/frontend/style.css" + via: "stylesheet link" + pattern: "style.css" +--- + + +Build the complete terminal-aesthetic frontend shell: HTML with full DAW skeleton layout (all panels), CSS with dark theme / monospace / cyan accents, and JavaScript with WebSocket stub and device panel interactivity. + +Purpose: Establishes the visual identity and layout structure that all future phases will fill in. The user sees the final app skeleton from day one. +Output: Three files in lightsync/frontend/ — index.html, style.css, app.js — that render the full DAW interface. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-foundation/01-CONTEXT.md +@.planning/phases/01-foundation/01-RESEARCH.md + + + + + + Task 1: Create HTML structure and CSS terminal aesthetic + + lightsync/frontend/index.html + lightsync/frontend/style.css + + + .planning/phases/01-foundation/01-CONTEXT.md + .planning/phases/01-foundation/01-RESEARCH.md + + + Create lightsync/frontend/ directory. + + Create lightsync/frontend/index.html with this structure (per D-06 through D-10): + + DOCTYPE html, lang="en", meta charset UTF-8, viewport meta, title "LIGHTSYNC". + Link to style.css, defer script app.js. + + Body layout using CSS Grid (per D-06 — full DAW skeleton from Phase 1): + + HEADER (per D-07): + - div.header containing: + - ASCII art or stylized text "LIGHTSYNC" in a pre or span element (use simple ASCII block letters or styled text — not an image) + - span.status-indicator with id="status" text "OFFLINE" (will toggle to "CONNECTED" via JS) + - span.show-name with id="show-name" text "No show loaded" + + LEFT SIDEBAR (per D-08): + - div.sidebar containing two panels stacked vertically: + - section.panel#devices-panel: + - h2.panel-header text "DEVICES" + - div#device-list (empty, populated by JS) + - form#add-device-form with inputs: name (text), strip_type (select: sk6812, ws2801, generic), led_count (number, min=1, max=1000), ip (text), port (number, min=1, max=65535), and a submit button text "+ ADD DEVICE" + - section.panel#animations-panel: + - h2.panel-header text "ANIMATIONS" + - div.placeholder-content text "[ Phase 3+ ]" + + MAIN CENTER (per D-09): + - div.main containing: + - section.panel#timeline-panel: + - h2.panel-header text "TIMELINE" + - div.placeholder-content text "[ TIMELINE -- Phase 2+ ]" + + BOTTOM BAR (per D-10): + - div.transport containing: + - section.panel#transport-panel: + - h2.panel-header text "TRANSPORT" + - div.placeholder-content text "[ TRANSPORT -- Phase 2+ ]" + + Create lightsync/frontend/style.css with terminal aesthetic (per D-11 through D-15): + + CSS custom properties on :root: + - --bg: #0a0a0a (per D-12) + - --bg-panel: #111111 + - --accent: #00ffff (per D-11) + - --accent-dim: #004444 (dimmed cyan for borders, per D-13) + - --text: #cccccc + - --text-bright: #ffffff + - --text-dim: #666666 + - --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace (per D-12) + + @import url for JetBrains Mono from Google Fonts (weight 400,700). + + *, *::before, *::after: box-sizing border-box, margin 0, padding 0. + html, body: height 100%, background var(--bg), color var(--text), font-family var(--font-mono), font-size 14px, overflow hidden. + + Body grid layout: grid-template-areas "header header header" / "sidebar main main" / "sidebar main main" / "transport transport transport". grid-template-columns: 280px 1fr. grid-template-rows: auto 1fr auto. + + .header: grid-area header, display flex, align-items center, gap 1rem, padding 8px 16px, border-bottom 1px solid var(--accent-dim), background var(--bg-panel). + .header pre (for ASCII title): color var(--accent), font-size 10px, line-height 1.1, white-space pre. + .status-indicator: font-size 11px, text-transform uppercase, padding 2px 8px, border 1px solid var(--accent-dim). When connected: color var(--accent). Default (offline): color #ff4444. + .show-name: margin-left auto, font-size 12px, color var(--text-dim). + + .sidebar: grid-area sidebar, display flex, flex-direction column, gap 0, border-right 1px solid var(--accent-dim), overflow-y auto. + .main: grid-area main, display flex, flex-direction column, overflow hidden, padding 8px. + .transport: grid-area transport, border-top 1px solid var(--accent-dim). + + .panel: background var(--bg-panel), border 1px solid var(--accent-dim) (per D-13), padding 0, margin 0. No border-radius (per D-15). No box-shadow (per D-15). + .panel-header: text-transform uppercase (per D-14), font-size 11px, letter-spacing 2px, color var(--accent), padding 8px 12px, border-bottom 1px solid var(--accent-dim), font-weight 700. + + .placeholder-content: display flex, align-items center, justify-content center, min-height 200px, color var(--text-dim), font-size 13px, letter-spacing 1px. + + #devices-panel: flex 1. + #animations-panel: flex 0 0 auto, border-top 1px solid var(--accent-dim). + #timeline-panel: flex 1, display flex, flex-direction column. The .placeholder-content inside: flex 1. + #transport-panel: padding 0. + #transport-panel .placeholder-content: min-height 48px. + + #device-list: padding 8px 12px. Each .device-item: display flex, justify-content space-between, align-items center, padding 4px 0, border-bottom 1px solid var(--accent-dim), font-size 12px. .device-item .device-name: color var(--text-bright). .device-item .device-meta: color var(--text-dim), font-size 11px. .device-item .device-remove: background none, border none, color #ff4444, cursor pointer, font-family var(--font-mono), font-size 14px. + + #add-device-form: padding 8px 12px, display flex, flex-direction column, gap 6px. + #add-device-form input, #add-device-form select: background var(--bg), border 1px solid var(--accent-dim), color var(--text), font-family var(--font-mono), font-size 12px, padding 4px 8px, outline none. Focus: border-color var(--accent). + #add-device-form button: background var(--accent-dim), border 1px solid var(--accent), color var(--accent), font-family var(--font-mono), font-size 12px, padding 6px, cursor pointer, text-transform uppercase, letter-spacing 1px. Hover: background var(--accent), color var(--bg). + + Scrollbar styling for webkit: 6px wide, track var(--bg), thumb var(--accent-dim), thumb hover var(--accent). + + + test -f /home/claude/led2/lightsync/frontend/index.html && test -f /home/claude/led2/lightsync/frontend/style.css && grep -c "LIGHTSYNC" /home/claude/led2/lightsync/frontend/index.html && grep -c "\-\-accent.*#00ffff" /home/claude/led2/lightsync/frontend/style.css && grep -c "JetBrains Mono" /home/claude/led2/lightsync/frontend/style.css + + + - lightsync/frontend/index.html contains "LIGHTSYNC" in header area + - lightsync/frontend/index.html contains id="devices-panel" and id="animations-panel" and id="timeline-panel" and id="transport-panel" + - lightsync/frontend/index.html contains id="add-device-form" with inputs for name, strip_type, led_count, ip, port + - lightsync/frontend/index.html contains id="device-list" + - lightsync/frontend/index.html contains "Phase 3+" in animations panel and "Phase 2+" in timeline panel and "Phase 2+" in transport panel + - lightsync/frontend/style.css contains `--accent: #00ffff` and `--bg: #0a0a0a` and `--font-mono` + - lightsync/frontend/style.css contains `text-transform: uppercase` for panel headers + - lightsync/frontend/style.css contains `border-radius` nowhere (per D-15 no rounded corners) OR only `border-radius: 0` + - lightsync/frontend/style.css contains `font-family: 'JetBrains Mono'` or references var(--font-mono) on body + - lightsync/frontend/style.css contains grid-template-areas with "header" and "sidebar" and "main" and "transport" + + HTML renders full DAW skeleton. CSS applies terminal aesthetic with dark background, monospace font, cyan accents, flat sharp edges. All panels visible with correct labels. + + + + Task 2: Create JavaScript — WebSocket client, device panel CRUD, API helpers + + lightsync/frontend/app.js + + + lightsync/frontend/index.html + .planning/phases/01-foundation/01-RESEARCH.md + + + Create lightsync/frontend/app.js with these modules: + + 1. API helper functions: + - async function apiGet(path): fetch(path), check response.ok, return response.json() + - async function apiPost(path, data): fetch(path, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(data)}), return response.json() + - async function apiDelete(path): fetch(path, {method: "DELETE"}) + + 2. WebSocket client stub: + - let ws = null + - function connectWebSocket(): create new WebSocket(`ws://${location.host}/ws`), set onopen to update #status text to "CONNECTED" and add .connected class, set onclose to update #status text to "OFFLINE" and remove .connected class, set onmessage to parse JSON and call handleWsMessage(data), set onerror to console.error. Auto-reconnect: onclose sets setTimeout(connectWebSocket, 2000). + - function handleWsMessage(msg): switch on msg.type — for now just console.log("WS:", msg). Phase 2 will add position/state handlers. + + 3. Device panel logic: + - async function loadDevices(): call apiGet("/api/devices"), for each device call renderDevice(device), populate #device-list + - function renderDevice(device): create div.device-item containing: span.device-name with device.name, span.device-meta with `${device.strip_type} | ${device.led_count} LEDs | ${device.ip}:${device.port}`, button.device-remove with text "x" and onclick calling removeDevice(device.id) + - async function removeDevice(id): call apiDelete(`/api/devices/${id}`), then loadDevices() to refresh + - Form submit handler on #add-device-form: preventDefault, collect form values (name, strip_type, led_count as int, ip, port as int), call apiPost("/api/devices", data), reset form, call loadDevices() + + 4. Initialization: + - document.addEventListener("DOMContentLoaded", () => { connectWebSocket(); loadDevices(); }) + + Add .connected CSS class to style.css for the status indicator: .status-indicator.connected { color: var(--accent); border-color: var(--accent); } + + + test -f /home/claude/led2/lightsync/frontend/app.js && grep -c "new WebSocket" /home/claude/led2/lightsync/frontend/app.js && grep -c "api/devices" /home/claude/led2/lightsync/frontend/app.js && grep -c "loadDevices" /home/claude/led2/lightsync/frontend/app.js && grep -c "connectWebSocket" /home/claude/led2/lightsync/frontend/app.js + + + - lightsync/frontend/app.js contains `new WebSocket` connecting to `/ws` + - lightsync/frontend/app.js contains `fetch` calls to `/api/devices` for GET, POST, and DELETE + - lightsync/frontend/app.js contains `function loadDevices` and `function renderDevice` and `function removeDevice` + - lightsync/frontend/app.js contains `DOMContentLoaded` listener calling `connectWebSocket()` and `loadDevices()` + - lightsync/frontend/app.js contains `function handleWsMessage` with msg.type switch/check + - lightsync/frontend/app.js contains auto-reconnect logic (setTimeout with connectWebSocket) + - lightsync/frontend/app.js contains `apiGet` and `apiPost` and `apiDelete` helper functions + + JavaScript connects WebSocket, displays connection status, loads device list from API, supports adding and removing devices via the form. All interactions use the API helpers. + + + + + +1. `ls lightsync/frontend/` shows index.html, style.css, app.js +2. index.html contains all four panel IDs: devices-panel, animations-panel, timeline-panel, transport-panel +3. style.css uses --accent: #00ffff and --bg: #0a0a0a +4. app.js connects to /ws and fetches from /api/devices + + + +- Opening the app in a browser shows: header with LIGHTSYNC title, left sidebar with DEVICES and ANIMATIONS panels, center TIMELINE placeholder, bottom TRANSPORT placeholder +- Dark background (#0a0a0a), monospace fonts, cyan accent color throughout +- No rounded corners, drop shadows, or gradients anywhere +- Device add form is functional (sends POST to /api/devices) +- WebSocket connects and shows CONNECTED status + + + +After completion, create `.planning/phases/01-foundation/01-02-SUMMARY.md` + diff --git a/.planning/phases/01-foundation/01-03-PLAN.md b/.planning/phases/01-foundation/01-03-PLAN.md new file mode 100644 index 0000000..b818213 --- /dev/null +++ b/.planning/phases/01-foundation/01-03-PLAN.md @@ -0,0 +1,254 @@ +--- +phase: 01-foundation +plan: 03 +type: execute +wave: 2 +depends_on: + - 01-01 + - 01-02 +files_modified: + - lightsync/api/devices.py + - lightsync/api/shows.py + - lightsync/main.py + - docker-compose.prod.yml + - Dockerfile +autonomous: true +requirements: + - INF-01 + - DEV-01 + - DEV-02 + - DEV-03 + - SHW-01 + - SHW-02 + - UI-01 + +must_haves: + truths: + - "POST /api/devices creates a device that appears in GET /api/devices and persists after restart" + - "DELETE /api/devices/{id} removes the device from the registry and from devices.json" + - "POST /api/shows creates a show file with schema_version=1 in the shows/ directory" + - "GET /api/shows/{id} returns the full show JSON including audio, devices, tracks fields" + - "Opening http://localhost:8000 serves index.html with working device CRUD through the UI" + - "The app runs inside Docker behind Traefik on lightsync.groll.cloud" + artifacts: + - path: "Dockerfile" + provides: "Python alpine container running lightsync" + contains: "python -m lightsync" + - path: "docker-compose.prod.yml" + provides: "Traefik-labeled service for lightsync.groll.cloud" + contains: "lightsync.groll.cloud" + key_links: + - from: "lightsync/frontend/app.js" + to: "lightsync/api/devices.py" + via: "fetch /api/devices — full CRUD round-trip" + pattern: "api/devices" + - from: "lightsync/api/shows.py" + to: "lightsync/store/show_store.py" + via: "ShowStore save/load calls" + pattern: "show_store" + - from: "lightsync/api/devices.py" + to: "lightsync/devices/registry.py" + via: "DeviceRegistry CRUD + persist" + pattern: "registry" +--- + + +Wire everything together: verify REST API endpoints work end-to-end with the frontend, add Docker deployment for groll.cloud, and run an integration smoke test confirming device CRUD and show save/load work through the full stack. + +Purpose: Proves the foundation actually works as a system — not just individual modules. Deploys to production so the user can see it live. +Output: Working app at lightsync.groll.cloud with device CRUD and show save/load functional. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-foundation/01-CONTEXT.md +@.planning/phases/01-foundation/01-01-SUMMARY.md +@.planning/phases/01-foundation/01-02-SUMMARY.md + + + + +From lightsync/models/device.py: +```python +StripType = Literal["sk6812", "ws2801", "generic"] +class DeviceConfig(BaseModel): + id: UUID, name: str, strip_type: StripType, led_count: int, ip: str, port: int, enabled: bool +``` + +From lightsync/models/show.py: +```python +class ShowModel(BaseModel): + schema_version: int = 1, id: UUID, name: str, created_at: datetime, updated_at: datetime, + audio: AudioRef, devices: list[DeviceConfig], analysis: AnalysisBlock, tracks: list[TrackModel] +``` + +From lightsync/devices/registry.py: +```python +class DeviceRegistry: + async def load(), async def save(), def add(config), def remove(device_id) -> bool, + def get(device_id), def list_all() -> list[DeviceConfig] +``` + +From lightsync/store/show_store.py: +```python +class ShowStore: + def ensure_dir(), async def save(show), async def load(show_id) -> ShowModel | None, def list_ids() -> list[str] +``` + + + + + + + Task 1: Integration test — verify API endpoints, fix any wiring issues, add Docker deployment + + lightsync/api/devices.py + lightsync/api/shows.py + lightsync/main.py + Dockerfile + docker-compose.prod.yml + + + lightsync/main.py + lightsync/api/devices.py + lightsync/api/shows.py + lightsync/api/ws.py + lightsync/devices/registry.py + lightsync/store/show_store.py + lightsync/frontend/app.js + lightsync/frontend/index.html + + + STEP 1 — Install and start the app: + - pip install -e ".[dev]" + - Start `python -m lightsync` in background + - Wait for server ready + + STEP 2 — Smoke test device CRUD via curl: + - POST /api/devices with body {"name": "Test Strip", "strip_type": "sk6812", "led_count": 60, "ip": "192.168.1.10", "port": 21324} — expect 201 with id field + - GET /api/devices — expect array containing the created device + - DELETE /api/devices/{id} — expect 204 + - GET /api/devices — expect empty array + - Stop and restart the app, POST a device, stop, restart, GET — verify device persists (DEV-02) + + STEP 3 — Smoke test show CRUD via curl: + - POST /api/shows with body {"name": "Test Show"} — expect 201 with schema_version=1 + - GET /api/shows — expect array with show summary + - GET /api/shows/{id} — expect full show JSON with fields: schema_version, id, name, audio, devices, tracks + - Verify JSON file exists in shows/ directory + + STEP 4 — Fix any issues found during smoke test: + - If any endpoint returns unexpected status codes or missing fields, fix the route handler + - If the shows API list endpoint is too slow (loading all JSON files), add a lightweight list that returns only {id, name} without full deserialization + - Ensure the shows POST endpoint generates a UUID if the client doesn't provide one, and sets created_at/updated_at + - Ensure devices POST endpoint generates a UUID if the client doesn't provide one + + STEP 5 — Verify frontend integration: + - GET / returns index.html (StaticFiles mount working) + - Verify style.css and app.js are served at /style.css and /app.js + + STEP 6 — Create Dockerfile: + - FROM python:3.11-alpine + - WORKDIR /app + - COPY pyproject.toml . + - RUN pip install --no-cache-dir . + - COPY lightsync/ lightsync/ + - EXPOSE 8000 + - CMD ["python", "-m", "lightsync"] + - Note: Use alpine to save RAM per CLAUDE.md constraint + + STEP 7 — Create docker-compose.prod.yml: + - service name: lightsync + - build: . + - container_name: lightsync + - restart: unless-stopped + - volumes: ./shows:/app/shows, ./devices.json:/app/devices.json + - networks: edge (external: true) + - labels for Traefik: + - traefik.enable=true + - traefik.http.routers.lightsync.rule=Host(`lightsync.groll.cloud`) + - traefik.http.routers.lightsync.entrypoints=websecure + - traefik.http.routers.lightsync.tls.certresolver=letsencrypt + - traefik.http.routers.lightsync.middlewares=authelia@docker + - traefik.http.services.lightsync.loadbalancer.server.port=8000 + - environment: HOST=0.0.0.0, PORT=8000 + + STEP 8 — Deploy: + - Run ~/bin/deploy.sh /home/claude/led2 + - Verify container starts successfully + + STEP 9 — Update ~/start/projects.json: + - Add entry: name "LightSync", url "https://lightsync.groll.cloud", icon "zap", badge "NEW", badgeColor "green", protected true, description "LED Light Show Manager", section "project" + - Run ~/bin/deploy.sh ~/start + + + cd /home/claude/led2 && pip install -e ".[dev]" 2>&1 | tail -1 && python -c " +import httpx, asyncio, json +from lightsync.main import app +async def test(): + async with httpx.AsyncClient(app=app, base_url='http://test') as c: + # Device CRUD + r = await c.post('/api/devices', json={'name':'Smoke','strip_type':'sk6812','led_count':30,'ip':'10.0.0.1','port':21324}) + assert r.status_code == 201, f'POST devices: {r.status_code}' + did = r.json()['id'] + r = await c.get('/api/devices') + assert r.status_code == 200 and len(r.json()) >= 1, 'GET devices failed' + # Show CRUD + r = await c.post('/api/shows', json={'name':'Smoke Show'}) + assert r.status_code == 201, f'POST shows: {r.status_code}' + sid = r.json()['id'] + assert r.json()['schema_version'] == 1, 'Missing schema_version' + r = await c.get(f'/api/shows/{sid}') + assert r.status_code == 200, f'GET show: {r.status_code}' + body = r.json() + assert 'audio' in body and 'tracks' in body and 'devices' in body, f'Missing fields: {list(body.keys())}' + # Static files + r = await c.get('/') + assert r.status_code == 200, f'GET /: {r.status_code}' + print('ALL SMOKE TESTS PASSED') +asyncio.run(test()) +" + + + - POST /api/devices returns 201 with valid JSON containing id, name, strip_type, led_count, ip, port + - GET /api/devices returns 200 with array of DeviceConfig objects + - DELETE /api/devices/{id} returns 204 for existing device and 404 for missing + - POST /api/shows returns 201 with JSON containing schema_version=1 + - GET /api/shows returns 200 with array of show summaries + - GET /api/shows/{id} returns 200 with full ShowModel JSON including audio, devices, tracks, analysis fields + - GET / returns 200 with HTML containing "LIGHTSYNC" + - Dockerfile contains `FROM python:3.11-alpine` and `CMD ["python", "-m", "lightsync"]` + - docker-compose.prod.yml contains `lightsync.groll.cloud` and `authelia@docker` and network `edge` + - Container runs successfully via `docker compose -f docker-compose.prod.yml ps` showing status "Up" + + All API endpoints respond correctly. Device persistence survives restart. Show files save as JSON with schema_version=1. Frontend loads from StaticFiles. App deployed to lightsync.groll.cloud behind Authelia. + + + + + +1. httpx smoke test (in verify above) passes all assertions +2. `docker compose -f docker-compose.prod.yml ps` shows lightsync as running +3. `curl -s http://localhost:8000/api/devices` returns JSON array +4. `curl -s http://localhost:8000/` contains "LIGHTSYNC" +5. `ls shows/` shows at least one .json file after creating a show + + + +- Complete device CRUD works: add device via POST, see it in GET, remove via DELETE, persists across restarts +- Complete show CRUD works: create show via POST with schema_version=1, list shows, load individual show with all fields +- Frontend serves from FastAPI StaticFiles at / +- App runs in Docker on lightsync.groll.cloud behind Authelia +- All five Phase 1 success criteria from ROADMAP.md are met + + + +After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md` +