From 061edfcf6a8fcae2cd9563a355ea322b898b38f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 18:45:19 +0000 Subject: [PATCH] feat(01-01): create package structure, Pydantic models, and device abstractions - pyproject.toml with FastAPI, uvicorn, Pydantic v2, aiofiles, structlog deps - DeviceConfig Pydantic model with StripType, UUID, validation constraints - ShowModel with AudioRef, TrackModel, CueModel, AnalysisBlock, schema_version=1 - BaseDevice ABC with encode_frame, encode_animation_cmd, bytes_per_pixel - SK6812Device (4 bytes/pixel) and WS2801Device (3 bytes/pixel) implementations --- .env | 2 ++ lightsync/__init__.py | 1 + lightsync/devices/__init__.py | 0 lightsync/devices/base.py | 33 +++++++++++++++++++++++++ lightsync/devices/sk6812.py | 28 +++++++++++++++++++++ lightsync/devices/ws2801.py | 27 ++++++++++++++++++++ lightsync/models/__init__.py | 4 +++ lightsync/models/device.py | 17 +++++++++++++ lightsync/models/show.py | 46 +++++++++++++++++++++++++++++++++++ pyproject.toml | 21 ++++++++++++++++ 10 files changed, 179 insertions(+) create mode 100644 .env create mode 100644 lightsync/__init__.py create mode 100644 lightsync/devices/__init__.py create mode 100644 lightsync/devices/base.py create mode 100644 lightsync/devices/sk6812.py create mode 100644 lightsync/devices/ws2801.py create mode 100644 lightsync/models/__init__.py create mode 100644 lightsync/models/device.py create mode 100644 lightsync/models/show.py create mode 100644 pyproject.toml diff --git a/.env b/.env new file mode 100644 index 0000000..9d70c6f --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +HOST=0.0.0.0 +PORT=8000 diff --git a/lightsync/__init__.py b/lightsync/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/lightsync/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/lightsync/devices/__init__.py b/lightsync/devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lightsync/devices/base.py b/lightsync/devices/base.py new file mode 100644 index 0000000..fd607ce --- /dev/null +++ b/lightsync/devices/base.py @@ -0,0 +1,33 @@ +from abc import ABC, abstractmethod +from lightsync.models.device import DeviceConfig + + +class BaseDevice(ABC): + def __init__(self, config: DeviceConfig): + self.config = config + + @property + def id(self): + return self.config.id + + @property + def led_count(self) -> int: + return self.config.led_count + + @abstractmethod + def encode_frame(self, pixels: list[tuple]) -> bytes: + """Encode a full pixel frame to UDP payload bytes. + pixels: list of (R, G, B) or (R, G, B, W) tuples. + """ + ... + + @abstractmethod + def encode_animation_cmd(self, animation: str, params: dict) -> bytes: + """Encode an animation+params command packet.""" + ... + + @property + @abstractmethod + def bytes_per_pixel(self) -> int: + """Number of bytes per LED in frame mode (3 for RGB, 4 for RGBW).""" + ... diff --git a/lightsync/devices/sk6812.py b/lightsync/devices/sk6812.py new file mode 100644 index 0000000..f9fd5a2 --- /dev/null +++ b/lightsync/devices/sk6812.py @@ -0,0 +1,28 @@ +from lightsync.devices.base import BaseDevice +from lightsync.models.device import DeviceConfig + + +class SK6812Device(BaseDevice): + """SK6812 RGBW LED strip — 4 bytes per pixel (R, G, B, W).""" + + def __init__(self, config: DeviceConfig): + super().__init__(config) + + @property + def bytes_per_pixel(self) -> int: + return 4 + + def encode_frame(self, pixels: list[tuple]) -> bytes: + """Encode pixel list to SK6812 frame bytes. + Each pixel: (r, g, b, w) tuple — 4 bytes per LED. + """ + buf = bytearray() + for pixel in pixels: + r, g, b = pixel[0], pixel[1], pixel[2] + w = pixel[3] if len(pixel) > 3 else 0 + buf.extend([r & 0xFF, g & 0xFF, b & 0xFF, w & 0xFF]) + return bytes(buf) + + def encode_animation_cmd(self, animation: str, params: dict) -> bytes: + """Stub — Phase 3 implements real animation command encoding.""" + return b"" diff --git a/lightsync/devices/ws2801.py b/lightsync/devices/ws2801.py new file mode 100644 index 0000000..9f52d97 --- /dev/null +++ b/lightsync/devices/ws2801.py @@ -0,0 +1,27 @@ +from lightsync.devices.base import BaseDevice +from lightsync.models.device import DeviceConfig + + +class WS2801Device(BaseDevice): + """WS2801 RGB LED strip — 3 bytes per pixel (R, G, B).""" + + def __init__(self, config: DeviceConfig): + super().__init__(config) + + @property + def bytes_per_pixel(self) -> int: + return 3 + + def encode_frame(self, pixels: list[tuple]) -> bytes: + """Encode pixel list to WS2801 frame bytes. + Each pixel: (r, g, b) tuple — 3 bytes per LED. + """ + buf = bytearray() + for pixel in pixels: + r, g, b = pixel[0], pixel[1], pixel[2] + buf.extend([r & 0xFF, g & 0xFF, b & 0xFF]) + return bytes(buf) + + def encode_animation_cmd(self, animation: str, params: dict) -> bytes: + """Stub — Phase 3 implements real animation command encoding.""" + return b"" diff --git a/lightsync/models/__init__.py b/lightsync/models/__init__.py new file mode 100644 index 0000000..7a7d56d --- /dev/null +++ b/lightsync/models/__init__.py @@ -0,0 +1,4 @@ +from lightsync.models.device import DeviceConfig +from lightsync.models.show import ShowModel + +__all__ = ["DeviceConfig", "ShowModel"] diff --git a/lightsync/models/device.py b/lightsync/models/device.py new file mode 100644 index 0000000..bafeb96 --- /dev/null +++ b/lightsync/models/device.py @@ -0,0 +1,17 @@ +from uuid import UUID, uuid4 +from typing import Literal +from pydantic import BaseModel, Field + +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} diff --git a/lightsync/models/show.py b/lightsync/models/show.py new file mode 100644 index 0000000..7149ba4 --- /dev/null +++ b/lightsync/models/show.py @@ -0,0 +1,46 @@ +from uuid import UUID, uuid4 +from datetime import datetime, timezone +from typing import Any, Literal +from pydantic import BaseModel, Field + +from lightsync.models.device import DeviceConfig + + +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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9b9584c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[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", +]