Add portal web simulator, SPI bridges, and Pico firmware updates.
Bring the five-panel hex portal online with a browser 3D/schematic preview, Pi SPI backends, and renamed multi-panel Pico UDP firmware. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
145
leds/portal_web.py
Normal file
145
leds/portal_web.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""FastAPI app for the portal web simulator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from leds.animations import DEFAULT_FPS
|
||||
from leds.portal_sim import portal_config, render_portal_frame
|
||||
|
||||
WEB_DIR = Path(__file__).resolve().parent.parent / "web"
|
||||
|
||||
_reload_queues: list[asyncio.Queue[str]] = []
|
||||
|
||||
|
||||
def _dev_reload_enabled() -> bool:
|
||||
return os.environ.get("PORTAL_DEV_RELOAD", "0") == "1"
|
||||
|
||||
|
||||
async def _notify_reload_clients() -> None:
|
||||
for queue in list(_reload_queues):
|
||||
await queue.put("reload")
|
||||
|
||||
|
||||
async def _watch_web_files() -> None:
|
||||
from watchfiles import awatch
|
||||
|
||||
async for _changes in awatch(WEB_DIR):
|
||||
await _notify_reload_clients()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app: FastAPI):
|
||||
watcher: asyncio.Task | None = None
|
||||
if _dev_reload_enabled():
|
||||
watcher = asyncio.create_task(_watch_web_files())
|
||||
yield
|
||||
if watcher is not None:
|
||||
watcher.cancel()
|
||||
try:
|
||||
await watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="Portal Simulator", lifespan=_lifespan)
|
||||
|
||||
@app.get("/")
|
||||
async def index() -> HTMLResponse:
|
||||
html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
|
||||
if _dev_reload_enabled():
|
||||
snippet = '<script type="module" src="/static/js/dev-reload.js"></script>'
|
||||
html = html.replace("</body>", f" {snippet}\n </body>")
|
||||
return HTMLResponse(html)
|
||||
|
||||
@app.get("/api/config")
|
||||
async def api_config() -> dict:
|
||||
return portal_config()
|
||||
|
||||
@app.get("/api/frame")
|
||||
async def api_frame(
|
||||
animation: str = Query("rainbow"),
|
||||
frame: int = Query(0),
|
||||
brightness: float = Query(0.35),
|
||||
) -> dict:
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
render_portal_frame, animation, frame, brightness=brightness
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/stream")
|
||||
async def api_stream(
|
||||
animation: str = Query("rainbow"),
|
||||
brightness: float = Query(0.35),
|
||||
fps: float = Query(None),
|
||||
) -> StreamingResponse:
|
||||
rate = fps if fps is not None else DEFAULT_FPS.get(animation, 25)
|
||||
rate = max(1.0, min(rate, 60.0))
|
||||
delay = 1.0 / rate
|
||||
|
||||
async def generate() -> object:
|
||||
frame = 0
|
||||
while True:
|
||||
payload = await asyncio.to_thread(
|
||||
render_portal_frame,
|
||||
animation,
|
||||
frame,
|
||||
brightness=brightness,
|
||||
)
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
frame += 1
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
@app.get("/api/dev/reload")
|
||||
async def api_dev_reload() -> StreamingResponse:
|
||||
if not _dev_reload_enabled():
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
queue: asyncio.Queue[str] = asyncio.Queue()
|
||||
_reload_queues.append(queue)
|
||||
|
||||
async def generate() -> object:
|
||||
try:
|
||||
yield "data: connected\n\n"
|
||||
while True:
|
||||
message = await queue.get()
|
||||
yield f"data: {message}\n\n"
|
||||
finally:
|
||||
if queue in _reload_queues:
|
||||
_reload_queues.remove(queue)
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon() -> FileResponse:
|
||||
path = WEB_DIR / "favicon.ico"
|
||||
if path.is_file():
|
||||
return FileResponse(path)
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
Reference in New Issue
Block a user