diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1a410d --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ +*.egg-info/ +dist/ +build/ + +# Runtime data (created by lightsync at startup) +devices.json +shows/ + +# Environment +.env.local + +# Editor +.vscode/ +.idea/ diff --git a/lightsync/__main__.py b/lightsync/__main__.py new file mode 100644 index 0000000..23f2aab --- /dev/null +++ b/lightsync/__main__.py @@ -0,0 +1,12 @@ +import os +import uvicorn +from dotenv import load_dotenv + +if __name__ == "__main__": + load_dotenv() + uvicorn.run( + "lightsync.main:app", + host=os.getenv("HOST", "0.0.0.0"), + port=int(os.getenv("PORT", "8000")), + reload=False, # set True manually during dev only + ) diff --git a/lightsync/api/__init__.py b/lightsync/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lightsync/api/devices.py b/lightsync/api/devices.py new file mode 100644 index 0000000..2130254 --- /dev/null +++ b/lightsync/api/devices.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, HTTPException +from lightsync.models.device import DeviceConfig +import lightsync.main as state # access registry via module-level ref + +router = APIRouter(tags=["devices"]) + + +@router.get("/") +async def list_devices() -> list[DeviceConfig]: + return state.registry.list_all() + + +@router.post("/", status_code=201) +async def add_device(config: DeviceConfig) -> DeviceConfig: + state.registry.add(config) + await state.registry.save() + return config + + +@router.delete("/{device_id}", status_code=204) +async def remove_device(device_id: str): + if not state.registry.remove(device_id): + raise HTTPException(404, "Device not found") + await state.registry.save() diff --git a/lightsync/api/shows.py b/lightsync/api/shows.py new file mode 100644 index 0000000..2f67f32 --- /dev/null +++ b/lightsync/api/shows.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, HTTPException +from lightsync.models.show import ShowModel +import lightsync.main as state # access show_store via module-level ref + +router = APIRouter(tags=["shows"]) + + +@router.get("/") +async def list_shows(): + """Return summary list of all shows.""" + summaries = [] + for show_id in state.show_store.list_ids(): + show = await state.show_store.load(show_id) + if show is not None: + summaries.append({ + "id": str(show.id), + "name": show.name, + "created_at": show.created_at.isoformat(), + }) + return summaries + + +@router.post("/", status_code=201) +async def create_show(show: ShowModel) -> ShowModel: + await state.show_store.save(show) + return show + + +@router.get("/{show_id}") +async def get_show(show_id: str) -> ShowModel: + show = await state.show_store.load(show_id) + if show is None: + raise HTTPException(404, "Show not found") + return show diff --git a/lightsync/api/ws.py b/lightsync/api/ws.py new file mode 100644 index 0000000..9d1cbf8 --- /dev/null +++ b/lightsync/api/ws.py @@ -0,0 +1,45 @@ +import json +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +router = APIRouter() + + +class ConnectionManager: + def __init__(self): + self.active_connections: list[WebSocket] = [] + + async def connect(self, ws: WebSocket) -> None: + await ws.accept() + self.active_connections.append(ws) + + def disconnect(self, ws: WebSocket) -> None: + if ws in self.active_connections: + self.active_connections.remove(ws) + + async def broadcast(self, message: dict) -> None: + dead = [] + for connection in self.active_connections: + try: + await connection.send_text(json.dumps(message)) + except Exception: + dead.append(connection) + for d in dead: + if d in self.active_connections: + self.active_connections.remove(d) + + +manager = ConnectionManager() + + +@router.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + try: + while True: + data = await websocket.receive_text() + msg = json.loads(data) + # Phase 2 will dispatch msg["type"] to handlers + # For now: echo back with type=ack + await websocket.send_text(json.dumps({"type": "ack", "echo": msg})) + except WebSocketDisconnect: + manager.disconnect(websocket) diff --git a/lightsync/devices/registry.py b/lightsync/devices/registry.py new file mode 100644 index 0000000..b1555b2 --- /dev/null +++ b/lightsync/devices/registry.py @@ -0,0 +1,55 @@ +import json +import aiofiles +from pathlib import Path +from lightsync.models.device import DeviceConfig +from lightsync.devices.base import BaseDevice +from lightsync.devices.sk6812 import SK6812Device +from lightsync.devices.ws2801 import WS2801Device + +# Registry of strip_type -> device class (INF-02: add new type here only) +_DEVICE_CLASSES: dict[str, type[BaseDevice]] = { + "sk6812": SK6812Device, + "ws2801": WS2801Device, +} + + +class DeviceRegistry: + def __init__(self, path: Path): + self._path = path + self._devices: dict[str, DeviceConfig] = {} # id (str) -> config + + async def load(self) -> None: + if self._path.exists(): + async with aiofiles.open(self._path) as f: + raw = json.loads(await f.read()) + self._devices = { + d["id"]: DeviceConfig.model_validate(d) + for d in raw.get("devices", []) + } + + async def save(self) -> None: + data = {"devices": [d.model_dump(mode="json") for d in self._devices.values()]} + async with aiofiles.open(self._path, "w") as f: + await f.write(json.dumps(data, indent=2)) + + def add(self, config: DeviceConfig) -> None: + self._devices[str(config.id)] = config + + def remove(self, device_id: str) -> bool: + return self._devices.pop(device_id, None) is not None + + def get(self, device_id: str) -> DeviceConfig | None: + return self._devices.get(device_id) + + def list_all(self) -> list[DeviceConfig]: + return list(self._devices.values()) + + def instantiate(self, device_id: str) -> BaseDevice | None: + """Create a live BaseDevice instance from stored config.""" + config = self.get(device_id) + if config is None: + return None + cls = _DEVICE_CLASSES.get(config.strip_type) + if cls is None: + raise ValueError(f"Unknown strip_type: {config.strip_type}") + return cls(config) diff --git a/lightsync/frontend/app.js b/lightsync/frontend/app.js new file mode 100644 index 0000000..f81ffc5 --- /dev/null +++ b/lightsync/frontend/app.js @@ -0,0 +1,88 @@ +// LightSync frontend — Phase 1 shell +// WebSocket stub + device list loader +// Phase 2 expands audio, transport, and WS protocol + +const WS_URL = `ws://${location.host}/ws`; + +class LightSyncClient { + constructor() { + this.ws = null; + this.reconnectDelay = 2000; + this._reconnectTimer = null; + } + + connect() { + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + this._reconnectTimer = null; + } + + this.ws = new WebSocket(WS_URL); + + this.ws.onopen = () => { + document.getElementById("status-dot").className = "status-dot connected"; + document.getElementById("status-text").textContent = "CONNECTED"; + }; + + this.ws.onclose = () => { + document.getElementById("status-dot").className = "status-dot"; + document.getElementById("status-text").textContent = "OFFLINE"; + this._reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay); + }; + + this.ws.onerror = () => { + document.getElementById("status-dot").className = "status-dot error"; + }; + + this.ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + this.handleMessage(msg); + }; + } + + handleMessage(msg) { + // Phase 2 will dispatch on msg.type + console.debug("[ws]", msg); + } + + send(msg) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } +} + +const client = new LightSyncClient(); +client.connect(); + +// Load device list on startup +async function loadDevices() { + try { + const res = await fetch("/api/devices/"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const devices = await res.json(); + const list = document.getElementById("device-list"); + if (devices.length === 0) { + list.innerHTML = 'no devices registered'; + return; + } + list.innerHTML = devices.map(d => + `