docs(01-foundation): create phase plan — 3 plans in 2 waves
This commit is contained in:
279
.planning/phases/01-foundation/01-01-PLAN.md
Normal file
279
.planning/phases/01-foundation/01-01-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-foundation/01-CONTEXT.md
|
||||
@.planning/phases/01-foundation/01-RESEARCH.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create package structure, Pydantic models, and pyproject.toml</name>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<read_first>
|
||||
.planning/phases/01-foundation/01-RESEARCH.md
|
||||
.planning/phases/01-foundation/01-CONTEXT.md
|
||||
</read_first>
|
||||
<action>
|
||||
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)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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')"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>All Pydantic models serialize/deserialize correctly. BaseDevice ABC exists with two concrete implementations. pyproject.toml lists all Phase 1 dependencies.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create device registry, show store, FastAPI app with lifespan, API route stubs, and WebSocket stub</name>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<read_first>
|
||||
.planning/phases/01-foundation/01-RESEARCH.md
|
||||
lightsync/models/device.py
|
||||
lightsync/models/show.py
|
||||
lightsync/devices/base.py
|
||||
</read_first>
|
||||
<action>
|
||||
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)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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)
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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)
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user