from microdot import Microdot from microdot.session import with_session from models.profile import Profile from models.zone import Zone from models.preset import Preset import json controller = Microdot() profiles = Profile() zones = Zone() presets = Preset() @controller.get('') @with_session async def list_profiles(request, session): """List all profiles with current profile info.""" profile_list = profiles.list() current_id = session.get('current_profile') if current_id and current_id not in profile_list: current_id = None # If no current profile in session, use first one if not current_id and profile_list: current_id = profile_list[0] session['current_profile'] = str(current_id) session.save() # Build profiles object profiles_data = {} for profile_id in profile_list: profile_data = profiles.read(profile_id) if profile_data: profiles_data[profile_id] = profile_data return json.dumps({ "profiles": profiles_data, "current_profile_id": current_id }), 200, {'Content-Type': 'application/json'} @controller.get('/current') @with_session async def get_current_profile(request, session): """Get the current profile ID from session (or fallback).""" profile_list = profiles.list() current_id = session.get('current_profile') if current_id and current_id not in profile_list: current_id = None if not current_id and profile_list: current_id = profile_list[0] session['current_profile'] = str(current_id) session.save() if current_id: profile = profiles.read(current_id) return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'} return json.dumps({"error": "No profile available"}), 404 @controller.get('/') @with_session async def get_profile(request, id, session): """Get a specific profile by ID.""" # Handle 'current' as a special case if id == 'current': return await get_current_profile(request, session) profile = profiles.read(id) if profile: return json.dumps(profile), 200, {'Content-Type': 'application/json'} return json.dumps({"error": "Profile not found"}), 404 @controller.post('//apply') @with_session async def apply_profile(request, session, id): """Apply a profile by saving it to session.""" if not profiles.read(id): return json.dumps({"error": "Profile not found"}), 404 session['current_profile'] = str(id) session.save() return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'} @controller.post('') async def create_profile(request): """Create a new profile.""" try: data = dict(request.json or {}) name = data.get("name", "") seed_raw = data.get("seed_dj_zone", False) if isinstance(seed_raw, str): seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on") else: seed_dj_zone = bool(seed_raw) # Request-only flag: do not persist on profile records. data.pop("seed_dj_zone", None) profile_id = profiles.create(name) # Avoid persisting request-only fields. data.pop("name", None) if data: profiles.update(profile_id, data) # New profiles always start with a default zone pre-populated with starter presets. default_preset_ids = [] default_preset_defs = [ { "name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": True, }, { "name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": True, }, { "name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": True, "n1": 2, }, { "name": "Colour Cycle", "pattern": "colour_cycle", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 100, "auto": True, "n1": 1, }, { "name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 500, "auto": True, }, { "name": "flicker", "pattern": "flicker", "colors": ["#FFB84D"], "brightness": 255, "delay": 80, "auto": True, "n1": 30, }, { "name": "flame", "pattern": "flame", "colors": [], "brightness": 255, "delay": 50, "auto": True, "n1": 35, "n2": 2600, "n3": 0, "n4": 0, }, { "name": "twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 255, "delay": 55, "auto": True, "n1": 72, "n2": 140, "n3": 2, "n4": 6, }, ] for preset_data in default_preset_defs: pid = presets.create(profile_id) presets.update(pid, preset_data) default_preset_ids.append(str(pid)) default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids]) zones.update(default_tab_id, { "presets_flat": default_preset_ids, "default_preset": default_preset_ids[0] if default_preset_ids else None, }) profile = profiles.read(profile_id) or {} profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else [] profile_tabs.append(str(default_tab_id)) if seed_dj_zone: # Seed a DJ-focused zone with three starter presets. seeded_preset_ids = [] preset_defs = [ { "name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, }, { "name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, }, { "name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, }, ] for preset_data in preset_defs: pid = presets.create(profile_id) presets.update(pid, preset_data) seeded_preset_ids.append(str(pid)) dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids]) zones.update(dj_tab_id, { "presets_flat": seeded_preset_ids, "default_preset": seeded_preset_ids[0] if seeded_preset_ids else None, }) profile_tabs.append(str(dj_tab_id)) profiles.update(profile_id, {"zones": profile_tabs}) profile_data = profiles.read(profile_id) return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'} except Exception as e: return json.dumps({"error": str(e)}), 400 @controller.post('//clone') async def clone_profile(request, id): """Clone an existing profile along with its tabs and palette.""" try: source = profiles.read(id) if not source: return json.dumps({"error": "Profile not found"}), 404 data = request.json or {} source_name = source.get("name") or f"Profile {id}" new_name = data.get("name") or source_name profile_type = source.get("type", "zones") def allocate_id(model, cache): if "next" not in cache: max_id = max((int(k) for k in model.keys() if str(k).isdigit()), default=0) cache["next"] = max_id + 1 next_id = str(cache["next"]) cache["next"] += 1 return next_id def map_preset_container(value, id_map, preset_cache, new_profile_id, new_presets): if isinstance(value, list): return [map_preset_container(v, id_map, preset_cache, new_profile_id, new_presets) for v in value] if value is None: return None preset_id = str(value) if preset_id in id_map: return id_map[preset_id] preset_data = presets.read(preset_id) if not preset_data: return None new_preset_id = allocate_id(presets, preset_cache) clone_data = dict(preset_data) clone_data["profile_id"] = str(new_profile_id) new_presets[new_preset_id] = clone_data id_map[preset_id] = new_preset_id return new_preset_id # Prepare new IDs without writing until everything is ready. profile_cache = {} palette_cache = {} tab_cache = {} preset_cache = {} new_profile_id = allocate_id(profiles, profile_cache) new_palette_id = allocate_id(profiles._palette_model, palette_cache) # Clone palette colors into the new profile's palette src_palette_id = source.get("palette_id") palette_colors = [] if src_palette_id: try: palette_colors = profiles._palette_model.read(src_palette_id) except Exception: palette_colors = [] # Clone tabs and presets used by those tabs source_tabs = source.get("zones") if not isinstance(source_tabs, list) or len(source_tabs) == 0: source_tabs = source.get("zone_order", []) source_tabs = source_tabs or [] cloned_tab_ids = [] preset_id_map = {} new_tabs = {} new_presets = {} for zone_id in source_tabs: zone = zones.read(zone_id) if not zone: continue tab_name = zone.get("name") or f"Zone {zone_id}" clone_name = tab_name mapped_presets = map_preset_container(zone.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets) clone_id = allocate_id(zones, tab_cache) clone_data = { "name": clone_name, "names": zone.get("names") or [], "presets": mapped_presets if mapped_presets is not None else [] } extra = {k: v for k, v in zone.items() if k not in ("name", "names", "presets")} if "presets_flat" in extra: extra["presets_flat"] = map_preset_container(extra.get("presets_flat"), preset_id_map, preset_cache, new_profile_id, new_presets) if extra: clone_data.update(extra) new_tabs[clone_id] = clone_data cloned_tab_ids.append(clone_id) new_profile_data = { "name": new_name, "type": profile_type, "zones": cloned_tab_ids, "scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [], "palette_id": str(new_palette_id), } # Commit all changes and save once per model. profiles._palette_model[str(new_palette_id)] = list(palette_colors) if palette_colors else [] for pid, pdata in new_presets.items(): presets[pid] = pdata for tid, tdata in new_tabs.items(): zones[tid] = tdata profiles[str(new_profile_id)] = new_profile_data profiles._palette_model.save() presets.save() zones.save() profiles.save() return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'} except Exception as e: return json.dumps({"error": str(e)}), 400 @controller.put('/current') @with_session async def update_current_profile(request, session): """Update the current profile using session (or fallback).""" try: data = request.json or {} profile_list = profiles.list() current_id = session.get('current_profile') if not current_id and profile_list: current_id = profile_list[0] session['current_profile'] = str(current_id) session.save() if not current_id: return json.dumps({"error": "No profile available"}), 404 if profiles.update(current_id, data): return json.dumps(profiles.read(current_id)), 200, {'Content-Type': 'application/json'} return json.dumps({"error": "Profile not found"}), 404 except Exception as e: return json.dumps({"error": str(e)}), 400 @controller.put('/') async def update_profile(request, id): """Update an existing profile.""" try: data = request.json if profiles.update(id, data): return json.dumps(profiles.read(id)), 200, {'Content-Type': 'application/json'} return json.dumps({"error": "Profile not found"}), 404 except Exception as e: return json.dumps({"error": str(e)}), 400 @controller.delete('/') async def delete_profile(request, id): """Delete a profile.""" if profiles.delete(id): return json.dumps({"message": "Profile deleted successfully"}), 200 return json.dumps({"error": "Profile not found"}), 404