Files
led2/.planning/phases/01-foundation/01-01-PLAN.md

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01-foundation 01 execute 1
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
true
INF-01
INF-02
DEV-01
DEV-02
SHW-01
SHW-02
truths artifacts key_links
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
path provides contains
lightsync/models/device.py DeviceConfig Pydantic model class DeviceConfig(BaseModel)
path provides contains
lightsync/models/show.py ShowModel with schema_version, AudioRef, TrackModel, CueModel, AnalysisBlock schema_version: int = 1
path provides contains
lightsync/devices/base.py BaseDevice ABC with encode_frame, encode_animation_cmd, bytes_per_pixel class BaseDevice(ABC)
path provides contains
lightsync/devices/registry.py DeviceRegistry with load/save/add/remove/list_all _DEVICE_CLASSES
path provides contains
lightsync/store/show_store.py ShowStore with save/load/list_ids model_dump_json
from to via pattern
lightsync/devices/registry.py lightsync/devices/base.py _DEVICE_CLASSES maps strip_type to BaseDevice subclass _DEVICE_CLASSES.*BaseDevice
from to via pattern
lightsync/devices/registry.py lightsync/models/device.py DeviceConfig used for persistence DeviceConfig.model_validate
from to via pattern
lightsync/store/show_store.py lightsync/models/show.py ShowModel serialization 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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)

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md`