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,
|
"max_colors": 0,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"rainbow": {
|
|
||||||
"n1": "Step Rate",
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"max_colors": 0,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"colour_cycle": {
|
"colour_cycle": {
|
||||||
"n1": "Step Rate",
|
"n1": "Step rate",
|
||||||
|
"mode": {
|
||||||
|
"0": "Scroll palette gradient",
|
||||||
|
"1": "Rainbow wheel (preset colours ignored)"
|
||||||
|
},
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"max_colors": 10,
|
"max_colors": 10,
|
||||||
@@ -40,7 +37,11 @@
|
|||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"max_colors": 2,
|
"max_colors": 2,
|
||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true,
|
||||||
|
"mode": {
|
||||||
|
"0": "Two-colour chase",
|
||||||
|
"1": "Marquee dashes (n1 on length, n2 off, n3 step)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"pulse": {
|
"pulse": {
|
||||||
"n1": "Attack",
|
"n1": "Attack",
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
"flame": {
|
"flame": {
|
||||||
"n1": "Min brightness",
|
"n1": "Min brightness",
|
||||||
"n2": "Breath period (ms)",
|
"n2": "Breath period (ms)",
|
||||||
"n3": "Spark gap min (ms, 0=default 10–30 s, -1=off)",
|
"n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)",
|
||||||
"n4": "Spark gap max (ms)",
|
"n4": "Spark gap max (ms)",
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
@@ -88,8 +89,8 @@
|
|||||||
"supports_manual": false
|
"supports_manual": false
|
||||||
},
|
},
|
||||||
"twinkle": {
|
"twinkle": {
|
||||||
"n1": "Twinkle activity (1–255, higher = more changes)",
|
"n1": "Twinkle activity (1\u2013255, higher = more changes)",
|
||||||
"n2": "Density (0–255, higher = more of the strip lit)",
|
"n2": "Density (0\u2013255, higher = more of the strip lit)",
|
||||||
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
|
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
|
||||||
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
|
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
@@ -108,58 +109,6 @@
|
|||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"meteor_rain": {
|
|
||||||
"n1": "Tail length",
|
|
||||||
"n2": "Speed (LEDs per frame)",
|
|
||||||
"n3": "Fade amount (1-255)",
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"max_colors": 10,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"scanner": {
|
|
||||||
"n1": "Eye width",
|
|
||||||
"n2": "End pause (frames)",
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"max_colors": 10,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"gradient_scroll": {
|
|
||||||
"n1": "Scroll step rate",
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"max_colors": 10,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"comet_dual": {
|
|
||||||
"n1": "Tail length",
|
|
||||||
"n2": "Speed",
|
|
||||||
"n3": "Gap",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"sparkle_trail": {
|
|
||||||
"n1": "Spark density",
|
|
||||||
"n2": "Decay",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"wave": {
|
|
||||||
"n1": "Wavelength",
|
|
||||||
"n2": "Amplitude",
|
|
||||||
"n3": "Drift speed",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"supports_manual": false
|
|
||||||
},
|
|
||||||
"plasma": {
|
"plasma": {
|
||||||
"n1": "Scale",
|
"n1": "Scale",
|
||||||
"n2": "Speed",
|
"n2": "Speed",
|
||||||
@@ -169,17 +118,6 @@
|
|||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"supports_manual": false
|
"supports_manual": false
|
||||||
},
|
},
|
||||||
"segment_chase": {
|
|
||||||
"n1": "Segment size",
|
|
||||||
"n2": "Phase step",
|
|
||||||
"n3": "Segment phase offset",
|
|
||||||
"n4": "Gap per segment",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"bar_graph": {
|
"bar_graph": {
|
||||||
"n1": "Level percent",
|
"n1": "Level percent",
|
||||||
"max_colors": 10,
|
"max_colors": 10,
|
||||||
@@ -188,14 +126,6 @@
|
|||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": false
|
"supports_manual": false
|
||||||
},
|
},
|
||||||
"breathing_dual": {
|
|
||||||
"n1": "Phase offset",
|
|
||||||
"n2": "Ease",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"supports_manual": false
|
|
||||||
},
|
|
||||||
"strobe_burst": {
|
"strobe_burst": {
|
||||||
"n1": "Burst count",
|
"n1": "Burst count",
|
||||||
"n2": "Burst gap",
|
"n2": "Burst gap",
|
||||||
@@ -215,15 +145,6 @@
|
|||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"fireflies": {
|
|
||||||
"n1": "Count",
|
|
||||||
"n2": "Twinkle speed",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"clock_sweep": {
|
"clock_sweep": {
|
||||||
"n1": "Hand width",
|
"n1": "Hand width",
|
||||||
"n2": "Marker interval",
|
"n2": "Marker interval",
|
||||||
@@ -233,30 +154,17 @@
|
|||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"marquee": {
|
|
||||||
"n1": "On length",
|
|
||||||
"n2": "Off length",
|
|
||||||
"n3": "Step",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"aurora": {
|
"aurora": {
|
||||||
"n1": "Band count",
|
"n1": "Band count (0) or spatial period LEDs (1)",
|
||||||
"n2": "Shimmer",
|
"n2": "Shimmer (0) or blend strength (1)",
|
||||||
"max_colors": 10,
|
"n3": "Unused (0) or drift speed (1)",
|
||||||
"min_delay": 10,
|
"mode": {
|
||||||
"max_delay": 10000,
|
"0": "Colour bands + shimmer",
|
||||||
"supports_manual": false
|
"1": "Sine northern wave"
|
||||||
},
|
},
|
||||||
"snowfall": {
|
|
||||||
"n1": "Flake density",
|
|
||||||
"n2": "Fall speed",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
@@ -290,16 +198,6 @@
|
|||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"northern_wave": {
|
|
||||||
"n1": "Spatial period (LEDs)",
|
|
||||||
"n2": "Blend strength",
|
|
||||||
"n3": "Drift speed",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"candle_glow": {
|
"candle_glow": {
|
||||||
"n1": "Candle count",
|
"n1": "Candle count",
|
||||||
"n2": "Glow width (LEDs)",
|
"n2": "Glow width (LEDs)",
|
||||||
@@ -310,36 +208,6 @@
|
|||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"starfall": {
|
|
||||||
"n1": "Spawn rate",
|
|
||||||
"n2": "Fall speed",
|
|
||||||
"n3": "Streak length",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"ice_sparkle": {
|
|
||||||
"n1": "Sparkle rate",
|
|
||||||
"n2": "Decay per refresh",
|
|
||||||
"n3": "Halo width (LEDs)",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"heartbeat": {
|
|
||||||
"n1": "Pulse 1 ms",
|
|
||||||
"n2": "Pulse 2 ms",
|
|
||||||
"n3": "Pause ms",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"orbit": {
|
"orbit": {
|
||||||
"n1": "Orbit count",
|
"n1": "Orbit count",
|
||||||
"n2": "Base speed",
|
"n2": "Base speed",
|
||||||
@@ -357,5 +225,49 @@
|
|||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"supports_manual": false
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"meteor": {
|
||||||
|
"n1": "Tail length (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]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
python_files = ["test_endpoints_pytest.py"]
|
python_files = ["test_*.py"]
|
||||||
|
# ``tests/models/`` is a package name clash with ``src/models``; run via tests/models/run_all.py
|
||||||
|
norecursedirs = ["models"]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from models.device import Device, normalize_mac
|
|||||||
from models.transport import get_current_sender
|
from models.transport import get_current_sender
|
||||||
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
||||||
from util.espnow_message import build_message, build_preset_dict
|
from util.espnow_message import build_message, build_preset_dict
|
||||||
|
from util.profile_bundle import export_preset_bundle, import_preset_bundle
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
@@ -50,6 +51,41 @@ async def list_presets(request, session):
|
|||||||
}
|
}
|
||||||
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('/<preset_id>/export')
|
||||||
|
@with_session
|
||||||
|
async def export_preset(request, session, preset_id):
|
||||||
|
"""Export one preset as a JSON bundle."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
preset = presets.read(preset_id)
|
||||||
|
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||||
|
return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'}
|
||||||
|
try:
|
||||||
|
bundle = export_preset_bundle(preset_id, presets)
|
||||||
|
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post('/import')
|
||||||
|
@with_session
|
||||||
|
async def import_preset(request, session):
|
||||||
|
"""Import a preset bundle into the current profile."""
|
||||||
|
try:
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'}
|
||||||
|
body = request.json or {}
|
||||||
|
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
|
||||||
|
return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
@controller.get('/<preset_id>')
|
@controller.get('/<preset_id>')
|
||||||
@with_session
|
@with_session
|
||||||
async def get_preset(request, session, preset_id):
|
async def get_preset(request, session, preset_id):
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ from microdot.session import with_session
|
|||||||
from models.profile import Profile
|
from models.profile import Profile
|
||||||
from models.zone import Zone
|
from models.zone import Zone
|
||||||
from models.preset import Preset
|
from models.preset import Preset
|
||||||
|
from models.sequence import Sequence
|
||||||
|
from util.profile_bundle import export_profile_bundle, import_profile_bundle
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
profiles = Profile()
|
profiles = Profile()
|
||||||
zones = Zone()
|
zones = Zone()
|
||||||
presets = Preset()
|
presets = Preset()
|
||||||
|
sequences = Sequence()
|
||||||
|
|
||||||
@controller.get('')
|
@controller.get('')
|
||||||
@with_session
|
@with_session
|
||||||
@@ -54,18 +57,64 @@ async def get_current_profile(request, session):
|
|||||||
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "No profile available"}), 404
|
return json.dumps({"error": "No profile available"}), 404
|
||||||
|
|
||||||
@controller.get('/<id>')
|
|
||||||
@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)
|
@controller.post('/import')
|
||||||
if profile:
|
@with_session
|
||||||
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
async def import_profile(request, session):
|
||||||
return json.dumps({"error": "Profile not found"}), 404
|
"""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')
|
@controller.post('/<id>/apply')
|
||||||
@with_session
|
@with_session
|
||||||
@@ -77,167 +126,6 @@ async def apply_profile(request, session, id):
|
|||||||
session.save()
|
session.save()
|
||||||
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@controller.post('')
|
|
||||||
async def create_profile(request):
|
|
||||||
"""Create a new profile."""
|
|
||||||
try:
|
|
||||||
data = dict(request.json or {})
|
|
||||||
name = data.get("name", "")
|
|
||||||
seed_raw = data.get("seed_dj_zone", False)
|
|
||||||
if isinstance(seed_raw, str):
|
|
||||||
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
else:
|
|
||||||
seed_dj_zone = bool(seed_raw)
|
|
||||||
# Request-only flag: do not persist on profile records.
|
|
||||||
data.pop("seed_dj_zone", None)
|
|
||||||
profile_id = profiles.create(name)
|
|
||||||
# Avoid persisting request-only fields.
|
|
||||||
data.pop("name", None)
|
|
||||||
if data:
|
|
||||||
profiles.update(profile_id, data)
|
|
||||||
|
|
||||||
# New profiles always start with a default zone pre-populated with starter presets.
|
|
||||||
default_preset_ids = []
|
|
||||||
default_preset_defs = [
|
|
||||||
{
|
|
||||||
"name": "on",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#FFFFFF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "off",
|
|
||||||
"pattern": "off",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 0,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rainbow",
|
|
||||||
"pattern": "rainbow",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Colour Cycle",
|
|
||||||
"pattern": "colour_cycle",
|
|
||||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "transition",
|
|
||||||
"pattern": "transition",
|
|
||||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 500,
|
|
||||||
"auto": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flicker",
|
|
||||||
"pattern": "flicker",
|
|
||||||
"colors": ["#FFB84D"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 80,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flame",
|
|
||||||
"pattern": "flame",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 50,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 35,
|
|
||||||
"n2": 2600,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "twinkle",
|
|
||||||
"pattern": "twinkle",
|
|
||||||
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 55,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 72,
|
|
||||||
"n2": 140,
|
|
||||||
"n3": 2,
|
|
||||||
"n4": 6,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for preset_data in default_preset_defs:
|
|
||||||
pid = presets.create(profile_id)
|
|
||||||
presets.update(pid, preset_data)
|
|
||||||
default_preset_ids.append(str(pid))
|
|
||||||
|
|
||||||
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
|
|
||||||
zones.update(default_tab_id, {
|
|
||||||
"presets_flat": default_preset_ids,
|
|
||||||
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
profile = profiles.read(profile_id) or {}
|
|
||||||
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
|
|
||||||
profile_tabs.append(str(default_tab_id))
|
|
||||||
|
|
||||||
if seed_dj_zone:
|
|
||||||
# Seed a DJ-focused zone with three starter presets.
|
|
||||||
seeded_preset_ids = []
|
|
||||||
preset_defs = [
|
|
||||||
{
|
|
||||||
"name": "DJ Rainbow",
|
|
||||||
"pattern": "rainbow",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 220,
|
|
||||||
"delay": 60,
|
|
||||||
"n1": 12,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DJ Single Color",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#ff00ff"],
|
|
||||||
"brightness": 220,
|
|
||||||
"delay": 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DJ Transition",
|
|
||||||
"pattern": "transition",
|
|
||||||
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
|
||||||
"brightness": 220,
|
|
||||||
"delay": 250,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for preset_data in preset_defs:
|
|
||||||
pid = presets.create(profile_id)
|
|
||||||
presets.update(pid, preset_data)
|
|
||||||
seeded_preset_ids.append(str(pid))
|
|
||||||
|
|
||||||
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
|
||||||
zones.update(dj_tab_id, {
|
|
||||||
"presets_flat": seeded_preset_ids,
|
|
||||||
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
profile_tabs.append(str(dj_tab_id))
|
|
||||||
|
|
||||||
profiles.update(profile_id, {"zones": profile_tabs})
|
|
||||||
|
|
||||||
profile_data = profiles.read(profile_id)
|
|
||||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({"error": str(e)}), 400
|
|
||||||
|
|
||||||
@controller.post('/<id>/clone')
|
@controller.post('/<id>/clone')
|
||||||
async def clone_profile(request, id):
|
async def clone_profile(request, id):
|
||||||
@@ -351,6 +239,184 @@ async def clone_profile(request, id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get('/<id>')
|
||||||
|
@with_session
|
||||||
|
async def get_profile(request, id, session):
|
||||||
|
"""Get a specific profile by ID."""
|
||||||
|
# Handle 'current' as a special case
|
||||||
|
if id == 'current':
|
||||||
|
return await get_current_profile(request, session)
|
||||||
|
|
||||||
|
profile = profiles.read(id)
|
||||||
|
if profile:
|
||||||
|
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Profile not found"}), 404
|
||||||
|
|
||||||
|
@controller.post('')
|
||||||
|
async def create_profile(request):
|
||||||
|
"""Create a new profile."""
|
||||||
|
try:
|
||||||
|
data = dict(request.json or {})
|
||||||
|
name = data.get("name", "")
|
||||||
|
seed_raw = data.get("seed_dj_zone", False)
|
||||||
|
if isinstance(seed_raw, str):
|
||||||
|
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
else:
|
||||||
|
seed_dj_zone = bool(seed_raw)
|
||||||
|
# Request-only flag: do not persist on profile records.
|
||||||
|
data.pop("seed_dj_zone", None)
|
||||||
|
profile_id = profiles.create(name)
|
||||||
|
# Avoid persisting request-only fields.
|
||||||
|
data.pop("name", None)
|
||||||
|
if data:
|
||||||
|
profiles.update(profile_id, data)
|
||||||
|
|
||||||
|
# New profiles always start with a default zone pre-populated with starter presets.
|
||||||
|
default_preset_ids = []
|
||||||
|
default_preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "on",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#FFFFFF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "off",
|
||||||
|
"pattern": "off",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 0,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rainbow",
|
||||||
|
"pattern": "colour_cycle",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 2,
|
||||||
|
"mode": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Colour Cycle",
|
||||||
|
"pattern": "colour_cycle",
|
||||||
|
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 500,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "flicker",
|
||||||
|
"pattern": "flicker",
|
||||||
|
"colors": ["#FFB84D"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 80,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "flame",
|
||||||
|
"pattern": "flame",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 50,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 35,
|
||||||
|
"n2": 2600,
|
||||||
|
"n3": 0,
|
||||||
|
"n4": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "twinkle",
|
||||||
|
"pattern": "twinkle",
|
||||||
|
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 55,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 72,
|
||||||
|
"n2": 140,
|
||||||
|
"n3": 2,
|
||||||
|
"n4": 6,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in default_preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
default_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||||
|
zones.update(default_tab_id, {
|
||||||
|
"presets_flat": default_preset_ids,
|
||||||
|
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile = profiles.read(profile_id) or {}
|
||||||
|
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
|
||||||
|
profile_tabs.append(str(default_tab_id))
|
||||||
|
|
||||||
|
if seed_dj_zone:
|
||||||
|
# Seed a DJ-focused zone with three starter presets.
|
||||||
|
seeded_preset_ids = []
|
||||||
|
preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "DJ Rainbow",
|
||||||
|
"pattern": "colour_cycle",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 60,
|
||||||
|
"n1": 12,
|
||||||
|
"mode": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Single Color",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#ff00ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 250,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
seeded_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||||
|
zones.update(dj_tab_id, {
|
||||||
|
"presets_flat": seeded_preset_ids,
|
||||||
|
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile_tabs.append(str(dj_tab_id))
|
||||||
|
|
||||||
|
profiles.update(profile_id, {"zones": profile_tabs})
|
||||||
|
|
||||||
|
profile_data = profiles.read(profile_id)
|
||||||
|
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.put('/current')
|
@controller.put('/current')
|
||||||
@with_session
|
@with_session
|
||||||
async def update_current_profile(request, session):
|
async def update_current_profile(request, session):
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ from microdot.session import with_session
|
|||||||
from models.sequence import Sequence
|
from models.sequence import Sequence
|
||||||
from models.profile import Profile
|
from models.profile import Profile
|
||||||
from models.transport import get_current_sender
|
from models.transport import get_current_sender
|
||||||
|
from models.preset import Preset
|
||||||
|
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
sequences = Sequence()
|
sequences = Sequence()
|
||||||
profiles = Profile()
|
profiles = Profile()
|
||||||
|
presets = Preset()
|
||||||
|
|
||||||
|
|
||||||
def get_current_profile_id(session=None):
|
def get_current_profile_id(session=None):
|
||||||
@@ -39,6 +42,57 @@ async def list_sequences(request, session):
|
|||||||
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
|
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/<id>/export")
|
||||||
|
@with_session
|
||||||
|
async def export_sequence(request, session, id):
|
||||||
|
"""Export a sequence and referenced presets as a JSON bundle."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"}
|
||||||
|
try:
|
||||||
|
bundle = export_sequence_bundle(
|
||||||
|
id,
|
||||||
|
sequences,
|
||||||
|
presets,
|
||||||
|
profile_id=current_profile_id,
|
||||||
|
)
|
||||||
|
return json.dumps(bundle), 200, {"Content-Type": "application/json"}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/import")
|
||||||
|
@with_session
|
||||||
|
async def import_sequence(request, session):
|
||||||
|
"""Import a sequence bundle into the current profile."""
|
||||||
|
try:
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "No profile available"}),
|
||||||
|
404,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
body = request.json or {}
|
||||||
|
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Expected JSON bundle"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
|
||||||
|
return (
|
||||||
|
json.dumps({new_id: seq_data}),
|
||||||
|
201,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
@controller.get("/<id>")
|
@controller.get("/<id>")
|
||||||
@with_session
|
@with_session
|
||||||
async def get_sequence(request, session, id):
|
async def get_sequence(request, session, id):
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ async def clone_zone(request, session, id):
|
|||||||
source.get("names"),
|
source.get("names"),
|
||||||
source.get("presets"),
|
source.get("presets"),
|
||||||
source.get("group_ids"),
|
source.get("group_ids"),
|
||||||
|
source.get("content_kind"),
|
||||||
)
|
)
|
||||||
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
||||||
if extra:
|
if extra:
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ class Zone(Model):
|
|||||||
if changed:
|
if changed:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalized_content_kind(doc):
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return None
|
||||||
|
kind = doc.get("content_kind")
|
||||||
|
return kind if kind in ("presets", "sequences") else None
|
||||||
|
|
||||||
|
def _enforce_content_kind_invariants(self, doc):
|
||||||
|
"""Presets-only zones hold no sequences; sequences-only hold no preset tiles."""
|
||||||
|
kind = self._normalized_content_kind(doc)
|
||||||
|
if kind == "presets":
|
||||||
|
doc["sequence_ids"] = []
|
||||||
|
elif kind == "sequences":
|
||||||
|
doc["presets"] = []
|
||||||
|
doc["presets_flat"] = []
|
||||||
|
|
||||||
def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None):
|
def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None):
|
||||||
next_id = self.get_next_id()
|
next_id = self.get_next_id()
|
||||||
gid_list = []
|
gid_list = []
|
||||||
@@ -62,6 +78,9 @@ class Zone(Model):
|
|||||||
}
|
}
|
||||||
if content_kind in ("presets", "sequences"):
|
if content_kind in ("presets", "sequences"):
|
||||||
doc["content_kind"] = content_kind
|
doc["content_kind"] = content_kind
|
||||||
|
if "sequence_ids" not in doc:
|
||||||
|
doc["sequence_ids"] = []
|
||||||
|
self._enforce_content_kind_invariants(doc)
|
||||||
self[next_id] = doc
|
self[next_id] = doc
|
||||||
self.save()
|
self.save()
|
||||||
return next_id
|
return next_id
|
||||||
@@ -74,7 +93,9 @@ class Zone(Model):
|
|||||||
id_str = str(id)
|
id_str = str(id)
|
||||||
if id_str not in self:
|
if id_str not in self:
|
||||||
return False
|
return False
|
||||||
self[id_str].update(data)
|
patch = data if isinstance(data, dict) else {}
|
||||||
|
self[id_str].update(patch)
|
||||||
|
self._enforce_content_kind_invariants(self[id_str])
|
||||||
self.save()
|
self.save()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
48
src/static/bundle_io.js
Normal file
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),
|
n3: coercePresetInt(preset.n3),
|
||||||
n4: coercePresetInt(preset.n4),
|
n4: coercePresetInt(preset.n4),
|
||||||
n5: coercePresetInt(preset.n5),
|
n5: coercePresetInt(preset.n5),
|
||||||
n6: coercePresetInt(preset.n6),
|
n6: (() => {
|
||||||
|
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
|
||||||
|
return coercePresetInt(preset.mode);
|
||||||
|
}
|
||||||
|
return coercePresetInt(preset.n6);
|
||||||
|
})(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (!Object.keys(wirePresets).length) {
|
if (!Object.keys(wirePresets).length) {
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ let espnowSocketReady = false;
|
|||||||
let espnowPendingMessages = [];
|
let espnowPendingMessages = [];
|
||||||
let currentProfileIdCache = null;
|
let currentProfileIdCache = null;
|
||||||
|
|
||||||
|
function coercePresetInt(v, def = 0) {
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
const t = parseInt(String(v), 10);
|
||||||
|
return Number.isFinite(t) ? t : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Style variant for wire ``n6``; presets may store ``mode`` or legacy ``n6``. */
|
||||||
|
function presetWireN6(preset, def = 0) {
|
||||||
|
if (!preset || typeof preset !== 'object') {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
|
||||||
|
return coercePresetInt(preset.mode, def);
|
||||||
|
}
|
||||||
|
return coercePresetInt(preset.n6, def);
|
||||||
|
}
|
||||||
|
|
||||||
const getCurrentProfileId = async () => {
|
const getCurrentProfileId = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||||||
@@ -243,6 +262,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const presetSaveButton = document.getElementById('preset-save-btn');
|
const presetSaveButton = document.getElementById('preset-save-btn');
|
||||||
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
||||||
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
|
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
|
||||||
|
const presetModeInput = document.getElementById('preset-mode-input');
|
||||||
|
const presetModeGroup = document.getElementById('preset-mode-group');
|
||||||
|
|
||||||
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
|
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
|
||||||
return;
|
return;
|
||||||
@@ -297,7 +318,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
patternConfig.parameter_mappings &&
|
patternConfig.parameter_mappings &&
|
||||||
typeof patternConfig.parameter_mappings === 'object'
|
typeof patternConfig.parameter_mappings === 'object'
|
||||||
) {
|
) {
|
||||||
patternConfig = patternConfig.parameter_mappings;
|
const { parameter_mappings: pm, data: _data, ...rest } = patternConfig;
|
||||||
|
patternConfig = { ...rest, ...pm };
|
||||||
}
|
}
|
||||||
return patternConfig && typeof patternConfig === 'object' ? patternConfig : null;
|
return patternConfig && typeof patternConfig === 'object' ? patternConfig : null;
|
||||||
};
|
};
|
||||||
@@ -311,6 +333,46 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return cfg.supports_manual !== false;
|
return cfg.supports_manual !== false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPatternModeOptions = (patternName) => {
|
||||||
|
const cfg = resolvePatternConfig(patternName);
|
||||||
|
if (!cfg || typeof cfg.mode !== 'object' || cfg.mode === null || Array.isArray(cfg.mode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entries = Object.entries(cfg.mode).filter(
|
||||||
|
([, label]) => typeof label === 'string' && label.trim(),
|
||||||
|
);
|
||||||
|
if (entries.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
entries.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10));
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const patternSupportsModes = (patternName) => getPatternModeOptions(patternName) !== null;
|
||||||
|
|
||||||
|
const setPresetModeFieldVisible = (show) => {
|
||||||
|
if (!presetModeGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
presetModeGroup.hidden = !show;
|
||||||
|
presetModeGroup.style.display = show ? '' : 'none';
|
||||||
|
if (!show && presetModeInput) {
|
||||||
|
presetModeInput.innerHTML = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const presetStoredMode = (preset) => {
|
||||||
|
if (!preset || typeof preset !== 'object') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
|
||||||
|
const m = parseInt(String(preset.mode), 10);
|
||||||
|
return Number.isFinite(m) ? m : 0;
|
||||||
|
}
|
||||||
|
const n6 = parseInt(String(preset.n6), 10);
|
||||||
|
return Number.isFinite(n6) ? n6 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
const updateManualBeatNVisibility = () => {
|
const updateManualBeatNVisibility = () => {
|
||||||
if (!presetManualBeatNWrap) {
|
if (!presetManualBeatNWrap) {
|
||||||
return;
|
return;
|
||||||
@@ -734,7 +796,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs
|
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs
|
||||||
updatePresetNLabels(patternName);
|
updatePresetNLabels(patternName, preset);
|
||||||
updateManualModeAvailability();
|
updateManualModeAvailability();
|
||||||
updatePresetEditorTabActionsVisibility();
|
updatePresetEditorTabActionsVisibility();
|
||||||
};
|
};
|
||||||
@@ -793,10 +855,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return section ? section.dataset.zoneId : null;
|
return section ? section.dataset.zoneId : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePresetEditorTabActionsVisibility = () => {
|
const updatePresetEditorTabActionsVisibility = async () => {
|
||||||
if (!presetRemoveFromTabButton) return;
|
if (!presetRemoveFromTabButton) return;
|
||||||
const show = Boolean(currentEditTabId && currentEditId);
|
if (!currentEditTabId || !currentEditId) {
|
||||||
presetRemoveFromTabButton.hidden = !show;
|
presetRemoveFromTabButton.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tabRes = await fetch(`/zones/${currentEditTabId}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!tabRes.ok) {
|
||||||
|
presetRemoveFromTabButton.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tabData = await tabRes.json();
|
||||||
|
const allowed =
|
||||||
|
typeof window.zoneAllowsPresets === 'function'
|
||||||
|
? window.zoneAllowsPresets(tabData)
|
||||||
|
: true;
|
||||||
|
presetRemoveFromTabButton.hidden = !allowed;
|
||||||
|
} catch (e) {
|
||||||
|
presetRemoveFromTabButton.hidden = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTabDefaultPreset = async (presetId) => {
|
const updateTabDefaultPreset = async (presetId) => {
|
||||||
@@ -827,8 +908,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (presetEditorModal) {
|
if (presetEditorModal) {
|
||||||
presetEditorModal.classList.add('active');
|
presetEditorModal.classList.add('active');
|
||||||
}
|
}
|
||||||
|
const patternName = presetPatternInput ? presetPatternInput.value : '';
|
||||||
|
const modeBefore = patternSupportsModes(patternName)
|
||||||
|
? presetStoredMode({
|
||||||
|
mode: presetModeInput ? presetModeInput.value : undefined,
|
||||||
|
n6: getNumberInput('preset-n6-input'),
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
loadPatterns().then(() => {
|
loadPatterns().then(() => {
|
||||||
updatePresetNLabels(presetPatternInput ? presetPatternInput.value : '');
|
updatePresetNLabels(patternName, { mode: modeBefore, n6: modeBefore });
|
||||||
updateColorSectionVisibility();
|
updateColorSectionVisibility();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -859,11 +947,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
})(),
|
})(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Always store numeric parameters as n1..n8.
|
// Always store numeric parameters as n1..n8 (except n6 when pattern uses mode).
|
||||||
|
const modeEntries = patternSupportsModes(payload.pattern)
|
||||||
|
? getPatternModeOptions(payload.pattern)
|
||||||
|
: null;
|
||||||
for (let i = 1; i <= 8; i++) {
|
for (let i = 1; i <= 8; i++) {
|
||||||
const nKey = `n${i}`;
|
const nKey = `n${i}`;
|
||||||
|
if (modeEntries && nKey === 'n6') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
|
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
|
||||||
}
|
}
|
||||||
|
if (modeEntries && presetModeInput) {
|
||||||
|
payload.mode = parseInt(presetModeInput.value, 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
@@ -950,30 +1047,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePresetNLabels = (patternName) => {
|
const updatePresetNLabels = (patternName, presetForMode = null) => {
|
||||||
const rawPatternName = String(patternName || '').trim();
|
const patternConfig = resolvePatternConfig(patternName);
|
||||||
const normalizedPatternName = rawPatternName.endsWith('.py')
|
|
||||||
? rawPatternName.slice(0, -3)
|
|
||||||
: rawPatternName;
|
|
||||||
let patternConfig =
|
|
||||||
(cachedPatterns && cachedPatterns[rawPatternName]) ||
|
|
||||||
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
|
|
||||||
null;
|
|
||||||
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
|
|
||||||
const lower = normalizedPatternName.toLowerCase();
|
|
||||||
const matchedKey = Object.keys(cachedPatterns).find(
|
|
||||||
(k) => String(k).toLowerCase() === lower,
|
|
||||||
);
|
|
||||||
if (matchedKey) {
|
|
||||||
patternConfig = cachedPatterns[matchedKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
|
|
||||||
patternConfig = patternConfig.data;
|
|
||||||
}
|
|
||||||
if (patternConfig && typeof patternConfig === 'object' && patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object') {
|
|
||||||
patternConfig = patternConfig.parameter_mappings;
|
|
||||||
}
|
|
||||||
const labels = {};
|
const labels = {};
|
||||||
const visibleNKeys = new Set();
|
const visibleNKeys = new Set();
|
||||||
|
|
||||||
@@ -989,9 +1064,35 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modeEntries = patternSupportsModes(patternName) ? getPatternModeOptions(patternName) : null;
|
||||||
|
if (modeEntries) {
|
||||||
|
visibleNKeys.delete('n6');
|
||||||
|
}
|
||||||
|
if (presetModeInput) {
|
||||||
|
if (modeEntries) {
|
||||||
|
setPresetModeFieldVisible(true);
|
||||||
|
presetModeInput.innerHTML = '';
|
||||||
|
modeEntries.forEach(([val, label]) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = val;
|
||||||
|
opt.textContent = label.trim();
|
||||||
|
presetModeInput.appendChild(opt);
|
||||||
|
});
|
||||||
|
const modeVal = presetForMode ? presetStoredMode(presetForMode) : 0;
|
||||||
|
const modeStr = String(modeVal);
|
||||||
|
if ([...presetModeInput.options].some((o) => o.value === modeStr)) {
|
||||||
|
presetModeInput.value = modeStr;
|
||||||
|
} else if (presetModeInput.options.length) {
|
||||||
|
presetModeInput.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPresetModeFieldVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hasPatternMeta =
|
const hasPatternMeta =
|
||||||
patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0;
|
patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0;
|
||||||
const hasAnyNLabel = visibleNKeys.size > 0;
|
const hasAnyNLabel = visibleNKeys.size > 0 || Boolean(modeEntries);
|
||||||
|
|
||||||
for (let i = 1; i <= 8; i++) {
|
for (let i = 1; i <= 8; i++) {
|
||||||
const nKey = `n${i}`;
|
const nKey = `n${i}`;
|
||||||
@@ -1073,6 +1174,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
void sendPresetViaEspNow(presetId, preset || {}, []);
|
void sendPresetViaEspNow(presetId, preset || {}, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exportButton = document.createElement('button');
|
||||||
|
exportButton.className = 'btn btn-secondary btn-small';
|
||||||
|
exportButton.textContent = 'Export';
|
||||||
|
exportButton.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/presets/${presetId}/export`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Export failed');
|
||||||
|
}
|
||||||
|
const bundle = await response.json();
|
||||||
|
const safeName = ((preset && preset.name) || presetId).replace(/[^\w.-]+/g, '_');
|
||||||
|
window.downloadJsonFile(`preset-${safeName}.json`, bundle);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export preset failed:', error);
|
||||||
|
alert('Failed to export preset.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const deleteButton = document.createElement('button');
|
const deleteButton = document.createElement('button');
|
||||||
deleteButton.className = 'btn btn-danger btn-small';
|
deleteButton.className = 'btn btn-danger btn-small';
|
||||||
deleteButton.textContent = 'Delete';
|
deleteButton.textContent = 'Delete';
|
||||||
@@ -1102,6 +1223,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(details);
|
row.appendChild(details);
|
||||||
row.appendChild(editButton);
|
row.appendChild(editButton);
|
||||||
|
row.appendChild(exportButton);
|
||||||
row.appendChild(sendButton);
|
row.appendChild(sendButton);
|
||||||
row.appendChild(deleteButton);
|
row.appendChild(deleteButton);
|
||||||
presetsList.appendChild(row);
|
presetsList.appendChild(row);
|
||||||
@@ -1148,6 +1270,34 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (presetsCloseButton) {
|
if (presetsCloseButton) {
|
||||||
presetsCloseButton.addEventListener('click', closeModal);
|
presetsCloseButton.addEventListener('click', closeModal);
|
||||||
}
|
}
|
||||||
|
const importPresetBtn = document.getElementById('import-preset-btn');
|
||||||
|
if (importPresetBtn) {
|
||||||
|
importPresetBtn.addEventListener('click', async () => {
|
||||||
|
const text = await window.pickJsonFile();
|
||||||
|
if (!text) return;
|
||||||
|
const bundle = window.parseJsonFileText(text);
|
||||||
|
if (!bundle || bundle.kind !== 'preset') {
|
||||||
|
alert('Invalid preset bundle file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/presets/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({ bundle }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Import failed');
|
||||||
|
}
|
||||||
|
await loadPresets();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Import preset failed:', error);
|
||||||
|
alert(error.message || 'Failed to import preset.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (presetsAddButton) {
|
if (presetsAddButton) {
|
||||||
presetsAddButton.addEventListener('click', () => {
|
presetsAddButton.addEventListener('click', () => {
|
||||||
clearForm();
|
clearForm();
|
||||||
@@ -1199,6 +1349,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const zoneCheck = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||||
|
if (zoneCheck.ok) {
|
||||||
|
const zoneDoc = await zoneCheck.json();
|
||||||
|
if (
|
||||||
|
typeof window.zoneAllowsPresets === 'function' &&
|
||||||
|
!window.zoneAllowsPresets(zoneDoc)
|
||||||
|
) {
|
||||||
|
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not verify zone content kind:', e);
|
||||||
|
}
|
||||||
|
|
||||||
// Load all presets
|
// Load all presets
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/presets', {
|
const response = await fetch('/presets', {
|
||||||
@@ -1327,11 +1493,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
throw new Error('Failed to load zone');
|
throw new Error('Failed to load zone');
|
||||||
}
|
}
|
||||||
const tabData = await tabResponse.json();
|
const tabData = await tabResponse.json();
|
||||||
const kind =
|
if (
|
||||||
typeof window.normalizeZoneContentKind === 'function'
|
typeof window.zoneAllowsPresets === 'function' &&
|
||||||
? window.normalizeZoneContentKind(tabData)
|
!window.zoneAllowsPresets(tabData)
|
||||||
: null;
|
) {
|
||||||
if (kind === 'sequences') {
|
|
||||||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1697,14 +1862,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
clearForm();
|
clearForm();
|
||||||
});
|
});
|
||||||
|
|
||||||
const coercePresetInt = (v, def = 0) => {
|
|
||||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
const t = parseInt(String(v), 10);
|
|
||||||
return Number.isFinite(t) ? t : def;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */
|
/** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */
|
||||||
const coercePresetAuto = (preset) => {
|
const coercePresetAuto = (preset) => {
|
||||||
if (!preset || typeof preset !== 'object') {
|
if (!preset || typeof preset !== 'object') {
|
||||||
@@ -1826,7 +1983,7 @@ const sendPresetViaEspNow = async (
|
|||||||
n3: coercePresetInt(preset.n3),
|
n3: coercePresetInt(preset.n3),
|
||||||
n4: coercePresetInt(preset.n4),
|
n4: coercePresetInt(preset.n4),
|
||||||
n5: coercePresetInt(preset.n5),
|
n5: coercePresetInt(preset.n5),
|
||||||
n6: coercePresetInt(preset.n6),
|
n6: presetWireN6(preset),
|
||||||
manual_beat_n: coerceManualBeatN(preset),
|
manual_beat_n: coerceManualBeatN(preset),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1929,7 +2086,7 @@ try {
|
|||||||
// window may not exist in some environments; ignore.
|
// window may not exist in some environments; ignore.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store selected preset(s) per zone (multi-select; merge send order = click order, last wins on device).
|
// Store selected preset per zone (single-select; one tile active, one driver push per click).
|
||||||
const zoneSelectedPresetIds = {};
|
const zoneSelectedPresetIds = {};
|
||||||
const zonePresetSelectionOrder = {};
|
const zonePresetSelectionOrder = {};
|
||||||
|
|
||||||
@@ -1956,19 +2113,21 @@ function getOrderedZonePresetSelection(zoneId) {
|
|||||||
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
|
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMergedZonePresetSelection(zoneId, tabData, allPresets) {
|
/** Preset id that should show the tile outline (last click in selection order). */
|
||||||
const ids = getOrderedZonePresetSelection(zoneId);
|
function getLastZonePresetSelectionId(zoneId) {
|
||||||
if (!ids.length) return;
|
const order = getOrderedZonePresetSelection(zoneId);
|
||||||
for (let i = 0; i < ids.length; i += 1) {
|
return order.length ? String(order[order.length - 1]) : null;
|
||||||
const pid = ids[i];
|
}
|
||||||
const preset = allPresets[pid];
|
|
||||||
if (!preset) continue;
|
async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPresets) {
|
||||||
|
const pid = String(presetId);
|
||||||
|
const body = (allPresets && allPresets[pid]) || preset;
|
||||||
|
if (!body) return;
|
||||||
const names =
|
const names =
|
||||||
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
|
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
|
||||||
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
|
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
|
||||||
: [];
|
: [];
|
||||||
await sendPresetViaEspNow(pid, preset, names, false, false, '2');
|
await sendPresetViaEspNow(pid, body, names, false, false, '2');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store selected preset per zone
|
// Store selected preset per zone
|
||||||
@@ -2053,6 +2212,12 @@ const savePresetGrid = async (zoneId, presetGrid) => {
|
|||||||
throw new Error('Failed to load zone');
|
throw new Error('Failed to load zone');
|
||||||
}
|
}
|
||||||
const tabData = await tabResponse.json();
|
const tabData = await tabResponse.json();
|
||||||
|
if (
|
||||||
|
typeof window.zoneAllowsPresets === 'function' &&
|
||||||
|
!window.zoneAllowsPresets(tabData)
|
||||||
|
) {
|
||||||
|
throw new Error('This zone is for sequences only.');
|
||||||
|
}
|
||||||
|
|
||||||
// Store as 2D grid
|
// Store as 2D grid
|
||||||
tabData.presets = presetGrid;
|
tabData.presets = presetGrid;
|
||||||
@@ -2265,7 +2430,9 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
|||||||
const preset = allPresets[presetId];
|
const preset = allPresets[presetId];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
ensureZonePresetSelection(zoneId);
|
ensureZonePresetSelection(zoneId);
|
||||||
const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId));
|
const lastSelectedId = getLastZonePresetSelectionId(zoneId);
|
||||||
|
const isSelected =
|
||||||
|
lastSelectedId !== null && lastSelectedId === String(presetId);
|
||||||
const displayPreset = {
|
const displayPreset = {
|
||||||
...preset,
|
...preset,
|
||||||
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
||||||
@@ -2285,7 +2452,10 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.appendZoneSequenceTiles === 'function' && ck !== 'presets') {
|
if (
|
||||||
|
typeof window.appendZoneSequenceTiles === 'function' &&
|
||||||
|
(typeof window.zoneAllowsSequences !== 'function' || window.zoneAllowsSequences(tabData))
|
||||||
|
) {
|
||||||
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
|
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2311,7 +2481,9 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
|
|||||||
}
|
}
|
||||||
|
|
||||||
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
|
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
|
||||||
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
|
const pat = (preset.pattern || '').toLowerCase();
|
||||||
|
const mode = presetWireN6(preset);
|
||||||
|
const isRainbow = pat === 'rainbow' || (pat === 'colour_cycle' && mode === 1);
|
||||||
const barColors = isRainbow
|
const barColors = isRainbow
|
||||||
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
|
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
|
||||||
: colors;
|
: colors;
|
||||||
@@ -2389,34 +2561,32 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
|
|||||||
ensureZonePresetSelection(zoneId);
|
ensureZonePresetSelection(zoneId);
|
||||||
const z = String(zoneId);
|
const z = String(zoneId);
|
||||||
const set = zoneSelectedPresetIds[z];
|
const set = zoneSelectedPresetIds[z];
|
||||||
const order = zonePresetSelectionOrder[z];
|
|
||||||
const idStr = String(presetId);
|
const idStr = String(presetId);
|
||||||
if (set.has(idStr)) {
|
const wasSelected = set.has(idStr);
|
||||||
set.delete(idStr);
|
set.clear();
|
||||||
zonePresetSelectionOrder[z] = order.filter((x) => String(x) !== idStr);
|
zonePresetSelectionOrder[z] = [];
|
||||||
} else {
|
if (!wasSelected) {
|
||||||
set.add(idStr);
|
set.add(idStr);
|
||||||
order.push(idStr);
|
zonePresetSelectionOrder[z] = [idStr];
|
||||||
}
|
}
|
||||||
|
const outlinePresetId = getLastZonePresetSelectionId(zoneId);
|
||||||
if (presetsListEl) {
|
if (presetsListEl) {
|
||||||
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
|
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
|
||||||
const pid = rw.dataset.presetId;
|
const pid = rw.dataset.presetId;
|
||||||
const btnEl = rw.querySelector('.preset-tile-main');
|
const btnEl = rw.querySelector('.preset-tile-main');
|
||||||
if (!btnEl || !pid) return;
|
if (!btnEl || !pid) return;
|
||||||
if (set.has(String(pid))) btnEl.classList.add('active');
|
if (outlinePresetId && String(pid) === outlinePresetId) btnEl.classList.add('active');
|
||||||
else btnEl.classList.remove('active');
|
else btnEl.classList.remove('active');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const orderList = getOrderedZonePresetSelection(zoneId);
|
if (!wasSelected) {
|
||||||
if (orderList.length) {
|
selectedPresets[zoneId] = idStr;
|
||||||
const lastPid = orderList[orderList.length - 1];
|
selectedPresetPayloads[zoneId] = (allPresets && allPresets[idStr]) || preset;
|
||||||
selectedPresets[zoneId] = lastPid;
|
void sendZonePresetSelection(zoneId, tabData, idStr, preset, allPresets);
|
||||||
selectedPresetPayloads[zoneId] = (allPresets && allPresets[lastPid]) || preset;
|
|
||||||
} else {
|
} else {
|
||||||
delete selectedPresets[zoneId];
|
delete selectedPresets[zoneId];
|
||||||
delete selectedPresetPayloads[zoneId];
|
delete selectedPresetPayloads[zoneId];
|
||||||
}
|
}
|
||||||
void sendMergedZonePresetSelection(zoneId, tabData, allPresets);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (canDrag) {
|
if (canDrag) {
|
||||||
@@ -2526,6 +2696,13 @@ const removePresetFromTab = async (zoneId, presetId) => {
|
|||||||
throw new Error('Failed to load zone');
|
throw new Error('Failed to load zone');
|
||||||
}
|
}
|
||||||
const tabData = await tabResponse.json();
|
const tabData = await tabResponse.json();
|
||||||
|
if (
|
||||||
|
typeof window.zoneAllowsPresets === 'function' &&
|
||||||
|
!window.zoneAllowsPresets(tabData)
|
||||||
|
) {
|
||||||
|
alert('This zone is for sequences only.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize to flat array
|
// Normalize to flat array
|
||||||
let flat = [];
|
let flat = [];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const newProfileInput = document.getElementById("new-profile-name");
|
const newProfileInput = document.getElementById("new-profile-name");
|
||||||
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
|
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
|
||||||
const createProfileButton = document.getElementById("create-profile-btn");
|
const createProfileButton = document.getElementById("create-profile-btn");
|
||||||
|
const importProfileButton = document.getElementById("import-profile-btn");
|
||||||
|
|
||||||
if (!profilesButton || !profilesModal || !profilesList) {
|
if (!profilesButton || !profilesModal || !profilesList) {
|
||||||
return;
|
return;
|
||||||
@@ -101,6 +102,26 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exportButton = document.createElement("button");
|
||||||
|
exportButton.className = "btn btn-secondary btn-small";
|
||||||
|
exportButton.textContent = "Export";
|
||||||
|
exportButton.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/profiles/${profileId}/export`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Export failed");
|
||||||
|
}
|
||||||
|
const bundle = await response.json();
|
||||||
|
const safeName = ((profile && profile.name) || profileId).replace(/[^\w.-]+/g, "_");
|
||||||
|
window.downloadJsonFile(`profile-${safeName}.json`, bundle);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Export profile failed:", error);
|
||||||
|
alert("Failed to export profile.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const cloneButton = document.createElement("button");
|
const cloneButton = document.createElement("button");
|
||||||
cloneButton.className = "btn btn-secondary btn-small";
|
cloneButton.className = "btn btn-secondary btn-small";
|
||||||
cloneButton.textContent = "Clone";
|
cloneButton.textContent = "Clone";
|
||||||
@@ -177,6 +198,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(applyButton);
|
row.appendChild(applyButton);
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
|
row.appendChild(exportButton);
|
||||||
row.appendChild(cloneButton);
|
row.appendChild(cloneButton);
|
||||||
row.appendChild(deleteButton);
|
row.appendChild(deleteButton);
|
||||||
}
|
}
|
||||||
@@ -276,6 +298,60 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (createProfileButton) {
|
if (createProfileButton) {
|
||||||
createProfileButton.addEventListener("click", createProfile);
|
createProfileButton.addEventListener("click", createProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importProfile = async () => {
|
||||||
|
if (!isEditModeActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await window.pickJsonFile();
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bundle = window.parseJsonFileText(text);
|
||||||
|
if (!bundle || typeof bundle !== "object") {
|
||||||
|
alert("Invalid JSON file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const defaultName =
|
||||||
|
(bundle.profile && bundle.profile.name) || "Imported profile";
|
||||||
|
const name = prompt("Profile name for import:", defaultName);
|
||||||
|
if (name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = String(name).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
alert("Profile name cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch("/profiles/import", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ bundle, name: trimmed, apply: true }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Import failed");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const newProfileId = data.id || Object.keys(data).find((k) => k !== "id");
|
||||||
|
if (newProfileId) {
|
||||||
|
await fetch(`/profiles/${newProfileId}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await loadProfiles();
|
||||||
|
await refreshTabsForActiveProfile();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Import profile failed:", error);
|
||||||
|
alert(error.message || "Failed to import profile.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (importProfileButton) {
|
||||||
|
importProfileButton.addEventListener("click", importProfile);
|
||||||
|
}
|
||||||
if (newProfileInput) {
|
if (newProfileInput) {
|
||||||
newProfileInput.addEventListener("keypress", (event) => {
|
newProfileInput.addEventListener("keypress", (event) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
|
|||||||
@@ -454,11 +454,10 @@ async function addSequenceToTab(sequenceId, zoneId) {
|
|||||||
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||||
if (!tabResponse.ok) throw new Error('Failed to load zone');
|
if (!tabResponse.ok) throw new Error('Failed to load zone');
|
||||||
const tabData = await tabResponse.json();
|
const tabData = await tabResponse.json();
|
||||||
const kind =
|
if (
|
||||||
typeof window.normalizeZoneContentKind === 'function'
|
typeof window.zoneAllowsSequences === 'function' &&
|
||||||
? window.normalizeZoneContentKind(tabData)
|
!window.zoneAllowsSequences(tabData)
|
||||||
: null;
|
) {
|
||||||
if (kind === 'presets') {
|
|
||||||
alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
|
alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -524,11 +523,10 @@ async function refreshEditTabSequencesUi(zoneId) {
|
|||||||
const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||||
if (!zoneRes.ok) throw new Error('zone');
|
if (!zoneRes.ok) throw new Error('zone');
|
||||||
const zone = await zoneRes.json();
|
const zone = await zoneRes.json();
|
||||||
const kind =
|
if (
|
||||||
typeof window.normalizeZoneContentKind === 'function'
|
typeof window.zoneAllowsSequences === 'function' &&
|
||||||
? window.normalizeZoneContentKind(zone)
|
!window.zoneAllowsSequences(zone)
|
||||||
: null;
|
) {
|
||||||
if (kind === 'presets') {
|
|
||||||
currentEl.innerHTML =
|
currentEl.innerHTML =
|
||||||
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
|
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
|
||||||
addEl.innerHTML = '<span class="muted-text">—</span>';
|
addEl.innerHTML = '<span class="muted-text">—</span>';
|
||||||
@@ -1092,12 +1090,31 @@ async function loadSequencesModalList() {
|
|||||||
const nSteps = ln.reduce((a, l) => a + l.length, 0);
|
const nSteps = ln.reduce((a, l) => a + l.length, 0);
|
||||||
const nLanes = ln.filter((l) => l.length > 0).length || 1;
|
const nLanes = ln.filter((l) => l.length > 0).length || 1;
|
||||||
title.textContent = `${doc.name || id} — ${nLanes} lane(s), ${nSteps} step(s)`;
|
title.textContent = `${doc.name || id} — ${nLanes} lane(s), ${nSteps} step(s)`;
|
||||||
|
const exportBtn = document.createElement('button');
|
||||||
|
exportBtn.type = 'button';
|
||||||
|
exportBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
exportBtn.textContent = 'Export';
|
||||||
|
exportBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/sequences/${id}/export`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Export failed');
|
||||||
|
const bundle = await response.json();
|
||||||
|
const safeName = String(doc.name || id).replace(/[^\w.-]+/g, '_');
|
||||||
|
window.downloadJsonFile(`sequence-${safeName}.json`, bundle);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Failed to export sequence.');
|
||||||
|
}
|
||||||
|
});
|
||||||
const edit = document.createElement('button');
|
const edit = document.createElement('button');
|
||||||
edit.type = 'button';
|
edit.type = 'button';
|
||||||
edit.className = 'btn btn-secondary btn-small';
|
edit.className = 'btn btn-secondary btn-small';
|
||||||
edit.textContent = 'Edit';
|
edit.textContent = 'Edit';
|
||||||
edit.addEventListener('click', () => openSequenceEditor(id, doc));
|
edit.addEventListener('click', () => openSequenceEditor(id, doc));
|
||||||
row.appendChild(title);
|
row.appendChild(title);
|
||||||
|
row.appendChild(exportBtn);
|
||||||
row.appendChild(edit);
|
row.appendChild(edit);
|
||||||
listEl.appendChild(row);
|
listEl.appendChild(row);
|
||||||
});
|
});
|
||||||
@@ -1139,6 +1156,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
openSequenceEditor(null, null);
|
openSequenceEditor(null, null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const importSeqBtn = document.getElementById('import-sequence-btn');
|
||||||
|
if (importSeqBtn) {
|
||||||
|
importSeqBtn.addEventListener('click', async () => {
|
||||||
|
const text = await window.pickJsonFile();
|
||||||
|
if (!text) return;
|
||||||
|
const bundle = window.parseJsonFileText(text);
|
||||||
|
if (!bundle || bundle.kind !== 'sequence') {
|
||||||
|
alert('Invalid sequence bundle file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/sequences/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({ bundle }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Import failed');
|
||||||
|
}
|
||||||
|
await loadSequencesModalList();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(e.message || 'Failed to import sequence.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn');
|
const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn');
|
||||||
if (openPresetsFromSeq) {
|
if (openPresetsFromSeq) {
|
||||||
openPresetsFromSeq.addEventListener('click', () => {
|
openPresetsFromSeq.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -598,6 +598,39 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preset-mode-field {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-mode-field label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-mode-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-mode-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6a9fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preset-editor-modal .preset-mode-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.n-input {
|
.n-input {
|
||||||
flex: 0 0 var(--n-input-width, 5ch);
|
flex: 0 0 var(--n-input-width, 5ch);
|
||||||
width: var(--n-input-width, 5ch);
|
width: var(--n-input-width, 5ch);
|
||||||
@@ -1383,6 +1416,22 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
min-width: 8rem;
|
min-width: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.zone-content-kind-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0.35rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content-kind-row label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.zone-devices-label {
|
.zone-devices-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
|
|||||||
@@ -497,6 +497,42 @@ function normalizeZoneContentKind(zoneDoc) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Display/save kind when ``content_kind`` is missing (legacy rows). */
|
||||||
|
function effectiveZoneContentKind(zoneDoc) {
|
||||||
|
const explicit = normalizeZoneContentKind(zoneDoc);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const seqIds = Array.isArray(zoneDoc && zoneDoc.sequence_ids)
|
||||||
|
? zoneDoc.sequence_ids.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const presetIds = tabPresetIdsInZoneDoc(zoneDoc || {});
|
||||||
|
if (seqIds.length > 0 && presetIds.length === 0) return 'sequences';
|
||||||
|
return 'presets';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {'presets' | 'sequences'} */
|
||||||
|
function editModalContentKindSelected() {
|
||||||
|
const radio = document.querySelector('input[name="edit-zone-content-kind"]:checked');
|
||||||
|
return radio && radio.value === 'sequences' ? 'sequences' : 'presets';
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeZoneContentKind(zoneDoc) {
|
||||||
|
const modal = document.getElementById('edit-zone-modal');
|
||||||
|
if (modal && modal.classList.contains('active')) {
|
||||||
|
return editModalContentKindSelected();
|
||||||
|
}
|
||||||
|
return effectiveZoneContentKind(zoneDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
function zoneAllowsPresets(zoneDoc) {
|
||||||
|
return activeZoneContentKind(zoneDoc) === 'presets';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
function zoneAllowsSequences(zoneDoc) {
|
||||||
|
return activeZoneContentKind(zoneDoc) === 'sequences';
|
||||||
|
}
|
||||||
|
|
||||||
function applyZoneContentKindEditModal(kind) {
|
function applyZoneContentKindEditModal(kind) {
|
||||||
const presetsBlock = document.getElementById('edit-zone-block-presets');
|
const presetsBlock = document.getElementById('edit-zone-block-presets');
|
||||||
const groupsBlock = document.getElementById('edit-zone-block-groups');
|
const groupsBlock = document.getElementById('edit-zone-block-groups');
|
||||||
@@ -504,17 +540,16 @@ function applyZoneContentKindEditModal(kind) {
|
|||||||
const vis = (el, show) => {
|
const vis = (el, show) => {
|
||||||
if (el) el.style.display = show ? '' : 'none';
|
if (el) el.style.display = show ? '' : 'none';
|
||||||
};
|
};
|
||||||
|
const k = kind === 'sequences' ? 'sequences' : 'presets';
|
||||||
vis(groupsBlock, true);
|
vis(groupsBlock, true);
|
||||||
if (!kind) {
|
vis(presetsBlock, k === 'presets');
|
||||||
vis(presetsBlock, true);
|
vis(seqBlock, k === 'sequences');
|
||||||
vis(seqBlock, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
vis(presetsBlock, kind === 'presets');
|
|
||||||
vis(seqBlock, kind === 'sequences');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.normalizeZoneContentKind = normalizeZoneContentKind;
|
window.normalizeZoneContentKind = normalizeZoneContentKind;
|
||||||
|
window.effectiveZoneContentKind = effectiveZoneContentKind;
|
||||||
|
window.zoneAllowsPresets = zoneAllowsPresets;
|
||||||
|
window.zoneAllowsSequences = zoneAllowsSequences;
|
||||||
|
|
||||||
// Load tabs list
|
// Load tabs list
|
||||||
async function loadZones() {
|
async function loadZones() {
|
||||||
@@ -573,10 +608,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
|
|||||||
const zone = tabs[zoneId];
|
const zone = tabs[zoneId];
|
||||||
if (zone) {
|
if (zone) {
|
||||||
const activeClass = zoneId === currentZoneId ? 'active' : '';
|
const activeClass = zoneId === currentZoneId ? 'active' : '';
|
||||||
let disp = zone.name || `Zone ${zoneId}`;
|
const disp = zone.name || `Zone ${zoneId}`;
|
||||||
const kind = normalizeZoneContentKind(zone);
|
|
||||||
if (kind === 'presets') disp += ' · presets';
|
|
||||||
else if (kind === 'sequences') disp += ' · sequences';
|
|
||||||
html += `
|
html += `
|
||||||
<button class="zone-button ${activeClass}"
|
<button class="zone-button ${activeClass}"
|
||||||
data-zone-id="${zoneId}"
|
data-zone-id="${zoneId}"
|
||||||
@@ -622,10 +654,7 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
|
|||||||
row.dataset.zoneId = String(zoneId);
|
row.dataset.zoneId = String(zoneId);
|
||||||
|
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
let disp = (zone && zone.name) || zoneId;
|
const disp = zone.name || `Zone ${zoneId}`;
|
||||||
const kind = normalizeZoneContentKind(zone);
|
|
||||||
if (kind === 'presets') disp += ' · presets';
|
|
||||||
else if (kind === 'sequences') disp += ' · sequences';
|
|
||||||
label.textContent = disp;
|
label.textContent = disp;
|
||||||
if (String(zoneId) === String(currentZoneId)) {
|
if (String(zoneId) === String(currentZoneId)) {
|
||||||
label.textContent = `✓ ${disp}`;
|
label.textContent = `✓ ${disp}`;
|
||||||
@@ -999,8 +1028,7 @@ async function refreshEditTabPresetsUi(zoneId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tabData = await tabRes.json();
|
const tabData = await tabRes.json();
|
||||||
const kind = normalizeZoneContentKind(tabData);
|
if (!zoneAllowsPresets(tabData)) {
|
||||||
if (kind === 'sequences') {
|
|
||||||
currentEl.innerHTML =
|
currentEl.innerHTML =
|
||||||
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
|
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
|
||||||
addEl.innerHTML = '<span class="muted-text">—</span>';
|
addEl.innerHTML = '<span class="muted-text">—</span>';
|
||||||
@@ -1138,8 +1166,13 @@ async function openEditZoneModal(zoneId, zone) {
|
|||||||
});
|
});
|
||||||
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
|
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
|
||||||
|
|
||||||
|
const kind = effectiveZoneContentKind(tabData);
|
||||||
|
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
|
||||||
|
radio.checked = radio.value === kind;
|
||||||
|
});
|
||||||
|
|
||||||
if (modal) modal.classList.add("active");
|
if (modal) modal.classList.add("active");
|
||||||
applyZoneContentKindEditModal(normalizeZoneContentKind(tabData));
|
applyZoneContentKindEditModal(kind);
|
||||||
await refreshEditTabPresetsUi(zoneId);
|
await refreshEditTabPresetsUi(zoneId);
|
||||||
if (typeof window.refreshEditTabSequencesUi === "function") {
|
if (typeof window.refreshEditTabSequencesUi === "function") {
|
||||||
await window.refreshEditTabSequencesUi(zoneId);
|
await window.refreshEditTabSequencesUi(zoneId);
|
||||||
@@ -1147,11 +1180,13 @@ async function openEditZoneModal(zoneId, zone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update an existing zone (name, group list; devices come from groups only).
|
// Update an existing zone (name, group list; devices come from groups only).
|
||||||
async function updateZone(zoneId, name, groupRows) {
|
async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||||
try {
|
try {
|
||||||
const gids = Array.isArray(groupRows)
|
const gids = Array.isArray(groupRows)
|
||||||
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
|
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
|
||||||
: [];
|
: [];
|
||||||
|
const ck =
|
||||||
|
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
|
||||||
const response = await fetch(`/zones/${zoneId}`, {
|
const response = await fetch(`/zones/${zoneId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1162,6 +1197,7 @@ async function updateZone(zoneId, name, groupRows) {
|
|||||||
names: [],
|
names: [],
|
||||||
group_ids: gids,
|
group_ids: gids,
|
||||||
preset_group_ids: {},
|
preset_group_ids: {},
|
||||||
|
content_kind: ck,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1301,6 +1337,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
|
||||||
|
radio.addEventListener('change', async () => {
|
||||||
|
applyZoneContentKindEditModal(editModalContentKindSelected());
|
||||||
|
const zoneId = document.getElementById('edit-zone-id')?.value;
|
||||||
|
if (!zoneId) return;
|
||||||
|
await refreshEditTabPresetsUi(zoneId);
|
||||||
|
if (typeof window.refreshEditTabSequencesUi === 'function') {
|
||||||
|
await window.refreshEditTabSequencesUi(zoneId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Set up edit zone form
|
// Set up edit zone form
|
||||||
const editZoneForm = document.getElementById('edit-zone-form');
|
const editZoneForm = document.getElementById('edit-zone-form');
|
||||||
if (editZoneForm) {
|
if (editZoneForm) {
|
||||||
@@ -1314,7 +1362,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const groupRows = window.__editTabGroupRows || [];
|
const groupRows = window.__editTabGroupRows || [];
|
||||||
|
|
||||||
if (zoneId && name) {
|
if (zoneId && name) {
|
||||||
await updateZone(zoneId, name, groupRows);
|
await updateZone(zoneId, name, groupRows, editModalContentKindSelected());
|
||||||
editZoneForm.reset();
|
editZoneForm.reset();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,11 +83,10 @@
|
|||||||
<input type="text" id="new-zone-name" placeholder="Zone name">
|
<input type="text" id="new-zone-name" placeholder="Zone name">
|
||||||
<button class="btn btn-primary" id="create-zone-btn">Create</button>
|
<button class="btn btn-primary" id="create-zone-btn">Create</button>
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="muted-text" style="margin:0.35rem 0 0.75rem;border:none;padding:0;">
|
<div class="zone-content-kind-row muted-text">
|
||||||
<legend style="font-size:0.85em;margin-bottom:0.35rem;">This zone is for</legend>
|
<label><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
|
||||||
<label style="margin-right:1rem;"><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
|
|
||||||
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
|
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
|
||||||
</fieldset>
|
</div>
|
||||||
<div id="zones-list-modal" class="profiles-list"></div>
|
<div id="zones-list-modal" class="profiles-list"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
||||||
@@ -101,12 +100,12 @@
|
|||||||
<h2>Edit Zone</h2>
|
<h2>Edit Zone</h2>
|
||||||
<form id="edit-zone-form">
|
<form id="edit-zone-form">
|
||||||
<input type="hidden" id="edit-zone-id">
|
<input type="hidden" id="edit-zone-id">
|
||||||
<div class="modal-actions" style="margin-bottom: 1rem;">
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
|
|
||||||
</div>
|
|
||||||
<label>Zone Name:</label>
|
<label>Zone Name:</label>
|
||||||
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
||||||
|
<div class="zone-content-kind-row muted-text">
|
||||||
|
<label><input type="radio" name="edit-zone-content-kind" value="presets" checked> Presets</label>
|
||||||
|
<label><input type="radio" name="edit-zone-content-kind" value="sequences"> Sequences</label>
|
||||||
|
</div>
|
||||||
<div id="edit-zone-block-groups">
|
<div id="edit-zone-block-groups">
|
||||||
<label class="zone-devices-label">Device groups on this zone</label>
|
<label class="zone-devices-label">Device groups on this zone</label>
|
||||||
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
|
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
|
||||||
@@ -123,6 +122,10 @@
|
|||||||
<label class="zone-presets-section-label">Add a sequence to this zone</label>
|
<label class="zone-presets-section-label">Add a sequence to this zone</label>
|
||||||
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
|
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,6 +137,7 @@
|
|||||||
<div class="profiles-actions">
|
<div class="profiles-actions">
|
||||||
<input type="text" id="new-profile-name" placeholder="Profile name">
|
<input type="text" id="new-profile-name" placeholder="Profile name">
|
||||||
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="import-profile-btn">Import</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
||||||
@@ -183,10 +187,6 @@
|
|||||||
<h2>Edit device group</h2>
|
<h2>Edit device group</h2>
|
||||||
<form id="edit-group-form">
|
<form id="edit-group-form">
|
||||||
<input type="hidden" id="edit-group-id">
|
<input type="hidden" id="edit-group-id">
|
||||||
<div class="modal-actions" style="margin-bottom: 1rem;">
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<label for="edit-group-name">Group name</label>
|
<label for="edit-group-name">Group name</label>
|
||||||
<input type="text" id="edit-group-name" required autocomplete="off">
|
<input type="text" id="edit-group-name" required autocomplete="off">
|
||||||
<label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;">
|
<label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;">
|
||||||
@@ -227,6 +227,10 @@
|
|||||||
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
|
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
|
||||||
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
|
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
|
||||||
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
|
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,6 +305,7 @@
|
|||||||
<h2>Presets</h2>
|
<h2>Presets</h2>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" id="preset-add-btn">Add</button>
|
<button class="btn btn-primary" id="preset-add-btn">Add</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="import-preset-btn">Import</button>
|
||||||
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
|
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="presets-list" class="profiles-list"></div>
|
<div id="presets-list" class="profiles-list"></div>
|
||||||
@@ -316,6 +321,7 @@
|
|||||||
<h2>Sequences</h2>
|
<h2>Sequences</h2>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
|
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="import-sequence-btn">Import</button>
|
||||||
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
|
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="sequences-list" class="profiles-list"></div>
|
<div id="sequences-list" class="profiles-list"></div>
|
||||||
@@ -344,10 +350,8 @@
|
|||||||
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;">—</p>
|
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;">—</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="sequence-editor-lanes"></div>
|
<div id="sequence-editor-lanes"></div>
|
||||||
<div class="modal-actions" style="margin-top:0.75rem;">
|
|
||||||
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions preset-editor-modal-actions">
|
<div class="modal-actions preset-editor-modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
|
||||||
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
|
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
|
||||||
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
|
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
|
||||||
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
|
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
|
||||||
@@ -401,6 +405,10 @@
|
|||||||
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
|
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="preset-editor-field preset-mode-field" id="preset-mode-group" hidden>
|
||||||
|
<label for="preset-mode-input" id="preset-mode-label">Mode</label>
|
||||||
|
<select id="preset-mode-input" class="preset-mode-input"></select>
|
||||||
|
</div>
|
||||||
<div class="n-params-grid">
|
<div class="n-params-grid">
|
||||||
<div class="n-param-group">
|
<div class="n-param-group">
|
||||||
<label for="preset-n1-input" id="preset-n1-label">n1:</label>
|
<label for="preset-n1-input" id="preset-n1-label">n1:</label>
|
||||||
@@ -789,6 +797,7 @@
|
|||||||
<script src="/static/help.js"></script>
|
<script src="/static/help.js"></script>
|
||||||
<script src="/static/led_tool.js"></script>
|
<script src="/static/led_tool.js"></script>
|
||||||
<script src="/static/color_palette.js"></script>
|
<script src="/static/color_palette.js"></script>
|
||||||
|
<script src="/static/bundle_io.js"></script>
|
||||||
<script src="/static/profiles.js"></script>
|
<script src="/static/profiles.js"></script>
|
||||||
<script src="/static/zone_palette.js"></script>
|
<script src="/static/zone_palette.js"></script>
|
||||||
<script src="/static/patterns.js"></script>
|
<script src="/static/patterns.js"></script>
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import json
|
|||||||
import struct
|
import struct
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from util.espnow_message import wire_n6
|
||||||
|
|
||||||
BINARY_ENVELOPE_VERSION_1 = 1
|
BINARY_ENVELOPE_VERSION_1 = 1
|
||||||
BINARY_ENVELOPE_VERSION_2 = 2
|
BINARY_ENVELOPE_VERSION_2 = 2
|
||||||
HEADER_LEN = 5
|
HEADER_LEN = 5
|
||||||
@@ -108,7 +110,7 @@ def _pack_preset_dict(name: str, preset: Dict[str, Any]) -> bytes:
|
|||||||
n3 = _clamp_i16(preset.get("n3", 0))
|
n3 = _clamp_i16(preset.get("n3", 0))
|
||||||
n4 = _clamp_i16(preset.get("n4", 0))
|
n4 = _clamp_i16(preset.get("n4", 0))
|
||||||
n5 = _clamp_i16(preset.get("n5", 0))
|
n5 = _clamp_i16(preset.get("n5", 0))
|
||||||
n6 = _clamp_i16(preset.get("n6", 0))
|
n6 = _clamp_i16(wire_n6(preset))
|
||||||
parts.append(
|
parts.append(
|
||||||
struct.pack(
|
struct.pack(
|
||||||
"<HBBhhhhhh",
|
"<HBBhhhhhh",
|
||||||
|
|||||||
@@ -113,6 +113,21 @@ def resolve_preset_background_hex(preset_data, palette_colors=None):
|
|||||||
return _hex_from_background_raw(bg_raw)
|
return _hex_from_background_raw(bg_raw)
|
||||||
|
|
||||||
|
|
||||||
|
def wire_n6(preset_data, default=0):
|
||||||
|
"""Resolve style mode for the wire (``n6``); preset may store ``mode`` or ``n6``."""
|
||||||
|
if not isinstance(preset_data, dict):
|
||||||
|
return default
|
||||||
|
if preset_data.get("mode") is not None:
|
||||||
|
try:
|
||||||
|
return max(0, int(preset_data["mode"]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return max(0, int(preset_data.get("n6", default) or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
def build_preset_dict(preset_data, palette_colors=None):
|
def build_preset_dict(preset_data, palette_colors=None):
|
||||||
"""
|
"""
|
||||||
Convert preset data to API-compliant format.
|
Convert preset data to API-compliant format.
|
||||||
@@ -188,7 +203,7 @@ def build_preset_dict(preset_data, palette_colors=None):
|
|||||||
"n3": preset_data.get("n3", 0),
|
"n3": preset_data.get("n3", 0),
|
||||||
"n4": preset_data.get("n4", 0),
|
"n4": preset_data.get("n4", 0),
|
||||||
"n5": preset_data.get("n5", 0),
|
"n5": preset_data.get("n5", 0),
|
||||||
"n6": preset_data.get("n6", 0)
|
"n6": wire_n6(preset_data),
|
||||||
}
|
}
|
||||||
|
|
||||||
return preset
|
return preset
|
||||||
|
|||||||
441
src/util/profile_bundle.py
Normal file
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")
|
print("\nTesting update preset")
|
||||||
update_data = {
|
update_data = {
|
||||||
"name": "test_preset",
|
"name": "test_preset",
|
||||||
"pattern": "on",
|
"pattern": "colour_cycle",
|
||||||
"colors": ["#FF0000", "#00FF00"],
|
"colors": ["#FF0000", "#00FF00"],
|
||||||
"delay": 100,
|
"delay": 100,
|
||||||
"brightness": 127,
|
"brightness": 127,
|
||||||
"n1": 10,
|
"n1": 10,
|
||||||
"n2": 20
|
"n2": 20,
|
||||||
|
"mode": 1,
|
||||||
}
|
}
|
||||||
result = presets.update(preset_id, update_data)
|
result = presets.update(preset_id, update_data)
|
||||||
assert result is True
|
assert result is True
|
||||||
updated = presets.read(preset_id)
|
updated = presets.read(preset_id)
|
||||||
assert updated["name"] == "test_preset"
|
assert updated["name"] == "test_preset"
|
||||||
assert updated["pattern"] == "on"
|
assert updated["pattern"] == "colour_cycle"
|
||||||
|
assert updated["mode"] == 1
|
||||||
assert updated["delay"] == 100
|
assert updated["delay"] == 100
|
||||||
|
|
||||||
print("\nTesting list presets")
|
print("\nTesting list presets")
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ def test_pack_parse_v2_brightness_only():
|
|||||||
assert data == {"v": "1", "b": 128}
|
assert data == {"v": "1", "b": 128}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_parse_v2_mode_maps_to_n6():
|
||||||
|
raw = pack_binary_envelope_v2(
|
||||||
|
presets={
|
||||||
|
"m": {
|
||||||
|
"p": "meteor",
|
||||||
|
"c": ["#aabbcc"],
|
||||||
|
"mode": 2,
|
||||||
|
"n6": 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data = parse_binary_envelope_v2(raw)
|
||||||
|
assert data["presets"]["m"]["n6"] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_pack_parse_v2_full():
|
def test_pack_parse_v2_full():
|
||||||
raw = pack_binary_envelope_v2(
|
raw = pack_binary_envelope_v2(
|
||||||
presets={
|
presets={
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) ->
|
|||||||
raise AssertionError(f"Could not find id for {field}={value!r}")
|
raise AssertionError(f"Could not find id for {field}={value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
|
||||||
|
"""Sequences/scenes/presets need an active profile in session."""
|
||||||
|
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
|
||||||
|
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
profile_id = next(iter(resp.json().keys()))
|
||||||
|
resp = c.post(f"{base_url}/profiles/{profile_id}/apply")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return str(profile_id)
|
||||||
|
|
||||||
|
|
||||||
def _start_microdot_server(app: Microdot, host: str, port: int):
|
def _start_microdot_server(app: Microdot, host: str, port: int):
|
||||||
"""
|
"""
|
||||||
Start Microdot server on a background thread.
|
Start Microdot server on a background thread.
|
||||||
@@ -474,6 +485,36 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
|||||||
resp = c.delete(f"{base_url}/zones/{zone_id}")
|
resp = c.delete(f"{base_url}/zones/{zone_id}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = c.get(f"{base_url}/profiles/{profile_id}/export")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
bundle = resp.json()
|
||||||
|
assert bundle.get("kind") == "profile"
|
||||||
|
assert isinstance(bundle.get("presets"), dict)
|
||||||
|
|
||||||
|
import_name = f"pytest-imported-{uuid.uuid4().hex[:8]}"
|
||||||
|
resp = c.post(
|
||||||
|
f"{base_url}/profiles/import",
|
||||||
|
json={"bundle": bundle, "name": import_name, "apply": False},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
imported_profile_id = resp.json().get("id") or next(
|
||||||
|
k for k in resp.json().keys() if k != "id"
|
||||||
|
)
|
||||||
|
resp = c.delete(f"{base_url}/profiles/{imported_profile_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = c.get(f"{base_url}/presets/{first_preset_id}/export")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json().get("kind") == "preset"
|
||||||
|
resp = c.post(
|
||||||
|
f"{base_url}/presets/import",
|
||||||
|
json={"bundle": resp.json()},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
imported_preset_id = next(iter(resp.json().keys()))
|
||||||
|
resp = c.delete(f"{base_url}/presets/{imported_preset_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
# Profile clone + update endpoints.
|
# Profile clone + update endpoints.
|
||||||
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
|
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
|
||||||
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
|
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
|
||||||
@@ -508,6 +549,8 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
|||||||
base_url: str = server["base_url"]
|
base_url: str = server["base_url"]
|
||||||
sender: DummySender = server["sender"]
|
sender: DummySender = server["sender"]
|
||||||
|
|
||||||
|
_create_and_apply_profile(c, base_url)
|
||||||
|
|
||||||
# Groups.
|
# Groups.
|
||||||
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
|
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
|
||||||
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
|
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
|
||||||
@@ -715,6 +758,13 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
definitions = resp.json()
|
definitions = resp.json()
|
||||||
assert isinstance(definitions, dict)
|
assert isinstance(definitions, dict)
|
||||||
|
assert "colour_cycle" in definitions
|
||||||
|
cc_mode = definitions["colour_cycle"].get("mode")
|
||||||
|
assert isinstance(cc_mode, dict)
|
||||||
|
assert "0" in cc_mode and "1" in cc_mode
|
||||||
|
assert "blink" in definitions
|
||||||
|
blink_mode = definitions["blink"].get("mode")
|
||||||
|
assert not isinstance(blink_mode, dict) or len(blink_mode) < 2
|
||||||
|
|
||||||
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
|
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
|
||||||
resp = c.post(
|
resp = c.post(
|
||||||
|
|||||||
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