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:
@@ -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}}
|
||||
@@ -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": []}
|
||||
216
db/pattern.json
216
db/pattern.json
@@ -11,15 +11,12 @@
|
||||
"max_colors": 0,
|
||||
"supports_manual": true
|
||||
},
|
||||
"rainbow": {
|
||||
"n1": "Step Rate",
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"max_colors": 0,
|
||||
"supports_manual": true
|
||||
},
|
||||
"colour_cycle": {
|
||||
"n1": "Step Rate",
|
||||
"n1": "Step rate",
|
||||
"mode": {
|
||||
"0": "Scroll palette gradient",
|
||||
"1": "Rainbow wheel (preset colours ignored)"
|
||||
},
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"max_colors": 10,
|
||||
@@ -40,7 +37,11 @@
|
||||
"max_delay": 10000,
|
||||
"max_colors": 2,
|
||||
"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": {
|
||||
"n1": "Attack",
|
||||
@@ -80,7 +81,7 @@
|
||||
"flame": {
|
||||
"n1": "Min brightness",
|
||||
"n2": "Breath period (ms)",
|
||||
"n3": "Spark gap min (ms, 0=default 10–30 s, -1=off)",
|
||||
"n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)",
|
||||
"n4": "Spark gap max (ms)",
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
@@ -88,8 +89,8 @@
|
||||
"supports_manual": false
|
||||
},
|
||||
"twinkle": {
|
||||
"n1": "Twinkle activity (1–255, higher = more changes)",
|
||||
"n2": "Density (0–255, higher = more of the strip lit)",
|
||||
"n1": "Twinkle activity (1\u2013255, higher = more changes)",
|
||||
"n2": "Density (0\u2013255, higher = more of the strip lit)",
|
||||
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
|
||||
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
|
||||
"min_delay": 10,
|
||||
@@ -108,58 +109,6 @@
|
||||
"has_background": 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": {
|
||||
"n1": "Scale",
|
||||
"n2": "Speed",
|
||||
@@ -169,17 +118,6 @@
|
||||
"max_delay": 10000,
|
||||
"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": {
|
||||
"n1": "Level percent",
|
||||
"max_colors": 10,
|
||||
@@ -188,14 +126,6 @@
|
||||
"has_background": true,
|
||||
"supports_manual": false
|
||||
},
|
||||
"breathing_dual": {
|
||||
"n1": "Phase offset",
|
||||
"n2": "Ease",
|
||||
"max_colors": 10,
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"supports_manual": false
|
||||
},
|
||||
"strobe_burst": {
|
||||
"n1": "Burst count",
|
||||
"n2": "Burst gap",
|
||||
@@ -215,15 +145,6 @@
|
||||
"has_background": 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": {
|
||||
"n1": "Hand width",
|
||||
"n2": "Marker interval",
|
||||
@@ -233,30 +154,17 @@
|
||||
"has_background": 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": {
|
||||
"n1": "Band count",
|
||||
"n2": "Shimmer",
|
||||
"max_colors": 10,
|
||||
"n1": "Band count (0) or spatial period LEDs (1)",
|
||||
"n2": "Shimmer (0) or blend strength (1)",
|
||||
"n3": "Unused (0) or drift speed (1)",
|
||||
"mode": {
|
||||
"0": "Colour bands + shimmer",
|
||||
"1": "Sine northern wave"
|
||||
},
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"supports_manual": false
|
||||
},
|
||||
"snowfall": {
|
||||
"n1": "Flake density",
|
||||
"n2": "Fall speed",
|
||||
"max_colors": 10,
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"has_background": true,
|
||||
"supports_manual": true
|
||||
},
|
||||
@@ -290,16 +198,6 @@
|
||||
"has_background": 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": {
|
||||
"n1": "Candle count",
|
||||
"n2": "Glow width (LEDs)",
|
||||
@@ -310,36 +208,6 @@
|
||||
"has_background": 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": {
|
||||
"n1": "Orbit count",
|
||||
"n2": "Base speed",
|
||||
@@ -357,5 +225,49 @@
|
||||
"min_delay": 10,
|
||||
"max_delay": 10000,
|
||||
"supports_manual": false
|
||||
},
|
||||
"meteor": {
|
||||
"n1": "Tail length (0–1) 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 (0–1) 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
@@ -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"}}
|
||||
@@ -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": [[]]}}
|
||||
{}
|
||||
@@ -1,3 +1,5 @@
|
||||
[tool.pytest.ini_options]
|
||||
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"]
|
||||
|
||||
@@ -7,6 +7,7 @@ from models.device import Device, normalize_mac
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
||||
from util.espnow_message import build_message, build_preset_dict
|
||||
from util.profile_bundle import export_preset_bundle, import_preset_bundle
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
@@ -50,6 +51,41 @@ async def list_presets(request, session):
|
||||
}
|
||||
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.get('/<preset_id>/export')
|
||||
@with_session
|
||||
async def export_preset(request, session, preset_id):
|
||||
"""Export one preset as a JSON bundle."""
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
preset = presets.read(preset_id)
|
||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'}
|
||||
try:
|
||||
bundle = export_preset_bundle(preset_id, presets)
|
||||
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.post('/import')
|
||||
@with_session
|
||||
async def import_preset(request, session):
|
||||
"""Import a preset bundle into the current profile."""
|
||||
try:
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'}
|
||||
body = request.json or {}
|
||||
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||
if not isinstance(bundle, dict):
|
||||
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
|
||||
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
|
||||
return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.get('/<preset_id>')
|
||||
@with_session
|
||||
async def get_preset(request, session, preset_id):
|
||||
|
||||
@@ -3,12 +3,15 @@ from microdot.session import with_session
|
||||
from models.profile import Profile
|
||||
from models.zone import Zone
|
||||
from models.preset import Preset
|
||||
from models.sequence import Sequence
|
||||
from util.profile_bundle import export_profile_bundle, import_profile_bundle
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
profiles = Profile()
|
||||
zones = Zone()
|
||||
presets = Preset()
|
||||
sequences = Sequence()
|
||||
|
||||
@controller.get('')
|
||||
@with_session
|
||||
@@ -54,18 +57,64 @@ async def get_current_profile(request, session):
|
||||
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "No profile available"}), 404
|
||||
|
||||
@controller.get('/<id>')
|
||||
@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('/import')
|
||||
@with_session
|
||||
async def import_profile(request, session):
|
||||
"""Import a profile bundle (optionally apply as current profile)."""
|
||||
try:
|
||||
body = request.json or {}
|
||||
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||
if not isinstance(bundle, dict):
|
||||
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
|
||||
name = body.get("name") if isinstance(body, dict) else None
|
||||
apply_raw = body.get("apply", True) if isinstance(body, dict) else True
|
||||
if isinstance(apply_raw, str):
|
||||
apply = apply_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
else:
|
||||
apply = bool(apply_raw)
|
||||
|
||||
new_profile_id, profile_data = import_profile_bundle(
|
||||
bundle,
|
||||
profiles,
|
||||
zones,
|
||||
presets,
|
||||
sequences,
|
||||
profiles._palette_model,
|
||||
name=str(name).strip() if name else None,
|
||||
)
|
||||
if apply:
|
||||
session['current_profile'] = str(new_profile_id)
|
||||
session.save()
|
||||
return (
|
||||
json.dumps({new_profile_id: profile_data, "id": new_profile_id}),
|
||||
201,
|
||||
{'Content-Type': 'application/json'},
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.get('/<id>/export')
|
||||
async def export_profile(request, id):
|
||||
"""Export profile, zones, presets, sequences, and palette as a JSON bundle."""
|
||||
try:
|
||||
bundle = export_profile_bundle(
|
||||
str(id),
|
||||
profiles,
|
||||
zones,
|
||||
presets,
|
||||
sequences,
|
||||
profiles._palette_model,
|
||||
)
|
||||
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
@controller.post('/<id>/apply')
|
||||
@with_session
|
||||
@@ -77,167 +126,6 @@ async def apply_profile(request, session, id):
|
||||
session.save()
|
||||
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.post('')
|
||||
async def create_profile(request):
|
||||
"""Create a new profile."""
|
||||
try:
|
||||
data = dict(request.json or {})
|
||||
name = data.get("name", "")
|
||||
seed_raw = data.get("seed_dj_zone", False)
|
||||
if isinstance(seed_raw, str):
|
||||
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
else:
|
||||
seed_dj_zone = bool(seed_raw)
|
||||
# Request-only flag: do not persist on profile records.
|
||||
data.pop("seed_dj_zone", None)
|
||||
profile_id = profiles.create(name)
|
||||
# Avoid persisting request-only fields.
|
||||
data.pop("name", None)
|
||||
if data:
|
||||
profiles.update(profile_id, data)
|
||||
|
||||
# New profiles always start with a default zone pre-populated with starter presets.
|
||||
default_preset_ids = []
|
||||
default_preset_defs = [
|
||||
{
|
||||
"name": "on",
|
||||
"pattern": "on",
|
||||
"colors": ["#FFFFFF"],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"name": "off",
|
||||
"pattern": "off",
|
||||
"colors": [],
|
||||
"brightness": 0,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"name": "rainbow",
|
||||
"pattern": "rainbow",
|
||||
"colors": [],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
"n1": 2,
|
||||
},
|
||||
{
|
||||
"name": "Colour Cycle",
|
||||
"pattern": "colour_cycle",
|
||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
"n1": 1,
|
||||
},
|
||||
{
|
||||
"name": "transition",
|
||||
"pattern": "transition",
|
||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||
"brightness": 255,
|
||||
"delay": 500,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"name": "flicker",
|
||||
"pattern": "flicker",
|
||||
"colors": ["#FFB84D"],
|
||||
"brightness": 255,
|
||||
"delay": 80,
|
||||
"auto": True,
|
||||
"n1": 30,
|
||||
},
|
||||
{
|
||||
"name": "flame",
|
||||
"pattern": "flame",
|
||||
"colors": [],
|
||||
"brightness": 255,
|
||||
"delay": 50,
|
||||
"auto": True,
|
||||
"n1": 35,
|
||||
"n2": 2600,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
},
|
||||
{
|
||||
"name": "twinkle",
|
||||
"pattern": "twinkle",
|
||||
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
|
||||
"brightness": 255,
|
||||
"delay": 55,
|
||||
"auto": True,
|
||||
"n1": 72,
|
||||
"n2": 140,
|
||||
"n3": 2,
|
||||
"n4": 6,
|
||||
},
|
||||
]
|
||||
|
||||
for preset_data in default_preset_defs:
|
||||
pid = presets.create(profile_id)
|
||||
presets.update(pid, preset_data)
|
||||
default_preset_ids.append(str(pid))
|
||||
|
||||
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||
zones.update(default_tab_id, {
|
||||
"presets_flat": default_preset_ids,
|
||||
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||
})
|
||||
|
||||
profile = profiles.read(profile_id) or {}
|
||||
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
|
||||
profile_tabs.append(str(default_tab_id))
|
||||
|
||||
if seed_dj_zone:
|
||||
# Seed a DJ-focused zone with three starter presets.
|
||||
seeded_preset_ids = []
|
||||
preset_defs = [
|
||||
{
|
||||
"name": "DJ Rainbow",
|
||||
"pattern": "rainbow",
|
||||
"colors": [],
|
||||
"brightness": 220,
|
||||
"delay": 60,
|
||||
"n1": 12,
|
||||
},
|
||||
{
|
||||
"name": "DJ Single Color",
|
||||
"pattern": "on",
|
||||
"colors": ["#ff00ff"],
|
||||
"brightness": 220,
|
||||
"delay": 100,
|
||||
},
|
||||
{
|
||||
"name": "DJ Transition",
|
||||
"pattern": "transition",
|
||||
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
||||
"brightness": 220,
|
||||
"delay": 250,
|
||||
},
|
||||
]
|
||||
|
||||
for preset_data in preset_defs:
|
||||
pid = presets.create(profile_id)
|
||||
presets.update(pid, preset_data)
|
||||
seeded_preset_ids.append(str(pid))
|
||||
|
||||
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||
zones.update(dj_tab_id, {
|
||||
"presets_flat": seeded_preset_ids,
|
||||
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||
})
|
||||
|
||||
profile_tabs.append(str(dj_tab_id))
|
||||
|
||||
profiles.update(profile_id, {"zones": profile_tabs})
|
||||
|
||||
profile_data = profiles.read(profile_id)
|
||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.post('/<id>/clone')
|
||||
async def clone_profile(request, id):
|
||||
@@ -351,6 +239,184 @@ async def clone_profile(request, id):
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.get('/<id>')
|
||||
@with_session
|
||||
async def get_profile(request, id, session):
|
||||
"""Get a specific profile by ID."""
|
||||
# Handle 'current' as a special case
|
||||
if id == 'current':
|
||||
return await get_current_profile(request, session)
|
||||
|
||||
profile = profiles.read(id)
|
||||
if profile:
|
||||
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Profile not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
async def create_profile(request):
|
||||
"""Create a new profile."""
|
||||
try:
|
||||
data = dict(request.json or {})
|
||||
name = data.get("name", "")
|
||||
seed_raw = data.get("seed_dj_zone", False)
|
||||
if isinstance(seed_raw, str):
|
||||
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
else:
|
||||
seed_dj_zone = bool(seed_raw)
|
||||
# Request-only flag: do not persist on profile records.
|
||||
data.pop("seed_dj_zone", None)
|
||||
profile_id = profiles.create(name)
|
||||
# Avoid persisting request-only fields.
|
||||
data.pop("name", None)
|
||||
if data:
|
||||
profiles.update(profile_id, data)
|
||||
|
||||
# New profiles always start with a default zone pre-populated with starter presets.
|
||||
default_preset_ids = []
|
||||
default_preset_defs = [
|
||||
{
|
||||
"name": "on",
|
||||
"pattern": "on",
|
||||
"colors": ["#FFFFFF"],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"name": "off",
|
||||
"pattern": "off",
|
||||
"colors": [],
|
||||
"brightness": 0,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"name": "rainbow",
|
||||
"pattern": "colour_cycle",
|
||||
"colors": [],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
"n1": 2,
|
||||
"mode": 1,
|
||||
},
|
||||
{
|
||||
"name": "Colour Cycle",
|
||||
"pattern": "colour_cycle",
|
||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||
"brightness": 255,
|
||||
"delay": 100,
|
||||
"auto": True,
|
||||
"n1": 1,
|
||||
},
|
||||
{
|
||||
"name": "transition",
|
||||
"pattern": "transition",
|
||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||
"brightness": 255,
|
||||
"delay": 500,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"name": "flicker",
|
||||
"pattern": "flicker",
|
||||
"colors": ["#FFB84D"],
|
||||
"brightness": 255,
|
||||
"delay": 80,
|
||||
"auto": True,
|
||||
"n1": 30,
|
||||
},
|
||||
{
|
||||
"name": "flame",
|
||||
"pattern": "flame",
|
||||
"colors": [],
|
||||
"brightness": 255,
|
||||
"delay": 50,
|
||||
"auto": True,
|
||||
"n1": 35,
|
||||
"n2": 2600,
|
||||
"n3": 0,
|
||||
"n4": 0,
|
||||
},
|
||||
{
|
||||
"name": "twinkle",
|
||||
"pattern": "twinkle",
|
||||
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
|
||||
"brightness": 255,
|
||||
"delay": 55,
|
||||
"auto": True,
|
||||
"n1": 72,
|
||||
"n2": 140,
|
||||
"n3": 2,
|
||||
"n4": 6,
|
||||
},
|
||||
]
|
||||
|
||||
for preset_data in default_preset_defs:
|
||||
pid = presets.create(profile_id)
|
||||
presets.update(pid, preset_data)
|
||||
default_preset_ids.append(str(pid))
|
||||
|
||||
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||
zones.update(default_tab_id, {
|
||||
"presets_flat": default_preset_ids,
|
||||
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||
})
|
||||
|
||||
profile = profiles.read(profile_id) or {}
|
||||
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
|
||||
profile_tabs.append(str(default_tab_id))
|
||||
|
||||
if seed_dj_zone:
|
||||
# Seed a DJ-focused zone with three starter presets.
|
||||
seeded_preset_ids = []
|
||||
preset_defs = [
|
||||
{
|
||||
"name": "DJ Rainbow",
|
||||
"pattern": "colour_cycle",
|
||||
"colors": [],
|
||||
"brightness": 220,
|
||||
"delay": 60,
|
||||
"n1": 12,
|
||||
"mode": 1,
|
||||
},
|
||||
{
|
||||
"name": "DJ Single Color",
|
||||
"pattern": "on",
|
||||
"colors": ["#ff00ff"],
|
||||
"brightness": 220,
|
||||
"delay": 100,
|
||||
},
|
||||
{
|
||||
"name": "DJ Transition",
|
||||
"pattern": "transition",
|
||||
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
||||
"brightness": 220,
|
||||
"delay": 250,
|
||||
},
|
||||
]
|
||||
|
||||
for preset_data in preset_defs:
|
||||
pid = presets.create(profile_id)
|
||||
presets.update(pid, preset_data)
|
||||
seeded_preset_ids.append(str(pid))
|
||||
|
||||
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||
zones.update(dj_tab_id, {
|
||||
"presets_flat": seeded_preset_ids,
|
||||
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||
})
|
||||
|
||||
profile_tabs.append(str(dj_tab_id))
|
||||
|
||||
profiles.update(profile_id, {"zones": profile_tabs})
|
||||
|
||||
profile_data = profiles.read(profile_id)
|
||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.put('/current')
|
||||
@with_session
|
||||
async def update_current_profile(request, session):
|
||||
|
||||
@@ -3,11 +3,14 @@ from microdot.session import with_session
|
||||
from models.sequence import Sequence
|
||||
from models.profile import Profile
|
||||
from models.transport import get_current_sender
|
||||
from models.preset import Preset
|
||||
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
sequences = Sequence()
|
||||
profiles = Profile()
|
||||
presets = Preset()
|
||||
|
||||
|
||||
def get_current_profile_id(session=None):
|
||||
@@ -39,6 +42,57 @@ async def list_sequences(request, session):
|
||||
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get("/<id>/export")
|
||||
@with_session
|
||||
async def export_sequence(request, session, id):
|
||||
"""Export a sequence and referenced presets as a JSON bundle."""
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"}
|
||||
try:
|
||||
bundle = export_sequence_bundle(
|
||||
id,
|
||||
sequences,
|
||||
presets,
|
||||
profile_id=current_profile_id,
|
||||
)
|
||||
return json.dumps(bundle), 200, {"Content-Type": "application/json"}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.post("/import")
|
||||
@with_session
|
||||
async def import_sequence(request, session):
|
||||
"""Import a sequence bundle into the current profile."""
|
||||
try:
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return (
|
||||
json.dumps({"error": "No profile available"}),
|
||||
404,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
body = request.json or {}
|
||||
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||
if not isinstance(bundle, dict):
|
||||
return (
|
||||
json.dumps({"error": "Expected JSON bundle"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
|
||||
return (
|
||||
json.dumps({new_id: seq_data}),
|
||||
201,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
@with_session
|
||||
async def get_sequence(request, session, id):
|
||||
|
||||
@@ -349,6 +349,7 @@ async def clone_zone(request, session, id):
|
||||
source.get("names"),
|
||||
source.get("presets"),
|
||||
source.get("group_ids"),
|
||||
source.get("content_kind"),
|
||||
)
|
||||
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
||||
if extra:
|
||||
|
||||
@@ -46,6 +46,22 @@ class Zone(Model):
|
||||
if changed:
|
||||
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):
|
||||
next_id = self.get_next_id()
|
||||
gid_list = []
|
||||
@@ -62,6 +78,9 @@ class Zone(Model):
|
||||
}
|
||||
if content_kind in ("presets", "sequences"):
|
||||
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.save()
|
||||
return next_id
|
||||
@@ -74,7 +93,9 @@ class Zone(Model):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
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()
|
||||
return True
|
||||
|
||||
|
||||
48
src/static/bundle_io.js
Normal file
48
src/static/bundle_io.js
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -573,7 +573,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
n3: coercePresetInt(preset.n3),
|
||||
n4: coercePresetInt(preset.n4),
|
||||
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) {
|
||||
|
||||
@@ -4,6 +4,25 @@ let espnowSocketReady = false;
|
||||
let espnowPendingMessages = [];
|
||||
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 () => {
|
||||
try {
|
||||
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 presetAddFromPaletteButton = document.getElementById('preset-add-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) {
|
||||
return;
|
||||
@@ -297,7 +318,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
patternConfig.parameter_mappings &&
|
||||
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;
|
||||
};
|
||||
@@ -311,6 +333,46 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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 = () => {
|
||||
if (!presetManualBeatNWrap) {
|
||||
return;
|
||||
@@ -734,7 +796,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs
|
||||
updatePresetNLabels(patternName);
|
||||
updatePresetNLabels(patternName, preset);
|
||||
updateManualModeAvailability();
|
||||
updatePresetEditorTabActionsVisibility();
|
||||
};
|
||||
@@ -793,10 +855,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return section ? section.dataset.zoneId : null;
|
||||
};
|
||||
|
||||
const updatePresetEditorTabActionsVisibility = () => {
|
||||
const updatePresetEditorTabActionsVisibility = async () => {
|
||||
if (!presetRemoveFromTabButton) return;
|
||||
const show = Boolean(currentEditTabId && currentEditId);
|
||||
presetRemoveFromTabButton.hidden = !show;
|
||||
if (!currentEditTabId || !currentEditId) {
|
||||
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) => {
|
||||
@@ -827,8 +908,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (presetEditorModal) {
|
||||
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(() => {
|
||||
updatePresetNLabels(presetPatternInput ? presetPatternInput.value : '');
|
||||
updatePresetNLabels(patternName, { mode: modeBefore, n6: modeBefore });
|
||||
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++) {
|
||||
const nKey = `n${i}`;
|
||||
if (modeEntries && nKey === 'n6') {
|
||||
continue;
|
||||
}
|
||||
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
|
||||
}
|
||||
if (modeEntries && presetModeInput) {
|
||||
payload.mode = parseInt(presetModeInput.value, 10) || 0;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
@@ -950,30 +1047,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updatePresetNLabels = (patternName) => {
|
||||
const rawPatternName = String(patternName || '').trim();
|
||||
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 updatePresetNLabels = (patternName, presetForMode = null) => {
|
||||
const patternConfig = resolvePatternConfig(patternName);
|
||||
const labels = {};
|
||||
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 =
|
||||
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++) {
|
||||
const nKey = `n${i}`;
|
||||
@@ -1073,6 +1174,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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');
|
||||
deleteButton.className = 'btn btn-danger btn-small';
|
||||
deleteButton.textContent = 'Delete';
|
||||
@@ -1102,6 +1223,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
row.appendChild(label);
|
||||
row.appendChild(details);
|
||||
row.appendChild(editButton);
|
||||
row.appendChild(exportButton);
|
||||
row.appendChild(sendButton);
|
||||
row.appendChild(deleteButton);
|
||||
presetsList.appendChild(row);
|
||||
@@ -1148,6 +1270,34 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (presetsCloseButton) {
|
||||
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) {
|
||||
presetsAddButton.addEventListener('click', () => {
|
||||
clearForm();
|
||||
@@ -1199,6 +1349,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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
|
||||
try {
|
||||
const response = await fetch('/presets', {
|
||||
@@ -1327,11 +1493,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
const kind =
|
||||
typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(tabData)
|
||||
: null;
|
||||
if (kind === 'sequences') {
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(tabData)
|
||||
) {
|
||||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||||
return;
|
||||
}
|
||||
@@ -1697,14 +1862,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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). */
|
||||
const coercePresetAuto = (preset) => {
|
||||
if (!preset || typeof preset !== 'object') {
|
||||
@@ -1826,7 +1983,7 @@ const sendPresetViaEspNow = async (
|
||||
n3: coercePresetInt(preset.n3),
|
||||
n4: coercePresetInt(preset.n4),
|
||||
n5: coercePresetInt(preset.n5),
|
||||
n6: coercePresetInt(preset.n6),
|
||||
n6: presetWireN6(preset),
|
||||
manual_beat_n: coerceManualBeatN(preset),
|
||||
},
|
||||
},
|
||||
@@ -1929,7 +2086,7 @@ try {
|
||||
// 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 zonePresetSelectionOrder = {};
|
||||
|
||||
@@ -1956,19 +2113,21 @@ function getOrderedZonePresetSelection(zoneId) {
|
||||
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
|
||||
}
|
||||
|
||||
async function sendMergedZonePresetSelection(zoneId, tabData, allPresets) {
|
||||
const ids = getOrderedZonePresetSelection(zoneId);
|
||||
if (!ids.length) return;
|
||||
for (let i = 0; i < ids.length; i += 1) {
|
||||
const pid = ids[i];
|
||||
const preset = allPresets[pid];
|
||||
if (!preset) continue;
|
||||
const names =
|
||||
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
|
||||
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
|
||||
: [];
|
||||
await sendPresetViaEspNow(pid, preset, names, false, false, '2');
|
||||
}
|
||||
/** Preset id that should show the tile outline (last click in selection order). */
|
||||
function getLastZonePresetSelectionId(zoneId) {
|
||||
const order = getOrderedZonePresetSelection(zoneId);
|
||||
return order.length ? String(order[order.length - 1]) : null;
|
||||
}
|
||||
|
||||
async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPresets) {
|
||||
const pid = String(presetId);
|
||||
const body = (allPresets && allPresets[pid]) || preset;
|
||||
if (!body) return;
|
||||
const names =
|
||||
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
|
||||
@@ -2053,6 +2212,12 @@ const savePresetGrid = async (zoneId, presetGrid) => {
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
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
|
||||
tabData.presets = presetGrid;
|
||||
@@ -2265,7 +2430,9 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
const preset = allPresets[presetId];
|
||||
if (preset) {
|
||||
ensureZonePresetSelection(zoneId);
|
||||
const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId));
|
||||
const lastSelectedId = getLastZonePresetSelectionId(zoneId);
|
||||
const isSelected =
|
||||
lastSelectedId !== null && lastSelectedId === String(presetId);
|
||||
const displayPreset = {
|
||||
...preset,
|
||||
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);
|
||||
}
|
||||
} 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 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
|
||||
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
|
||||
: colors;
|
||||
@@ -2389,34 +2561,32 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
|
||||
ensureZonePresetSelection(zoneId);
|
||||
const z = String(zoneId);
|
||||
const set = zoneSelectedPresetIds[z];
|
||||
const order = zonePresetSelectionOrder[z];
|
||||
const idStr = String(presetId);
|
||||
if (set.has(idStr)) {
|
||||
set.delete(idStr);
|
||||
zonePresetSelectionOrder[z] = order.filter((x) => String(x) !== idStr);
|
||||
} else {
|
||||
const wasSelected = set.has(idStr);
|
||||
set.clear();
|
||||
zonePresetSelectionOrder[z] = [];
|
||||
if (!wasSelected) {
|
||||
set.add(idStr);
|
||||
order.push(idStr);
|
||||
zonePresetSelectionOrder[z] = [idStr];
|
||||
}
|
||||
const outlinePresetId = getLastZonePresetSelectionId(zoneId);
|
||||
if (presetsListEl) {
|
||||
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
|
||||
const pid = rw.dataset.presetId;
|
||||
const btnEl = rw.querySelector('.preset-tile-main');
|
||||
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');
|
||||
});
|
||||
}
|
||||
const orderList = getOrderedZonePresetSelection(zoneId);
|
||||
if (orderList.length) {
|
||||
const lastPid = orderList[orderList.length - 1];
|
||||
selectedPresets[zoneId] = lastPid;
|
||||
selectedPresetPayloads[zoneId] = (allPresets && allPresets[lastPid]) || preset;
|
||||
if (!wasSelected) {
|
||||
selectedPresets[zoneId] = idStr;
|
||||
selectedPresetPayloads[zoneId] = (allPresets && allPresets[idStr]) || preset;
|
||||
void sendZonePresetSelection(zoneId, tabData, idStr, preset, allPresets);
|
||||
} else {
|
||||
delete selectedPresets[zoneId];
|
||||
delete selectedPresetPayloads[zoneId];
|
||||
}
|
||||
void sendMergedZonePresetSelection(zoneId, tabData, allPresets);
|
||||
});
|
||||
|
||||
if (canDrag) {
|
||||
@@ -2526,6 +2696,13 @@ const removePresetFromTab = async (zoneId, presetId) => {
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
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
|
||||
let flat = [];
|
||||
|
||||
@@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const newProfileInput = document.getElementById("new-profile-name");
|
||||
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
|
||||
const createProfileButton = document.getElementById("create-profile-btn");
|
||||
const importProfileButton = document.getElementById("import-profile-btn");
|
||||
|
||||
if (!profilesButton || !profilesModal || !profilesList) {
|
||||
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");
|
||||
cloneButton.className = "btn btn-secondary btn-small";
|
||||
cloneButton.textContent = "Clone";
|
||||
@@ -177,6 +198,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
row.appendChild(label);
|
||||
row.appendChild(applyButton);
|
||||
if (editMode) {
|
||||
row.appendChild(exportButton);
|
||||
row.appendChild(cloneButton);
|
||||
row.appendChild(deleteButton);
|
||||
}
|
||||
@@ -276,6 +298,60 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (createProfileButton) {
|
||||
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) {
|
||||
newProfileInput.addEventListener("keypress", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
|
||||
@@ -454,11 +454,10 @@ async function addSequenceToTab(sequenceId, zoneId) {
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||
if (!tabResponse.ok) throw new Error('Failed to load zone');
|
||||
const tabData = await tabResponse.json();
|
||||
const kind =
|
||||
typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(tabData)
|
||||
: null;
|
||||
if (kind === 'presets') {
|
||||
if (
|
||||
typeof window.zoneAllowsSequences === 'function' &&
|
||||
!window.zoneAllowsSequences(tabData)
|
||||
) {
|
||||
alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
|
||||
return;
|
||||
}
|
||||
@@ -524,11 +523,10 @@ async function refreshEditTabSequencesUi(zoneId) {
|
||||
const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||
if (!zoneRes.ok) throw new Error('zone');
|
||||
const zone = await zoneRes.json();
|
||||
const kind =
|
||||
typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(zone)
|
||||
: null;
|
||||
if (kind === 'presets') {
|
||||
if (
|
||||
typeof window.zoneAllowsSequences === 'function' &&
|
||||
!window.zoneAllowsSequences(zone)
|
||||
) {
|
||||
currentEl.innerHTML =
|
||||
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</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 nLanes = ln.filter((l) => l.length > 0).length || 1;
|
||||
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');
|
||||
edit.type = 'button';
|
||||
edit.className = 'btn btn-secondary btn-small';
|
||||
edit.textContent = 'Edit';
|
||||
edit.addEventListener('click', () => openSequenceEditor(id, doc));
|
||||
row.appendChild(title);
|
||||
row.appendChild(exportBtn);
|
||||
row.appendChild(edit);
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
@@ -1139,6 +1156,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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');
|
||||
if (openPresetsFromSeq) {
|
||||
openPresetsFromSeq.addEventListener('click', () => {
|
||||
|
||||
@@ -598,6 +598,39 @@ body.preset-ui-run .edit-mode-only {
|
||||
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 {
|
||||
flex: 0 0 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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: block;
|
||||
margin-top: 0.75rem;
|
||||
|
||||
@@ -497,6 +497,42 @@ function normalizeZoneContentKind(zoneDoc) {
|
||||
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) {
|
||||
const presetsBlock = document.getElementById('edit-zone-block-presets');
|
||||
const groupsBlock = document.getElementById('edit-zone-block-groups');
|
||||
@@ -504,17 +540,16 @@ function applyZoneContentKindEditModal(kind) {
|
||||
const vis = (el, show) => {
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
};
|
||||
const k = kind === 'sequences' ? 'sequences' : 'presets';
|
||||
vis(groupsBlock, true);
|
||||
if (!kind) {
|
||||
vis(presetsBlock, true);
|
||||
vis(seqBlock, true);
|
||||
return;
|
||||
}
|
||||
vis(presetsBlock, kind === 'presets');
|
||||
vis(seqBlock, kind === 'sequences');
|
||||
vis(presetsBlock, k === 'presets');
|
||||
vis(seqBlock, k === 'sequences');
|
||||
}
|
||||
|
||||
window.normalizeZoneContentKind = normalizeZoneContentKind;
|
||||
window.effectiveZoneContentKind = effectiveZoneContentKind;
|
||||
window.zoneAllowsPresets = zoneAllowsPresets;
|
||||
window.zoneAllowsSequences = zoneAllowsSequences;
|
||||
|
||||
// Load tabs list
|
||||
async function loadZones() {
|
||||
@@ -573,10 +608,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
|
||||
const zone = tabs[zoneId];
|
||||
if (zone) {
|
||||
const activeClass = zoneId === currentZoneId ? 'active' : '';
|
||||
let disp = zone.name || `Zone ${zoneId}`;
|
||||
const kind = normalizeZoneContentKind(zone);
|
||||
if (kind === 'presets') disp += ' · presets';
|
||||
else if (kind === 'sequences') disp += ' · sequences';
|
||||
const disp = zone.name || `Zone ${zoneId}`;
|
||||
html += `
|
||||
<button class="zone-button ${activeClass}"
|
||||
data-zone-id="${zoneId}"
|
||||
@@ -622,10 +654,7 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
|
||||
row.dataset.zoneId = String(zoneId);
|
||||
|
||||
const label = document.createElement("span");
|
||||
let disp = (zone && zone.name) || zoneId;
|
||||
const kind = normalizeZoneContentKind(zone);
|
||||
if (kind === 'presets') disp += ' · presets';
|
||||
else if (kind === 'sequences') disp += ' · sequences';
|
||||
const disp = zone.name || `Zone ${zoneId}`;
|
||||
label.textContent = disp;
|
||||
if (String(zoneId) === String(currentZoneId)) {
|
||||
label.textContent = `✓ ${disp}`;
|
||||
@@ -999,8 +1028,7 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
return;
|
||||
}
|
||||
const tabData = await tabRes.json();
|
||||
const kind = normalizeZoneContentKind(tabData);
|
||||
if (kind === 'sequences') {
|
||||
if (!zoneAllowsPresets(tabData)) {
|
||||
currentEl.innerHTML =
|
||||
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
|
||||
addEl.innerHTML = '<span class="muted-text">—</span>';
|
||||
@@ -1138,8 +1166,13 @@ async function openEditZoneModal(zoneId, zone) {
|
||||
});
|
||||
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");
|
||||
applyZoneContentKindEditModal(normalizeZoneContentKind(tabData));
|
||||
applyZoneContentKindEditModal(kind);
|
||||
await refreshEditTabPresetsUi(zoneId);
|
||||
if (typeof window.refreshEditTabSequencesUi === "function") {
|
||||
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).
|
||||
async function updateZone(zoneId, name, groupRows) {
|
||||
async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||
try {
|
||||
const gids = Array.isArray(groupRows)
|
||||
? 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}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -1162,6 +1197,7 @@ async function updateZone(zoneId, name, groupRows) {
|
||||
names: [],
|
||||
group_ids: gids,
|
||||
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
|
||||
const editZoneForm = document.getElementById('edit-zone-form');
|
||||
if (editZoneForm) {
|
||||
@@ -1314,7 +1362,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const groupRows = window.__editTabGroupRows || [];
|
||||
|
||||
if (zoneId && name) {
|
||||
await updateZone(zoneId, name, groupRows);
|
||||
await updateZone(zoneId, name, groupRows, editModalContentKindSelected());
|
||||
editZoneForm.reset();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -83,11 +83,10 @@
|
||||
<input type="text" id="new-zone-name" placeholder="Zone name">
|
||||
<button class="btn btn-primary" id="create-zone-btn">Create</button>
|
||||
</div>
|
||||
<fieldset class="muted-text" style="margin:0.35rem 0 0.75rem;border:none;padding:0;">
|
||||
<legend style="font-size:0.85em;margin-bottom:0.35rem;">This zone is for</legend>
|
||||
<label style="margin-right:1rem;"><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
|
||||
<div class="zone-content-kind-row muted-text">
|
||||
<label><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>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div id="zones-list-modal" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
||||
@@ -101,12 +100,12 @@
|
||||
<h2>Edit Zone</h2>
|
||||
<form id="edit-zone-form">
|
||||
<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>
|
||||
<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">
|
||||
<label class="zone-devices-label">Device groups on this zone</label>
|
||||
<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>
|
||||
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,6 +137,7 @@
|
||||
<div class="profiles-actions">
|
||||
<input type="text" id="new-profile-name" placeholder="Profile name">
|
||||
<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 class="form-group" style="margin-top: 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>
|
||||
<form id="edit-group-form">
|
||||
<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>
|
||||
<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;">
|
||||
@@ -227,6 +227,10 @@
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,6 +305,7 @@
|
||||
<h2>Presets</h2>
|
||||
<div class="modal-actions">
|
||||
<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>
|
||||
</div>
|
||||
<div id="presets-list" class="profiles-list"></div>
|
||||
@@ -316,6 +321,7 @@
|
||||
<h2>Sequences</h2>
|
||||
<div class="modal-actions">
|
||||
<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>
|
||||
</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>
|
||||
</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">
|
||||
<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-primary" id="sequence-editor-save-btn">Save</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>
|
||||
</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-param-group">
|
||||
<label for="preset-n1-input" id="preset-n1-label">n1:</label>
|
||||
@@ -789,6 +797,7 @@
|
||||
<script src="/static/help.js"></script>
|
||||
<script src="/static/led_tool.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/zone_palette.js"></script>
|
||||
<script src="/static/patterns.js"></script>
|
||||
|
||||
@@ -43,6 +43,8 @@ import json
|
||||
import struct
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from util.espnow_message import wire_n6
|
||||
|
||||
BINARY_ENVELOPE_VERSION_1 = 1
|
||||
BINARY_ENVELOPE_VERSION_2 = 2
|
||||
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))
|
||||
n4 = _clamp_i16(preset.get("n4", 0))
|
||||
n5 = _clamp_i16(preset.get("n5", 0))
|
||||
n6 = _clamp_i16(preset.get("n6", 0))
|
||||
n6 = _clamp_i16(wire_n6(preset))
|
||||
parts.append(
|
||||
struct.pack(
|
||||
"<HBBhhhhhh",
|
||||
|
||||
@@ -113,6 +113,21 @@ def resolve_preset_background_hex(preset_data, palette_colors=None):
|
||||
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):
|
||||
"""
|
||||
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),
|
||||
"n4": preset_data.get("n4", 0),
|
||||
"n5": preset_data.get("n5", 0),
|
||||
"n6": preset_data.get("n6", 0)
|
||||
"n6": wire_n6(preset_data),
|
||||
}
|
||||
|
||||
return preset
|
||||
|
||||
441
src/util/profile_bundle.py
Normal file
441
src/util/profile_bundle.py
Normal 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
|
||||
@@ -25,18 +25,20 @@ def test_preset():
|
||||
print("\nTesting update preset")
|
||||
update_data = {
|
||||
"name": "test_preset",
|
||||
"pattern": "on",
|
||||
"pattern": "colour_cycle",
|
||||
"colors": ["#FF0000", "#00FF00"],
|
||||
"delay": 100,
|
||||
"brightness": 127,
|
||||
"n1": 10,
|
||||
"n2": 20
|
||||
"n2": 20,
|
||||
"mode": 1,
|
||||
}
|
||||
result = presets.update(preset_id, update_data)
|
||||
assert result is True
|
||||
updated = presets.read(preset_id)
|
||||
assert updated["name"] == "test_preset"
|
||||
assert updated["pattern"] == "on"
|
||||
assert updated["pattern"] == "colour_cycle"
|
||||
assert updated["mode"] == 1
|
||||
assert updated["delay"] == 100
|
||||
|
||||
print("\nTesting list presets")
|
||||
|
||||
@@ -29,6 +29,21 @@ def test_pack_parse_v2_brightness_only():
|
||||
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():
|
||||
raw = pack_binary_envelope_v2(
|
||||
presets={
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
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}")
|
||||
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.
|
||||
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
|
||||
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"]
|
||||
sender: DummySender = server["sender"]
|
||||
|
||||
_create_and_apply_profile(c, base_url)
|
||||
|
||||
# Groups.
|
||||
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
|
||||
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
|
||||
definitions = resp.json()
|
||||
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]}"
|
||||
resp = c.post(
|
||||
|
||||
51
tests/test_preset_wire_mode.py
Normal file
51
tests/test_preset_wire_mode.py
Normal 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
|
||||
133
tests/test_profile_bundle.py
Normal file
133
tests/test_profile_bundle.py
Normal 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)
|
||||
Reference in New Issue
Block a user