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:
143
db/preset.json
143
db/preset.json
@@ -15,7 +15,8 @@
|
|||||||
"n5": 0,
|
"n5": 0,
|
||||||
"n6": 0,
|
"n6": 0,
|
||||||
"n7": 0,
|
"n7": 0,
|
||||||
"n8": 0
|
"n8": 0,
|
||||||
|
"profile_id": "1"
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"name": "off",
|
"name": "off",
|
||||||
@@ -31,7 +32,8 @@
|
|||||||
"n5": 0,
|
"n5": 0,
|
||||||
"n6": 0,
|
"n6": 0,
|
||||||
"n7": 0,
|
"n7": 0,
|
||||||
"n8": 0
|
"n8": 0,
|
||||||
|
"profile_id": "1"
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
"name": "rainbow",
|
"name": "rainbow",
|
||||||
@@ -47,7 +49,8 @@
|
|||||||
"n5": 0,
|
"n5": 0,
|
||||||
"n6": 0,
|
"n6": 0,
|
||||||
"n7": 0,
|
"n7": 0,
|
||||||
"n8": 0
|
"n8": 0,
|
||||||
|
"profile_id": "1"
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
"name": "transition",
|
"name": "transition",
|
||||||
@@ -67,7 +70,8 @@
|
|||||||
"n5": 0,
|
"n5": 0,
|
||||||
"n6": 0,
|
"n6": 0,
|
||||||
"n7": 0,
|
"n7": 0,
|
||||||
"n8": 0
|
"n8": 0,
|
||||||
|
"profile_id": "1"
|
||||||
},
|
},
|
||||||
"5": {
|
"5": {
|
||||||
"name": "chase",
|
"name": "chase",
|
||||||
@@ -86,7 +90,8 @@
|
|||||||
"n5": 0,
|
"n5": 0,
|
||||||
"n6": 0,
|
"n6": 0,
|
||||||
"n7": 0,
|
"n7": 0,
|
||||||
"n8": 0
|
"n8": 0,
|
||||||
|
"profile_id": "1"
|
||||||
},
|
},
|
||||||
"6": {
|
"6": {
|
||||||
"name": "pulse",
|
"name": "pulse",
|
||||||
@@ -104,7 +109,8 @@
|
|||||||
"n5": 0,
|
"n5": 0,
|
||||||
"n6": 0,
|
"n6": 0,
|
||||||
"n7": 0,
|
"n7": 0,
|
||||||
"n8": 0
|
"n8": 0,
|
||||||
|
"profile_id": "1"
|
||||||
},
|
},
|
||||||
"7": {
|
"7": {
|
||||||
"name": "circle",
|
"name": "circle",
|
||||||
@@ -123,7 +129,8 @@
|
|||||||
"n5": 0,
|
"n5": 0,
|
||||||
"n6": 0,
|
"n6": 0,
|
||||||
"n7": 0,
|
"n7": 0,
|
||||||
"n8": 0
|
"n8": 0,
|
||||||
|
"profile_id": "1"
|
||||||
},
|
},
|
||||||
"8": {
|
"8": {
|
||||||
"name": "blink",
|
"name": "blink",
|
||||||
@@ -144,6 +151,126 @@
|
|||||||
"n5": 0,
|
"n5": 0,
|
||||||
"n6": 0,
|
"n6": 0,
|
||||||
"n7": 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
21
db/tab.json
21
db/tab.json
@@ -2,7 +2,7 @@
|
|||||||
"1": {
|
"1": {
|
||||||
"name": "default",
|
"name": "default",
|
||||||
"names": [
|
"names": [
|
||||||
"1"
|
"1","2","3","4","5","6","7","8"
|
||||||
],
|
],
|
||||||
"presets": [
|
"presets": [
|
||||||
[
|
[
|
||||||
@@ -13,18 +13,15 @@
|
|||||||
"5",
|
"5",
|
||||||
"6",
|
"6",
|
||||||
"7",
|
"7",
|
||||||
"8"
|
"8",
|
||||||
|
"9",
|
||||||
|
"10",
|
||||||
|
"11",
|
||||||
|
"12",
|
||||||
|
"13",
|
||||||
|
"14",
|
||||||
|
"15"
|
||||||
]
|
]
|
||||||
],
|
|
||||||
"presets_flat": [
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
"3",
|
|
||||||
"4",
|
|
||||||
"5",
|
|
||||||
"6",
|
|
||||||
"7",
|
|
||||||
"8"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,31 +1,67 @@
|
|||||||
from microdot import Microdot
|
from microdot import Microdot
|
||||||
|
from microdot.session import with_session
|
||||||
from models.preset import Preset
|
from models.preset import Preset
|
||||||
|
from models.profile import Profile
|
||||||
from models.espnow import ESPNow
|
from models.espnow import ESPNow
|
||||||
from util.espnow_message import build_message, build_preset_dict
|
from util.espnow_message import build_message, build_preset_dict
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
presets = Preset()
|
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('')
|
@controller.get('')
|
||||||
async def list_presets(request):
|
@with_session
|
||||||
"""List all presets."""
|
async def list_presets(request, session):
|
||||||
return json.dumps(presets), 200, {'Content-Type': 'application/json'}
|
"""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>')
|
@controller.get('/<id>')
|
||||||
async def get_preset(request, id):
|
@with_session
|
||||||
"""Get a specific preset by ID."""
|
async def get_preset(request, id, session):
|
||||||
|
"""Get a specific preset by ID (current profile only)."""
|
||||||
preset = presets.read(id)
|
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(preset), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
|
|
||||||
@controller.post('')
|
@controller.post('')
|
||||||
async def create_preset(request):
|
@with_session
|
||||||
"""Create a new preset."""
|
async def create_preset(request, session):
|
||||||
|
"""Create a new preset for the current profile."""
|
||||||
try:
|
try:
|
||||||
data = request.json
|
try:
|
||||||
preset_id = presets.create()
|
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):
|
if presets.update(preset_id, data):
|
||||||
preset_data = presets.read(preset_id)
|
preset_data = presets.read(preset_id)
|
||||||
return json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'}
|
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
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.put('/<id>')
|
@controller.put('/<id>')
|
||||||
async def update_preset(request, id):
|
@with_session
|
||||||
"""Update an existing preset."""
|
async def update_preset(request, id, session):
|
||||||
|
"""Update an existing preset (current profile only)."""
|
||||||
try:
|
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):
|
if presets.update(id, data):
|
||||||
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
|
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
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
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.delete('/<id>')
|
@controller.delete('/<id>')
|
||||||
async def delete_preset(request, id):
|
@with_session
|
||||||
"""Delete a preset."""
|
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):
|
if presets.delete(id):
|
||||||
return json.dumps({"message": "Preset deleted successfully"}), 200
|
return json.dumps({"message": "Preset deleted successfully"}), 200
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
@controller.post('/send')
|
@controller.post('/send')
|
||||||
async def send_presets(request):
|
@with_session
|
||||||
|
async def send_presets(request, session):
|
||||||
"""
|
"""
|
||||||
Send one or more presets over ESPNow.
|
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')
|
preset_ids = data.get('preset_ids') or data.get('ids')
|
||||||
if not isinstance(preset_ids, list) or not preset_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'}
|
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)
|
# Build API-compliant preset map keyed by preset ID (not name)
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
presets_by_name = {}
|
presets_by_name = {}
|
||||||
for pid in preset_ids:
|
for pid in preset_ids:
|
||||||
preset_data = presets.read(str(pid))
|
preset_data = presets.read(str(pid))
|
||||||
if not preset_data:
|
if not preset_data:
|
||||||
continue
|
continue
|
||||||
|
if str(preset_data.get("profile_id")) != str(current_profile_id):
|
||||||
|
continue
|
||||||
preset_id_key = str(pid)
|
preset_id_key = str(pid)
|
||||||
presets_by_name[preset_id_key] = build_preset_dict(preset_data)
|
presets_by_name[preset_id_key] = build_preset_dict(preset_data)
|
||||||
|
|
||||||
@@ -91,7 +150,8 @@ async def send_presets(request):
|
|||||||
esp = ESPNow()
|
esp = ESPNow()
|
||||||
|
|
||||||
async def send_chunk(chunk_presets):
|
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)
|
await esp.send(msg)
|
||||||
|
|
||||||
MAX_BYTES = 240
|
MAX_BYTES = 240
|
||||||
@@ -104,7 +164,7 @@ async def send_presets(request):
|
|||||||
for name, preset_obj in entries:
|
for name, preset_obj in entries:
|
||||||
test_batch = dict(batch)
|
test_batch = dict(batch)
|
||||||
test_batch[name] = preset_obj
|
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)
|
size = len(test_msg)
|
||||||
|
|
||||||
if size <= MAX_BYTES or not batch:
|
if size <= MAX_BYTES or not batch:
|
||||||
@@ -114,7 +174,7 @@ async def send_presets(request):
|
|||||||
await send_chunk(batch)
|
await send_chunk(batch)
|
||||||
messages_sent += 1
|
messages_sent += 1
|
||||||
batch = {name: preset_obj}
|
batch = {name: preset_obj}
|
||||||
last_msg = build_message(presets=batch)
|
last_msg = build_message(presets=batch, save=save_flag)
|
||||||
|
|
||||||
if batch:
|
if batch:
|
||||||
await send_chunk(batch)
|
await send_chunk(batch)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
from microdot import Microdot
|
from microdot import Microdot
|
||||||
from microdot.session import with_session
|
from microdot.session import with_session
|
||||||
from models.profile import Profile
|
from models.profile import Profile
|
||||||
|
from models.tab import Tab
|
||||||
|
from models.preset import Preset
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
profiles = Profile()
|
profiles = Profile()
|
||||||
|
tabs = Tab()
|
||||||
|
presets = Preset()
|
||||||
|
|
||||||
@controller.get('')
|
@controller.get('')
|
||||||
@with_session
|
@with_session
|
||||||
@@ -12,6 +16,8 @@ async def list_profiles(request, session):
|
|||||||
"""List all profiles with current profile info."""
|
"""List all profiles with current profile info."""
|
||||||
profile_list = profiles.list()
|
profile_list = profiles.list()
|
||||||
current_id = session.get('current_profile')
|
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 no current profile in session, use first one
|
||||||
if not current_id and profile_list:
|
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)."""
|
"""Get the current profile ID from session (or fallback)."""
|
||||||
profile_list = profiles.list()
|
profile_list = profiles.list()
|
||||||
current_id = session.get('current_profile')
|
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:
|
if not current_id and profile_list:
|
||||||
current_id = profile_list[0]
|
current_id = profile_list[0]
|
||||||
session['current_profile'] = str(current_id)
|
session['current_profile'] = str(current_id)
|
||||||
@@ -83,6 +91,118 @@ async def create_profile(request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
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')
|
@controller.put('/current')
|
||||||
@with_session
|
@with_session
|
||||||
async def update_current_profile(request, session):
|
async def update_current_profile(request, session):
|
||||||
|
|||||||
@@ -128,9 +128,7 @@ def _render_tab_content_fragment(request, session, id):
|
|||||||
html = (
|
html = (
|
||||||
'<div class="presets-section" data-tab-id="' + str(id) + '">'
|
'<div class="presets-section" data-tab-id="' + str(id) + '">'
|
||||||
'<h3>Presets</h3>'
|
'<h3>Presets</h3>'
|
||||||
'<div class="profiles-actions" style="margin-bottom: 1rem;">'
|
'<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
|
||||||
'<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button>'
|
|
||||||
'</div>'
|
|
||||||
'<div id="presets-list-tab" class="presets-list">'
|
'<div id="presets-list-tab" class="presets-list">'
|
||||||
'<!-- Presets will be loaded here -->'
|
'<!-- Presets will be loaded here -->'
|
||||||
'</div>'
|
'</div>'
|
||||||
@@ -307,3 +305,42 @@ async def create_tab(request, session):
|
|||||||
import sys
|
import sys
|
||||||
sys.print_exception(e)
|
sys.print_exception(e)
|
||||||
return json.dumps({"error": str(e)}), 400
|
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
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
from models.model import Model
|
from models.model import Model
|
||||||
|
from models.profile import Profile
|
||||||
|
|
||||||
class Preset(Model):
|
class Preset(Model):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
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()
|
next_id = self.get_next_id()
|
||||||
self[next_id] = {
|
self[next_id] = {
|
||||||
"name": "",
|
"name": "",
|
||||||
@@ -20,6 +36,7 @@ class Preset(Model):
|
|||||||
"n6": 0,
|
"n6": 0,
|
||||||
"n7": 0,
|
"n7": 0,
|
||||||
"n8": 0,
|
"n8": 0,
|
||||||
|
"profile_id": str(profile_id) if profile_id is not None else None,
|
||||||
}
|
}
|
||||||
self.save()
|
self.save()
|
||||||
return next_id
|
return next_id
|
||||||
|
|||||||
@@ -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"]}]}
|
|
||||||
@@ -7,7 +7,7 @@ This module provides utilities to build ESPNow messages according to the API spe
|
|||||||
import json
|
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.
|
Build an ESPNow message according to the API specification.
|
||||||
|
|
||||||
@@ -40,6 +40,10 @@ def build_message(presets=None, select=None):
|
|||||||
|
|
||||||
if presets:
|
if presets:
|
||||||
message["presets"] = 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:
|
if select:
|
||||||
message["select"] = select
|
message["select"] = select
|
||||||
|
|||||||
Reference in New Issue
Block a user