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:
2026-05-16 21:12:42 +12:00
parent 6286297646
commit 96d1e1b5fd
28 changed files with 1715 additions and 458 deletions

View File

@@ -1 +1 @@
{"1": {"name": "group1", "devices": ["e8f60a16fb00", "e8f60a170794"], "wifi_driver_display_name": "desk", "wifi_driver_num_leds": 59, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "output_brightness": 255}, "2": {"name": "group2", "devices": ["188b0e1560a8"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}} {"1": {"name": "group1", "devices": ["e8f60a16fb00", "e8f60a170794"], "wifi_driver_display_name": "desk", "wifi_driver_num_leds": 59, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "output_brightness": 255}, "2": {"name": "group2", "devices": ["188b0e1560a8"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "3": {"name": "group3", "devices": ["e8f60a16f288"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "4": {"name": "group4", "devices": ["e8f60a16e79c"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "5": {"name": "desk", "devices": ["188b0e1560a8"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": null}}

View File

@@ -1 +1 @@
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]} {"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000", "#050500"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"], "13": []}

View File

@@ -11,15 +11,12 @@
"max_colors": 0, "max_colors": 0,
"supports_manual": true "supports_manual": true
}, },
"rainbow": {
"n1": "Step Rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0,
"supports_manual": true
},
"colour_cycle": { "colour_cycle": {
"n1": "Step Rate", "n1": "Step rate",
"mode": {
"0": "Scroll palette gradient",
"1": "Rainbow wheel (preset colours ignored)"
},
"min_delay": 10, "min_delay": 10,
"max_delay": 10000, "max_delay": 10000,
"max_colors": 10, "max_colors": 10,
@@ -40,7 +37,11 @@
"max_delay": 10000, "max_delay": 10000,
"max_colors": 2, "max_colors": 2,
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true,
"mode": {
"0": "Two-colour chase",
"1": "Marquee dashes (n1 on length, n2 off, n3 step)"
}
}, },
"pulse": { "pulse": {
"n1": "Attack", "n1": "Attack",
@@ -80,7 +81,7 @@
"flame": { "flame": {
"n1": "Min brightness", "n1": "Min brightness",
"n2": "Breath period (ms)", "n2": "Breath period (ms)",
"n3": "Spark gap min (ms, 0=default 1030 s, -1=off)", "n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)",
"n4": "Spark gap max (ms)", "n4": "Spark gap max (ms)",
"min_delay": 10, "min_delay": 10,
"max_delay": 10000, "max_delay": 10000,
@@ -88,8 +89,8 @@
"supports_manual": false "supports_manual": false
}, },
"twinkle": { "twinkle": {
"n1": "Twinkle activity (1255, higher = more changes)", "n1": "Twinkle activity (1\u2013255, higher = more changes)",
"n2": "Density (0255, higher = more of the strip lit)", "n2": "Density (0\u2013255, higher = more of the strip lit)",
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
"min_delay": 10, "min_delay": 10,
@@ -108,58 +109,6 @@
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },
"meteor_rain": {
"n1": "Tail length",
"n2": "Speed (LEDs per frame)",
"n3": "Fade amount (1-255)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"scanner": {
"n1": "Eye width",
"n2": "End pause (frames)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"gradient_scroll": {
"n1": "Scroll step rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"comet_dual": {
"n1": "Tail length",
"n2": "Speed",
"n3": "Gap",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"sparkle_trail": {
"n1": "Spark density",
"n2": "Decay",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": true
},
"wave": {
"n1": "Wavelength",
"n2": "Amplitude",
"n3": "Drift speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"plasma": { "plasma": {
"n1": "Scale", "n1": "Scale",
"n2": "Speed", "n2": "Speed",
@@ -169,17 +118,6 @@
"max_delay": 10000, "max_delay": 10000,
"supports_manual": false "supports_manual": false
}, },
"segment_chase": {
"n1": "Segment size",
"n2": "Phase step",
"n3": "Segment phase offset",
"n4": "Gap per segment",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"bar_graph": { "bar_graph": {
"n1": "Level percent", "n1": "Level percent",
"max_colors": 10, "max_colors": 10,
@@ -188,14 +126,6 @@
"has_background": true, "has_background": true,
"supports_manual": false "supports_manual": false
}, },
"breathing_dual": {
"n1": "Phase offset",
"n2": "Ease",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"strobe_burst": { "strobe_burst": {
"n1": "Burst count", "n1": "Burst count",
"n2": "Burst gap", "n2": "Burst gap",
@@ -215,15 +145,6 @@
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },
"fireflies": {
"n1": "Count",
"n2": "Twinkle speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"clock_sweep": { "clock_sweep": {
"n1": "Hand width", "n1": "Hand width",
"n2": "Marker interval", "n2": "Marker interval",
@@ -233,30 +154,17 @@
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },
"marquee": {
"n1": "On length",
"n2": "Off length",
"n3": "Step",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"aurora": { "aurora": {
"n1": "Band count", "n1": "Band count (0) or spatial period LEDs (1)",
"n2": "Shimmer", "n2": "Shimmer (0) or blend strength (1)",
"max_colors": 10, "n3": "Unused (0) or drift speed (1)",
"mode": {
"0": "Colour bands + shimmer",
"1": "Sine northern wave"
},
"min_delay": 10, "min_delay": 10,
"max_delay": 10000, "max_delay": 10000,
"supports_manual": false
},
"snowfall": {
"n1": "Flake density",
"n2": "Fall speed",
"max_colors": 10, "max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },
@@ -290,16 +198,6 @@
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },
"northern_wave": {
"n1": "Spatial period (LEDs)",
"n2": "Blend strength",
"n3": "Drift speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"candle_glow": { "candle_glow": {
"n1": "Candle count", "n1": "Candle count",
"n2": "Glow width (LEDs)", "n2": "Glow width (LEDs)",
@@ -310,36 +208,6 @@
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },
"starfall": {
"n1": "Spawn rate",
"n2": "Fall speed",
"n3": "Streak length",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"ice_sparkle": {
"n1": "Sparkle rate",
"n2": "Decay per refresh",
"n3": "Halo width (LEDs)",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"heartbeat": {
"n1": "Pulse 1 ms",
"n2": "Pulse 2 ms",
"n3": "Pause ms",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"orbit": { "orbit": {
"n1": "Orbit count", "n1": "Orbit count",
"n2": "Base speed", "n2": "Base speed",
@@ -357,5 +225,49 @@
"min_delay": 10, "min_delay": 10,
"max_delay": 10000, "max_delay": 10000,
"supports_manual": false "supports_manual": false
},
"meteor": {
"n1": "Tail length (01) or eye width (2)",
"n2": "Speed (LEDs per frame)",
"n3": "Fade amount (0), comet gap (1), or end pause frames (2)",
"mode": {
"0": "Fading meteor",
"1": "Dual comets",
"2": "Bouncing scanner"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"particles": {
"n1": "Flake density (0) or spawn rate (1)",
"n2": "Fall speed (LEDs per frame)",
"n3": "Unused (0) or streak length (1)",
"mode": {
"0": "Snowfall flakes",
"1": "Starfall streaks"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"sparkle": {
"n1": "Spark density (01) or firefly count (2)",
"n2": "Trail decay (0) or twinkle speed (2)",
"n3": "Ice halo width LEDs (1); unused in 0 and 2",
"mode": {
"0": "Sparkle trail",
"1": "Ice burst + halo",
"2": "Fireflies"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"1": {"name": "default", "type": "zones", "zones": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}} {"1": {"name": "default", "type": "zones", "zones": ["1", "9", "8", "10"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}}

View File

@@ -1 +1 @@
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0, "steps": [], "step_duration_ms": 3000, "loop": true, "name": "Main Group", "profile_id": "1", "lanes": [[{"preset_id": "42", "beats": 6}, {"preset_id": "5", "beats": 2}], [{"preset_id": "6", "beats": 1}]], "group_ids": ["1"], "advance_mode": "beats", "lanes_group_ids": [["1"], ["2"]]}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0, "steps": [{"preset_id": "2", "group_ids": [], "beats": 1}, {"preset_id": "3", "group_ids": [], "beats": 1}], "step_duration_ms": 2000, "loop": true, "name": "Accent Group", "profile_id": "1", "lanes": [[{"preset_id": "2", "group_ids": [], "beats": 1}, {"preset_id": "3", "group_ids": [], "beats": 1}]], "group_ids": [], "advance_mode": "time", "lanes_group_ids": [[]]}} {}

View File

@@ -1,3 +1,5 @@
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
python_files = ["test_endpoints_pytest.py"] python_files = ["test_*.py"]
# ``tests/models/`` is a package name clash with ``src/models``; run via tests/models/run_all.py
norecursedirs = ["models"]

View File

@@ -7,6 +7,7 @@ from models.device import Device, normalize_mac
from models.transport import get_current_sender from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device 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.espnow_message import build_message, build_preset_dict
from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json import json
controller = Microdot() controller = Microdot()
@@ -50,6 +51,41 @@ async def list_presets(request, session):
} }
return json.dumps(scoped), 200, {'Content-Type': 'application/json'} 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>') @controller.get('/<preset_id>')
@with_session @with_session
async def get_preset(request, session, preset_id): async def get_preset(request, session, preset_id):

View File

@@ -3,12 +3,15 @@ from microdot.session import with_session
from models.profile import Profile from models.profile import Profile
from models.zone import Zone from models.zone import Zone
from models.preset import Preset from models.preset import Preset
from models.sequence import Sequence
from util.profile_bundle import export_profile_bundle, import_profile_bundle
import json import json
controller = Microdot() controller = Microdot()
profiles = Profile() profiles = Profile()
zones = Zone() zones = Zone()
presets = Preset() presets = Preset()
sequences = Sequence()
@controller.get('') @controller.get('')
@with_session @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({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "No profile available"}), 404 return json.dumps({"error": "No profile available"}), 404
@controller.get('/<id>')
@controller.post('/import')
@with_session @with_session
async def get_profile(request, id, session): async def import_profile(request, session):
"""Get a specific profile by ID.""" """Import a profile bundle (optionally apply as current profile)."""
# Handle 'current' as a special case try:
if id == 'current': body = request.json or {}
return await get_current_profile(request, session) bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
profile = profiles.read(id) return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
if profile: name = body.get("name") if isinstance(body, dict) else None
return json.dumps(profile), 200, {'Content-Type': 'application/json'} apply_raw = body.get("apply", True) if isinstance(body, dict) else True
return json.dumps({"error": "Profile not found"}), 404 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') @controller.post('/<id>/apply')
@with_session @with_session
@@ -77,167 +126,6 @@ async def apply_profile(request, session, id):
session.save() session.save()
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'} 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') @controller.post('/<id>/clone')
async def clone_profile(request, id): async def clone_profile(request, id):
@@ -351,6 +239,184 @@ async def clone_profile(request, id):
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 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') @controller.put('/current')
@with_session @with_session
async def update_current_profile(request, session): async def update_current_profile(request, session):

View File

@@ -3,11 +3,14 @@ from microdot.session import with_session
from models.sequence import Sequence from models.sequence import Sequence
from models.profile import Profile from models.profile import Profile
from models.transport import get_current_sender 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 import json
controller = Microdot() controller = Microdot()
sequences = Sequence() sequences = Sequence()
profiles = Profile() profiles = Profile()
presets = Preset()
def get_current_profile_id(session=None): 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"} 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>") @controller.get("/<id>")
@with_session @with_session
async def get_sequence(request, session, id): async def get_sequence(request, session, id):

View File

@@ -349,6 +349,7 @@ async def clone_zone(request, session, id):
source.get("names"), source.get("names"),
source.get("presets"), source.get("presets"),
source.get("group_ids"), source.get("group_ids"),
source.get("content_kind"),
) )
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")} extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra: if extra:

View File

@@ -46,6 +46,22 @@ class Zone(Model):
if changed: if changed:
self.save() self.save()
@staticmethod
def _normalized_content_kind(doc):
if not isinstance(doc, dict):
return None
kind = doc.get("content_kind")
return kind if kind in ("presets", "sequences") else None
def _enforce_content_kind_invariants(self, doc):
"""Presets-only zones hold no sequences; sequences-only hold no preset tiles."""
kind = self._normalized_content_kind(doc)
if kind == "presets":
doc["sequence_ids"] = []
elif kind == "sequences":
doc["presets"] = []
doc["presets_flat"] = []
def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None): def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None):
next_id = self.get_next_id() next_id = self.get_next_id()
gid_list = [] gid_list = []
@@ -62,6 +78,9 @@ class Zone(Model):
} }
if content_kind in ("presets", "sequences"): if content_kind in ("presets", "sequences"):
doc["content_kind"] = content_kind doc["content_kind"] = content_kind
if "sequence_ids" not in doc:
doc["sequence_ids"] = []
self._enforce_content_kind_invariants(doc)
self[next_id] = doc self[next_id] = doc
self.save() self.save()
return next_id return next_id
@@ -74,7 +93,9 @@ class Zone(Model):
id_str = str(id) id_str = str(id)
if id_str not in self: if id_str not in self:
return False return False
self[id_str].update(data) patch = data if isinstance(data, dict) else {}
self[id_str].update(patch)
self._enforce_content_kind_invariants(self[id_str])
self.save() self.save()
return True return True

48
src/static/bundle_io.js Normal file
View File

@@ -0,0 +1,48 @@
/** Download/upload JSON bundles for profile, preset, and sequence import/export. */
window.downloadJsonFile = function downloadJsonFile(filename, data) {
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'bundle.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
window.pickJsonFile = function pickJsonFile() {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.style.display = 'none';
document.body.appendChild(input);
input.addEventListener('change', () => {
const file = input.files && input.files[0];
input.remove();
if (!file) {
resolve(null);
return;
}
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => resolve(null);
reader.readAsText(file);
});
input.click();
});
};
window.parseJsonFileText = function parseJsonFileText(text) {
if (text == null || text === '') {
return null;
}
try {
return JSON.parse(text);
} catch (e) {
return null;
}
};

View File

@@ -573,7 +573,12 @@ document.addEventListener('DOMContentLoaded', () => {
n3: coercePresetInt(preset.n3), n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4), n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5), n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6), n6: (() => {
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
return coercePresetInt(preset.mode);
}
return coercePresetInt(preset.n6);
})(),
}; };
}); });
if (!Object.keys(wirePresets).length) { if (!Object.keys(wirePresets).length) {

View File

@@ -4,6 +4,25 @@ let espnowSocketReady = false;
let espnowPendingMessages = []; let espnowPendingMessages = [];
let currentProfileIdCache = null; let currentProfileIdCache = null;
function coercePresetInt(v, def = 0) {
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
const t = parseInt(String(v), 10);
return Number.isFinite(t) ? t : def;
}
/** Style variant for wire ``n6``; presets may store ``mode`` or legacy ``n6``. */
function presetWireN6(preset, def = 0) {
if (!preset || typeof preset !== 'object') {
return def;
}
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
return coercePresetInt(preset.mode, def);
}
return coercePresetInt(preset.n6, def);
}
const getCurrentProfileId = async () => { const getCurrentProfileId = async () => {
try { try {
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } }); const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
@@ -243,6 +262,8 @@ document.addEventListener('DOMContentLoaded', () => {
const presetSaveButton = document.getElementById('preset-save-btn'); const presetSaveButton = document.getElementById('preset-save-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn'); const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn'); const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
const presetModeInput = document.getElementById('preset-mode-input');
const presetModeGroup = document.getElementById('preset-mode-group');
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) { if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
return; return;
@@ -297,7 +318,8 @@ document.addEventListener('DOMContentLoaded', () => {
patternConfig.parameter_mappings && patternConfig.parameter_mappings &&
typeof patternConfig.parameter_mappings === 'object' typeof patternConfig.parameter_mappings === 'object'
) { ) {
patternConfig = patternConfig.parameter_mappings; const { parameter_mappings: pm, data: _data, ...rest } = patternConfig;
patternConfig = { ...rest, ...pm };
} }
return patternConfig && typeof patternConfig === 'object' ? patternConfig : null; return patternConfig && typeof patternConfig === 'object' ? patternConfig : null;
}; };
@@ -311,6 +333,46 @@ document.addEventListener('DOMContentLoaded', () => {
return cfg.supports_manual !== false; return cfg.supports_manual !== false;
}; };
const getPatternModeOptions = (patternName) => {
const cfg = resolvePatternConfig(patternName);
if (!cfg || typeof cfg.mode !== 'object' || cfg.mode === null || Array.isArray(cfg.mode)) {
return null;
}
const entries = Object.entries(cfg.mode).filter(
([, label]) => typeof label === 'string' && label.trim(),
);
if (entries.length < 2) {
return null;
}
entries.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10));
return entries;
};
const patternSupportsModes = (patternName) => getPatternModeOptions(patternName) !== null;
const setPresetModeFieldVisible = (show) => {
if (!presetModeGroup) {
return;
}
presetModeGroup.hidden = !show;
presetModeGroup.style.display = show ? '' : 'none';
if (!show && presetModeInput) {
presetModeInput.innerHTML = '';
}
};
const presetStoredMode = (preset) => {
if (!preset || typeof preset !== 'object') {
return 0;
}
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
const m = parseInt(String(preset.mode), 10);
return Number.isFinite(m) ? m : 0;
}
const n6 = parseInt(String(preset.n6), 10);
return Number.isFinite(n6) ? n6 : 0;
};
const updateManualBeatNVisibility = () => { const updateManualBeatNVisibility = () => {
if (!presetManualBeatNWrap) { if (!presetManualBeatNWrap) {
return; return;
@@ -734,7 +796,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs // After values: show only mapped n params with labels from pattern.json; clear hidden inputs
updatePresetNLabels(patternName); updatePresetNLabels(patternName, preset);
updateManualModeAvailability(); updateManualModeAvailability();
updatePresetEditorTabActionsVisibility(); updatePresetEditorTabActionsVisibility();
}; };
@@ -793,10 +855,29 @@ document.addEventListener('DOMContentLoaded', () => {
return section ? section.dataset.zoneId : null; return section ? section.dataset.zoneId : null;
}; };
const updatePresetEditorTabActionsVisibility = () => { const updatePresetEditorTabActionsVisibility = async () => {
if (!presetRemoveFromTabButton) return; if (!presetRemoveFromTabButton) return;
const show = Boolean(currentEditTabId && currentEditId); if (!currentEditTabId || !currentEditId) {
presetRemoveFromTabButton.hidden = !show; presetRemoveFromTabButton.hidden = true;
return;
}
try {
const tabRes = await fetch(`/zones/${currentEditTabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabRes.ok) {
presetRemoveFromTabButton.hidden = false;
return;
}
const tabData = await tabRes.json();
const allowed =
typeof window.zoneAllowsPresets === 'function'
? window.zoneAllowsPresets(tabData)
: true;
presetRemoveFromTabButton.hidden = !allowed;
} catch (e) {
presetRemoveFromTabButton.hidden = false;
}
}; };
const updateTabDefaultPreset = async (presetId) => { const updateTabDefaultPreset = async (presetId) => {
@@ -827,8 +908,15 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetEditorModal) { if (presetEditorModal) {
presetEditorModal.classList.add('active'); presetEditorModal.classList.add('active');
} }
const patternName = presetPatternInput ? presetPatternInput.value : '';
const modeBefore = patternSupportsModes(patternName)
? presetStoredMode({
mode: presetModeInput ? presetModeInput.value : undefined,
n6: getNumberInput('preset-n6-input'),
})
: 0;
loadPatterns().then(() => { loadPatterns().then(() => {
updatePresetNLabels(presetPatternInput ? presetPatternInput.value : ''); updatePresetNLabels(patternName, { mode: modeBefore, n6: modeBefore });
updateColorSectionVisibility(); updateColorSectionVisibility();
}); });
}; };
@@ -859,11 +947,20 @@ document.addEventListener('DOMContentLoaded', () => {
})(), })(),
}; };
// Always store numeric parameters as n1..n8. // Always store numeric parameters as n1..n8 (except n6 when pattern uses mode).
const modeEntries = patternSupportsModes(payload.pattern)
? getPatternModeOptions(payload.pattern)
: null;
for (let i = 1; i <= 8; i++) { for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`; const nKey = `n${i}`;
if (modeEntries && nKey === 'n6') {
continue;
}
payload[nKey] = getNumberInput(`preset-${nKey}-input`); payload[nKey] = getNumberInput(`preset-${nKey}-input`);
} }
if (modeEntries && presetModeInput) {
payload.mode = parseInt(presetModeInput.value, 10) || 0;
}
return payload; return payload;
}; };
@@ -950,30 +1047,8 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}; };
const updatePresetNLabels = (patternName) => { const updatePresetNLabels = (patternName, presetForMode = null) => {
const rawPatternName = String(patternName || '').trim(); const patternConfig = resolvePatternConfig(patternName);
const normalizedPatternName = rawPatternName.endsWith('.py')
? rawPatternName.slice(0, -3)
: rawPatternName;
let patternConfig =
(cachedPatterns && cachedPatterns[rawPatternName]) ||
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
null;
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
const lower = normalizedPatternName.toLowerCase();
const matchedKey = Object.keys(cachedPatterns).find(
(k) => String(k).toLowerCase() === lower,
);
if (matchedKey) {
patternConfig = cachedPatterns[matchedKey];
}
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
patternConfig = patternConfig.data;
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object') {
patternConfig = patternConfig.parameter_mappings;
}
const labels = {}; const labels = {};
const visibleNKeys = new Set(); const visibleNKeys = new Set();
@@ -989,9 +1064,35 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
const modeEntries = patternSupportsModes(patternName) ? getPatternModeOptions(patternName) : null;
if (modeEntries) {
visibleNKeys.delete('n6');
}
if (presetModeInput) {
if (modeEntries) {
setPresetModeFieldVisible(true);
presetModeInput.innerHTML = '';
modeEntries.forEach(([val, label]) => {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = label.trim();
presetModeInput.appendChild(opt);
});
const modeVal = presetForMode ? presetStoredMode(presetForMode) : 0;
const modeStr = String(modeVal);
if ([...presetModeInput.options].some((o) => o.value === modeStr)) {
presetModeInput.value = modeStr;
} else if (presetModeInput.options.length) {
presetModeInput.selectedIndex = 0;
}
} else {
setPresetModeFieldVisible(false);
}
}
const hasPatternMeta = const hasPatternMeta =
patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0; patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0;
const hasAnyNLabel = visibleNKeys.size > 0; const hasAnyNLabel = visibleNKeys.size > 0 || Boolean(modeEntries);
for (let i = 1; i <= 8; i++) { for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`; const nKey = `n${i}`;
@@ -1073,6 +1174,26 @@ document.addEventListener('DOMContentLoaded', () => {
void sendPresetViaEspNow(presetId, preset || {}, []); void sendPresetViaEspNow(presetId, preset || {}, []);
}); });
const exportButton = document.createElement('button');
exportButton.className = 'btn btn-secondary btn-small';
exportButton.textContent = 'Export';
exportButton.addEventListener('click', async () => {
try {
const response = await fetch(`/presets/${presetId}/export`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Export failed');
}
const bundle = await response.json();
const safeName = ((preset && preset.name) || presetId).replace(/[^\w.-]+/g, '_');
window.downloadJsonFile(`preset-${safeName}.json`, bundle);
} catch (error) {
console.error('Export preset failed:', error);
alert('Failed to export preset.');
}
});
const deleteButton = document.createElement('button'); const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-danger btn-small'; deleteButton.className = 'btn btn-danger btn-small';
deleteButton.textContent = 'Delete'; deleteButton.textContent = 'Delete';
@@ -1102,6 +1223,7 @@ document.addEventListener('DOMContentLoaded', () => {
row.appendChild(label); row.appendChild(label);
row.appendChild(details); row.appendChild(details);
row.appendChild(editButton); row.appendChild(editButton);
row.appendChild(exportButton);
row.appendChild(sendButton); row.appendChild(sendButton);
row.appendChild(deleteButton); row.appendChild(deleteButton);
presetsList.appendChild(row); presetsList.appendChild(row);
@@ -1148,6 +1270,34 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetsCloseButton) { if (presetsCloseButton) {
presetsCloseButton.addEventListener('click', closeModal); presetsCloseButton.addEventListener('click', closeModal);
} }
const importPresetBtn = document.getElementById('import-preset-btn');
if (importPresetBtn) {
importPresetBtn.addEventListener('click', async () => {
const text = await window.pickJsonFile();
if (!text) return;
const bundle = window.parseJsonFileText(text);
if (!bundle || bundle.kind !== 'preset') {
alert('Invalid preset bundle file.');
return;
}
try {
const response = await fetch('/presets/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bundle }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Import failed');
}
await loadPresets();
} catch (error) {
console.error('Import preset failed:', error);
alert(error.message || 'Failed to import preset.');
}
});
}
if (presetsAddButton) { if (presetsAddButton) {
presetsAddButton.addEventListener('click', () => { presetsAddButton.addEventListener('click', () => {
clearForm(); clearForm();
@@ -1198,6 +1348,22 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Could not determine current zone.'); alert('Could not determine current zone.');
return; return;
} }
try {
const zoneCheck = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (zoneCheck.ok) {
const zoneDoc = await zoneCheck.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(zoneDoc)
) {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
}
}
} catch (e) {
console.warn('Could not verify zone content kind:', e);
}
// Load all presets // Load all presets
try { try {
@@ -1327,11 +1493,10 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
const kind = if (
typeof window.normalizeZoneContentKind === 'function' typeof window.zoneAllowsPresets === 'function' &&
? window.normalizeZoneContentKind(tabData) !window.zoneAllowsPresets(tabData)
: null; ) {
if (kind === 'sequences') {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.'); alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return; return;
} }
@@ -1697,14 +1862,6 @@ document.addEventListener('DOMContentLoaded', () => {
clearForm(); clearForm();
}); });
const coercePresetInt = (v, def = 0) => {
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
const t = parseInt(String(v), 10);
return Number.isFinite(t) ? t : def;
};
/** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */ /** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */
const coercePresetAuto = (preset) => { const coercePresetAuto = (preset) => {
if (!preset || typeof preset !== 'object') { if (!preset || typeof preset !== 'object') {
@@ -1826,7 +1983,7 @@ const sendPresetViaEspNow = async (
n3: coercePresetInt(preset.n3), n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4), n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5), n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6), n6: presetWireN6(preset),
manual_beat_n: coerceManualBeatN(preset), manual_beat_n: coerceManualBeatN(preset),
}, },
}, },
@@ -1929,7 +2086,7 @@ try {
// window may not exist in some environments; ignore. // window may not exist in some environments; ignore.
} }
// Store selected preset(s) per zone (multi-select; merge send order = click order, last wins on device). // Store selected preset per zone (single-select; one tile active, one driver push per click).
const zoneSelectedPresetIds = {}; const zoneSelectedPresetIds = {};
const zonePresetSelectionOrder = {}; const zonePresetSelectionOrder = {};
@@ -1956,19 +2113,21 @@ function getOrderedZonePresetSelection(zoneId) {
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id))); return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
} }
async function sendMergedZonePresetSelection(zoneId, tabData, allPresets) { /** Preset id that should show the tile outline (last click in selection order). */
const ids = getOrderedZonePresetSelection(zoneId); function getLastZonePresetSelectionId(zoneId) {
if (!ids.length) return; const order = getOrderedZonePresetSelection(zoneId);
for (let i = 0; i < ids.length; i += 1) { return order.length ? String(order[order.length - 1]) : null;
const pid = ids[i]; }
const preset = allPresets[pid];
if (!preset) continue; async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPresets) {
const names = const pid = String(presetId);
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function' const body = (allPresets && allPresets[pid]) || preset;
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid) if (!body) return;
: []; const names =
await sendPresetViaEspNow(pid, preset, names, false, false, '2'); window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
} ? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
: [];
await sendPresetViaEspNow(pid, body, names, false, false, '2');
} }
// Store selected preset per zone // Store selected preset per zone
@@ -2053,6 +2212,12 @@ const savePresetGrid = async (zoneId, presetGrid) => {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData)
) {
throw new Error('This zone is for sequences only.');
}
// Store as 2D grid // Store as 2D grid
tabData.presets = presetGrid; tabData.presets = presetGrid;
@@ -2265,7 +2430,9 @@ const renderTabPresets = async (zoneId, options = {}) => {
const preset = allPresets[presetId]; const preset = allPresets[presetId];
if (preset) { if (preset) {
ensureZonePresetSelection(zoneId); ensureZonePresetSelection(zoneId);
const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId)); const lastSelectedId = getLastZonePresetSelectionId(zoneId);
const isSelected =
lastSelectedId !== null && lastSelectedId === String(presetId);
const displayPreset = { const displayPreset = {
...preset, ...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors), colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
@@ -2285,7 +2452,10 @@ const renderTabPresets = async (zoneId, options = {}) => {
}); });
} }
if (typeof window.appendZoneSequenceTiles === 'function' && ck !== 'presets') { if (
typeof window.appendZoneSequenceTiles === 'function' &&
(typeof window.zoneAllowsSequences !== 'function' || window.zoneAllowsSequences(tabData))
) {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList); await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
} }
} catch (error) { } catch (error) {
@@ -2311,7 +2481,9 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
} }
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : []; const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow'; const pat = (preset.pattern || '').toLowerCase();
const mode = presetWireN6(preset);
const isRainbow = pat === 'rainbow' || (pat === 'colour_cycle' && mode === 1);
const barColors = isRainbow const barColors = isRainbow
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF'] ? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
: colors; : colors;
@@ -2389,34 +2561,32 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
ensureZonePresetSelection(zoneId); ensureZonePresetSelection(zoneId);
const z = String(zoneId); const z = String(zoneId);
const set = zoneSelectedPresetIds[z]; const set = zoneSelectedPresetIds[z];
const order = zonePresetSelectionOrder[z];
const idStr = String(presetId); const idStr = String(presetId);
if (set.has(idStr)) { const wasSelected = set.has(idStr);
set.delete(idStr); set.clear();
zonePresetSelectionOrder[z] = order.filter((x) => String(x) !== idStr); zonePresetSelectionOrder[z] = [];
} else { if (!wasSelected) {
set.add(idStr); set.add(idStr);
order.push(idStr); zonePresetSelectionOrder[z] = [idStr];
} }
const outlinePresetId = getLastZonePresetSelectionId(zoneId);
if (presetsListEl) { if (presetsListEl) {
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => { presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
const pid = rw.dataset.presetId; const pid = rw.dataset.presetId;
const btnEl = rw.querySelector('.preset-tile-main'); const btnEl = rw.querySelector('.preset-tile-main');
if (!btnEl || !pid) return; if (!btnEl || !pid) return;
if (set.has(String(pid))) btnEl.classList.add('active'); if (outlinePresetId && String(pid) === outlinePresetId) btnEl.classList.add('active');
else btnEl.classList.remove('active'); else btnEl.classList.remove('active');
}); });
} }
const orderList = getOrderedZonePresetSelection(zoneId); if (!wasSelected) {
if (orderList.length) { selectedPresets[zoneId] = idStr;
const lastPid = orderList[orderList.length - 1]; selectedPresetPayloads[zoneId] = (allPresets && allPresets[idStr]) || preset;
selectedPresets[zoneId] = lastPid; void sendZonePresetSelection(zoneId, tabData, idStr, preset, allPresets);
selectedPresetPayloads[zoneId] = (allPresets && allPresets[lastPid]) || preset;
} else { } else {
delete selectedPresets[zoneId]; delete selectedPresets[zoneId];
delete selectedPresetPayloads[zoneId]; delete selectedPresetPayloads[zoneId];
} }
void sendMergedZonePresetSelection(zoneId, tabData, allPresets);
}); });
if (canDrag) { if (canDrag) {
@@ -2526,6 +2696,13 @@ const removePresetFromTab = async (zoneId, presetId) => {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData)
) {
alert('This zone is for sequences only.');
return;
}
// Normalize to flat array // Normalize to flat array
let flat = []; let flat = [];

View File

@@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => {
const newProfileInput = document.getElementById("new-profile-name"); const newProfileInput = document.getElementById("new-profile-name");
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj"); const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
const createProfileButton = document.getElementById("create-profile-btn"); const createProfileButton = document.getElementById("create-profile-btn");
const importProfileButton = document.getElementById("import-profile-btn");
if (!profilesButton || !profilesModal || !profilesList) { if (!profilesButton || !profilesModal || !profilesList) {
return; return;
@@ -101,6 +102,26 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}); });
const exportButton = document.createElement("button");
exportButton.className = "btn btn-secondary btn-small";
exportButton.textContent = "Export";
exportButton.addEventListener("click", async () => {
try {
const response = await fetch(`/profiles/${profileId}/export`, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error("Export failed");
}
const bundle = await response.json();
const safeName = ((profile && profile.name) || profileId).replace(/[^\w.-]+/g, "_");
window.downloadJsonFile(`profile-${safeName}.json`, bundle);
} catch (error) {
console.error("Export profile failed:", error);
alert("Failed to export profile.");
}
});
const cloneButton = document.createElement("button"); const cloneButton = document.createElement("button");
cloneButton.className = "btn btn-secondary btn-small"; cloneButton.className = "btn btn-secondary btn-small";
cloneButton.textContent = "Clone"; cloneButton.textContent = "Clone";
@@ -177,6 +198,7 @@ document.addEventListener("DOMContentLoaded", () => {
row.appendChild(label); row.appendChild(label);
row.appendChild(applyButton); row.appendChild(applyButton);
if (editMode) { if (editMode) {
row.appendChild(exportButton);
row.appendChild(cloneButton); row.appendChild(cloneButton);
row.appendChild(deleteButton); row.appendChild(deleteButton);
} }
@@ -276,6 +298,60 @@ document.addEventListener("DOMContentLoaded", () => {
if (createProfileButton) { if (createProfileButton) {
createProfileButton.addEventListener("click", createProfile); createProfileButton.addEventListener("click", createProfile);
} }
const importProfile = async () => {
if (!isEditModeActive()) {
return;
}
const text = await window.pickJsonFile();
if (!text) {
return;
}
const bundle = window.parseJsonFileText(text);
if (!bundle || typeof bundle !== "object") {
alert("Invalid JSON file.");
return;
}
const defaultName =
(bundle.profile && bundle.profile.name) || "Imported profile";
const name = prompt("Profile name for import:", defaultName);
if (name === null) {
return;
}
const trimmed = String(name).trim();
if (!trimmed) {
alert("Profile name cannot be empty.");
return;
}
try {
const response = await fetch("/profiles/import", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ bundle, name: trimmed, apply: true }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || "Import failed");
}
const data = await response.json();
const newProfileId = data.id || Object.keys(data).find((k) => k !== "id");
if (newProfileId) {
await fetch(`/profiles/${newProfileId}/apply`, {
method: "POST",
headers: { Accept: "application/json" },
});
}
await loadProfiles();
await refreshTabsForActiveProfile();
} catch (error) {
console.error("Import profile failed:", error);
alert(error.message || "Failed to import profile.");
}
};
if (importProfileButton) {
importProfileButton.addEventListener("click", importProfile);
}
if (newProfileInput) { if (newProfileInput) {
newProfileInput.addEventListener("keypress", (event) => { newProfileInput.addEventListener("keypress", (event) => {
if (event.key === "Enter") { if (event.key === "Enter") {

View File

@@ -454,11 +454,10 @@ async function addSequenceToTab(sequenceId, zoneId) {
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!tabResponse.ok) throw new Error('Failed to load zone'); if (!tabResponse.ok) throw new Error('Failed to load zone');
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
const kind = if (
typeof window.normalizeZoneContentKind === 'function' typeof window.zoneAllowsSequences === 'function' &&
? window.normalizeZoneContentKind(tabData) !window.zoneAllowsSequences(tabData)
: null; ) {
if (kind === 'presets') {
alert('This zone is for presets only. Add presets from the zone Edit menu instead.'); alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
return; return;
} }
@@ -524,11 +523,10 @@ async function refreshEditTabSequencesUi(zoneId) {
const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!zoneRes.ok) throw new Error('zone'); if (!zoneRes.ok) throw new Error('zone');
const zone = await zoneRes.json(); const zone = await zoneRes.json();
const kind = if (
typeof window.normalizeZoneContentKind === 'function' typeof window.zoneAllowsSequences === 'function' &&
? window.normalizeZoneContentKind(zone) !window.zoneAllowsSequences(zone)
: null; ) {
if (kind === 'presets') {
currentEl.innerHTML = currentEl.innerHTML =
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>'; '<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>'; addEl.innerHTML = '<span class="muted-text">—</span>';
@@ -1092,12 +1090,31 @@ async function loadSequencesModalList() {
const nSteps = ln.reduce((a, l) => a + l.length, 0); const nSteps = ln.reduce((a, l) => a + l.length, 0);
const nLanes = ln.filter((l) => l.length > 0).length || 1; const nLanes = ln.filter((l) => l.length > 0).length || 1;
title.textContent = `${doc.name || id}${nLanes} lane(s), ${nSteps} step(s)`; title.textContent = `${doc.name || id}${nLanes} lane(s), ${nSteps} step(s)`;
const exportBtn = document.createElement('button');
exportBtn.type = 'button';
exportBtn.className = 'btn btn-secondary btn-small';
exportBtn.textContent = 'Export';
exportBtn.addEventListener('click', async () => {
try {
const response = await fetch(`/sequences/${id}/export`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error('Export failed');
const bundle = await response.json();
const safeName = String(doc.name || id).replace(/[^\w.-]+/g, '_');
window.downloadJsonFile(`sequence-${safeName}.json`, bundle);
} catch (e) {
console.error(e);
alert('Failed to export sequence.');
}
});
const edit = document.createElement('button'); const edit = document.createElement('button');
edit.type = 'button'; edit.type = 'button';
edit.className = 'btn btn-secondary btn-small'; edit.className = 'btn btn-secondary btn-small';
edit.textContent = 'Edit'; edit.textContent = 'Edit';
edit.addEventListener('click', () => openSequenceEditor(id, doc)); edit.addEventListener('click', () => openSequenceEditor(id, doc));
row.appendChild(title); row.appendChild(title);
row.appendChild(exportBtn);
row.appendChild(edit); row.appendChild(edit);
listEl.appendChild(row); listEl.appendChild(row);
}); });
@@ -1139,6 +1156,33 @@ document.addEventListener('DOMContentLoaded', () => {
openSequenceEditor(null, null); openSequenceEditor(null, null);
}); });
} }
const importSeqBtn = document.getElementById('import-sequence-btn');
if (importSeqBtn) {
importSeqBtn.addEventListener('click', async () => {
const text = await window.pickJsonFile();
if (!text) return;
const bundle = window.parseJsonFileText(text);
if (!bundle || bundle.kind !== 'sequence') {
alert('Invalid sequence bundle file.');
return;
}
try {
const response = await fetch('/sequences/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bundle }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Import failed');
}
await loadSequencesModalList();
} catch (e) {
console.error(e);
alert(e.message || 'Failed to import sequence.');
}
});
}
const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn'); const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn');
if (openPresetsFromSeq) { if (openPresetsFromSeq) {
openPresetsFromSeq.addEventListener('click', () => { openPresetsFromSeq.addEventListener('click', () => {

View File

@@ -598,6 +598,39 @@ body.preset-ui-run .edit-mode-only {
font-weight: 500; font-weight: 500;
} }
.preset-mode-field {
margin-top: 0.75rem;
margin-bottom: 0.25rem;
}
.preset-mode-field label {
display: block;
font-weight: 500;
margin-bottom: 0.35rem;
}
.preset-mode-input {
display: block;
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.5rem 0.6rem;
background-color: #3a3a3a;
color: #fff;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
}
.preset-mode-input:focus {
outline: none;
border-color: #6a9fff;
}
#preset-editor-modal .preset-mode-field {
grid-column: 1 / -1;
}
.n-input { .n-input {
flex: 0 0 var(--n-input-width, 5ch); flex: 0 0 var(--n-input-width, 5ch);
width: var(--n-input-width, 5ch); width: var(--n-input-width, 5ch);
@@ -1383,6 +1416,22 @@ body.preset-ui-run .edit-mode-only {
min-width: 8rem; min-width: 8rem;
} }
.zone-content-kind-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 1rem;
margin: 0.35rem 0 0.75rem;
}
.zone-content-kind-row label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: 0;
white-space: nowrap;
}
.zone-devices-label { .zone-devices-label {
display: block; display: block;
margin-top: 0.75rem; margin-top: 0.75rem;

View File

@@ -497,6 +497,42 @@ function normalizeZoneContentKind(zoneDoc) {
return null; return null;
} }
/** Display/save kind when ``content_kind`` is missing (legacy rows). */
function effectiveZoneContentKind(zoneDoc) {
const explicit = normalizeZoneContentKind(zoneDoc);
if (explicit) return explicit;
const seqIds = Array.isArray(zoneDoc && zoneDoc.sequence_ids)
? zoneDoc.sequence_ids.filter(Boolean)
: [];
const presetIds = tabPresetIdsInZoneDoc(zoneDoc || {});
if (seqIds.length > 0 && presetIds.length === 0) return 'sequences';
return 'presets';
}
/** @returns {'presets' | 'sequences'} */
function editModalContentKindSelected() {
const radio = document.querySelector('input[name="edit-zone-content-kind"]:checked');
return radio && radio.value === 'sequences' ? 'sequences' : 'presets';
}
function activeZoneContentKind(zoneDoc) {
const modal = document.getElementById('edit-zone-modal');
if (modal && modal.classList.contains('active')) {
return editModalContentKindSelected();
}
return effectiveZoneContentKind(zoneDoc);
}
/** @returns {boolean} */
function zoneAllowsPresets(zoneDoc) {
return activeZoneContentKind(zoneDoc) === 'presets';
}
/** @returns {boolean} */
function zoneAllowsSequences(zoneDoc) {
return activeZoneContentKind(zoneDoc) === 'sequences';
}
function applyZoneContentKindEditModal(kind) { function applyZoneContentKindEditModal(kind) {
const presetsBlock = document.getElementById('edit-zone-block-presets'); const presetsBlock = document.getElementById('edit-zone-block-presets');
const groupsBlock = document.getElementById('edit-zone-block-groups'); const groupsBlock = document.getElementById('edit-zone-block-groups');
@@ -504,17 +540,16 @@ function applyZoneContentKindEditModal(kind) {
const vis = (el, show) => { const vis = (el, show) => {
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
}; };
const k = kind === 'sequences' ? 'sequences' : 'presets';
vis(groupsBlock, true); vis(groupsBlock, true);
if (!kind) { vis(presetsBlock, k === 'presets');
vis(presetsBlock, true); vis(seqBlock, k === 'sequences');
vis(seqBlock, true);
return;
}
vis(presetsBlock, kind === 'presets');
vis(seqBlock, kind === 'sequences');
} }
window.normalizeZoneContentKind = normalizeZoneContentKind; window.normalizeZoneContentKind = normalizeZoneContentKind;
window.effectiveZoneContentKind = effectiveZoneContentKind;
window.zoneAllowsPresets = zoneAllowsPresets;
window.zoneAllowsSequences = zoneAllowsSequences;
// Load tabs list // Load tabs list
async function loadZones() { async function loadZones() {
@@ -573,10 +608,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
const zone = tabs[zoneId]; const zone = tabs[zoneId];
if (zone) { if (zone) {
const activeClass = zoneId === currentZoneId ? 'active' : ''; const activeClass = zoneId === currentZoneId ? 'active' : '';
let disp = zone.name || `Zone ${zoneId}`; const disp = zone.name || `Zone ${zoneId}`;
const kind = normalizeZoneContentKind(zone);
if (kind === 'presets') disp += ' · presets';
else if (kind === 'sequences') disp += ' · sequences';
html += ` html += `
<button class="zone-button ${activeClass}" <button class="zone-button ${activeClass}"
data-zone-id="${zoneId}" data-zone-id="${zoneId}"
@@ -622,10 +654,7 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
row.dataset.zoneId = String(zoneId); row.dataset.zoneId = String(zoneId);
const label = document.createElement("span"); const label = document.createElement("span");
let disp = (zone && zone.name) || zoneId; const disp = zone.name || `Zone ${zoneId}`;
const kind = normalizeZoneContentKind(zone);
if (kind === 'presets') disp += ' · presets';
else if (kind === 'sequences') disp += ' · sequences';
label.textContent = disp; label.textContent = disp;
if (String(zoneId) === String(currentZoneId)) { if (String(zoneId) === String(currentZoneId)) {
label.textContent = `${disp}`; label.textContent = `${disp}`;
@@ -999,8 +1028,7 @@ async function refreshEditTabPresetsUi(zoneId) {
return; return;
} }
const tabData = await tabRes.json(); const tabData = await tabRes.json();
const kind = normalizeZoneContentKind(tabData); if (!zoneAllowsPresets(tabData)) {
if (kind === 'sequences') {
currentEl.innerHTML = currentEl.innerHTML =
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>'; '<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>'; addEl.innerHTML = '<span class="muted-text">—</span>';
@@ -1138,8 +1166,13 @@ async function openEditZoneModal(zoneId, zone) {
}); });
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap); renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
const kind = effectiveZoneContentKind(tabData);
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
radio.checked = radio.value === kind;
});
if (modal) modal.classList.add("active"); if (modal) modal.classList.add("active");
applyZoneContentKindEditModal(normalizeZoneContentKind(tabData)); applyZoneContentKindEditModal(kind);
await refreshEditTabPresetsUi(zoneId); await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === "function") { if (typeof window.refreshEditTabSequencesUi === "function") {
await window.refreshEditTabSequencesUi(zoneId); await window.refreshEditTabSequencesUi(zoneId);
@@ -1147,11 +1180,13 @@ async function openEditZoneModal(zoneId, zone) {
} }
// Update an existing zone (name, group list; devices come from groups only). // Update an existing zone (name, group list; devices come from groups only).
async function updateZone(zoneId, name, groupRows) { async function updateZone(zoneId, name, groupRows, contentKind) {
try { try {
const gids = Array.isArray(groupRows) const gids = Array.isArray(groupRows)
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0) ? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
: []; : [];
const ck =
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
const response = await fetch(`/zones/${zoneId}`, { const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -1162,6 +1197,7 @@ async function updateZone(zoneId, name, groupRows) {
names: [], names: [],
group_ids: gids, group_ids: gids,
preset_group_ids: {}, preset_group_ids: {},
content_kind: ck,
}) })
}); });
@@ -1301,6 +1337,18 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
radio.addEventListener('change', async () => {
applyZoneContentKindEditModal(editModalContentKindSelected());
const zoneId = document.getElementById('edit-zone-id')?.value;
if (!zoneId) return;
await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === 'function') {
await window.refreshEditTabSequencesUi(zoneId);
}
});
});
// Set up edit zone form // Set up edit zone form
const editZoneForm = document.getElementById('edit-zone-form'); const editZoneForm = document.getElementById('edit-zone-form');
if (editZoneForm) { if (editZoneForm) {
@@ -1314,7 +1362,7 @@ document.addEventListener('DOMContentLoaded', () => {
const groupRows = window.__editTabGroupRows || []; const groupRows = window.__editTabGroupRows || [];
if (zoneId && name) { if (zoneId && name) {
await updateZone(zoneId, name, groupRows); await updateZone(zoneId, name, groupRows, editModalContentKindSelected());
editZoneForm.reset(); editZoneForm.reset();
} }
}); });

View File

@@ -83,11 +83,10 @@
<input type="text" id="new-zone-name" placeholder="Zone name"> <input type="text" id="new-zone-name" placeholder="Zone name">
<button class="btn btn-primary" id="create-zone-btn">Create</button> <button class="btn btn-primary" id="create-zone-btn">Create</button>
</div> </div>
<fieldset class="muted-text" style="margin:0.35rem 0 0.75rem;border:none;padding:0;"> <div class="zone-content-kind-row muted-text">
<legend style="font-size:0.85em;margin-bottom:0.35rem;">This zone is for</legend> <label><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
<label style="margin-right:1rem;"><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label> <label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
</fieldset> </div>
<div id="zones-list-modal" class="profiles-list"></div> <div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="zones-close-btn">Close</button> <button class="btn btn-secondary" id="zones-close-btn">Close</button>
@@ -101,12 +100,12 @@
<h2>Edit Zone</h2> <h2>Edit Zone</h2>
<form id="edit-zone-form"> <form id="edit-zone-form">
<input type="hidden" id="edit-zone-id"> <input type="hidden" id="edit-zone-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div>
<label>Zone Name:</label> <label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required> <input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<div class="zone-content-kind-row muted-text">
<label><input type="radio" name="edit-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="edit-zone-content-kind" value="sequences"> Sequences</label>
</div>
<div id="edit-zone-block-groups"> <div id="edit-zone-block-groups">
<label class="zone-devices-label">Device groups on this zone</label> <label class="zone-devices-label">Device groups on this zone</label>
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div> <div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
@@ -123,6 +122,10 @@
<label class="zone-presets-section-label">Add a sequence to this zone</label> <label class="zone-presets-section-label">Add a sequence to this zone</label>
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div> <div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
</div> </div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -134,6 +137,7 @@
<div class="profiles-actions"> <div class="profiles-actions">
<input type="text" id="new-profile-name" placeholder="Profile name"> <input type="text" id="new-profile-name" placeholder="Profile name">
<button class="btn btn-primary" id="create-profile-btn">Create</button> <button class="btn btn-primary" id="create-profile-btn">Create</button>
<button type="button" class="btn btn-secondary" id="import-profile-btn">Import</button>
</div> </div>
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;"> <div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;"> <label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
@@ -183,10 +187,6 @@
<h2>Edit device group</h2> <h2>Edit device group</h2>
<form id="edit-group-form"> <form id="edit-group-form">
<input type="hidden" id="edit-group-id"> <input type="hidden" id="edit-group-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
</div>
<label for="edit-group-name">Group name</label> <label for="edit-group-name">Group name</label>
<input type="text" id="edit-group-name" required autocomplete="off"> <input type="text" id="edit-group-name" required autocomplete="off">
<label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;"> <label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;">
@@ -227,6 +227,10 @@
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label> <label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small> <small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea> <textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -301,6 +305,7 @@
<h2>Presets</h2> <h2>Presets</h2>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" id="preset-add-btn">Add</button> <button class="btn btn-primary" id="preset-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="import-preset-btn">Import</button>
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button> <button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
</div> </div>
<div id="presets-list" class="profiles-list"></div> <div id="presets-list" class="profiles-list"></div>
@@ -316,6 +321,7 @@
<h2>Sequences</h2> <h2>Sequences</h2>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button> <button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="import-sequence-btn">Import</button>
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button> <button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
</div> </div>
<div id="sequences-list" class="profiles-list"></div> <div id="sequences-list" class="profiles-list"></div>
@@ -344,10 +350,8 @@
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;"></p> <p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;"></p>
</div> </div>
<div id="sequence-editor-lanes"></div> <div id="sequence-editor-lanes"></div>
<div class="modal-actions" style="margin-top:0.75rem;">
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
</div>
<div class="modal-actions preset-editor-modal-actions"> <div class="modal-actions preset-editor-modal-actions">
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button> <button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button> <button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button> <button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
@@ -401,6 +405,10 @@
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span> <span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
</div> </div>
</div> </div>
<div class="preset-editor-field preset-mode-field" id="preset-mode-group" hidden>
<label for="preset-mode-input" id="preset-mode-label">Mode</label>
<select id="preset-mode-input" class="preset-mode-input"></select>
</div>
<div class="n-params-grid"> <div class="n-params-grid">
<div class="n-param-group"> <div class="n-param-group">
<label for="preset-n1-input" id="preset-n1-label">n1:</label> <label for="preset-n1-input" id="preset-n1-label">n1:</label>
@@ -789,6 +797,7 @@
<script src="/static/help.js"></script> <script src="/static/help.js"></script>
<script src="/static/led_tool.js"></script> <script src="/static/led_tool.js"></script>
<script src="/static/color_palette.js"></script> <script src="/static/color_palette.js"></script>
<script src="/static/bundle_io.js"></script>
<script src="/static/profiles.js"></script> <script src="/static/profiles.js"></script>
<script src="/static/zone_palette.js"></script> <script src="/static/zone_palette.js"></script>
<script src="/static/patterns.js"></script> <script src="/static/patterns.js"></script>

View File

@@ -43,6 +43,8 @@ import json
import struct import struct
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from util.espnow_message import wire_n6
BINARY_ENVELOPE_VERSION_1 = 1 BINARY_ENVELOPE_VERSION_1 = 1
BINARY_ENVELOPE_VERSION_2 = 2 BINARY_ENVELOPE_VERSION_2 = 2
HEADER_LEN = 5 HEADER_LEN = 5
@@ -108,7 +110,7 @@ def _pack_preset_dict(name: str, preset: Dict[str, Any]) -> bytes:
n3 = _clamp_i16(preset.get("n3", 0)) n3 = _clamp_i16(preset.get("n3", 0))
n4 = _clamp_i16(preset.get("n4", 0)) n4 = _clamp_i16(preset.get("n4", 0))
n5 = _clamp_i16(preset.get("n5", 0)) n5 = _clamp_i16(preset.get("n5", 0))
n6 = _clamp_i16(preset.get("n6", 0)) n6 = _clamp_i16(wire_n6(preset))
parts.append( parts.append(
struct.pack( struct.pack(
"<HBBhhhhhh", "<HBBhhhhhh",

View File

@@ -113,6 +113,21 @@ def resolve_preset_background_hex(preset_data, palette_colors=None):
return _hex_from_background_raw(bg_raw) return _hex_from_background_raw(bg_raw)
def wire_n6(preset_data, default=0):
"""Resolve style mode for the wire (``n6``); preset may store ``mode`` or ``n6``."""
if not isinstance(preset_data, dict):
return default
if preset_data.get("mode") is not None:
try:
return max(0, int(preset_data["mode"]))
except (TypeError, ValueError):
pass
try:
return max(0, int(preset_data.get("n6", default) or 0))
except (TypeError, ValueError):
return default
def build_preset_dict(preset_data, palette_colors=None): def build_preset_dict(preset_data, palette_colors=None):
""" """
Convert preset data to API-compliant format. Convert preset data to API-compliant format.
@@ -188,7 +203,7 @@ def build_preset_dict(preset_data, palette_colors=None):
"n3": preset_data.get("n3", 0), "n3": preset_data.get("n3", 0),
"n4": preset_data.get("n4", 0), "n4": preset_data.get("n4", 0),
"n5": preset_data.get("n5", 0), "n5": preset_data.get("n5", 0),
"n6": preset_data.get("n6", 0) "n6": wire_n6(preset_data),
} }
return preset return preset

441
src/util/profile_bundle.py Normal file
View File

@@ -0,0 +1,441 @@
"""Export/import profile bundles (profile, zones, presets, sequences, palette)."""
from __future__ import annotations
import copy
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
BUNDLE_VERSION = 1
KIND_PROFILE = "profile"
KIND_PRESET = "preset"
KIND_SEQUENCE = "sequence"
def _allocate_id(model, cache: Dict[str, int]) -> str:
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 _palette_colors(palette_model, palette_id) -> List:
if not palette_id:
return []
try:
colors = palette_model.read(str(palette_id))
except Exception:
colors = None
if isinstance(colors, list):
return list(colors)
if isinstance(colors, dict) and isinstance(colors.get("colors"), list):
return list(colors["colors"])
return []
def _walk_preset_refs(value, out: Set[str]) -> None:
if isinstance(value, list):
for item in value:
_walk_preset_refs(item, out)
elif value is not None and value != "":
out.add(str(value))
def _preset_ids_in_zone(zone: Dict[str, Any]) -> Set[str]:
ids: Set[str] = set()
if not isinstance(zone, dict):
return ids
_walk_preset_refs(zone.get("presets"), ids)
_walk_preset_refs(zone.get("presets_flat"), ids)
if zone.get("default_preset") not in (None, ""):
ids.add(str(zone["default_preset"]))
return ids
def _preset_ids_in_sequence(seq: Dict[str, Any]) -> Set[str]:
ids: Set[str] = set()
if not isinstance(seq, dict):
return ids
for lane in seq.get("lanes") or []:
if not isinstance(lane, list):
continue
for step in lane:
if isinstance(step, dict) and step.get("preset_id") not in (None, ""):
ids.add(str(step["preset_id"]))
for step in seq.get("steps") or []:
if isinstance(step, dict) and step.get("preset_id") not in (None, ""):
ids.add(str(step["preset_id"]))
return ids
def _map_preset_container(
value,
id_map: Dict[str, str],
preset_cache: Dict[str, int],
new_profile_id: str,
new_presets: Dict[str, Dict[str, Any]],
presets_model,
) -> Any:
if isinstance(value, list):
return [
_map_preset_container(v, id_map, preset_cache, new_profile_id, new_presets, presets_model)
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_model.read(preset_id)
if not preset_data:
return None
new_preset_id = _allocate_id(presets_model, 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
def _map_sequence_lanes(
seq: Dict[str, Any],
preset_id_map: Dict[str, str],
) -> Dict[str, Any]:
out = copy.deepcopy(seq)
lanes = out.get("lanes")
if isinstance(lanes, list):
for lane in lanes:
if not isinstance(lane, list):
continue
for step in lane:
if not isinstance(step, dict):
continue
pid = step.get("preset_id")
if pid is not None and str(pid) in preset_id_map:
step["preset_id"] = preset_id_map[str(pid)]
steps = out.get("steps")
if isinstance(steps, list):
for step in steps:
if not isinstance(step, dict):
continue
pid = step.get("preset_id")
if pid is not None and str(pid) in preset_id_map:
step["preset_id"] = preset_id_map[str(pid)]
return out
def export_profile_bundle(
profile_id: str,
profiles_model,
zones_model,
presets_model,
sequences_model,
palette_model,
) -> Dict[str, Any]:
source = profiles_model.read(profile_id)
if not source:
raise ValueError("Profile not found")
zone_ids = source.get("zones")
if not isinstance(zone_ids, list) or not zone_ids:
zone_ids = source.get("zone_order") or []
zone_ids = [str(z) for z in zone_ids if z is not None]
zones_out: Dict[str, Any] = {}
preset_ids: Set[str] = set()
sequence_ids: Set[str] = set()
for zid in zone_ids:
zone = zones_model.read(zid)
if not zone:
continue
zones_out[zid] = copy.deepcopy(zone)
preset_ids |= _preset_ids_in_zone(zone)
for sid in zone.get("sequence_ids") or []:
if sid is not None and str(sid).strip():
sequence_ids.add(str(sid))
sequences_out: Dict[str, Any] = {}
for sid in sequence_ids:
seq = sequences_model.read(sid)
if not seq or str(seq.get("profile_id")) != str(profile_id):
continue
sequences_out[sid] = copy.deepcopy(seq)
preset_ids |= _preset_ids_in_sequence(seq)
presets_out: Dict[str, Any] = {}
for pid in preset_ids:
pdata = presets_model.read(pid)
if pdata and str(pdata.get("profile_id")) == str(profile_id):
presets_out[pid] = copy.deepcopy(pdata)
profile_doc = copy.deepcopy(source)
profile_doc.pop("palette", None)
return {
"version": BUNDLE_VERSION,
"kind": KIND_PROFILE,
"profile": profile_doc,
"palette": {"colors": _palette_colors(palette_model, source.get("palette_id"))},
"zones": zones_out,
"presets": presets_out,
"sequences": sequences_out,
}
def import_profile_bundle(
bundle: Dict[str, Any],
profiles_model,
zones_model,
presets_model,
sequences_model,
palette_model,
*,
name: Optional[str] = None,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("version") not in (BUNDLE_VERSION, str(BUNDLE_VERSION)):
raise ValueError("Unsupported bundle version")
if bundle.get("kind") not in (KIND_PROFILE, None):
raise ValueError("Not a profile bundle")
source_profile = bundle.get("profile")
if not isinstance(source_profile, dict):
raise ValueError("Bundle missing profile")
source_name = source_profile.get("name") or "Imported profile"
new_name = (name or source_name).strip() or source_name
profile_type = source_profile.get("type", "zones")
profile_cache: Dict[str, int] = {}
palette_cache: Dict[str, int] = {}
zone_cache: Dict[str, int] = {}
preset_cache: Dict[str, int] = {}
sequence_cache: Dict[str, int] = {}
new_profile_id = _allocate_id(profiles_model, profile_cache)
new_palette_id = _allocate_id(palette_model, palette_cache)
palette_in = bundle.get("palette") or {}
palette_colors = palette_in.get("colors") if isinstance(palette_in, dict) else []
if not isinstance(palette_colors, list):
palette_colors = []
preset_id_map: Dict[str, str] = {}
new_presets: Dict[str, Dict[str, Any]] = {}
new_zones: Dict[str, Dict[str, Any]] = {}
new_sequences: Dict[str, Dict[str, Any]] = {}
sequence_id_map: Dict[str, str] = {}
zones_in = bundle.get("zones") if isinstance(bundle.get("zones"), dict) else {}
presets_in = bundle.get("presets") if isinstance(bundle.get("presets"), dict) else {}
sequences_in = bundle.get("sequences") if isinstance(bundle.get("sequences"), dict) else {}
for old_pid, pdata in presets_in.items():
if not isinstance(pdata, dict):
continue
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(new_profile_id)
new_presets[new_pid] = clone
preset_id_map[str(old_pid)] = new_pid
for old_sid, sdata in sequences_in.items():
if not isinstance(sdata, dict):
continue
new_sid = _allocate_id(sequences_model, sequence_cache)
clone = _map_sequence_lanes(sdata, preset_id_map)
clone["profile_id"] = str(new_profile_id)
new_sequences[new_sid] = clone
sequence_id_map[str(old_sid)] = new_sid
source_zone_order = source_profile.get("zones")
if not isinstance(source_zone_order, list):
source_zone_order = list(zones_in.keys())
cloned_zone_ids: List[str] = []
for old_zid in source_zone_order:
zone = zones_in.get(str(old_zid))
if not isinstance(zone, dict):
continue
new_zid = _allocate_id(zones_model, zone_cache)
clone_data: Dict[str, Any] = {
"name": zone.get("name") or f"Zone {old_zid}",
"names": list(zone.get("names") or []),
}
mapped_presets = _map_preset_container(
zone.get("presets"),
preset_id_map,
preset_cache,
new_profile_id,
new_presets,
presets_model,
)
if mapped_presets is not None:
clone_data["presets"] = mapped_presets
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,
presets_model,
)
if "default_preset" in extra and extra["default_preset"] is not None:
old_dp = str(extra["default_preset"])
if old_dp in preset_id_map:
extra["default_preset"] = preset_id_map[old_dp]
if "sequence_ids" in extra and isinstance(extra.get("sequence_ids"), list):
extra["sequence_ids"] = [
sequence_id_map.get(str(s), str(s))
for s in extra["sequence_ids"]
if s is not None
]
clone_data.update(extra)
new_zones[new_zid] = clone_data
cloned_zone_ids.append(new_zid)
new_profile_data = {
"name": new_name,
"type": profile_type,
"zones": cloned_zone_ids,
"scenes": list(source_profile.get("scenes", []))
if isinstance(source_profile.get("scenes"), list)
else [],
"palette_id": str(new_palette_id),
}
palette_model[str(new_palette_id)] = list(palette_colors)
for pid, pdata in new_presets.items():
presets_model[pid] = pdata
for zid, zdata in new_zones.items():
zones_model[zid] = zdata
for sid, sdata in new_sequences.items():
sequences_model[sid] = sdata
profiles_model[str(new_profile_id)] = new_profile_data
palette_model.save()
presets_model.save()
zones_model.save()
sequences_model.save()
profiles_model.save()
return str(new_profile_id), new_profile_data
def export_preset_bundle(preset_id: str, presets_model) -> Dict[str, Any]:
preset = presets_model.read(preset_id)
if not preset:
raise ValueError("Preset not found")
return {
"version": BUNDLE_VERSION,
"kind": KIND_PRESET,
"preset": copy.deepcopy(preset),
}
def import_preset_bundle(
bundle: Dict[str, Any],
presets_model,
profile_id: str,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("kind") != KIND_PRESET:
raise ValueError("Not a preset bundle")
preset = bundle.get("preset")
if not isinstance(preset, dict):
raise ValueError("Bundle missing preset")
new_id = presets_model.create(profile_id)
data = copy.deepcopy(preset)
data["profile_id"] = str(profile_id)
presets_model.update(new_id, data)
return str(new_id), presets_model.read(new_id) or data
def export_sequence_bundle(
sequence_id: str,
sequences_model,
presets_model,
*,
profile_id: Optional[str] = None,
) -> Dict[str, Any]:
seq = sequences_model.read(sequence_id)
if not seq:
raise ValueError("Sequence not found")
if profile_id is not None and str(seq.get("profile_id")) != str(profile_id):
raise ValueError("Sequence not found")
pid = str(profile_id or seq.get("profile_id") or "")
preset_ids = _preset_ids_in_sequence(seq)
presets_out: Dict[str, Any] = {}
for old_pid in preset_ids:
pdata = presets_model.read(old_pid)
if pdata and (not pid or str(pdata.get("profile_id")) == pid):
presets_out[old_pid] = copy.deepcopy(pdata)
return {
"version": BUNDLE_VERSION,
"kind": KIND_SEQUENCE,
"sequence": copy.deepcopy(seq),
"presets": presets_out,
}
def import_sequence_bundle(
bundle: Dict[str, Any],
sequences_model,
presets_model,
profile_id: str,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("kind") != KIND_SEQUENCE:
raise ValueError("Not a sequence bundle")
seq = bundle.get("sequence")
if not isinstance(seq, dict):
raise ValueError("Bundle missing sequence")
preset_cache: Dict[str, int] = {}
preset_id_map: Dict[str, str] = {}
new_presets: Dict[str, Dict[str, Any]] = {}
presets_in = bundle.get("presets") if isinstance(bundle.get("presets"), dict) else {}
for old_pid, pdata in presets_in.items():
if not isinstance(pdata, dict):
continue
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(profile_id)
new_presets[new_pid] = clone
preset_id_map[str(old_pid)] = new_pid
for old_pid in _preset_ids_in_sequence(seq):
op = str(old_pid)
if op not in preset_id_map:
pdata = presets_model.read(op)
if pdata:
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(profile_id)
new_presets[new_pid] = clone
preset_id_map[op] = new_pid
for pid, pdata in new_presets.items():
presets_model[pid] = pdata
if new_presets:
presets_model.save()
new_seq_id = sequences_model.create(profile_id)
mapped = _map_sequence_lanes(seq, preset_id_map)
mapped["profile_id"] = str(profile_id)
sequences_model.update(new_seq_id, mapped)
return str(new_seq_id), sequences_model.read(new_seq_id) or mapped

View File

@@ -25,18 +25,20 @@ def test_preset():
print("\nTesting update preset") print("\nTesting update preset")
update_data = { update_data = {
"name": "test_preset", "name": "test_preset",
"pattern": "on", "pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00"], "colors": ["#FF0000", "#00FF00"],
"delay": 100, "delay": 100,
"brightness": 127, "brightness": 127,
"n1": 10, "n1": 10,
"n2": 20 "n2": 20,
"mode": 1,
} }
result = presets.update(preset_id, update_data) result = presets.update(preset_id, update_data)
assert result is True assert result is True
updated = presets.read(preset_id) updated = presets.read(preset_id)
assert updated["name"] == "test_preset" assert updated["name"] == "test_preset"
assert updated["pattern"] == "on" assert updated["pattern"] == "colour_cycle"
assert updated["mode"] == 1
assert updated["delay"] == 100 assert updated["delay"] == 100
print("\nTesting list presets") print("\nTesting list presets")

View File

@@ -29,6 +29,21 @@ def test_pack_parse_v2_brightness_only():
assert data == {"v": "1", "b": 128} assert data == {"v": "1", "b": 128}
def test_pack_parse_v2_mode_maps_to_n6():
raw = pack_binary_envelope_v2(
presets={
"m": {
"p": "meteor",
"c": ["#aabbcc"],
"mode": 2,
"n6": 0,
}
},
)
data = parse_binary_envelope_v2(raw)
assert data["presets"]["m"]["n6"] == 2
def test_pack_parse_v2_full(): def test_pack_parse_v2_full():
raw = pack_binary_envelope_v2( raw = pack_binary_envelope_v2(
presets={ presets={

View File

@@ -50,6 +50,17 @@ def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) ->
raise AssertionError(f"Could not find id for {field}={value!r}") raise AssertionError(f"Could not find id for {field}={value!r}")
def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
"""Sequences/scenes/presets need an active profile in session."""
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
assert resp.status_code == 201
profile_id = next(iter(resp.json().keys()))
resp = c.post(f"{base_url}/profiles/{profile_id}/apply")
assert resp.status_code == 200
return str(profile_id)
def _start_microdot_server(app: Microdot, host: str, port: int): def _start_microdot_server(app: Microdot, host: str, port: int):
""" """
Start Microdot server on a background thread. Start Microdot server on a background thread.
@@ -474,6 +485,36 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
resp = c.delete(f"{base_url}/zones/{zone_id}") resp = c.delete(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.get(f"{base_url}/profiles/{profile_id}/export")
assert resp.status_code == 200
bundle = resp.json()
assert bundle.get("kind") == "profile"
assert isinstance(bundle.get("presets"), dict)
import_name = f"pytest-imported-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/profiles/import",
json={"bundle": bundle, "name": import_name, "apply": False},
)
assert resp.status_code == 201
imported_profile_id = resp.json().get("id") or next(
k for k in resp.json().keys() if k != "id"
)
resp = c.delete(f"{base_url}/profiles/{imported_profile_id}")
assert resp.status_code == 200
resp = c.get(f"{base_url}/presets/{first_preset_id}/export")
assert resp.status_code == 200
assert resp.json().get("kind") == "preset"
resp = c.post(
f"{base_url}/presets/import",
json={"bundle": resp.json()},
)
assert resp.status_code == 201
imported_preset_id = next(iter(resp.json().keys()))
resp = c.delete(f"{base_url}/presets/{imported_preset_id}")
assert resp.status_code == 200
# Profile clone + update endpoints. # Profile clone + update endpoints.
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}" clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name}) resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
@@ -508,6 +549,8 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
base_url: str = server["base_url"] base_url: str = server["base_url"]
sender: DummySender = server["sender"] sender: DummySender = server["sender"]
_create_and_apply_profile(c, base_url)
# Groups. # Groups.
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}" unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name}) resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
@@ -715,6 +758,13 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200 assert resp.status_code == 200
definitions = resp.json() definitions = resp.json()
assert isinstance(definitions, dict) assert isinstance(definitions, dict)
assert "colour_cycle" in definitions
cc_mode = definitions["colour_cycle"].get("mode")
assert isinstance(cc_mode, dict)
assert "0" in cc_mode and "1" in cc_mode
assert "blink" in definitions
blink_mode = definitions["blink"].get("mode")
assert not isinstance(blink_mode, dict) or len(blink_mode) < 2
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}" pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
resp = c.post( resp = c.post(

View File

@@ -0,0 +1,51 @@
"""Preset style mode: ``mode`` field, wire ``n6``, and pattern.json metadata."""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(0, str(PROJECT_ROOT / "led-driver" / "src"))
from patterns.pattern_modes import style_mode # noqa: E402
from preset import Preset # noqa: E402
from util.espnow_message import build_preset_dict, wire_n6 # noqa: E402
def test_wire_n6_prefers_mode_over_n6():
assert wire_n6({"mode": 2, "n6": 0}) == 2
assert wire_n6({"n6": 1}) == 1
assert wire_n6({}) == 0
def test_build_preset_dict_maps_mode_to_n6():
wire = build_preset_dict({"pattern": "meteor", "mode": 2, "colors": ["#ffffff"]})
assert wire["n6"] == 2
assert wire["p"] == "meteor"
def test_preset_edit_accepts_mode_alias():
p = Preset({"p": "colour_cycle", "mode": 1, "d": 100, "c": [(255, 255, 255)]})
assert p.n6 == 1
def test_style_mode_reads_mode_and_legacy_pattern_id():
p = Preset({"p": "colour_cycle", "mode": 0, "d": 100, "c": [(255, 0, 0)]})
assert style_mode(p, 0, {"rainbow": 1}) == 0
legacy = Preset({"p": "rainbow", "d": 100, "c": [(255, 0, 0)]})
assert style_mode(legacy, 0, {"rainbow": 1}) == 1
def test_pattern_json_defines_mode_for_merged_patterns():
path = PROJECT_ROOT / "db" / "pattern.json"
definitions = json.loads(path.read_text(encoding="utf-8"))
for name in ("colour_cycle", "chase", "aurora", "meteor", "particles", "sparkle"):
assert name in definitions, name
mode = definitions[name].get("mode")
assert isinstance(mode, dict), name
assert len(mode) >= 2, name
blink = definitions.get("blink", {})
assert "mode" not in blink or not isinstance(blink.get("mode"), dict) or len(blink.get("mode", {})) < 2

View File

@@ -0,0 +1,133 @@
"""Unit tests for profile/preset/sequence bundle import/export."""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from models.pallet import Palette # noqa: E402
from models.preset import Preset # noqa: E402
from models.profile import Profile # noqa: E402
from models.sequence import Sequence # noqa: E402
from models.zone import Zone # noqa: E402
from util.profile_bundle import ( # noqa: E402
export_preset_bundle,
export_profile_bundle,
export_sequence_bundle,
import_preset_bundle,
import_profile_bundle,
import_sequence_bundle,
)
def _fresh_models(tmp_path, monkeypatch):
import models.model as model_mod
db = tmp_path / "db"
db.mkdir()
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(db))
for cls in (Profile, Zone, Preset, Sequence, Palette):
if hasattr(cls, "_instance"):
delattr(cls, "_instance")
profiles = Profile()
zones = Zone()
presets = Preset()
sequences = Sequence()
palette = Palette()
return profiles, zones, presets, sequences, palette
def test_profile_export_import_round_trip(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("Source")
zid = zones.create(name="main")
preset_id = presets.create(pid)
presets.update(
preset_id,
{
"name": "Test preset",
"pattern": "blink",
"colors": ["#ff0000"],
"brightness": 200,
"delay": 50,
},
)
zones.update(
zid,
{
"presets_flat": [str(preset_id)],
"default_preset": str(preset_id),
},
)
seq_id = sequences.create(pid)
sequences.update(
seq_id,
{
"name": "Beat seq",
"lanes": [[{"preset_id": str(preset_id), "group_ids": [], "beats": 2}]],
"lanes_group_ids": [[]],
},
)
zones.update(zid, {"sequence_ids": [str(seq_id)]})
profiles.update(pid, {"zones": [str(zid)]})
palette_id = profiles.read(pid)["palette_id"]
palette.update(palette_id, {"colors": ["#112233", "#445566"]})
bundle = export_profile_bundle(
str(pid), profiles, zones, presets, sequences, palette
)
assert bundle["kind"] == "profile"
assert str(preset_id) in bundle["presets"]
assert str(seq_id) in bundle["sequences"]
assert bundle["palette"]["colors"] == ["#112233", "#445566"]
new_pid, _ = import_profile_bundle(
bundle, profiles, zones, presets, sequences, palette, name="Imported"
)
assert new_pid != str(pid)
found = [
presets.read(k)
for k in presets.list()
if isinstance(presets.read(k), dict)
and str(presets.read(k).get("profile_id")) == str(new_pid)
and presets.read(k).get("name") == "Test preset"
]
assert found
def test_preset_export_import(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("P")
preset_id = presets.create(pid)
presets.update(preset_id, {"name": "Solo", "pattern": "on", "colors": ["#00ff00"]})
bundle = export_preset_bundle(str(preset_id), presets)
assert bundle["kind"] == "preset"
new_id, data = import_preset_bundle(bundle, presets, str(pid))
assert new_id != str(preset_id)
assert data["name"] == "Solo"
def test_sequence_export_import_with_presets(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("P")
preset_id = presets.create(pid)
presets.update(preset_id, {"name": "Step", "pattern": "off"})
seq_id = sequences.create(pid)
sequences.update(
seq_id,
{"name": "S", "lanes": [[{"preset_id": str(preset_id), "beats": 1}]]},
)
bundle = export_sequence_bundle(str(seq_id), sequences, presets, profile_id=str(pid))
assert str(preset_id) in bundle["presets"]
new_seq_id, doc = import_sequence_bundle(bundle, sequences, presets, str(pid))
assert new_seq_id != str(seq_id)
assert doc["lanes"][0][0]["preset_id"] != str(preset_id)