Files
led-controller/src/controllers/preset.py
Jimmy ace5770b3a 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>
2026-06-11 22:55:28 +12:00

394 lines
14 KiB
Python

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
from models.device import Device, normalize_mac
from models.transport import get_current_bridge
from util.driver_delivery import (
build_preset_json_chunks,
deliver_json_messages,
)
from util.espnow_message import build_message, build_preset_dict
from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json
router = APIRouter()
presets = Preset()
profiles = Profile()
def _palette_colors_for_profile(profile_id):
prof = profiles.read(str(profile_id))
if not isinstance(prof, dict):
return None
pid = prof.get("palette_id") or prof.get("paletteId")
if not pid:
return None
cols = Palette().read(str(pid))
return cols if isinstance(cols, list) else None
def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list()
session_profile = None
if session is not None:
session_profile = session.get('current_profile')
if session_profile and session_profile in profile_list:
return session_profile
if profile_list:
return profile_list[0]
return None
@router.get("/")
@with_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 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 J(scoped, 200)
@router.get("/{preset_id}/export")
@with_session
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 J({"error": "Preset not found"}, 404)
try:
bundle = export_preset_bundle(preset_id, presets)
return J(bundle, 200)
except ValueError as e:
return J({"error": str(e)}, 404)
@router.post("/import")
@with_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 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 J({"error": "Expected JSON bundle"}, 400)
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
return J({new_id: preset_data}, 201)
except ValueError as e:
return J({"error": str(e)}, 400)
except Exception as e:
return J({"error": str(e)}, 400)
@router.get("/{preset_id}")
@with_session
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 J(preset, 200)
return J({"error": "Preset not found"}, 404)
@router.post("/")
@with_session
async def create_preset(request: Request, session):
"""Create a new preset for the current profile."""
try:
try:
data = await read_json(request)
except Exception:
return J({"error": "Invalid JSON"}, 400)
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return J({"error": "No profile available"}, 404)
preset_id = presets.create(current_profile_id)
if not isinstance(data, dict):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if presets.update(preset_id, data):
preset_data = presets.read(preset_id)
return J({preset_id: preset_data}, 201)
return J({"error": "Failed to create preset"}, 400)
except Exception as e:
return J({"error": str(e)}, 400)
@router.put("/{preset_id}")
@with_session
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 J({"error": "Preset not found"}, 404)
try:
data = await read_json(request)
except Exception:
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 J(presets.read(preset_id), 200)
return J({"error": "Preset not found"}, 404)
except Exception as e:
return J({"error": str(e)}, 400)
@router.delete("/{preset_id}")
@with_session
async def delete_preset(request: Request, session, preset_id):
"""Delete a preset (current profile only)."""
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 J({"error": "Preset not found"}, 404)
if presets.delete(preset_id):
return J({"message": "Preset deleted successfully"}, 200)
return J({"error": "Preset not found"}, 404)
@router.post("/send")
@with_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).
Body JSON:
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
Optional "targets": ["aabbccddeeff", ...] — registry MACs. When set: preset
chunks are ESP-NOW broadcast once each; Wi-Fi drivers get the same chunks
over TCP; if "default" is set, each target then gets a unicast default
message (serial or TCP) with that device name in "targets".
Omit targets for broadcast-only serial (legacy).
Optional "destination_mac" / "to": single MAC when targets is omitted.
"""
try:
data = await read_json(request)
except Exception:
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 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')
destination_mac = data.get('destination_mac') or data.get('to')
# Build API-compliant preset map keyed by preset ID, include name
current_profile_id = get_current_profile_id(session)
palette_colors = _palette_colors_for_profile(current_profile_id)
presets_by_name = {}
for pid in preset_ids:
preset_data = presets.read(str(pid))
if not preset_data:
continue
if str(preset_data.get("profile_id")) != str(current_profile_id):
continue
preset_key = str(pid)
preset_payload = build_preset_dict(preset_data, palette_colors)
preset_payload["name"] = preset_data.get("name", "")
presets_by_name[preset_key] = preset_payload
if not presets_by_name:
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 J({"error": "Transport not configured"}, 503)
send_delay_s = 0.1
total_presets = len(presets_by_name)
chunk_messages = build_preset_json_chunks(
presets_by_name,
save=save_flag,
default=str(default_id) if default_id is not None else None,
)
target_list = None
raw_targets = data.get("targets")
if isinstance(raw_targets, list) and raw_targets:
target_list = []
for t in raw_targets:
m = normalize_mac(str(t))
if m:
target_list.append(m)
target_list = list(dict.fromkeys(target_list))
if not target_list:
target_list = None
elif destination_mac:
dm = normalize_mac(str(destination_mac))
target_list = [dm] if dm else None
group_ids = data.get("group_ids") or data.get("groups")
if isinstance(group_ids, list):
group_ids = [str(g).strip() for g in group_ids if str(g).strip()]
else:
group_ids = None
unicast = bool(data.get("unicast")) or bool(destination_mac)
try:
if unicast and target_list:
deliveries = 0
for msg in chunk_messages:
d, _chunks = await deliver_json_messages(
bridge, [msg],
target_list,
Device(),
delay_s=send_delay_s,
unicast=True,
)
deliveries += d
if default_id is not None:
def_msg = json.dumps(
{"v": "1", "default": str(default_id), "save": True},
separators=(",", ":"),
)
d, _chunks = await deliver_json_messages(
bridge,
[def_msg],
target_list,
Device(),
delay_s=send_delay_s,
unicast=True,
)
deliveries += d
else:
wire_messages = []
for msg in chunk_messages:
body = json.loads(msg)
if group_ids:
body["groups"] = list(group_ids)
wire_messages.append(json.dumps(body, separators=(",", ":")))
deliveries, _chunks = await deliver_json_messages(
bridge,
wire_messages,
None,
Device(),
delay_s=send_delay_s,
)
except Exception:
return J({"error": "Send failed"}, 503)
return J({
"message": "Presets sent",
"presets_sent": total_presets,
"messages_sent": deliveries,
}, 200)
@router.post("/push")
@with_session
async def push_driver_messages(request: Request, session):
"""
Deliver one or more raw v1 JSON objects to devices (ESP-NOW and/or TCP).
Body:
{"sequence": [{ "v": "1", ... }, ...], "targets": ["mac", ...]}
or a single {"payload": {...}, "targets": [...]}.
"""
try:
data = await read_json(request)
except Exception:
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 J({"error": "sequence or payload required"}, 400)
raw_targets = data.get("targets")
target_list = None
if isinstance(raw_targets, list) and raw_targets:
target_list = []
for t in raw_targets:
m = normalize_mac(str(t))
if m:
target_list.append(m)
target_list = list(dict.fromkeys(target_list))
if not target_list:
target_list = None
bridge = get_current_bridge()
if not bridge:
return J({"error": "Transport not configured"}, 503)
messages = []
i = 0
while i < len(seq):
item = seq[i]
if not isinstance(item, dict):
if isinstance(item, str):
messages.append(item)
i += 1
continue
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)
and "presets" in item
and "select" not in item
and "select" in nxt
and "presets" not in nxt
):
combined = dict(item)
combined["select"] = nxt["select"]
combined_str = json.dumps(combined, separators=(",", ":"))
if len(combined_str.encode("utf-8")) <= 248:
messages.append(combined_str)
i += 2
continue
messages.append(json.dumps(item, separators=(",", ":")))
i += 1
delay_s = data.get("delay_s", 0.05)
try:
delay_s = float(delay_s)
except (TypeError, ValueError):
delay_s = 0.05
unicast = bool(data.get("unicast"))
try:
deliveries, _chunks = await deliver_json_messages(
bridge,
messages,
target_list,
Device(),
delay_s=delay_s,
unicast=unicast,
)
except Exception:
return J({"error": "Send failed"}, 503)
try:
from util import sequence_playback as seq_pb
from util.beat_driver_route import sync_beat_route_from_push_sequence
preserve = bool(seq_pb.playback_status().get("active"))
sync_beat_route_from_push_sequence(
seq, target_macs=target_list, preserve_parallel_lane_routes=preserve
)
except Exception:
pass
return J({
"message": "Delivered",
"deliveries": deliveries,
}, 200)