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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user