From 96d1e1b5fd261267a76b3ed88fd512931e676965 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sat, 16 May 2026 21:12:42 +1200 Subject: [PATCH] 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 --- db/group.json | 2 +- db/palette.json | 2 +- db/pattern.json | 216 +++++----------- db/preset.json | 2 +- db/profile.json | 2 +- db/sequence.json | 2 +- pyproject.toml | 4 +- src/controllers/preset.py | 36 +++ src/controllers/profile.py | 410 +++++++++++++++++------------- src/controllers/sequence.py | 54 ++++ src/controllers/zone.py | 1 + src/models/zone.py | 23 +- src/static/bundle_io.js | 48 ++++ src/static/patterns.js | 7 +- src/static/presets.js | 329 ++++++++++++++++++------ src/static/profiles.js | 76 ++++++ src/static/sequences.js | 64 ++++- src/static/style.css | 49 ++++ src/static/zones.js | 88 +++++-- src/templates/index.html | 39 +-- src/util/binary_envelope.py | 4 +- src/util/espnow_message.py | 17 +- src/util/profile_bundle.py | 441 +++++++++++++++++++++++++++++++++ tests/models/test_preset.py | 8 +- tests/test_binary_envelope.py | 15 ++ tests/test_endpoints_pytest.py | 50 ++++ tests/test_preset_wire_mode.py | 51 ++++ tests/test_profile_bundle.py | 133 ++++++++++ 28 files changed, 1715 insertions(+), 458 deletions(-) create mode 100644 src/static/bundle_io.js create mode 100644 src/util/profile_bundle.py create mode 100644 tests/test_preset_wire_mode.py create mode 100644 tests/test_profile_bundle.py diff --git a/db/group.json b/db/group.json index fb867f8..bd73159 100644 --- a/db/group.json +++ b/db/group.json @@ -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}} \ No newline at end of file +{"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}} \ No newline at end of file diff --git a/db/palette.json b/db/palette.json index 257bf58..113e00f 100644 --- a/db/palette.json +++ b/db/palette.json @@ -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"]} \ No newline at end of file +{"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": []} \ No newline at end of file diff --git a/db/pattern.json b/db/pattern.json index bf8eab4..f42b654 100644 --- a/db/pattern.json +++ b/db/pattern.json @@ -11,15 +11,12 @@ "max_colors": 0, "supports_manual": true }, - "rainbow": { - "n1": "Step Rate", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 0, - "supports_manual": true - }, "colour_cycle": { - "n1": "Step Rate", + "n1": "Step rate", + "mode": { + "0": "Scroll palette gradient", + "1": "Rainbow wheel (preset colours ignored)" + }, "min_delay": 10, "max_delay": 10000, "max_colors": 10, @@ -40,7 +37,11 @@ "max_delay": 10000, "max_colors": 2, "has_background": true, - "supports_manual": true + "supports_manual": true, + "mode": { + "0": "Two-colour chase", + "1": "Marquee dashes (n1 on length, n2 off, n3 step)" + } }, "pulse": { "n1": "Attack", @@ -80,7 +81,7 @@ "flame": { "n1": "Min brightness", "n2": "Breath period (ms)", - "n3": "Spark gap min (ms, 0=default 10–30 s, -1=off)", + "n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, @@ -88,8 +89,8 @@ "supports_manual": false }, "twinkle": { - "n1": "Twinkle activity (1–255, higher = more changes)", - "n2": "Density (0–255, higher = more of the strip lit)", + "n1": "Twinkle activity (1\u2013255, higher = more changes)", + "n2": "Density (0\u2013255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, @@ -108,58 +109,6 @@ "has_background": true, "supports_manual": true }, - "meteor_rain": { - "n1": "Tail length", - "n2": "Speed (LEDs per frame)", - "n3": "Fade amount (1-255)", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 10, - "supports_manual": true - }, - "scanner": { - "n1": "Eye width", - "n2": "End pause (frames)", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 10, - "has_background": true, - "supports_manual": true - }, - "gradient_scroll": { - "n1": "Scroll step rate", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 10, - "supports_manual": true - }, - "comet_dual": { - "n1": "Tail length", - "n2": "Speed", - "n3": "Gap", - "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, - "has_background": true, - "supports_manual": true - }, - "sparkle_trail": { - "n1": "Spark density", - "n2": "Decay", - "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, - "supports_manual": true - }, - "wave": { - "n1": "Wavelength", - "n2": "Amplitude", - "n3": "Drift speed", - "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, - "supports_manual": false - }, "plasma": { "n1": "Scale", "n2": "Speed", @@ -169,17 +118,6 @@ "max_delay": 10000, "supports_manual": false }, - "segment_chase": { - "n1": "Segment size", - "n2": "Phase step", - "n3": "Segment phase offset", - "n4": "Gap per segment", - "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, - "has_background": true, - "supports_manual": true - }, "bar_graph": { "n1": "Level percent", "max_colors": 10, @@ -188,14 +126,6 @@ "has_background": true, "supports_manual": false }, - "breathing_dual": { - "n1": "Phase offset", - "n2": "Ease", - "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, - "supports_manual": false - }, "strobe_burst": { "n1": "Burst count", "n2": "Burst gap", @@ -215,15 +145,6 @@ "has_background": true, "supports_manual": true }, - "fireflies": { - "n1": "Count", - "n2": "Twinkle speed", - "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, - "has_background": true, - "supports_manual": true - }, "clock_sweep": { "n1": "Hand width", "n2": "Marker interval", @@ -233,30 +154,17 @@ "has_background": true, "supports_manual": true }, - "marquee": { - "n1": "On length", - "n2": "Off length", - "n3": "Step", - "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, - "has_background": true, - "supports_manual": true - }, "aurora": { - "n1": "Band count", - "n2": "Shimmer", - "max_colors": 10, + "n1": "Band count (0) or spatial period LEDs (1)", + "n2": "Shimmer (0) or blend strength (1)", + "n3": "Unused (0) or drift speed (1)", + "mode": { + "0": "Colour bands + shimmer", + "1": "Sine northern wave" + }, "min_delay": 10, "max_delay": 10000, - "supports_manual": false - }, - "snowfall": { - "n1": "Flake density", - "n2": "Fall speed", "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, "has_background": true, "supports_manual": true }, @@ -290,16 +198,6 @@ "has_background": true, "supports_manual": true }, - "northern_wave": { - "n1": "Spatial period (LEDs)", - "n2": "Blend strength", - "n3": "Drift speed", - "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, - "has_background": true, - "supports_manual": true - }, "candle_glow": { "n1": "Candle count", "n2": "Glow width (LEDs)", @@ -310,36 +208,6 @@ "has_background": true, "supports_manual": true }, - "starfall": { - "n1": "Spawn rate", - "n2": "Fall speed", - "n3": "Streak length", - "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, - "has_background": true, - "supports_manual": true - }, - "ice_sparkle": { - "n1": "Sparkle rate", - "n2": "Decay per refresh", - "n3": "Halo width (LEDs)", - "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, - "has_background": true, - "supports_manual": true - }, - "heartbeat": { - "n1": "Pulse 1 ms", - "n2": "Pulse 2 ms", - "n3": "Pause ms", - "max_colors": 10, - "min_delay": 10, - "max_delay": 10000, - "has_background": true, - "supports_manual": true - }, "orbit": { "n1": "Orbit count", "n2": "Base speed", @@ -357,5 +225,49 @@ "min_delay": 10, "max_delay": 10000, "supports_manual": false + }, + "meteor": { + "n1": "Tail length (0–1) or eye width (2)", + "n2": "Speed (LEDs per frame)", + "n3": "Fade amount (0), comet gap (1), or end pause frames (2)", + "mode": { + "0": "Fading meteor", + "1": "Dual comets", + "2": "Bouncing scanner" + }, + "min_delay": 10, + "max_delay": 10000, + "max_colors": 10, + "has_background": true, + "supports_manual": true + }, + "particles": { + "n1": "Flake density (0) or spawn rate (1)", + "n2": "Fall speed (LEDs per frame)", + "n3": "Unused (0) or streak length (1)", + "mode": { + "0": "Snowfall flakes", + "1": "Starfall streaks" + }, + "min_delay": 10, + "max_delay": 10000, + "max_colors": 10, + "has_background": true, + "supports_manual": true + }, + "sparkle": { + "n1": "Spark density (0–1) or firefly count (2)", + "n2": "Trail decay (0) or twinkle speed (2)", + "n3": "Ice halo width LEDs (1); unused in 0 and 2", + "mode": { + "0": "Sparkle trail", + "1": "Ice burst + halo", + "2": "Fireflies" + }, + "min_delay": 10, + "max_delay": 10000, + "max_colors": 10, + "has_background": true, + "supports_manual": true } } diff --git a/db/preset.json b/db/preset.json index 3ee79d4..12789a7 100644 --- a/db/preset.json +++ b/db/preset.json @@ -1 +1 @@ -{"1":{"name":"on","pattern":"on","colors":["#FFFFFF"],"brightness":255,"delay":100,"auto":true,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"2":{"name":"off","pattern":"off","colors":[],"brightness":0,"delay":100,"auto":true,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"3":{"name":"rainbow","pattern":"rainbow","colors":[],"brightness":255,"delay":100,"auto":true,"n1":2,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"4":{"name":"transition","pattern":"transition","colors":["#FF0000","#00FF00","#0000FF"],"brightness":255,"delay":300,"auto":true,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null,null],"background":"#000000","manual_beat_n":1},"5":{"name":"chase","pattern":"chase","colors":["#FFFF00","#FF00FF"],"brightness":8,"delay":200,"auto":false,"n1":30,"n2":30,"n3":30,"n4":30,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[3,4],"manual_beat_n":1,"background":"#000000","background_palette_ref":null},"6":{"name":"pulse","pattern":"pulse","colors":["#FF00FF"],"brightness":255,"delay":1000,"auto":false,"n1":100,"n2":0,"n3":100,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[4],"background_color":"#ec0909","background_palette_ref":8,"manual_beat_n":1,"background":"#050500"},"7":{"name":"circle","pattern":"circle","colors":["#FFA500","#800080"],"brightness":255,"delay":200,"auto":true,"n1":2,"n2":10,"n3":2,"n4":5,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"8":{"name":"blink","pattern":"blink","colors":["#FF0000","#00FF00","#0000FF","#FFFF00"],"brightness":255,"delay":1000,"auto":false,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null,null,null]},"9":{"name":"warm white","pattern":"on","colors":["#FFF5E6"],"brightness":200,"delay":100,"auto":true,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"10":{"name":"cool white","pattern":"on","colors":["#E6F2FF"],"brightness":200,"delay":100,"auto":true,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"11":{"name":"red","pattern":"on","colors":["#FF0000"],"brightness":255,"delay":100,"auto":true,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"12":{"name":"blue","pattern":"on","colors":["#0000FF"],"brightness":255,"delay":100,"auto":true,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"13":{"name":"rainbow slow","pattern":"rainbow","colors":[],"brightness":255,"delay":150,"auto":true,"n1":1,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"14":{"name":"pulse slow","pattern":"pulse","colors":["#FF6600"],"brightness":255,"delay":800,"auto":true,"n1":2000,"n2":1000,"n3":2000,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"15":{"name":"blink red green","pattern":"blink","colors":["#FF0000","#00FF00"],"brightness":255,"delay":500,"auto":true,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"30":{"name":"rainbow slow","pattern":"rainbow","colors":[],"brightness":255,"delay":150,"auto":true,"n1":1,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"2"},"31":{"name":"DJ Rainbow","pattern":"rainbow","colors":[],"brightness":220,"delay":60,"n1":12,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"2"},"32":{"name":"DJ Single Color","pattern":"on","colors":["#ff00ff"],"brightness":220,"delay":100,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"2"},"33":{"name":"DJ Transition","pattern":"transition","colors":["#ff0000","#00ff00","#0000ff"],"brightness":220,"delay":250,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"2"},"34":{"name":"DJ Rainbow","pattern":"rainbow","colors":[],"brightness":220,"delay":60,"n1":12,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"2"},"35":{"name":"DJ Single Color","pattern":"on","colors":["#ff00ff"],"brightness":220,"delay":100,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"2"},"36":{"name":"DJ Transition","pattern":"transition","colors":["#ff0000","#00ff00","#0000ff"],"brightness":220,"delay":250,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"2"},"37":{"name":"tranistion2","pattern":"transition","colors":["#FF0000","#FFFF00","#FF00FF"],"brightness":128,"delay":1000,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[0,3,4]},"38":{"name":"Colour Cycle","pattern":"colour_cycle","colors":["#FF0000","#0000FF"],"brightness":255,"delay":100,"auto":true,"n1":1,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null]},"39":{"name":"flicker","pattern":"flicker","colors":["#ae00ff"],"brightness":255,"delay":50,"auto":false,"n1":100,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null],"background":"#000000","manual_beat_n":1},"40":{"name":"flame","pattern":"flame","colors":["#ffc800"],"brightness":128,"delay":50,"auto":true,"n1":35,"n2":2600,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null]},"41":{"name":"twinkle","pattern":"twinkle","colors":["#78C8FF","#508CFF","#B478FF","#64DCE8","#A0C8FF"],"brightness":255,"delay":100,"auto":true,"n1":150,"n2":20,"n3":0,"n4":10,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null,null,null,null],"background":"#000000","manual_beat_n":1},"42":{"name":"radiate","pattern":"radiate","colors":["#a600ff"],"brightness":255,"delay":2000,"auto":false,"n1":60,"n2":200,"n3":100,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null],"manual_beat_n":1,"background":"#050500","background_palette_ref":8},"43":{"name":"test meteor rain","pattern":"meteor_rain","colors":["#FF5000","#0080FF"],"brightness":200,"delay":40,"auto":true,"n1":50,"n2":1,"n3":200,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null]},"44":{"name":"test scanner","pattern":"scanner","colors":["#FF0000"],"brightness":255,"delay":30,"auto":true,"n1":4,"n2":2,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"45":{"name":"test gradient scroll","pattern":"gradient_scroll","colors":["#FF0000","#00FF00","#0000FF"],"brightness":220,"delay":60,"auto":true,"n1":2,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"46":{"name":"test comet dual","pattern":"comet_dual","colors":["#FFAA00","#00AAFF"],"brightness":200,"delay":60,"auto":true,"n1":8,"n2":1,"n3":3,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null],"background":"#0b000f","manual_beat_n":1},"47":{"name":"test sparkle trail","pattern":"sparkle_trail","colors":["#88CCFF","#FFFFFF"],"brightness":200,"delay":60,"auto":true,"n1":24,"n2":210,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null],"background":"#000000","manual_beat_n":1},"48":{"name":"test wave","pattern":"wave","colors":["#00B4FF"],"brightness":200,"delay":60,"auto":true,"n1":12,"n2":180,"n3":1,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"49":{"name":"test plasma","pattern":"plasma","colors":["#FF0066"],"brightness":200,"delay":60,"auto":true,"n1":6,"n2":2,"n3":2,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"50":{"name":"test segment chase","pattern":"segment_chase","colors":["#FF0000","#0000FF"],"brightness":200,"delay":60,"auto":true,"n1":4,"n2":1,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"51":{"name":"test bar graph","pattern":"bar_graph","colors":["#00FF00","#102010"],"brightness":200,"delay":60,"auto":true,"n1":60,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"52":{"name":"test breathing dual","pattern":"breathing_dual","colors":["#FF0088","#00AAFF"],"brightness":200,"delay":60,"auto":true,"n1":128,"n2":1,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"53":{"name":"test strobe burst","pattern":"strobe_burst","colors":["#FFFFFF"],"brightness":200,"delay":60,"auto":false,"n1":2,"n2":10,"n3":100,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null],"background":"#000000","manual_beat_n":1},"54":{"name":"test rain drops","pattern":"rain_drops","colors":["#7cbdfe"],"brightness":200,"delay":60,"auto":true,"n1":32,"n2":3,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null],"background":"#000000","manual_beat_n":1},"55":{"name":"test fireflies","pattern":"fireflies","colors":["#FFD060","#90FF90"],"brightness":200,"delay":60,"auto":false,"n1":6,"n2":8,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null],"background":"#000000","manual_beat_n":1},"56":{"name":"test clock sweep","pattern":"clock_sweep","colors":["#FFFFFF","#202020"],"brightness":200,"delay":60,"auto":true,"n1":1,"n2":5,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"57":{"name":"test marquee","pattern":"marquee","colors":["#FFFFFF"],"brightness":200,"delay":60,"auto":true,"n1":3,"n2":2,"n3":1,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"58":{"name":"test aurora","pattern":"aurora","colors":["#2CC88C","#5078FF","#A050DC"],"brightness":200,"delay":60,"auto":true,"n1":3,"n2":40,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"59":{"name":"test snowfall","pattern":"snowfall","colors":["#FFFFFF","#B0DCFF"],"brightness":200,"delay":60,"auto":true,"n1":20,"n2":1,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"60":{"name":"test heartbeat","pattern":"heartbeat","colors":["#FF2840"],"brightness":200,"delay":60,"auto":false,"n1":200,"n2":50,"n3":500,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null],"background":"#000000","manual_beat_n":1},"61":{"name":"test orbit","pattern":"orbit","colors":["#FFFFFF","#00B4FF","#FF0077"],"brightness":200,"delay":60,"auto":true,"n1":3,"n2":1,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null,null]},"62":{"name":"test palette morph","pattern":"palette_morph","colors":["#FF0000","#00FF00","#0000FF"],"brightness":200,"delay":60,"auto":true,"n1":1200,"n2":200,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1"},"63":{"name":"off","pattern":"off","colors":[],"background":"#000000","brightness":0,"delay":0,"n1":0,"n2":0,"n3":0,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"manual_beat_n":1,"profile_id":"2","palette_refs":[],"auto":true},"64":{"name":"winter icicles","pattern":"icicles","colors":["#F0F8FF","#9ECFFF","#FFFFFF"],"brightness":220,"delay":80,"auto":true,"n1":14,"n2":11,"n3":1,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null,null],"background":"#0A1520","manual_beat_n":1},"65":{"name":"winter blizzard","pattern":"blizzard","colors":["#FFFFFF","#CDE8FF","#AACCF5"],"brightness":220,"delay":45,"auto":true,"n1":110,"n2":2,"n3":140,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null,null],"background":"#050810","manual_beat_n":1},"66":{"name":"winter rime frost","pattern":"rime","colors":["#E8F4FF","#FFFFFF","#B8DCF8"],"brightness":200,"delay":120,"auto":true,"n1":40,"n2":18,"n3":4,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null,null],"background":"#071018","manual_beat_n":1},"67":{"name":"winter northern wave","pattern":"northern_wave","colors":["#183050","#5090C8","#C8E8FF"],"brightness":200,"delay":90,"auto":true,"n1":22,"n2":210,"n3":1,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null,null],"background":"#060C18","manual_beat_n":1},"68":{"name":"winter candle glow","pattern":"candle_glow","colors":["#FF8020","#FFC080","#FFA040"],"brightness":180,"delay":70,"auto":true,"n1":4,"n2":3,"n3":120,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null,null],"background":"#0A0508","manual_beat_n":1},"69":{"name":"winter starfall","pattern":"starfall","colors":["#FFFFFF","#C8E8FF","#FFF8E0"],"brightness":220,"delay":55,"auto":true,"n1":16,"n2":2,"n3":12,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null,null],"background":"#040810","manual_beat_n":1},"70":{"name":"winter ice sparkle","pattern":"ice_sparkle","colors":["#E8F4FF","#B0DCFF","#FFFFFF"],"brightness":210,"delay":50,"auto":true,"n1":70,"n2":165,"n3":1,"n4":0,"n5":0,"n6":0,"n7":0,"n8":0,"profile_id":"1","palette_refs":[null,null,null],"background":"#081018","manual_beat_n":1}} \ No newline at end of file +{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 300, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#000000", "manual_beat_n": 1}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FFFF00", "#FF00FF"], "brightness": 8, "delay": 200, "auto": false, "n1": 30, "n2": 30, "n3": 30, "n4": 30, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [3, 4], "manual_beat_n": 1, "background": "#000000", "background_palette_ref": null}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#FF00FF"], "brightness": 255, "delay": 1000, "auto": false, "n1": 100, "n2": 0, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [4], "background_color": "#ec0909", "background_palette_ref": 8, "manual_beat_n": 1, "background": "#050500"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": false, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null]}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}, "38": {"name": "Colour Cycle", "pattern": "colour_cycle", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "39": {"name": "flicker", "pattern": "flicker", "colors": ["#ae00ff"], "brightness": 255, "delay": 50, "auto": false, "n1": 100, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "40": {"name": "flame", "pattern": "flame", "colors": ["#ffc800"], "brightness": 128, "delay": 50, "auto": true, "n1": 35, "n2": 2600, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null]}, "41": {"name": "twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null, null], "background": "#000000", "manual_beat_n": 1}, "42": {"name": "radiate", "pattern": "radiate", "colors": ["#a600ff"], "brightness": 255, "delay": 2000, "auto": false, "n1": 60, "n2": 200, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "manual_beat_n": 1, "background": "#050500", "background_palette_ref": 8}, "43": {"name": "test meteor rain", "pattern": "meteor", "colors": ["#FF5000", "#0080FF"], "brightness": 200, "delay": 40, "auto": true, "n1": 50, "n2": 1, "n3": 200, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "44": {"name": "test scanner", "pattern": "meteor", "colors": ["#FF0000"], "brightness": 255, "delay": 30, "auto": true, "n1": 4, "n2": 2, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1"}, "45": {"name": "test gradient scroll", "pattern": "colour_cycle", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 220, "delay": 60, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "46": {"name": "test comet dual", "pattern": "meteor", "colors": ["#FFAA00", "#00AAFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 8, "n2": 1, "n3": 3, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#0b000f", "manual_beat_n": 1}, "47": {"name": "test sparkle trail", "pattern": "sparkle", "colors": ["#88CCFF", "#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 24, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "49": {"name": "test plasma", "pattern": "plasma", "colors": ["#FF0066"], "brightness": 200, "delay": 60, "auto": true, "n1": 6, "n2": 2, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "51": {"name": "test bar graph", "pattern": "bar_graph", "colors": ["#00FF00", "#102010"], "brightness": 200, "delay": 60, "auto": true, "n1": 60, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "53": {"name": "test strobe burst", "pattern": "strobe_burst", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": false, "n1": 2, "n2": 10, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "54": {"name": "test rain drops", "pattern": "rain_drops", "colors": ["#7cbdfe"], "brightness": 200, "delay": 60, "auto": true, "n1": 32, "n2": 3, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "55": {"name": "test fireflies", "pattern": "sparkle", "colors": ["#FFD060", "#90FF90"], "brightness": 200, "delay": 60, "auto": false, "n1": 6, "n2": 8, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "56": {"name": "test clock sweep", "pattern": "clock_sweep", "colors": ["#FFFFFF", "#202020"], "brightness": 200, "delay": 60, "auto": true, "n1": 1, "n2": 5, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "57": {"name": "test marquee", "pattern": "chase", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 2, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "58": {"name": "test aurora", "pattern": "aurora", "colors": ["#2CC88C", "#5078FF", "#A050DC"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 40, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "59": {"name": "test snowfall", "pattern": "particles", "colors": ["#FFFFFF", "#B0DCFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 20, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "61": {"name": "test orbit", "pattern": "orbit", "colors": ["#FFFFFF", "#00B4FF", "#FF0077"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null]}, "62": {"name": "test palette morph", "pattern": "palette_morph", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 1200, "n2": 200, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "63": {"name": "off", "pattern": "off", "colors": [], "background": "#000000", "brightness": 0, "delay": 0, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "2", "palette_refs": [], "auto": true}, "64": {"name": "winter icicles", "pattern": "icicles", "colors": ["#F0F8FF", "#9ECFFF", "#FFFFFF"], "brightness": 220, "delay": 80, "auto": true, "n1": 14, "n2": 11, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A1520", "manual_beat_n": 1}, "65": {"name": "winter blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#CDE8FF", "#AACCF5"], "brightness": 220, "delay": 45, "auto": true, "n1": 110, "n2": 2, "n3": 140, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050810", "manual_beat_n": 1}, "66": {"name": "winter rime frost", "pattern": "rime", "colors": ["#E8F4FF", "#FFFFFF", "#B8DCF8"], "brightness": 200, "delay": 120, "auto": true, "n1": 40, "n2": 18, "n3": 4, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#071018", "manual_beat_n": 1}, "67": {"name": "winter northern wave", "pattern": "aurora", "colors": ["#183050", "#5090C8", "#C8E8FF"], "brightness": 200, "delay": 90, "auto": true, "n1": 22, "n2": 210, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#060C18", "manual_beat_n": 1}, "68": {"name": "winter candle glow", "pattern": "candle_glow", "colors": ["#FF8020", "#FFC080", "#FFA040"], "brightness": 180, "delay": 70, "auto": true, "n1": 4, "n2": 3, "n3": 120, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A0508", "manual_beat_n": 1}, "69": {"name": "winter starfall", "pattern": "particles", "colors": ["#FFFFFF", "#C8E8FF", "#FFF8E0"], "brightness": 220, "delay": 55, "auto": true, "n1": 16, "n2": 2, "n3": 12, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "70": {"name": "winter ice sparkle", "pattern": "sparkle", "colors": ["#E8F4FF", "#B0DCFF", "#FFFFFF"], "brightness": 210, "delay": 50, "auto": true, "n1": 70, "n2": 165, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081018", "manual_beat_n": 1}, "71": {"name": "test northern wave", "pattern": "aurora", "colors": ["#204060", "#4080C0", "#D0F0FF"], "brightness": 200, "delay": 75, "auto": true, "n1": 18, "n2": 190, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050A14", "manual_beat_n": 1}, "72": {"name": "test candle glow", "pattern": "candle_glow", "colors": ["#FF7020", "#FFD090", "#FFB060"], "brightness": 190, "delay": 65, "auto": true, "n1": 3, "n2": 4, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#080408", "manual_beat_n": 1}, "73": {"name": "test starfall", "pattern": "particles", "colors": ["#FFFFFF", "#B8D8FF", "#FFF0C0"], "brightness": 220, "delay": 50, "auto": true, "n1": 20, "n2": 3, "n3": 10, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#030610", "manual_beat_n": 1}, "74": {"name": "test ice sparkle", "pattern": "sparkle", "colors": ["#F0F8FF", "#A8D0FF", "#FFFFFF"], "brightness": 215, "delay": 45, "auto": true, "n1": 85, "n2": 150, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#06121A", "manual_beat_n": 1}, "75": {"name": "test icicles", "pattern": "icicles", "colors": ["#E8F4FF", "#88C0FF", "#FFFFFF"], "brightness": 220, "delay": 70, "auto": true, "n1": 12, "n2": 9, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081420", "manual_beat_n": 1}, "76": {"name": "test blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#D0E8FF", "#B0C8F0"], "brightness": 220, "delay": 40, "auto": true, "n1": 95, "n2": 3, "n3": 128, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "77": {"name": "test rime", "pattern": "rime", "colors": ["#E0F0FF", "#FFFFFF", "#A8D0F0"], "brightness": 205, "delay": 100, "auto": true, "n1": 35, "n2": 20, "n3": 5, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#061018", "manual_beat_n": 1}} \ No newline at end of file diff --git a/db/profile.json b/db/profile.json index d74ac27..f45e4a2 100644 --- a/db/profile.json +++ b/db/profile.json @@ -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"}} \ No newline at end of file +{"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"}} \ No newline at end of file diff --git a/db/sequence.json b/db/sequence.json index 1c14e09..9e26dfe 100644 --- a/db/sequence.json +++ b/db/sequence.json @@ -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": [[]]}} \ No newline at end of file +{} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8a9d1ae..b7a98c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,5 @@ [tool.pytest.ini_options] testpaths = ["tests"] -python_files = ["test_endpoints_pytest.py"] +python_files = ["test_*.py"] +# ``tests/models/`` is a package name clash with ``src/models``; run via tests/models/run_all.py +norecursedirs = ["models"] diff --git a/src/controllers/preset.py b/src/controllers/preset.py index 03d7265..ef2c34e 100644 --- a/src/controllers/preset.py +++ b/src/controllers/preset.py @@ -7,6 +7,7 @@ from models.device import Device, normalize_mac from models.transport import get_current_sender from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device from util.espnow_message import build_message, build_preset_dict +from util.profile_bundle import export_preset_bundle, import_preset_bundle import json controller = Microdot() @@ -50,6 +51,41 @@ async def list_presets(request, session): } return json.dumps(scoped), 200, {'Content-Type': 'application/json'} +@controller.get('//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('/') @with_session async def get_preset(request, session, preset_id): diff --git a/src/controllers/profile.py b/src/controllers/profile.py index 675dd3f..6d76d7b 100644 --- a/src/controllers/profile.py +++ b/src/controllers/profile.py @@ -3,12 +3,15 @@ from microdot.session import with_session from models.profile import Profile from models.zone import Zone from models.preset import Preset +from models.sequence import Sequence +from util.profile_bundle import export_profile_bundle, import_profile_bundle import json controller = Microdot() profiles = Profile() zones = Zone() presets = Preset() +sequences = Sequence() @controller.get('') @with_session @@ -54,18 +57,64 @@ async def get_current_profile(request, session): return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'} return json.dumps({"error": "No profile available"}), 404 -@controller.get('/') + +@controller.post('/import') @with_session -async def get_profile(request, id, session): - """Get a specific profile by ID.""" - # Handle 'current' as a special case - if id == 'current': - return await get_current_profile(request, session) - - profile = profiles.read(id) - if profile: - return json.dumps(profile), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Profile not found"}), 404 +async def import_profile(request, session): + """Import a profile bundle (optionally apply as current profile).""" + try: + body = request.json or {} + bundle = body.get("bundle") if isinstance(body, dict) else body + if not isinstance(bundle, dict): + return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'} + name = body.get("name") if isinstance(body, dict) else None + apply_raw = body.get("apply", True) if isinstance(body, dict) else True + if isinstance(apply_raw, str): + apply = apply_raw.strip().lower() in ("1", "true", "yes", "on") + else: + apply = bool(apply_raw) + + new_profile_id, profile_data = import_profile_bundle( + bundle, + profiles, + zones, + presets, + sequences, + profiles._palette_model, + name=str(name).strip() if name else None, + ) + if apply: + session['current_profile'] = str(new_profile_id) + session.save() + return ( + json.dumps({new_profile_id: profile_data, "id": new_profile_id}), + 201, + {'Content-Type': 'application/json'}, + ) + except ValueError as e: + return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'} + except Exception as e: + return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'} + + +@controller.get('//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('//apply') @with_session @@ -77,167 +126,6 @@ async def apply_profile(request, session, id): session.save() return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'} -@controller.post('') -async def create_profile(request): - """Create a new profile.""" - try: - data = dict(request.json or {}) - name = data.get("name", "") - seed_raw = data.get("seed_dj_zone", False) - if isinstance(seed_raw, str): - seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on") - else: - seed_dj_zone = bool(seed_raw) - # Request-only flag: do not persist on profile records. - data.pop("seed_dj_zone", None) - profile_id = profiles.create(name) - # Avoid persisting request-only fields. - data.pop("name", None) - if data: - profiles.update(profile_id, data) - - # New profiles always start with a default zone pre-populated with starter presets. - default_preset_ids = [] - default_preset_defs = [ - { - "name": "on", - "pattern": "on", - "colors": ["#FFFFFF"], - "brightness": 255, - "delay": 100, - "auto": True, - }, - { - "name": "off", - "pattern": "off", - "colors": [], - "brightness": 0, - "delay": 100, - "auto": True, - }, - { - "name": "rainbow", - "pattern": "rainbow", - "colors": [], - "brightness": 255, - "delay": 100, - "auto": True, - "n1": 2, - }, - { - "name": "Colour Cycle", - "pattern": "colour_cycle", - "colors": ["#FF0000", "#00FF00", "#0000FF"], - "brightness": 255, - "delay": 100, - "auto": True, - "n1": 1, - }, - { - "name": "transition", - "pattern": "transition", - "colors": ["#FF0000", "#00FF00", "#0000FF"], - "brightness": 255, - "delay": 500, - "auto": True, - }, - { - "name": "flicker", - "pattern": "flicker", - "colors": ["#FFB84D"], - "brightness": 255, - "delay": 80, - "auto": True, - "n1": 30, - }, - { - "name": "flame", - "pattern": "flame", - "colors": [], - "brightness": 255, - "delay": 50, - "auto": True, - "n1": 35, - "n2": 2600, - "n3": 0, - "n4": 0, - }, - { - "name": "twinkle", - "pattern": "twinkle", - "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], - "brightness": 255, - "delay": 55, - "auto": True, - "n1": 72, - "n2": 140, - "n3": 2, - "n4": 6, - }, - ] - - for preset_data in default_preset_defs: - pid = presets.create(profile_id) - presets.update(pid, preset_data) - default_preset_ids.append(str(pid)) - - default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids]) - zones.update(default_tab_id, { - "presets_flat": default_preset_ids, - "default_preset": default_preset_ids[0] if default_preset_ids else None, - }) - - profile = profiles.read(profile_id) or {} - profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else [] - profile_tabs.append(str(default_tab_id)) - - if seed_dj_zone: - # Seed a DJ-focused zone with three starter presets. - seeded_preset_ids = [] - preset_defs = [ - { - "name": "DJ Rainbow", - "pattern": "rainbow", - "colors": [], - "brightness": 220, - "delay": 60, - "n1": 12, - }, - { - "name": "DJ Single Color", - "pattern": "on", - "colors": ["#ff00ff"], - "brightness": 220, - "delay": 100, - }, - { - "name": "DJ Transition", - "pattern": "transition", - "colors": ["#ff0000", "#00ff00", "#0000ff"], - "brightness": 220, - "delay": 250, - }, - ] - - for preset_data in preset_defs: - pid = presets.create(profile_id) - presets.update(pid, preset_data) - seeded_preset_ids.append(str(pid)) - - dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids]) - zones.update(dj_tab_id, { - "presets_flat": seeded_preset_ids, - "default_preset": seeded_preset_ids[0] if seeded_preset_ids else None, - }) - - profile_tabs.append(str(dj_tab_id)) - - profiles.update(profile_id, {"zones": profile_tabs}) - - profile_data = profiles.read(profile_id) - return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'} - except Exception as e: - return json.dumps({"error": str(e)}), 400 @controller.post('//clone') async def clone_profile(request, id): @@ -351,6 +239,184 @@ async def clone_profile(request, id): except Exception as e: return json.dumps({"error": str(e)}), 400 + +@controller.get('/') +@with_session +async def get_profile(request, id, session): + """Get a specific profile by ID.""" + # Handle 'current' as a special case + if id == 'current': + return await get_current_profile(request, session) + + profile = profiles.read(id) + if profile: + return json.dumps(profile), 200, {'Content-Type': 'application/json'} + return json.dumps({"error": "Profile not found"}), 404 + +@controller.post('') +async def create_profile(request): + """Create a new profile.""" + try: + data = dict(request.json or {}) + name = data.get("name", "") + seed_raw = data.get("seed_dj_zone", False) + if isinstance(seed_raw, str): + seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on") + else: + seed_dj_zone = bool(seed_raw) + # Request-only flag: do not persist on profile records. + data.pop("seed_dj_zone", None) + profile_id = profiles.create(name) + # Avoid persisting request-only fields. + data.pop("name", None) + if data: + profiles.update(profile_id, data) + + # New profiles always start with a default zone pre-populated with starter presets. + default_preset_ids = [] + default_preset_defs = [ + { + "name": "on", + "pattern": "on", + "colors": ["#FFFFFF"], + "brightness": 255, + "delay": 100, + "auto": True, + }, + { + "name": "off", + "pattern": "off", + "colors": [], + "brightness": 0, + "delay": 100, + "auto": True, + }, + { + "name": "rainbow", + "pattern": "colour_cycle", + "colors": [], + "brightness": 255, + "delay": 100, + "auto": True, + "n1": 2, + "mode": 1, + }, + { + "name": "Colour Cycle", + "pattern": "colour_cycle", + "colors": ["#FF0000", "#00FF00", "#0000FF"], + "brightness": 255, + "delay": 100, + "auto": True, + "n1": 1, + }, + { + "name": "transition", + "pattern": "transition", + "colors": ["#FF0000", "#00FF00", "#0000FF"], + "brightness": 255, + "delay": 500, + "auto": True, + }, + { + "name": "flicker", + "pattern": "flicker", + "colors": ["#FFB84D"], + "brightness": 255, + "delay": 80, + "auto": True, + "n1": 30, + }, + { + "name": "flame", + "pattern": "flame", + "colors": [], + "brightness": 255, + "delay": 50, + "auto": True, + "n1": 35, + "n2": 2600, + "n3": 0, + "n4": 0, + }, + { + "name": "twinkle", + "pattern": "twinkle", + "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], + "brightness": 255, + "delay": 55, + "auto": True, + "n1": 72, + "n2": 140, + "n3": 2, + "n4": 6, + }, + ] + + for preset_data in default_preset_defs: + pid = presets.create(profile_id) + presets.update(pid, preset_data) + default_preset_ids.append(str(pid)) + + default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids]) + zones.update(default_tab_id, { + "presets_flat": default_preset_ids, + "default_preset": default_preset_ids[0] if default_preset_ids else None, + }) + + profile = profiles.read(profile_id) or {} + profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else [] + profile_tabs.append(str(default_tab_id)) + + if seed_dj_zone: + # Seed a DJ-focused zone with three starter presets. + seeded_preset_ids = [] + preset_defs = [ + { + "name": "DJ Rainbow", + "pattern": "colour_cycle", + "colors": [], + "brightness": 220, + "delay": 60, + "n1": 12, + "mode": 1, + }, + { + "name": "DJ Single Color", + "pattern": "on", + "colors": ["#ff00ff"], + "brightness": 220, + "delay": 100, + }, + { + "name": "DJ Transition", + "pattern": "transition", + "colors": ["#ff0000", "#00ff00", "#0000ff"], + "brightness": 220, + "delay": 250, + }, + ] + + for preset_data in preset_defs: + pid = presets.create(profile_id) + presets.update(pid, preset_data) + seeded_preset_ids.append(str(pid)) + + dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids]) + zones.update(dj_tab_id, { + "presets_flat": seeded_preset_ids, + "default_preset": seeded_preset_ids[0] if seeded_preset_ids else None, + }) + + profile_tabs.append(str(dj_tab_id)) + + profiles.update(profile_id, {"zones": profile_tabs}) + + profile_data = profiles.read(profile_id) + return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'} + except Exception as e: + return json.dumps({"error": str(e)}), 400 + @controller.put('/current') @with_session async def update_current_profile(request, session): diff --git a/src/controllers/sequence.py b/src/controllers/sequence.py index 11c2a87..18ff721 100644 --- a/src/controllers/sequence.py +++ b/src/controllers/sequence.py @@ -3,11 +3,14 @@ from microdot.session import with_session from models.sequence import Sequence from models.profile import Profile from models.transport import get_current_sender +from models.preset import Preset +from util.profile_bundle import export_sequence_bundle, import_sequence_bundle import json controller = Microdot() sequences = Sequence() profiles = Profile() +presets = Preset() def get_current_profile_id(session=None): @@ -39,6 +42,57 @@ async def list_sequences(request, session): return json.dumps(scoped), 200, {"Content-Type": "application/json"} +@controller.get("//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("/") @with_session async def get_sequence(request, session, id): diff --git a/src/controllers/zone.py b/src/controllers/zone.py index 51d241b..cc811e1 100644 --- a/src/controllers/zone.py +++ b/src/controllers/zone.py @@ -349,6 +349,7 @@ async def clone_zone(request, session, id): source.get("names"), source.get("presets"), source.get("group_ids"), + source.get("content_kind"), ) extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")} if extra: diff --git a/src/models/zone.py b/src/models/zone.py index 5c88968..de155bd 100644 --- a/src/models/zone.py +++ b/src/models/zone.py @@ -46,6 +46,22 @@ class Zone(Model): if changed: self.save() + @staticmethod + def _normalized_content_kind(doc): + if not isinstance(doc, dict): + return None + kind = doc.get("content_kind") + return kind if kind in ("presets", "sequences") else None + + def _enforce_content_kind_invariants(self, doc): + """Presets-only zones hold no sequences; sequences-only hold no preset tiles.""" + kind = self._normalized_content_kind(doc) + if kind == "presets": + doc["sequence_ids"] = [] + elif kind == "sequences": + doc["presets"] = [] + doc["presets_flat"] = [] + def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None): next_id = self.get_next_id() gid_list = [] @@ -62,6 +78,9 @@ class Zone(Model): } if content_kind in ("presets", "sequences"): doc["content_kind"] = content_kind + if "sequence_ids" not in doc: + doc["sequence_ids"] = [] + self._enforce_content_kind_invariants(doc) self[next_id] = doc self.save() return next_id @@ -74,7 +93,9 @@ class Zone(Model): id_str = str(id) if id_str not in self: return False - self[id_str].update(data) + patch = data if isinstance(data, dict) else {} + self[id_str].update(patch) + self._enforce_content_kind_invariants(self[id_str]) self.save() return True diff --git a/src/static/bundle_io.js b/src/static/bundle_io.js new file mode 100644 index 0000000..1aca90b --- /dev/null +++ b/src/static/bundle_io.js @@ -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; + } +}; diff --git a/src/static/patterns.js b/src/static/patterns.js index ed7b2be..dccb476 100644 --- a/src/static/patterns.js +++ b/src/static/patterns.js @@ -573,7 +573,12 @@ document.addEventListener('DOMContentLoaded', () => { n3: coercePresetInt(preset.n3), n4: coercePresetInt(preset.n4), n5: coercePresetInt(preset.n5), - n6: coercePresetInt(preset.n6), + n6: (() => { + if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') { + return coercePresetInt(preset.mode); + } + return coercePresetInt(preset.n6); + })(), }; }); if (!Object.keys(wirePresets).length) { diff --git a/src/static/presets.js b/src/static/presets.js index cd5c67c..0c7bb50 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -4,6 +4,25 @@ let espnowSocketReady = false; let espnowPendingMessages = []; let currentProfileIdCache = null; +function coercePresetInt(v, def = 0) { + if (typeof v === 'number' && Number.isFinite(v)) { + return v; + } + const t = parseInt(String(v), 10); + return Number.isFinite(t) ? t : def; +} + +/** Style variant for wire ``n6``; presets may store ``mode`` or legacy ``n6``. */ +function presetWireN6(preset, def = 0) { + if (!preset || typeof preset !== 'object') { + return def; + } + if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') { + return coercePresetInt(preset.mode, def); + } + return coercePresetInt(preset.n6, def); +} + const getCurrentProfileId = async () => { try { const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } }); @@ -243,6 +262,8 @@ document.addEventListener('DOMContentLoaded', () => { const presetSaveButton = document.getElementById('preset-save-btn'); const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn'); const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn'); + const presetModeInput = document.getElementById('preset-mode-input'); + const presetModeGroup = document.getElementById('preset-mode-group'); if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) { return; @@ -297,7 +318,8 @@ document.addEventListener('DOMContentLoaded', () => { patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object' ) { - patternConfig = patternConfig.parameter_mappings; + const { parameter_mappings: pm, data: _data, ...rest } = patternConfig; + patternConfig = { ...rest, ...pm }; } return patternConfig && typeof patternConfig === 'object' ? patternConfig : null; }; @@ -311,6 +333,46 @@ document.addEventListener('DOMContentLoaded', () => { return cfg.supports_manual !== false; }; + const getPatternModeOptions = (patternName) => { + const cfg = resolvePatternConfig(patternName); + if (!cfg || typeof cfg.mode !== 'object' || cfg.mode === null || Array.isArray(cfg.mode)) { + return null; + } + const entries = Object.entries(cfg.mode).filter( + ([, label]) => typeof label === 'string' && label.trim(), + ); + if (entries.length < 2) { + return null; + } + entries.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10)); + return entries; + }; + + const patternSupportsModes = (patternName) => getPatternModeOptions(patternName) !== null; + + const setPresetModeFieldVisible = (show) => { + if (!presetModeGroup) { + return; + } + presetModeGroup.hidden = !show; + presetModeGroup.style.display = show ? '' : 'none'; + if (!show && presetModeInput) { + presetModeInput.innerHTML = ''; + } + }; + + const presetStoredMode = (preset) => { + if (!preset || typeof preset !== 'object') { + return 0; + } + if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') { + const m = parseInt(String(preset.mode), 10); + return Number.isFinite(m) ? m : 0; + } + const n6 = parseInt(String(preset.n6), 10); + return Number.isFinite(n6) ? n6 : 0; + }; + const updateManualBeatNVisibility = () => { if (!presetManualBeatNWrap) { return; @@ -734,7 +796,7 @@ document.addEventListener('DOMContentLoaded', () => { } // After values: show only mapped n params with labels from pattern.json; clear hidden inputs - updatePresetNLabels(patternName); + updatePresetNLabels(patternName, preset); updateManualModeAvailability(); updatePresetEditorTabActionsVisibility(); }; @@ -793,10 +855,29 @@ document.addEventListener('DOMContentLoaded', () => { return section ? section.dataset.zoneId : null; }; - const updatePresetEditorTabActionsVisibility = () => { + const updatePresetEditorTabActionsVisibility = async () => { if (!presetRemoveFromTabButton) return; - const show = Boolean(currentEditTabId && currentEditId); - presetRemoveFromTabButton.hidden = !show; + if (!currentEditTabId || !currentEditId) { + presetRemoveFromTabButton.hidden = true; + return; + } + try { + const tabRes = await fetch(`/zones/${currentEditTabId}`, { + headers: { Accept: 'application/json' }, + }); + if (!tabRes.ok) { + presetRemoveFromTabButton.hidden = false; + return; + } + const tabData = await tabRes.json(); + const allowed = + typeof window.zoneAllowsPresets === 'function' + ? window.zoneAllowsPresets(tabData) + : true; + presetRemoveFromTabButton.hidden = !allowed; + } catch (e) { + presetRemoveFromTabButton.hidden = false; + } }; const updateTabDefaultPreset = async (presetId) => { @@ -827,8 +908,15 @@ document.addEventListener('DOMContentLoaded', () => { if (presetEditorModal) { presetEditorModal.classList.add('active'); } + const patternName = presetPatternInput ? presetPatternInput.value : ''; + const modeBefore = patternSupportsModes(patternName) + ? presetStoredMode({ + mode: presetModeInput ? presetModeInput.value : undefined, + n6: getNumberInput('preset-n6-input'), + }) + : 0; loadPatterns().then(() => { - updatePresetNLabels(presetPatternInput ? presetPatternInput.value : ''); + updatePresetNLabels(patternName, { mode: modeBefore, n6: modeBefore }); updateColorSectionVisibility(); }); }; @@ -859,11 +947,20 @@ document.addEventListener('DOMContentLoaded', () => { })(), }; - // Always store numeric parameters as n1..n8. + // Always store numeric parameters as n1..n8 (except n6 when pattern uses mode). + const modeEntries = patternSupportsModes(payload.pattern) + ? getPatternModeOptions(payload.pattern) + : null; for (let i = 1; i <= 8; i++) { const nKey = `n${i}`; + if (modeEntries && nKey === 'n6') { + continue; + } payload[nKey] = getNumberInput(`preset-${nKey}-input`); } + if (modeEntries && presetModeInput) { + payload.mode = parseInt(presetModeInput.value, 10) || 0; + } return payload; }; @@ -950,30 +1047,8 @@ document.addEventListener('DOMContentLoaded', () => { } }; - const updatePresetNLabels = (patternName) => { - const rawPatternName = String(patternName || '').trim(); - const normalizedPatternName = rawPatternName.endsWith('.py') - ? rawPatternName.slice(0, -3) - : rawPatternName; - let patternConfig = - (cachedPatterns && cachedPatterns[rawPatternName]) || - (cachedPatterns && cachedPatterns[normalizedPatternName]) || - null; - if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') { - const lower = normalizedPatternName.toLowerCase(); - const matchedKey = Object.keys(cachedPatterns).find( - (k) => String(k).toLowerCase() === lower, - ); - if (matchedKey) { - patternConfig = cachedPatterns[matchedKey]; - } - } - if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') { - patternConfig = patternConfig.data; - } - if (patternConfig && typeof patternConfig === 'object' && patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object') { - patternConfig = patternConfig.parameter_mappings; - } + const updatePresetNLabels = (patternName, presetForMode = null) => { + const patternConfig = resolvePatternConfig(patternName); const labels = {}; const visibleNKeys = new Set(); @@ -989,9 +1064,35 @@ document.addEventListener('DOMContentLoaded', () => { }); } + const modeEntries = patternSupportsModes(patternName) ? getPatternModeOptions(patternName) : null; + if (modeEntries) { + visibleNKeys.delete('n6'); + } + if (presetModeInput) { + if (modeEntries) { + setPresetModeFieldVisible(true); + presetModeInput.innerHTML = ''; + modeEntries.forEach(([val, label]) => { + const opt = document.createElement('option'); + opt.value = val; + opt.textContent = label.trim(); + presetModeInput.appendChild(opt); + }); + const modeVal = presetForMode ? presetStoredMode(presetForMode) : 0; + const modeStr = String(modeVal); + if ([...presetModeInput.options].some((o) => o.value === modeStr)) { + presetModeInput.value = modeStr; + } else if (presetModeInput.options.length) { + presetModeInput.selectedIndex = 0; + } + } else { + setPresetModeFieldVisible(false); + } + } + const hasPatternMeta = patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0; - const hasAnyNLabel = visibleNKeys.size > 0; + const hasAnyNLabel = visibleNKeys.size > 0 || Boolean(modeEntries); for (let i = 1; i <= 8; i++) { const nKey = `n${i}`; @@ -1073,6 +1174,26 @@ document.addEventListener('DOMContentLoaded', () => { void sendPresetViaEspNow(presetId, preset || {}, []); }); + const exportButton = document.createElement('button'); + exportButton.className = 'btn btn-secondary btn-small'; + exportButton.textContent = 'Export'; + exportButton.addEventListener('click', async () => { + try { + const response = await fetch(`/presets/${presetId}/export`, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error('Export failed'); + } + const bundle = await response.json(); + const safeName = ((preset && preset.name) || presetId).replace(/[^\w.-]+/g, '_'); + window.downloadJsonFile(`preset-${safeName}.json`, bundle); + } catch (error) { + console.error('Export preset failed:', error); + alert('Failed to export preset.'); + } + }); + const deleteButton = document.createElement('button'); deleteButton.className = 'btn btn-danger btn-small'; deleteButton.textContent = 'Delete'; @@ -1102,6 +1223,7 @@ document.addEventListener('DOMContentLoaded', () => { row.appendChild(label); row.appendChild(details); row.appendChild(editButton); + row.appendChild(exportButton); row.appendChild(sendButton); row.appendChild(deleteButton); presetsList.appendChild(row); @@ -1148,6 +1270,34 @@ document.addEventListener('DOMContentLoaded', () => { if (presetsCloseButton) { presetsCloseButton.addEventListener('click', closeModal); } + const importPresetBtn = document.getElementById('import-preset-btn'); + if (importPresetBtn) { + importPresetBtn.addEventListener('click', async () => { + const text = await window.pickJsonFile(); + if (!text) return; + const bundle = window.parseJsonFileText(text); + if (!bundle || bundle.kind !== 'preset') { + alert('Invalid preset bundle file.'); + return; + } + try { + const response = await fetch('/presets/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ bundle }), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Import failed'); + } + await loadPresets(); + } catch (error) { + console.error('Import preset failed:', error); + alert(error.message || 'Failed to import preset.'); + } + }); + } + if (presetsAddButton) { presetsAddButton.addEventListener('click', () => { clearForm(); @@ -1198,6 +1348,22 @@ document.addEventListener('DOMContentLoaded', () => { alert('Could not determine current zone.'); return; } + + try { + const zoneCheck = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); + if (zoneCheck.ok) { + const zoneDoc = await zoneCheck.json(); + if ( + typeof window.zoneAllowsPresets === 'function' && + !window.zoneAllowsPresets(zoneDoc) + ) { + alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.'); + return; + } + } + } catch (e) { + console.warn('Could not verify zone content kind:', e); + } // Load all presets try { @@ -1327,11 +1493,10 @@ document.addEventListener('DOMContentLoaded', () => { throw new Error('Failed to load zone'); } const tabData = await tabResponse.json(); - const kind = - typeof window.normalizeZoneContentKind === 'function' - ? window.normalizeZoneContentKind(tabData) - : null; - if (kind === 'sequences') { + if ( + typeof window.zoneAllowsPresets === 'function' && + !window.zoneAllowsPresets(tabData) + ) { alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.'); return; } @@ -1697,14 +1862,6 @@ document.addEventListener('DOMContentLoaded', () => { clearForm(); }); -const coercePresetInt = (v, def = 0) => { - if (typeof v === 'number' && Number.isFinite(v)) { - return v; - } - const t = parseInt(String(v), 10); - return Number.isFinite(t) ? t : def; -}; - /** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */ const coercePresetAuto = (preset) => { if (!preset || typeof preset !== 'object') { @@ -1826,7 +1983,7 @@ const sendPresetViaEspNow = async ( n3: coercePresetInt(preset.n3), n4: coercePresetInt(preset.n4), n5: coercePresetInt(preset.n5), - n6: coercePresetInt(preset.n6), + n6: presetWireN6(preset), manual_beat_n: coerceManualBeatN(preset), }, }, @@ -1929,7 +2086,7 @@ try { // window may not exist in some environments; ignore. } -// Store selected preset(s) per zone (multi-select; merge send order = click order, last wins on device). +// Store selected preset per zone (single-select; one tile active, one driver push per click). const zoneSelectedPresetIds = {}; const zonePresetSelectionOrder = {}; @@ -1956,19 +2113,21 @@ function getOrderedZonePresetSelection(zoneId) { return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id))); } -async function sendMergedZonePresetSelection(zoneId, tabData, allPresets) { - const ids = getOrderedZonePresetSelection(zoneId); - if (!ids.length) return; - for (let i = 0; i < ids.length; i += 1) { - const pid = ids[i]; - const preset = allPresets[pid]; - if (!preset) continue; - const names = - window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function' - ? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid) - : []; - await sendPresetViaEspNow(pid, preset, names, false, false, '2'); - } +/** Preset id that should show the tile outline (last click in selection order). */ +function getLastZonePresetSelectionId(zoneId) { + const order = getOrderedZonePresetSelection(zoneId); + return order.length ? String(order[order.length - 1]) : null; +} + +async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPresets) { + const pid = String(presetId); + const body = (allPresets && allPresets[pid]) || preset; + if (!body) return; + const names = + window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function' + ? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid) + : []; + await sendPresetViaEspNow(pid, body, names, false, false, '2'); } // Store selected preset per zone @@ -2053,6 +2212,12 @@ const savePresetGrid = async (zoneId, presetGrid) => { throw new Error('Failed to load zone'); } const tabData = await tabResponse.json(); + if ( + typeof window.zoneAllowsPresets === 'function' && + !window.zoneAllowsPresets(tabData) + ) { + throw new Error('This zone is for sequences only.'); + } // Store as 2D grid tabData.presets = presetGrid; @@ -2265,7 +2430,9 @@ const renderTabPresets = async (zoneId, options = {}) => { const preset = allPresets[presetId]; if (preset) { ensureZonePresetSelection(zoneId); - const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId)); + const lastSelectedId = getLastZonePresetSelectionId(zoneId); + const isSelected = + lastSelectedId !== null && lastSelectedId === String(presetId); const displayPreset = { ...preset, colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors), @@ -2285,7 +2452,10 @@ const renderTabPresets = async (zoneId, options = {}) => { }); } - if (typeof window.appendZoneSequenceTiles === 'function' && ck !== 'presets') { + if ( + typeof window.appendZoneSequenceTiles === 'function' && + (typeof window.zoneAllowsSequences !== 'function' || window.zoneAllowsSequences(tabData)) + ) { await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList); } } catch (error) { @@ -2311,7 +2481,9 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group } const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : []; - const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow'; + const pat = (preset.pattern || '').toLowerCase(); + const mode = presetWireN6(preset); + const isRainbow = pat === 'rainbow' || (pat === 'colour_cycle' && mode === 1); const barColors = isRainbow ? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF'] : colors; @@ -2389,34 +2561,32 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group ensureZonePresetSelection(zoneId); const z = String(zoneId); const set = zoneSelectedPresetIds[z]; - const order = zonePresetSelectionOrder[z]; const idStr = String(presetId); - if (set.has(idStr)) { - set.delete(idStr); - zonePresetSelectionOrder[z] = order.filter((x) => String(x) !== idStr); - } else { + const wasSelected = set.has(idStr); + set.clear(); + zonePresetSelectionOrder[z] = []; + if (!wasSelected) { set.add(idStr); - order.push(idStr); + zonePresetSelectionOrder[z] = [idStr]; } + const outlinePresetId = getLastZonePresetSelectionId(zoneId); if (presetsListEl) { presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => { const pid = rw.dataset.presetId; const btnEl = rw.querySelector('.preset-tile-main'); if (!btnEl || !pid) return; - if (set.has(String(pid))) btnEl.classList.add('active'); + if (outlinePresetId && String(pid) === outlinePresetId) btnEl.classList.add('active'); else btnEl.classList.remove('active'); }); } - const orderList = getOrderedZonePresetSelection(zoneId); - if (orderList.length) { - const lastPid = orderList[orderList.length - 1]; - selectedPresets[zoneId] = lastPid; - selectedPresetPayloads[zoneId] = (allPresets && allPresets[lastPid]) || preset; + if (!wasSelected) { + selectedPresets[zoneId] = idStr; + selectedPresetPayloads[zoneId] = (allPresets && allPresets[idStr]) || preset; + void sendZonePresetSelection(zoneId, tabData, idStr, preset, allPresets); } else { delete selectedPresets[zoneId]; delete selectedPresetPayloads[zoneId]; } - void sendMergedZonePresetSelection(zoneId, tabData, allPresets); }); if (canDrag) { @@ -2526,6 +2696,13 @@ const removePresetFromTab = async (zoneId, presetId) => { throw new Error('Failed to load zone'); } const tabData = await tabResponse.json(); + if ( + typeof window.zoneAllowsPresets === 'function' && + !window.zoneAllowsPresets(tabData) + ) { + alert('This zone is for sequences only.'); + return; + } // Normalize to flat array let flat = []; diff --git a/src/static/profiles.js b/src/static/profiles.js index 5dc6393..710c19b 100644 --- a/src/static/profiles.js +++ b/src/static/profiles.js @@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => { const newProfileInput = document.getElementById("new-profile-name"); const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj"); const createProfileButton = document.getElementById("create-profile-btn"); + const importProfileButton = document.getElementById("import-profile-btn"); if (!profilesButton || !profilesModal || !profilesList) { return; @@ -101,6 +102,26 @@ document.addEventListener("DOMContentLoaded", () => { } }); + const exportButton = document.createElement("button"); + exportButton.className = "btn btn-secondary btn-small"; + exportButton.textContent = "Export"; + exportButton.addEventListener("click", async () => { + try { + const response = await fetch(`/profiles/${profileId}/export`, { + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + throw new Error("Export failed"); + } + const bundle = await response.json(); + const safeName = ((profile && profile.name) || profileId).replace(/[^\w.-]+/g, "_"); + window.downloadJsonFile(`profile-${safeName}.json`, bundle); + } catch (error) { + console.error("Export profile failed:", error); + alert("Failed to export profile."); + } + }); + const cloneButton = document.createElement("button"); cloneButton.className = "btn btn-secondary btn-small"; cloneButton.textContent = "Clone"; @@ -177,6 +198,7 @@ document.addEventListener("DOMContentLoaded", () => { row.appendChild(label); row.appendChild(applyButton); if (editMode) { + row.appendChild(exportButton); row.appendChild(cloneButton); row.appendChild(deleteButton); } @@ -276,6 +298,60 @@ document.addEventListener("DOMContentLoaded", () => { if (createProfileButton) { createProfileButton.addEventListener("click", createProfile); } + + const importProfile = async () => { + if (!isEditModeActive()) { + return; + } + const text = await window.pickJsonFile(); + if (!text) { + return; + } + const bundle = window.parseJsonFileText(text); + if (!bundle || typeof bundle !== "object") { + alert("Invalid JSON file."); + return; + } + const defaultName = + (bundle.profile && bundle.profile.name) || "Imported profile"; + const name = prompt("Profile name for import:", defaultName); + if (name === null) { + return; + } + const trimmed = String(name).trim(); + if (!trimmed) { + alert("Profile name cannot be empty."); + return; + } + try { + const response = await fetch("/profiles/import", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ bundle, name: trimmed, apply: true }), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || "Import failed"); + } + const data = await response.json(); + const newProfileId = data.id || Object.keys(data).find((k) => k !== "id"); + if (newProfileId) { + await fetch(`/profiles/${newProfileId}/apply`, { + method: "POST", + headers: { Accept: "application/json" }, + }); + } + await loadProfiles(); + await refreshTabsForActiveProfile(); + } catch (error) { + console.error("Import profile failed:", error); + alert(error.message || "Failed to import profile."); + } + }; + + if (importProfileButton) { + importProfileButton.addEventListener("click", importProfile); + } if (newProfileInput) { newProfileInput.addEventListener("keypress", (event) => { if (event.key === "Enter") { diff --git a/src/static/sequences.js b/src/static/sequences.js index d6ff8ee..e155c3f 100644 --- a/src/static/sequences.js +++ b/src/static/sequences.js @@ -454,11 +454,10 @@ async function addSequenceToTab(sequenceId, zoneId) { const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); if (!tabResponse.ok) throw new Error('Failed to load zone'); const tabData = await tabResponse.json(); - const kind = - typeof window.normalizeZoneContentKind === 'function' - ? window.normalizeZoneContentKind(tabData) - : null; - if (kind === 'presets') { + if ( + typeof window.zoneAllowsSequences === 'function' && + !window.zoneAllowsSequences(tabData) + ) { alert('This zone is for presets only. Add presets from the zone Edit menu instead.'); return; } @@ -524,11 +523,10 @@ async function refreshEditTabSequencesUi(zoneId) { const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); if (!zoneRes.ok) throw new Error('zone'); const zone = await zoneRes.json(); - const kind = - typeof window.normalizeZoneContentKind === 'function' - ? window.normalizeZoneContentKind(zone) - : null; - if (kind === 'presets') { + if ( + typeof window.zoneAllowsSequences === 'function' && + !window.zoneAllowsSequences(zone) + ) { currentEl.innerHTML = 'This zone is for presets only. Sequences are hidden.'; addEl.innerHTML = ''; @@ -1092,12 +1090,31 @@ async function loadSequencesModalList() { const nSteps = ln.reduce((a, l) => a + l.length, 0); const nLanes = ln.filter((l) => l.length > 0).length || 1; title.textContent = `${doc.name || id} — ${nLanes} lane(s), ${nSteps} step(s)`; + const exportBtn = document.createElement('button'); + exportBtn.type = 'button'; + exportBtn.className = 'btn btn-secondary btn-small'; + exportBtn.textContent = 'Export'; + exportBtn.addEventListener('click', async () => { + try { + const response = await fetch(`/sequences/${id}/export`, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error('Export failed'); + const bundle = await response.json(); + const safeName = String(doc.name || id).replace(/[^\w.-]+/g, '_'); + window.downloadJsonFile(`sequence-${safeName}.json`, bundle); + } catch (e) { + console.error(e); + alert('Failed to export sequence.'); + } + }); const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'btn btn-secondary btn-small'; edit.textContent = 'Edit'; edit.addEventListener('click', () => openSequenceEditor(id, doc)); row.appendChild(title); + row.appendChild(exportBtn); row.appendChild(edit); listEl.appendChild(row); }); @@ -1139,6 +1156,33 @@ document.addEventListener('DOMContentLoaded', () => { openSequenceEditor(null, null); }); } + const importSeqBtn = document.getElementById('import-sequence-btn'); + if (importSeqBtn) { + importSeqBtn.addEventListener('click', async () => { + const text = await window.pickJsonFile(); + if (!text) return; + const bundle = window.parseJsonFileText(text); + if (!bundle || bundle.kind !== 'sequence') { + alert('Invalid sequence bundle file.'); + return; + } + try { + const response = await fetch('/sequences/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ bundle }), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Import failed'); + } + await loadSequencesModalList(); + } catch (e) { + console.error(e); + alert(e.message || 'Failed to import sequence.'); + } + }); + } const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn'); if (openPresetsFromSeq) { openPresetsFromSeq.addEventListener('click', () => { diff --git a/src/static/style.css b/src/static/style.css index b9e7e8d..b3eb79e 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -598,6 +598,39 @@ body.preset-ui-run .edit-mode-only { font-weight: 500; } +.preset-mode-field { + margin-top: 0.75rem; + margin-bottom: 0.25rem; +} + +.preset-mode-field label { + display: block; + font-weight: 500; + margin-bottom: 0.35rem; +} + +.preset-mode-input { + display: block; + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 0.5rem 0.6rem; + background-color: #3a3a3a; + color: #fff; + border: 1px solid #4a4a4a; + border-radius: 4px; + font-size: 1rem; +} + +.preset-mode-input:focus { + outline: none; + border-color: #6a9fff; +} + +#preset-editor-modal .preset-mode-field { + grid-column: 1 / -1; +} + .n-input { flex: 0 0 var(--n-input-width, 5ch); width: var(--n-input-width, 5ch); @@ -1383,6 +1416,22 @@ body.preset-ui-run .edit-mode-only { min-width: 8rem; } +.zone-content-kind-row { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 1rem; + margin: 0.35rem 0 0.75rem; +} + +.zone-content-kind-row label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0; + white-space: nowrap; +} + .zone-devices-label { display: block; margin-top: 0.75rem; diff --git a/src/static/zones.js b/src/static/zones.js index b95438c..8edcfc8 100644 --- a/src/static/zones.js +++ b/src/static/zones.js @@ -497,6 +497,42 @@ function normalizeZoneContentKind(zoneDoc) { return null; } +/** Display/save kind when ``content_kind`` is missing (legacy rows). */ +function effectiveZoneContentKind(zoneDoc) { + const explicit = normalizeZoneContentKind(zoneDoc); + if (explicit) return explicit; + const seqIds = Array.isArray(zoneDoc && zoneDoc.sequence_ids) + ? zoneDoc.sequence_ids.filter(Boolean) + : []; + const presetIds = tabPresetIdsInZoneDoc(zoneDoc || {}); + if (seqIds.length > 0 && presetIds.length === 0) return 'sequences'; + return 'presets'; +} + +/** @returns {'presets' | 'sequences'} */ +function editModalContentKindSelected() { + const radio = document.querySelector('input[name="edit-zone-content-kind"]:checked'); + return radio && radio.value === 'sequences' ? 'sequences' : 'presets'; +} + +function activeZoneContentKind(zoneDoc) { + const modal = document.getElementById('edit-zone-modal'); + if (modal && modal.classList.contains('active')) { + return editModalContentKindSelected(); + } + return effectiveZoneContentKind(zoneDoc); +} + +/** @returns {boolean} */ +function zoneAllowsPresets(zoneDoc) { + return activeZoneContentKind(zoneDoc) === 'presets'; +} + +/** @returns {boolean} */ +function zoneAllowsSequences(zoneDoc) { + return activeZoneContentKind(zoneDoc) === 'sequences'; +} + function applyZoneContentKindEditModal(kind) { const presetsBlock = document.getElementById('edit-zone-block-presets'); const groupsBlock = document.getElementById('edit-zone-block-groups'); @@ -504,17 +540,16 @@ function applyZoneContentKindEditModal(kind) { const vis = (el, show) => { if (el) el.style.display = show ? '' : 'none'; }; + const k = kind === 'sequences' ? 'sequences' : 'presets'; vis(groupsBlock, true); - if (!kind) { - vis(presetsBlock, true); - vis(seqBlock, true); - return; - } - vis(presetsBlock, kind === 'presets'); - vis(seqBlock, kind === 'sequences'); + vis(presetsBlock, k === 'presets'); + vis(seqBlock, k === 'sequences'); } window.normalizeZoneContentKind = normalizeZoneContentKind; +window.effectiveZoneContentKind = effectiveZoneContentKind; +window.zoneAllowsPresets = zoneAllowsPresets; +window.zoneAllowsSequences = zoneAllowsSequences; // Load tabs list async function loadZones() { @@ -573,10 +608,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) { const zone = tabs[zoneId]; if (zone) { const activeClass = zoneId === currentZoneId ? 'active' : ''; - let disp = zone.name || `Zone ${zoneId}`; - const kind = normalizeZoneContentKind(zone); - if (kind === 'presets') disp += ' · presets'; - else if (kind === 'sequences') disp += ' · sequences'; + const disp = zone.name || `Zone ${zoneId}`; html += ` -
- This zone is for - +
+ -
+
@@ -134,6 +137,7 @@
+
@@ -301,6 +305,7 @@

Presets

@@ -316,6 +321,7 @@

Sequences

@@ -344,10 +350,8 @@

- +
@@ -789,6 +797,7 @@ + diff --git a/src/util/binary_envelope.py b/src/util/binary_envelope.py index 9e2cc7d..15a0ab3 100644 --- a/src/util/binary_envelope.py +++ b/src/util/binary_envelope.py @@ -43,6 +43,8 @@ import json import struct from typing import Any, Dict, List, Optional, Tuple +from util.espnow_message import wire_n6 + BINARY_ENVELOPE_VERSION_1 = 1 BINARY_ENVELOPE_VERSION_2 = 2 HEADER_LEN = 5 @@ -108,7 +110,7 @@ def _pack_preset_dict(name: str, preset: Dict[str, Any]) -> bytes: n3 = _clamp_i16(preset.get("n3", 0)) n4 = _clamp_i16(preset.get("n4", 0)) n5 = _clamp_i16(preset.get("n5", 0)) - n6 = _clamp_i16(preset.get("n6", 0)) + n6 = _clamp_i16(wire_n6(preset)) parts.append( struct.pack( " 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 diff --git a/tests/models/test_preset.py b/tests/models/test_preset.py index b0bc68a..1e114a7 100644 --- a/tests/models/test_preset.py +++ b/tests/models/test_preset.py @@ -25,18 +25,20 @@ def test_preset(): print("\nTesting update preset") update_data = { "name": "test_preset", - "pattern": "on", + "pattern": "colour_cycle", "colors": ["#FF0000", "#00FF00"], "delay": 100, "brightness": 127, "n1": 10, - "n2": 20 + "n2": 20, + "mode": 1, } result = presets.update(preset_id, update_data) assert result is True updated = presets.read(preset_id) assert updated["name"] == "test_preset" - assert updated["pattern"] == "on" + assert updated["pattern"] == "colour_cycle" + assert updated["mode"] == 1 assert updated["delay"] == 100 print("\nTesting list presets") diff --git a/tests/test_binary_envelope.py b/tests/test_binary_envelope.py index aeaf7d3..1eb8d30 100644 --- a/tests/test_binary_envelope.py +++ b/tests/test_binary_envelope.py @@ -29,6 +29,21 @@ def test_pack_parse_v2_brightness_only(): assert data == {"v": "1", "b": 128} +def test_pack_parse_v2_mode_maps_to_n6(): + raw = pack_binary_envelope_v2( + presets={ + "m": { + "p": "meteor", + "c": ["#aabbcc"], + "mode": 2, + "n6": 0, + } + }, + ) + data = parse_binary_envelope_v2(raw) + assert data["presets"]["m"]["n6"] == 2 + + def test_pack_parse_v2_full(): raw = pack_binary_envelope_v2( presets={ diff --git a/tests/test_endpoints_pytest.py b/tests/test_endpoints_pytest.py index 442c724..81f408a 100644 --- a/tests/test_endpoints_pytest.py +++ b/tests/test_endpoints_pytest.py @@ -50,6 +50,17 @@ def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) -> raise AssertionError(f"Could not find id for {field}={value!r}") +def _create_and_apply_profile(c: requests.Session, base_url: str) -> str: + """Sequences/scenes/presets need an active profile in session.""" + unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}" + resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name}) + assert resp.status_code == 201 + profile_id = next(iter(resp.json().keys())) + resp = c.post(f"{base_url}/profiles/{profile_id}/apply") + assert resp.status_code == 200 + return str(profile_id) + + def _start_microdot_server(app: Microdot, host: str, port: int): """ Start Microdot server on a background thread. @@ -474,6 +485,36 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch): resp = c.delete(f"{base_url}/zones/{zone_id}") assert resp.status_code == 200 + resp = c.get(f"{base_url}/profiles/{profile_id}/export") + assert resp.status_code == 200 + bundle = resp.json() + assert bundle.get("kind") == "profile" + assert isinstance(bundle.get("presets"), dict) + + import_name = f"pytest-imported-{uuid.uuid4().hex[:8]}" + resp = c.post( + f"{base_url}/profiles/import", + json={"bundle": bundle, "name": import_name, "apply": False}, + ) + assert resp.status_code == 201 + imported_profile_id = resp.json().get("id") or next( + k for k in resp.json().keys() if k != "id" + ) + resp = c.delete(f"{base_url}/profiles/{imported_profile_id}") + assert resp.status_code == 200 + + resp = c.get(f"{base_url}/presets/{first_preset_id}/export") + assert resp.status_code == 200 + assert resp.json().get("kind") == "preset" + resp = c.post( + f"{base_url}/presets/import", + json={"bundle": resp.json()}, + ) + assert resp.status_code == 201 + imported_preset_id = next(iter(resp.json().keys())) + resp = c.delete(f"{base_url}/presets/{imported_preset_id}") + assert resp.status_code == 200 + # Profile clone + update endpoints. clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}" resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name}) @@ -508,6 +549,8 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server): base_url: str = server["base_url"] sender: DummySender = server["sender"] + _create_and_apply_profile(c, base_url) + # Groups. unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}" resp = c.post(f"{base_url}/groups", json={"name": unique_group_name}) @@ -715,6 +758,13 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server): assert resp.status_code == 200 definitions = resp.json() assert isinstance(definitions, dict) + assert "colour_cycle" in definitions + cc_mode = definitions["colour_cycle"].get("mode") + assert isinstance(cc_mode, dict) + assert "0" in cc_mode and "1" in cc_mode + assert "blink" in definitions + blink_mode = definitions["blink"].get("mode") + assert not isinstance(blink_mode, dict) or len(blink_mode) < 2 pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}" resp = c.post( diff --git a/tests/test_preset_wire_mode.py b/tests/test_preset_wire_mode.py new file mode 100644 index 0000000..b948420 --- /dev/null +++ b/tests/test_preset_wire_mode.py @@ -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 diff --git a/tests/test_profile_bundle.py b/tests/test_profile_bundle.py new file mode 100644 index 0000000..34dc69a --- /dev/null +++ b/tests/test_profile_bundle.py @@ -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)