Files
led-controller/src/controllers/profile.py
2026-04-21 20:43:25 +12:00

391 lines
14 KiB
Python

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('/<id>')
@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('/<id>/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('/<id>/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('/<id>')
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('/<id>')
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