refactor(api): migrate server to fastapi and uvicorn
Replace the Microdot-only entrypoint with a CombinedASGI app that handles FastAPI routes (audio API, websocket, dev live-reload) while delegating the rest to Microdot. Suppress noisy /__dev/ access logs during live-reload polling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
298
src/app_factory.py
Normal file
298
src/app_factory.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""Application factory: Microdot routes and shared runtime startup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from typing import Any, Optional
|
||||
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.session import Session
|
||||
from settings import WIFI_CHANNEL_DEFAULT, get_settings
|
||||
|
||||
import controllers.preset as preset
|
||||
import controllers.profile as profile
|
||||
import controllers.group as group
|
||||
import controllers.sequence as sequence
|
||||
import controllers.zone as zone
|
||||
import controllers.palette as palette
|
||||
import controllers.scene as scene
|
||||
import controllers.pattern as pattern
|
||||
import controllers.settings as settings_controller
|
||||
import controllers.device as device_controller
|
||||
import controllers.led_tool as led_tool_controller
|
||||
import controllers.wifi_bridge as wifi_bridge_controller
|
||||
from models.transport import (
|
||||
BridgeSerialTransport,
|
||||
BridgeWsTransport,
|
||||
get_bridge,
|
||||
set_bridge,
|
||||
)
|
||||
from models.device import Device
|
||||
from models.bridge_serial_client import init_bridge_serial_client
|
||||
from models.bridge_ws_client import init_bridge_client
|
||||
from util.espnow_registry import handle_bridge_uplink
|
||||
from util.bridge_runtime import set_bridge_uplink_handler
|
||||
from util.audio_detector import AudioBeatDetector
|
||||
|
||||
|
||||
def live_reload_enabled() -> bool:
|
||||
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
|
||||
return v not in ("", "0", "false", "no")
|
||||
|
||||
|
||||
_dev_build_id: Optional[str] = None
|
||||
|
||||
|
||||
def dev_build_id() -> Optional[str]:
|
||||
global _dev_build_id
|
||||
if not live_reload_enabled():
|
||||
return None
|
||||
if _dev_build_id is None:
|
||||
_dev_build_id = secrets.token_hex(12)
|
||||
return _dev_build_id
|
||||
|
||||
|
||||
def dev_client_revision() -> Optional[str]:
|
||||
"""Revision of static/template assets (changes when UI files are saved)."""
|
||||
if not live_reload_enabled():
|
||||
return None
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
parts: list[str] = []
|
||||
for sub in ("static", "templates"):
|
||||
root = os.path.join(base, sub)
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for dirpath, _, files in os.walk(root):
|
||||
for name in sorted(files):
|
||||
if not name.endswith((".js", ".css", ".html")):
|
||||
continue
|
||||
path = os.path.join(dirpath, name)
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
parts.append(f"{path}:{st.st_mtime_ns}:{st.st_size}")
|
||||
if not parts:
|
||||
return "0"
|
||||
digest = hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
||||
return digest[:16]
|
||||
|
||||
|
||||
def create_microdot_app(*, inject_live_reload: bool = False) -> Microdot:
|
||||
"""Build the Microdot app with mounted controllers and static routes."""
|
||||
settings = get_settings()
|
||||
app = Microdot()
|
||||
|
||||
secret_key = settings.get(
|
||||
"session_secret_key", "led-controller-secret-key-change-in-production"
|
||||
)
|
||||
Session(app, secret_key=secret_key)
|
||||
|
||||
app.mount(preset.controller, "/presets")
|
||||
app.mount(profile.controller, "/profiles")
|
||||
app.mount(group.controller, "/groups")
|
||||
app.mount(sequence.controller, "/sequences")
|
||||
app.mount(zone.controller, "/zones")
|
||||
app.mount(palette.controller, "/palettes")
|
||||
app.mount(scene.controller, "/scenes")
|
||||
app.mount(pattern.controller, "/patterns")
|
||||
app.mount(settings_controller.controller, "/settings")
|
||||
app.mount(wifi_bridge_controller.controller, "/settings/wifi")
|
||||
app.mount(device_controller.controller, "/devices")
|
||||
app.mount(led_tool_controller.controller, "/led-tool")
|
||||
|
||||
build_id = dev_build_id() if inject_live_reload else None
|
||||
if build_id:
|
||||
|
||||
@app.route("/__dev/build-id")
|
||||
def dev_build_id_route(request):
|
||||
_ = request
|
||||
return (
|
||||
build_id,
|
||||
200,
|
||||
{
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
@app.route("/")
|
||||
def index(request):
|
||||
_ = request
|
||||
if build_id:
|
||||
try:
|
||||
with open("templates/index.html", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
tag = '<script src="/static/dev-live-reload.js" defer></script>'
|
||||
if "</body>" in html:
|
||||
html = html.replace("</body>", tag + "\n</body>", 1)
|
||||
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
|
||||
except OSError:
|
||||
pass
|
||||
return send_file("templates/index.html")
|
||||
|
||||
@app.route("/favicon.ico")
|
||||
def favicon(request):
|
||||
_ = request
|
||||
return "", 204
|
||||
|
||||
@app.route("/static/<path:path>")
|
||||
def static_handler(request, path):
|
||||
if ".." in path:
|
||||
return "Not found", 404
|
||||
return send_file("static/" + path)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class AppRuntime:
|
||||
"""Holds long-lived services started with the HTTP server."""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.audio_detector = AudioBeatDetector()
|
||||
self.bridge = None
|
||||
|
||||
async def startup(self, *, test_mode: bool = False) -> None:
|
||||
set_bridge_uplink_handler(handle_bridge_uplink)
|
||||
|
||||
if test_mode:
|
||||
return
|
||||
|
||||
self.bridge = get_bridge(self.settings)
|
||||
set_bridge(self.bridge)
|
||||
|
||||
bridge_mode = str(self.settings.get("bridge_transport") or "wifi").strip().lower()
|
||||
if bridge_mode == "wifi":
|
||||
ws_url = str(self.settings.get("bridge_ws_url") or "").strip()
|
||||
if ws_url:
|
||||
try:
|
||||
ch = int(self.settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
|
||||
except (TypeError, ValueError):
|
||||
ch = WIFI_CHANNEL_DEFAULT
|
||||
ws_client = init_bridge_client(ws_url, wifi_channel=ch)
|
||||
ws_client.set_uplink_handler(handle_bridge_uplink)
|
||||
ws_client.start()
|
||||
set_bridge(BridgeWsTransport())
|
||||
elif bridge_mode == "serial":
|
||||
serial_port = str(self.settings.get("bridge_serial_port") or "").strip()
|
||||
if serial_port:
|
||||
baud = 115200
|
||||
for prof in self.settings.get("bridges") or []:
|
||||
if not isinstance(prof, dict):
|
||||
continue
|
||||
if str(prof.get("transport") or "").strip().lower() != "serial":
|
||||
continue
|
||||
if str(prof.get("serial_port") or "").strip() != serial_port:
|
||||
continue
|
||||
try:
|
||||
baud = int(prof.get("serial_baudrate") or baud)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
else:
|
||||
try:
|
||||
baud = int(self.settings.get("bridge_serial_baudrate") or baud)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
serial_client = init_bridge_serial_client(serial_port, baudrate=baud)
|
||||
serial_client.set_uplink_handler(handle_bridge_uplink)
|
||||
serial_client.start()
|
||||
set_bridge(BridgeSerialTransport())
|
||||
|
||||
try:
|
||||
from util import audio_detector as audio_detector_module
|
||||
|
||||
audio_detector_module.set_shared_beat_detector(self.audio_detector)
|
||||
except Exception as e:
|
||||
print(f"[startup] audio detector shared registration skipped: {e!r}")
|
||||
|
||||
try:
|
||||
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
|
||||
|
||||
persisted = read_audio_run_state()
|
||||
if persisted.get("enabled"):
|
||||
sel = persisted.get("device_select") or persisted.get("device")
|
||||
dev = coerce_audio_device(sel)
|
||||
self.audio_detector.start(device=dev)
|
||||
print("[startup] audio beat detector started from saved run state")
|
||||
except Exception as e:
|
||||
print(f"[startup] audio auto-start skipped: {e!r}")
|
||||
|
||||
from util import beat_driver_route
|
||||
|
||||
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
seq_pb.ensure_beat_consumer_started()
|
||||
Device()
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
try:
|
||||
self.audio_detector.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
seq_pb.stop()
|
||||
for attr in ("_pending_beat_task", "_sim_beat_task"):
|
||||
t = getattr(seq_pb, attr, None)
|
||||
if t is not None and not t.done():
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def audio_status_payload(
|
||||
audio_detector: AudioBeatDetector, settings: Any
|
||||
) -> dict:
|
||||
from util import beat_driver_route
|
||||
from util import sequence_playback
|
||||
from util.audio_run_persist import read_audio_run_state
|
||||
|
||||
st = audio_detector.status()
|
||||
st["sequence"] = sequence_playback.playback_status()
|
||||
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
|
||||
seq = st.get("sequence")
|
||||
beat_readout = ""
|
||||
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
|
||||
beat_readout = str(seq.get("beat_readout") or "").strip()
|
||||
elif st.get("running"):
|
||||
mb = st.get("manual_beat_stride")
|
||||
if isinstance(mb, dict) and mb.get("active"):
|
||||
try:
|
||||
n = int(mb.get("stride_n") or 1)
|
||||
except (TypeError, ValueError):
|
||||
n = 1
|
||||
n = max(1, min(64, n))
|
||||
try:
|
||||
bi = int(mb.get("beat_in_stride") or 1)
|
||||
except (TypeError, ValueError):
|
||||
bi = 1
|
||||
pos = min(n, max(1, bi))
|
||||
beat_readout = f"{pos}/{n}"
|
||||
else:
|
||||
try:
|
||||
bs = int(st.get("beat_seq") or 0)
|
||||
except (TypeError, ValueError):
|
||||
bs = 0
|
||||
if bs > 0:
|
||||
beat_readout = str(bs)
|
||||
st["beat_readout"] = beat_readout
|
||||
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
|
||||
try:
|
||||
st["input_volume"] = int(settings.get("audio_input_volume") or 100)
|
||||
except (TypeError, ValueError):
|
||||
st["input_volume"] = 100
|
||||
st["input_volume"] = max(0, min(200, st["input_volume"]))
|
||||
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
|
||||
if seq_wait not in ("beat", "downbeat"):
|
||||
seq_wait = "beat"
|
||||
st["sequence_switch_wait"] = seq_wait
|
||||
st["audio_run"] = read_audio_run_state()
|
||||
return st
|
||||
251
src/fastapi_app.py
Normal file
251
src/fastapi_app.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""FastAPI entrypoint; Microdot controllers run behind an ASGI bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from app_factory import (
|
||||
AppRuntime,
|
||||
audio_status_payload,
|
||||
create_microdot_app,
|
||||
live_reload_enabled,
|
||||
)
|
||||
from microdot_asgi import MicrodotASGI
|
||||
from models.transport import get_current_bridge
|
||||
|
||||
|
||||
_runtime: Optional[AppRuntime] = None
|
||||
_microdot_app = None
|
||||
_test_mode = False
|
||||
|
||||
|
||||
class _SuppressDevAccessLogFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return "/__dev/" not in record.getMessage()
|
||||
|
||||
|
||||
logging.getLogger("uvicorn.access").addFilter(_SuppressDevAccessLogFilter())
|
||||
|
||||
|
||||
def _bridge():
|
||||
return get_current_bridge()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI):
|
||||
global _runtime
|
||||
_runtime = AppRuntime()
|
||||
await _runtime.startup(test_mode=_test_mode)
|
||||
if live_reload_enabled() and not _test_mode:
|
||||
print(
|
||||
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when uvicorn reloads"
|
||||
)
|
||||
yield
|
||||
await _runtime.shutdown()
|
||||
|
||||
|
||||
def _create_fastapi() -> FastAPI:
|
||||
api = FastAPI(title="LED Controller", lifespan=_lifespan)
|
||||
|
||||
@api.get("/__dev/build-id", response_class=PlainTextResponse)
|
||||
async def dev_build_id_route():
|
||||
from app_factory import dev_build_id as current_build_id
|
||||
|
||||
bid = current_build_id()
|
||||
if not bid:
|
||||
return PlainTextResponse("", status_code=404)
|
||||
return PlainTextResponse(
|
||||
bid,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@api.get("/__dev/client-rev", response_class=PlainTextResponse)
|
||||
async def dev_client_rev_route():
|
||||
from app_factory import dev_client_revision
|
||||
|
||||
rev = dev_client_revision()
|
||||
if not rev:
|
||||
return PlainTextResponse("", status_code=404)
|
||||
return PlainTextResponse(
|
||||
rev,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@api.get("/api/audio/devices")
|
||||
async def audio_devices():
|
||||
if _runtime is None:
|
||||
return JSONResponse({"error": "not ready"}, status_code=503)
|
||||
try:
|
||||
return {
|
||||
"devices": _runtime.audio_detector.list_input_devices(),
|
||||
"diagnostics": _runtime.audio_detector.diagnostics(),
|
||||
}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
@api.post("/api/audio/start")
|
||||
async def audio_start(payload: dict | None = None):
|
||||
if _runtime is None:
|
||||
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||
body = payload if isinstance(payload, dict) else {}
|
||||
device = body.get("device", None)
|
||||
if device in ("", None):
|
||||
device = None
|
||||
device_select = str(body.get("device_select") or "").strip()
|
||||
if not device_select and device not in ("", None):
|
||||
device_select = str(device).strip()
|
||||
try:
|
||||
from util.pulse_audio_devices import resolve_capture_device
|
||||
|
||||
device = resolve_capture_device(device)
|
||||
_runtime.audio_detector.start(device=device)
|
||||
from util.audio_run_persist import write_audio_run_state
|
||||
|
||||
write_audio_run_state(
|
||||
enabled=True,
|
||||
device=device,
|
||||
device_override=str(body.get("device_override") or ""),
|
||||
device_select=device_select,
|
||||
)
|
||||
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||
except Exception as e:
|
||||
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
||||
|
||||
@api.put("/api/audio/device")
|
||||
async def audio_set_device(payload: dict | None = None):
|
||||
if _runtime is None:
|
||||
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||
body = payload if isinstance(payload, dict) else {}
|
||||
device_select = str(body.get("device_select") or "").strip()
|
||||
device_override = str(body.get("device_override") or "").strip()
|
||||
raw = device_override if device_override else device_select
|
||||
device = raw if raw else None
|
||||
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
|
||||
|
||||
prev = read_audio_run_state()
|
||||
write_audio_run_state(
|
||||
enabled=bool(prev.get("enabled")),
|
||||
device=device if raw else None,
|
||||
device_override=device_override,
|
||||
device_select=device_select,
|
||||
)
|
||||
return {"ok": True, "audio_run": read_audio_run_state()}
|
||||
|
||||
@api.post("/api/audio/stop")
|
||||
async def audio_stop():
|
||||
if _runtime is None:
|
||||
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||
_runtime.audio_detector.stop()
|
||||
from util.audio_run_persist import write_audio_run_state
|
||||
|
||||
write_audio_run_state(enabled=False)
|
||||
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||
|
||||
@api.post("/api/audio/reset")
|
||||
async def audio_reset():
|
||||
if _runtime is None:
|
||||
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||
ok = _runtime.audio_detector.reset_tracking()
|
||||
if not ok:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Audio detector is not running"},
|
||||
status_code=409,
|
||||
)
|
||||
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||
|
||||
@api.post("/api/audio/anchor-bar")
|
||||
async def audio_anchor_bar():
|
||||
if _runtime is None:
|
||||
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||
ok = _runtime.audio_detector.anchor_bar_phase()
|
||||
if not ok:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Audio detector is not running"},
|
||||
status_code=409,
|
||||
)
|
||||
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||
|
||||
@api.get("/api/audio/status")
|
||||
async def audio_status():
|
||||
if _runtime is None:
|
||||
return JSONResponse({"error": "not ready"}, status_code=503)
|
||||
return {"status": audio_status_payload(_runtime.audio_detector, _runtime.settings)}
|
||||
|
||||
@api.websocket("/ws")
|
||||
async def ws_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
bridge = _bridge()
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive()
|
||||
if data.get("type") == "websocket.disconnect":
|
||||
break
|
||||
if "bytes" in data and data["bytes"] is not None:
|
||||
await bridge.send(bytes(data["bytes"]))
|
||||
continue
|
||||
text = data.get("text")
|
||||
if text is None:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
addr = parsed.pop("to", None)
|
||||
await bridge.send(parsed, addr=addr)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
try:
|
||||
await websocket.send_text(json.dumps({"error": "Send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return api
|
||||
|
||||
|
||||
class CombinedASGI:
|
||||
"""Route FastAPI-only paths first; delegate the rest to Microdot."""
|
||||
|
||||
_FASTAPI_PREFIXES = ("/api/", "/__dev/")
|
||||
|
||||
def __init__(self, fastapi_app: FastAPI, microdot_asgi: MicrodotASGI):
|
||||
self.fastapi_app = fastapi_app
|
||||
self.microdot_asgi = microdot_asgi
|
||||
|
||||
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
||||
stype = scope.get("type")
|
||||
if stype == "lifespan":
|
||||
await self.fastapi_app(scope, receive, send)
|
||||
return
|
||||
if stype == "websocket":
|
||||
if scope.get("path") == "/ws":
|
||||
await self.fastapi_app(scope, receive, send)
|
||||
return
|
||||
await send({"type": "websocket.close", "code": 1000})
|
||||
return
|
||||
if stype == "http":
|
||||
path = scope.get("path") or ""
|
||||
if path.startswith(self._FASTAPI_PREFIXES):
|
||||
await self.fastapi_app(scope, receive, send)
|
||||
return
|
||||
await self.microdot_asgi(scope, receive, send)
|
||||
|
||||
|
||||
def create_application(*, test_mode: bool = False) -> CombinedASGI:
|
||||
global _microdot_app, _test_mode
|
||||
_test_mode = test_mode
|
||||
_microdot_app = create_microdot_app(inject_live_reload=live_reload_enabled())
|
||||
fastapi_app = _create_fastapi()
|
||||
return CombinedASGI(fastapi_app, MicrodotASGI(_microdot_app))
|
||||
|
||||
|
||||
app = create_application()
|
||||
456
src/main.py
456
src/main.py
@@ -1,456 +0,0 @@
|
||||
import asyncio
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.websocket import with_websocket
|
||||
from microdot.session import Session
|
||||
from settings import WIFI_CHANNEL_DEFAULT, get_settings
|
||||
|
||||
import controllers.preset as preset
|
||||
import controllers.profile as profile
|
||||
import controllers.group as group
|
||||
import controllers.sequence as sequence
|
||||
import controllers.zone as zone
|
||||
import controllers.palette as palette
|
||||
import controllers.scene as scene
|
||||
import controllers.pattern as pattern
|
||||
import controllers.settings as settings_controller
|
||||
import controllers.device as device_controller
|
||||
import controllers.led_tool as led_tool_controller
|
||||
from models.transport import (
|
||||
get_bridge,
|
||||
set_bridge,
|
||||
get_current_bridge,
|
||||
BridgeSerialTransport,
|
||||
BridgeWsTransport,
|
||||
)
|
||||
from models.device import Device
|
||||
from models.bridge_serial_client import init_bridge_serial_client
|
||||
from models.bridge_ws_client import init_bridge_client
|
||||
from util.espnow_registry import handle_bridge_uplink
|
||||
from util.bridge_runtime import set_bridge_uplink_handler
|
||||
import controllers.wifi_bridge as wifi_bridge_controller
|
||||
from util.audio_detector import AudioBeatDetector
|
||||
|
||||
|
||||
def _live_reload_enabled() -> bool:
|
||||
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
|
||||
return v not in ("", "0", "false", "no")
|
||||
|
||||
|
||||
async def main(port=80):
|
||||
settings = get_settings()
|
||||
print(settings)
|
||||
print("Starting")
|
||||
|
||||
set_bridge_uplink_handler(handle_bridge_uplink)
|
||||
|
||||
bridge = get_bridge(settings)
|
||||
set_bridge(bridge)
|
||||
|
||||
bridge_mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
|
||||
if bridge_mode == "wifi":
|
||||
ws_url = str(settings.get("bridge_ws_url") or "").strip()
|
||||
if ws_url:
|
||||
try:
|
||||
ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
|
||||
except (TypeError, ValueError):
|
||||
ch = WIFI_CHANNEL_DEFAULT
|
||||
ws_client = init_bridge_client(ws_url, wifi_channel=ch)
|
||||
ws_client.set_uplink_handler(handle_bridge_uplink)
|
||||
ws_client.start()
|
||||
set_bridge(BridgeWsTransport())
|
||||
elif bridge_mode == "serial":
|
||||
serial_port = str(settings.get("bridge_serial_port") or "").strip()
|
||||
if serial_port:
|
||||
baud = 115200
|
||||
for prof in settings.get("bridges") or []:
|
||||
if not isinstance(prof, dict):
|
||||
continue
|
||||
if str(prof.get("transport") or "").strip().lower() != "serial":
|
||||
continue
|
||||
if str(prof.get("serial_port") or "").strip() != serial_port:
|
||||
continue
|
||||
try:
|
||||
baud = int(prof.get("serial_baudrate") or baud)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
else:
|
||||
try:
|
||||
baud = int(settings.get("bridge_serial_baudrate") or baud)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
serial_client = init_bridge_serial_client(serial_port, baudrate=baud)
|
||||
serial_client.set_uplink_handler(handle_bridge_uplink)
|
||||
serial_client.start()
|
||||
set_bridge(BridgeSerialTransport())
|
||||
|
||||
app = Microdot()
|
||||
audio_detector = AudioBeatDetector()
|
||||
try:
|
||||
from util import audio_detector as audio_detector_module
|
||||
|
||||
audio_detector_module.set_shared_beat_detector(audio_detector)
|
||||
except Exception as e:
|
||||
print(f"[startup] audio detector shared registration skipped: {e!r}")
|
||||
try:
|
||||
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
|
||||
|
||||
persisted = read_audio_run_state()
|
||||
if persisted.get("enabled"):
|
||||
sel = persisted.get("device_select") or persisted.get("device")
|
||||
dev = coerce_audio_device(sel)
|
||||
audio_detector.start(device=dev)
|
||||
print("[startup] audio beat detector started from saved run state")
|
||||
except Exception as e:
|
||||
print(f"[startup] audio auto-start skipped: {e!r}")
|
||||
from util import beat_driver_route
|
||||
|
||||
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
seq_pb.ensure_beat_consumer_started()
|
||||
|
||||
# Initialize sessions with a secret key from settings
|
||||
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
|
||||
Session(app, secret_key=secret_key)
|
||||
|
||||
# Mount model controllers as subroutes
|
||||
# Verify controllers are Microdot instances before mounting
|
||||
controllers_to_mount = [
|
||||
('/presets', preset, 'preset'),
|
||||
('/profiles', profile, 'profile'),
|
||||
('/groups', group, 'group'),
|
||||
('/sequences', sequence, 'sequence'),
|
||||
('/zones', zone, 'zone'),
|
||||
('/palettes', palette, 'palette'),
|
||||
('/scenes', scene, 'scene'),
|
||||
]
|
||||
|
||||
# Mount model controllers as subroutes
|
||||
app.mount(preset.controller, '/presets')
|
||||
app.mount(profile.controller, '/profiles')
|
||||
app.mount(group.controller, '/groups')
|
||||
app.mount(sequence.controller, '/sequences')
|
||||
app.mount(zone.controller, '/zones')
|
||||
app.mount(palette.controller, '/palettes')
|
||||
app.mount(scene.controller, '/scenes')
|
||||
app.mount(pattern.controller, '/patterns')
|
||||
app.mount(settings_controller.controller, '/settings')
|
||||
app.mount(wifi_bridge_controller.controller, '/settings/wifi')
|
||||
app.mount(device_controller.controller, '/devices')
|
||||
app.mount(led_tool_controller.controller, '/led-tool')
|
||||
|
||||
live_reload = _live_reload_enabled()
|
||||
dev_build_id = secrets.token_hex(12) if live_reload else None
|
||||
if live_reload:
|
||||
print(
|
||||
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when the server process restarts"
|
||||
)
|
||||
|
||||
if dev_build_id:
|
||||
|
||||
@app.route("/__dev/build-id")
|
||||
def dev_build_id_route(request):
|
||||
_ = request
|
||||
return (
|
||||
dev_build_id,
|
||||
200,
|
||||
{
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
||||
@app.route("/")
|
||||
def index(request):
|
||||
"""Serve the main web UI."""
|
||||
if dev_build_id:
|
||||
try:
|
||||
with open("templates/index.html", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
tag = '<script src="/static/dev-live-reload.js" defer></script>'
|
||||
if "</body>" in html:
|
||||
html = html.replace("</body>", tag + "\n</body>", 1)
|
||||
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
|
||||
except OSError:
|
||||
pass
|
||||
return send_file("templates/index.html")
|
||||
|
||||
# Favicon: avoid 404 in browser console (no file needed)
|
||||
@app.route('/favicon.ico')
|
||||
def favicon(request):
|
||||
return '', 204
|
||||
|
||||
@app.route('/api/audio/devices')
|
||||
async def audio_devices(request):
|
||||
_ = request
|
||||
try:
|
||||
return {
|
||||
"devices": audio_detector.list_input_devices(),
|
||||
"diagnostics": audio_detector.diagnostics(),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
@app.route('/api/audio/start', methods=['POST'])
|
||||
async def audio_start(request):
|
||||
payload = request.json if isinstance(request.json, dict) else {}
|
||||
device = payload.get("device", None)
|
||||
if device in ("", None):
|
||||
device = None
|
||||
device_select = str(payload.get("device_select") or "").strip()
|
||||
if not device_select and device not in ("", None):
|
||||
device_select = str(device).strip()
|
||||
try:
|
||||
from util.pulse_audio_devices import resolve_capture_device
|
||||
|
||||
device = resolve_capture_device(device)
|
||||
audio_detector.start(device=device)
|
||||
from util.audio_run_persist import write_audio_run_state
|
||||
|
||||
write_audio_run_state(
|
||||
enabled=True,
|
||||
device=device,
|
||||
device_override=str(payload.get("device_override") or ""),
|
||||
device_select=device_select,
|
||||
)
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}, 500
|
||||
|
||||
@app.route('/api/audio/device', methods=['PUT'])
|
||||
async def audio_set_device(request):
|
||||
"""Save preferred input device without toggling run state."""
|
||||
payload = request.json if isinstance(request.json, dict) else {}
|
||||
device_select = str(payload.get("device_select") or "").strip()
|
||||
device_override = str(payload.get("device_override") or "").strip()
|
||||
raw = device_override if device_override else device_select
|
||||
device = raw if raw else None
|
||||
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
|
||||
|
||||
prev = read_audio_run_state()
|
||||
write_audio_run_state(
|
||||
enabled=bool(prev.get("enabled")),
|
||||
device=device if raw else None,
|
||||
device_override=device_override,
|
||||
device_select=device_select,
|
||||
)
|
||||
return {"ok": True, "audio_run": read_audio_run_state()}
|
||||
|
||||
@app.route('/api/audio/stop', methods=['POST'])
|
||||
async def audio_stop(request):
|
||||
_ = request
|
||||
audio_detector.stop()
|
||||
from util.audio_run_persist import write_audio_run_state
|
||||
|
||||
write_audio_run_state(enabled=False)
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
|
||||
@app.route('/api/audio/reset', methods=['POST'])
|
||||
async def audio_reset(request):
|
||||
"""Clear beat/BPM tracking state without stopping the detector."""
|
||||
_ = request
|
||||
ok = audio_detector.reset_tracking()
|
||||
if not ok:
|
||||
return {"ok": False, "error": "Audio detector is not running"}, 409
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
|
||||
@app.route('/api/audio/anchor-bar', methods=['POST'])
|
||||
async def audio_anchor_bar(request):
|
||||
"""Mark the current moment as bar beat 1 (downbeat)."""
|
||||
_ = request
|
||||
ok = audio_detector.anchor_bar_phase()
|
||||
if not ok:
|
||||
return {"ok": False, "error": "Audio detector is not running"}, 409
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
|
||||
@app.route('/api/audio/status')
|
||||
async def audio_status(request):
|
||||
_ = request
|
||||
from util import beat_driver_route
|
||||
from util import sequence_playback
|
||||
|
||||
st = audio_detector.status()
|
||||
st["sequence"] = sequence_playback.playback_status()
|
||||
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
|
||||
seq = st.get("sequence")
|
||||
beat_readout = ""
|
||||
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
|
||||
beat_readout = str(seq.get("beat_readout") or "").strip()
|
||||
elif st.get("running"):
|
||||
mb = st.get("manual_beat_stride")
|
||||
if isinstance(mb, dict) and mb.get("active"):
|
||||
try:
|
||||
n = int(mb.get("stride_n") or 1)
|
||||
except (TypeError, ValueError):
|
||||
n = 1
|
||||
n = max(1, min(64, n))
|
||||
try:
|
||||
bi = int(mb.get("beat_in_stride") or 1)
|
||||
except (TypeError, ValueError):
|
||||
bi = 1
|
||||
pos = min(n, max(1, bi))
|
||||
beat_readout = f"{pos}/{n}"
|
||||
else:
|
||||
try:
|
||||
bs = int(st.get("beat_seq") or 0)
|
||||
except (TypeError, ValueError):
|
||||
bs = 0
|
||||
if bs > 0:
|
||||
beat_readout = str(bs)
|
||||
st["beat_readout"] = beat_readout
|
||||
from util.audio_run_persist import read_audio_run_state
|
||||
|
||||
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
|
||||
try:
|
||||
st["input_volume"] = int(settings.get("audio_input_volume") or 100)
|
||||
except (TypeError, ValueError):
|
||||
st["input_volume"] = 100
|
||||
st["input_volume"] = max(0, min(200, st["input_volume"]))
|
||||
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
|
||||
if seq_wait not in ("beat", "downbeat"):
|
||||
seq_wait = "beat"
|
||||
st["sequence_switch_wait"] = seq_wait
|
||||
st["audio_run"] = read_audio_run_state()
|
||||
return {"status": st}
|
||||
|
||||
# Static file route
|
||||
@app.route("/static/<path:path>")
|
||||
def static_handler(request, path):
|
||||
"""Serve static files."""
|
||||
if '..' in path:
|
||||
# Directory traversal is not allowed
|
||||
return 'Not found', 404
|
||||
return send_file('static/' + path)
|
||||
|
||||
@app.route('/ws')
|
||||
@with_websocket
|
||||
async def ws(request, ws):
|
||||
try:
|
||||
while True:
|
||||
data = await ws.receive()
|
||||
if not data:
|
||||
break
|
||||
try:
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
await bridge.send(bytes(data))
|
||||
continue
|
||||
parsed = json.loads(data)
|
||||
addr = parsed.pop("to", None)
|
||||
await bridge.send(parsed, addr=addr)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
try:
|
||||
await ws.send(json.dumps({"error": "Send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Device()
|
||||
loop = asyncio.get_running_loop()
|
||||
server_tasks: list[asyncio.Task] = []
|
||||
|
||||
def _graceful_shutdown(*_args):
|
||||
print("[server] shutting down...")
|
||||
try:
|
||||
audio_detector.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
seq_pb.stop()
|
||||
for attr in ("_pending_beat_task", "_sim_beat_task"):
|
||||
t = getattr(seq_pb, attr, None)
|
||||
if t is not None and not t.done():
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
if getattr(app, "server", None) is not None:
|
||||
try:
|
||||
app.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
for t in server_tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
|
||||
shutdown_handlers_registered = False
|
||||
try:
|
||||
try:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, _graceful_shutdown)
|
||||
shutdown_handlers_registered = True
|
||||
except (NotImplementedError, RuntimeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
server_tasks[:] = [
|
||||
asyncio.create_task(
|
||||
app.start_server(host="0.0.0.0", port=port), name="http"
|
||||
),
|
||||
]
|
||||
await asyncio.gather(*server_tasks)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except OSError as e:
|
||||
if e.errno == errno.EADDRINUSE:
|
||||
print(
|
||||
f"[server] bind failed (address already in use): {e!s}\n"
|
||||
f"[server] HTTP is configured for port {port} (env PORT). "
|
||||
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
audio_detector.stop()
|
||||
except Exception:
|
||||
pass
|
||||
srv = getattr(app, "server", None)
|
||||
if srv is not None:
|
||||
try:
|
||||
srv.close()
|
||||
await srv.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
app.server = None
|
||||
except Exception:
|
||||
pass
|
||||
for t in list(server_tasks):
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
if server_tasks:
|
||||
await asyncio.gather(*server_tasks, return_exceptions=True)
|
||||
pending = [
|
||||
t
|
||||
for t in asyncio.all_tasks(loop)
|
||||
if t is not asyncio.current_task() and not t.done()
|
||||
]
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
if shutdown_handlers_registered:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.remove_signal_handler(sig)
|
||||
except (NotImplementedError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
port = int(os.environ.get("PORT", 80))
|
||||
try:
|
||||
asyncio.run(main(port=port))
|
||||
except KeyboardInterrupt:
|
||||
print("[server] interrupted")
|
||||
84
src/microdot_asgi.py
Normal file
84
src/microdot_asgi.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""ASGI bridge for existing Microdot route handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from microdot.microdot import Microdot, NoCaseDict, Request, Response
|
||||
|
||||
|
||||
class MicrodotASGI:
|
||||
"""Dispatch HTTP requests to a :class:`Microdot` application."""
|
||||
|
||||
def __init__(self, microdot_app: Microdot):
|
||||
self.app = microdot_app
|
||||
|
||||
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
||||
if scope.get("type") != "http":
|
||||
return
|
||||
|
||||
body = b""
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] != "http.request":
|
||||
continue
|
||||
body += message.get("body", b"")
|
||||
if not message.get("more_body"):
|
||||
break
|
||||
|
||||
headers = NoCaseDict()
|
||||
for key, value in scope.get("headers", ()):
|
||||
headers[key.decode("latin-1")] = value.decode("latin-1")
|
||||
|
||||
path = scope.get("path", "/") or "/"
|
||||
query = scope.get("query_string", b"").decode("latin-1")
|
||||
url = path + (f"?{query}" if query else "")
|
||||
|
||||
client = scope.get("client") or ("127.0.0.1", 0)
|
||||
req = Request(
|
||||
self.app,
|
||||
client,
|
||||
scope.get("method", "GET"),
|
||||
url,
|
||||
"1.1",
|
||||
headers,
|
||||
body=body,
|
||||
)
|
||||
|
||||
res = await self.app.dispatch_request(req)
|
||||
if res is Response.already_handled:
|
||||
return
|
||||
await _send_microdot_response(res, send)
|
||||
|
||||
|
||||
async def _send_microdot_response(res: Response, send: Any) -> None:
|
||||
res.complete()
|
||||
headers: list[tuple[bytes, bytes]] = []
|
||||
for header, value in res.headers.items():
|
||||
values = value if isinstance(value, list) else [value]
|
||||
for item in values:
|
||||
headers.append(
|
||||
(header.lower().encode("latin-1"), str(item).encode("latin-1"))
|
||||
)
|
||||
|
||||
body = res.body
|
||||
if isinstance(body, str):
|
||||
payload = body.encode()
|
||||
elif isinstance(body, bytes):
|
||||
payload = body
|
||||
else:
|
||||
parts: list[bytes] = []
|
||||
async for chunk in res.body_iter():
|
||||
if isinstance(chunk, str):
|
||||
chunk = chunk.encode()
|
||||
parts.append(chunk)
|
||||
payload = b"".join(parts)
|
||||
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": res.status_code,
|
||||
"headers": headers,
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": payload})
|
||||
@@ -1,25 +1,43 @@
|
||||
/* Polls server build id; full reload when watchfiles restarts Python (new process = new id). */
|
||||
/* Reload when uvicorn restarts (build-id) or static/template files change (client-rev). */
|
||||
(function () {
|
||||
var prev = null;
|
||||
function tick() {
|
||||
fetch('/__dev/build-id', { cache: 'no-store', credentials: 'same-origin' })
|
||||
var prevBuild = null;
|
||||
var prevRev = null;
|
||||
|
||||
function fetchText(url) {
|
||||
return fetch(url, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
return r.ok ? r.text() : '';
|
||||
})
|
||||
.then(function (id) {
|
||||
id = (id || '').trim();
|
||||
if (!id) return;
|
||||
if (prev === null) {
|
||||
prev = id;
|
||||
return;
|
||||
}
|
||||
if (id !== prev) {
|
||||
prev = id;
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
.catch(function () {
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
function tick() {
|
||||
Promise.all([
|
||||
fetchText('/__dev/build-id'),
|
||||
fetchText('/__dev/client-rev'),
|
||||
]).then(function (parts) {
|
||||
var buildId = (parts[0] || '').trim();
|
||||
var clientRev = (parts[1] || '').trim();
|
||||
if (!buildId && !clientRev) return;
|
||||
|
||||
if (prevBuild === null && prevRev === null) {
|
||||
prevBuild = buildId;
|
||||
prevRev = clientRev;
|
||||
return;
|
||||
}
|
||||
|
||||
var buildChanged = buildId && buildId !== prevBuild;
|
||||
var revChanged = clientRev && clientRev !== prevRev;
|
||||
if (buildChanged || revChanged) {
|
||||
if (buildId) prevBuild = buildId;
|
||||
if (clientRev) prevRev = clientRev;
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(tick, 750);
|
||||
tick();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user