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>
394 lines
14 KiB
Python
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)
|
|
|