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)