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,5 +1,7 @@
from microdot import Microdot
from microdot.session import with_session
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.preset import Preset
from models.profile import Profile
from models.pallet import Palette
@@ -13,7 +15,7 @@ from util.espnow_message import build_message, build_preset_dict
from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json
controller = Microdot()
router = APIRouter()
presets = Preset()
profiles = Profile()
@@ -41,76 +43,75 @@ def get_current_profile_id(session=None):
return profile_list[0]
return None
@controller.get('')
@router.get("/")
@with_session
async def list_presets(request, session):
async def list_presets(request: Request, session):
"""List presets for the current profile."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({}), 200, {'Content-Type': 'application/json'}
return J({}, 200)
scoped = {
pid: pdata for pid, pdata in presets.items()
if isinstance(pdata, dict) and str(pdata.get("profile_id")) == str(current_profile_id)
}
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
return J(scoped, 200)
@controller.get('/<preset_id>/export')
@router.get("/{preset_id}/export")
@with_session
async def export_preset(request, session, preset_id):
async def export_preset(request: Request, session, preset_id):
"""Export one preset as a JSON bundle."""
current_profile_id = get_current_profile_id(session)
preset = presets.read(preset_id)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'}
return J({"error": "Preset not found"}, 404)
try:
bundle = export_preset_bundle(preset_id, presets)
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
return J(bundle, 200)
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
return J({"error": str(e)}, 404)
@controller.post('/import')
@router.post("/import")
@with_session
async def import_preset(request, session):
async def import_preset(request: Request, session):
"""Import a preset bundle into the current profile."""
try:
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'}
body = request.json or {}
return J({"error": "No profile available"}, 404)
body = await read_json(request)
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Expected JSON bundle"}, 400)
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'}
return J({new_id: preset_data}, 201)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
return J({"error": str(e)}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
return J({"error": str(e)}, 400)
@controller.get('/<preset_id>')
@router.get("/{preset_id}")
@with_session
async def get_preset(request, session, preset_id):
async def get_preset(request: Request, session, preset_id):
"""Get a specific preset by ID (current profile only)."""
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if preset and str(preset.get("profile_id")) == str(current_profile_id):
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404
@controller.post('')
return J(preset, 200)
return J({"error": "Preset not found"}, 404)
@router.post("/")
@with_session
async def create_preset(request, session):
async def create_preset(request: Request, session):
"""Create a new preset for the current profile."""
try:
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404
return J({"error": "No profile available"}, 404)
preset_id = presets.create(current_profile_id)
if not isinstance(data, dict):
data = {}
@@ -118,65 +119,46 @@ async def create_preset(request, session):
data["profile_id"] = str(current_profile_id)
if presets.update(preset_id, data):
preset_data = presets.read(preset_id)
return json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'}
return json.dumps({"error": "Failed to create preset"}), 400
return J({preset_id: preset_data}, 201)
return J({"error": "Failed to create preset"}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<preset_id>')
return J({"error": str(e)}, 400)
@router.put("/{preset_id}")
@with_session
async def update_preset(request, session, preset_id):
async def update_preset(request: Request, session, preset_id):
"""Update an existing preset (current profile only)."""
try:
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404
return J({"error": "Preset not found"}, 404)
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
if not isinstance(data, dict):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if presets.update(preset_id, data):
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404
return J(presets.read(preset_id), 200)
return J({"error": "Preset not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<preset_id>')
return J({"error": str(e)}, 400)
@router.delete("/{preset_id}")
@with_session
async def delete_preset(request, *args, **kwargs):
async def delete_preset(request: Request, session, preset_id):
"""Delete a preset (current profile only)."""
# Be tolerant of wrapper/arg-order variations.
session = None
preset_id = None
if len(args) > 0:
session = args[0]
if len(args) > 1:
preset_id = args[1]
if 'session' in kwargs and kwargs.get('session') is not None:
session = kwargs.get('session')
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
preset_id = kwargs.get('preset_id')
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
preset_id = kwargs.get('id')
if preset_id is None:
return json.dumps({"error": "Preset ID is required"}), 400
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404
return J({"error": "Preset not found"}, 404)
if presets.delete(preset_id):
return json.dumps({"message": "Preset deleted successfully"}), 200
return json.dumps({"error": "Preset not found"}), 404
@controller.post('/send')
return J({"message": "Preset deleted successfully"}, 200)
return J({"error": "Preset not found"}, 404)
@router.post("/send")
@with_session
async def send_presets(request, session):
async def send_presets(request: Request, session):
"""
Send one or more presets to LED drivers (serial/ESP-NOW and/or TCP Wi-Fi clients).
@@ -191,13 +173,12 @@ async def send_presets(request, session):
Optional "destination_mac" / "to": single MAC when targets is omitted.
"""
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
preset_ids = data.get('preset_ids') or data.get('ids')
if not isinstance(preset_ids, list) or not preset_ids:
return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'}
return J({"error": "preset_ids must be a non-empty list"}, 400)
save_flag = data.get('save', True)
save_flag = bool(save_flag)
default_id = data.get('default')
@@ -219,14 +200,14 @@ async def send_presets(request, session):
presets_by_name[preset_key] = preset_payload
if not presets_by_name:
return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'}
return J({"error": "No matching presets found"}, 404)
if default_id is not None and str(default_id) not in presets_by_name:
default_id = None
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
return J({"error": "Transport not configured"}, 503)
send_delay_s = 0.1
total_presets = len(presets_by_name)
@@ -300,18 +281,18 @@ async def send_presets(request, session):
delay_s=send_delay_s,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return J({"error": "Send failed"}, 503)
return json.dumps({
return J({
"message": "Presets sent",
"presets_sent": total_presets,
"messages_sent": deliveries,
}), 200, {'Content-Type': 'application/json'}
}, 200)
@controller.post('/push')
@router.post("/push")
@with_session
async def push_driver_messages(request, session):
async def push_driver_messages(request: Request, session):
"""
Deliver one or more raw v1 JSON objects to devices (ESP-NOW and/or TCP).
@@ -320,15 +301,15 @@ async def push_driver_messages(request, session):
or a single {"payload": {...}, "targets": [...]}.
"""
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
seq = data.get("sequence")
if not seq and data.get("payload") is not None:
seq = [data["payload"]]
if not isinstance(seq, list) or not seq:
return json.dumps({"error": "sequence or payload required"}), 400, {'Content-Type': 'application/json'}
return J({"error": "sequence or payload required"}, 400)
raw_targets = data.get("targets")
target_list = None
@@ -344,7 +325,7 @@ async def push_driver_messages(request, session):
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
return J({"error": "Transport not configured"}, 503)
messages = []
i = 0
@@ -355,7 +336,7 @@ async def push_driver_messages(request, session):
messages.append(item)
i += 1
continue
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
return J({"error": "sequence items must be objects or strings"}, 400)
nxt = seq[i + 1] if i + 1 < len(seq) else None
if (
isinstance(nxt, dict)
@@ -392,7 +373,7 @@ async def push_driver_messages(request, session):
unicast=unicast,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return J({"error": "Send failed"}, 503)
try:
from util import sequence_playback as seq_pb
@@ -405,8 +386,8 @@ async def push_driver_messages(request, session):
except Exception:
pass
return json.dumps({
return J({
"message": "Delivered",
"deliveries": deliveries,
}), 200, {'Content-Type': 'application/json'}
}, 200)