Scope presets to active profiles and support cloning.

This keeps data isolated per profile while letting users duplicate setups quickly.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-08 13:51:02 +13:00
parent 00514f0525
commit 6c6ed22dbe
8 changed files with 406 additions and 45 deletions

View File

@@ -15,7 +15,8 @@
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
"n8": 0,
"profile_id": "1"
},
"2": {
"name": "off",
@@ -31,7 +32,8 @@
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
"n8": 0,
"profile_id": "1"
},
"3": {
"name": "rainbow",
@@ -47,7 +49,8 @@
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
"n8": 0,
"profile_id": "1"
},
"4": {
"name": "transition",
@@ -67,7 +70,8 @@
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
"n8": 0,
"profile_id": "1"
},
"5": {
"name": "chase",
@@ -86,7 +90,8 @@
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
"n8": 0,
"profile_id": "1"
},
"6": {
"name": "pulse",
@@ -104,7 +109,8 @@
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
"n8": 0,
"profile_id": "1"
},
"7": {
"name": "circle",
@@ -123,7 +129,8 @@
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
"n8": 0,
"profile_id": "1"
},
"8": {
"name": "blink",
@@ -144,6 +151,126 @@
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
"n8": 0,
"profile_id": "1"
},
"9": {
"name": "warm white",
"pattern": "on",
"colors": ["#FFF5E6"],
"brightness": 200,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"10": {
"name": "cool white",
"pattern": "on",
"colors": ["#E6F2FF"],
"brightness": 200,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"11": {
"name": "red",
"pattern": "on",
"colors": ["#FF0000"],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"12": {
"name": "blue",
"pattern": "on",
"colors": ["#0000FF"],
"brightness": 255,
"delay": 100,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"13": {
"name": "rainbow slow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 150,
"auto": true,
"n1": 1,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"14": {
"name": "pulse slow",
"pattern": "pulse",
"colors": ["#FF6600"],
"brightness": 255,
"delay": 800,
"auto": true,
"n1": 2000,
"n2": 1000,
"n3": 2000,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
},
"15": {
"name": "blink red green",
"pattern": "blink",
"colors": ["#FF0000", "#00FF00"],
"brightness": 255,
"delay": 500,
"auto": true,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": "1"
}
}

View File

@@ -2,7 +2,7 @@
"1": {
"name": "default",
"names": [
"1"
"1","2","3","4","5","6","7","8"
],
"presets": [
[
@@ -13,18 +13,15 @@
"5",
"6",
"7",
"8"
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15"
]
],
"presets_flat": [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8"
]
}
}

View File

@@ -1,31 +1,67 @@
from microdot import Microdot
from microdot.session import with_session
from models.preset import Preset
from models.profile import Profile
from models.espnow import ESPNow
from util.espnow_message import build_message, build_preset_dict
import json
controller = Microdot()
presets = Preset()
profiles = Profile()
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
@controller.get('')
async def list_presets(request):
"""List all presets."""
return json.dumps(presets), 200, {'Content-Type': 'application/json'}
@with_session
async def list_presets(request, session):
"""List presets for the current profile."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({}), 200, {'Content-Type': 'application/json'}
scoped = {
pid: pdata for pid, pdata in presets.items()
if isinstance(pdata, dict) and str(pdata.get("profile_id")) == str(current_profile_id)
}
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
async def get_preset(request, id):
"""Get a specific preset by ID."""
@with_session
async def get_preset(request, id, session):
"""Get a specific preset by ID (current profile only)."""
preset = presets.read(id)
if preset:
current_profile_id = get_current_profile_id(session)
if preset and str(preset.get("profile_id")) == str(current_profile_id):
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404
@controller.post('')
async def create_preset(request):
"""Create a new preset."""
@with_session
async def create_preset(request, session):
"""Create a new preset for the current profile."""
try:
data = request.json
preset_id = presets.create()
try:
data = request.json or {}
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"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 json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'}
@@ -34,10 +70,22 @@ async def create_preset(request):
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_preset(request, id):
"""Update an existing preset."""
@with_session
async def update_preset(request, id, session):
"""Update an existing preset (current profile only)."""
try:
data = request.json
preset = presets.read(id)
current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404
try:
data = request.json or {}
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
if not isinstance(data, dict):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if presets.update(id, data):
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404
@@ -45,15 +93,21 @@ async def update_preset(request, id):
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_preset(request, id):
"""Delete a preset."""
@with_session
async def delete_preset(request, id, session):
"""Delete a preset (current profile only)."""
preset = presets.read(id)
current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404
if presets.delete(id):
return json.dumps({"message": "Preset deleted successfully"}), 200
return json.dumps({"error": "Preset not found"}), 404
@controller.post('/send')
async def send_presets(request):
@with_session
async def send_presets(request, session):
"""
Send one or more presets over ESPNow.
@@ -74,13 +128,18 @@ async def send_presets(request):
preset_ids = data.get('preset_ids') or data.get('ids')
if not isinstance(preset_ids, list) or not preset_ids:
return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'}
save_flag = data.get('save', True)
save_flag = bool(save_flag)
# Build API-compliant preset map keyed by preset ID (not name)
current_profile_id = get_current_profile_id(session)
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_id_key = str(pid)
presets_by_name[preset_id_key] = build_preset_dict(preset_data)
@@ -91,7 +150,8 @@ async def send_presets(request):
esp = ESPNow()
async def send_chunk(chunk_presets):
msg = build_message(presets=chunk_presets)
# Include save flag so the led-driver can persist when desired.
msg = build_message(presets=chunk_presets, save=save_flag)
await esp.send(msg)
MAX_BYTES = 240
@@ -104,7 +164,7 @@ async def send_presets(request):
for name, preset_obj in entries:
test_batch = dict(batch)
test_batch[name] = preset_obj
test_msg = build_message(presets=test_batch)
test_msg = build_message(presets=test_batch, save=save_flag)
size = len(test_msg)
if size <= MAX_BYTES or not batch:
@@ -114,7 +174,7 @@ async def send_presets(request):
await send_chunk(batch)
messages_sent += 1
batch = {name: preset_obj}
last_msg = build_message(presets=batch)
last_msg = build_message(presets=batch, save=save_flag)
if batch:
await send_chunk(batch)

View File

@@ -1,10 +1,14 @@
from microdot import Microdot
from microdot.session import with_session
from models.profile import Profile
from models.tab import Tab
from models.preset import Preset
import json
controller = Microdot()
profiles = Profile()
tabs = Tab()
presets = Preset()
@controller.get('')
@with_session
@@ -12,6 +16,8 @@ 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:
@@ -37,6 +43,8 @@ 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)
@@ -83,6 +91,118 @@ async def create_profile(request):
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", "tabs")
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("tabs")
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
source_tabs = source.get("tab_order", [])
source_tabs = source_tabs or []
cloned_tab_ids = []
preset_id_map = {}
new_tabs = {}
new_presets = {}
for tab_id in source_tabs:
tab = tabs.read(tab_id)
if not tab:
continue
tab_name = tab.get("name") or f"Tab {tab_id}"
clone_name = tab_name
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
clone_id = allocate_id(tabs, tab_cache)
clone_data = {
"name": clone_name,
"names": tab.get("names") or [],
"presets": mapped_presets if mapped_presets is not None else []
}
extra = {k: v for k, v in tab.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,
"tabs": 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():
tabs[tid] = tdata
profiles[str(new_profile_id)] = new_profile_data
profiles._palette_model.save()
presets.save()
tabs.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):

View File

@@ -128,9 +128,7 @@ def _render_tab_content_fragment(request, session, id):
html = (
'<div class="presets-section" data-tab-id="' + str(id) + '">'
'<h3>Presets</h3>'
'<div class="profiles-actions" style="margin-bottom: 1rem;">'
'<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button>'
'</div>'
'<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
'<div id="presets-list-tab" class="presets-list">'
'<!-- Presets will be loaded here -->'
'</div>'
@@ -307,3 +305,42 @@ async def create_tab(request, session):
import sys
sys.print_exception(e)
return json.dumps({"error": str(e)}), 400
@controller.post('/<id>/clone')
@with_session
async def clone_tab(request, session, id):
"""Clone an existing tab and add it to the current profile."""
try:
source = tabs.read(id)
if not source:
return json.dumps({"error": "Tab not found"}), 404
data = request.json or {}
source_name = source.get("name") or f"Tab {id}"
new_name = data.get("name") or f"{source_name} Copy"
clone_id = tabs.create(new_name, source.get("names"), source.get("presets"))
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra:
tabs.update(clone_id, extra)
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if clone_id not in tabs_list:
tabs_list.append(clone_id)
profile['tabs'] = tabs_list
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
tab_data = tabs.read(clone_id)
return json.dumps({clone_id: tab_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
import sys
try:
sys.print_exception(e)
except:
pass
return json.dumps({"error": str(e)}), 400

View File

@@ -1,10 +1,26 @@
from models.model import Model
from models.profile import Profile
class Preset(Model):
def __init__(self):
super().__init__()
# Backfill profile ownership for existing presets.
try:
profiles = Profile()
profile_list = profiles.list()
default_profile_id = profile_list[0] if profile_list else None
changed = False
for preset_id, preset_data in list(self.items()):
if isinstance(preset_data, dict) and "profile_id" not in preset_data:
if default_profile_id is not None:
preset_data["profile_id"] = str(default_profile_id)
changed = True
if changed:
self.save()
except Exception:
pass
def create(self):
def create(self, profile_id=None):
next_id = self.get_next_id()
self[next_id] = {
"name": "",
@@ -20,6 +36,7 @@ class Preset(Model):
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": str(profile_id) if profile_id is not None else None,
}
self.save()
return next_id

View File

@@ -1 +0,0 @@
{"name": "led-controller", "patterns": [{"name": "Off", "pt": "off", "cl": ["#ff0000"], "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10}, {"name": "On", "pt": "on", "cl": ["#ff0000"], "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10}, {"name": "Blink", "pt": "blink", "cl": ["#ff0000"], "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10}, {"name": "Rainbow", "pt": "rainbow", "cl": ["#ff0000"], "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10}, {"name": "test1", "pt": "off", "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10, "cl": ["#ff0000"]}, {"name": "test2", "pt": "off", "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10, "cl": ["#ff0000"]}, {"name": "test3", "pt": "off", "dl": 100, "n1": 10, "n2": 10, "n3": 10, "n4": 10, "n5": 10, "n6": 10, "cl": ["#ff0000"]}]}

View File

@@ -7,7 +7,7 @@ This module provides utilities to build ESPNow messages according to the API spe
import json
def build_message(presets=None, select=None):
def build_message(presets=None, select=None, save=False):
"""
Build an ESPNow message according to the API specification.
@@ -40,6 +40,10 @@ def build_message(presets=None, select=None):
if presets:
message["presets"] = presets
# When sending presets, optionally include a save flag so the
# led-driver can persist them.
if save:
message["save"] = True
if select:
message["select"] = select