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:
96
src/led_sync/models.py
Normal file
96
src/led_sync/models.py
Normal 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())
|
||||
Reference in New Issue
Block a user