refactor(api): complete fastapi migration and related features

Finish native FastAPI controllers, drop vendored microdot, and add
Wi-Fi driver runtime, beat SSE, simulated BPM, sequence playback
improvements, bridge ESP-NOW sources, UI updates, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 22:55:28 +12:00
parent cb9758b97b
commit ace5770b3a
73 changed files with 4540 additions and 4487 deletions

View File

@@ -1,23 +1,18 @@
"""Audio input device_select persistence (Pulse name must survive start)."""
import asyncio
import json
import os
import sys
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import requests
from fastapi import FastAPI
from starlette.testclient import TestClient
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src"
if str(SRC_PATH) not in sys.path:
sys.path.insert(0, str(SRC_PATH))
from microdot import Microdot # noqa: E402
from util.audio_run_persist import read_audio_run_state, write_audio_run_state # noqa: E402
SNOWBALL = (
@@ -25,28 +20,6 @@ SNOWBALL = (
)
def _start_app(app: Microdot, port: int = 0):
def runner():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.start_server(host="127.0.0.1", port=port))
finally:
loop.close()
thread = threading.Thread(target=runner, daemon=True)
thread.start()
deadline = time.time() + 5.0
while time.time() < deadline:
server = getattr(app, "server", None)
if server and getattr(server, "sockets", None):
sockets = server.sockets or []
if sockets:
return thread, sockets[0].getsockname()[1]
time.sleep(0.05)
raise RuntimeError("server failed to start")
@pytest.fixture
def audio_run_path(tmp_path, monkeypatch):
path = tmp_path / "audio_run.json"
@@ -69,12 +42,12 @@ def test_write_start_keeps_pulse_device_select_not_portaudio_index(audio_run_pat
def test_put_device_saves_pulse_name(audio_run_path):
app = Microdot()
api = FastAPI()
@app.route("/api/audio/device", methods=["PUT"])
async def audio_set_device(request):
payload = request.json if isinstance(request.json, dict) else {}
device_select = str(payload.get("device_select") or "").strip()
@api.put("/api/audio/device")
async def audio_set_device(payload: dict | None = None):
body = payload if isinstance(payload, dict) else {}
device_select = str(body.get("device_select") or "").strip()
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
prev = read_audio_run_state()
@@ -86,13 +59,11 @@ def test_put_device_saves_pulse_name(audio_run_path):
)
return {"ok": True, "audio_run": read_audio_run_state()}
_, port = _start_app(app)
base = f"http://127.0.0.1:{port}"
resp = requests.put(
f"{base}/api/audio/device",
json={"device_select": SNOWBALL, "device_override": ""},
timeout=5,
)
with TestClient(api) as client:
resp = client.put(
"/api/audio/device",
json={"device_select": SNOWBALL, "device_override": ""},
)
assert resp.status_code == 200
body = resp.json()
assert body["audio_run"]["device_select"] == SNOWBALL
@@ -112,15 +83,15 @@ def test_start_preserves_device_select_in_status(audio_run_path, monkeypatch):
fake_resolve,
)
app = Microdot()
api = FastAPI()
@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)
@api.post("/api/audio/start")
async def audio_start(payload: dict | None = None):
body = payload if isinstance(payload, dict) else {}
device = body.get("device", None)
if device in ("", None):
device = None
device_select = str(payload.get("device_select") or "").strip()
device_select = str(body.get("device_select") or "").strip()
if not device_select and device not in ("", None):
device_select = str(device).strip()
from util.pulse_audio_devices import resolve_capture_device
@@ -138,29 +109,26 @@ def test_start_preserves_device_select_in_status(audio_run_path, monkeypatch):
st["audio_run"] = read_audio_run_state()
return {"ok": True, "status": st}
@app.route("/api/audio/status")
async def audio_status(request):
_ = request
@api.get("/api/audio/status")
async def audio_status():
from util.audio_run_persist import read_audio_run_state
st = detector.status()
st["audio_run"] = read_audio_run_state()
return {"status": st}
_, port = _start_app(app)
base = f"http://127.0.0.1:{port}"
start = requests.post(
f"{base}/api/audio/start",
json={"device": SNOWBALL, "device_select": SNOWBALL, "device_override": ""},
timeout=5,
)
assert start.status_code == 200, start.text
run = start.json()["status"]["audio_run"]
assert run["device_select"] == SNOWBALL
assert run["device"] == 2
with TestClient(api) as client:
start = client.post(
"/api/audio/start",
json={"device": SNOWBALL, "device_select": SNOWBALL, "device_override": ""},
)
assert start.status_code == 200, start.text
run = start.json()["status"]["audio_run"]
assert run["device_select"] == SNOWBALL
assert run["device"] == 2
status = requests.get(f"{base}/api/audio/status", timeout=5).json()["status"]
assert status["audio_run"]["device_select"] == SNOWBALL
status = client.get("/api/audio/status").json()["status"]
assert status["audio_run"]["device_select"] == SNOWBALL
def test_pulse_device_list_uses_stable_pulse_ids():