feat(ui): pattern modes, bundles, and zone content kind
Add profile/preset/sequence JSON import and export; map preset mode to wire n6 with a mode dropdown for multi-mode patterns; zone edit shows presets or sequences only with content_kind on save; update catalogue and tests for merged pattern names. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,7 @@ from models.device import Device, normalize_mac
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
||||
from util.espnow_message import build_message, build_preset_dict
|
||||
from util.profile_bundle import export_preset_bundle, import_preset_bundle
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
@@ -50,6 +51,41 @@ async def list_presets(request, session):
|
||||
}
|
||||
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.get('/<preset_id>/export')
|
||||
@with_session
|
||||
async def export_preset(request, session, preset_id):
|
||||
"""Export one preset as a JSON bundle."""
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
preset = presets.read(preset_id)
|
||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'}
|
||||
try:
|
||||
bundle = export_preset_bundle(preset_id, presets)
|
||||
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.post('/import')
|
||||
@with_session
|
||||
async def import_preset(request, session):
|
||||
"""Import a preset bundle into the current profile."""
|
||||
try:
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'}
|
||||
body = request.json or {}
|
||||
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||
if not isinstance(bundle, dict):
|
||||
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
|
||||
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
|
||||
return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.get('/<preset_id>')
|
||||
@with_session
|
||||
async def get_preset(request, session, preset_id):
|
||||
|
||||
@@ -3,12 +3,15 @@ from microdot.session import with_session
|
||||
from models.profile import Profile
|
||||
from models.zone import Zone
|
||||
from models.preset import Preset
|
||||
from models.sequence import Sequence
|
||||
from util.profile_bundle import export_profile_bundle, import_profile_bundle
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
profiles = Profile()
|
||||
zones = Zone()
|
||||
presets = Preset()
|
||||
sequences = Sequence()
|
||||
|
||||
@controller.get('')
|
||||
@with_session
|
||||
@@ -54,18 +57,64 @@ async def get_current_profile(request, session):
|
||||
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "No profile available"}), 404
|
||||
|
||||
@controller.get('/<id>')
|
||||
|
||||
@controller.post('/import')
|
||||
@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
|
||||
async def import_profile(request, session):
|
||||
"""Import a profile bundle (optionally apply as current profile)."""
|
||||
try:
|
||||
body = request.json or {}
|
||||
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||
if not isinstance(bundle, dict):
|
||||
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
|
||||
name = body.get("name") if isinstance(body, dict) else None
|
||||
apply_raw = body.get("apply", True) if isinstance(body, dict) else True
|
||||
if isinstance(apply_raw, str):
|
||||
apply = apply_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
else:
|
||||
apply = bool(apply_raw)
|
||||
|
||||
new_profile_id, profile_data = import_profile_bundle(
|
||||
bundle,
|
||||
profiles,
|
||||
zones,
|
||||
presets,
|
||||
sequences,
|
||||
profiles._palette_model,
|
||||
name=str(name).strip() if name else None,
|
||||
)
|
||||
if apply:
|
||||
session['current_profile'] = str(new_profile_id)
|
||||
session.save()
|
||||
return (
|
||||
json.dumps({new_profile_id: profile_data, "id": new_profile_id}),
|
||||
201,
|
||||
{'Content-Type': 'application/json'},
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.get('/<id>/export')
|
||||
async def export_profile(request, id):
|
||||
"""Export profile, zones, presets, sequences, and palette as a JSON bundle."""
|
||||
try:
|
||||
bundle = export_profile_bundle(
|
||||
str(id),
|
||||
profiles,
|
||||
zones,
|
||||
presets,
|
||||
sequences,
|
||||
profiles._palette_model,
|
||||
)
|
||||
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.post('/<id>/apply')
|
||||
@with_session
|
||||
@@ -77,167 +126,6 @@ async def apply_profile(request, session, 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):
|
||||
@@ -351,6 +239,184 @@ async def clone_profile(request, id):
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@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('')
|
||||
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": "colour_cycle",
|
||||
"colors": [],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
"n1": 2,
|
||||
"mode": 1,
|
||||
},
|
||||
{
|
||||
"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": "colour_cycle",
|
||||
"colors": [],
|
||||
"brightness": 220,
|
||||
"delay": 60,
|
||||
"n1": 12,
|
||||
"mode": 1,
|
||||
},
|
||||
{
|
||||
"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.put('/current')
|
||||
@with_session
|
||||
async def update_current_profile(request, session):
|
||||
|
||||
@@ -3,11 +3,14 @@ from microdot.session import with_session
|
||||
from models.sequence import Sequence
|
||||
from models.profile import Profile
|
||||
from models.transport import get_current_sender
|
||||
from models.preset import Preset
|
||||
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
sequences = Sequence()
|
||||
profiles = Profile()
|
||||
presets = Preset()
|
||||
|
||||
|
||||
def get_current_profile_id(session=None):
|
||||
@@ -39,6 +42,57 @@ async def list_sequences(request, session):
|
||||
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get("/<id>/export")
|
||||
@with_session
|
||||
async def export_sequence(request, session, id):
|
||||
"""Export a sequence and referenced presets as a JSON bundle."""
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"}
|
||||
try:
|
||||
bundle = export_sequence_bundle(
|
||||
id,
|
||||
sequences,
|
||||
presets,
|
||||
profile_id=current_profile_id,
|
||||
)
|
||||
return json.dumps(bundle), 200, {"Content-Type": "application/json"}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.post("/import")
|
||||
@with_session
|
||||
async def import_sequence(request, session):
|
||||
"""Import a sequence bundle into the current profile."""
|
||||
try:
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return (
|
||||
json.dumps({"error": "No profile available"}),
|
||||
404,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
body = request.json or {}
|
||||
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||
if not isinstance(bundle, dict):
|
||||
return (
|
||||
json.dumps({"error": "Expected JSON bundle"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
|
||||
return (
|
||||
json.dumps({new_id: seq_data}),
|
||||
201,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
@with_session
|
||||
async def get_sequence(request, session, id):
|
||||
|
||||
@@ -349,6 +349,7 @@ async def clone_zone(request, session, id):
|
||||
source.get("names"),
|
||||
source.get("presets"),
|
||||
source.get("group_ids"),
|
||||
source.get("content_kind"),
|
||||
)
|
||||
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
||||
if extra:
|
||||
|
||||
Reference in New Issue
Block a user