--- 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`