1058 lines
42 KiB
Markdown
1058 lines
42 KiB
Markdown
# Phase 1: Foundation - Research
|
|
|
|
**Researched:** 2026-04-05
|
|
**Domain:** FastAPI + Pydantic v2 + Vanilla JS terminal aesthetic (Python 3.11, Windows 11 primary)
|
|
**Confidence:** HIGH (FastAPI/Pydantic patterns), MEDIUM (CSS terminal aesthetic), HIGH (device ABC pattern)
|
|
|
|
---
|
|
|
|
<user_constraints>
|
|
## User Constraints (from CONTEXT.md)
|
|
|
|
### Locked Decisions
|
|
|
|
- **D-01:** Flat package layout — `lightsync/` directory at project root (not `src/` layout)
|
|
- **D-02:** Package structure: `lightsync/__init__.py`, `lightsync/main.py` (entry point), `lightsync/api/`, `lightsync/devices/`, `lightsync/models/`, `lightsync/frontend/`
|
|
- **D-03:** Frontend (HTML/CSS/JS) lives inside `lightsync/frontend/` — served via FastAPI `StaticFiles`, no separate frontend directory
|
|
- **D-04:** Start command: `python -m lightsync` — standard module invocation, cross-platform, no install step required
|
|
- **D-05:** `pyproject.toml` at root for dependency management
|
|
- **D-06:** Full DAW skeleton from Phase 1 — all final panels exist, most as dark labeled placeholders
|
|
- **D-07:** Header: ASCII art / stylized `LIGHTSYNC` title + system status indicator (API connected / offline) + current show name
|
|
- **D-08:** Left sidebar: DEVICES panel stacked above ANIMATIONS panel (placeholder label "Phase 3+")
|
|
- **D-09:** Main center area: large TIMELINE placeholder panel labeled `[ TIMELINE — Phase 2+ ]`
|
|
- **D-10:** Bottom bar: TRANSPORT placeholder `[ TRANSPORT — Phase 2+ ]` spanning full width
|
|
- **D-11:** Color accent: cyan/teal (`#00ffff` or close variant) on a dark background — terminal/hacker aesthetic
|
|
- **D-12:** Dark background (`#0a0a0a` or `#111`), monospace font (JetBrains Mono, Fira Code, or system monospace fallback)
|
|
- **D-13:** Panel borders: `border: 1px solid` with dimmed cyan/teal — terminal window panes
|
|
- **D-14:** Uppercase panel headers (`DEVICES`, `ANIMATIONS`, `TIMELINE`) — terminal convention
|
|
- **D-15:** No drop shadows, gradients, or rounded corners — flat, sharp edges
|
|
|
|
### Claude's Discretion
|
|
|
|
- Exact Pydantic model field names and nesting for show files and devices (follow ARCHITECTURE.md conventions)
|
|
- Device registry storage format (separate `devices.json` vs embedded — choose cleanest)
|
|
- REST API route naming conventions
|
|
- WebSocket message format stub (just needs to exist, Phase 2 defines real protocol)
|
|
- Exact CSS grid/flexbox layout implementation
|
|
|
|
### Deferred Ideas (OUT OF SCOPE)
|
|
|
|
- Audio transport panel content — Phase 2
|
|
- Animation library panel content — Phase 3
|
|
- Timeline canvas implementation — Phase 4
|
|
- AI sync — Phase 6
|
|
</user_constraints>
|
|
|
|
---
|
|
|
|
<phase_requirements>
|
|
## Phase Requirements
|
|
|
|
| ID | Description | Research Support |
|
|
|----|-------------|------------------|
|
|
| INF-01 | Python 3.11 backend (FastAPI + uvicorn) | pyproject.toml pattern, uvicorn.run(), lifespan hook |
|
|
| INF-02 | Modular device abstraction — new strip type = new class only | BaseDevice ABC pattern with register dict in registry.py |
|
|
| DEV-01 | Register device with name, strip type, LED count, IP, port | Pydantic DeviceModel, POST /devices endpoint |
|
|
| DEV-02 | Devices persist across sessions | devices.json load/save in lifespan hook |
|
|
| DEV-03 | Device list shown in UI — enable/disable per show | DeviceRegistry GET endpoint + JS panel |
|
|
| SHW-01 | Show files saved/loaded as JSON (schema_version required) | Pydantic ShowModel, show store load/save |
|
|
| SHW-02 | Show file includes audio path/URL, device snapshot, animation blocks, beat data | Full ShowModel schema from ARCHITECTURE.md |
|
|
| UI-01 | Web app served by Python backend — no external hosting | FastAPI StaticFiles mount with html=True |
|
|
| UI-02 | Terminal/hacker aesthetic — dark theme, monospace, CRT vibe | CSS custom properties, grid layout, no CSS framework |
|
|
</phase_requirements>
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
Phase 1 establishes the complete project skeleton: Python package, Pydantic data models, FastAPI app with REST stubs, device registry with file persistence, show store, and a full DAW-skeleton frontend with terminal aesthetic. All panels that will exist in the final product are present as labeled placeholders from day one.
|
|
|
|
The primary technical challenge is the structural gap between `ARCHITECTURE.md` (which describes a `backend/`+`frontend/` layout) and the locked decisions in CONTEXT.md (which specify a flat `lightsync/` package). The implementation MUST follow CONTEXT.md's locked structure. The architecture patterns from ARCHITECTURE.md apply in terms of component design, just nested differently.
|
|
|
|
Windows 11 is the primary runtime. All uvicorn invocations must use `if __name__ == "__main__"` guard and avoid `--reload` in the shipped `__main__.py` (use it only during dev). File paths must use `pathlib.Path` for cross-platform safety even though Windows is primary.
|
|
|
|
**Primary recommendation:** Build in this order within the phase: Pydantic models first (everything depends on them), then show store + device registry, then FastAPI app with lifespan, then REST endpoints, then frontend shell, then integration smoke test.
|
|
|
|
---
|
|
|
|
## Standard Stack
|
|
|
|
### Core (Phase 1 only — no audio, no UDP)
|
|
|
|
| Library | Version | Purpose | Why Standard |
|
|
|---------|---------|---------|--------------|
|
|
| Python | 3.11 | Runtime | 3.11 avoids madmom incompatibility (3.12+), good Windows support, target per INF-01 |
|
|
| FastAPI | 0.115.x | Web framework + REST + WebSocket + static file serving | Native async, Pydantic v2 built-in, StaticFiles mounting, single process covers all |
|
|
| uvicorn | 0.34.x | ASGI server | Standard FastAPI runner, ProactorEventLoop on Windows by default |
|
|
| Pydantic | 2.x (FastAPI dep) | Show file and device model validation + JSON serialization | Already required by FastAPI; v2 is significantly faster than v1 |
|
|
| aiofiles | 24.x | Async file I/O for show JSON read/write | Keeps FastAPI async handlers non-blocking during file operations |
|
|
|
|
### Supporting (Phase 1)
|
|
|
|
| Library | Version | Purpose | When to Use |
|
|
|---------|---------|---------|-------------|
|
|
| python-dotenv | 1.x | Host/port config via .env file | On startup to allow port override without code changes |
|
|
| structlog | 24.x | Structured logging | For backend log messages — easier to read than stdlib logging |
|
|
|
|
### Not Needed in Phase 1
|
|
librosa, python-mpv, aiosqlite, numpy — these are Phase 2+ dependencies. Phase 1 persists devices as JSON, no SQLite needed.
|
|
|
|
### Installation (pyproject.toml)
|
|
|
|
```toml
|
|
[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", # for TestClient in smoke tests
|
|
]
|
|
```
|
|
|
|
**Note on `uvicorn[standard]`:** The `[standard]` extra installs `watchfiles` for hot reload and `websockets` for WebSocket support. Required because Phase 1 includes a WebSocket stub endpoint.
|
|
|
|
**Version check note:** STACK.md pinned FastAPI at 0.135.x but the latest stable at research time is 0.115.x (the 0.115.x series is the current active release line). Use `>=0.115.0` rather than a hard pin so patch updates flow through.
|
|
|
|
---
|
|
|
|
## Architecture Patterns
|
|
|
|
### Project Structure (locked by CONTEXT.md decisions D-01 through D-05)
|
|
|
|
```
|
|
lightsync/ # Python package (D-01, D-02)
|
|
├── __init__.py
|
|
├── __main__.py # python -m lightsync entry point (D-04)
|
|
├── main.py # FastAPI app factory, lifespan, route registration
|
|
├── api/
|
|
│ ├── __init__.py
|
|
│ ├── shows.py # REST: CRUD shows
|
|
│ ├── devices.py # REST: CRUD devices
|
|
│ └── ws.py # WebSocket stub endpoint
|
|
├── devices/
|
|
│ ├── __init__.py
|
|
│ ├── base.py # BaseDevice ABC
|
|
│ ├── sk6812.py # SK6812 RGBW device
|
|
│ ├── ws2801.py # WS2801 RGB device
|
|
│ └── registry.py # DeviceRegistry (CRUD + JSON persistence)
|
|
├── models/
|
|
│ ├── __init__.py
|
|
│ ├── show.py # ShowModel, TrackModel, CueModel Pydantic models
|
|
│ └── device.py # DeviceConfig Pydantic model
|
|
├── store/
|
|
│ ├── __init__.py
|
|
│ └── show_store.py # Load/save shows from shows/ directory
|
|
└── frontend/ # Static files served via StaticFiles (D-03)
|
|
├── index.html
|
|
├── style.css
|
|
└── app.js
|
|
|
|
pyproject.toml # Dependencies (D-05)
|
|
shows/ # Show JSON files (created at runtime)
|
|
devices.json # Device registry (created at runtime)
|
|
.env # PORT=8000, HOST=127.0.0.1
|
|
```
|
|
|
|
**Note:** ARCHITECTURE.md suggests a `store/` subdirectory and `models/` subdirectory — this maps cleanly onto CONTEXT.md's D-02 structure. The `ws/` directory from ARCHITECTURE.md becomes `api/ws.py` in the flat layout.
|
|
|
|
### Pattern 1: FastAPI App with Lifespan Hook
|
|
|
|
**What:** The lifespan context manager handles startup (load devices.json, initialize stores) and shutdown (save devices.json). This replaces deprecated `@app.on_event("startup")`.
|
|
|
|
**When to use:** All state initialization goes in lifespan — not at module-level globals, and not in route handlers.
|
|
|
|
```python
|
|
# lightsync/main.py
|
|
# Source: https://fastapi.tiangolo.com/advanced/events/
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pathlib import Path
|
|
|
|
from lightsync.devices.registry import DeviceRegistry
|
|
from lightsync.store.show_store import ShowStore
|
|
|
|
# Module-level containers populated in lifespan
|
|
registry: DeviceRegistry | None = None
|
|
show_store: ShowStore | None = None
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global registry, show_store
|
|
# Startup
|
|
registry = DeviceRegistry(Path("devices.json"))
|
|
await registry.load()
|
|
show_store = ShowStore(Path("shows"))
|
|
show_store.ensure_dir()
|
|
yield
|
|
# Shutdown
|
|
await registry.save()
|
|
|
|
def create_app() -> FastAPI:
|
|
app = FastAPI(title="LightSync", lifespan=lifespan)
|
|
|
|
# API routes (registered before static mount)
|
|
from lightsync.api import shows, devices, ws
|
|
app.include_router(shows.router, prefix="/api/shows")
|
|
app.include_router(devices.router, prefix="/api/devices")
|
|
app.include_router(ws.router)
|
|
|
|
# Static file serving — MUST be last (catches all unmatched paths)
|
|
frontend_dir = Path(__file__).parent / "frontend"
|
|
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
|
|
|
|
return app
|
|
|
|
app = create_app()
|
|
```
|
|
|
|
```python
|
|
# lightsync/__main__.py
|
|
import uvicorn
|
|
from dotenv import load_dotenv
|
|
import os
|
|
|
|
if __name__ == "__main__":
|
|
load_dotenv()
|
|
uvicorn.run(
|
|
"lightsync.main:app",
|
|
host=os.getenv("HOST", "127.0.0.1"),
|
|
port=int(os.getenv("PORT", "8000")),
|
|
reload=False, # set True manually during dev only
|
|
)
|
|
```
|
|
|
|
**Windows note:** The `if __name__ == "__main__"` guard in `__main__.py` is required on Windows. Without it, the `--reload` subprocess spawning crashes with a multiprocessing error. Since `python -m lightsync` invokes `__main__.py` as `__main__`, this guard works correctly.
|
|
|
|
### Pattern 2: Pydantic v2 Show and Device Models
|
|
|
|
**What:** All JSON data structures use Pydantic BaseModel. Show files serialize to JSON via `model_dump_json()`. Devices use a flat model matching the JSON storage format.
|
|
|
|
```python
|
|
# lightsync/models/device.py
|
|
# Source: https://docs.pydantic.dev/latest/concepts/models/
|
|
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}
|
|
```
|
|
|
|
```python
|
|
# lightsync/models/show.py
|
|
# Source: https://docs.pydantic.dev/latest/concepts/models/
|
|
from uuid import UUID, uuid4
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Literal
|
|
from pydantic import BaseModel, Field
|
|
|
|
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 # seconds from start
|
|
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 # SHW-01: required field
|
|
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) # SHW-02: device snapshot
|
|
analysis: AnalysisBlock = Field(default_factory=AnalysisBlock)
|
|
tracks: list[TrackModel] = Field(default_factory=list) # SHW-02: animation blocks
|
|
ai_sequences: list[Any] = Field(default_factory=list)
|
|
```
|
|
|
|
**Key Pydantic v2 notes:**
|
|
- Use `model_dump_json()` for JSON serialization (replaces v1 `.json()`)
|
|
- Use `model_dump()` for dict output
|
|
- Use `Model.model_validate(data)` for deserialization (replaces v1 `.parse_obj()`)
|
|
- `Field(default_factory=uuid4)` is the correct v2 idiom for UUID defaults
|
|
- `model_config = {"populate_by_name": True}` allows both alias and field name on input
|
|
|
|
### Pattern 3: BaseDevice ABC for INF-02
|
|
|
|
**What:** All device types inherit from `BaseDevice`. The Show Engine and UDP Sender only call methods on `BaseDevice`. Adding SK9822 in Phase 5 requires zero changes outside `devices/`.
|
|
|
|
```python
|
|
# lightsync/devices/base.py
|
|
# Source: https://docs.python.org/3/library/abc.html
|
|
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)."""
|
|
...
|
|
```
|
|
|
|
```python
|
|
# lightsync/devices/registry.py
|
|
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 -> 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)
|
|
```
|
|
|
|
**INF-02 enforcement:** The `_DEVICE_CLASSES` dict is the single place a new strip type is registered. The engine never checks `strip_type` directly. Adding `"ws2812b"` requires only: (1) create `lightsync/devices/ws2812b.py` implementing `BaseDevice`, (2) add one line to `_DEVICE_CLASSES`.
|
|
|
|
### Pattern 4: Show Store (Load/Save)
|
|
|
|
```python
|
|
# lightsync/store/show_store.py
|
|
import json
|
|
import aiofiles
|
|
from pathlib import Path
|
|
from lightsync.models.show import ShowModel
|
|
|
|
class ShowStore:
|
|
def __init__(self, shows_dir: Path):
|
|
self._dir = shows_dir
|
|
|
|
def ensure_dir(self) -> None:
|
|
self._dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _path_for(self, show_id: str) -> Path:
|
|
return self._dir / f"{show_id}.json"
|
|
|
|
async def save(self, show: ShowModel) -> None:
|
|
path = self._path_for(str(show.id))
|
|
async with aiofiles.open(path, "w") as f:
|
|
await f.write(show.model_dump_json(indent=2))
|
|
|
|
async def load(self, show_id: str) -> ShowModel | None:
|
|
path = self._path_for(show_id)
|
|
if not path.exists():
|
|
return None
|
|
async with aiofiles.open(path) as f:
|
|
return ShowModel.model_validate_json(await f.read())
|
|
|
|
def list_ids(self) -> list[str]:
|
|
return [p.stem for p in self._dir.glob("*.json")]
|
|
```
|
|
|
|
**Note on `model_dump_json()`:** Pydantic v2's native JSON serialization handles UUID, datetime, and nested models automatically — no custom JSON encoder needed.
|
|
|
|
### Pattern 5: REST Route Stubs
|
|
|
|
```python
|
|
# lightsync/api/devices.py
|
|
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()
|
|
```
|
|
|
|
**Dependency injection note:** For Phase 1, passing registry via module-level state in `main.py` is acceptable (single process, no tests). Phase 2 should migrate to FastAPI's `Depends()` pattern.
|
|
|
|
### Pattern 6: WebSocket Stub
|
|
|
|
```python
|
|
# lightsync/api/ws.py
|
|
# Source: https://fastapi.tiangolo.com/advanced/websockets/
|
|
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:
|
|
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:
|
|
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)
|
|
```
|
|
|
|
**Dead connection handling:** The broadcast method catches send failures and removes dead connections. Without this, a stale connection blocks the entire broadcast.
|
|
|
|
### Pattern 7: StaticFiles Mount — Critical Ordering
|
|
|
|
```python
|
|
# API routes MUST be registered before the StaticFiles mount
|
|
# StaticFiles at "/" is a catch-all — it intercepts any path not matched above
|
|
app.include_router(shows.router, prefix="/api/shows")
|
|
app.include_router(devices.router, prefix="/api/devices")
|
|
app.include_router(ws.router)
|
|
# StaticFiles LAST
|
|
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
|
|
```
|
|
|
|
If `StaticFiles` is mounted before API routes, all `/api/*` requests return 404 from the static file handler.
|
|
|
|
### Pattern 8: Terminal Aesthetic CSS
|
|
|
|
**What:** Pure CSS with custom properties, CSS Grid for the DAW layout, no framework.
|
|
|
|
```css
|
|
/* lightsync/frontend/style.css */
|
|
/* Source: CSS-Tricks terminal aesthetic, terminal.css patterns */
|
|
|
|
:root {
|
|
--bg-primary: #0a0a0a;
|
|
--bg-panel: #0f0f0f;
|
|
--bg-panel-dark: #080808;
|
|
--border-dim: #1a4a4a; /* dimmed cyan for panel borders */
|
|
--border-bright: #00ffff; /* full cyan for active/focus */
|
|
--text-primary: #cccccc;
|
|
--text-dim: #555555;
|
|
--text-accent: #00ffff; /* cyan accent */
|
|
--text-warning: #ff6600;
|
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
|
--font-size: 13px;
|
|
--panel-gap: 1px; /* tight packing, terminal feel */
|
|
}
|
|
|
|
*, *::before, *::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--font-size);
|
|
line-height: 1.4;
|
|
height: 100vh;
|
|
overflow: hidden; /* DAW: no scroll, everything fits viewport */
|
|
}
|
|
|
|
/* DAW layout — matches D-06 through D-10 panel arrangement */
|
|
.app-shell {
|
|
display: grid;
|
|
grid-template-rows: 40px 1fr 48px; /* header, main, transport */
|
|
grid-template-columns: 280px 1fr; /* sidebar, timeline */
|
|
grid-template-areas:
|
|
"header header"
|
|
"sidebar main"
|
|
"transport transport";
|
|
height: 100vh;
|
|
gap: var(--panel-gap);
|
|
}
|
|
|
|
.header {
|
|
grid-area: header;
|
|
background: var(--bg-panel-dark);
|
|
border-bottom: 1px solid var(--border-dim);
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 16px;
|
|
gap: 24px;
|
|
}
|
|
|
|
.header-title {
|
|
color: var(--text-accent);
|
|
font-size: 16px;
|
|
letter-spacing: 0.15em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.sidebar {
|
|
grid-area: sidebar;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-right: 1px solid var(--border-dim);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.panel {
|
|
border: 1px solid var(--border-dim);
|
|
background: var(--bg-panel);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.panel-header {
|
|
background: var(--bg-panel-dark);
|
|
border-bottom: 1px solid var(--border-dim);
|
|
padding: 6px 12px;
|
|
font-size: 11px;
|
|
letter-spacing: 0.12em;
|
|
color: var(--text-accent);
|
|
text-transform: uppercase; /* D-14 */
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.panel-content {
|
|
flex: 1;
|
|
padding: 8px 12px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* Scrollbar styling — terminal feel */
|
|
::-webkit-scrollbar { width: 4px; }
|
|
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
|
::-webkit-scrollbar-thumb { background: var(--border-dim); }
|
|
|
|
.main-area {
|
|
grid-area: main;
|
|
background: var(--bg-panel-dark);
|
|
border: 1px solid var(--border-dim);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-dim);
|
|
font-size: 14px;
|
|
letter-spacing: 0.1em;
|
|
}
|
|
|
|
.transport-bar {
|
|
grid-area: transport;
|
|
background: var(--bg-panel-dark);
|
|
border-top: 1px solid var(--border-dim);
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 16px;
|
|
color: var(--text-dim);
|
|
font-size: 12px;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
/* Form elements — terminal style */
|
|
input, select, button {
|
|
background: var(--bg-panel);
|
|
border: 1px solid var(--border-dim);
|
|
color: var(--text-primary);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--font-size);
|
|
padding: 4px 8px;
|
|
outline: none;
|
|
}
|
|
|
|
button:hover {
|
|
border-color: var(--border-bright);
|
|
color: var(--text-accent);
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Status indicator */
|
|
.status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%; /* small exception — status dots are conventionally round */
|
|
background: var(--text-dim);
|
|
}
|
|
.status-dot.connected { background: #00ff88; }
|
|
.status-dot.error { background: #ff3333; }
|
|
```
|
|
|
|
**Key CSS decisions:**
|
|
- `border-radius: 0` everywhere (D-15) — `border-radius: 50%` only for status dot (functional exception)
|
|
- Grid layout for the outer shell locks all panel positions
|
|
- `overflow: hidden` on body prevents any scroll; each panel manages its own overflow
|
|
- Custom properties at `:root` level — planner should avoid hardcoding any color values
|
|
|
|
### Pattern 9: Frontend HTML Shell
|
|
|
|
```html
|
|
<!-- lightsync/frontend/index.html -->
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LIGHTSYNC</title>
|
|
<link rel="stylesheet" href="/style.css">
|
|
<!-- Google Fonts — JetBrains Mono -->
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
|
</head>
|
|
<body>
|
|
<div class="app-shell">
|
|
|
|
<header class="header">
|
|
<span class="header-title">⬛ LIGHTSYNC</span>
|
|
<div class="status-dot" id="status-dot"></div>
|
|
<span id="status-text" class="text-dim">OFFLINE</span>
|
|
<span id="show-name" class="text-dim">— no show loaded —</span>
|
|
</header>
|
|
|
|
<aside class="sidebar">
|
|
<div class="panel" style="flex: 1">
|
|
<div class="panel-header">DEVICES</div>
|
|
<div class="panel-content" id="device-list">
|
|
<!-- Populated by app.js -->
|
|
</div>
|
|
</div>
|
|
<div class="panel" style="flex: 0 0 120px">
|
|
<div class="panel-header">ANIMATIONS</div>
|
|
<div class="panel-content text-dim">[ PHASE 3+ ]</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="main-area">
|
|
<span>[ TIMELINE — PHASE 2+ ]</span>
|
|
</main>
|
|
|
|
<footer class="transport-bar">
|
|
<span>[ TRANSPORT — PHASE 2+ ]</span>
|
|
</footer>
|
|
|
|
</div>
|
|
<script src="/app.js" type="module"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
**Font fallback chain:** If the Google Fonts request fails (offline dev), the CSS variable `--font-mono` falls back to `'Fira Code'`, then `'Consolas'` (Windows default monospace), then `monospace`.
|
|
|
|
### Pattern 10: WebSocket Client Stub (JS)
|
|
|
|
```javascript
|
|
// lightsync/frontend/app.js
|
|
// Minimal WebSocket stub — Phase 2 expands protocol handling
|
|
|
|
const WS_URL = `ws://${location.host}/ws`;
|
|
|
|
class LightSyncClient {
|
|
constructor() {
|
|
this.ws = null;
|
|
this.reconnectDelay = 2000;
|
|
}
|
|
|
|
connect() {
|
|
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";
|
|
setTimeout(() => this.connect(), this.reconnectDelay);
|
|
};
|
|
|
|
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() {
|
|
const res = await fetch("/api/devices/");
|
|
const devices = await res.json();
|
|
const list = document.getElementById("device-list");
|
|
if (devices.length === 0) {
|
|
list.innerHTML = '<span class="text-dim">no devices registered</span>';
|
|
return;
|
|
}
|
|
list.innerHTML = devices.map(d =>
|
|
`<div class="device-row">
|
|
<span class="text-accent">${d.name}</span>
|
|
<span class="text-dim">${d.strip_type} / ${d.led_count} LEDs</span>
|
|
</div>`
|
|
).join("");
|
|
}
|
|
|
|
loadDevices();
|
|
```
|
|
|
|
### Anti-Patterns to Avoid
|
|
|
|
- **StaticFiles before API routes:** Mount static files last or `/api/*` paths return 404
|
|
- **Blocking file I/O in async handlers:** Always use `aiofiles` for JSON reads/writes — stdlib `open()` blocks the event loop
|
|
- **Module-level device instantiation:** Don't call `DeviceRegistry.load()` at import time — put it in lifespan
|
|
- **Pydantic v1 `.json()` and `.parse_obj()`:** These are removed in v2; use `.model_dump_json()` and `.model_validate()`
|
|
- **`@app.on_event("startup")`:** Deprecated; use the lifespan context manager
|
|
- **Hardcoded paths:** Use `Path(__file__).parent` for paths relative to the package, not hardcoded strings like `"lightsync/frontend"`
|
|
|
|
---
|
|
|
|
## Don't Hand-Roll
|
|
|
|
| Problem | Don't Build | Use Instead | Why |
|
|
|---------|-------------|-------------|-----|
|
|
| JSON validation on input | Manual type checks in route handlers | Pydantic BaseModel as route body type | FastAPI auto-validates, auto-returns 422 on bad input |
|
|
| File serving | Custom route that reads files | `StaticFiles(html=True)` | Handles 404, directory index, cache headers, MIME types |
|
|
| UUID generation | `str(random.randint(...))` | `uuid4()` as Pydantic `default_factory` | Collision-proof, standard format |
|
|
| Async file write | Threads + stdlib `open()` | `aiofiles.open()` | Non-blocking; stdlib blocks the event loop |
|
|
| WebSocket connection tracking | Raw dict with manual cleanup | `ConnectionManager` pattern (see above) | Handles disconnects, dead connection removal |
|
|
| CSS terminal font stack | Single hardcoded font | CSS custom property `--font-mono` with fallbacks | Survives font load failure on offline dev |
|
|
|
|
**Key insight:** FastAPI + Pydantic v2 handles about 60% of what you'd otherwise write by hand (validation, serialization, 422 errors, OpenAPI docs). Don't add logic that duplicates what the framework already does.
|
|
|
|
---
|
|
|
|
## Common Pitfalls
|
|
|
|
### Pitfall 1: StaticFiles Intercepts API Routes
|
|
**What goes wrong:** `/api/devices/` returns 404 from the static file handler instead of the router.
|
|
**Why it happens:** `app.mount()` is processed in definition order. If `StaticFiles` at `"/"` is mounted before `include_router()`, it matches everything.
|
|
**How to avoid:** Always mount StaticFiles after all `include_router()` calls.
|
|
**Warning signs:** All API routes return 404 with `Content-Type: text/html` (the 404.html from StaticFiles).
|
|
|
|
### Pitfall 2: Pydantic v2 Breaking Changes from v1
|
|
**What goes wrong:** Code works during local dev but breaks on a clean install because of v1/v2 API differences.
|
|
**Why it happens:** STACK.md and ARCHITECTURE.md use some v1 patterns (`.json()`, `.parse_obj()`).
|
|
**How to avoid:** Use only v2 APIs: `model_dump()`, `model_dump_json()`, `model_validate()`, `model_validate_json()`. Never call `.dict()` or `.json()`.
|
|
**Warning signs:** `AttributeError: 'ShowModel' object has no attribute 'json'`
|
|
|
|
### Pitfall 3: uvicorn Hot Reload on Windows
|
|
**What goes wrong:** `uvicorn.run(..., reload=True)` crashes with a multiprocessing error on Windows when called without `if __name__ == "__main__"` guard.
|
|
**Why it happens:** Windows uses `spawn` (not `fork`) for multiprocessing. Without the guard, the worker process re-executes the module and tries to start another server.
|
|
**How to avoid:** Always use `if __name__ == "__main__"` in `__main__.py`. Do NOT enable reload by default; document it as a dev-only flag.
|
|
**Warning signs:** `RuntimeError: This event loop is already running` or `AssertionError` on Windows startup.
|
|
|
|
### Pitfall 4: Path Separator Bugs on Windows
|
|
**What goes wrong:** Show file paths stored as `/shows/my_show.json` fail on Windows because Windows uses backslash.
|
|
**Why it happens:** Hardcoded forward slashes in path strings.
|
|
**How to avoid:** Use `pathlib.Path` everywhere. `Path("shows") / show_id` handles separators automatically. Never concatenate path strings.
|
|
**Warning signs:** `FileNotFoundError` with forward-slash paths on Windows.
|
|
|
|
### Pitfall 5: DeviceConfig id as UUID vs str
|
|
**What goes wrong:** `device_id` parameter in URL routes is typed as `str` but the registry stores keys as `str(UUID)`. Mismatch causes lookups to silently fail.
|
|
**Why it happens:** UUID `__str__` uses lowercase hex with dashes; manual construction may differ.
|
|
**How to avoid:** Store registry keys as `str(config.id)` explicitly. In route handlers, accept `device_id: str` and pass directly to `registry.get(device_id)`.
|
|
**Warning signs:** DELETE /devices/{id} returns 404 for a valid device.
|
|
|
|
### Pitfall 6: Frontend Google Fonts on Offline Dev
|
|
**What goes wrong:** The UI uses a raw system monospace font instead of JetBrains Mono during offline development.
|
|
**Why it happens:** Google Fonts CDN is unavailable without network.
|
|
**How to avoid:** This is acceptable behavior — CSS fallback chain handles it. Alternatively, bundle the font file in `lightsync/frontend/fonts/`. Document the expected behavior.
|
|
**Warning signs:** UI renders in Courier New instead of JetBrains Mono — cosmetic only, not a bug.
|
|
|
|
---
|
|
|
|
## State of the Art
|
|
|
|
| Old Approach | Current Approach | When Changed | Impact |
|
|
|--------------|------------------|--------------|--------|
|
|
| `@app.on_event("startup")` | `lifespan=` context manager | FastAPI 0.93 (2023) | Old events deprecated; lifespan is now the only supported pattern |
|
|
| `model.json()` (Pydantic v1) | `model.model_dump_json()` | Pydantic v2 (2023) | v1 methods raise AttributeError in v2 |
|
|
| `Model.parse_obj(data)` (v1) | `Model.model_validate(data)` | Pydantic v2 (2023) | Same — v1 API removed |
|
|
| `uvicorn[all]` extra | `uvicorn[standard]` extra | ~2023 | `[all]` was removed; `[standard]` is the correct WebSocket + watchfiles bundle |
|
|
|
|
**Deprecated/outdated:**
|
|
- `@app.on_event`: Deprecated since FastAPI 0.93; still works but emits deprecation warnings
|
|
- `pydantic.validator`: Replaced by `@field_validator` in v2; old decorator removed
|
|
|
|
---
|
|
|
|
## Environment Availability
|
|
|
|
> Step 2.6: The project runs on Windows 11 (primary runtime). Research machine is Linux. Environment audit is for the Windows 11 target.
|
|
|
|
| Dependency | Required By | Available (Windows 11) | Notes | Fallback |
|
|
|------------|------------|------------------------|-------|----------|
|
|
| Python 3.11 | INF-01 | Must verify | Standard install from python.org or winget | — |
|
|
| uv | Dependency mgmt | Recommended | `winget install astral-sh.uv` or `pip install uv` | pip directly |
|
|
| FastAPI + uvicorn | INF-01 | Install via uv | No OS-level dependencies | — |
|
|
| Pydantic v2 | Models | FastAPI dependency | Installed automatically | — |
|
|
| aiofiles | Show store | Install via uv | Pure Python, no OS deps | — |
|
|
| JetBrains Mono font | UI-02 | Served via Google Fonts CDN | Requires internet; fallback to system monospace | Consolas (Windows built-in) |
|
|
| Browser | UI-01 | Chrome/Edge/Firefox on Win11 | Any modern browser works | — |
|
|
|
|
**Missing dependencies with no fallback:**
|
|
- Python 3.11 must be present on the Windows 11 machine before any installation step
|
|
|
|
**Missing dependencies with fallback:**
|
|
- Google Fonts CDN: fallback to Consolas (Windows built-in monospace)
|
|
- uv: fallback to pip
|
|
|
|
**Windows-specific notes:**
|
|
- `asyncio.ProactorEventLoop` is the Windows default since Python 3.8 — no configuration needed for UDP and subprocess (Phase 2+)
|
|
- `uvicorn.run()` uses `WindowsSelectorEventLoopPolicy` internally when `--reload` is active — don't fight this
|
|
- File paths: use `pathlib.Path` throughout; `show_dir = Path("shows")` resolves relative to CWD on Windows correctly
|
|
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
1. **Device registry storage: separate `devices.json` vs embedded in each show**
|
|
- What we know: ARCHITECTURE.md says "Config in show JSON or separate devices.json"
|
|
- What's unclear: The show file embeds a device snapshot (for portability), but the registry for the UI list needs a separate source of truth
|
|
- Recommendation (Claude's discretion): Use **both** — `devices.json` is the live registry (what the UI CRUD panel manages), and each show file embeds a snapshot of the devices at the time of show creation. This is consistent with ARCHITECTURE.md's "self-contained show file" principle.
|
|
|
|
2. **Dependency injection for registry/store in route handlers**
|
|
- What we know: Module-level globals in `main.py` work for Phase 1 (single process)
|
|
- What's unclear: FastAPI's `Depends()` is cleaner but adds boilerplate for a solo project
|
|
- Recommendation: Use module-level state for Phase 1. Add a TODO comment to migrate to `Depends()` when tests are added in Phase 3.
|
|
|
|
3. **Show name vs show ID in URL routes**
|
|
- What we know: Show IDs are UUIDs (hard to type in browser), names are user-defined
|
|
- Recommendation: `/api/shows/` returns list with both; `/api/shows/{show_id}` uses UUID. Frontend always uses the UUID from the API response.
|
|
|
|
---
|
|
|
|
## Code Examples
|
|
|
|
### Full pyproject.toml
|
|
|
|
```toml
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
|
|
[project]
|
|
name = "lightsync"
|
|
version = "0.1.0"
|
|
description = "Music-to-light synchronization system"
|
|
requires-python = ">=3.11"
|
|
dependencies = [
|
|
"fastapi>=0.115.0",
|
|
"uvicorn[standard]>=0.34.0",
|
|
"aiofiles>=24.0.0",
|
|
"python-dotenv>=1.0.0",
|
|
"structlog>=24.0.0",
|
|
]
|
|
|
|
[project.optional-dependencies]
|
|
dev = [
|
|
"httpx>=0.27.0",
|
|
]
|
|
|
|
[tool.hatch.build.targets.wheel]
|
|
packages = ["lightsync"]
|
|
```
|
|
|
|
**Note:** Pydantic is not listed separately — it is pulled in as a FastAPI dependency. If a specific version pin is needed: add `"pydantic>=2.0.0"` to dependencies.
|
|
|
|
### SK6812 Device Implementation Example
|
|
|
|
```python
|
|
# lightsync/devices/sk6812.py
|
|
from lightsync.devices.base import BaseDevice
|
|
|
|
class SK6812Device(BaseDevice):
|
|
"""RGBW strip — 4 bytes per LED."""
|
|
|
|
@property
|
|
def bytes_per_pixel(self) -> int:
|
|
return 4
|
|
|
|
def encode_frame(self, pixels: list[tuple]) -> bytes:
|
|
# WLED DRGBW protocol: header [3, timeout_seconds]
|
|
payload = bytearray([3, 2])
|
|
for pixel in pixels:
|
|
r, g, b = pixel[:3]
|
|
w = pixel[3] if len(pixel) > 3 else 0
|
|
payload.extend([r, g, b, w])
|
|
return bytes(payload)
|
|
|
|
def encode_animation_cmd(self, animation: str, params: dict) -> bytes:
|
|
import json
|
|
cmd = {"type": "animation", "anim": animation, "params": params}
|
|
return json.dumps(cmd).encode("utf-8")
|
|
```
|
|
|
|
---
|
|
|
|
## Sources
|
|
|
|
### Primary (HIGH confidence)
|
|
- [FastAPI Lifespan Events](https://fastapi.tiangolo.com/advanced/events/) — lifespan context manager pattern, startup/shutdown
|
|
- [FastAPI Static Files](https://fastapi.tiangolo.com/tutorial/static-files/) — StaticFiles mounting, html=True for SPA serving
|
|
- [FastAPI WebSockets](https://fastapi.tiangolo.com/advanced/websockets/) — ConnectionManager pattern, broadcast
|
|
- [Pydantic v2 Models](https://docs.pydantic.dev/latest/concepts/models/) — BaseModel, Field, default_factory
|
|
- [Pydantic v2 Serialization](https://docs.pydantic.dev/latest/concepts/serialization/) — model_dump_json, model_validate
|
|
- [Python ABC docs](https://docs.python.org/3/library/abc.html) — abstract base class infrastructure
|
|
- [uvicorn Settings](https://uvicorn.dev/settings/) — ProactorEventLoop, Windows behavior, reload guard
|
|
|
|
### Secondary (MEDIUM confidence)
|
|
- [WebSearch: uvicorn Windows ProactorEventLoop](https://github.com/fastapi/fastapi/discussions/13549) — confirmed if __name__ == "__main__" guard required on Windows
|
|
- [terminal.css](https://terminalcss.xyz/) — terminal CSS aesthetic reference patterns
|
|
- [CSS-Tricks: Old Timey Terminal Styling](https://css-tricks.com/old-timey-terminal-styling/) — terminal CSS techniques
|
|
|
|
### Tertiary (LOW confidence — from STACK.md/ARCHITECTURE.md, accepted as project-internal decisions)
|
|
- `.planning/research/STACK.md` — version recommendations, technology decisions
|
|
- `.planning/research/ARCHITECTURE.md` — show file JSON schema, component boundaries, data flow
|
|
|
|
---
|
|
|
|
## Metadata
|
|
|
|
**Confidence breakdown:**
|
|
- Standard stack: HIGH — FastAPI/Pydantic v2/uvicorn are stable, well-documented, official docs verified
|
|
- Architecture: HIGH — patterns from official FastAPI docs; device ABC is standard Python
|
|
- CSS terminal aesthetic: MEDIUM — no official spec; based on CSS custom properties patterns + terminal.css reference
|
|
- Windows pitfalls: HIGH — uvicorn/Windows reload issue is documented in FastAPI discussions
|
|
- Pydantic v2 API: HIGH — official docs consulted, v1→v2 migration changes verified
|
|
|
|
**Research date:** 2026-04-05
|
|
**Valid until:** 2026-10-05 (stable libraries; Pydantic v2 and FastAPI 0.115.x are not fast-moving)
|