From 6c6ed22dbe66ad7c6dd4a990b9a09e2991fd92b0 Mon Sep 17 00:00:00 2001 From: jimmy Date: Sun, 8 Feb 2026 13:51:02 +1300 Subject: [PATCH] Scope presets to active profiles and support cloning. This keeps data isolated per profile while letting users duplicate setups quickly. Co-authored-by: Cursor --- db/preset.json | 143 ++++++++++++++++++++++++++++++++++--- db/tab.json | 21 +++--- src/controllers/preset.py | 98 ++++++++++++++++++++----- src/controllers/profile.py | 120 +++++++++++++++++++++++++++++++ src/controllers/tab.py | 43 ++++++++++- src/models/preset.py | 19 ++++- src/settings.json | 1 - src/util/espnow_message.py | 6 +- 8 files changed, 406 insertions(+), 45 deletions(-) delete mode 100644 src/settings.json diff --git a/db/preset.json b/db/preset.json index 7eef11c..77971ef 100644 --- a/db/preset.json +++ b/db/preset.json @@ -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" } } \ No newline at end of file diff --git a/db/tab.json b/db/tab.json index 4c1e8d9..aa03731 100644 --- a/db/tab.json +++ b/db/tab.json @@ -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" ] } } \ No newline at end of file diff --git a/src/controllers/preset.py b/src/controllers/preset.py index 68b5784..1eac6f6 100644 --- a/src/controllers/preset.py +++ b/src/controllers/preset.py @@ -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('/') -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('/') -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('/') -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) diff --git a/src/controllers/profile.py b/src/controllers/profile.py index 859379c..7fcbf9c 100644 --- a/src/controllers/profile.py +++ b/src/controllers/profile.py @@ -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('//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): diff --git a/src/controllers/tab.py b/src/controllers/tab.py index ed176eb..ed6bcf1 100644 --- a/src/controllers/tab.py +++ b/src/controllers/tab.py @@ -128,9 +128,7 @@ def _render_tab_content_fragment(request, session, id): html = ( '
' '

Presets

' - '
' - '' - '
' + '
' '
' '' '
' @@ -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('//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 diff --git a/src/models/preset.py b/src/models/preset.py index cd0f05b..78be827 100644 --- a/src/models/preset.py +++ b/src/models/preset.py @@ -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 diff --git a/src/settings.json b/src/settings.json deleted file mode 100644 index 1155c37..0000000 --- a/src/settings.json +++ /dev/null @@ -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"]}]} \ No newline at end of file diff --git a/src/util/espnow_message.py b/src/util/espnow_message.py index c4abe29..96a4bb5 100644 --- a/src/util/espnow_message.py +++ b/src/util/espnow_message.py @@ -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