feat(02-01): implement ChoreoEvent and ChoreoFile Pydantic v2 models

- ChoreoEvent: validated zone (schrank/wand/all), animation (8 names), timestamp (>= 0)
- ChoreoFile: save/load .choreo.json roundtrip, events always sorted by timestamp
- add_event() inserts and re-sorts by timestamp (D-02)
- Exports VALID_ZONES and VALID_ANIMATIONS constants
- 17 tests pass: validators, roundtrip, sorting, constants
This commit is contained in:
Claude
2026-04-03 14:22:41 +02:00
parent 09c1ac1321
commit 6b507e9605
2 changed files with 308 additions and 0 deletions

96
src/led_sync/models.py Normal file
View File

@@ -0,0 +1,96 @@
"""Choreography data model for LED Sync Studio.
Implements ChoreoEvent and ChoreoFile Pydantic v2 models per CONTEXT.md D-01/D-02/D-03.
Valid zones: schrank, wand, all (from docs/protocol.md)
Valid animations: chase, pulse, rainbow, strobe, color_wash, breathe, sparkle, gradient_sweep
File extension convention: .choreo.json (D-03)
Events are always sorted by timestamp (D-02).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
from pydantic import BaseModel, field_validator
VALID_ZONES: set[str] = {"schrank", "wand", "all"}
VALID_ANIMATIONS: set[str] = {
"chase",
"pulse",
"rainbow",
"strobe",
"color_wash",
"breathe",
"sparkle",
"gradient_sweep",
}
class ChoreoEvent(BaseModel):
"""A single choreography event: at `timestamp` seconds, trigger `animation` on `zone`."""
timestamp: float # seconds from song start; must be >= 0
zone: str # "schrank" | "wand" | "all"
animation: str # must match ESP32 animation names from docs/protocol.md
params: dict # speed, intensity, colors, direction — forwarded as-is to ESP32
loop: Optional[int] = None # None = play once; N = repeat N times
duration: Optional[float] = None # seconds; None = run until next event
@field_validator("timestamp")
@classmethod
def validate_timestamp(cls, v: float) -> float:
if v < 0:
raise ValueError("timestamp must be >= 0")
return v
@field_validator("zone")
@classmethod
def validate_zone(cls, v: str) -> str:
if v not in VALID_ZONES:
raise ValueError(
f"Invalid zone: {v!r}. Must be one of {sorted(VALID_ZONES)}"
)
return v
@field_validator("animation")
@classmethod
def validate_animation(cls, v: str) -> str:
if v not in VALID_ANIMATIONS:
raise ValueError(
f"Unknown animation: {v!r}. Must be one of {sorted(VALID_ANIMATIONS)}"
)
return v
class ChoreoFile(BaseModel):
"""A choreography file: song path, optional BPM, and a sorted list of events.
Events are always kept sorted by timestamp (D-02).
Persist with .choreo.json extension (D-03).
"""
song_path: str
bpm: Optional[float] = None
events: list[ChoreoEvent] = []
def model_post_init(self, __context: object) -> None:
"""Sort events by timestamp on initialization (D-02)."""
self.events = sorted(self.events, key=lambda e: e.timestamp)
def add_event(self, event: ChoreoEvent) -> None:
"""Insert an event and re-sort by timestamp."""
self.events.append(event)
self.events.sort(key=lambda e: e.timestamp)
def save(self, path: str | Path) -> None:
"""Write choreography to a .choreo.json file."""
Path(path).write_text(self.model_dump_json(indent=2))
@classmethod
def load(cls, path: str | Path) -> "ChoreoFile":
"""Load a choreography from a .choreo.json file. Events will be sorted."""
return cls.model_validate_json(Path(path).read_text())