Compare commits
16 Commits
beta-1.03
...
b140aedf00
| Author | SHA1 | Date | |
|---|---|---|---|
| b140aedf00 | |||
| 15f8c8a039 | |||
| 70641c63af | |||
| ef15c54593 | |||
| 301e1c64bf | |||
| c286e504eb | |||
| 964cfc6d91 | |||
| 7ecb5c3b3e | |||
| 879db2a7df | |||
| 96d1e1b5fd | |||
| 6286297646 | |||
| ca3fef3f8a | |||
| 6c9e06f33b | |||
| c1c3e5d71b | |||
| c64dd736f2 | |||
| cad0aa7e59 |
2
Pipfile
2
Pipfile
@@ -27,7 +27,7 @@ python_version = "3.11"
|
|||||||
web = "python tests/web.py"
|
web = "python tests/web.py"
|
||||||
watch = "python -m watchfiles \"python tests/web.py\" src tests"
|
watch = "python -m watchfiles \"python tests/web.py\" src tests"
|
||||||
run = "sh -c 'cd src && python main.py'"
|
run = "sh -c 'cd src && python main.py'"
|
||||||
dev = "python -m watchfiles \"sh -c 'cd src && python main.py'\" src"
|
dev = "python -m watchfiles \"sh -c 'cd src && LED_CONTROLLER_LIVE_RELOAD=1 python main.py'\" src"
|
||||||
test = "python -m pytest"
|
test = "python -m pytest"
|
||||||
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
|
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
|
||||||
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"
|
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"1": {"name": "Main Group", "devices": ["188b0e1560a8"], "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}}
|
{"1": {"name": "group1", "devices": ["e8f60a16fb00", "e8f60a170794"], "wifi_driver_display_name": "desk", "wifi_driver_num_leds": 59, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "output_brightness": 255}, "2": {"name": "group2", "devices": ["188b0e1560a8"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "3": {"name": "group3", "devices": ["e8f60a16f288"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "4": {"name": "group4", "devices": ["e8f60a16e79c"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}, "5": {"name": "desk", "devices": ["188b0e1560a8"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": null}}
|
||||||
@@ -1 +1 @@
|
|||||||
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]}
|
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000", "#050500"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"], "13": []}
|
||||||
207
db/pattern.json
207
db/pattern.json
@@ -11,15 +11,13 @@
|
|||||||
"max_colors": 0,
|
"max_colors": 0,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"rainbow": {
|
|
||||||
"n1": "Step Rate",
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"max_colors": 0,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"colour_cycle": {
|
"colour_cycle": {
|
||||||
"n1": "Step Rate",
|
"supports_reverse": true,
|
||||||
|
"n1": "Step rate",
|
||||||
|
"mode": {
|
||||||
|
"0": "Scroll palette gradient",
|
||||||
|
"1": "Rainbow wheel (preset colours ignored)"
|
||||||
|
},
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"max_colors": 10,
|
"max_colors": 10,
|
||||||
@@ -32,6 +30,7 @@
|
|||||||
"supports_manual": false
|
"supports_manual": false
|
||||||
},
|
},
|
||||||
"chase": {
|
"chase": {
|
||||||
|
"supports_reverse": true,
|
||||||
"n1": "Colour 1 Length",
|
"n1": "Colour 1 Length",
|
||||||
"n2": "Colour 2 Length",
|
"n2": "Colour 2 Length",
|
||||||
"n3": "Step 1",
|
"n3": "Step 1",
|
||||||
@@ -40,7 +39,11 @@
|
|||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"max_colors": 2,
|
"max_colors": 2,
|
||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true,
|
||||||
|
"mode": {
|
||||||
|
"0": "Two-colour chase",
|
||||||
|
"1": "Marquee dashes (n1 on length, n2 off, n3 step)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"pulse": {
|
"pulse": {
|
||||||
"n1": "Attack",
|
"n1": "Attack",
|
||||||
@@ -80,7 +83,7 @@
|
|||||||
"flame": {
|
"flame": {
|
||||||
"n1": "Min brightness",
|
"n1": "Min brightness",
|
||||||
"n2": "Breath period (ms)",
|
"n2": "Breath period (ms)",
|
||||||
"n3": "Spark gap min (ms, 0=default 10–30 s, -1=off)",
|
"n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)",
|
||||||
"n4": "Spark gap max (ms)",
|
"n4": "Spark gap max (ms)",
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
@@ -88,8 +91,8 @@
|
|||||||
"supports_manual": false
|
"supports_manual": false
|
||||||
},
|
},
|
||||||
"twinkle": {
|
"twinkle": {
|
||||||
"n1": "Twinkle activity (1–255, higher = more changes)",
|
"n1": "Twinkle activity (1\u2013255, higher = more changes)",
|
||||||
"n2": "Density (0–255, higher = more of the strip lit)",
|
"n2": "Density (0\u2013255, higher = more of the strip lit)",
|
||||||
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
|
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
|
||||||
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
|
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
@@ -104,62 +107,10 @@
|
|||||||
"n3": "In time (ms)",
|
"n3": "In time (ms)",
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"max_colors": 2,
|
|
||||||
"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,
|
"max_colors": 10,
|
||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"gradient_scroll": {
|
|
||||||
"n1": "Scroll step rate",
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"max_colors": 10,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"comet_dual": {
|
|
||||||
"n1": "Tail length",
|
|
||||||
"n2": "Speed",
|
|
||||||
"n3": "Gap",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"sparkle_trail": {
|
|
||||||
"n1": "Spark density",
|
|
||||||
"n2": "Decay",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"wave": {
|
|
||||||
"n1": "Wavelength",
|
|
||||||
"n2": "Amplitude",
|
|
||||||
"n3": "Drift speed",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"supports_manual": false
|
|
||||||
},
|
|
||||||
"plasma": {
|
"plasma": {
|
||||||
"n1": "Scale",
|
"n1": "Scale",
|
||||||
"n2": "Speed",
|
"n2": "Speed",
|
||||||
@@ -169,17 +120,6 @@
|
|||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"supports_manual": false
|
"supports_manual": false
|
||||||
},
|
},
|
||||||
"segment_chase": {
|
|
||||||
"n1": "Segment size",
|
|
||||||
"n2": "Phase step",
|
|
||||||
"n3": "Segment phase offset",
|
|
||||||
"n4": "Gap per segment",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"bar_graph": {
|
"bar_graph": {
|
||||||
"n1": "Level percent",
|
"n1": "Level percent",
|
||||||
"max_colors": 10,
|
"max_colors": 10,
|
||||||
@@ -188,14 +128,6 @@
|
|||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": false
|
"supports_manual": false
|
||||||
},
|
},
|
||||||
"breathing_dual": {
|
|
||||||
"n1": "Phase offset",
|
|
||||||
"n2": "Ease",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"supports_manual": false
|
|
||||||
},
|
|
||||||
"strobe_burst": {
|
"strobe_burst": {
|
||||||
"n1": "Burst count",
|
"n1": "Burst count",
|
||||||
"n2": "Burst gap",
|
"n2": "Burst gap",
|
||||||
@@ -215,15 +147,6 @@
|
|||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"fireflies": {
|
|
||||||
"n1": "Count",
|
|
||||||
"n2": "Twinkle speed",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"has_background": true,
|
|
||||||
"supports_manual": true
|
|
||||||
},
|
|
||||||
"clock_sweep": {
|
"clock_sweep": {
|
||||||
"n1": "Hand width",
|
"n1": "Hand width",
|
||||||
"n2": "Marker interval",
|
"n2": "Marker interval",
|
||||||
@@ -233,37 +156,57 @@
|
|||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"marquee": {
|
"aurora": {
|
||||||
"n1": "On length",
|
"supports_reverse": true,
|
||||||
"n2": "Off length",
|
"n1": "Band count (0) or spatial period LEDs (1)",
|
||||||
"n3": "Step",
|
"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,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"icicles": {
|
||||||
|
"supports_reverse": true,
|
||||||
|
"n1": "Anchor spacing (LEDs)",
|
||||||
|
"n2": "Max icicle length (LEDs)",
|
||||||
|
"n3": "Phase step per refresh",
|
||||||
"max_colors": 10,
|
"max_colors": 10,
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"aurora": {
|
"blizzard": {
|
||||||
"n1": "Band count",
|
"supports_reverse": true,
|
||||||
"n2": "Shimmer",
|
|
||||||
"max_colors": 10,
|
|
||||||
"min_delay": 10,
|
|
||||||
"max_delay": 10000,
|
|
||||||
"supports_manual": false
|
|
||||||
},
|
|
||||||
"snowfall": {
|
|
||||||
"n1": "Flake density",
|
"n1": "Flake density",
|
||||||
"n2": "Fall speed",
|
"n2": "Fall speed",
|
||||||
|
"n3": "Wind (128 = centred; lower/raise for drift bias)",
|
||||||
"max_colors": 10,
|
"max_colors": 10,
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"has_background": true,
|
"has_background": true,
|
||||||
"supports_manual": true
|
"supports_manual": true
|
||||||
},
|
},
|
||||||
"heartbeat": {
|
"rime": {
|
||||||
"n1": "Pulse 1 ms",
|
"n1": "Crystallisation rate",
|
||||||
"n2": "Pulse 2 ms",
|
"n2": "Melt (decay) per refresh",
|
||||||
"n3": "Pause ms",
|
"n3": "Spark cap (LEDs refreshed per cycle)",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"candle_glow": {
|
||||||
|
"n1": "Candle count",
|
||||||
|
"n2": "Glow width (LEDs)",
|
||||||
|
"n3": "Flicker strength",
|
||||||
"max_colors": 10,
|
"max_colors": 10,
|
||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
@@ -287,5 +230,51 @@
|
|||||||
"min_delay": 10,
|
"min_delay": 10,
|
||||||
"max_delay": 10000,
|
"max_delay": 10000,
|
||||||
"supports_manual": false
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"meteor": {
|
||||||
|
"supports_reverse": true,
|
||||||
|
"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": {
|
||||||
|
"supports_reverse": true,
|
||||||
|
"n1": "Flake density (0) or spawn rate (1)",
|
||||||
|
"n2": "Fall speed (LEDs per frame)",
|
||||||
|
"n3": "Unused (0) or streak length (1)",
|
||||||
|
"mode": {
|
||||||
|
"0": "Snowfall flakes",
|
||||||
|
"1": "Starfall streaks"
|
||||||
|
},
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"sparkle": {
|
||||||
|
"n1": "Spark density (0–1) or firefly count (2)",
|
||||||
|
"n2": "Trail decay (0) or twinkle speed (2)",
|
||||||
|
"n3": "Ice halo width LEDs (1); unused in 0 and 2",
|
||||||
|
"mode": {
|
||||||
|
"0": "Sparkle trail",
|
||||||
|
"1": "Ice burst + halo",
|
||||||
|
"2": "Fireflies"
|
||||||
|
},
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"1": {"name": "default", "type": "zones", "zones": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}}
|
{"1": {"name": "default", "type": "zones", "zones": ["1", "9", "8", "10"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}}
|
||||||
@@ -1 +1 @@
|
|||||||
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "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}}
|
{"1": {"name": "Pulse (manual)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "6", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}, "2": {"name": "Off (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "2", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "3": {"name": "On (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "1", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "1", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "4": {"name": "Rainbow \u2192 transition \u2192 off", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 100, "sequence_transition": 500, "loop": true}, "5": {"name": "Manual pulse + chase", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "6": {"name": "RGB solid cycle", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 90, "sequence_transition": 500, "loop": true}, "7": {"name": "Winter trio", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 80, "sequence_transition": 500, "loop": true}, "8": {"name": "Fast rainbow", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "1", "beats": 1}, {"preset_id": "3", "beats": 4}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "1", "beats": 1}, {"preset_id": "3", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 180, "sequence_transition": 500, "loop": true}, "9": {"name": "Off then on", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "10": {"name": "Twinkle + flame", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "41", "beats": 6}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "41", "beats": 6}], "step_duration_ms": 3000, "simulated_bpm": 110, "sequence_transition": 500, "loop": true}, "11": {"name": "radiate chase", "profile_id": "1", "group_ids": [], "lanes": [[{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}]], "lanes_group_ids": [[]], "advance_mode": "beats", "steps": [{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}}
|
||||||
Submodule led-driver updated: 2a768376d0...85490a3bd0
2
led-tool
2
led-tool
Submodule led-tool updated: 580fd11aca...f74e21f206
@@ -1,3 +1,5 @@
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
python_files = ["test_endpoints_pytest.py"]
|
python_files = ["test_*.py"]
|
||||||
|
# ``tests/models/`` is a package name clash with ``src/models``; run via tests/models/run_all.py
|
||||||
|
norecursedirs = ["models"]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from models.device import (
|
|||||||
)
|
)
|
||||||
from models.group import Group
|
from models.group import Group
|
||||||
from models.transport import get_current_sender
|
from models.transport import get_current_sender
|
||||||
from settings import Settings
|
from settings import get_settings
|
||||||
from util.brightness_combine import effective_brightness_for_mac
|
from util.brightness_combine import effective_brightness_for_mac
|
||||||
from models.wifi_ws_clients import (
|
from models.wifi_ws_clients import (
|
||||||
normalize_tcp_peer_ip,
|
normalize_tcp_peer_ip,
|
||||||
@@ -77,7 +77,7 @@ def _brightness_save_message_json(b_val: int) -> str:
|
|||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
devices = Device()
|
devices = Device()
|
||||||
_group_registry = Group()
|
_group_registry = Group()
|
||||||
_pi_settings = Settings()
|
_pi_settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
def _device_live_connected(dev_dict):
|
def _device_live_connected(dev_dict):
|
||||||
@@ -167,6 +167,107 @@ async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, nam
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
|
||||||
|
"""
|
||||||
|
Send the same identify blink as ``POST /devices/<id>/identify``.
|
||||||
|
|
||||||
|
Returns ``(http_status, "")`` on success, or ``(status, error_message)`` on failure
|
||||||
|
(status matches the single-device route).
|
||||||
|
"""
|
||||||
|
dev = devices.read(dev_id)
|
||||||
|
if not dev:
|
||||||
|
return 404, "Device not found"
|
||||||
|
sender = get_current_sender()
|
||||||
|
if not sender:
|
||||||
|
return 503, "Transport not configured"
|
||||||
|
name = str(dev.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return 400, "Device must have a name to identify"
|
||||||
|
|
||||||
|
transport = dev.get("transport") or "espnow"
|
||||||
|
wifi_ip = None
|
||||||
|
if transport == "wifi":
|
||||||
|
wifi_ip = dev.get("address")
|
||||||
|
if not wifi_ip:
|
||||||
|
return 400, "Device has no IP address"
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = _compact_v1_json(
|
||||||
|
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||||
|
select={name: [_IDENTIFY_PRESET_KEY]},
|
||||||
|
)
|
||||||
|
if transport == "wifi":
|
||||||
|
ok = await send_json_line_to_ip(wifi_ip, msg)
|
||||||
|
if not ok:
|
||||||
|
return 503, "Wi-Fi driver not connected"
|
||||||
|
else:
|
||||||
|
await sender.send(msg, addr=dev_id)
|
||||||
|
|
||||||
|
asyncio.create_task(
|
||||||
|
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return 503, str(e)
|
||||||
|
return 200, ""
|
||||||
|
|
||||||
|
|
||||||
|
async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dict]]:
|
||||||
|
"""
|
||||||
|
Identify every listed registry MAC in one delivery round: merged ``select`` and a single
|
||||||
|
ESP-NOW split envelope when multiple peers share the serial bridge (avoids per-device
|
||||||
|
``SerialSender`` lock serialisation). Wi-Fi peers are sent in parallel as in
|
||||||
|
``deliver_json_messages``.
|
||||||
|
"""
|
||||||
|
from util.driver_delivery import deliver_json_messages
|
||||||
|
|
||||||
|
errors: list[dict] = []
|
||||||
|
sender = get_current_sender()
|
||||||
|
if not sender:
|
||||||
|
return 0, [{"mac": "*", "error": "Transport not configured"}]
|
||||||
|
|
||||||
|
merged_select: dict[str, list[str]] = {}
|
||||||
|
valid_macs: list[str] = []
|
||||||
|
for dev_id in macs:
|
||||||
|
dev = devices.read(dev_id)
|
||||||
|
if not dev:
|
||||||
|
errors.append({"mac": dev_id, "error": "Device not found"})
|
||||||
|
continue
|
||||||
|
name = str(dev.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
errors.append({"mac": dev_id, "error": "Device must have a name to identify"})
|
||||||
|
continue
|
||||||
|
transport = (dev.get("transport") or "espnow").strip().lower()
|
||||||
|
if transport == "wifi":
|
||||||
|
if not dev.get("address"):
|
||||||
|
errors.append({"mac": dev_id, "error": "Device has no IP address"})
|
||||||
|
continue
|
||||||
|
merged_select[name] = [_IDENTIFY_PRESET_KEY]
|
||||||
|
valid_macs.append(dev_id)
|
||||||
|
|
||||||
|
if not merged_select:
|
||||||
|
return 0, errors
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = _compact_v1_json(
|
||||||
|
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||||
|
select=merged_select,
|
||||||
|
)
|
||||||
|
await deliver_json_messages(sender, [msg], valid_macs, devices, delay_s=0)
|
||||||
|
except Exception as e:
|
||||||
|
return 0, errors + [{"mac": "*", "error": str(e)}]
|
||||||
|
|
||||||
|
for dev_id in valid_macs:
|
||||||
|
dev = devices.read(dev_id) or {}
|
||||||
|
name = str(dev.get("name") or "").strip()
|
||||||
|
transport = (dev.get("transport") or "espnow").strip().lower()
|
||||||
|
wifi_ip = dev.get("address") if transport == "wifi" else None
|
||||||
|
asyncio.create_task(
|
||||||
|
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
|
||||||
|
)
|
||||||
|
|
||||||
|
return len(valid_macs), errors
|
||||||
|
|
||||||
|
|
||||||
@controller.get("")
|
@controller.get("")
|
||||||
async def list_devices(request):
|
async def list_devices(request):
|
||||||
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
|
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
|
||||||
@@ -341,53 +442,12 @@ async def identify_device(request, id):
|
|||||||
this device name — same combined shape as profile sends the driver already accepts over TCP
|
this device name — same combined shape as profile sends the driver already accepts over TCP
|
||||||
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
|
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
|
||||||
"""
|
"""
|
||||||
dev = devices.read(id)
|
status, err = await send_identify_to_device(id)
|
||||||
if not dev:
|
if status == 200:
|
||||||
return json.dumps({"error": "Device not found"}), 404, {
|
return json.dumps({"message": "Identify sent"}), 200, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
sender = get_current_sender()
|
return json.dumps({"error": err}), status, {"Content-Type": "application/json"}
|
||||||
if not sender:
|
|
||||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
name = str(dev.get("name") or "").strip()
|
|
||||||
if not name:
|
|
||||||
return json.dumps({"error": "Device must have a name to identify"}), 400, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
transport = dev.get("transport") or "espnow"
|
|
||||||
wifi_ip = None
|
|
||||||
if transport == "wifi":
|
|
||||||
wifi_ip = dev.get("address")
|
|
||||||
if not wifi_ip:
|
|
||||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg = _compact_v1_json(
|
|
||||||
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
|
||||||
select={name: [_IDENTIFY_PRESET_KEY]},
|
|
||||||
)
|
|
||||||
if transport == "wifi":
|
|
||||||
ok = await send_json_line_to_ip(wifi_ip, msg)
|
|
||||||
if not ok:
|
|
||||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
await sender.send(msg, addr=id)
|
|
||||||
|
|
||||||
asyncio.create_task(
|
|
||||||
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
|
||||||
return json.dumps({"message": "Identify sent"}), 200, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@controller.post("/<id>/brightness")
|
@controller.post("/<id>/brightness")
|
||||||
|
|||||||
@@ -1,57 +1,140 @@
|
|||||||
from microdot import Microdot
|
from microdot import Microdot
|
||||||
|
from microdot.session import with_session
|
||||||
|
import asyncio
|
||||||
from models.group import Group
|
from models.group import Group
|
||||||
from models.device import Device
|
from models.device import Device
|
||||||
from models.transport import get_current_sender
|
from models.transport import get_current_sender
|
||||||
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
|
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
|
||||||
from settings import Settings
|
from settings import get_settings
|
||||||
from util.brightness_combine import effective_brightness_for_mac
|
from util.brightness_combine import effective_brightness_for_mac
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
groups = Group()
|
groups = Group()
|
||||||
devices = Device()
|
devices = Device()
|
||||||
_pi_settings = Settings()
|
_pi_settings = get_settings()
|
||||||
|
|
||||||
@controller.get('')
|
|
||||||
async def list_groups(request):
|
|
||||||
"""List all groups."""
|
|
||||||
return json.dumps(groups), 200, {'Content-Type': 'application/json'}
|
|
||||||
|
|
||||||
@controller.get('/<id>')
|
def _group_doc_visible_for_profile(doc, profile_id):
|
||||||
async def get_group(request, id):
|
if not isinstance(doc, dict):
|
||||||
"""Get a specific group by ID."""
|
return False
|
||||||
|
scoped = doc.get("profile_id")
|
||||||
|
if scoped is None:
|
||||||
|
scoped = doc.get("profileId")
|
||||||
|
if scoped is None or str(scoped).strip() == "":
|
||||||
|
return True
|
||||||
|
if not profile_id:
|
||||||
|
return False
|
||||||
|
return str(scoped).strip() == str(profile_id).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _filtered_groups_dict(session):
|
||||||
|
from controllers.zone import get_current_profile_id
|
||||||
|
|
||||||
|
pid = get_current_profile_id(session)
|
||||||
|
out = {}
|
||||||
|
for gid, doc in groups.items():
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
continue
|
||||||
|
if _group_doc_visible_for_profile(doc, pid):
|
||||||
|
out[str(gid)] = doc
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("")
|
||||||
|
@with_session
|
||||||
|
async def list_groups(request, session):
|
||||||
|
"""List groups visible for the current profile (shared + profile-scoped)."""
|
||||||
|
return json.dumps(_filtered_groups_dict(session)), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/<id>")
|
||||||
|
@with_session
|
||||||
|
async def get_group(request, session, id):
|
||||||
|
"""Get a specific group by ID (404 if scoped to another profile)."""
|
||||||
group = groups.read(id)
|
group = groups.read(id)
|
||||||
if group:
|
if not group or not isinstance(group, dict):
|
||||||
return json.dumps(group), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"error": "Group not found"}), 404
|
||||||
return json.dumps({"error": "Group not found"}), 404
|
from controllers.zone import get_current_profile_id
|
||||||
|
|
||||||
@controller.post('')
|
if not _group_doc_visible_for_profile(group, get_current_profile_id(session)):
|
||||||
async def create_group(request):
|
return json.dumps({"error": "Group not found"}), 404
|
||||||
"""Create a new group."""
|
return json.dumps(group), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_group_profile_id_write(data, session):
|
||||||
|
"""Allow ``profile_id`` only for the active profile, or null to share across profiles."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
from controllers.zone import get_current_profile_id
|
||||||
|
|
||||||
|
cur = get_current_profile_id(session)
|
||||||
|
if "profile_id" not in data and "profileId" not in data:
|
||||||
|
return
|
||||||
|
raw = data.get("profile_id")
|
||||||
|
if raw is None and "profileId" in data:
|
||||||
|
raw = data.get("profileId")
|
||||||
|
if raw is None or raw == "":
|
||||||
|
data.pop("profileId", None)
|
||||||
|
data["profile_id"] = None
|
||||||
|
return
|
||||||
|
if not cur or str(raw).strip() != str(cur).strip():
|
||||||
|
data.pop("profileId", None)
|
||||||
|
data.pop("profile_id", None)
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("")
|
||||||
|
@with_session
|
||||||
|
async def create_group(request, session):
|
||||||
|
"""Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only)."""
|
||||||
try:
|
try:
|
||||||
data = request.json or {}
|
data = dict(request.json or {})
|
||||||
name = data.get("name", "")
|
name = data.get("name", "")
|
||||||
|
profile_scoped = bool(data.pop("profile_scoped", False))
|
||||||
|
_sanitize_group_profile_id_write(data, session)
|
||||||
group_id = groups.create(name)
|
group_id = groups.create(name)
|
||||||
if data:
|
if data:
|
||||||
groups.update(group_id, data)
|
groups.update(group_id, data)
|
||||||
return json.dumps(groups.read(group_id)), 201, {'Content-Type': 'application/json'}
|
if profile_scoped:
|
||||||
|
from controllers.zone import get_current_profile_id
|
||||||
|
|
||||||
|
cur = get_current_profile_id(session)
|
||||||
|
if cur:
|
||||||
|
groups.update(group_id, {"profile_id": str(cur)})
|
||||||
|
return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.put('/<id>')
|
|
||||||
async def update_group(request, id):
|
@controller.put("/<id>")
|
||||||
|
@with_session
|
||||||
|
async def update_group(request, session, id):
|
||||||
"""Update an existing group."""
|
"""Update an existing group."""
|
||||||
try:
|
try:
|
||||||
data = request.json
|
data = request.json
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"}
|
||||||
|
data = dict(data)
|
||||||
|
_sanitize_group_profile_id_write(data, session)
|
||||||
if groups.update(id, data):
|
if groups.update(id, data):
|
||||||
return json.dumps(groups.read(id)), 200, {'Content-Type': 'application/json'}
|
g = groups.read(id)
|
||||||
|
if g:
|
||||||
|
return json.dumps(g), 200, {"Content-Type": "application/json"}
|
||||||
return json.dumps({"error": "Group not found"}), 404
|
return json.dumps({"error": "Group not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.delete('/<id>')
|
@controller.delete("/<id>")
|
||||||
async def delete_group(request, id):
|
@with_session
|
||||||
"""Delete a group."""
|
async def delete_group(request, session, id):
|
||||||
|
"""Delete a group (not allowed for another profile's scoped group)."""
|
||||||
|
g = groups.read(id)
|
||||||
|
if not g or not isinstance(g, dict):
|
||||||
|
return json.dumps({"error": "Group not found"}), 404
|
||||||
|
from controllers.zone import get_current_profile_id
|
||||||
|
|
||||||
|
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
|
||||||
|
return json.dumps({"error": "Group not found"}), 404
|
||||||
if groups.delete(id):
|
if groups.delete(id):
|
||||||
return json.dumps({"message": "Group deleted successfully"}), 200
|
return json.dumps({"message": "Group deleted successfully"}), 200
|
||||||
return json.dumps({"error": "Group not found"}), 404
|
return json.dumps({"error": "Group not found"}), 404
|
||||||
@@ -86,13 +169,25 @@ def _group_driver_config_payload(doc):
|
|||||||
return dc
|
return dc
|
||||||
|
|
||||||
|
|
||||||
@controller.post('/<id>/driver-config')
|
def _read_group_for_session(session, id):
|
||||||
async def push_group_driver_config(request, id):
|
g = groups.read(id)
|
||||||
|
if not g or not isinstance(g, dict):
|
||||||
|
return None
|
||||||
|
from controllers.zone import get_current_profile_id
|
||||||
|
|
||||||
|
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
|
||||||
|
return None
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/<id>/driver-config")
|
||||||
|
@with_session
|
||||||
|
async def push_group_driver_config(request, session, id):
|
||||||
"""
|
"""
|
||||||
Push group Wi‑Fi defaults to every Wi‑Fi device listed in the group (TCP WebSocket).
|
Push group Wi‑Fi defaults to every Wi‑Fi device listed in the group (TCP WebSocket).
|
||||||
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
|
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
|
||||||
"""
|
"""
|
||||||
gdoc = groups.read(id)
|
gdoc = _read_group_for_session(session, id)
|
||||||
if not gdoc:
|
if not gdoc:
|
||||||
return json.dumps({"error": "Group not found"}), 404
|
return json.dumps({"error": "Group not found"}), 404
|
||||||
|
|
||||||
@@ -116,6 +211,11 @@ async def push_group_driver_config(request, id):
|
|||||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||||
sent = 0
|
sent = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
msg = json.dumps(
|
||||||
|
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
|
||||||
|
)
|
||||||
|
tasks = []
|
||||||
|
meta_macs = []
|
||||||
for mac in mac_list:
|
for mac in mac_list:
|
||||||
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||||
if len(m) != 12:
|
if len(m) != 12:
|
||||||
@@ -130,14 +230,17 @@ async def push_group_driver_config(request, id):
|
|||||||
if not ip:
|
if not ip:
|
||||||
errors.append({"mac": m, "error": "no IP"})
|
errors.append({"mac": m, "error": "no IP"})
|
||||||
continue
|
continue
|
||||||
msg = json.dumps(
|
tasks.append(send_json_line_to_ip(ip, msg))
|
||||||
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
|
meta_macs.append(m)
|
||||||
)
|
if tasks:
|
||||||
ok = await send_json_line_to_ip(ip, msg)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
if ok:
|
for m, r in zip(meta_macs, results):
|
||||||
sent += 1
|
if r is True:
|
||||||
else:
|
sent += 1
|
||||||
errors.append({"mac": m, "error": "driver not connected"})
|
elif isinstance(r, Exception):
|
||||||
|
errors.append({"mac": m, "error": str(r)})
|
||||||
|
else:
|
||||||
|
errors.append({"mac": m, "error": "driver not connected"})
|
||||||
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{"message": "driver-config sent", "sent": sent, "errors": errors}
|
{"message": "driver-config sent", "sent": sent, "errors": errors}
|
||||||
@@ -149,26 +252,22 @@ def _brightness_save_message_json(b_val: int) -> str:
|
|||||||
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
|
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
@controller.post('/<id>/brightness')
|
@controller.post("/<id>/brightness")
|
||||||
async def push_group_output_brightness(request, id):
|
@with_session
|
||||||
|
async def push_group_output_brightness(request, session, id):
|
||||||
"""
|
"""
|
||||||
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
|
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
|
||||||
"""
|
"""
|
||||||
gdoc = groups.read(id)
|
gdoc = _read_group_for_session(session, id)
|
||||||
if not gdoc:
|
if not gdoc:
|
||||||
return json.dumps({"error": "Group not found"}), 404
|
return json.dumps({"error": "Group not found"}), 404
|
||||||
|
|
||||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||||
sent = 0
|
sent = 0
|
||||||
errors = []
|
errors = []
|
||||||
for mac in mac_list:
|
sender = get_current_sender()
|
||||||
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
|
||||||
if len(m) != 12:
|
async def _push_brightness_one(m: str, dev: dict) -> tuple[str, bool, str | None]:
|
||||||
continue
|
|
||||||
dev = devices.read(m)
|
|
||||||
if not dev:
|
|
||||||
errors.append({"mac": m, "error": "not in registry"})
|
|
||||||
continue
|
|
||||||
b_val = effective_brightness_for_mac(
|
b_val = effective_brightness_for_mac(
|
||||||
_pi_settings,
|
_pi_settings,
|
||||||
groups,
|
groups,
|
||||||
@@ -181,24 +280,80 @@ async def push_group_output_brightness(request, id):
|
|||||||
if transport == "wifi":
|
if transport == "wifi":
|
||||||
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
|
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
|
||||||
if not ip:
|
if not ip:
|
||||||
errors.append({"mac": m, "error": "no IP"})
|
return m, False, "no IP"
|
||||||
continue
|
|
||||||
ok = await send_json_line_to_ip(ip, msg)
|
ok = await send_json_line_to_ip(ip, msg)
|
||||||
|
return m, bool(ok), None if ok else "driver not connected"
|
||||||
|
if not sender:
|
||||||
|
return m, False, "transport not configured"
|
||||||
|
try:
|
||||||
|
await sender.send(msg, addr=m)
|
||||||
|
return m, True, None
|
||||||
|
except Exception as e:
|
||||||
|
return m, False, str(e)
|
||||||
|
|
||||||
|
tasks: list = []
|
||||||
|
for mac in mac_list:
|
||||||
|
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||||
|
if len(m) != 12:
|
||||||
|
continue
|
||||||
|
dev = devices.read(m)
|
||||||
|
if not dev:
|
||||||
|
errors.append({"mac": m, "error": "not in registry"})
|
||||||
|
continue
|
||||||
|
tasks.append(_push_brightness_one(m, dev))
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
for r in results:
|
||||||
|
if isinstance(r, Exception):
|
||||||
|
errors.append({"mac": "*", "error": str(r)})
|
||||||
|
continue
|
||||||
|
m, ok, err = r
|
||||||
if ok:
|
if ok:
|
||||||
sent += 1
|
sent += 1
|
||||||
else:
|
elif err:
|
||||||
errors.append({"mac": m, "error": "driver not connected"})
|
errors.append({"mac": m, "error": err})
|
||||||
else:
|
|
||||||
sender = get_current_sender()
|
|
||||||
if not sender:
|
|
||||||
errors.append({"mac": m, "error": "transport not configured"})
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await sender.send(msg, addr=m)
|
|
||||||
sent += 1
|
|
||||||
except Exception as e:
|
|
||||||
errors.append({"mac": m, "error": str(e)})
|
|
||||||
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{"message": "brightness sent", "sent": sent, "errors": errors}
|
{"message": "brightness sent", "sent": sent, "errors": errors}
|
||||||
), 200, {"Content-Type": "application/json"}
|
), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/<id>/identify")
|
||||||
|
@with_session
|
||||||
|
async def identify_group_devices(request, session, id):
|
||||||
|
"""
|
||||||
|
Run the same identify blink as ``POST /devices/<id>/identify`` for every registry member
|
||||||
|
in parallel so all drivers in the group blink together.
|
||||||
|
"""
|
||||||
|
_ = request
|
||||||
|
gdoc = _read_group_for_session(session, id)
|
||||||
|
if not gdoc:
|
||||||
|
return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||||
|
if not mac_list:
|
||||||
|
return json.dumps({"error": "Group has no devices"}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
from controllers.device import send_identify_to_group_devices
|
||||||
|
|
||||||
|
normalized: list[str] = []
|
||||||
|
errors: list[dict] = []
|
||||||
|
for mac in mac_list:
|
||||||
|
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||||
|
if len(m) != 12:
|
||||||
|
errors.append({"mac": str(mac), "error": "invalid MAC"})
|
||||||
|
continue
|
||||||
|
normalized.append(m)
|
||||||
|
|
||||||
|
if not normalized:
|
||||||
|
return json.dumps(
|
||||||
|
{"message": "identify group done", "sent": 0, "errors": errors}
|
||||||
|
), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
sent, batch_errors = await send_identify_to_group_devices(normalized)
|
||||||
|
errors.extend(batch_errors)
|
||||||
|
|
||||||
|
return json.dumps(
|
||||||
|
{"message": "identify group done", "sent": sent, "errors": errors}
|
||||||
|
), 200, {"Content-Type": "application/json"}
|
||||||
|
|||||||
@@ -3,20 +3,40 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from microdot import Microdot
|
from microdot import Microdot, send_file
|
||||||
from serial.tools import list_ports
|
from serial.tools import list_ports
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
|
|
||||||
|
_STATIC_ALLOWED = frozenset(
|
||||||
|
{"settings_editor.html", "settings_editor.js", "web_serial.js"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _repo_root() -> str:
|
def _repo_root() -> str:
|
||||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
|
||||||
|
def _led_tool_static_dir() -> str:
|
||||||
|
return os.path.join(_repo_root(), "led-tool", "static")
|
||||||
|
|
||||||
|
|
||||||
def _led_cli_path() -> str:
|
def _led_cli_path() -> str:
|
||||||
return os.path.join(_repo_root(), "led-tool", "cli.py")
|
return os.path.join(_repo_root(), "led-tool", "cli.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_host_serial_ports(ports: list) -> list:
|
||||||
|
mod_path = os.path.join(_repo_root(), "led-tool", "host_ports.py")
|
||||||
|
if not os.path.isfile(mod_path):
|
||||||
|
return ports
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("led_tool_host_ports", mod_path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod.filter_port_dicts(ports)
|
||||||
|
|
||||||
|
|
||||||
def _build_led_cli_command(port: str, payload: dict):
|
def _build_led_cli_command(port: str, payload: dict):
|
||||||
cmd = [sys.executable, _led_cli_path(), "--port", port]
|
cmd = [sys.executable, _led_cli_path(), "--port", port]
|
||||||
|
|
||||||
@@ -92,17 +112,41 @@ def _extract_settings_from_stdout(stdout: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/editor")
|
||||||
|
async def settings_editor_page(request):
|
||||||
|
"""led-tool settings UI (Web Serial + host serial via led-cli)."""
|
||||||
|
path = os.path.join(_led_tool_static_dir(), "settings_editor.html")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "led-tool/static/settings_editor.html not found"}),
|
||||||
|
404,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return send_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/static/<path:filename>")
|
||||||
|
async def led_tool_static(request, filename):
|
||||||
|
if filename not in _STATIC_ALLOWED:
|
||||||
|
return "Not found", 404
|
||||||
|
path = os.path.join(_led_tool_static_dir(), filename)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return "Not found", 404
|
||||||
|
return send_file(path)
|
||||||
|
|
||||||
|
|
||||||
@controller.get("/ports")
|
@controller.get("/ports")
|
||||||
async def list_serial_ports(request):
|
async def list_serial_ports(request):
|
||||||
ports = []
|
ports = _filter_host_serial_ports(
|
||||||
for info in list_ports.comports():
|
[
|
||||||
ports.append(
|
|
||||||
{
|
{
|
||||||
"device": info.device,
|
"device": info.device,
|
||||||
"description": info.description,
|
"description": info.description,
|
||||||
"hwid": info.hwid,
|
"hwid": info.hwid,
|
||||||
}
|
}
|
||||||
)
|
for info in list_ports.comports()
|
||||||
|
]
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,16 +2,30 @@ from microdot import Microdot
|
|||||||
from microdot.session import with_session
|
from microdot.session import with_session
|
||||||
from models.preset import Preset
|
from models.preset import Preset
|
||||||
from models.profile import Profile
|
from models.profile import Profile
|
||||||
|
from models.pallet import Palette
|
||||||
from models.device import Device, normalize_mac
|
from models.device import Device, normalize_mac
|
||||||
from models.transport import get_current_sender
|
from models.transport import get_current_sender
|
||||||
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
||||||
from util.espnow_message import build_message, build_preset_dict
|
from util.espnow_message import build_message, build_preset_dict
|
||||||
|
from util.profile_bundle import export_preset_bundle, import_preset_bundle
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
presets = Preset()
|
presets = Preset()
|
||||||
profiles = Profile()
|
profiles = Profile()
|
||||||
|
|
||||||
|
|
||||||
|
def _palette_colors_for_profile(profile_id):
|
||||||
|
prof = profiles.read(str(profile_id))
|
||||||
|
if not isinstance(prof, dict):
|
||||||
|
return None
|
||||||
|
pid = prof.get("palette_id") or prof.get("paletteId")
|
||||||
|
if not pid:
|
||||||
|
return None
|
||||||
|
cols = Palette().read(str(pid))
|
||||||
|
return cols if isinstance(cols, list) else None
|
||||||
|
|
||||||
|
|
||||||
def get_current_profile_id(session=None):
|
def get_current_profile_id(session=None):
|
||||||
"""Get the current active profile ID from session or fallback to first."""
|
"""Get the current active profile ID from session or fallback to first."""
|
||||||
profile_list = profiles.list()
|
profile_list = profiles.list()
|
||||||
@@ -37,6 +51,41 @@ async def list_presets(request, session):
|
|||||||
}
|
}
|
||||||
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('/<preset_id>/export')
|
||||||
|
@with_session
|
||||||
|
async def export_preset(request, session, preset_id):
|
||||||
|
"""Export one preset as a JSON bundle."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
preset = presets.read(preset_id)
|
||||||
|
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||||
|
return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'}
|
||||||
|
try:
|
||||||
|
bundle = export_preset_bundle(preset_id, presets)
|
||||||
|
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post('/import')
|
||||||
|
@with_session
|
||||||
|
async def import_preset(request, session):
|
||||||
|
"""Import a preset bundle into the current profile."""
|
||||||
|
try:
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'}
|
||||||
|
body = request.json or {}
|
||||||
|
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
|
||||||
|
return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
@controller.get('/<preset_id>')
|
@controller.get('/<preset_id>')
|
||||||
@with_session
|
@with_session
|
||||||
async def get_preset(request, session, preset_id):
|
async def get_preset(request, session, preset_id):
|
||||||
@@ -153,6 +202,7 @@ async def send_presets(request, session):
|
|||||||
|
|
||||||
# Build API-compliant preset map keyed by preset ID, include name
|
# Build API-compliant preset map keyed by preset ID, include name
|
||||||
current_profile_id = get_current_profile_id(session)
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
palette_colors = _palette_colors_for_profile(current_profile_id)
|
||||||
presets_by_name = {}
|
presets_by_name = {}
|
||||||
for pid in preset_ids:
|
for pid in preset_ids:
|
||||||
preset_data = presets.read(str(pid))
|
preset_data = presets.read(str(pid))
|
||||||
@@ -161,7 +211,7 @@ async def send_presets(request, session):
|
|||||||
if str(preset_data.get("profile_id")) != str(current_profile_id):
|
if str(preset_data.get("profile_id")) != str(current_profile_id):
|
||||||
continue
|
continue
|
||||||
preset_key = str(pid)
|
preset_key = str(pid)
|
||||||
preset_payload = build_preset_dict(preset_data)
|
preset_payload = build_preset_dict(preset_data, palette_colors)
|
||||||
preset_payload["name"] = preset_data.get("name", "")
|
preset_payload["name"] = preset_data.get("name", "")
|
||||||
presets_by_name[preset_key] = preset_payload
|
presets_by_name[preset_key] = preset_payload
|
||||||
|
|
||||||
@@ -316,9 +366,13 @@ async def push_driver_messages(request, session):
|
|||||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from util import sequence_playback as seq_pb
|
||||||
from util.beat_driver_route import sync_beat_route_from_push_sequence
|
from util.beat_driver_route import sync_beat_route_from_push_sequence
|
||||||
|
|
||||||
sync_beat_route_from_push_sequence(seq, target_macs=target_list)
|
preserve = bool(seq_pb.playback_status().get("active"))
|
||||||
|
sync_beat_route_from_push_sequence(
|
||||||
|
seq, target_macs=target_list, preserve_parallel_lane_routes=preserve
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ from microdot.session import with_session
|
|||||||
from models.profile import Profile
|
from models.profile import Profile
|
||||||
from models.zone import Zone
|
from models.zone import Zone
|
||||||
from models.preset import Preset
|
from models.preset import Preset
|
||||||
|
from models.sequence import Sequence
|
||||||
|
from util.profile_bundle import export_profile_bundle, import_profile_bundle
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
profiles = Profile()
|
profiles = Profile()
|
||||||
zones = Zone()
|
zones = Zone()
|
||||||
presets = Preset()
|
presets = Preset()
|
||||||
|
sequences = Sequence()
|
||||||
|
|
||||||
@controller.get('')
|
@controller.get('')
|
||||||
@with_session
|
@with_session
|
||||||
@@ -54,18 +57,64 @@ async def get_current_profile(request, session):
|
|||||||
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "No profile available"}), 404
|
return json.dumps({"error": "No profile available"}), 404
|
||||||
|
|
||||||
@controller.get('/<id>')
|
|
||||||
|
@controller.post('/import')
|
||||||
@with_session
|
@with_session
|
||||||
async def get_profile(request, id, session):
|
async def import_profile(request, session):
|
||||||
"""Get a specific profile by ID."""
|
"""Import a profile bundle (optionally apply as current profile)."""
|
||||||
# Handle 'current' as a special case
|
try:
|
||||||
if id == 'current':
|
body = request.json or {}
|
||||||
return await get_current_profile(request, session)
|
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
profile = profiles.read(id)
|
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
|
||||||
if profile:
|
name = body.get("name") if isinstance(body, dict) else None
|
||||||
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
apply_raw = body.get("apply", True) if isinstance(body, dict) else True
|
||||||
return json.dumps({"error": "Profile not found"}), 404
|
if isinstance(apply_raw, str):
|
||||||
|
apply = apply_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
else:
|
||||||
|
apply = bool(apply_raw)
|
||||||
|
|
||||||
|
new_profile_id, profile_data = import_profile_bundle(
|
||||||
|
bundle,
|
||||||
|
profiles,
|
||||||
|
zones,
|
||||||
|
presets,
|
||||||
|
sequences,
|
||||||
|
profiles._palette_model,
|
||||||
|
name=str(name).strip() if name else None,
|
||||||
|
)
|
||||||
|
if apply:
|
||||||
|
session['current_profile'] = str(new_profile_id)
|
||||||
|
session.save()
|
||||||
|
return (
|
||||||
|
json.dumps({new_profile_id: profile_data, "id": new_profile_id}),
|
||||||
|
201,
|
||||||
|
{'Content-Type': 'application/json'},
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get('/<id>/export')
|
||||||
|
async def export_profile(request, id):
|
||||||
|
"""Export profile, zones, presets, sequences, and palette as a JSON bundle."""
|
||||||
|
try:
|
||||||
|
bundle = export_profile_bundle(
|
||||||
|
str(id),
|
||||||
|
profiles,
|
||||||
|
zones,
|
||||||
|
presets,
|
||||||
|
sequences,
|
||||||
|
profiles._palette_model,
|
||||||
|
)
|
||||||
|
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
@controller.post('/<id>/apply')
|
@controller.post('/<id>/apply')
|
||||||
@with_session
|
@with_session
|
||||||
@@ -77,167 +126,6 @@ async def apply_profile(request, session, id):
|
|||||||
session.save()
|
session.save()
|
||||||
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@controller.post('')
|
|
||||||
async def create_profile(request):
|
|
||||||
"""Create a new profile."""
|
|
||||||
try:
|
|
||||||
data = dict(request.json or {})
|
|
||||||
name = data.get("name", "")
|
|
||||||
seed_raw = data.get("seed_dj_zone", False)
|
|
||||||
if isinstance(seed_raw, str):
|
|
||||||
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
else:
|
|
||||||
seed_dj_zone = bool(seed_raw)
|
|
||||||
# Request-only flag: do not persist on profile records.
|
|
||||||
data.pop("seed_dj_zone", None)
|
|
||||||
profile_id = profiles.create(name)
|
|
||||||
# Avoid persisting request-only fields.
|
|
||||||
data.pop("name", None)
|
|
||||||
if data:
|
|
||||||
profiles.update(profile_id, data)
|
|
||||||
|
|
||||||
# New profiles always start with a default zone pre-populated with starter presets.
|
|
||||||
default_preset_ids = []
|
|
||||||
default_preset_defs = [
|
|
||||||
{
|
|
||||||
"name": "on",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#FFFFFF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "off",
|
|
||||||
"pattern": "off",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 0,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rainbow",
|
|
||||||
"pattern": "rainbow",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Colour Cycle",
|
|
||||||
"pattern": "colour_cycle",
|
|
||||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "transition",
|
|
||||||
"pattern": "transition",
|
|
||||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 500,
|
|
||||||
"auto": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flicker",
|
|
||||||
"pattern": "flicker",
|
|
||||||
"colors": ["#FFB84D"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 80,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flame",
|
|
||||||
"pattern": "flame",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 50,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 35,
|
|
||||||
"n2": 2600,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "twinkle",
|
|
||||||
"pattern": "twinkle",
|
|
||||||
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 55,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 72,
|
|
||||||
"n2": 140,
|
|
||||||
"n3": 2,
|
|
||||||
"n4": 6,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for preset_data in default_preset_defs:
|
|
||||||
pid = presets.create(profile_id)
|
|
||||||
presets.update(pid, preset_data)
|
|
||||||
default_preset_ids.append(str(pid))
|
|
||||||
|
|
||||||
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
|
|
||||||
zones.update(default_tab_id, {
|
|
||||||
"presets_flat": default_preset_ids,
|
|
||||||
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
profile = profiles.read(profile_id) or {}
|
|
||||||
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
|
|
||||||
profile_tabs.append(str(default_tab_id))
|
|
||||||
|
|
||||||
if seed_dj_zone:
|
|
||||||
# Seed a DJ-focused zone with three starter presets.
|
|
||||||
seeded_preset_ids = []
|
|
||||||
preset_defs = [
|
|
||||||
{
|
|
||||||
"name": "DJ Rainbow",
|
|
||||||
"pattern": "rainbow",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 220,
|
|
||||||
"delay": 60,
|
|
||||||
"n1": 12,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DJ Single Color",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#ff00ff"],
|
|
||||||
"brightness": 220,
|
|
||||||
"delay": 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DJ Transition",
|
|
||||||
"pattern": "transition",
|
|
||||||
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
|
||||||
"brightness": 220,
|
|
||||||
"delay": 250,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for preset_data in preset_defs:
|
|
||||||
pid = presets.create(profile_id)
|
|
||||||
presets.update(pid, preset_data)
|
|
||||||
seeded_preset_ids.append(str(pid))
|
|
||||||
|
|
||||||
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
|
||||||
zones.update(dj_tab_id, {
|
|
||||||
"presets_flat": seeded_preset_ids,
|
|
||||||
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
profile_tabs.append(str(dj_tab_id))
|
|
||||||
|
|
||||||
profiles.update(profile_id, {"zones": profile_tabs})
|
|
||||||
|
|
||||||
profile_data = profiles.read(profile_id)
|
|
||||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({"error": str(e)}), 400
|
|
||||||
|
|
||||||
@controller.post('/<id>/clone')
|
@controller.post('/<id>/clone')
|
||||||
async def clone_profile(request, id):
|
async def clone_profile(request, id):
|
||||||
@@ -351,6 +239,184 @@ async def clone_profile(request, id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get('/<id>')
|
||||||
|
@with_session
|
||||||
|
async def get_profile(request, id, session):
|
||||||
|
"""Get a specific profile by ID."""
|
||||||
|
# Handle 'current' as a special case
|
||||||
|
if id == 'current':
|
||||||
|
return await get_current_profile(request, session)
|
||||||
|
|
||||||
|
profile = profiles.read(id)
|
||||||
|
if profile:
|
||||||
|
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Profile not found"}), 404
|
||||||
|
|
||||||
|
@controller.post('')
|
||||||
|
async def create_profile(request):
|
||||||
|
"""Create a new profile."""
|
||||||
|
try:
|
||||||
|
data = dict(request.json or {})
|
||||||
|
name = data.get("name", "")
|
||||||
|
seed_raw = data.get("seed_dj_zone", False)
|
||||||
|
if isinstance(seed_raw, str):
|
||||||
|
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
else:
|
||||||
|
seed_dj_zone = bool(seed_raw)
|
||||||
|
# Request-only flag: do not persist on profile records.
|
||||||
|
data.pop("seed_dj_zone", None)
|
||||||
|
profile_id = profiles.create(name)
|
||||||
|
# Avoid persisting request-only fields.
|
||||||
|
data.pop("name", None)
|
||||||
|
if data:
|
||||||
|
profiles.update(profile_id, data)
|
||||||
|
|
||||||
|
# New profiles always start with a default zone pre-populated with starter presets.
|
||||||
|
default_preset_ids = []
|
||||||
|
default_preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "on",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#FFFFFF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "off",
|
||||||
|
"pattern": "off",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 0,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rainbow",
|
||||||
|
"pattern": "colour_cycle",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 2,
|
||||||
|
"mode": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Colour Cycle",
|
||||||
|
"pattern": "colour_cycle",
|
||||||
|
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 500,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "flicker",
|
||||||
|
"pattern": "flicker",
|
||||||
|
"colors": ["#FFB84D"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 80,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "flame",
|
||||||
|
"pattern": "flame",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 50,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 35,
|
||||||
|
"n2": 2600,
|
||||||
|
"n3": 0,
|
||||||
|
"n4": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "twinkle",
|
||||||
|
"pattern": "twinkle",
|
||||||
|
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 55,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 72,
|
||||||
|
"n2": 140,
|
||||||
|
"n3": 2,
|
||||||
|
"n4": 6,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in default_preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
default_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||||
|
zones.update(default_tab_id, {
|
||||||
|
"presets_flat": default_preset_ids,
|
||||||
|
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile = profiles.read(profile_id) or {}
|
||||||
|
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
|
||||||
|
profile_tabs.append(str(default_tab_id))
|
||||||
|
|
||||||
|
if seed_dj_zone:
|
||||||
|
# Seed a DJ-focused zone with three starter presets.
|
||||||
|
seeded_preset_ids = []
|
||||||
|
preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "DJ Rainbow",
|
||||||
|
"pattern": "colour_cycle",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 60,
|
||||||
|
"n1": 12,
|
||||||
|
"mode": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Single Color",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#ff00ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 250,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
seeded_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||||
|
zones.update(dj_tab_id, {
|
||||||
|
"presets_flat": seeded_preset_ids,
|
||||||
|
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile_tabs.append(str(dj_tab_id))
|
||||||
|
|
||||||
|
profiles.update(profile_id, {"zones": profile_tabs})
|
||||||
|
|
||||||
|
profile_data = profiles.read(profile_id)
|
||||||
|
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.put('/current')
|
@controller.put('/current')
|
||||||
@with_session
|
@with_session
|
||||||
async def update_current_profile(request, session):
|
async def update_current_profile(request, session):
|
||||||
|
|||||||
@@ -1,51 +1,298 @@
|
|||||||
from microdot import Microdot
|
from microdot import Microdot
|
||||||
from models.squence import Sequence
|
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
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
sequences = Sequence()
|
sequences = Sequence()
|
||||||
|
profiles = Profile()
|
||||||
|
presets = Preset()
|
||||||
|
|
||||||
@controller.get('')
|
|
||||||
async def list_sequences(request):
|
|
||||||
"""List all sequences."""
|
|
||||||
return json.dumps(sequences), 200, {'Content-Type': 'application/json'}
|
|
||||||
|
|
||||||
@controller.get('/<id>')
|
def get_current_profile_id(session=None):
|
||||||
async def get_sequence(request, id):
|
"""Get the current active profile ID from session or fallback to first."""
|
||||||
"""Get a specific sequence by ID."""
|
profile_list = profiles.list()
|
||||||
sequence = sequences.read(id)
|
session_profile = None
|
||||||
if sequence:
|
if session is not None:
|
||||||
return json.dumps(sequence), 200, {'Content-Type': 'application/json'}
|
session_profile = session.get("current_profile")
|
||||||
|
if session_profile and session_profile in profile_list:
|
||||||
|
return session_profile
|
||||||
|
if profile_list:
|
||||||
|
return profile_list[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("")
|
||||||
|
@with_session
|
||||||
|
async def list_sequences(request, session):
|
||||||
|
"""List sequences for the current profile."""
|
||||||
|
sequences.load()
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
|
scoped = {
|
||||||
|
sid: sdata
|
||||||
|
for sid, sdata in sequences.items()
|
||||||
|
if isinstance(sdata, dict)
|
||||||
|
and str(sdata.get("profile_id")) == str(current_profile_id)
|
||||||
|
}
|
||||||
|
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/<id>/export")
|
||||||
|
@with_session
|
||||||
|
async def export_sequence(request, session, id):
|
||||||
|
"""Export a sequence and referenced presets as a JSON bundle."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"}
|
||||||
|
try:
|
||||||
|
bundle = export_sequence_bundle(
|
||||||
|
id,
|
||||||
|
sequences,
|
||||||
|
presets,
|
||||||
|
profile_id=current_profile_id,
|
||||||
|
)
|
||||||
|
return json.dumps(bundle), 200, {"Content-Type": "application/json"}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/import")
|
||||||
|
@with_session
|
||||||
|
async def import_sequence(request, session):
|
||||||
|
"""Import a sequence bundle into the current profile."""
|
||||||
|
try:
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "No profile available"}),
|
||||||
|
404,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
body = request.json or {}
|
||||||
|
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Expected JSON bundle"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
|
||||||
|
return (
|
||||||
|
json.dumps({new_id: seq_data}),
|
||||||
|
201,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/<id>")
|
||||||
|
@with_session
|
||||||
|
async def get_sequence(request, session, id):
|
||||||
|
"""Get a specific sequence by ID (current profile only)."""
|
||||||
|
sequences.load()
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
seq = sequences.read(id)
|
||||||
|
if (
|
||||||
|
seq
|
||||||
|
and current_profile_id
|
||||||
|
and str(seq.get("profile_id")) == str(current_profile_id)
|
||||||
|
):
|
||||||
|
return json.dumps(seq), 200, {"Content-Type": "application/json"}
|
||||||
return json.dumps({"error": "Sequence not found"}), 404
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
|
||||||
@controller.post('')
|
|
||||||
async def create_sequence(request):
|
|
||||||
"""Create a new sequence."""
|
|
||||||
try:
|
|
||||||
data = request.json or {}
|
|
||||||
group_name = data.get("group_name", "")
|
|
||||||
preset_names = data.get("presets", None)
|
|
||||||
sequence_id = sequences.create(group_name, preset_names)
|
|
||||||
if data:
|
|
||||||
sequences.update(sequence_id, data)
|
|
||||||
return json.dumps(sequences.read(sequence_id)), 201, {'Content-Type': 'application/json'}
|
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({"error": str(e)}), 400
|
|
||||||
|
|
||||||
@controller.put('/<id>')
|
@controller.post("")
|
||||||
async def update_sequence(request, id):
|
@with_session
|
||||||
"""Update an existing sequence."""
|
async def create_sequence(request, session):
|
||||||
|
"""Create a new sequence for the current profile."""
|
||||||
try:
|
try:
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Invalid JSON"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
sequence_id = sequences.create(current_profile_id)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
data = dict(data)
|
||||||
|
data["profile_id"] = str(current_profile_id)
|
||||||
|
if sequences.update(sequence_id, data):
|
||||||
|
seq_data = sequences.read(sequence_id)
|
||||||
|
return (
|
||||||
|
json.dumps({sequence_id: seq_data}),
|
||||||
|
201,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Failed to create sequence"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.put("/<id>")
|
||||||
|
@with_session
|
||||||
|
async def update_sequence(request, session, id):
|
||||||
|
"""Update an existing sequence (current profile only)."""
|
||||||
|
try:
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
seq = sequences.read(id)
|
||||||
|
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
||||||
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
data = request.json
|
data = request.json
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Invalid JSON"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
data = dict(data)
|
||||||
|
data["profile_id"] = str(current_profile_id)
|
||||||
if sequences.update(id, data):
|
if sequences.update(id, data):
|
||||||
return json.dumps(sequences.read(id)), 200, {'Content-Type': 'application/json'}
|
try:
|
||||||
|
from util.sequence_playback import stop_if_playing_sequence
|
||||||
|
|
||||||
|
stop_if_playing_sequence(str(id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return json.dumps(sequences.read(id)), 200, {"Content-Type": "application/json"}
|
||||||
return json.dumps({"error": "Sequence not found"}), 404
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
@controller.delete('/<id>')
|
|
||||||
async def delete_sequence(request, id):
|
@controller.delete("/<id>")
|
||||||
"""Delete a sequence."""
|
@with_session
|
||||||
|
async def delete_sequence(request, session, id):
|
||||||
|
"""Delete a sequence (current profile only)."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
seq = sequences.read(id)
|
||||||
|
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
||||||
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
try:
|
||||||
|
from util.sequence_playback import stop_if_playing_sequence
|
||||||
|
|
||||||
|
stop_if_playing_sequence(str(id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if sequences.delete(id):
|
if sequences.delete(id):
|
||||||
return json.dumps({"message": "Sequence deleted successfully"}), 200
|
return (
|
||||||
|
json.dumps({"message": "Sequence deleted successfully"}),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
return json.dumps({"error": "Sequence not found"}), 404
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/sync-phase")
|
||||||
|
@with_session
|
||||||
|
async def sync_sequence_beat_phase(request, session):
|
||||||
|
"""Align beat counters while a sequence is playing (body: {\"mode\": \"step\"|\"pass\"})."""
|
||||||
|
_ = session
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
mode = data.get("mode") or data.get("align") or "step"
|
||||||
|
try:
|
||||||
|
from util.sequence_playback import sync_beat_phase
|
||||||
|
|
||||||
|
if not await sync_beat_phase(str(mode)):
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "No sequence is playing"}),
|
||||||
|
409,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
from util.audio_detector import anchor_shared_bar_phase
|
||||||
|
|
||||||
|
anchor_shared_bar_phase()
|
||||||
|
return json.dumps({"ok": True, "mode": str(mode).strip().lower()}), 200, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/stop")
|
||||||
|
@with_session
|
||||||
|
async def stop_sequence_playback(request, session):
|
||||||
|
"""Stop server-driven zone sequence playback."""
|
||||||
|
_ = request
|
||||||
|
try:
|
||||||
|
from util.sequence_playback import stop_playback
|
||||||
|
|
||||||
|
await stop_playback(clear_devices=True)
|
||||||
|
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/<id>/play")
|
||||||
|
@with_session
|
||||||
|
async def play_sequence(request, session, id):
|
||||||
|
"""Start server-driven playback for a sequence in a zone (body: {\"zone_id\": \"...\"})."""
|
||||||
|
if not get_current_sender():
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Transport not configured"}),
|
||||||
|
503,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
zone_id = data.get("zone_id") or data.get("zoneId")
|
||||||
|
if zone_id is None or str(zone_id).strip() == "":
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "zone_id required"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
zone_id = str(zone_id).strip()
|
||||||
|
try:
|
||||||
|
from util.sequence_playback import start
|
||||||
|
|
||||||
|
play_opts = data if isinstance(data, dict) else None
|
||||||
|
await start(zone_id, str(id), str(current_profile_id), play_opts)
|
||||||
|
from util.sequence_playback import pending_play_status
|
||||||
|
|
||||||
|
body = {"ok": True, **pending_play_status()}
|
||||||
|
return json.dumps(body), 200, {"Content-Type": "application/json"}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
except RuntimeError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import json
|
|||||||
from microdot import Microdot, send_file
|
from microdot import Microdot, send_file
|
||||||
|
|
||||||
from models import wifi_ws_clients
|
from models import wifi_ws_clients
|
||||||
from settings import Settings
|
from settings import get_settings
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
settings = Settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@controller.get('')
|
@controller.get('')
|
||||||
async def get_settings(request):
|
async def get_settings(request):
|
||||||
@@ -75,7 +75,21 @@ def _validate_global_brightness(value):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
@controller.put('/settings')
|
def _validate_sequence_switch_wait(value):
|
||||||
|
s = str(value).strip().lower()
|
||||||
|
if s not in ("beat", "downbeat"):
|
||||||
|
raise ValueError("sequence_switch_wait must be beat or downbeat")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_audio_beat_phase_ms(value):
|
||||||
|
v = int(value)
|
||||||
|
if v < 0 or v > 500:
|
||||||
|
raise ValueError("audio_beat_phase_ms must be between 0 and 500")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@controller.put('')
|
||||||
async def update_settings(request):
|
async def update_settings(request):
|
||||||
"""Update general settings."""
|
"""Update general settings."""
|
||||||
try:
|
try:
|
||||||
@@ -87,6 +101,10 @@ async def update_settings(request):
|
|||||||
elif key == 'global_brightness' and value is not None:
|
elif key == 'global_brightness' and value is not None:
|
||||||
settings[key] = _validate_global_brightness(value)
|
settings[key] = _validate_global_brightness(value)
|
||||||
global_brightness_changed = True
|
global_brightness_changed = True
|
||||||
|
elif key == 'sequence_switch_wait' and value is not None:
|
||||||
|
settings[key] = _validate_sequence_switch_wait(value)
|
||||||
|
elif key == 'audio_beat_phase_ms' and value is not None:
|
||||||
|
settings[key] = _validate_audio_beat_phase_ms(value)
|
||||||
else:
|
else:
|
||||||
settings[key] = value
|
settings[key] = value
|
||||||
settings.save()
|
settings.save()
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ async def zone_content_fragment(request, session, id):
|
|||||||
@controller.get("")
|
@controller.get("")
|
||||||
@with_session
|
@with_session
|
||||||
async def list_zones(request, session):
|
async def list_zones(request, session):
|
||||||
|
zones.load()
|
||||||
profile_id = get_current_profile_id(session)
|
profile_id = get_current_profile_id(session)
|
||||||
current_zone_id = get_current_zone_id(request, session)
|
current_zone_id = get_current_zone_id(request, session)
|
||||||
zone_order = get_profile_zone_order(profile_id) if profile_id else []
|
zone_order = get_profile_zone_order(profile_id) if profile_id else []
|
||||||
@@ -213,6 +214,7 @@ async def set_current_zone(request, id):
|
|||||||
|
|
||||||
@controller.get("/<id>")
|
@controller.get("/<id>")
|
||||||
async def get_zone(request, id):
|
async def get_zone(request, id):
|
||||||
|
zones.load()
|
||||||
z = zones.read(id)
|
z = zones.read(id)
|
||||||
if z:
|
if z:
|
||||||
return json.dumps(z), 200, {"Content-Type": "application/json"}
|
return json.dumps(z), 200, {"Content-Type": "application/json"}
|
||||||
@@ -291,6 +293,7 @@ async def create_zone(request, session):
|
|||||||
names = [i.strip() for i in ids_str.split(",") if i.strip()]
|
names = [i.strip() for i in ids_str.split(",") if i.strip()]
|
||||||
preset_ids = None
|
preset_ids = None
|
||||||
group_ids = []
|
group_ids = []
|
||||||
|
content_kind = None
|
||||||
else:
|
else:
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
name = data.get("name", "")
|
name = data.get("name", "")
|
||||||
@@ -305,11 +308,13 @@ async def create_zone(request, session):
|
|||||||
group_ids = [str(x) for x in group_ids if x is not None]
|
group_ids = [str(x) for x in group_ids if x is not None]
|
||||||
else:
|
else:
|
||||||
group_ids = []
|
group_ids = []
|
||||||
|
raw_kind = data.get("content_kind")
|
||||||
|
content_kind = raw_kind if raw_kind in ("presets", "sequences") else None
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
return json.dumps({"error": "Zone name cannot be empty"}), 400
|
return json.dumps({"error": "Zone name cannot be empty"}), 400
|
||||||
|
|
||||||
zid = zones.create(name, names, preset_ids, group_ids)
|
zid = zones.create(name, names, preset_ids, group_ids, content_kind)
|
||||||
|
|
||||||
profile_id = get_current_profile_id(session)
|
profile_id = get_current_profile_id(session)
|
||||||
if profile_id:
|
if profile_id:
|
||||||
@@ -346,6 +351,7 @@ async def clone_zone(request, session, id):
|
|||||||
source.get("names"),
|
source.get("names"),
|
||||||
source.get("presets"),
|
source.get("presets"),
|
||||||
source.get("group_ids"),
|
source.get("group_ids"),
|
||||||
|
source.get("content_kind"),
|
||||||
)
|
)
|
||||||
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
||||||
if extra:
|
if extra:
|
||||||
|
|||||||
261
src/main.py
261
src/main.py
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import errno
|
import errno
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
@@ -9,7 +10,7 @@ import traceback
|
|||||||
from microdot import Microdot, send_file
|
from microdot import Microdot, send_file
|
||||||
from microdot.websocket import with_websocket
|
from microdot.websocket import with_websocket
|
||||||
from microdot.session import Session
|
from microdot.session import Session
|
||||||
from settings import Settings
|
from settings import get_settings
|
||||||
|
|
||||||
import controllers.preset as preset
|
import controllers.preset as preset
|
||||||
import controllers.profile as profile
|
import controllers.profile as profile
|
||||||
@@ -38,6 +39,11 @@ _tcp_device_lock = threading.Lock()
|
|||||||
DISCOVERY_UDP_PORT = 8766
|
DISCOVERY_UDP_PORT = 8766
|
||||||
|
|
||||||
|
|
||||||
|
def _live_reload_enabled() -> bool:
|
||||||
|
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
|
||||||
|
return v not in ("", "0", "false", "no")
|
||||||
|
|
||||||
|
|
||||||
def _register_udp_device_sync(
|
def _register_udp_device_sync(
|
||||||
device_name: str, peer_ip: str, mac, device_type=None
|
device_name: str, peer_ip: str, mac, device_type=None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -94,11 +100,7 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _prime_wifi_outbound_driver_connections() -> None:
|
def _prime_wifi_outbound_driver_connections() -> None:
|
||||||
"""
|
"""On boot, dial each registered Wi-Fi driver (same 4-attempt limit as UDP hello)."""
|
||||||
For each Wi‑Fi device in the registry with a usable IPv4, start (or keep) the
|
|
||||||
outbound WebSocket task. The client loop reconnects automatically if the link
|
|
||||||
drops. Presets are not pushed automatically; use Send Presets / profile apply.
|
|
||||||
"""
|
|
||||||
n = 0
|
n = 0
|
||||||
try:
|
try:
|
||||||
dev = Device()
|
dev = Device()
|
||||||
@@ -137,65 +139,6 @@ def _ipv4_address(addr: str) -> str | None:
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
|
|
||||||
"""
|
|
||||||
While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on
|
|
||||||
UDP discovery port so the device can announce itself and we can reconnect.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
interval = 10.0
|
|
||||||
if interval <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
sock.setblocking(False)
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
if udp_holder.get("closing"):
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
dev = Device()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[hello] device list failed: {e!r}")
|
|
||||||
continue
|
|
||||||
for _mac_key, doc in list(dev.items()):
|
|
||||||
if not isinstance(doc, dict):
|
|
||||||
continue
|
|
||||||
if doc.get("transport") != "wifi":
|
|
||||||
continue
|
|
||||||
ip = _ipv4_address(str(doc.get("address") or ""))
|
|
||||||
if not ip:
|
|
||||||
continue
|
|
||||||
if tcp_client_registry.tcp_client_connected(ip):
|
|
||||||
continue
|
|
||||||
name = (doc.get("name") or "").strip()
|
|
||||||
mac = normalize_mac(doc.get("id") or _mac_key)
|
|
||||||
if not name or not mac:
|
|
||||||
continue
|
|
||||||
line = (
|
|
||||||
json.dumps(
|
|
||||||
{"m": "hello", "device_name": name, "mac": mac},
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await loop.sock_sendto(
|
|
||||||
sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT)
|
|
||||||
)
|
|
||||||
except OSError as e:
|
|
||||||
print(f"[hello] UDP to {ip!r} failed: {e!r}")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
sock.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_udp_discovery_server(udp_holder=None) -> None:
|
async def _run_udp_discovery_server(udp_holder=None) -> None:
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.setblocking(False)
|
sock.setblocking(False)
|
||||||
@@ -238,7 +181,7 @@ async def _send_bridge_wifi_channel(settings, sender):
|
|||||||
|
|
||||||
|
|
||||||
async def main(port=80):
|
async def main(port=80):
|
||||||
settings = Settings()
|
settings = get_settings()
|
||||||
print(settings)
|
print(settings)
|
||||||
print("Starting")
|
print("Starting")
|
||||||
|
|
||||||
@@ -248,9 +191,28 @@ async def main(port=80):
|
|||||||
|
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
audio_detector = AudioBeatDetector()
|
audio_detector = AudioBeatDetector()
|
||||||
|
try:
|
||||||
|
from util import audio_detector as audio_detector_module
|
||||||
|
|
||||||
|
audio_detector_module.set_shared_beat_detector(audio_detector)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[startup] audio detector shared registration skipped: {e!r}")
|
||||||
|
try:
|
||||||
|
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
|
||||||
|
|
||||||
|
persisted = read_audio_run_state()
|
||||||
|
if persisted.get("enabled"):
|
||||||
|
dev = coerce_audio_device(persisted.get("device"))
|
||||||
|
audio_detector.start(device=dev)
|
||||||
|
print("[startup] audio beat detector started from saved run state")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[startup] audio auto-start skipped: {e!r}")
|
||||||
from util import beat_driver_route
|
from util import beat_driver_route
|
||||||
|
|
||||||
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
||||||
|
from util import sequence_playback as seq_pb
|
||||||
|
|
||||||
|
seq_pb.ensure_beat_consumer_started()
|
||||||
|
|
||||||
# Initialize sessions with a secret key from settings
|
# Initialize sessions with a secret key from settings
|
||||||
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
|
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
|
||||||
@@ -284,11 +246,42 @@ async def main(port=80):
|
|||||||
tcp_client_registry.set_settings(settings)
|
tcp_client_registry.set_settings(settings)
|
||||||
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
||||||
|
|
||||||
|
live_reload = _live_reload_enabled()
|
||||||
|
dev_build_id = secrets.token_hex(12) if live_reload else None
|
||||||
|
if live_reload:
|
||||||
|
print(
|
||||||
|
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when the server process restarts"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dev_build_id:
|
||||||
|
|
||||||
|
@app.route("/__dev/build-id")
|
||||||
|
def dev_build_id_route(request):
|
||||||
|
_ = request
|
||||||
|
return (
|
||||||
|
dev_build_id,
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
||||||
@app.route('/')
|
@app.route("/")
|
||||||
def index(request):
|
def index(request):
|
||||||
"""Serve the main web UI."""
|
"""Serve the main web UI."""
|
||||||
return send_file('templates/index.html')
|
if dev_build_id:
|
||||||
|
try:
|
||||||
|
with open("templates/index.html", encoding="utf-8") as f:
|
||||||
|
html = f.read()
|
||||||
|
tag = '<script src="/static/dev-live-reload.js" defer></script>'
|
||||||
|
if "</body>" in html:
|
||||||
|
html = html.replace("</body>", tag + "\n</body>", 1)
|
||||||
|
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return send_file("templates/index.html")
|
||||||
|
|
||||||
# Favicon: avoid 404 in browser console (no file needed)
|
# Favicon: avoid 404 in browser console (no file needed)
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
@@ -319,6 +312,14 @@ async def main(port=80):
|
|||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
audio_detector.start(device=device)
|
audio_detector.start(device=device)
|
||||||
|
from util.audio_run_persist import write_audio_run_state
|
||||||
|
|
||||||
|
write_audio_run_state(
|
||||||
|
enabled=True,
|
||||||
|
device=device,
|
||||||
|
device_override=str(payload.get("device_override") or ""),
|
||||||
|
device_select=str(payload.get("device_select") or ""),
|
||||||
|
)
|
||||||
return {"ok": True, "status": audio_detector.status()}
|
return {"ok": True, "status": audio_detector.status()}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e)}, 500
|
return {"ok": False, "error": str(e)}, 500
|
||||||
@@ -327,12 +328,73 @@ async def main(port=80):
|
|||||||
async def audio_stop(request):
|
async def audio_stop(request):
|
||||||
_ = request
|
_ = request
|
||||||
audio_detector.stop()
|
audio_detector.stop()
|
||||||
|
from util.audio_run_persist import write_audio_run_state
|
||||||
|
|
||||||
|
write_audio_run_state(enabled=False)
|
||||||
|
return {"ok": True, "status": audio_detector.status()}
|
||||||
|
|
||||||
|
@app.route('/api/audio/reset', methods=['POST'])
|
||||||
|
async def audio_reset(request):
|
||||||
|
"""Clear beat/BPM tracking state without stopping the detector."""
|
||||||
|
_ = request
|
||||||
|
ok = audio_detector.reset_tracking()
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "error": "Audio detector is not running"}, 409
|
||||||
|
return {"ok": True, "status": audio_detector.status()}
|
||||||
|
|
||||||
|
@app.route('/api/audio/anchor-bar', methods=['POST'])
|
||||||
|
async def audio_anchor_bar(request):
|
||||||
|
"""Mark the current moment as bar beat 1 (downbeat)."""
|
||||||
|
_ = request
|
||||||
|
ok = audio_detector.anchor_bar_phase()
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "error": "Audio detector is not running"}, 409
|
||||||
return {"ok": True, "status": audio_detector.status()}
|
return {"ok": True, "status": audio_detector.status()}
|
||||||
|
|
||||||
@app.route('/api/audio/status')
|
@app.route('/api/audio/status')
|
||||||
async def audio_status(request):
|
async def audio_status(request):
|
||||||
_ = request
|
_ = request
|
||||||
return {"status": audio_detector.status()}
|
from util import beat_driver_route
|
||||||
|
from util import sequence_playback
|
||||||
|
|
||||||
|
st = audio_detector.status()
|
||||||
|
st["sequence"] = sequence_playback.playback_status()
|
||||||
|
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
|
||||||
|
seq = st.get("sequence")
|
||||||
|
beat_readout = ""
|
||||||
|
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
|
||||||
|
beat_readout = str(seq.get("beat_readout") or "").strip()
|
||||||
|
elif st.get("running"):
|
||||||
|
mb = st.get("manual_beat_stride")
|
||||||
|
if isinstance(mb, dict) and mb.get("active"):
|
||||||
|
try:
|
||||||
|
n = int(mb.get("stride_n") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 1
|
||||||
|
n = max(1, min(64, n))
|
||||||
|
try:
|
||||||
|
bi = int(mb.get("beat_in_stride") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
bi = 1
|
||||||
|
pos = min(n, max(1, bi))
|
||||||
|
beat_readout = f"{pos}/{n}"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
bs = int(st.get("beat_seq") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
bs = 0
|
||||||
|
if bs > 0:
|
||||||
|
beat_readout = str(bs)
|
||||||
|
st["beat_readout"] = beat_readout
|
||||||
|
from util.audio_run_persist import read_audio_run_state
|
||||||
|
|
||||||
|
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
|
||||||
|
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
|
||||||
|
if seq_wait not in ("beat", "downbeat"):
|
||||||
|
seq_wait = "beat"
|
||||||
|
st["sequence_switch_wait"] = seq_wait
|
||||||
|
st["audio_run"] = read_audio_run_state()
|
||||||
|
return {"status": st}
|
||||||
|
|
||||||
# Static file route
|
# Static file route
|
||||||
@app.route("/static/<path:path>")
|
@app.route("/static/<path:path>")
|
||||||
@@ -386,16 +448,30 @@ async def main(port=80):
|
|||||||
await _send_bridge_wifi_channel(settings, sender)
|
await _send_bridge_wifi_channel(settings, sender)
|
||||||
_prime_wifi_outbound_driver_connections()
|
_prime_wifi_outbound_driver_connections()
|
||||||
|
|
||||||
udp_holder = {"closing": False}
|
udp_holder = {"closing": False, "shutting_down": False}
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
server_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
def _graceful_shutdown(*_args):
|
def _graceful_shutdown(*_args):
|
||||||
|
if udp_holder.get("shutting_down"):
|
||||||
|
raise SystemExit(0)
|
||||||
|
udp_holder["shutting_down"] = True
|
||||||
print("[server] shutting down...")
|
print("[server] shutting down...")
|
||||||
udp_holder["closing"] = True
|
udp_holder["closing"] = True
|
||||||
try:
|
try:
|
||||||
audio_detector.stop()
|
audio_detector.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
from util import sequence_playback as seq_pb
|
||||||
|
|
||||||
|
seq_pb.stop()
|
||||||
|
for attr in ("_pending_beat_task", "_sim_beat_task"):
|
||||||
|
t = getattr(seq_pb, attr, None)
|
||||||
|
if t is not None and not t.done():
|
||||||
|
t.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
u = udp_holder.get("sock")
|
u = udp_holder.get("sock")
|
||||||
if u is not None:
|
if u is not None:
|
||||||
try:
|
try:
|
||||||
@@ -404,7 +480,13 @@ async def main(port=80):
|
|||||||
pass
|
pass
|
||||||
tcp_client_registry.cancel_all_driver_tasks()
|
tcp_client_registry.cancel_all_driver_tasks()
|
||||||
if getattr(app, "server", None) is not None:
|
if getattr(app, "server", None) is not None:
|
||||||
app.shutdown()
|
try:
|
||||||
|
app.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for t in server_tasks:
|
||||||
|
if not t.done():
|
||||||
|
t.cancel()
|
||||||
|
|
||||||
shutdown_handlers_registered = False
|
shutdown_handlers_registered = False
|
||||||
try:
|
try:
|
||||||
@@ -417,11 +499,17 @@ async def main(port=80):
|
|||||||
|
|
||||||
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
|
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(
|
server_tasks[:] = [
|
||||||
app.start_server(host="0.0.0.0", port=port),
|
asyncio.create_task(
|
||||||
_run_udp_discovery_server(udp_holder),
|
app.start_server(host="0.0.0.0", port=port), name="http"
|
||||||
_periodic_wifi_driver_hello_loop(settings, udp_holder),
|
),
|
||||||
)
|
asyncio.create_task(
|
||||||
|
_run_udp_discovery_server(udp_holder), name="udp"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
await asyncio.gather(*server_tasks)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if e.errno == errno.EADDRINUSE:
|
if e.errno == errno.EADDRINUSE:
|
||||||
print(
|
print(
|
||||||
@@ -446,6 +534,21 @@ async def main(port=80):
|
|||||||
app.server = None
|
app.server = None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
udp_holder["closing"] = True
|
||||||
|
for t in list(server_tasks):
|
||||||
|
if not t.done():
|
||||||
|
t.cancel()
|
||||||
|
if server_tasks:
|
||||||
|
await asyncio.gather(*server_tasks, return_exceptions=True)
|
||||||
|
pending = [
|
||||||
|
t
|
||||||
|
for t in asyncio.all_tasks(loop)
|
||||||
|
if t is not asyncio.current_task() and not t.done()
|
||||||
|
]
|
||||||
|
for t in pending:
|
||||||
|
t.cancel()
|
||||||
|
if pending:
|
||||||
|
await asyncio.gather(*pending, return_exceptions=True)
|
||||||
if shutdown_handlers_registered:
|
if shutdown_handlers_registered:
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
try:
|
try:
|
||||||
@@ -455,5 +558,9 @@ async def main(port=80):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import os
|
import os
|
||||||
|
|
||||||
port = int(os.environ.get("PORT", 80))
|
port = int(os.environ.get("PORT", 80))
|
||||||
asyncio.run(main(port=port))
|
try:
|
||||||
|
asyncio.run(main(port=port))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[server] interrupted")
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ from models.model import Model
|
|||||||
|
|
||||||
|
|
||||||
class Group(Model):
|
class Group(Model):
|
||||||
"""Device groups (members + optional Wi‑Fi driver defaults); also pattern fields for sequences."""
|
"""Device groups (members + optional Wi‑Fi driver defaults); also pattern fields for sequences.
|
||||||
|
|
||||||
|
Omit ``profile_id`` (or set it null) for a **shared** group: every profile can attach it to
|
||||||
|
zones and sequences. Set ``profile_id`` to a profile id to show the group only when that
|
||||||
|
profile is active (still one global record in ``group.json``).
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ class Preset(Model):
|
|||||||
if default_profile_id is not None:
|
if default_profile_id is not None:
|
||||||
preset_data["profile_id"] = str(default_profile_id)
|
preset_data["profile_id"] = str(default_profile_id)
|
||||||
changed = True
|
changed = True
|
||||||
|
if isinstance(preset_data, dict) and "group_ids" in preset_data:
|
||||||
|
preset_data.pop("group_ids", None)
|
||||||
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
self.save()
|
self.save()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
159
src/models/sequence.py
Normal file
159
src/models/sequence.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
from models.model import Model
|
||||||
|
|
||||||
|
|
||||||
|
class Sequence(Model):
|
||||||
|
def load(self):
|
||||||
|
super().load()
|
||||||
|
self._migrate_after_load()
|
||||||
|
|
||||||
|
def _migrate_after_load(self):
|
||||||
|
try:
|
||||||
|
from models.profile import Profile
|
||||||
|
|
||||||
|
profiles = Profile()
|
||||||
|
profile_list = profiles.list()
|
||||||
|
default_profile_id = profile_list[0] if profile_list else None
|
||||||
|
except Exception:
|
||||||
|
default_profile_id = None
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
for _sid, doc in list(self.items()):
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
continue
|
||||||
|
if not isinstance(doc.get("steps"), list):
|
||||||
|
presets = doc.get("presets")
|
||||||
|
if isinstance(presets, list) and presets:
|
||||||
|
doc["steps"] = [
|
||||||
|
{"preset_id": str(p), "group_ids": []} for p in presets
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
doc["steps"] = []
|
||||||
|
changed = True
|
||||||
|
if "step_duration_ms" not in doc:
|
||||||
|
dur = doc.get("sequence_duration")
|
||||||
|
doc["step_duration_ms"] = (
|
||||||
|
int(dur) if isinstance(dur, (int, float)) else 3000
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
if "loop" not in doc:
|
||||||
|
doc["loop"] = bool(doc.get("sequence_loop", False))
|
||||||
|
changed = True
|
||||||
|
if "name" not in doc:
|
||||||
|
doc["name"] = str(doc.get("group_name") or "")
|
||||||
|
changed = True
|
||||||
|
if "profile_id" not in doc and default_profile_id is not None:
|
||||||
|
doc["profile_id"] = str(default_profile_id)
|
||||||
|
changed = True
|
||||||
|
if not isinstance(doc.get("lanes"), list):
|
||||||
|
steps = doc.get("steps")
|
||||||
|
if isinstance(steps, list) and steps:
|
||||||
|
doc["lanes"] = [list(steps)]
|
||||||
|
else:
|
||||||
|
doc["lanes"] = [[]]
|
||||||
|
changed = True
|
||||||
|
if "group_ids" not in doc or not isinstance(doc.get("group_ids"), list):
|
||||||
|
doc["group_ids"] = []
|
||||||
|
changed = True
|
||||||
|
if doc.get("advance_mode") != "beats":
|
||||||
|
doc["advance_mode"] = "beats"
|
||||||
|
changed = True
|
||||||
|
if "simulated_bpm" not in doc:
|
||||||
|
doc["simulated_bpm"] = 120
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
sb = int(float(doc["simulated_bpm"]))
|
||||||
|
doc["simulated_bpm"] = max(30, min(300, sb))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
doc["simulated_bpm"] = 120
|
||||||
|
changed = True
|
||||||
|
if "sequence_transition" not in doc:
|
||||||
|
doc["sequence_transition"] = 500
|
||||||
|
changed = True
|
||||||
|
# Ensure each step has beats (beat-based advance); default 1
|
||||||
|
for lane in doc.get("lanes") or []:
|
||||||
|
if not isinstance(lane, list):
|
||||||
|
continue
|
||||||
|
for step in lane:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
if "beats" not in step:
|
||||||
|
step["beats"] = 1
|
||||||
|
changed = True
|
||||||
|
# Per-lane group ids (parallel to ``lanes``)
|
||||||
|
lanes_list = [x for x in (doc.get("lanes") or []) if isinstance(x, list)]
|
||||||
|
n_lanes = len(lanes_list)
|
||||||
|
lg = doc.get("lanes_group_ids")
|
||||||
|
if n_lanes and (not isinstance(lg, list) or len(lg) != n_lanes):
|
||||||
|
shared = doc.get("group_ids") if isinstance(doc.get("group_ids"), list) else []
|
||||||
|
shared_s = [str(x).strip() for x in shared if x is not None and str(x).strip()]
|
||||||
|
if n_lanes == 1 and lanes_list[0]:
|
||||||
|
first = lanes_list[0][0] if isinstance(lanes_list[0][0], dict) else {}
|
||||||
|
step_g = (
|
||||||
|
first.get("group_ids")
|
||||||
|
if isinstance(first.get("group_ids"), list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
step_s = [
|
||||||
|
str(x).strip() for x in step_g if x is not None and str(x).strip()
|
||||||
|
]
|
||||||
|
doc["lanes_group_ids"] = [step_s if step_s else list(shared_s)]
|
||||||
|
else:
|
||||||
|
doc["lanes_group_ids"] = [list(shared_s) for _ in range(n_lanes)]
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def create(self, profile_id=None):
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
self[next_id] = {
|
||||||
|
"name": "",
|
||||||
|
"profile_id": str(profile_id) if profile_id is not None else None,
|
||||||
|
"group_ids": [],
|
||||||
|
"lanes": [[]],
|
||||||
|
"lanes_group_ids": [[]],
|
||||||
|
"advance_mode": "beats",
|
||||||
|
"steps": [],
|
||||||
|
"step_duration_ms": 3000,
|
||||||
|
"simulated_bpm": 120,
|
||||||
|
"sequence_transition": 500,
|
||||||
|
"loop": True,
|
||||||
|
}
|
||||||
|
self.save()
|
||||||
|
return next_id
|
||||||
|
|
||||||
|
def read(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
return self.get(id_str, None)
|
||||||
|
|
||||||
|
def update(self, id, data):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return False
|
||||||
|
data = dict(data)
|
||||||
|
steps = data.get("steps")
|
||||||
|
lanes = data.get("lanes")
|
||||||
|
if isinstance(steps, list) and steps:
|
||||||
|
lanes_ok = (
|
||||||
|
isinstance(lanes, list)
|
||||||
|
and lanes
|
||||||
|
and any(isinstance(x, list) and len(x) > 0 for x in lanes)
|
||||||
|
)
|
||||||
|
if not lanes_ok:
|
||||||
|
data["lanes"] = [list(steps)]
|
||||||
|
self[id_str].update(data)
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
self.pop(id_str)
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list(self):
|
||||||
|
return list(self.keys())
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from models.model import Model
|
|
||||||
|
|
||||||
class Sequence(Model):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def create(self, group_name="", preset_names=None):
|
|
||||||
next_id = self.get_next_id()
|
|
||||||
self[next_id] = {
|
|
||||||
"group_name": group_name,
|
|
||||||
"presets": preset_names if preset_names else [],
|
|
||||||
"sequence_duration": 3000, # Duration per preset in ms
|
|
||||||
"sequence_transition": 500, # Transition time in ms
|
|
||||||
"sequence_loop": False,
|
|
||||||
"sequence_repeat_count": 0, # 0 = infinite
|
|
||||||
"sequence_active": False,
|
|
||||||
"sequence_index": 0,
|
|
||||||
"sequence_start_time": 0
|
|
||||||
}
|
|
||||||
self.save()
|
|
||||||
return next_id
|
|
||||||
|
|
||||||
def read(self, id):
|
|
||||||
id_str = str(id)
|
|
||||||
return self.get(id_str, None)
|
|
||||||
|
|
||||||
def update(self, id, data):
|
|
||||||
id_str = str(id)
|
|
||||||
if id_str not in self:
|
|
||||||
return False
|
|
||||||
self[id_str].update(data)
|
|
||||||
self.save()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def delete(self, id):
|
|
||||||
id_str = str(id)
|
|
||||||
if id_str not in self:
|
|
||||||
return False
|
|
||||||
self.pop(id_str)
|
|
||||||
self.save()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def list(self):
|
|
||||||
return list(self.keys())
|
|
||||||
@@ -13,7 +13,6 @@ from websockets.exceptions import ConnectionClosed
|
|||||||
_connections: dict[str, object] = {}
|
_connections: dict[str, object] = {}
|
||||||
_send_locks: dict[str, asyncio.Lock] = {}
|
_send_locks: dict[str, asyncio.Lock] = {}
|
||||||
_tasks: dict[str, asyncio.Task] = {}
|
_tasks: dict[str, asyncio.Task] = {}
|
||||||
_unreachable_counts: dict[str, int] = {}
|
|
||||||
_settings = None
|
_settings = None
|
||||||
|
|
||||||
_tcp_status_broadcast = None
|
_tcp_status_broadcast = None
|
||||||
@@ -119,7 +118,6 @@ def _register_ws(ip: str, ws) -> None:
|
|||||||
if not key:
|
if not key:
|
||||||
return
|
return
|
||||||
_connections[key] = ws
|
_connections[key] = ws
|
||||||
_unreachable_counts.pop(key, None)
|
|
||||||
if key not in _send_locks:
|
if key not in _send_locks:
|
||||||
_send_locks[key] = asyncio.Lock()
|
_send_locks[key] = asyncio.Lock()
|
||||||
_schedule_status_broadcast(key, True)
|
_schedule_status_broadcast(key, True)
|
||||||
@@ -275,52 +273,43 @@ async def _driver_connection_loop(ip: str) -> None:
|
|||||||
if stagger > 0:
|
if stagger > 0:
|
||||||
await asyncio.sleep(stagger)
|
await asyncio.sleep(stagger)
|
||||||
|
|
||||||
# Only bound boot-time: after we have connected once, keep retrying (Wi-Fi drops, reboots).
|
|
||||||
connected_once = False
|
|
||||||
boot_attempts = 0
|
|
||||||
try:
|
try:
|
||||||
while True:
|
for attempt in range(1, max_boot_attempts + 1):
|
||||||
if not connected_once:
|
|
||||||
if boot_attempts >= max_boot_attempts:
|
|
||||||
print(
|
|
||||||
f"[WS] driver {ip} still unreachable after {max_boot_attempts} "
|
|
||||||
f"initial dial attempt(s); stopping until next UDP hello / registry prime"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
boot_attempts += 1
|
|
||||||
try:
|
try:
|
||||||
print(f"[WS] connecting to {uri!r}")
|
print(f"[WS] connecting to {uri!r} (attempt {attempt}/{max_boot_attempts})")
|
||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
uri,
|
uri,
|
||||||
ping_interval=20,
|
ping_interval=20,
|
||||||
ping_timeout=15,
|
ping_timeout=15,
|
||||||
open_timeout=open_timeout,
|
open_timeout=open_timeout,
|
||||||
) as ws:
|
) as ws:
|
||||||
connected_once = True
|
|
||||||
_register_ws(ip, ws)
|
_register_ws(ip, ws)
|
||||||
try:
|
try:
|
||||||
await _recv_forward_loop(ip, ws)
|
await _recv_forward_loop(ip, ws)
|
||||||
finally:
|
finally:
|
||||||
unregister_tcp_writer(ip, ws)
|
unregister_tcp_writer(ip, ws)
|
||||||
|
return
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except ConnectionClosed as e:
|
except ConnectionClosed as e:
|
||||||
print(f"[WS] driver {ip} closed: {e}")
|
print(f"[WS] driver {ip} closed: {e}")
|
||||||
unregister_tcp_writer(ip, None)
|
unregister_tcp_writer(ip, None)
|
||||||
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _benign_ws_connect_failure(e):
|
if _benign_ws_connect_failure(e):
|
||||||
n = _unreachable_counts.get(ip, 0) + 1
|
print(
|
||||||
_unreachable_counts[ip] = n
|
f"[WS] driver {ip} unreachable (attempt {attempt}/{max_boot_attempts}): {e}"
|
||||||
if n == 1 or (n % 30) == 0:
|
)
|
||||||
print(
|
|
||||||
f"[WS] driver {ip} unreachable, retry in {retry_interval_s}s: {e} (x{n})"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
print(f"[WS] driver {ip} session error: {e!r}")
|
print(f"[WS] driver {ip} session error: {e!r}")
|
||||||
traceback.print_exception(type(e), e, e.__traceback__)
|
traceback.print_exception(type(e), e, e.__traceback__)
|
||||||
_unreachable_counts.pop(ip, None)
|
|
||||||
unregister_tcp_writer(ip, None)
|
unregister_tcp_writer(ip, None)
|
||||||
await asyncio.sleep(retry_interval_s)
|
if attempt < max_boot_attempts:
|
||||||
|
await asyncio.sleep(retry_interval_s)
|
||||||
|
print(
|
||||||
|
f"[WS] driver {ip} still unreachable after {max_boot_attempts} attempt(s); "
|
||||||
|
"waiting for next UDP hello"
|
||||||
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
unregister_tcp_writer(ip, None)
|
unregister_tcp_writer(ip, None)
|
||||||
raise
|
raise
|
||||||
@@ -329,10 +318,12 @@ async def _driver_connection_loop(ip: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_driver_connection(peer_ip: str) -> None:
|
def ensure_driver_connection(peer_ip: str) -> None:
|
||||||
"""Start (or keep) a background task that maintains ``ws://<ip>:port/ws``."""
|
"""Dial ``ws://<ip>:port/ws`` up to wifi_driver_initial_connect_attempts times (UDP hello only)."""
|
||||||
key = normalize_tcp_peer_ip(peer_ip)
|
key = normalize_tcp_peer_ip(peer_ip)
|
||||||
if not key:
|
if not key:
|
||||||
return
|
return
|
||||||
|
if tcp_client_connected(key):
|
||||||
|
return
|
||||||
t = _tasks.get(key)
|
t = _tasks.get(key)
|
||||||
if t is not None and not t.done():
|
if t is not None and not t.done():
|
||||||
return
|
return
|
||||||
@@ -353,4 +344,3 @@ def cancel_all_driver_tasks() -> None:
|
|||||||
_schedule_status_broadcast(ip, False)
|
_schedule_status_broadcast(ip, False)
|
||||||
_connections.clear()
|
_connections.clear()
|
||||||
_send_locks.clear()
|
_send_locks.clear()
|
||||||
_unreachable_counts.clear()
|
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ def _maybe_migrate_tab_json_to_zone():
|
|||||||
|
|
||||||
|
|
||||||
class Zone(Model):
|
class Zone(Model):
|
||||||
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab."""
|
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
|
||||||
|
|
||||||
|
Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"``
|
||||||
|
(sequence tiles only). Legacy rows without ``content_kind`` are inferred on load.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
if not getattr(Zone, "_migration_checked", False):
|
if not getattr(Zone, "_migration_checked", False):
|
||||||
@@ -36,22 +40,89 @@ class Zone(Model):
|
|||||||
if "group_ids" not in doc:
|
if "group_ids" not in doc:
|
||||||
doc["group_ids"] = []
|
doc["group_ids"] = []
|
||||||
changed = True
|
changed = True
|
||||||
|
if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
|
||||||
|
doc["preset_group_ids"] = {}
|
||||||
|
changed = True
|
||||||
|
if "sequence_ids" not in doc or not isinstance(doc.get("sequence_ids"), list):
|
||||||
|
doc["sequence_ids"] = []
|
||||||
|
changed = True
|
||||||
|
if not self._normalized_content_kind(doc):
|
||||||
|
doc["content_kind"] = self._infer_content_kind(doc)
|
||||||
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def create(self, name="", names=None, presets=None, group_ids=None):
|
@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
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _preset_ids_in_doc(doc):
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return []
|
||||||
|
flat = doc.get("presets_flat")
|
||||||
|
if isinstance(flat, list):
|
||||||
|
return [str(x) for x in flat if x is not None and str(x).strip()]
|
||||||
|
presets = doc.get("presets")
|
||||||
|
if not isinstance(presets, list) or not presets:
|
||||||
|
return []
|
||||||
|
if isinstance(presets[0], str):
|
||||||
|
return [str(x) for x in presets if x is not None and str(x).strip()]
|
||||||
|
if isinstance(presets[0], list):
|
||||||
|
out = []
|
||||||
|
for row in presets:
|
||||||
|
if isinstance(row, list):
|
||||||
|
out.extend(str(x) for x in row if x is not None and str(x).strip())
|
||||||
|
return out
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _infer_content_kind(cls, doc):
|
||||||
|
kind = cls._normalized_content_kind(doc)
|
||||||
|
if kind:
|
||||||
|
return kind
|
||||||
|
seq_ids = [
|
||||||
|
str(x).strip()
|
||||||
|
for x in (doc.get("sequence_ids") or [])
|
||||||
|
if x is not None and str(x).strip()
|
||||||
|
]
|
||||||
|
preset_ids = cls._preset_ids_in_doc(doc)
|
||||||
|
if seq_ids and not preset_ids:
|
||||||
|
return "sequences"
|
||||||
|
return "presets"
|
||||||
|
|
||||||
|
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()
|
next_id = self.get_next_id()
|
||||||
gid_list = []
|
gid_list = []
|
||||||
if isinstance(group_ids, list):
|
if isinstance(group_ids, list):
|
||||||
gid_list = [str(x) for x in group_ids if x is not None]
|
gid_list = [str(x).strip() for x in group_ids if x is not None and str(x).strip()]
|
||||||
self[next_id] = {
|
doc = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"names": names if names else [],
|
"names": names if names else [],
|
||||||
"group_ids": gid_list,
|
"group_ids": gid_list,
|
||||||
|
"preset_group_ids": {},
|
||||||
"presets": presets if presets else [],
|
"presets": presets if presets else [],
|
||||||
"default_preset": None,
|
"default_preset": None,
|
||||||
"brightness": 255,
|
"brightness": 255,
|
||||||
}
|
}
|
||||||
|
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()
|
self.save()
|
||||||
return next_id
|
return next_id
|
||||||
|
|
||||||
@@ -63,7 +134,10 @@ class Zone(Model):
|
|||||||
id_str = str(id)
|
id_str = str(id)
|
||||||
if id_str not in self:
|
if id_str not in self:
|
||||||
return False
|
return False
|
||||||
self[id_str].update(data)
|
patch = data if isinstance(data, dict) else {}
|
||||||
|
self[id_str].update(patch)
|
||||||
|
if "content_kind" in patch:
|
||||||
|
self._enforce_content_kind_invariants(self[id_str])
|
||||||
self.save()
|
self.save()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ def _settings_path():
|
|||||||
return "settings.json"
|
return "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
_settings_singleton: "Settings | None" = None
|
||||||
|
|
||||||
|
|
||||||
class Settings(dict):
|
class Settings(dict):
|
||||||
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, *, quiet: bool = False):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self._quiet = quiet
|
||||||
if Settings.SETTINGS_FILE is None:
|
if Settings.SETTINGS_FILE is None:
|
||||||
Settings.SETTINGS_FILE = _settings_path()
|
Settings.SETTINGS_FILE = _settings_path()
|
||||||
self.load() # Load settings from file during initialization
|
self.load() # Load settings from file during initialization
|
||||||
@@ -53,12 +57,9 @@ class Settings(dict):
|
|||||||
self['wifi_driver_ws_port'] = 80
|
self['wifi_driver_ws_port'] = 80
|
||||||
if 'wifi_driver_ws_path' not in self:
|
if 'wifi_driver_ws_path' not in self:
|
||||||
self['wifi_driver_ws_path'] = '/ws'
|
self['wifi_driver_ws_path'] = '/ws'
|
||||||
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is
|
# Legacy (unused): periodic UDP nudges removed; connect only on driver hello.
|
||||||
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
|
|
||||||
if 'wifi_driver_hello_interval_s' not in self:
|
if 'wifi_driver_hello_interval_s' not in self:
|
||||||
self['wifi_driver_hello_interval_s'] = 10.0
|
self['wifi_driver_hello_interval_s'] = 0
|
||||||
# Legacy key (no longer read): initial outbound dial limit uses
|
|
||||||
# wifi_driver_initial_connect_attempts instead.
|
|
||||||
if 'wifi_driver_connect_retry_window_s' not in self:
|
if 'wifi_driver_connect_retry_window_s' not in self:
|
||||||
self['wifi_driver_connect_retry_window_s'] = 120.0
|
self['wifi_driver_connect_retry_window_s'] = 120.0
|
||||||
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
|
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
|
||||||
@@ -70,7 +71,7 @@ class Settings(dict):
|
|||||||
# Pause between outbound WebSocket dial attempts (seconds).
|
# Pause between outbound WebSocket dial attempts (seconds).
|
||||||
if 'wifi_driver_connect_retry_interval_s' not in self:
|
if 'wifi_driver_connect_retry_interval_s' not in self:
|
||||||
self['wifi_driver_connect_retry_interval_s'] = 2.0
|
self['wifi_driver_connect_retry_interval_s'] = 2.0
|
||||||
# Outbound dial attempts to the saved driver IP before first success; then wait for UDP discovery.
|
# Outbound WebSocket dial attempts per driver UDP hello (then wait for next hello).
|
||||||
if 'wifi_driver_initial_connect_attempts' not in self:
|
if 'wifi_driver_initial_connect_attempts' not in self:
|
||||||
self['wifi_driver_initial_connect_attempts'] = 4
|
self['wifi_driver_initial_connect_attempts'] = 4
|
||||||
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
|
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
|
||||||
@@ -79,13 +80,22 @@ class Settings(dict):
|
|||||||
# Zone UI global brightness (0–255); shared across browsers/devices.
|
# Zone UI global brightness (0–255); shared across browsers/devices.
|
||||||
if 'global_brightness' not in self:
|
if 'global_brightness' not in self:
|
||||||
self['global_brightness'] = 255
|
self['global_brightness'] = 255
|
||||||
|
# Sequence tile start: wait for beat or downbeat (server-owned).
|
||||||
|
if 'sequence_switch_wait' not in self:
|
||||||
|
self['sequence_switch_wait'] = 'beat'
|
||||||
|
elif str(self.get('sequence_switch_wait', '')).strip().lower() == 'phrase':
|
||||||
|
self['sequence_switch_wait'] = 'beat'
|
||||||
|
# Beat flash alignment delay (ms); applied by all UI clients polling audio status.
|
||||||
|
if 'audio_beat_phase_ms' not in self:
|
||||||
|
self['audio_beat_phase_ms'] = 0
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
try:
|
try:
|
||||||
j = json.dumps(self)
|
j = json.dumps(self)
|
||||||
with open(self.SETTINGS_FILE, 'w') as file:
|
with open(self.SETTINGS_FILE, 'w') as file:
|
||||||
file.write(j)
|
file.write(j)
|
||||||
print("Settings saved successfully.")
|
if not getattr(self, "_quiet", False):
|
||||||
|
print("Settings saved successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving settings: {e}")
|
print(f"Error saving settings: {e}")
|
||||||
|
|
||||||
@@ -96,9 +106,11 @@ class Settings(dict):
|
|||||||
loaded_settings = json.load(file)
|
loaded_settings = json.load(file)
|
||||||
self.update(loaded_settings)
|
self.update(loaded_settings)
|
||||||
loaded_from_file = True
|
loaded_from_file = True
|
||||||
print("Settings loaded successfully.")
|
if not getattr(self, "_quiet", False):
|
||||||
|
print("Settings loaded successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading settings")
|
if not getattr(self, "_quiet", False):
|
||||||
|
print(f"Error loading settings: {e}")
|
||||||
self.clear()
|
self.clear()
|
||||||
finally:
|
finally:
|
||||||
# Ensure defaults are set even if file exists but is missing keys
|
# Ensure defaults are set even if file exists but is missing keys
|
||||||
@@ -106,3 +118,18 @@ class Settings(dict):
|
|||||||
# Only save if file didn't exist or was invalid
|
# Only save if file didn't exist or was invalid
|
||||||
if not loaded_from_file:
|
if not loaded_from_file:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Process-wide settings instance (avoid re-reading settings.json on every request)."""
|
||||||
|
global _settings_singleton
|
||||||
|
if _settings_singleton is None:
|
||||||
|
_settings_singleton = Settings()
|
||||||
|
return _settings_singleton
|
||||||
|
|
||||||
|
|
||||||
|
def reload_settings() -> Settings:
|
||||||
|
"""Re-read settings.json (e.g. after external file edit)."""
|
||||||
|
global _settings_singleton
|
||||||
|
_settings_singleton = Settings(quiet=True)
|
||||||
|
return _settings_singleton
|
||||||
|
|||||||
@@ -1,53 +1,59 @@
|
|||||||
(() => {
|
(() => {
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let lastBeatSeq = 0;
|
let lastBeatSeq = 0;
|
||||||
|
let lastLoggedSequenceBeatFractions = "";
|
||||||
const STORAGE_KEY = "led-controller-audio-restore";
|
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
|
||||||
const STORAGE_VERSION = 1;
|
let prevZoneSequencePlaybackActive = false;
|
||||||
|
/**
|
||||||
function readRestorePrefs() {
|
* After sequence playback ends/stops while audio keeps running, keep header # idle until the
|
||||||
try {
|
* next beat bumps `beat_seq` (avoids the stuck final cumulative value vs sequence readout).
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
*/
|
||||||
if (!raw) return null;
|
let headerBeatStickyIdleAfterSeq = false;
|
||||||
const o = JSON.parse(raw);
|
/** Suppresses duplicate `console.log` when the same `beat_seq` + server `beat_readout` repeats. */
|
||||||
if (!o || o.v !== STORAGE_VERSION || !o.restore) return null;
|
let lastBeatConsoleKey = "";
|
||||||
return {
|
/** @type {Set<ReturnType<typeof setTimeout>>} */
|
||||||
override: typeof o.override === "string" ? o.override : "",
|
const pendingBeatPhaseTimers = new Set();
|
||||||
select: typeof o.select === "string" ? o.select : "",
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeRestorePrefs(override, select) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
v: STORAGE_VERSION,
|
|
||||||
restore: true,
|
|
||||||
override: override || "",
|
|
||||||
select: select || "",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("audio restore prefs save failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearRestorePrefs() {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("audio restore prefs clear failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function el(id) {
|
function el(id) {
|
||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {Record<string, unknown>} status */
|
||||||
|
function updateBeatReadoutDisplays(status) {
|
||||||
|
const text = String((status && status.beat_readout) || "").trim();
|
||||||
|
for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) {
|
||||||
|
const n = el(id);
|
||||||
|
if (n) n.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On each new audio `beat_seq`, log server `beat_readout` once (deduped when poll repeats the
|
||||||
|
* same `beat_seq` + line).
|
||||||
|
* @param {Record<string, unknown>} status
|
||||||
|
*/
|
||||||
|
function logServerBeatConsoleOnPollEdge(status) {
|
||||||
|
const beatSeq = Number((status && status.beat_seq) || 0);
|
||||||
|
const line = String((status && status.beat_readout) || "").trim();
|
||||||
|
const key = `${beatSeq}\t${line}`;
|
||||||
|
if (key !== lastBeatConsoleKey) {
|
||||||
|
lastBeatConsoleKey = key;
|
||||||
|
if (!line) return;
|
||||||
|
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
|
||||||
|
const seqBeats = !!seq && !!seq.active;
|
||||||
|
let out = line;
|
||||||
|
if (seqBeats) {
|
||||||
|
const nLanes = Number(seq && seq.num_lanes);
|
||||||
|
const lanesNote =
|
||||||
|
Number.isFinite(nLanes) && nLanes > 1
|
||||||
|
? `lane 1 of ${nLanes} (readout is for this lane only)`
|
||||||
|
: "lane 1";
|
||||||
|
out = `${line} — ${lanesNote}`;
|
||||||
|
}
|
||||||
|
console.log(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateBpmDisplay(bpm) {
|
function updateBpmDisplay(bpm) {
|
||||||
const node = el("audio-bpm-value");
|
const node = el("audio-bpm-value");
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -58,11 +64,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBeatCounter(seq) {
|
/** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
|
||||||
const topNode = el("audio-top-beat-count");
|
function sequencePlaybackActiveFromStatus(status) {
|
||||||
if (!topNode) return;
|
const seq = /** @type {Record<string, unknown>|undefined} */ (
|
||||||
const n = Number(seq);
|
status && status.sequence
|
||||||
topNode.textContent = Number.isFinite(n) && n >= 0 ? `#${Math.floor(n)}` : "#0";
|
);
|
||||||
|
return !!(seq && seq.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build sequence beat fractions for debug logging (browser console only). */
|
||||||
|
function formatSequenceBeatFractionsForLog(status) {
|
||||||
|
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
|
||||||
|
if (!seq || !seq.active) return null;
|
||||||
|
|
||||||
|
const laneBeatAt = Number(seq.lane0_beat_in_step);
|
||||||
|
const laneBeatsPerStep = Number(seq.lane0_beats_per_step);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(laneBeatAt) ||
|
||||||
|
laneBeatAt <= 0 ||
|
||||||
|
!Number.isFinite(laneBeatsPerStep) ||
|
||||||
|
laneBeatsPerStep <= 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const presetFraction = `${Math.floor(laneBeatAt)}/${Math.floor(laneBeatsPerStep)}`;
|
||||||
|
|
||||||
|
const sequenceBeatAt = Number(seq.sequence_beat_at);
|
||||||
|
const sequenceBeatsPerPass = Number(seq.sequence_beats_per_pass);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(sequenceBeatAt) ||
|
||||||
|
sequenceBeatAt <= 0 ||
|
||||||
|
!Number.isFinite(sequenceBeatsPerPass) ||
|
||||||
|
sequenceBeatsPerPass <= 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sequenceFraction = `${Math.floor(sequenceBeatAt)}/${Math.floor(sequenceBeatsPerPass)}`;
|
||||||
|
|
||||||
|
return `${presetFraction} ${sequenceFraction}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHitTypeDisplay(hitType, confidence) {
|
function updateHitTypeDisplay(hitType, confidence) {
|
||||||
@@ -73,33 +112,156 @@
|
|||||||
node.textContent = `${label}${conf}`;
|
node.textContent = `${label}${conf}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {Record<string, unknown>} status */
|
||||||
|
function updateBarPhaseDisplay(status) {
|
||||||
|
const readout = String((status && status.bar_phase_readout) || "").trim();
|
||||||
|
const phaseConf = Number((status && status.phase_confidence) || 0);
|
||||||
|
const downbeat = !!(status && status.is_downbeat);
|
||||||
|
let text = readout || "--";
|
||||||
|
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
|
||||||
|
text = `${text} (${Math.round(phaseConf * 100)}%)`;
|
||||||
|
}
|
||||||
|
for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) {
|
||||||
|
const node = el(id);
|
||||||
|
if (!node) continue;
|
||||||
|
node.textContent = status && status.running ? text : "";
|
||||||
|
node.classList.toggle("is-downbeat", downbeat && !!readout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setTopBpmVisible(on) {
|
function setTopBpmVisible(on) {
|
||||||
const top = el("audio-top-indicator");
|
const top = el("audio-top-indicator");
|
||||||
if (!top) return;
|
if (!top) return;
|
||||||
top.classList.toggle("audio-running", !!on);
|
top.classList.toggle("audio-running", !!on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setNavResetVisible(on) {
|
||||||
|
for (const id of ["audio-nav-reset-btn", "audio-nav-reset-mobile"]) {
|
||||||
|
const node = el(id);
|
||||||
|
if (node) node.hidden = !on;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetAudioTracking() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/audio/reset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
console.warn("audio reset failed", data.error || res.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pollStatus();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("audio reset failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSequenceSyncControls(zoneSeqActive) {
|
||||||
|
const topSync = el("audio-top-beat-sync");
|
||||||
|
if (topSync) topSync.disabled = !zoneSeqActive;
|
||||||
|
const modalBeat = el("audio-modal-beat-readout");
|
||||||
|
if (modalBeat) modalBeat.disabled = !zoneSeqActive;
|
||||||
|
const passBtn = el("audio-sync-pass-btn");
|
||||||
|
if (passBtn) passBtn.disabled = !zoneSeqActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncSequenceBeatPhase(mode) {
|
||||||
|
const res = await fetch("/sequences/sync-phase", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ mode: mode || "step" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Sync failed (${res.status})`);
|
||||||
|
}
|
||||||
|
await pollStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTypingTarget(target) {
|
||||||
|
if (!target || typeof target !== "object") return false;
|
||||||
|
const tag = String(target.tagName || "").toLowerCase();
|
||||||
|
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
function flashBeat() {
|
function flashBeat() {
|
||||||
const node = el("audio-beat-flash");
|
const node = el("audio-beat-flash");
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
node.classList.add("active");
|
node.classList.add("active");
|
||||||
setTimeout(() => node.classList.remove("active"), 80);
|
setTimeout(() => node.classList.remove("active"), 80);
|
||||||
|
const syncBtn = el("audio-top-beat-sync");
|
||||||
const top = el("audio-top-indicator");
|
const top = el("audio-top-indicator");
|
||||||
if (top && top.classList.contains("audio-running")) {
|
if (syncBtn && top && top.classList.contains("audio-running")) {
|
||||||
top.classList.add("flash");
|
syncBtn.classList.add("flash");
|
||||||
setTimeout(() => top.classList.remove("flash"), 90);
|
setTimeout(() => syncBtn.classList.remove("flash"), 90);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearBeatPhaseTimers() {
|
||||||
|
pendingBeatPhaseTimers.forEach((t) => clearTimeout(t));
|
||||||
|
pendingBeatPhaseTimers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBeatPhaseDelayMs() {
|
||||||
|
const inp = el("audio-beat-phase-ms");
|
||||||
|
if (inp && String(inp.value).trim() !== "") {
|
||||||
|
const n = parseInt(String(inp.value).trim(), 10);
|
||||||
|
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistBeatPhaseMs() {
|
||||||
|
const ms = getBeatPhaseDelayMs();
|
||||||
|
try {
|
||||||
|
await fetch("/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ audio_beat_phase_ms: ms }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("beat phase ms save failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBeatPhaseFire(seq, delayMs) {
|
||||||
|
let tid = null;
|
||||||
|
const run = () => {
|
||||||
|
if (tid != null) pendingBeatPhaseTimers.delete(tid);
|
||||||
|
flashBeat();
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (delayMs <= 0) {
|
||||||
|
run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tid = setTimeout(run, delayMs);
|
||||||
|
pendingBeatPhaseTimers.add(tid);
|
||||||
|
}
|
||||||
|
|
||||||
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
||||||
async function stopAudioOnly() {
|
async function stopAudioOnly() {
|
||||||
setTopBpmVisible(false);
|
setTopBpmVisible(false);
|
||||||
|
setNavResetVisible(false);
|
||||||
|
clearBeatPhaseTimers();
|
||||||
if (pollTimer) {
|
if (pollTimer) {
|
||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
}
|
}
|
||||||
lastBeatSeq = 0;
|
lastBeatSeq = 0;
|
||||||
updateBeatCounter(0);
|
prevZoneSequencePlaybackActive = false;
|
||||||
|
headerBeatStickyIdleAfterSeq = false;
|
||||||
|
lastBeatConsoleKey = "";
|
||||||
|
updateBeatReadoutDisplays({});
|
||||||
try {
|
try {
|
||||||
await fetch("/api/audio/stop", { method: "POST" });
|
await fetch("/api/audio/stop", { method: "POST" });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -107,15 +269,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** User-initiated stop: also forget auto-restart on next page load. */
|
/** User-initiated stop (run intent cleared on server). */
|
||||||
async function stopAudio() {
|
async function stopAudio() {
|
||||||
await stopAudioOnly();
|
await stopAudioOnly();
|
||||||
clearRestorePrefs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollStatus() {
|
async function pollStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/audio/status");
|
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const status = data?.status || {};
|
const status = data?.status || {};
|
||||||
if (status.error && String(status.error).trim()) {
|
if (status.error && String(status.error).trim()) {
|
||||||
@@ -123,23 +284,66 @@
|
|||||||
if (node) {
|
if (node) {
|
||||||
node.textContent = String(status.error).trim().slice(0, 120);
|
node.textContent = String(status.error).trim().slice(0, 120);
|
||||||
}
|
}
|
||||||
|
updateBeatReadoutDisplays({});
|
||||||
updateBpmDisplay(null);
|
updateBpmDisplay(null);
|
||||||
setTopBpmVisible(!!status.running);
|
setTopBpmVisible(!!status.running);
|
||||||
|
setNavResetVisible(!!status.running);
|
||||||
if (!status.running && pollTimer) {
|
if (!status.running && pollTimer) {
|
||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTopBpmVisible(!!status.running);
|
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
||||||
|
setTopBpmVisible(!!status.running || zoneSeqActive);
|
||||||
|
setNavResetVisible(!!status.running);
|
||||||
|
updateSequenceSyncControls(zoneSeqActive);
|
||||||
updateBpmDisplay(status.bpm);
|
updateBpmDisplay(status.bpm);
|
||||||
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
|
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
|
||||||
const seq = Number(status.beat_seq || 0);
|
updateBarPhaseDisplay(status);
|
||||||
updateBeatCounter(seq);
|
applyServerAudioUiFields(status);
|
||||||
if (seq > lastBeatSeq) {
|
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
|
||||||
lastBeatSeq = seq;
|
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
|
||||||
flashBeat();
|
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle
|
||||||
|
* after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
|
||||||
|
* `sequence` on each poll.
|
||||||
|
*/
|
||||||
|
const beatSeq = Number(status.beat_seq || 0);
|
||||||
|
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
|
||||||
|
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
|
||||||
|
prevZoneSequencePlaybackActive = zoneSeqActive;
|
||||||
|
if (startedSeq) {
|
||||||
|
headerBeatStickyIdleAfterSeq = false;
|
||||||
|
lastLoggedSequenceBeatFractions = "";
|
||||||
|
}
|
||||||
|
if (endedSeq) {
|
||||||
|
headerBeatStickyIdleAfterSeq = true;
|
||||||
|
clearBeatPhaseTimers();
|
||||||
|
lastBeatSeq = beatSeq;
|
||||||
|
}
|
||||||
|
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
|
||||||
|
if (beatSeq > lastBeatSeq) {
|
||||||
|
lastBeatSeq = beatSeq;
|
||||||
|
logServerBeatConsoleOnPollEdge(status);
|
||||||
|
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
|
||||||
|
headerBeatStickyIdleAfterSeq = false;
|
||||||
|
}
|
||||||
|
} else if (beatSeq > lastBeatSeq) {
|
||||||
|
lastBeatSeq = beatSeq;
|
||||||
|
logServerBeatConsoleOnPollEdge(status);
|
||||||
|
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
|
||||||
|
}
|
||||||
|
const beatFractions = formatSequenceBeatFractionsForLog(status);
|
||||||
|
if (beatFractions) {
|
||||||
|
if (beatFractions !== lastLoggedSequenceBeatFractions) {
|
||||||
|
lastLoggedSequenceBeatFractions = beatFractions;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastLoggedSequenceBeatFractions = "";
|
||||||
|
}
|
||||||
|
updateBeatReadoutDisplays(status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("audio status poll failed", e);
|
console.warn("audio status poll failed", e);
|
||||||
}
|
}
|
||||||
@@ -151,7 +355,11 @@
|
|||||||
const selected = el("audio-device-select")?.value || "";
|
const selected = el("audio-device-select")?.value || "";
|
||||||
const rawDevice = override !== "" ? override : selected;
|
const rawDevice = override !== "" ? override : selected;
|
||||||
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
|
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
|
||||||
const body = { device: rawDevice === "" ? null : numeric };
|
const body = {
|
||||||
|
device: rawDevice === "" ? null : numeric,
|
||||||
|
device_override: override,
|
||||||
|
device_select: selected,
|
||||||
|
};
|
||||||
const res = await fetch("/api/audio/start", {
|
const res = await fetch("/api/audio/start", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -161,10 +369,8 @@
|
|||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error || "Failed to start audio detector");
|
throw new Error(data.error || "Failed to start audio detector");
|
||||||
}
|
}
|
||||||
writeRestorePrefs(override, selected);
|
|
||||||
updateBpmDisplay(null);
|
updateBpmDisplay(null);
|
||||||
updateHitTypeDisplay("unknown", NaN);
|
updateHitTypeDisplay("unknown", NaN);
|
||||||
updateBeatCounter(0);
|
|
||||||
pollTimer = setInterval(pollStatus, 250);
|
pollTimer = setInterval(pollStatus, 250);
|
||||||
await pollStatus();
|
await pollStatus();
|
||||||
}
|
}
|
||||||
@@ -210,6 +416,7 @@
|
|||||||
const closeBtn = el("audio-close-btn");
|
const closeBtn = el("audio-close-btn");
|
||||||
const startBtn = el("audio-start-btn");
|
const startBtn = el("audio-start-btn");
|
||||||
const stopBtn = el("audio-stop-btn");
|
const stopBtn = el("audio-stop-btn");
|
||||||
|
const navResetBtn = el("audio-nav-reset-btn");
|
||||||
const refreshBtn = el("audio-refresh-btn");
|
const refreshBtn = el("audio-refresh-btn");
|
||||||
if (!modal || !openBtn) return;
|
if (!modal || !openBtn) return;
|
||||||
|
|
||||||
@@ -242,6 +449,9 @@
|
|||||||
await stopAudio();
|
await stopAudio();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (navResetBtn) {
|
||||||
|
navResetBtn.addEventListener("click", () => resetAudioTracking());
|
||||||
|
}
|
||||||
if (refreshBtn) {
|
if (refreshBtn) {
|
||||||
refreshBtn.addEventListener("click", async () => {
|
refreshBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
@@ -252,17 +462,49 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const phaseInp = el("audio-beat-phase-ms");
|
||||||
|
if (phaseInp) {
|
||||||
|
phaseInp.addEventListener("change", () => {
|
||||||
|
void persistBeatPhaseMs();
|
||||||
|
});
|
||||||
|
phaseInp.addEventListener("input", () => {
|
||||||
|
void persistBeatPhaseMs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindSync = (node, mode) => {
|
||||||
|
if (!node) return;
|
||||||
|
node.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await syncSequenceBeatPhase(mode);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("sequence beat sync failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
bindSync(el("audio-top-beat-sync"), "step");
|
||||||
|
bindSync(el("audio-modal-beat-readout"), "step");
|
||||||
|
bindSync(el("audio-sync-pass-btn"), "pass");
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
|
||||||
|
const k = String(ev.key || "").toLowerCase();
|
||||||
|
if (k !== "s") return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const mode = ev.shiftKey ? "pass" : "step";
|
||||||
|
void syncSequenceBeatPhase(mode).catch((e) => console.warn("sequence beat sync failed", e));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumePollingIfDetectorRunning() {
|
async function resumePollingIfDetectorRunning() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/audio/status");
|
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const status = data?.status || {};
|
const status = data?.status || {};
|
||||||
if (status.running && !pollTimer) {
|
if (status.running && !pollTimer) {
|
||||||
pollTimer = setInterval(pollStatus, 250);
|
pollTimer = setInterval(pollStatus, 250);
|
||||||
lastBeatSeq = Number(status.beat_seq || 0);
|
lastBeatSeq = Number(status.beat_seq || 0);
|
||||||
updateBeatCounter(lastBeatSeq);
|
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
|
||||||
await pollStatus();
|
await pollStatus();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -270,30 +512,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreAudioIfNeeded() {
|
/** Apply server-owned audio UI fields from status (device form, beat phase delay). */
|
||||||
if (pollTimer) return;
|
function applyServerAudioUiFields(status) {
|
||||||
const prefs = readRestorePrefs();
|
if (!status || typeof status !== "object") return;
|
||||||
if (!prefs) return;
|
const run = status.audio_run;
|
||||||
const ov = el("audio-device-override");
|
if (run && typeof run === "object") {
|
||||||
const sel = el("audio-device-select");
|
const ov = el("audio-device-override");
|
||||||
if (ov) ov.value = prefs.override || "";
|
const sel = el("audio-device-select");
|
||||||
try {
|
if (ov && run.device_override != null) ov.value = String(run.device_override);
|
||||||
await refreshDevices();
|
if (sel && run.device_select) sel.value = String(run.device_select);
|
||||||
} catch (e) {
|
|
||||||
console.warn("audio restore refresh devices failed", e);
|
|
||||||
}
|
}
|
||||||
if (sel && prefs.select) sel.value = prefs.select;
|
const phaseInp = el("audio-beat-phase-ms");
|
||||||
try {
|
if (
|
||||||
await startAudio();
|
phaseInp &&
|
||||||
} catch (e) {
|
status.beat_phase_ms != null &&
|
||||||
console.warn("audio auto-restart failed", e);
|
document.activeElement !== phaseInp
|
||||||
clearRestorePrefs();
|
) {
|
||||||
|
const ms = parseInt(String(status.beat_phase_ms), 10);
|
||||||
|
if (Number.isFinite(ms)) {
|
||||||
|
phaseInp.value = String(Math.min(500, Math.max(0, ms)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadServerAudioUiFields() {
|
||||||
|
try {
|
||||||
|
await refreshDevices();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("audio device list refresh failed", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||||
|
const data = await res.json();
|
||||||
|
applyServerAudioUiFields(data?.status || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("audio status load failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from sequences.js when server playback starts/stops without audio polling. */
|
||||||
|
window.ledControllerSequencePlaybackChanged = (active) => {
|
||||||
|
updateSequenceSyncControls(!!active);
|
||||||
|
if (active) {
|
||||||
|
setTopBpmVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pollTimer) {
|
||||||
|
setTopBpmVisible(false);
|
||||||
|
updateSequenceSyncControls(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
bind();
|
bind();
|
||||||
|
await loadServerAudioUiFields();
|
||||||
await resumePollingIfDetectorRunning();
|
await resumePollingIfDetectorRunning();
|
||||||
await restoreAudioIfNeeded();
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
48
src/static/bundle_io.js
Normal file
48
src/static/bundle_io.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/** Download/upload JSON bundles for profile, preset, and sequence import/export. */
|
||||||
|
|
||||||
|
window.downloadJsonFile = function downloadJsonFile(filename, data) {
|
||||||
|
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([text], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename || 'bundle.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pickJsonFile = function pickJsonFile() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'application/json,.json';
|
||||||
|
input.style.display = 'none';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
input.remove();
|
||||||
|
if (!file) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = () => resolve(null);
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.parseJsonFileText = function parseJsonFileText(text) {
|
||||||
|
if (text == null || text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
25
src/static/dev-live-reload.js
Normal file
25
src/static/dev-live-reload.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/* Polls server build id; full reload when watchfiles restarts Python (new process = new id). */
|
||||||
|
(function () {
|
||||||
|
var prev = null;
|
||||||
|
function tick() {
|
||||||
|
fetch('/__dev/build-id', { cache: 'no-store', credentials: 'same-origin' })
|
||||||
|
.then(function (r) {
|
||||||
|
return r.ok ? r.text() : '';
|
||||||
|
})
|
||||||
|
.then(function (id) {
|
||||||
|
id = (id || '').trim();
|
||||||
|
if (!id) return;
|
||||||
|
if (prev === null) {
|
||||||
|
prev = id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id !== prev) {
|
||||||
|
prev = id;
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
setInterval(tick, 750);
|
||||||
|
tick();
|
||||||
|
})();
|
||||||
@@ -1,8 +1,27 @@
|
|||||||
// Device groups: members (MAC ids) + Wi‑Fi driver defaults; persisted via /groups.
|
// Device groups: members (MAC ids) + Wi‑Fi driver defaults; persisted via /groups.
|
||||||
|
// Without ``profile_id``, a group is shared across all profiles; with ``profile_id`` it is listed only for that profile.
|
||||||
|
|
||||||
|
async function getCurrentProfileIdForGroups() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/profiles/current', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
const id = data && (data.id || (data.profile && data.profile.id));
|
||||||
|
return id != null ? String(id) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchGroupsMap() {
|
async function fetchGroupsMap() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/groups', { headers: { Accept: 'application/json' } });
|
const response = await fetch('/groups', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
if (!response.ok) return {};
|
if (!response.ok) return {};
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data && typeof data === 'object' ? data : {};
|
return data && typeof data === 'object' ? data : {};
|
||||||
@@ -137,6 +156,14 @@ function refreshEditGroupDebug() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncGroupShareCheckboxFromDoc(g) {
|
||||||
|
const cb = document.getElementById('edit-group-share-all-profiles');
|
||||||
|
if (!cb) return;
|
||||||
|
const raw = g && (g.profile_id != null ? g.profile_id : g.profileId);
|
||||||
|
const scoped = raw != null && String(raw).trim() !== '';
|
||||||
|
cb.checked = !scoped;
|
||||||
|
}
|
||||||
|
|
||||||
function loadWifiFieldsFromGroup(g) {
|
function loadWifiFieldsFromGroup(g) {
|
||||||
const wName = document.getElementById('edit-group-wifi-driver-name');
|
const wName = document.getElementById('edit-group-wifi-driver-name');
|
||||||
const wLeds = document.getElementById('edit-group-wifi-num-leds');
|
const wLeds = document.getElementById('edit-group-wifi-num-leds');
|
||||||
@@ -189,7 +216,10 @@ async function openEditGroupModal(groupId, groupDoc) {
|
|||||||
let g = groupDoc;
|
let g = groupDoc;
|
||||||
if (!g || typeof g !== 'object') {
|
if (!g || typeof g !== 'object') {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`);
|
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
if (response.ok) g = await response.json();
|
if (response.ok) g = await response.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -217,6 +247,7 @@ async function openEditGroupModal(groupId, groupDoc) {
|
|||||||
});
|
});
|
||||||
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
|
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
|
||||||
loadWifiFieldsFromGroup(g);
|
loadWifiFieldsFromGroup(g);
|
||||||
|
syncGroupShareCheckboxFromDoc(g);
|
||||||
refreshEditGroupDebug();
|
refreshEditGroupDebug();
|
||||||
if (modal) modal.classList.add('active');
|
if (modal) modal.classList.add('active');
|
||||||
}
|
}
|
||||||
@@ -259,8 +290,13 @@ function renderGroupsList(groups) {
|
|||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
const devs = Array.isArray(g.devices) ? g.devices : [];
|
const devs = Array.isArray(g.devices) ? g.devices : [];
|
||||||
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
|
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
|
||||||
label.style.flex = '1';
|
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'muted-text';
|
||||||
|
meta.style.fontSize = '0.8em';
|
||||||
|
const rawPid = g.profile_id != null ? g.profile_id : g.profileId;
|
||||||
|
const scoped = rawPid != null && String(rawPid).trim() !== '';
|
||||||
|
meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles';
|
||||||
const editBtn = document.createElement('button');
|
const editBtn = document.createElement('button');
|
||||||
editBtn.className = 'btn btn-secondary btn-small';
|
editBtn.className = 'btn btn-secondary btn-small';
|
||||||
editBtn.textContent = 'Edit';
|
editBtn.textContent = 'Edit';
|
||||||
@@ -326,13 +362,26 @@ function renderGroupsList(groups) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const identifyBtn = document.createElement('button');
|
||||||
|
identifyBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
identifyBtn.type = 'button';
|
||||||
|
identifyBtn.textContent = 'Identify';
|
||||||
|
identifyBtn.title =
|
||||||
|
'Identify all devices in this group at once (red blink at 10 Hz)';
|
||||||
|
identifyBtn.addEventListener('click', async () => {
|
||||||
|
await identifyGroupById(gid);
|
||||||
|
});
|
||||||
|
|
||||||
const delBtn = document.createElement('button');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.className = 'btn btn-danger btn-small';
|
delBtn.className = 'btn btn-danger btn-small';
|
||||||
delBtn.textContent = 'Delete';
|
delBtn.textContent = 'Delete';
|
||||||
delBtn.addEventListener('click', async () => {
|
delBtn.addEventListener('click', async () => {
|
||||||
if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return;
|
if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'DELETE' });
|
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
if (res.ok) await loadGroupsModal();
|
if (res.ok) await loadGroupsModal();
|
||||||
else {
|
else {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -344,15 +393,49 @@ function renderGroupsList(groups) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
row.appendChild(label);
|
const left = document.createElement('div');
|
||||||
|
left.style.flex = '1';
|
||||||
|
left.style.minWidth = '0';
|
||||||
|
left.appendChild(label);
|
||||||
|
left.appendChild(meta);
|
||||||
|
row.appendChild(left);
|
||||||
row.appendChild(editBtn);
|
row.appendChild(editBtn);
|
||||||
row.appendChild(brightBtn);
|
row.appendChild(brightBtn);
|
||||||
row.appendChild(applyBtn);
|
row.appendChild(applyBtn);
|
||||||
|
row.appendChild(identifyBtn);
|
||||||
row.appendChild(delBtn);
|
row.appendChild(delBtn);
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function identifyGroupById(gid) {
|
||||||
|
if (!gid) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/groups/${encodeURIComponent(gid)}/identify`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
alert(data.error || 'Identify failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = typeof data.sent === 'number' ? data.sent : 0;
|
||||||
|
const errs = Array.isArray(data.errors) ? data.errors : [];
|
||||||
|
const failed = errs.filter((e) => e && e.error).length;
|
||||||
|
let msg = n ? `Identify sent to ${n} device(s).` : 'No devices received identify.';
|
||||||
|
if (failed) {
|
||||||
|
msg += ` ${failed} failed — see console for details.`;
|
||||||
|
console.warn('Group identify errors', errs);
|
||||||
|
}
|
||||||
|
alert(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Identify failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const groupsBtn = document.getElementById('groups-btn');
|
const groupsBtn = document.getElementById('groups-btn');
|
||||||
const groupsModal = document.getElementById('groups-modal');
|
const groupsModal = document.getElementById('groups-modal');
|
||||||
@@ -381,14 +464,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editIdentifyBtn = document.getElementById('edit-group-identify-btn');
|
||||||
|
if (editIdentifyBtn) {
|
||||||
|
editIdentifyBtn.addEventListener('click', async () => {
|
||||||
|
const idInput = document.getElementById('edit-group-id');
|
||||||
|
const gid = idInput && idInput.value;
|
||||||
|
if (!gid) return;
|
||||||
|
await identifyGroupById(gid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const createHandler = async () => {
|
const createHandler = async () => {
|
||||||
const name = newNameInput && newNameInput.value.trim();
|
const name = newNameInput && newNameInput.value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
const profileOnly = document.getElementById('new-group-profile-only');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/groups', {
|
const res = await fetch('/groups', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
profile_scoped: !!(profileOnly && profileOnly.checked),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -396,6 +494,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newNameInput) newNameInput.value = '';
|
if (newNameInput) newNameInput.value = '';
|
||||||
|
if (profileOnly) profileOnly.checked = false;
|
||||||
await loadGroupsModal();
|
await loadGroupsModal();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -417,9 +516,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const { gid, payload } = collectGroupEditPayload();
|
const { gid, payload } = collectGroupEditPayload();
|
||||||
if (!gid) return;
|
if (!gid) return;
|
||||||
|
|
||||||
|
const shareCb = document.getElementById('edit-group-share-all-profiles');
|
||||||
|
if (shareCb && shareCb.checked) {
|
||||||
|
payload.profile_id = null;
|
||||||
|
} else {
|
||||||
|
const pid = await getCurrentProfileIdForGroups();
|
||||||
|
payload.profile_id = pid || null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
|
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
@@ -449,4 +557,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (editCloseBtn && editModal) {
|
if (editCloseBtn && editModal) {
|
||||||
editCloseBtn.addEventListener('click', () => editModal.classList.remove('active'));
|
editCloseBtn.addEventListener('click', () => editModal.classList.remove('active'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.openDeviceGroupsModal = async () => {
|
||||||
|
const gm = document.getElementById('groups-modal');
|
||||||
|
if (!gm) return;
|
||||||
|
gm.classList.add('active');
|
||||||
|
try {
|
||||||
|
await loadGroupsModal();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('openDeviceGroupsModal', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/settings/settings', {
|
const response = await fetch('/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -2,254 +2,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const openBtn = document.getElementById('led-tool-btn');
|
const openBtn = document.getElementById('led-tool-btn');
|
||||||
const modal = document.getElementById('led-tool-modal');
|
const modal = document.getElementById('led-tool-modal');
|
||||||
const closeBtn = document.getElementById('led-tool-close-btn');
|
const closeBtn = document.getElementById('led-tool-close-btn');
|
||||||
const refreshPortsBtn = document.getElementById('led-tool-refresh-ports-btn');
|
const iframe = document.getElementById('led-tool-iframe');
|
||||||
const form = document.getElementById('led-tool-form');
|
|
||||||
const readBtn = document.getElementById('led-tool-read-btn');
|
|
||||||
const resetBtn = document.getElementById('led-tool-reset-btn');
|
|
||||||
const portSelect = document.getElementById('led-tool-port');
|
|
||||||
const outputEl = document.getElementById('led-tool-output');
|
|
||||||
const messageEl = document.getElementById('led-tool-message');
|
|
||||||
|
|
||||||
if (!openBtn || !modal || !form || !portSelect || !outputEl || !messageEl) {
|
if (!openBtn || !modal || !iframe) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showMessage = (text, type = 'success') => {
|
|
||||||
messageEl.textContent = text;
|
|
||||||
messageEl.className = `message ${type} show`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setOutput = (text) => {
|
|
||||||
outputEl.value = text || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseApiResponse = async (response) => {
|
|
||||||
const bodyText = await response.text();
|
|
||||||
let data = null;
|
|
||||||
try {
|
|
||||||
data = bodyText ? JSON.parse(bodyText) : {};
|
|
||||||
} catch (error) {
|
|
||||||
data = { error: bodyText || `HTTP ${response.status}` };
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setFieldValue = (id, value) => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
if (value === undefined || value === null) return;
|
|
||||||
el.value = String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const populateFormFromSettings = (settings) => {
|
|
||||||
if (!settings || typeof settings !== 'object') return false;
|
|
||||||
setFieldValue('led-tool-name', settings.name);
|
|
||||||
setFieldValue('led-tool-num-leds', settings.num_leds);
|
|
||||||
setFieldValue('led-tool-led-pin', settings.led_pin);
|
|
||||||
setFieldValue('led-tool-brightness', settings.brightness);
|
|
||||||
setFieldValue('led-tool-transport', settings.transport_type);
|
|
||||||
setFieldValue('led-tool-ssid', settings.ssid);
|
|
||||||
setFieldValue('led-tool-password', settings.password);
|
|
||||||
setFieldValue('led-tool-wifi-channel', settings.wifi_channel);
|
|
||||||
setFieldValue('led-tool-default', settings.default);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadPorts = async () => {
|
|
||||||
const defaultPort = '/dev/ttyACM0';
|
|
||||||
try {
|
|
||||||
const response = await fetch('/led-tool/ports');
|
|
||||||
const data = await response.json();
|
|
||||||
const previous = portSelect.value;
|
|
||||||
portSelect.innerHTML = '<option value="">Select a serial port</option>';
|
|
||||||
|
|
||||||
for (const port of data.ports || []) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = port.device;
|
|
||||||
option.textContent = `${port.device} - ${port.description || 'Unknown'}`;
|
|
||||||
portSelect.appendChild(option);
|
|
||||||
}
|
|
||||||
if (previous) {
|
|
||||||
portSelect.value = previous;
|
|
||||||
} else if ((data.ports || []).some((p) => p.device === defaultPort)) {
|
|
||||||
portSelect.value = defaultPort;
|
|
||||||
} else {
|
|
||||||
const fallback = document.createElement('option');
|
|
||||||
fallback.value = defaultPort;
|
|
||||||
fallback.textContent = `${defaultPort} - default`;
|
|
||||||
portSelect.appendChild(fallback);
|
|
||||||
portSelect.value = defaultPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.led_cli_exists) {
|
|
||||||
showMessage('led-tool/cli.py was not found on the host.', 'error');
|
|
||||||
} else if ((data.ports || []).length === 0) {
|
|
||||||
showMessage('No serial ports found.', 'error');
|
|
||||||
} else {
|
|
||||||
showMessage(`Found ${(data.ports || []).length} serial port(s).`, 'success');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Failed to read serial ports: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
openBtn.addEventListener('click', () => {
|
openBtn.addEventListener('click', () => {
|
||||||
|
iframe.src = '/led-tool/editor';
|
||||||
modal.classList.add('active');
|
modal.classList.add('active');
|
||||||
loadPorts();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (closeBtn) {
|
if (closeBtn) {
|
||||||
closeBtn.addEventListener('click', () => {
|
closeBtn.addEventListener('click', () => {
|
||||||
modal.classList.remove('active');
|
modal.classList.remove('active');
|
||||||
|
iframe.src = 'about:blank';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (refreshPortsBtn) {
|
|
||||||
refreshPortsBtn.addEventListener('click', () => {
|
|
||||||
loadPorts();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (readBtn) {
|
|
||||||
readBtn.addEventListener('click', async () => {
|
|
||||||
const port = portSelect.value.trim();
|
|
||||||
if (!port) {
|
|
||||||
showMessage('Select a serial port first.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOutput('Reading settings from device...');
|
|
||||||
showMessage('Reading settings over USB...', 'success');
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/led-tool/settings?port=${encodeURIComponent(port)}`);
|
|
||||||
const data = await parseApiResponse(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
showMessage(data.error || 'Read failed.', 'error');
|
|
||||||
setOutput(data.error || 'Request failed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const output = [
|
|
||||||
`exit code: ${data.returncode}`,
|
|
||||||
'',
|
|
||||||
'stdout:',
|
|
||||||
data.stdout || '(none)',
|
|
||||||
'',
|
|
||||||
'stderr:',
|
|
||||||
data.stderr || '(none)',
|
|
||||||
].join('\n');
|
|
||||||
setOutput(output);
|
|
||||||
if (data.ok) {
|
|
||||||
const populated = populateFormFromSettings(data.settings);
|
|
||||||
if (populated) {
|
|
||||||
showMessage('Settings read and fields populated.', 'success');
|
|
||||||
} else {
|
|
||||||
showMessage('Settings read successfully.', 'success');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showMessage('Read completed with errors. Check output.', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Request failed: ${error.message}`, 'error');
|
|
||||||
setOutput(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resetBtn) {
|
|
||||||
resetBtn.addEventListener('click', async () => {
|
|
||||||
const port = portSelect.value.trim();
|
|
||||||
if (!port) {
|
|
||||||
showMessage('Select a serial port first.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOutput('Resetting device and following output...');
|
|
||||||
showMessage('Resetting device over USB...', 'success');
|
|
||||||
try {
|
|
||||||
const response = await fetch('/led-tool/reset', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ port }),
|
|
||||||
});
|
|
||||||
const data = await parseApiResponse(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
showMessage(data.error || 'Reset failed.', 'error');
|
|
||||||
setOutput(data.error || 'Request failed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const output = [
|
|
||||||
`exit code: ${data.returncode}`,
|
|
||||||
'',
|
|
||||||
'stdout:',
|
|
||||||
data.stdout || '(none)',
|
|
||||||
'',
|
|
||||||
'stderr:',
|
|
||||||
data.stderr || '(none)',
|
|
||||||
].join('\n');
|
|
||||||
setOutput(output);
|
|
||||||
if (data.ok) {
|
|
||||||
showMessage('Device reset complete.', 'success');
|
|
||||||
} else {
|
|
||||||
showMessage('Reset completed with errors. Check output.', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Request failed: ${error.message}`, 'error');
|
|
||||||
setOutput(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const port = portSelect.value.trim();
|
|
||||||
if (!port) {
|
|
||||||
showMessage('Select a serial port first.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
port,
|
|
||||||
name: document.getElementById('led-tool-name')?.value?.trim() || '',
|
|
||||||
num_leds: document.getElementById('led-tool-num-leds')?.value?.trim() || '',
|
|
||||||
led_pin: document.getElementById('led-tool-led-pin')?.value?.trim() || '',
|
|
||||||
brightness: document.getElementById('led-tool-brightness')?.value?.trim() || '',
|
|
||||||
transport: document.getElementById('led-tool-transport')?.value?.trim() || '',
|
|
||||||
ssid: document.getElementById('led-tool-ssid')?.value?.trim() || '',
|
|
||||||
password: document.getElementById('led-tool-password')?.value?.trim() || '',
|
|
||||||
wifi_channel: document.getElementById('led-tool-wifi-channel')?.value?.trim() || '',
|
|
||||||
default: document.getElementById('led-tool-default')?.value?.trim() || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
setOutput('Running led-tool command...');
|
|
||||||
showMessage('Running command over USB...', 'success');
|
|
||||||
try {
|
|
||||||
const response = await fetch('/led-tool/settings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const data = await parseApiResponse(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
showMessage(data.error || 'Command failed.', 'error');
|
|
||||||
setOutput(data.error || 'Request failed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const output = [
|
|
||||||
`exit code: ${data.returncode}`,
|
|
||||||
'',
|
|
||||||
'stdout:',
|
|
||||||
data.stdout || '(none)',
|
|
||||||
'',
|
|
||||||
'stderr:',
|
|
||||||
data.stderr || '(none)',
|
|
||||||
].join('\n');
|
|
||||||
setOutput(output);
|
|
||||||
if (data.ok) {
|
|
||||||
showMessage('Settings applied via USB.', 'success');
|
|
||||||
} else {
|
|
||||||
showMessage('Command completed with errors. Check output.', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Request failed: ${error.message}`, 'error');
|
|
||||||
setOutput(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
117
src/static/numpad.js
Normal file
117
src/static/numpad.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Bluetooth / USB HID numpad shortcuts (browser focus required).
|
||||||
|
*
|
||||||
|
* Numpad1–9,0 → zone 1–10 (visible zone list order)
|
||||||
|
* NumpadEnter → sequence beat sync (step), same as S
|
||||||
|
* NumpadDecimal → sequence beat sync (pass), same as Shift+S
|
||||||
|
* NumpadMultiply → reset audio detector
|
||||||
|
* NumpadAdd → brightness +16
|
||||||
|
* NumpadSubtract → brightness −16
|
||||||
|
* NumpadDivide → stop zone sequence playback
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const BRIGHTNESS_STEP = 16;
|
||||||
|
|
||||||
|
function isTypingTarget(target) {
|
||||||
|
if (!target || typeof target !== "object") return false;
|
||||||
|
const tag = String(target.tagName || "").toLowerCase();
|
||||||
|
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneIdsInListOrder() {
|
||||||
|
return [...document.querySelectorAll("#zones-list .zone-button[data-zone-id]")]
|
||||||
|
.map((el) => el.getAttribute("data-zone-id"))
|
||||||
|
.filter((id) => id != null && id !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectZoneByListIndex(oneBased) {
|
||||||
|
const order = zoneIdsInListOrder();
|
||||||
|
if (oneBased < 1 || oneBased > order.length) return;
|
||||||
|
const zoneId = order[oneBased - 1];
|
||||||
|
if (window.tabsManager && typeof window.tabsManager.selectZone === "function") {
|
||||||
|
await window.tabsManager.selectZone(zoneId);
|
||||||
|
} else if (typeof selectZone === "function") {
|
||||||
|
await selectZone(zoneId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncSequenceBeatPhase(mode) {
|
||||||
|
const res = await fetch("/sequences/sync-phase", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ mode: mode || "step" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Sync failed (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetAudioTracking() {
|
||||||
|
const res = await fetch("/api/audio/reset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Reset failed (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustZoneBrightness(delta) {
|
||||||
|
const zoneId =
|
||||||
|
(window.tabsManager && typeof window.tabsManager.getCurrentTabId === "function"
|
||||||
|
? window.tabsManager.getCurrentTabId()
|
||||||
|
: null) ||
|
||||||
|
(window.tabsManager && typeof window.tabsManager.getCurrentZoneId === "function"
|
||||||
|
? window.tabsManager.getCurrentZoneId()
|
||||||
|
: null);
|
||||||
|
if (!zoneId) return;
|
||||||
|
const slider =
|
||||||
|
document.getElementById("header-brightness-slider") ||
|
||||||
|
document.getElementById("menu-brightness-slider");
|
||||||
|
if (!slider) return;
|
||||||
|
const cur = parseInt(slider.value, 10);
|
||||||
|
const base = Number.isFinite(cur) ? cur : 127;
|
||||||
|
const next = Math.max(0, Math.min(255, base + delta));
|
||||||
|
if (String(slider.value) === String(next)) return;
|
||||||
|
slider.value = String(next);
|
||||||
|
slider.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopSequencePlayback() {
|
||||||
|
if (typeof window.stopZoneSequencePlayback === "function") {
|
||||||
|
await window.stopZoneSequencePlayback(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, () => void | Promise<void>>} */
|
||||||
|
const actions = {
|
||||||
|
NumpadEnter: () => syncSequenceBeatPhase("step"),
|
||||||
|
NumpadDecimal: () => syncSequenceBeatPhase("pass"),
|
||||||
|
NumpadMultiply: () => resetAudioTracking(),
|
||||||
|
NumpadAdd: () => adjustZoneBrightness(BRIGHTNESS_STEP),
|
||||||
|
NumpadSubtract: () => adjustZoneBrightness(-BRIGHTNESS_STEP),
|
||||||
|
NumpadDivide: () => stopSequencePlayback(),
|
||||||
|
Numpad1: () => selectZoneByListIndex(1),
|
||||||
|
Numpad2: () => selectZoneByListIndex(2),
|
||||||
|
Numpad3: () => selectZoneByListIndex(3),
|
||||||
|
Numpad4: () => selectZoneByListIndex(4),
|
||||||
|
Numpad5: () => selectZoneByListIndex(5),
|
||||||
|
Numpad6: () => selectZoneByListIndex(6),
|
||||||
|
Numpad7: () => selectZoneByListIndex(7),
|
||||||
|
Numpad8: () => selectZoneByListIndex(8),
|
||||||
|
Numpad9: () => selectZoneByListIndex(9),
|
||||||
|
Numpad0: () => selectZoneByListIndex(10),
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
|
||||||
|
const code = ev.code;
|
||||||
|
if (!code || !code.startsWith("Numpad")) return;
|
||||||
|
const action = actions[code];
|
||||||
|
if (!action) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
Promise.resolve(action()).catch((e) => console.warn("numpad shortcut failed:", e));
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -573,7 +573,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
n3: coercePresetInt(preset.n3),
|
n3: coercePresetInt(preset.n3),
|
||||||
n4: coercePresetInt(preset.n4),
|
n4: coercePresetInt(preset.n4),
|
||||||
n5: coercePresetInt(preset.n5),
|
n5: coercePresetInt(preset.n5),
|
||||||
n6: coercePresetInt(preset.n6),
|
n6: (() => {
|
||||||
|
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
|
||||||
|
return coercePresetInt(preset.mode);
|
||||||
|
}
|
||||||
|
return coercePresetInt(preset.n6);
|
||||||
|
})(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (!Object.keys(wirePresets).length) {
|
if (!Object.keys(wirePresets).length) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const newProfileInput = document.getElementById("new-profile-name");
|
const newProfileInput = document.getElementById("new-profile-name");
|
||||||
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
|
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
|
||||||
const createProfileButton = document.getElementById("create-profile-btn");
|
const createProfileButton = document.getElementById("create-profile-btn");
|
||||||
|
const importProfileButton = document.getElementById("import-profile-btn");
|
||||||
|
|
||||||
if (!profilesButton || !profilesModal || !profilesList) {
|
if (!profilesButton || !profilesModal || !profilesList) {
|
||||||
return;
|
return;
|
||||||
@@ -101,6 +102,26 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exportButton = document.createElement("button");
|
||||||
|
exportButton.className = "btn btn-secondary btn-small";
|
||||||
|
exportButton.textContent = "Export";
|
||||||
|
exportButton.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/profiles/${profileId}/export`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Export failed");
|
||||||
|
}
|
||||||
|
const bundle = await response.json();
|
||||||
|
const safeName = ((profile && profile.name) || profileId).replace(/[^\w.-]+/g, "_");
|
||||||
|
window.downloadJsonFile(`profile-${safeName}.json`, bundle);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Export profile failed:", error);
|
||||||
|
alert("Failed to export profile.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const cloneButton = document.createElement("button");
|
const cloneButton = document.createElement("button");
|
||||||
cloneButton.className = "btn btn-secondary btn-small";
|
cloneButton.className = "btn btn-secondary btn-small";
|
||||||
cloneButton.textContent = "Clone";
|
cloneButton.textContent = "Clone";
|
||||||
@@ -177,6 +198,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(applyButton);
|
row.appendChild(applyButton);
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
|
row.appendChild(exportButton);
|
||||||
row.appendChild(cloneButton);
|
row.appendChild(cloneButton);
|
||||||
row.appendChild(deleteButton);
|
row.appendChild(deleteButton);
|
||||||
}
|
}
|
||||||
@@ -276,6 +298,60 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (createProfileButton) {
|
if (createProfileButton) {
|
||||||
createProfileButton.addEventListener("click", createProfile);
|
createProfileButton.addEventListener("click", createProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importProfile = async () => {
|
||||||
|
if (!isEditModeActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await window.pickJsonFile();
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bundle = window.parseJsonFileText(text);
|
||||||
|
if (!bundle || typeof bundle !== "object") {
|
||||||
|
alert("Invalid JSON file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const defaultName =
|
||||||
|
(bundle.profile && bundle.profile.name) || "Imported profile";
|
||||||
|
const name = prompt("Profile name for import:", defaultName);
|
||||||
|
if (name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = String(name).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
alert("Profile name cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch("/profiles/import", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ bundle, name: trimmed, apply: true }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Import failed");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const newProfileId = data.id || Object.keys(data).find((k) => k !== "id");
|
||||||
|
if (newProfileId) {
|
||||||
|
await fetch(`/profiles/${newProfileId}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await loadProfiles();
|
||||||
|
await refreshTabsForActiveProfile();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Import profile failed:", error);
|
||||||
|
alert(error.message || "Failed to import profile.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (importProfileButton) {
|
||||||
|
importProfileButton.addEventListener("click", importProfile);
|
||||||
|
}
|
||||||
if (newProfileInput) {
|
if (newProfileInput) {
|
||||||
newProfileInput.addEventListener("keypress", (event) => {
|
newProfileInput.addEventListener("keypress", (event) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
|
|||||||
1301
src/static/sequences.js
Normal file
1301
src/static/sequences.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,7 +106,7 @@ header h1 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Second header row: BPM, brightness, desktop buttons / mobile menu */
|
/* Top header row: BPM, brightness, desktop buttons, mobile menu (above zone tabs) */
|
||||||
.header-end {
|
.header-end {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -200,18 +200,55 @@ header h1 {
|
|||||||
.audio-top-indicator {
|
.audio-top-indicator {
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.35rem;
|
||||||
padding: 0.25rem 0.55rem;
|
min-width: 9rem;
|
||||||
border: 1px solid #4a4a4a;
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
min-width: 6.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-top-indicator.audio-running {
|
.audio-top-indicator.audio-running {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audio-top-indicator .audio-top-beat-sync {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-beat-sync {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-beat-sync:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-beat-sync:not(:disabled):hover {
|
||||||
|
border-color: #6a6a6a;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-indicator-extra {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: #9e9e9e;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: right;
|
||||||
|
max-width: 16rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.audio-top-indicator-label {
|
.audio-top-indicator-label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: #bdbdbd;
|
color: #bdbdbd;
|
||||||
@@ -226,6 +263,49 @@ header h1 {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audio-top-beat-readout {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #b0bec5;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-beat-readout:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-beat-readout:not(:empty)::before {
|
||||||
|
content: "·";
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
color: #757575;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-bar-phase {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #90a4ae;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-bar-phase:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-bar-phase:not(:empty)::before {
|
||||||
|
content: "·";
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
color: #757575;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-top-bar-phase.is-downbeat {
|
||||||
|
color: #ffab91;
|
||||||
|
}
|
||||||
|
|
||||||
.audio-top-indicator-subvalue {
|
.audio-top-indicator-subvalue {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #9e9e9e;
|
color: #9e9e9e;
|
||||||
@@ -233,14 +313,15 @@ header h1 {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-top-indicator.flash {
|
.audio-top-beat-sync.flash {
|
||||||
background-color: #ff5252;
|
background-color: #ff5252;
|
||||||
border-color: #ff8a80;
|
border-color: #ff8a80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-top-indicator.flash .audio-top-indicator-value,
|
.audio-top-beat-sync.flash .audio-top-indicator-value,
|
||||||
.audio-top-indicator.flash .audio-top-indicator-label,
|
.audio-top-beat-sync.flash .audio-top-indicator-label,
|
||||||
.audio-top-indicator.flash .audio-top-indicator-subvalue {
|
.audio-top-beat-sync.flash .audio-top-beat-readout,
|
||||||
|
.audio-top-beat-sync.flash .audio-top-beat-readout::before {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +381,7 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
|
|
||||||
.zones-container {
|
.zones-container {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
padding: 0.35rem 0 0;
|
padding: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -565,6 +646,39 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preset-mode-field {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-mode-field label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-mode-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-mode-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6a9fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preset-editor-modal .preset-mode-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.n-input {
|
.n-input {
|
||||||
flex: 0 0 var(--n-input-width, 5ch);
|
flex: 0 0 var(--n-input-width, 5ch);
|
||||||
width: var(--n-input-width, 5ch);
|
width: var(--n-input-width, 5ch);
|
||||||
@@ -620,7 +734,8 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||||
grid-auto-rows: 5rem;
|
/* min-content height prevents taller tiles (edit actions, wrapping) from overlapping the next row and stealing clicks */
|
||||||
|
grid-auto-rows: minmax(5rem, auto);
|
||||||
column-gap: 0.3rem;
|
column-gap: 0.3rem;
|
||||||
row-gap: 0.3rem;
|
row-gap: 0.3rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -784,6 +899,55 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audio-bpm-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-bpm-row .audio-bpm-readout {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#audio-modal .audio-settings-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#audio-modal .audio-settings-section .audio-modal-beat-readout {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-modal-beat-readout {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 10rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #252525;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #b0bec5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-modal-beat-readout:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-modal-beat-readout:not(:disabled):hover {
|
||||||
|
border-color: #6a6a6a;
|
||||||
|
background-color: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
.audio-hit-type-readout {
|
.audio-hit-type-readout {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -851,17 +1015,43 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-tile-row--run .preset-tile-actions {
|
.preset-tile-row-top {
|
||||||
display: none;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-tile-main {
|
.preset-tile-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 5rem;
|
height: 5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-main .preset-tile-groups {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.15;
|
||||||
|
opacity: 0.88;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-row--run .preset-tile-actions {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Edit only beside the preset tile in edit mode. */
|
/* Edit only beside the preset tile in edit mode. */
|
||||||
@@ -890,13 +1080,98 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-mode-toggle--edit {
|
.nav-slide-toggle-wrap {
|
||||||
background-color: #4a3f8f;
|
display: inline-flex;
|
||||||
border: 1px solid #7b6fd6;
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-mode-toggle--edit:hover {
|
.nav-slide-toggle-side-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #888;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-slide-toggle-wrap:not(.nav-slide-toggle-wrap--downbeat) .nav-slide-toggle-side-label--beat,
|
||||||
|
.nav-slide-toggle-wrap--downbeat .nav-slide-toggle-side-label--downbeat {
|
||||||
|
color: #e8e8e8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-slide-toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-slide-toggle-switch:hover {
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-slide-toggle-switch:focus-visible {
|
||||||
|
outline: 2px solid #7b6fd6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-slide-toggle-track {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-slide-toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 2px;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #bdbdbd;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: left 0.2s ease, background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-slide-toggle-switch.seq-switch-toggle--downbeat {
|
||||||
|
background-color: #4a3f8f;
|
||||||
|
border-color: #7b6fd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-slide-toggle-switch.seq-switch-toggle--downbeat:hover {
|
||||||
background-color: #5a4f9f;
|
background-color: #5a4f9f;
|
||||||
|
border-color: #8b7fe6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
|
||||||
|
left: calc(100% - 1rem - 2px);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background-color: #e8e4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-menu-dropdown .nav-slide-toggle-wrap--mobile {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preset select buttons inside the zone grid */
|
/* Preset select buttons inside the zone grid */
|
||||||
@@ -1030,6 +1305,46 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stack sequence modals below groups / preset editor so in-modal actions stay visible */
|
||||||
|
#sequence-editor-modal.active,
|
||||||
|
#sequences-modal.active {
|
||||||
|
z-index: 1040;
|
||||||
|
}
|
||||||
|
#groups-modal.active,
|
||||||
|
#edit-group-modal.active,
|
||||||
|
#presets-modal.active {
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
#preset-editor-modal.active {
|
||||||
|
z-index: 1060;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Child / overlay modals: must paint above preset editor (1060) and list modals (1050). */
|
||||||
|
#color-palette-modal.active,
|
||||||
|
#pattern-editor-modal.active,
|
||||||
|
#edit-device-modal.active,
|
||||||
|
#edit-zone-modal.active {
|
||||||
|
z-index: 1070;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Patterns library (often used next to presets); below preset editor, above sequences. */
|
||||||
|
#patterns-modal.active {
|
||||||
|
z-index: 1055;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header / global dialogs */
|
||||||
|
#help-modal.active,
|
||||||
|
#audio-modal.active,
|
||||||
|
#settings-modal.active,
|
||||||
|
#led-tool-modal.active {
|
||||||
|
z-index: 1080;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JS-appended overlays (e.g. preset “From Palette”, add-preset-to-zone) — must sit above #preset-editor-modal */
|
||||||
|
.modal.modal-child-overlay.active {
|
||||||
|
z-index: 1080;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background-color: #2e2e2e;
|
background-color: #2e2e2e;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
@@ -1108,13 +1423,43 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Beat/downbeat toggle lives in the mobile menu only */
|
||||||
|
#seq-switch-toggle-wrap {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-menu-dropdown {
|
||||||
|
max-width: min(16rem, calc(100vw - 1rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-side-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle {
|
||||||
|
width: 3.6rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch .nav-slide-toggle-thumb {
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
|
||||||
|
left: calc(100% - 0.9rem - 2px);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.header-menu-mobile {
|
.header-menu-mobile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-left: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-end {
|
.header-end {
|
||||||
@@ -1124,10 +1469,15 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
|
|
||||||
.header-end .audio-top-indicator {
|
.header-end .audio-top-indicator {
|
||||||
min-width: 5rem;
|
min-width: 5rem;
|
||||||
padding: 0.2rem 0.45rem;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-end .audio-top-beat-sync {
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
padding: 0.4rem 0.7rem;
|
padding: 0.4rem 0.7rem;
|
||||||
@@ -1263,6 +1613,22 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
min-width: 8rem;
|
min-width: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.zone-content-kind-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0.35rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content-kind-row label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.zone-devices-label {
|
.zone-devices-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
@@ -1500,6 +1866,14 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sequence-step-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sequence-step-row.dragging {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
/* Settings modal */
|
/* Settings modal */
|
||||||
#settings-modal .modal-content {
|
#settings-modal .modal-content {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
// Zone management JavaScript
|
// Zone management JavaScript
|
||||||
let currentZoneId = null;
|
let currentZoneId = null;
|
||||||
let brightnessSendTimeout = null;
|
let brightnessSendTimeout = null;
|
||||||
|
/**
|
||||||
|
* When true, the next `loadZoneContent` skips `sendZoneBrightness` (run/edit toggle: same zone, UI only).
|
||||||
|
*/
|
||||||
|
let suppressZoneContentDriverSideEffects = false;
|
||||||
|
/** First successful `loadZoneContent` after open: skip hardware brightness push (read-only hydration). */
|
||||||
|
let isFirstZoneContentHydration = true;
|
||||||
|
|
||||||
function clamp255(n) {
|
function clamp255(n) {
|
||||||
const v = parseInt(n, 10);
|
const v = parseInt(n, 10);
|
||||||
@@ -150,7 +156,10 @@ async function fetchDevicesMap() {
|
|||||||
|
|
||||||
async function fetchGroupsMap() {
|
async function fetchGroupsMap() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/groups", { headers: { Accept: "application/json" } });
|
const response = await fetch("/groups", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
if (!response.ok) return {};
|
if (!response.ok) return {};
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data && typeof data === "object" ? data : {};
|
return data && typeof data === "object" ? data : {};
|
||||||
@@ -162,7 +171,7 @@ async function fetchGroupsMap() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve registry names + MACs for a zone document (``group_ids`` expands groups;
|
* Resolve registry names + MACs for a zone document (``group_ids`` expands groups;
|
||||||
* otherwise legacy ``names``).
|
* otherwise ``names`` only).
|
||||||
*/
|
*/
|
||||||
async function computeZoneTargets(zone) {
|
async function computeZoneTargets(zone) {
|
||||||
const dm = await fetchDevicesMap();
|
const dm = await fetchDevicesMap();
|
||||||
@@ -202,6 +211,147 @@ async function computeZoneTargets(zone) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tab device list for sequences: zone ``group_ids`` first, else legacy ``names`` only. */
|
||||||
|
async function computeZoneNamesTargets(zone) {
|
||||||
|
const gids = Array.isArray(zone && zone.group_ids)
|
||||||
|
? zone.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
|
: [];
|
||||||
|
if (gids.length > 0) {
|
||||||
|
const t = await resolveTargetsFromGroupIds(gids);
|
||||||
|
return {
|
||||||
|
names: Array.isArray(t.names) ? t.names : [],
|
||||||
|
macs: Array.isArray(t.macs) ? [...new Set(t.macs.filter(Boolean))] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const dm = await fetchDevicesMap();
|
||||||
|
const zoneNames = Array.isArray(zone && zone.names) ? zone.names : [];
|
||||||
|
const rows = namesToRows(zoneNames, dm);
|
||||||
|
return {
|
||||||
|
names: rowsToNames(rows),
|
||||||
|
macs: [...new Set(rows.map((r) => r.mac).filter(Boolean))],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceMac(raw) {
|
||||||
|
return String(raw || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/:/g, "")
|
||||||
|
.replace(/-/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat preset ids on a zone document (grid or flat). */
|
||||||
|
function tabPresetIdsInZoneDoc(zoneDoc) {
|
||||||
|
let ids = [];
|
||||||
|
if (Array.isArray(zoneDoc && zoneDoc.presets_flat)) {
|
||||||
|
ids = zoneDoc.presets_flat.slice();
|
||||||
|
} else if (Array.isArray(zoneDoc && zoneDoc.presets)) {
|
||||||
|
if (zoneDoc.presets.length && typeof zoneDoc.presets[0] === "string") {
|
||||||
|
ids = zoneDoc.presets.slice();
|
||||||
|
} else if (zoneDoc.presets.length && Array.isArray(zoneDoc.presets[0])) {
|
||||||
|
ids = zoneDoc.presets.flat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (ids || []).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group ids used for standalone presets on this zone: zone ``group_ids`` only. */
|
||||||
|
function effectiveGroupIdsForZonePreset(zoneDoc) {
|
||||||
|
return Array.isArray(zoneDoc && zoneDoc.group_ids)
|
||||||
|
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve device names + MACs from a list of group ids (same rules as zone group expansion). */
|
||||||
|
async function resolveTargetsFromGroupIds(groupIds) {
|
||||||
|
const dm = await fetchDevicesMap();
|
||||||
|
const gids = Array.isArray(groupIds)
|
||||||
|
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
|
: [];
|
||||||
|
if (!gids.length) {
|
||||||
|
return { names: [], macs: [] };
|
||||||
|
}
|
||||||
|
const gm = await fetchGroupsMap();
|
||||||
|
const seen = new Set();
|
||||||
|
const names = [];
|
||||||
|
const macs = [];
|
||||||
|
for (const gid of gids) {
|
||||||
|
const g = gm[gid];
|
||||||
|
if (!g || !Array.isArray(g.devices)) continue;
|
||||||
|
for (const raw of g.devices) {
|
||||||
|
const m = normalizeDeviceMac(raw);
|
||||||
|
if (m.length !== 12) continue;
|
||||||
|
if (seen.has(m)) continue;
|
||||||
|
seen.add(m);
|
||||||
|
const d = dm[m];
|
||||||
|
const n = d && String((d.name || "").trim()) ? String(d.name).trim() : m;
|
||||||
|
names.push(n);
|
||||||
|
macs.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { names, macs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Device names for standalone presets: zone ``group_ids``, or all devices on the tab (``names``). */
|
||||||
|
async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) {
|
||||||
|
void presetId;
|
||||||
|
const gids = effectiveGroupIdsForZonePreset(zoneDoc);
|
||||||
|
if (gids.length) {
|
||||||
|
const t = await resolveTargetsFromGroupIds(gids);
|
||||||
|
if (t.names.length) return t.names;
|
||||||
|
}
|
||||||
|
const zt = await computeZoneTargets(zoneDoc);
|
||||||
|
return Array.isArray(zt.names) ? zt.names.slice() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union of devices targeted by standalone presets on the zone (same as zone preset targeting). */
|
||||||
|
async function computeZonePresetUnionTargets(zoneDoc) {
|
||||||
|
return await computeZoneTargets(zoneDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only).
|
||||||
|
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
|
||||||
|
*/
|
||||||
|
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
|
||||||
|
const zoneT = await computeZoneNamesTargets(zone);
|
||||||
|
const names = Array.isArray(zoneT.names) ? zoneT.names : [];
|
||||||
|
const macs = Array.isArray(zoneT.macs) ? zoneT.macs : [];
|
||||||
|
const gids = Array.isArray(stepGroupIds)
|
||||||
|
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
|
: [];
|
||||||
|
if (!gids.length) {
|
||||||
|
return names.slice();
|
||||||
|
}
|
||||||
|
const zoneMacSet = new Set(
|
||||||
|
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
|
||||||
|
);
|
||||||
|
const zoneNameByMac = new Map();
|
||||||
|
for (let i = 0; i < macs.length; i++) {
|
||||||
|
const m = normalizeDeviceMac(macs[i]);
|
||||||
|
if (m.length === 12 && !zoneNameByMac.has(m)) {
|
||||||
|
zoneNameByMac.set(m, names[i] || m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const gm = await fetchGroupsMap();
|
||||||
|
const stepMacs = new Set();
|
||||||
|
for (const gid of gids) {
|
||||||
|
const g = gm[gid];
|
||||||
|
if (!g || !Array.isArray(g.devices)) continue;
|
||||||
|
for (const raw of g.devices) {
|
||||||
|
const m = normalizeDeviceMac(raw);
|
||||||
|
if (m.length !== 12 || !zoneMacSet.has(m)) continue;
|
||||||
|
stepMacs.add(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const m of stepMacs) {
|
||||||
|
const n = zoneNameByMac.get(m);
|
||||||
|
if (n) out.push(n);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveZoneDeviceMacsFromZoneData(zone) {
|
async function resolveZoneDeviceMacsFromZoneData(zone) {
|
||||||
const t = await computeZoneTargets(zone);
|
const t = await computeZoneTargets(zone);
|
||||||
return t.macs;
|
return t.macs;
|
||||||
@@ -250,67 +400,6 @@ function rowsToNames(rows) {
|
|||||||
return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0);
|
return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderZoneDevicesEditor(containerEl, rows, devicesMap) {
|
|
||||||
if (!containerEl) return;
|
|
||||||
containerEl.innerHTML = "";
|
|
||||||
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b));
|
|
||||||
|
|
||||||
rows.forEach((row, idx) => {
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.className = "zone-device-row profiles-row";
|
|
||||||
const label = document.createElement("span");
|
|
||||||
label.className = "zone-device-row-label";
|
|
||||||
const strong = document.createElement("strong");
|
|
||||||
strong.textContent = row.name || "—";
|
|
||||||
label.appendChild(strong);
|
|
||||||
label.appendChild(document.createTextNode(" "));
|
|
||||||
const sub = document.createElement("span");
|
|
||||||
sub.className = "muted-text";
|
|
||||||
sub.textContent = row.mac ? row.mac : "(not in registry)";
|
|
||||||
label.appendChild(sub);
|
|
||||||
|
|
||||||
const rm = document.createElement("button");
|
|
||||||
rm.type = "button";
|
|
||||||
rm.className = "btn btn-danger btn-small";
|
|
||||||
rm.textContent = "Remove";
|
|
||||||
rm.addEventListener("click", () => {
|
|
||||||
rows.splice(idx, 1);
|
|
||||||
renderZoneDevicesEditor(containerEl, rows, devicesMap);
|
|
||||||
});
|
|
||||||
div.appendChild(label);
|
|
||||||
div.appendChild(rm);
|
|
||||||
containerEl.appendChild(div);
|
|
||||||
});
|
|
||||||
|
|
||||||
const macsInRows = new Set(rows.map((r) => r.mac).filter(Boolean));
|
|
||||||
const addWrap = document.createElement("div");
|
|
||||||
addWrap.className = "zone-devices-add profiles-actions";
|
|
||||||
const sel = document.createElement("select");
|
|
||||||
sel.className = "zone-device-add-select";
|
|
||||||
sel.appendChild(new Option("Add device…", ""));
|
|
||||||
entries.forEach(([mac, d]) => {
|
|
||||||
if (macsInRows.has(mac)) return;
|
|
||||||
const labelName = d && d.name ? String(d.name).trim() : "";
|
|
||||||
const optLabel = labelName ? `${labelName} — ${mac}` : mac;
|
|
||||||
sel.appendChild(new Option(optLabel, mac));
|
|
||||||
});
|
|
||||||
const addBtn = document.createElement("button");
|
|
||||||
addBtn.type = "button";
|
|
||||||
addBtn.className = "btn btn-primary btn-small";
|
|
||||||
addBtn.textContent = "Add";
|
|
||||||
addBtn.addEventListener("click", () => {
|
|
||||||
const mac = sel.value;
|
|
||||||
if (!mac || !devicesMap[mac]) return;
|
|
||||||
const n = String((devicesMap[mac].name || "").trim() || mac);
|
|
||||||
rows.push({ mac, name: n });
|
|
||||||
sel.value = "";
|
|
||||||
renderZoneDevicesEditor(containerEl, rows, devicesMap);
|
|
||||||
});
|
|
||||||
addWrap.appendChild(sel);
|
|
||||||
addWrap.appendChild(addBtn);
|
|
||||||
containerEl.appendChild(addWrap);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
|
function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
|
||||||
if (!containerEl) return;
|
if (!containerEl) return;
|
||||||
containerEl.innerHTML = "";
|
containerEl.innerHTML = "";
|
||||||
@@ -372,13 +461,6 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
|
|||||||
containerEl.appendChild(addWrap);
|
containerEl.appendChild(addWrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default group for a new zone (empty if no groups exist yet). */
|
|
||||||
async function defaultGroupIdsForNewTab() {
|
|
||||||
const gm = await fetchGroupsMap();
|
|
||||||
const ids = Object.keys(gm || {}).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
|
||||||
return ids.length ? [ids[0]] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */
|
/** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */
|
||||||
function parseTabDeviceNames(section) {
|
function parseTabDeviceNames(section) {
|
||||||
if (!section) return [];
|
if (!section) return [];
|
||||||
@@ -408,6 +490,83 @@ function escapeHtmlAttr(s) {
|
|||||||
.replace(/</g, "<");
|
.replace(/</g, "<");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {null | 'presets' | 'sequences'} */
|
||||||
|
function normalizeZoneContentKind(zoneDoc) {
|
||||||
|
const k = zoneDoc && zoneDoc.content_kind;
|
||||||
|
if (k === 'presets' || k === 'sequences') return k;
|
||||||
|
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, zoneId) {
|
||||||
|
const modal = document.getElementById('edit-zone-modal');
|
||||||
|
const editingId = document.getElementById('edit-zone-id')?.value;
|
||||||
|
if (
|
||||||
|
modal &&
|
||||||
|
modal.classList.contains('active') &&
|
||||||
|
zoneId != null &&
|
||||||
|
zoneId !== '' &&
|
||||||
|
String(editingId) === String(zoneId)
|
||||||
|
) {
|
||||||
|
return editModalContentKindSelected();
|
||||||
|
}
|
||||||
|
return effectiveZoneContentKind(zoneDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the zone row has an explicit presets vs sequences type (not legacy inferred). */
|
||||||
|
function zoneHasExplicitContentKind(zoneDoc) {
|
||||||
|
return normalizeZoneContentKind(zoneDoc) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
function zoneAllowsPresets(zoneDoc, zoneId) {
|
||||||
|
void zoneId;
|
||||||
|
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
|
||||||
|
return effectiveZoneContentKind(zoneDoc) === 'presets';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
function zoneAllowsSequences(zoneDoc, zoneId) {
|
||||||
|
void zoneId;
|
||||||
|
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
|
||||||
|
return effectiveZoneContentKind(zoneDoc) === 'sequences';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoneContentKindEditModal(kind) {
|
||||||
|
const presetsBlock = document.getElementById('edit-zone-block-presets');
|
||||||
|
const groupsBlock = document.getElementById('edit-zone-block-groups');
|
||||||
|
const seqBlock = document.getElementById('edit-zone-block-sequences');
|
||||||
|
const vis = (el, show) => {
|
||||||
|
if (el) el.style.display = show ? '' : 'none';
|
||||||
|
};
|
||||||
|
const k = kind === 'sequences' ? 'sequences' : 'presets';
|
||||||
|
vis(groupsBlock, true);
|
||||||
|
vis(presetsBlock, k === 'presets');
|
||||||
|
vis(seqBlock, k === 'sequences');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.normalizeZoneContentKind = normalizeZoneContentKind;
|
||||||
|
window.effectiveZoneContentKind = effectiveZoneContentKind;
|
||||||
|
window.zoneAllowsPresets = zoneAllowsPresets;
|
||||||
|
window.zoneAllowsSequences = zoneAllowsSequences;
|
||||||
|
|
||||||
// Load tabs list
|
// Load tabs list
|
||||||
async function loadZones() {
|
async function loadZones() {
|
||||||
try {
|
try {
|
||||||
@@ -465,13 +624,13 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
|
|||||||
const zone = tabs[zoneId];
|
const zone = tabs[zoneId];
|
||||||
if (zone) {
|
if (zone) {
|
||||||
const activeClass = zoneId === currentZoneId ? 'active' : '';
|
const activeClass = zoneId === currentZoneId ? 'active' : '';
|
||||||
const tabName = zone.name || `Zone ${zoneId}`;
|
const disp = zone.name || `Zone ${zoneId}`;
|
||||||
html += `
|
html += `
|
||||||
<button class="zone-button ${activeClass}"
|
<button class="zone-button ${activeClass}"
|
||||||
data-zone-id="${zoneId}"
|
data-zone-id="${zoneId}"
|
||||||
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
|
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
|
||||||
onclick="selectZone('${zoneId}')">
|
onclick="selectZone('${zoneId}')">
|
||||||
${tabName}
|
${escapeHtmlAttr(disp)}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -511,9 +670,10 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
|
|||||||
row.dataset.zoneId = String(zoneId);
|
row.dataset.zoneId = String(zoneId);
|
||||||
|
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.textContent = (zone && zone.name) || zoneId;
|
const disp = zone.name || `Zone ${zoneId}`;
|
||||||
|
label.textContent = disp;
|
||||||
if (String(zoneId) === String(currentZoneId)) {
|
if (String(zoneId) === String(currentZoneId)) {
|
||||||
label.textContent = `✓ ${label.textContent}`;
|
label.textContent = `✓ ${disp}`;
|
||||||
label.style.fontWeight = "bold";
|
label.style.fontWeight = "bold";
|
||||||
label.style.color = "#FFD700";
|
label.style.color = "#FFD700";
|
||||||
}
|
}
|
||||||
@@ -735,8 +895,14 @@ async function loadZoneContent(zoneId) {
|
|||||||
? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
|
? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
|
||||||
: 255;
|
: 255;
|
||||||
applyBrightnessSliders(normalizedBrightness);
|
applyBrightnessSliders(normalizedBrightness);
|
||||||
// Apply this zone's saved brightness when switching zones.
|
const initialHydration = isFirstZoneContentHydration;
|
||||||
sendZoneBrightness(zoneId, normalizedBrightness);
|
if (isFirstZoneContentHydration) {
|
||||||
|
isFirstZoneContentHydration = false;
|
||||||
|
}
|
||||||
|
if (!suppressZoneContentDriverSideEffects && !initialHydration) {
|
||||||
|
// Apply this zone's saved brightness when switching zones (not initial page load or UI-only strip refresh).
|
||||||
|
sendZoneBrightness(zoneId, normalizedBrightness);
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger presets loading if the function exists
|
// Trigger presets loading if the function exists
|
||||||
if (typeof renderTabPresets === 'function') {
|
if (typeof renderTabPresets === 'function') {
|
||||||
@@ -857,17 +1023,7 @@ async function sendProfilePresets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tabPresetIdsInOrder(tabData) {
|
function tabPresetIdsInOrder(tabData) {
|
||||||
let ids = [];
|
return tabPresetIdsInZoneDoc(tabData);
|
||||||
if (Array.isArray(tabData.presets_flat)) {
|
|
||||||
ids = tabData.presets_flat.slice();
|
|
||||||
} else if (Array.isArray(tabData.presets)) {
|
|
||||||
if (tabData.presets.length && typeof tabData.presets[0] === "string") {
|
|
||||||
ids = tabData.presets.slice();
|
|
||||||
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
|
||||||
ids = tabData.presets.flat();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (ids || []).filter(Boolean);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Presets already on the zone (remove) and presets available to add (select).
|
// Presets already on the zone (remove) and presets available to add (select).
|
||||||
@@ -888,6 +1044,12 @@ async function refreshEditTabPresetsUi(zoneId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tabData = await tabRes.json();
|
const tabData = await tabRes.json();
|
||||||
|
if (!zoneAllowsPresets(tabData, zoneId)) {
|
||||||
|
currentEl.innerHTML =
|
||||||
|
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
|
||||||
|
addEl.innerHTML = '<span class="muted-text">—</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const inTabIds = tabPresetIdsInOrder(tabData);
|
const inTabIds = tabPresetIdsInOrder(tabData);
|
||||||
const inTabSet = new Set(inTabIds.map((id) => String(id)));
|
const inTabSet = new Set(inTabIds.map((id) => String(id)));
|
||||||
|
|
||||||
@@ -911,8 +1073,12 @@ async function refreshEditTabPresetsUi(zoneId) {
|
|||||||
for (const presetId of inTabIds) {
|
for (const presetId of inTabIds) {
|
||||||
const preset = allPresets[presetId] || {};
|
const preset = allPresets[presetId] || {};
|
||||||
const name = preset.name || presetId;
|
const name = preset.name || presetId;
|
||||||
const row = makeRow();
|
const block = document.createElement("div");
|
||||||
|
block.style.cssText =
|
||||||
|
"border:1px solid rgba(255,255,255,0.12);border-radius:6px;padding:0.5rem 0.65rem;margin-bottom:0.65rem;";
|
||||||
|
const top = makeRow();
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
|
label.style.fontWeight = "600";
|
||||||
label.textContent = name;
|
label.textContent = name;
|
||||||
const removeBtn = document.createElement("button");
|
const removeBtn = document.createElement("button");
|
||||||
removeBtn.type = "button";
|
removeBtn.type = "button";
|
||||||
@@ -924,9 +1090,11 @@ async function refreshEditTabPresetsUi(zoneId) {
|
|||||||
await window.removePresetFromTab(zoneId, presetId);
|
await window.removePresetFromTab(zoneId, presetId);
|
||||||
await refreshEditTabPresetsUi(zoneId);
|
await refreshEditTabPresetsUi(zoneId);
|
||||||
});
|
});
|
||||||
row.appendChild(label);
|
top.appendChild(label);
|
||||||
row.appendChild(removeBtn);
|
top.appendChild(removeBtn);
|
||||||
currentEl.appendChild(row);
|
block.appendChild(top);
|
||||||
|
|
||||||
|
currentEl.appendChild(block);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,7 +1155,6 @@ async function openEditZoneModal(zoneId, zone) {
|
|||||||
const modal = document.getElementById("edit-zone-modal");
|
const modal = document.getElementById("edit-zone-modal");
|
||||||
const idInput = document.getElementById("edit-zone-id");
|
const idInput = document.getElementById("edit-zone-id");
|
||||||
const nameInput = document.getElementById("edit-zone-name");
|
const nameInput = document.getElementById("edit-zone-name");
|
||||||
const editor = document.getElementById("edit-zone-devices-editor");
|
|
||||||
|
|
||||||
let tabData = zone;
|
let tabData = zone;
|
||||||
if (!tabData || typeof tabData !== "object" || tabData.error) {
|
if (!tabData || typeof tabData !== "object" || tabData.error) {
|
||||||
@@ -1005,6 +1172,7 @@ async function openEditZoneModal(zoneId, zone) {
|
|||||||
if (idInput) idInput.value = zoneId;
|
if (idInput) idInput.value = zoneId;
|
||||||
if (nameInput) nameInput.value = tabData.name || "";
|
if (nameInput) nameInput.value = tabData.name || "";
|
||||||
|
|
||||||
|
const groupsEditor = document.getElementById("edit-zone-groups-editor");
|
||||||
const groupsMap = await fetchGroupsMap();
|
const groupsMap = await fetchGroupsMap();
|
||||||
const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : [];
|
const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : [];
|
||||||
window.__editTabGroupRows = rawGids.map((gid) => {
|
window.__editTabGroupRows = rawGids.map((gid) => {
|
||||||
@@ -1012,27 +1180,56 @@ async function openEditZoneModal(zoneId, zone) {
|
|||||||
const g = groupsMap[id];
|
const g = groupsMap[id];
|
||||||
return { id, name: g && g.name ? String(g.name).trim() : id };
|
return { id, name: g && g.name ? String(g.name).trim() : id };
|
||||||
});
|
});
|
||||||
renderZoneGroupsEditor(editor, window.__editTabGroupRows, groupsMap);
|
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
|
||||||
|
|
||||||
|
const kind = effectiveZoneContentKind(tabData);
|
||||||
|
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
|
||||||
|
radio.checked = radio.value === kind;
|
||||||
|
});
|
||||||
|
|
||||||
if (modal) modal.classList.add("active");
|
if (modal) modal.classList.add("active");
|
||||||
|
applyZoneContentKindEditModal(kind);
|
||||||
await refreshEditTabPresetsUi(zoneId);
|
await refreshEditTabPresetsUi(zoneId);
|
||||||
|
if (typeof window.refreshEditTabSequencesUi === "function") {
|
||||||
|
await window.refreshEditTabSequencesUi(zoneId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update an existing zone
|
// Update an existing zone (name, group list; devices come from groups only).
|
||||||
async function updateZone(zoneId, name, groupIds) {
|
async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||||
try {
|
try {
|
||||||
const gids = Array.isArray(groupIds)
|
const gids = Array.isArray(groupRows)
|
||||||
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
|
||||||
: [];
|
: [];
|
||||||
|
const ck =
|
||||||
|
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (cur.ok) {
|
||||||
|
const j = await cur.json();
|
||||||
|
if (j && typeof j === 'object') existing = j;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* use empty existing */
|
||||||
|
}
|
||||||
const response = await fetch(`/zones/${zoneId}`, {
|
const response = await fetch(`/zones/${zoneId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
...existing,
|
||||||
name: name,
|
name: name,
|
||||||
group_ids: gids,
|
|
||||||
names: [],
|
names: [],
|
||||||
|
group_ids: gids,
|
||||||
|
preset_group_ids:
|
||||||
|
existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
|
||||||
|
? existing.preset_group_ids
|
||||||
|
: {},
|
||||||
|
content_kind: ck,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1055,12 +1252,11 @@ async function updateZone(zoneId, name, groupIds) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new zone
|
// Create a new zone (add devices in Edit zone). ``contentKind`` is ``'presets'`` | ``'sequences'``.
|
||||||
async function createZone(name, groupIds) {
|
async function createZone(name, contentKind) {
|
||||||
try {
|
try {
|
||||||
const gids = Array.isArray(groupIds)
|
const ck =
|
||||||
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
|
||||||
: [];
|
|
||||||
const response = await fetch('/zones', {
|
const response = await fetch('/zones', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1068,8 +1264,9 @@ async function createZone(name, groupIds) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: name,
|
name: name,
|
||||||
group_ids: gids,
|
|
||||||
names: [],
|
names: [],
|
||||||
|
group_ids: [],
|
||||||
|
content_kind: ck,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1150,8 +1347,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const name = newTabNameInput.value.trim();
|
const name = newTabNameInput.value.trim();
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
const groupIds = await defaultGroupIdsForNewTab();
|
const kindRadio = document.querySelector(
|
||||||
await createZone(name, groupIds);
|
'input[name="new-zone-content-kind"]:checked',
|
||||||
|
);
|
||||||
|
const contentKind =
|
||||||
|
kindRadio && kindRadio.value === 'sequences' ? 'sequences' : 'presets';
|
||||||
|
await createZone(name, contentKind);
|
||||||
if (newTabNameInput) newTabNameInput.value = "";
|
if (newTabNameInput) newTabNameInput.value = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1168,6 +1369,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
|
||||||
|
radio.addEventListener('change', async () => {
|
||||||
|
applyZoneContentKindEditModal(editModalContentKindSelected());
|
||||||
|
const zoneId = document.getElementById('edit-zone-id')?.value;
|
||||||
|
if (!zoneId) return;
|
||||||
|
await refreshEditTabPresetsUi(zoneId);
|
||||||
|
if (typeof window.refreshEditTabSequencesUi === 'function') {
|
||||||
|
await window.refreshEditTabSequencesUi(zoneId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Set up edit zone form
|
// Set up edit zone form
|
||||||
const editZoneForm = document.getElementById('edit-zone-form');
|
const editZoneForm = document.getElementById('edit-zone-form');
|
||||||
if (editZoneForm) {
|
if (editZoneForm) {
|
||||||
@@ -1178,15 +1391,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
const zoneId = idInput ? idInput.value : null;
|
const zoneId = idInput ? idInput.value : null;
|
||||||
const name = nameInput ? nameInput.value.trim() : "";
|
const name = nameInput ? nameInput.value.trim() : "";
|
||||||
const rows = window.__editTabGroupRows || [];
|
const groupRows = window.__editTabGroupRows || [];
|
||||||
const groupIds = rows.map((r) => r.id).filter(Boolean);
|
|
||||||
|
|
||||||
if (zoneId && name) {
|
if (zoneId && name) {
|
||||||
if (groupIds.length === 0) {
|
await updateZone(zoneId, name, groupRows, editModalContentKindSelected());
|
||||||
alert("Add at least one device group.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await updateZone(zoneId, name, groupIds);
|
|
||||||
editZoneForm.reset();
|
editZoneForm.reset();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1220,9 +1428,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
await loadZones();
|
suppressZoneContentDriverSideEffects = true;
|
||||||
if (zonesModal && zonesModal.classList.contains("active")) {
|
try {
|
||||||
await loadZonesModal();
|
await loadZones();
|
||||||
|
if (zonesModal && zonesModal.classList.contains("active")) {
|
||||||
|
await loadZonesModal();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
suppressZoneContentDriverSideEffects = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1240,6 +1453,14 @@ window.zonesManager = {
|
|||||||
resolveZoneDeviceMacsFromZoneData,
|
resolveZoneDeviceMacsFromZoneData,
|
||||||
resolveTabDeviceMacs: resolveZoneDeviceMacs,
|
resolveTabDeviceMacs: resolveZoneDeviceMacs,
|
||||||
getCurrentZoneId: () => currentZoneId,
|
getCurrentZoneId: () => currentZoneId,
|
||||||
|
computeZoneTargets,
|
||||||
|
computeZoneNamesTargets,
|
||||||
|
computeZonePresetUnionTargets,
|
||||||
|
effectiveGroupIdsForZonePreset,
|
||||||
|
resolveDeviceNamesForZonePreset,
|
||||||
|
resolveSequenceStepDeviceNames,
|
||||||
|
fetchGroupsMap,
|
||||||
|
renderZoneGroupsEditor,
|
||||||
};
|
};
|
||||||
window.tabsManager = window.zonesManager;
|
window.tabsManager = window.zonesManager;
|
||||||
window.tabsManager.getCurrentTabId = () => currentZoneId;
|
window.tabsManager.getCurrentTabId = () => currentZoneId;
|
||||||
|
|||||||
@@ -9,16 +9,21 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<header>
|
<header>
|
||||||
<div class="zones-container">
|
|
||||||
<div id="zones-list">
|
|
||||||
Loading zones...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="header-end">
|
<div class="header-end">
|
||||||
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
|
<div class="nav-slide-toggle-wrap seq-switch-toggle-wrap edit-mode-only" id="seq-switch-toggle-wrap">
|
||||||
<span class="audio-top-indicator-label">BPM</span>
|
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
|
||||||
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
|
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle" aria-pressed="false" aria-label="Switch sequence on beat" title="When starting a sequence: wait for beat or downbeat">
|
||||||
<span id="audio-top-beat-count" class="audio-top-indicator-subvalue">#0</span>
|
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
|
||||||
|
</button>
|
||||||
|
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
|
||||||
|
</div>
|
||||||
|
<div id="audio-top-indicator" class="audio-top-indicator">
|
||||||
|
<button type="button" id="audio-top-beat-sync" class="audio-top-beat-sync" disabled title="Sync step to music (S)">
|
||||||
|
<span class="audio-top-indicator-label">BPM</span>
|
||||||
|
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
|
||||||
|
<span id="audio-top-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
|
||||||
|
<span id="audio-top-bar-phase" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<div class="header-brightness-control">
|
<div class="header-brightness-control">
|
||||||
@@ -30,17 +35,26 @@
|
|||||||
<button class="btn btn-secondary edit-mode-only" id="groups-btn">Groups</button>
|
<button class="btn btn-secondary edit-mode-only" id="groups-btn">Groups</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
|
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
||||||
|
<button class="btn btn-secondary edit-mode-only" id="sequences-btn">Sequences</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
|
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
|
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
|
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
|
||||||
<button class="btn btn-secondary" id="audio-btn">Audio</button>
|
<button class="btn btn-secondary" id="audio-btn">Audio</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="audio-nav-reset-btn" hidden title="Clear stuck BPM / beat tracking">Reset detector</button>
|
||||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||||
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-menu-mobile">
|
<div class="header-menu-mobile">
|
||||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||||
|
<div class="nav-slide-toggle-wrap nav-slide-toggle-wrap--mobile seq-switch-toggle-wrap" id="seq-switch-toggle-wrap-mobile">
|
||||||
|
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
|
||||||
|
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle-mobile" aria-pressed="false" aria-label="Switch sequence on beat" title="Beat or downbeat">
|
||||||
|
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
|
||||||
|
</button>
|
||||||
|
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
|
||||||
|
</div>
|
||||||
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||||
<div class="menu-brightness-control">
|
<div class="menu-brightness-control">
|
||||||
<label for="menu-brightness-slider">Brightness</label>
|
<label for="menu-brightness-slider">Brightness</label>
|
||||||
@@ -51,15 +65,22 @@
|
|||||||
<button type="button" class="edit-mode-only" data-target="groups-btn">Groups</button>
|
<button type="button" class="edit-mode-only" data-target="groups-btn">Groups</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
|
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
||||||
|
<button type="button" class="edit-mode-only" data-target="sequences-btn">Sequences</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
|
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
|
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
|
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
|
||||||
<button type="button" data-target="audio-btn">Audio</button>
|
<button type="button" data-target="audio-btn">Audio</button>
|
||||||
|
<button type="button" id="audio-nav-reset-mobile" data-target="audio-nav-reset-btn" hidden>Reset detector</button>
|
||||||
<button type="button" data-target="help-btn">Help</button>
|
<button type="button" data-target="help-btn">Help</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="zones-container">
|
||||||
|
<div id="zones-list">
|
||||||
|
Loading zones...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
@@ -79,6 +100,10 @@
|
|||||||
<input type="text" id="new-zone-name" placeholder="Zone name">
|
<input type="text" id="new-zone-name" placeholder="Zone name">
|
||||||
<button class="btn btn-primary" id="create-zone-btn">Create</button>
|
<button class="btn btn-primary" id="create-zone-btn">Create</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="zone-content-kind-row muted-text">
|
||||||
|
<label><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
|
||||||
|
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
|
||||||
|
</div>
|
||||||
<div id="zones-list-modal" class="profiles-list"></div>
|
<div id="zones-list-modal" class="profiles-list"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
||||||
@@ -92,18 +117,32 @@
|
|||||||
<h2>Edit Zone</h2>
|
<h2>Edit Zone</h2>
|
||||||
<form id="edit-zone-form">
|
<form id="edit-zone-form">
|
||||||
<input type="hidden" id="edit-zone-id">
|
<input type="hidden" id="edit-zone-id">
|
||||||
<div class="modal-actions" style="margin-bottom: 1rem;">
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
|
|
||||||
</div>
|
|
||||||
<label>Zone Name:</label>
|
<label>Zone Name:</label>
|
||||||
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
||||||
<label class="zone-devices-label">Device groups in this zone</label>
|
<div class="zone-content-kind-row muted-text">
|
||||||
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
|
<label><input type="radio" name="edit-zone-content-kind" value="presets" checked> Presets</label>
|
||||||
|
<label><input type="radio" name="edit-zone-content-kind" value="sequences"> Sequences</label>
|
||||||
|
</div>
|
||||||
|
<div id="edit-zone-block-groups">
|
||||||
|
<label class="zone-devices-label">Device groups on this zone</label>
|
||||||
|
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
|
||||||
|
</div>
|
||||||
|
<div id="edit-zone-block-presets">
|
||||||
<label class="zone-presets-section-label">Presets on this zone</label>
|
<label class="zone-presets-section-label">Presets on this zone</label>
|
||||||
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
|
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
<label class="zone-presets-section-label">Add presets to this zone</label>
|
<label class="zone-presets-section-label">Add presets to this zone</label>
|
||||||
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
|
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
|
</div>
|
||||||
|
<div id="edit-zone-block-sequences">
|
||||||
|
<label class="zone-presets-section-label">Sequences on this zone</label>
|
||||||
|
<div id="edit-zone-sequences-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
|
<label class="zone-presets-section-label">Add a sequence to this zone</label>
|
||||||
|
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,6 +154,7 @@
|
|||||||
<div class="profiles-actions">
|
<div class="profiles-actions">
|
||||||
<input type="text" id="new-profile-name" placeholder="Profile name">
|
<input type="text" id="new-profile-name" placeholder="Profile name">
|
||||||
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="import-profile-btn">Import</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
||||||
@@ -140,13 +180,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Device groups: members + Wi‑Fi driver defaults (zones reference groups) -->
|
<!-- Device groups: members + Wi‑Fi driver defaults (zones reference groups for presets) -->
|
||||||
<div id="groups-modal" class="modal">
|
<div id="groups-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Device groups</h2>
|
<h2>Device groups</h2>
|
||||||
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set Wi‑Fi defaults once per group, then attach groups to zones.</p>
|
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set Wi‑Fi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lane’s groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
|
||||||
<div class="profiles-actions zone-modal-create-row">
|
<div class="profiles-actions zone-modal-create-row">
|
||||||
<input type="text" id="new-group-name" placeholder="Group name">
|
<input type="text" id="new-group-name" placeholder="Group name">
|
||||||
|
<label class="muted-text" style="display:inline-flex;align-items:center;gap:0.35rem;white-space:nowrap;">
|
||||||
|
<input type="checkbox" id="new-group-profile-only"> This profile only
|
||||||
|
</label>
|
||||||
<button class="btn btn-primary" id="create-group-btn">Create</button>
|
<button class="btn btn-primary" id="create-group-btn">Create</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="groups-list-modal" class="profiles-list"></div>
|
<div id="groups-list-modal" class="profiles-list"></div>
|
||||||
@@ -161,14 +204,18 @@
|
|||||||
<h2>Edit device group</h2>
|
<h2>Edit device group</h2>
|
||||||
<form id="edit-group-form">
|
<form id="edit-group-form">
|
||||||
<input type="hidden" id="edit-group-id">
|
<input type="hidden" id="edit-group-id">
|
||||||
<div class="modal-actions" style="margin-bottom: 1rem;">
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<label for="edit-group-name">Group name</label>
|
<label for="edit-group-name">Group name</label>
|
||||||
<input type="text" id="edit-group-name" required autocomplete="off">
|
<input type="text" id="edit-group-name" required autocomplete="off">
|
||||||
|
<label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;">
|
||||||
|
<input type="checkbox" id="edit-group-share-all-profiles" style="margin-top:0.2rem;">
|
||||||
|
<span>Share with all profiles (untick to keep this group on the <strong>current profile only</strong>)</span>
|
||||||
|
</label>
|
||||||
<label class="zone-devices-label">Devices in this group</label>
|
<label class="zone-devices-label">Devices in this group</label>
|
||||||
<div id="edit-group-devices-editor" class="zone-devices-editor"></div>
|
<div id="edit-group-devices-editor" class="zone-devices-editor"></div>
|
||||||
|
<div class="profiles-actions" style="margin-top: 0.5rem;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="edit-group-identify-btn">Identify devices in group</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted-text" style="margin-top:0.25rem;">Runs identify on every driver in the group at the same time so they blink together.</p>
|
||||||
<label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0–255)</label>
|
<label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0–255)</label>
|
||||||
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
|
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
|
||||||
<input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;">
|
<input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;">
|
||||||
@@ -197,6 +244,10 @@
|
|||||||
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
|
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
|
||||||
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
|
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
|
||||||
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
|
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,6 +322,7 @@
|
|||||||
<h2>Presets</h2>
|
<h2>Presets</h2>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" id="preset-add-btn">Add</button>
|
<button class="btn btn-primary" id="preset-add-btn">Add</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="import-preset-btn">Import</button>
|
||||||
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
|
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="presets-list" class="profiles-list"></div>
|
<div id="presets-list" class="profiles-list"></div>
|
||||||
@@ -280,6 +332,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sequences Modal -->
|
||||||
|
<div id="sequences-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Sequences</h2>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="import-sequence-btn">Import</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
|
||||||
|
</div>
|
||||||
|
<div id="sequences-list" class="profiles-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="sequences-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sequence Editor Modal -->
|
||||||
|
<div id="sequence-editor-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Sequence</h2>
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="sequence-editor-name">Name</label>
|
||||||
|
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
|
||||||
|
</div>
|
||||||
|
<div id="sequence-editor-beats-panel" style="margin:0 0 0.75rem 0;">
|
||||||
|
<p class="muted-text" style="font-size:0.85em;margin:0 0 0.5rem 0;">
|
||||||
|
Each step runs for the number of <strong>beats</strong> you set on that step.
|
||||||
|
When the header <strong>Audio</strong> detector is running, real beats advance the sequence.
|
||||||
|
When it is stopped, the server uses <strong>simulated</strong> beats at the BPM below.
|
||||||
|
</p>
|
||||||
|
<label for="sequence-editor-simulated-bpm" style="display:block;margin-bottom:0.25rem;">Simulated BPM (when audio is off)</label>
|
||||||
|
<input type="number" id="sequence-editor-simulated-bpm" min="30" max="300" value="120" style="width:6rem;" title="Used only while the audio detector is stopped">
|
||||||
|
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;">—</p>
|
||||||
|
<label style="display:block;margin-top:0.65rem;">
|
||||||
|
<input type="checkbox" id="sequence-editor-loop" checked>
|
||||||
|
Loop sequence (restart from the first step after the last)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="sequence-editor-lanes"></div>
|
||||||
|
<div class="modal-actions preset-editor-modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Preset Editor Modal -->
|
<!-- Preset Editor Modal -->
|
||||||
<div id="preset-editor-modal" class="modal">
|
<div id="preset-editor-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -309,6 +409,7 @@
|
|||||||
<label for="preset-background-input">Background</label>
|
<label for="preset-background-input">Background</label>
|
||||||
<div class="profiles-actions" style="gap: 0.4rem;">
|
<div class="profiles-actions" style="gap: 0.4rem;">
|
||||||
<button type="button" class="btn btn-secondary btn-small" id="preset-background-btn" title="Choose background colour">#000000</button>
|
<button type="button" class="btn btn-secondary btn-small" id="preset-background-btn" title="Choose background colour">#000000</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="preset-background-from-palette-btn">From Palette</button>
|
||||||
<input type="color" id="preset-background-input" value="#000000" title="Background colour used in patterns with background support" style="position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;">
|
<input type="color" id="preset-background-input" value="#000000" title="Background colour used in patterns with background support" style="position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,10 +422,20 @@
|
|||||||
<p id="preset-manual-mode-hint" class="muted-text" style="display: none; margin-top: 0.35rem; font-size: 0.85em;"></p>
|
<p id="preset-manual-mode-hint" class="muted-text" style="display: none; margin-top: 0.35rem; font-size: 0.85em;"></p>
|
||||||
<div id="preset-manual-beat-n-wrap" class="preset-editor-field" style="display: none; margin-top: 0.5rem;">
|
<div id="preset-manual-beat-n-wrap" class="preset-editor-field" style="display: none; margin-top: 0.5rem;">
|
||||||
<label for="preset-manual-beat-n-input">Audio beat: every</label>
|
<label for="preset-manual-beat-n-input">Audio beat: every</label>
|
||||||
<input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" title="Controller only; not sent to pattern logic">
|
<input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" title="Controller only; not sent to pattern logic" autocomplete="off">
|
||||||
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
|
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="preset-editor-field" id="preset-reverse-group" hidden>
|
||||||
|
<label for="preset-reverse-input" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0;">
|
||||||
|
<input type="checkbox" id="preset-reverse-input">
|
||||||
|
Reverse direction (strip installed upside down)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-field preset-mode-field" id="preset-mode-group" hidden>
|
||||||
|
<label for="preset-mode-input" id="preset-mode-label">Mode</label>
|
||||||
|
<select id="preset-mode-input" class="preset-mode-input"></select>
|
||||||
|
</div>
|
||||||
<div class="n-params-grid">
|
<div class="n-params-grid">
|
||||||
<div class="n-param-group">
|
<div class="n-param-group">
|
||||||
<label for="preset-n1-input" id="preset-n1-label">n1:</label>
|
<label for="preset-n1-input" id="preset-n1-label">n1:</label>
|
||||||
@@ -533,16 +644,47 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Current BPM</label>
|
<label>Current BPM</label>
|
||||||
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
|
<div class="audio-bpm-row">
|
||||||
|
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Detected hit type</label>
|
<label>Detected hit type</label>
|
||||||
<div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div>
|
<div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bar phase</label>
|
||||||
|
<div class="audio-bpm-row">
|
||||||
|
<div id="audio-bar-phase-value" class="audio-bpm-readout" title="Beat in bar (kick hints downbeat)">--</div>
|
||||||
|
</div>
|
||||||
|
<small class="muted-text">Bar uses kick-heavy hits (default 4/4). Tap <strong>Sync</strong> on a downbeat to lock bar phase.</small>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Flash on beat</label>
|
<label>Flash on beat</label>
|
||||||
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
|
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section audio-settings-section">
|
||||||
|
<h3>Audio settings</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
|
||||||
|
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
|
||||||
|
<small class="muted-text">Delays beat flashes so they line up with what you hear (saved on the controller).</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Beat sync</label>
|
||||||
|
<button type="button" id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" disabled title="Sync step to music (S)" aria-live="polite"></button>
|
||||||
|
<small class="muted-text">While a sequence is playing, tap the BPM/beat button in the header on a downbeat to align the step counter. Shortcut: <kbd>S</kbd>.</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Sequence alignment</label>
|
||||||
|
<div class="profiles-actions" style="flex-wrap: wrap;">
|
||||||
|
<button type="button" class="btn btn-secondary" id="audio-sync-pass-btn">Restart pass</button>
|
||||||
|
</div>
|
||||||
|
<small class="muted-text"><strong>Restart pass</strong> jumps to step 1 of the sequence (<kbd>Shift+S</kbd>). Use <strong>Reset detector</strong> in the header (while audio is running) to clear stuck BPM/beat tracking without stopping audio.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
|
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
|
||||||
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
|
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
|
||||||
@@ -623,79 +765,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LED Tool Modal -->
|
<!-- LED Tool Modal (led-tool/static settings editor) -->
|
||||||
<div id="led-tool-modal" class="modal">
|
<div id="led-tool-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content" style="max-width: 960px; width: 95vw;">
|
||||||
<h2>LED Tool (USB)</h2>
|
<div class="modal-actions" style="margin-bottom: 0.5rem;">
|
||||||
<p class="muted-text" style="margin-top: 0;">Configure a driver connected over USB using <code>led-tool</code>.</p>
|
<h2 style="margin: 0; flex: 1;">LED Tool — device settings</h2>
|
||||||
<div id="led-tool-message" class="message" style="margin-bottom: 0.75rem;"></div>
|
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
|
||||||
<form id="led-tool-form">
|
</div>
|
||||||
<div class="form-group">
|
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" style="width:100%;height:min(75vh,720px);border:1px solid #4a4a4a;border-radius:4px;background:#0b1020;"></iframe>
|
||||||
<label for="led-tool-port">Serial port</label>
|
|
||||||
<div class="profiles-actions" style="gap: 0.5rem;">
|
|
||||||
<select id="led-tool-port" required style="flex:1;">
|
|
||||||
<option value="">Select a serial port</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-refresh-ports-btn">Refresh</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="led-tool-name">Name</label>
|
|
||||||
<input type="text" id="led-tool-name" placeholder="led-abcdef123456">
|
|
||||||
</div>
|
|
||||||
<div class="profiles-actions">
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-num-leds">Num LEDs</label>
|
|
||||||
<input type="number" id="led-tool-num-leds" min="1" max="5000" placeholder="60">
|
|
||||||
</div>
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-led-pin">LED pin</label>
|
|
||||||
<input type="number" id="led-tool-led-pin" min="0" max="48" placeholder="4">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="profiles-actions">
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-brightness">Brightness</label>
|
|
||||||
<input type="number" id="led-tool-brightness" min="0" max="255" placeholder="255">
|
|
||||||
</div>
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-wifi-channel">WiFi channel</label>
|
|
||||||
<input type="number" id="led-tool-wifi-channel" min="1" max="11" placeholder="6">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="profiles-actions">
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-transport">Transport</label>
|
|
||||||
<select id="led-tool-transport">
|
|
||||||
<option value="">(no change)</option>
|
|
||||||
<option value="espnow">espnow</option>
|
|
||||||
<option value="wifi">wifi</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-default">Default preset</label>
|
|
||||||
<input type="text" id="led-tool-default" placeholder="on">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="led-tool-ssid">SSID</label>
|
|
||||||
<input type="text" id="led-tool-ssid" placeholder="Your WiFi SSID">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="led-tool-password">WiFi password</label>
|
|
||||||
<input type="password" id="led-tool-password" placeholder="WiFi password">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-read-btn">Read</button>
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-reset-btn">Reset</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Apply via USB</button>
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<label for="led-tool-output" style="margin-top:0.5rem; display:block;">Command output</label>
|
|
||||||
<textarea id="led-tool-output" rows="12" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -705,11 +782,14 @@
|
|||||||
<script src="/static/help.js"></script>
|
<script src="/static/help.js"></script>
|
||||||
<script src="/static/led_tool.js"></script>
|
<script src="/static/led_tool.js"></script>
|
||||||
<script src="/static/color_palette.js"></script>
|
<script src="/static/color_palette.js"></script>
|
||||||
|
<script src="/static/bundle_io.js"></script>
|
||||||
<script src="/static/profiles.js"></script>
|
<script src="/static/profiles.js"></script>
|
||||||
<script src="/static/zone_palette.js"></script>
|
<script src="/static/zone_palette.js"></script>
|
||||||
<script src="/static/patterns.js"></script>
|
<script src="/static/patterns.js"></script>
|
||||||
<script src="/static/presets.js"></script>
|
<script src="/static/presets.js"></script>
|
||||||
|
<script src="/static/sequences.js"></script>
|
||||||
<script src="/static/devices.js"></script>
|
<script src="/static/devices.js"></script>
|
||||||
<script src="/static/audio.js"></script>
|
<script src="/static/audio.js"></script>
|
||||||
|
<script src="/static/numpad.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -261,7 +261,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/settings/settings', {
|
const response = await fetch('/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ wifi_channel: wifiChannel }),
|
body: JSON.stringify({ wifi_channel: wifiChannel }),
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import os
|
|||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_HOLDOVER_BPM_MIN = 30.0
|
||||||
|
_HOLDOVER_BPM_MAX = 300.0
|
||||||
|
_HOLDOVER_MAX_S = 300.0
|
||||||
|
|
||||||
|
|
||||||
class AudioBeatDetector:
|
class AudioBeatDetector:
|
||||||
@@ -13,6 +19,11 @@ class AudioBeatDetector:
|
|||||||
self._stream = None
|
self._stream = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
|
self._runtime = None
|
||||||
|
self._pending_reset = False
|
||||||
|
self._holdover_thread: threading.Thread | None = None
|
||||||
|
self._holdover_stop = threading.Event()
|
||||||
|
self._holdover_active = False
|
||||||
self._status = {
|
self._status = {
|
||||||
"running": False,
|
"running": False,
|
||||||
"bpm": None,
|
"bpm": None,
|
||||||
@@ -20,6 +31,11 @@ class AudioBeatDetector:
|
|||||||
"beat_seq": 0,
|
"beat_seq": 0,
|
||||||
"beat_type": "unknown",
|
"beat_type": "unknown",
|
||||||
"beat_type_confidence": 0.0,
|
"beat_type_confidence": 0.0,
|
||||||
|
"bar_beat": 1,
|
||||||
|
"beats_per_bar": 4,
|
||||||
|
"is_downbeat": False,
|
||||||
|
"phase_confidence": 0.0,
|
||||||
|
"bar_phase_readout": "1/4",
|
||||||
"error": None,
|
"error": None,
|
||||||
"device": None,
|
"device": None,
|
||||||
}
|
}
|
||||||
@@ -100,6 +116,11 @@ class AudioBeatDetector:
|
|||||||
"beat_seq": 0,
|
"beat_seq": 0,
|
||||||
"beat_type": "unknown",
|
"beat_type": "unknown",
|
||||||
"beat_type_confidence": 0.0,
|
"beat_type_confidence": 0.0,
|
||||||
|
"bar_beat": 1,
|
||||||
|
"beats_per_bar": 4,
|
||||||
|
"is_downbeat": False,
|
||||||
|
"phase_confidence": 0.0,
|
||||||
|
"bar_phase_readout": "1/4",
|
||||||
"error": None,
|
"error": None,
|
||||||
"device": device,
|
"device": device,
|
||||||
}
|
}
|
||||||
@@ -111,6 +132,7 @@ class AudioBeatDetector:
|
|||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
self._stop_bpm_holdover()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
t = self._thread
|
t = self._thread
|
||||||
@@ -139,11 +161,159 @@ class AudioBeatDetector:
|
|||||||
self._running = False
|
self._running = False
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stream = None
|
self._stream = None
|
||||||
|
self._pending_reset = False
|
||||||
self._status["running"] = False
|
self._status["running"] = False
|
||||||
|
|
||||||
def status(self):
|
def status(self):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return dict(self._status)
|
st = dict(self._status)
|
||||||
|
holdover = self._holdover_active
|
||||||
|
last = st.get("last_beat_ts")
|
||||||
|
if st.get("running") and last is not None and not holdover:
|
||||||
|
try:
|
||||||
|
if (time.time() - float(last)) > 4.0:
|
||||||
|
st["bpm"] = None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return st
|
||||||
|
|
||||||
|
def _apply_tracking_reset_status(self) -> None:
|
||||||
|
"""Refresh published status after a tracking reset (lock must be held)."""
|
||||||
|
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
|
||||||
|
self._status.update(
|
||||||
|
{
|
||||||
|
"running": True,
|
||||||
|
"beat_type": "unknown",
|
||||||
|
"beat_type_confidence": 0.0,
|
||||||
|
"bar_beat": 1,
|
||||||
|
"is_downbeat": True,
|
||||||
|
"phase_confidence": 0.0,
|
||||||
|
"bar_phase_readout": f"1/{bpb}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clamp_holdover_bpm(self, bpm: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
v = float(bpm)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not (_HOLDOVER_BPM_MIN <= v <= _HOLDOVER_BPM_MAX):
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
def _holdover_interval_s(self, bpm: float) -> float:
|
||||||
|
return 60.0 / max(_HOLDOVER_BPM_MIN, min(_HOLDOVER_BPM_MAX, float(bpm)))
|
||||||
|
|
||||||
|
def _stop_bpm_holdover(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._holdover_active = False
|
||||||
|
self._holdover_stop.set()
|
||||||
|
t = self._holdover_thread
|
||||||
|
if t and t.is_alive() and t is not threading.current_thread():
|
||||||
|
t.join(timeout=2.0)
|
||||||
|
with self._lock:
|
||||||
|
if self._holdover_thread is t:
|
||||||
|
self._holdover_thread = None
|
||||||
|
|
||||||
|
def _advance_holdover_bar_phase_locked(self) -> dict:
|
||||||
|
"""Advance bar phase for one synthetic beat (lock must be held)."""
|
||||||
|
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
|
||||||
|
prev = int(self._status.get("bar_beat") or 1)
|
||||||
|
bar_beat = (prev % bpb) + 1
|
||||||
|
is_downbeat = bar_beat == 1
|
||||||
|
bar_readout = f"{bar_beat}/{bpb}"
|
||||||
|
self._status["bar_beat"] = bar_beat
|
||||||
|
self._status["is_downbeat"] = is_downbeat
|
||||||
|
self._status["bar_phase_readout"] = bar_readout
|
||||||
|
return {
|
||||||
|
"bar_beat": bar_beat,
|
||||||
|
"beats_per_bar": bpb,
|
||||||
|
"is_downbeat": is_downbeat,
|
||||||
|
"bar_phase_readout": bar_readout,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _emit_holdover_beat(self, bpm: float) -> None:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
if not self._running or not self._holdover_active:
|
||||||
|
return
|
||||||
|
self._advance_holdover_bar_phase_locked()
|
||||||
|
self._status["last_beat_ts"] = now
|
||||||
|
self._status["bpm"] = float(bpm)
|
||||||
|
self._status["beat_type"] = "holdover"
|
||||||
|
self._status["beat_type_confidence"] = 0.0
|
||||||
|
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
|
||||||
|
try:
|
||||||
|
from util import sequence_playback as seq_pb
|
||||||
|
|
||||||
|
seq_pb.push_thread_beat()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[audio] holdover beat queue: {e}")
|
||||||
|
|
||||||
|
def _holdover_loop(self, bpm: float, started_at: float) -> None:
|
||||||
|
interval = self._holdover_interval_s(bpm)
|
||||||
|
while not self._holdover_stop.is_set():
|
||||||
|
with self._lock:
|
||||||
|
if not self._running or not self._holdover_active:
|
||||||
|
return
|
||||||
|
if (time.time() - started_at) > _HOLDOVER_MAX_S:
|
||||||
|
self._holdover_active = False
|
||||||
|
return
|
||||||
|
last = self._status.get("last_beat_ts")
|
||||||
|
if last is not None:
|
||||||
|
try:
|
||||||
|
delay = max(0.02, float(last) + interval - time.time())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
delay = interval
|
||||||
|
else:
|
||||||
|
delay = interval
|
||||||
|
if self._holdover_stop.wait(delay):
|
||||||
|
return
|
||||||
|
self._emit_holdover_beat(bpm)
|
||||||
|
|
||||||
|
def _start_bpm_holdover(self, bpm: float) -> None:
|
||||||
|
bpm_v = self._clamp_holdover_bpm(bpm)
|
||||||
|
if bpm_v is None:
|
||||||
|
return
|
||||||
|
self._stop_bpm_holdover()
|
||||||
|
self._holdover_stop.clear()
|
||||||
|
started_at = time.time()
|
||||||
|
with self._lock:
|
||||||
|
self._holdover_active = True
|
||||||
|
self._holdover_thread = threading.Thread(
|
||||||
|
target=self._holdover_loop,
|
||||||
|
args=(bpm_v, started_at),
|
||||||
|
name="audio-bpm-holdover",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t = self._holdover_thread
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def _process_pending_reset(self, runtime) -> None:
|
||||||
|
"""Run ``reset_state`` on the audio thread (safe for aubio tempo)."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._pending_reset:
|
||||||
|
return
|
||||||
|
self._pending_reset = False
|
||||||
|
try:
|
||||||
|
runtime.reset_state()
|
||||||
|
with self._lock:
|
||||||
|
self._apply_tracking_reset_status()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[audio] pending reset: {e}")
|
||||||
|
|
||||||
|
def reset_tracking(self) -> bool:
|
||||||
|
"""Clear detector tempo history without stopping the input stream."""
|
||||||
|
holdover_bpm = None
|
||||||
|
with self._lock:
|
||||||
|
if not self._running or self._runtime is None:
|
||||||
|
return False
|
||||||
|
holdover_bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
|
||||||
|
self._pending_reset = True
|
||||||
|
self._apply_tracking_reset_status()
|
||||||
|
if holdover_bpm is not None:
|
||||||
|
self._start_bpm_holdover(holdover_bpm)
|
||||||
|
return True
|
||||||
|
|
||||||
def _set_error(self, msg):
|
def _set_error(self, msg):
|
||||||
print(f"[audio] {msg}")
|
print(f"[audio] {msg}")
|
||||||
@@ -152,7 +322,28 @@ class AudioBeatDetector:
|
|||||||
self._status["running"] = False
|
self._status["running"] = False
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0):
|
def anchor_bar_phase(self) -> bool:
|
||||||
|
"""Mark the current moment as bar beat 1 (downbeat), e.g. after manual sync."""
|
||||||
|
with self._lock:
|
||||||
|
rt = self._runtime
|
||||||
|
if rt is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
rt.anchor_bar_phase(time.time())
|
||||||
|
with self._lock:
|
||||||
|
self._status["bar_beat"] = 1
|
||||||
|
self._status["is_downbeat"] = True
|
||||||
|
self._status["bar_phase_readout"] = f"1/{int(self._status.get('beats_per_bar') or 4)}"
|
||||||
|
self._status["phase_confidence"] = max(
|
||||||
|
float(self._status.get("phase_confidence") or 0.0), 0.85
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[audio] anchor_bar_phase: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0, **phase_fields):
|
||||||
|
self._stop_bpm_holdover()
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._status["last_beat_ts"] = now
|
self._status["last_beat_ts"] = now
|
||||||
@@ -160,12 +351,22 @@ class AudioBeatDetector:
|
|||||||
self._status["beat_type"] = beat_type
|
self._status["beat_type"] = beat_type
|
||||||
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
|
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
|
||||||
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
|
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
|
||||||
|
if phase_fields.get("bar_beat") is not None:
|
||||||
|
self._status["bar_beat"] = int(phase_fields["bar_beat"])
|
||||||
|
if phase_fields.get("beats_per_bar") is not None:
|
||||||
|
self._status["beats_per_bar"] = int(phase_fields["beats_per_bar"])
|
||||||
|
if phase_fields.get("is_downbeat") is not None:
|
||||||
|
self._status["is_downbeat"] = bool(phase_fields["is_downbeat"])
|
||||||
|
if phase_fields.get("phase_confidence") is not None:
|
||||||
|
self._status["phase_confidence"] = float(phase_fields["phase_confidence"])
|
||||||
|
if phase_fields.get("bar_phase_readout"):
|
||||||
|
self._status["bar_phase_readout"] = str(phase_fields["bar_phase_readout"])
|
||||||
try:
|
try:
|
||||||
from util.beat_driver_route import notify_beat_detected
|
from util import sequence_playback as seq_pb
|
||||||
|
|
||||||
notify_beat_detected()
|
seq_pb.push_thread_beat()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[audio] beat driver route: {e}")
|
print(f"[audio] sequence beat queue: {e}")
|
||||||
|
|
||||||
def _run_loop(self, device):
|
def _run_loop(self, device):
|
||||||
try:
|
try:
|
||||||
@@ -210,15 +411,17 @@ class AudioBeatDetector:
|
|||||||
flux_weight=0.3,
|
flux_weight=0.3,
|
||||||
threshold_multiplier=1.35,
|
threshold_multiplier=1.35,
|
||||||
ema_alpha=0.08,
|
ema_alpha=0.08,
|
||||||
min_ioi_ms=85.0,
|
min_ioi_ms=100.0,
|
||||||
bpm_window=8,
|
bpm_window=8,
|
||||||
post_url="",
|
post_url="",
|
||||||
aubio_method="default",
|
aubio_method="default",
|
||||||
aubio_threshold=0.12,
|
aubio_threshold=0.14,
|
||||||
silence_gate_db=-58.0,
|
beats_per_bar=4,
|
||||||
)
|
)
|
||||||
runtime = beat_mod.BeatDetectRuntime(args)
|
runtime = beat_mod.BeatDetectRuntime(args)
|
||||||
runtime.setup(sample_rate=sample_rate)
|
runtime.setup(sample_rate=sample_rate)
|
||||||
|
with self._lock:
|
||||||
|
self._runtime = runtime
|
||||||
hop_size = runtime.frame_size
|
hop_size = runtime.frame_size
|
||||||
|
|
||||||
audio_q = queue.Queue(maxsize=64)
|
audio_q = queue.Queue(maxsize=64)
|
||||||
@@ -243,10 +446,12 @@ class AudioBeatDetector:
|
|||||||
stream.start()
|
stream.start()
|
||||||
try:
|
try:
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
|
self._process_pending_reset(runtime)
|
||||||
try:
|
try:
|
||||||
frame = audio_q.get(timeout=0.1)
|
frame = audio_q.get(timeout=0.1)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
|
self._process_pending_reset(runtime)
|
||||||
if frame.shape[0] != hop_size:
|
if frame.shape[0] != hop_size:
|
||||||
if frame.shape[0] > hop_size:
|
if frame.shape[0] > hop_size:
|
||||||
frame = frame[:hop_size]
|
frame = frame[:hop_size]
|
||||||
@@ -260,6 +465,11 @@ class AudioBeatDetector:
|
|||||||
bpm,
|
bpm,
|
||||||
beat_type=event.get("beat_type", "unknown"),
|
beat_type=event.get("beat_type", "unknown"),
|
||||||
beat_type_confidence=event.get("beat_type_confidence", 0.0),
|
beat_type_confidence=event.get("beat_type_confidence", 0.0),
|
||||||
|
bar_beat=event.get("bar_beat"),
|
||||||
|
beats_per_bar=event.get("beats_per_bar"),
|
||||||
|
is_downbeat=event.get("is_downbeat"),
|
||||||
|
phase_confidence=event.get("phase_confidence"),
|
||||||
|
bar_phase_readout=event.get("bar_phase_readout"),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -280,3 +490,45 @@ class AudioBeatDetector:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._running = False
|
self._running = False
|
||||||
self._status["running"] = False
|
self._status["running"] = False
|
||||||
|
self._runtime = None
|
||||||
|
|
||||||
|
|
||||||
|
# Set from ``main`` so sequence playback can tell real audio from simulated beats.
|
||||||
|
_shared_beat_detector = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_shared_beat_detector(det):
|
||||||
|
global _shared_beat_detector
|
||||||
|
_shared_beat_detector = det
|
||||||
|
|
||||||
|
|
||||||
|
def shared_beat_detector_running():
|
||||||
|
d = _shared_beat_detector
|
||||||
|
if d is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(d.status().get("running"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def shared_beat_status_snapshot() -> dict:
|
||||||
|
"""Thread-safe copy of live detector status, or {} if audio is off."""
|
||||||
|
d = _shared_beat_detector
|
||||||
|
if d is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return dict(d.status())
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def anchor_shared_bar_phase() -> bool:
|
||||||
|
"""Anchor bar phase on the shared detector (no-op if audio is off)."""
|
||||||
|
d = _shared_beat_detector
|
||||||
|
if d is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(d.anchor_bar_phase())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|||||||
96
src/util/audio_run_persist.py
Normal file
96
src/util/audio_run_persist.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Persist whether the audio beat detector should be running (survives process restarts)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _db_path() -> str:
|
||||||
|
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
return os.path.join(base, "db", "audio_run.json")
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_audio_device(device: Any) -> Optional[Any]:
|
||||||
|
"""Match ``/api/audio/start`` body coercion (None = host default input)."""
|
||||||
|
if device in ("", None):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(device)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def read_audio_run_state() -> Dict[str, Any]:
|
||||||
|
path = _db_path()
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError, TypeError):
|
||||||
|
return {"enabled": False, "device": None}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {
|
||||||
|
"enabled": False,
|
||||||
|
"device": None,
|
||||||
|
"device_override": "",
|
||||||
|
"device_select": "",
|
||||||
|
}
|
||||||
|
enabled = bool(raw.get("enabled"))
|
||||||
|
dev = raw.get("device", None)
|
||||||
|
return {
|
||||||
|
"enabled": enabled,
|
||||||
|
"device": dev,
|
||||||
|
"device_override": str(raw.get("device_override") or ""),
|
||||||
|
"device_select": str(raw.get("device_select") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_audio_run_state(
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
device: Any = None,
|
||||||
|
device_override: str | None = None,
|
||||||
|
device_select: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write run intent. When ``enabled`` is false, keep device fields from the previous file."""
|
||||||
|
path = _db_path()
|
||||||
|
prev = read_audio_run_state()
|
||||||
|
if enabled:
|
||||||
|
data = {
|
||||||
|
"enabled": True,
|
||||||
|
"device": device,
|
||||||
|
"device_override": (
|
||||||
|
str(device_override)
|
||||||
|
if device_override is not None
|
||||||
|
else str(prev.get("device_override") or "")
|
||||||
|
),
|
||||||
|
"device_select": (
|
||||||
|
str(device_select)
|
||||||
|
if device_select is not None
|
||||||
|
else str(prev.get("device_select") or "")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if device_select is None and device is not None:
|
||||||
|
data["device_select"] = str(device)
|
||||||
|
else:
|
||||||
|
data = {
|
||||||
|
"enabled": False,
|
||||||
|
"device": prev.get("device"),
|
||||||
|
"device_override": (
|
||||||
|
str(device_override)
|
||||||
|
if device_override is not None
|
||||||
|
else str(prev.get("device_override") or "")
|
||||||
|
),
|
||||||
|
"device_select": (
|
||||||
|
str(device_select)
|
||||||
|
if device_select is not None
|
||||||
|
else str(prev.get("device_select") or "")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"[audio_run_persist] save failed: {e!r}")
|
||||||
@@ -6,9 +6,13 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from typing import Any, Dict, List, Optional, Set
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
_route_lock = threading.Lock()
|
_route_lock = threading.Lock()
|
||||||
|
# Per-lane manual routes: key ``-1`` = legacy single-route (preset push / UI); keys ``0..n`` =
|
||||||
|
# zone sequence lanes so every manual lane gets its own stride counter and wire.
|
||||||
|
_lane_manual: Dict[int, Dict[str, Any]] = {}
|
||||||
|
# Public mirror for ``get_beat_route`` / header UI (derived from lane table).
|
||||||
_beat_route: Dict[str, Any] = {
|
_beat_route: Dict[str, Any] = {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"device_names": [],
|
"device_names": [],
|
||||||
@@ -18,6 +22,7 @@ _beat_route: Dict[str, Any] = {
|
|||||||
"manual_beat_n": 1,
|
"manual_beat_n": 1,
|
||||||
}
|
}
|
||||||
_beat_counter: int = 0
|
_beat_counter: int = 0
|
||||||
|
_preset_session_beats: int = 0
|
||||||
_main_loop: Optional[asyncio.AbstractEventLoop] = None
|
_main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -26,16 +31,65 @@ def set_beat_route_main_loop(loop: asyncio.AbstractEventLoop) -> None:
|
|||||||
_main_loop = loop
|
_main_loop = loop
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_display_lane_key() -> Optional[int]:
|
||||||
|
"""Lane key used for header stride readout (prefer sequence lane 0)."""
|
||||||
|
if not _lane_manual:
|
||||||
|
return None
|
||||||
|
if 0 in _lane_manual:
|
||||||
|
return 0
|
||||||
|
seq_keys = [k for k in _lane_manual if isinstance(k, int) and k >= 0]
|
||||||
|
if seq_keys:
|
||||||
|
return min(seq_keys)
|
||||||
|
if -1 in _lane_manual:
|
||||||
|
return -1
|
||||||
|
return min(_lane_manual.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_public_beat_route_from_lane_table() -> None:
|
||||||
|
"""Mirror ``_lane_manual`` into legacy ``_beat_route`` shape for API consumers."""
|
||||||
|
global _beat_route, _beat_counter
|
||||||
|
pick = _pick_display_lane_key()
|
||||||
|
if pick is None:
|
||||||
|
_beat_route = {
|
||||||
|
"enabled": False,
|
||||||
|
"device_names": [],
|
||||||
|
"wire_preset_id": "2",
|
||||||
|
"is_manual": False,
|
||||||
|
"pattern": "",
|
||||||
|
"manual_beat_n": 1,
|
||||||
|
}
|
||||||
|
_beat_counter = 0
|
||||||
|
return
|
||||||
|
e = _lane_manual[pick]
|
||||||
|
_beat_route = {
|
||||||
|
"enabled": True,
|
||||||
|
"device_names": list(e.get("device_names") or []),
|
||||||
|
"wire_preset_id": str(e.get("wire_preset_id") or "2"),
|
||||||
|
"is_manual": True,
|
||||||
|
"pattern": str(e.get("pattern") or ""),
|
||||||
|
"manual_beat_n": int(e.get("manual_beat_n") or 1),
|
||||||
|
}
|
||||||
|
_beat_counter = int(e.get("beat_counter", 0))
|
||||||
|
|
||||||
|
|
||||||
def update_beat_route(payload: Dict[str, Any]) -> None:
|
def update_beat_route(payload: Dict[str, Any]) -> None:
|
||||||
"""Internal: set or clear routing from explicit fields (tests / future APIs)."""
|
"""Internal: set or clear routing from explicit fields (tests / future APIs)."""
|
||||||
global _beat_route, _beat_counter
|
global _lane_manual, _beat_route, _beat_counter, _preset_session_beats
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return
|
return
|
||||||
with _route_lock:
|
with _route_lock:
|
||||||
if payload.get("enabled") is False:
|
if payload.get("enabled") is False:
|
||||||
_beat_route = {**_beat_route, "enabled": False}
|
_lane_manual.clear()
|
||||||
|
_beat_route = {
|
||||||
|
**_beat_route,
|
||||||
|
"enabled": False,
|
||||||
|
"is_manual": False,
|
||||||
|
"device_names": [],
|
||||||
|
}
|
||||||
_beat_counter = 0
|
_beat_counter = 0
|
||||||
|
_preset_session_beats = 0
|
||||||
return
|
return
|
||||||
|
old = dict(_beat_route)
|
||||||
names = payload.get("device_names")
|
names = payload.get("device_names")
|
||||||
if not isinstance(names, list):
|
if not isinstance(names, list):
|
||||||
names = []
|
names = []
|
||||||
@@ -44,15 +98,20 @@ def update_beat_route(payload: Dict[str, Any]) -> None:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
n_raw = 1
|
n_raw = 1
|
||||||
manual_n = max(1, min(64, n_raw))
|
manual_n = max(1, min(64, n_raw))
|
||||||
_beat_route = {
|
new_wire = str(payload.get("wire_preset_id") or "2")
|
||||||
"enabled": bool(payload.get("enabled", False)),
|
old_wire = str(old.get("wire_preset_id") or "2")
|
||||||
"device_names": [str(n).strip() for n in names if str(n).strip()],
|
if not old.get("enabled") or old_wire != new_wire:
|
||||||
"wire_preset_id": str(payload.get("wire_preset_id") or "2"),
|
_preset_session_beats = 0
|
||||||
"is_manual": bool(payload.get("is_manual", False)),
|
clean_names = [str(n).strip() for n in names if str(n).strip()]
|
||||||
|
_lane_manual.clear()
|
||||||
|
_lane_manual[-1] = {
|
||||||
|
"device_names": clean_names,
|
||||||
|
"wire_preset_id": new_wire,
|
||||||
"pattern": str(payload.get("pattern") or "").strip(),
|
"pattern": str(payload.get("pattern") or "").strip(),
|
||||||
"manual_beat_n": manual_n,
|
"manual_beat_n": manual_n,
|
||||||
|
"beat_counter": 0,
|
||||||
}
|
}
|
||||||
_beat_counter = 0
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
|
||||||
|
|
||||||
def get_beat_route() -> Dict[str, Any]:
|
def get_beat_route() -> Dict[str, Any]:
|
||||||
@@ -60,6 +119,44 @@ def get_beat_route() -> Dict[str, Any]:
|
|||||||
return dict(_beat_route)
|
return dict(_beat_route)
|
||||||
|
|
||||||
|
|
||||||
|
def manual_beat_stride_status() -> Dict[str, Any]:
|
||||||
|
"""Audio-beat stride for a live manual preset (not sequence). For UI readout with BPM.
|
||||||
|
|
||||||
|
``beat_in_stride`` is always in ``1..stride_n`` when ``active`` (1-based within the stride).
|
||||||
|
With multiple sequence manual lanes, reflects lane 0 (or the smallest lane index).
|
||||||
|
"""
|
||||||
|
with _route_lock:
|
||||||
|
pick = _pick_display_lane_key()
|
||||||
|
if pick is None or pick not in _lane_manual:
|
||||||
|
wid = str(_beat_route.get("wire_preset_id") or "").strip()
|
||||||
|
return {"active": False, "preset_session_beats": 0, "wire_preset_id": wid}
|
||||||
|
e = _lane_manual[pick]
|
||||||
|
c = int(e.get("beat_counter", 0))
|
||||||
|
psb = int(_preset_session_beats)
|
||||||
|
wid = str(e.get("wire_preset_id") or "").strip()
|
||||||
|
try:
|
||||||
|
n = int(e.get("manual_beat_n") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 1
|
||||||
|
n = max(1, min(64, n))
|
||||||
|
if c <= 0:
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"beat_in_stride": 1,
|
||||||
|
"stride_n": n,
|
||||||
|
"preset_session_beats": psb,
|
||||||
|
"wire_preset_id": wid,
|
||||||
|
}
|
||||||
|
beat_in_stride = ((c - 1) % n) + 1
|
||||||
|
return {
|
||||||
|
"active": True,
|
||||||
|
"beat_in_stride": beat_in_stride,
|
||||||
|
"stride_n": n,
|
||||||
|
"preset_session_beats": psb,
|
||||||
|
"wire_preset_id": wid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _coerce_manual_beat_n(body: Any) -> int:
|
def _coerce_manual_beat_n(body: Any) -> int:
|
||||||
"""Beats between audio-triggered selects (led-controller only); default 1 = every beat."""
|
"""Beats between audio-triggered selects (led-controller only); default 1 = every beat."""
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
@@ -136,34 +233,192 @@ def _apply_manual_beat_route(
|
|||||||
wire_preset_id: str,
|
wire_preset_id: str,
|
||||||
preset_body: Any,
|
preset_body: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Enable audio→driver routing for one manual preset, or disable if invalid."""
|
"""Enable audio→driver routing for one manual preset (clears all lanes, including sequence)."""
|
||||||
|
global _lane_manual
|
||||||
if not device_names:
|
if not device_names:
|
||||||
update_beat_route({"enabled": False})
|
with _route_lock:
|
||||||
|
_lane_manual.clear()
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
return
|
return
|
||||||
if not isinstance(preset_body, dict):
|
if not isinstance(preset_body, dict):
|
||||||
update_beat_route({"enabled": False})
|
with _route_lock:
|
||||||
|
_lane_manual.clear()
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
return
|
return
|
||||||
if _coerce_auto_from_body(preset_body):
|
if _coerce_auto_from_body(preset_body):
|
||||||
update_beat_route({"enabled": False})
|
with _route_lock:
|
||||||
|
_lane_manual.clear()
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
return
|
return
|
||||||
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
|
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
|
||||||
if pattern and not _pattern_supports_manual(pattern):
|
if pattern and not _pattern_supports_manual(pattern):
|
||||||
update_beat_route({"enabled": False})
|
with _route_lock:
|
||||||
|
_lane_manual.clear()
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
return
|
return
|
||||||
update_beat_route(
|
names = [str(n).strip() for n in device_names if str(n).strip()]
|
||||||
{
|
with _route_lock:
|
||||||
"enabled": True,
|
_lane_manual.clear()
|
||||||
"device_names": device_names,
|
_lane_manual[-1] = {
|
||||||
"wire_preset_id": wire_preset_id,
|
"device_names": names,
|
||||||
"is_manual": True,
|
"wire_preset_id": str(wire_preset_id).strip(),
|
||||||
"pattern": pattern,
|
"pattern": pattern,
|
||||||
"manual_beat_n": _coerce_manual_beat_n(preset_body),
|
"manual_beat_n": _coerce_manual_beat_n(preset_body),
|
||||||
|
"beat_counter": 0,
|
||||||
}
|
}
|
||||||
)
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_manual_beat_route_standalone_overlay(
|
||||||
|
device_names: List[str],
|
||||||
|
wire_preset_id: str,
|
||||||
|
preset_body: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact."""
|
||||||
|
global _lane_manual
|
||||||
|
if not device_names:
|
||||||
|
with _route_lock:
|
||||||
|
_lane_manual.pop(-1, None)
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
return
|
||||||
|
if not isinstance(preset_body, dict):
|
||||||
|
with _route_lock:
|
||||||
|
_lane_manual.pop(-1, None)
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
return
|
||||||
|
if _coerce_auto_from_body(preset_body):
|
||||||
|
with _route_lock:
|
||||||
|
_lane_manual.pop(-1, None)
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
return
|
||||||
|
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
|
||||||
|
if pattern and not _pattern_supports_manual(pattern):
|
||||||
|
with _route_lock:
|
||||||
|
_lane_manual.pop(-1, None)
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
return
|
||||||
|
names = [str(n).strip() for n in device_names if str(n).strip()]
|
||||||
|
with _route_lock:
|
||||||
|
if _sequence_lane_covers_standalone_overlay(names, str(wire_preset_id).strip()):
|
||||||
|
_lane_manual.pop(-1, None)
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
return
|
||||||
|
_lane_manual[-1] = {
|
||||||
|
"device_names": names,
|
||||||
|
"wire_preset_id": str(wire_preset_id).strip(),
|
||||||
|
"pattern": pattern,
|
||||||
|
"manual_beat_n": _coerce_manual_beat_n(preset_body),
|
||||||
|
"beat_counter": 0,
|
||||||
|
}
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
|
||||||
|
|
||||||
|
def set_sequence_manual_lane_route(
|
||||||
|
lane_index: int,
|
||||||
|
device_names: List[str],
|
||||||
|
wire_preset_id: str,
|
||||||
|
preset_body: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Register or update one sequence lane's manual beat route (parallel lanes, independent strides)."""
|
||||||
|
global _lane_manual
|
||||||
|
names = [str(n).strip() for n in (device_names or []) if str(n).strip()]
|
||||||
|
if not names or not isinstance(preset_body, dict) or _coerce_auto_from_body(preset_body):
|
||||||
|
with _route_lock:
|
||||||
|
if lane_index in _lane_manual:
|
||||||
|
del _lane_manual[lane_index]
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
return
|
||||||
|
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
|
||||||
|
if pattern and not _pattern_supports_manual(pattern):
|
||||||
|
with _route_lock:
|
||||||
|
if lane_index in _lane_manual:
|
||||||
|
del _lane_manual[lane_index]
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
return
|
||||||
|
mn = _coerce_manual_beat_n(preset_body)
|
||||||
|
wid = str(wire_preset_id).strip()
|
||||||
|
with _route_lock:
|
||||||
|
old = _lane_manual.get(lane_index)
|
||||||
|
bc = 0
|
||||||
|
if (
|
||||||
|
old
|
||||||
|
and str(old.get("wire_preset_id") or "") == wid
|
||||||
|
and int(old.get("manual_beat_n") or 1) == mn
|
||||||
|
and set(old.get("device_names") or []) == set(names)
|
||||||
|
):
|
||||||
|
bc = int(old.get("beat_counter", 0))
|
||||||
|
_lane_manual[lane_index] = {
|
||||||
|
"device_names": names,
|
||||||
|
"wire_preset_id": wid,
|
||||||
|
"pattern": pattern,
|
||||||
|
"manual_beat_n": mn,
|
||||||
|
"beat_counter": bc,
|
||||||
|
}
|
||||||
|
overlay = _lane_manual.get(-1)
|
||||||
|
if overlay and _lane_route_targets_key(names, wid) == _lane_route_targets_key(
|
||||||
|
overlay.get("device_names") or [], str(overlay.get("wire_preset_id") or "")
|
||||||
|
):
|
||||||
|
_lane_manual.pop(-1, None)
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_sequence_manual_lane_route(lane_index: int) -> None:
|
||||||
|
"""Remove beat routing for one sequence lane (e.g. step switched to auto)."""
|
||||||
|
global _lane_manual
|
||||||
|
with _route_lock:
|
||||||
|
if lane_index in _lane_manual:
|
||||||
|
del _lane_manual[lane_index]
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
|
||||||
|
|
||||||
|
def _lane_route_targets_key(device_names: List[str], wire_preset_id: str) -> Tuple[Tuple[str, ...], str]:
|
||||||
|
names = tuple(sorted({str(n).strip() for n in (device_names or []) if str(n).strip()}))
|
||||||
|
return names, str(wire_preset_id or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _sequence_lane_covers_standalone_overlay(device_names: List[str], wire_preset_id: str) -> bool:
|
||||||
|
"""True when a sequence lane (0..n) already routes the same device(s) and wire preset."""
|
||||||
|
key = _lane_route_targets_key(device_names, wire_preset_id)
|
||||||
|
for lane_key, entry in _lane_manual.items():
|
||||||
|
if not isinstance(lane_key, int) or lane_key < 0:
|
||||||
|
continue
|
||||||
|
other = _lane_route_targets_key(
|
||||||
|
entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
|
||||||
|
)
|
||||||
|
if other == key:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def mark_manual_select_sent_for_targets(
|
||||||
|
device_names: List[str], wire_preset_id: str
|
||||||
|
) -> None:
|
||||||
|
"""A ``select`` was just sent for these targets; skip one duplicate on the next beat."""
|
||||||
|
key = _lane_route_targets_key(device_names, wire_preset_id)
|
||||||
|
with _route_lock:
|
||||||
|
for entry in _lane_manual.values():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
other = _lane_route_targets_key(
|
||||||
|
entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
|
||||||
|
)
|
||||||
|
if other == key:
|
||||||
|
entry["suppress_next_notify"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def mark_sequence_manual_lane_select_sent(lane_index: int) -> None:
|
||||||
|
"""A ``select`` was just sent for this lane; skip one duplicate on the next beat."""
|
||||||
|
with _route_lock:
|
||||||
|
e = _lane_manual.get(lane_index)
|
||||||
|
if e is not None:
|
||||||
|
e["suppress_next_notify"] = True
|
||||||
|
|
||||||
|
|
||||||
def sync_beat_route_from_push_sequence(
|
def sync_beat_route_from_push_sequence(
|
||||||
sequence: List[Any], target_macs: Optional[List[str]] = None
|
sequence: List[Any],
|
||||||
|
target_macs: Optional[List[str]] = None,
|
||||||
|
*,
|
||||||
|
preserve_parallel_lane_routes: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
|
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
|
||||||
@@ -173,6 +428,11 @@ def sync_beat_route_from_push_sequence(
|
|||||||
Without ``select`` (e.g. manual preset loaded without immediate select): if ``target_macs``
|
Without ``select`` (e.g. manual preset loaded without immediate select): if ``target_macs``
|
||||||
is set and the merged ``presets`` contain exactly one manual preset, enable routing using
|
is set and the merged ``presets`` contain exactly one manual preset, enable routing using
|
||||||
registry names for those MACs so the first advance is on the next audio beat.
|
registry names for those MACs so the first advance is on the next audio beat.
|
||||||
|
|
||||||
|
When ``preserve_parallel_lane_routes`` is true (e.g. zone sequence playback is active), an
|
||||||
|
auto preset in ``select`` does not clear manual routing — other lanes still receive
|
||||||
|
``notify_beat_detected``. A manual preset in ``select`` is applied on lane ``-1`` only so
|
||||||
|
sequence lanes ``0..n`` keep their stride counters and wire ids.
|
||||||
"""
|
"""
|
||||||
merged_presets: Dict[str, Any] = {}
|
merged_presets: Dict[str, Any] = {}
|
||||||
last_select: Optional[Dict[str, Any]] = None
|
last_select: Optional[Dict[str, Any]] = None
|
||||||
@@ -194,7 +454,8 @@ def sync_beat_route_from_push_sequence(
|
|||||||
if last_select:
|
if last_select:
|
||||||
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
|
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
|
||||||
if not device_names:
|
if not device_names:
|
||||||
update_beat_route({"enabled": False})
|
if not preserve_parallel_lane_routes:
|
||||||
|
update_beat_route({"enabled": False})
|
||||||
return
|
return
|
||||||
|
|
||||||
wire_ids: Set[str] = set()
|
wire_ids: Set[str] = set()
|
||||||
@@ -205,7 +466,8 @@ def sync_beat_route_from_push_sequence(
|
|||||||
elif val is not None:
|
elif val is not None:
|
||||||
wire_ids.add(str(val).strip())
|
wire_ids.add(str(val).strip())
|
||||||
if len(wire_ids) != 1:
|
if len(wire_ids) != 1:
|
||||||
update_beat_route({"enabled": False})
|
if not preserve_parallel_lane_routes:
|
||||||
|
update_beat_route({"enabled": False})
|
||||||
return
|
return
|
||||||
wire_preset_id = wire_ids.pop()
|
wire_preset_id = wire_ids.pop()
|
||||||
preset_body = merged_presets.get(wire_preset_id)
|
preset_body = merged_presets.get(wire_preset_id)
|
||||||
@@ -214,16 +476,34 @@ def sync_beat_route_from_push_sequence(
|
|||||||
if str(k).strip() == wire_preset_id:
|
if str(k).strip() == wire_preset_id:
|
||||||
preset_body = v
|
preset_body = v
|
||||||
break
|
break
|
||||||
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
|
if preset_body is None:
|
||||||
|
if not preserve_parallel_lane_routes:
|
||||||
|
update_beat_route({"enabled": False})
|
||||||
|
return
|
||||||
|
if _coerce_auto_from_body(preset_body):
|
||||||
|
if not preserve_parallel_lane_routes:
|
||||||
|
update_beat_route({"enabled": False})
|
||||||
|
return
|
||||||
|
if preserve_parallel_lane_routes:
|
||||||
|
_apply_manual_beat_route_standalone_overlay(
|
||||||
|
device_names, wire_preset_id, preset_body
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
|
||||||
|
mark_manual_select_sent_for_targets(device_names, wire_preset_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
wire_id, body = _single_manual_wire_preset(merged_presets)
|
wire_id, body = _single_manual_wire_preset(merged_presets)
|
||||||
if wire_id and body is not None:
|
if wire_id and body is not None:
|
||||||
names = _registry_names_for_macs(target_macs)
|
names = _registry_names_for_macs(target_macs)
|
||||||
_apply_manual_beat_route(names, wire_id, body)
|
if preserve_parallel_lane_routes:
|
||||||
|
_apply_manual_beat_route_standalone_overlay(names, wire_id, body)
|
||||||
|
else:
|
||||||
|
_apply_manual_beat_route(names, wire_id, body)
|
||||||
return
|
return
|
||||||
|
|
||||||
update_beat_route({"enabled": False})
|
if not preserve_parallel_lane_routes:
|
||||||
|
update_beat_route({"enabled": False})
|
||||||
|
|
||||||
|
|
||||||
def _pattern_supports_manual(pattern_key: str) -> bool:
|
def _pattern_supports_manual(pattern_key: str) -> bool:
|
||||||
@@ -247,25 +527,30 @@ def _pattern_supports_manual(pattern_key: str) -> bool:
|
|||||||
|
|
||||||
def remap_beat_route_device_name(old_name: str, new_name: str) -> None:
|
def remap_beat_route_device_name(old_name: str, new_name: str) -> None:
|
||||||
"""Update cached audio-beat target names after a device registry rename."""
|
"""Update cached audio-beat target names after a device registry rename."""
|
||||||
global _beat_route
|
global _lane_manual
|
||||||
o = str(old_name or "").strip()
|
o = str(old_name or "").strip()
|
||||||
n = str(new_name or "").strip()
|
n = str(new_name or "").strip()
|
||||||
if not o or not n or o == n:
|
if not o or not n or o == n:
|
||||||
return
|
return
|
||||||
with _route_lock:
|
with _route_lock:
|
||||||
if not _beat_route.get("enabled"):
|
any_changed = False
|
||||||
return
|
for e in _lane_manual.values():
|
||||||
names = _beat_route.get("device_names") or []
|
names = e.get("device_names") or []
|
||||||
new_list: List[str] = []
|
if not isinstance(names, list):
|
||||||
changed = False
|
continue
|
||||||
for item in names:
|
new_list: List[str] = []
|
||||||
if str(item).strip() == o:
|
row_changed = False
|
||||||
new_list.append(n)
|
for item in names:
|
||||||
changed = True
|
if str(item).strip() == o:
|
||||||
else:
|
new_list.append(n)
|
||||||
new_list.append(str(item))
|
row_changed = True
|
||||||
if changed:
|
else:
|
||||||
_beat_route = {**_beat_route, "device_names": new_list}
|
new_list.append(str(item))
|
||||||
|
if row_changed:
|
||||||
|
e["device_names"] = new_list
|
||||||
|
any_changed = True
|
||||||
|
if any_changed:
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
|
||||||
|
|
||||||
async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
|
async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
|
||||||
@@ -302,35 +587,53 @@ async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
|
|||||||
print(f"[beat-route] deliver failed: {e}")
|
print(f"[beat-route] deliver failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _deliver_select_batch(pairs: List[Tuple[List[str], str]]) -> None:
|
||||||
|
for names, pid in pairs:
|
||||||
|
await _deliver_select(names, pid)
|
||||||
|
|
||||||
|
|
||||||
def notify_beat_detected() -> None:
|
def notify_beat_detected() -> None:
|
||||||
"""Invoked from the audio thread when a beat is detected."""
|
"""Invoked from the audio thread when a beat is detected."""
|
||||||
global _beat_counter
|
global _preset_session_beats
|
||||||
|
work: List[Tuple[List[str], str]] = []
|
||||||
with _route_lock:
|
with _route_lock:
|
||||||
r = dict(_beat_route)
|
if not _lane_manual:
|
||||||
if not r.get("enabled"):
|
|
||||||
return
|
return
|
||||||
if not r.get("is_manual"):
|
work = []
|
||||||
return
|
seen_targets: Set[Tuple[Tuple[str, ...], str]] = set()
|
||||||
pattern = r.get("pattern") or ""
|
for key in sorted(_lane_manual.keys()):
|
||||||
if pattern and not _pattern_supports_manual(pattern):
|
e = _lane_manual[key]
|
||||||
return
|
names = e.get("device_names") or []
|
||||||
names = r.get("device_names") or []
|
if not isinstance(names, list) or not names:
|
||||||
if not names:
|
continue
|
||||||
return
|
pattern = str(e.get("pattern") or "")
|
||||||
try:
|
if pattern and not _pattern_supports_manual(pattern):
|
||||||
n = int(r.get("manual_beat_n") or 1)
|
continue
|
||||||
except (TypeError, ValueError):
|
if e.pop("suppress_next_notify", False):
|
||||||
n = 1
|
continue
|
||||||
n = max(1, min(64, n))
|
try:
|
||||||
_beat_counter += 1
|
n = int(e.get("manual_beat_n") or 1)
|
||||||
if ((_beat_counter - 1) % n) != 0:
|
except (TypeError, ValueError):
|
||||||
return
|
n = 1
|
||||||
preset_id = str(r.get("wire_preset_id") or "2")
|
n = max(1, min(64, n))
|
||||||
names_copy = list(names)
|
e["beat_counter"] = int(e.get("beat_counter", 0)) + 1
|
||||||
|
c = int(e["beat_counter"])
|
||||||
|
if (c - 1) % n != 0:
|
||||||
|
continue
|
||||||
|
wire = str(e.get("wire_preset_id") or "2")
|
||||||
|
target_key = _lane_route_targets_key(names, wire)
|
||||||
|
if target_key in seen_targets:
|
||||||
|
continue
|
||||||
|
seen_targets.add(target_key)
|
||||||
|
work.append((list(names), wire))
|
||||||
|
if work:
|
||||||
|
_preset_session_beats += 1
|
||||||
|
if not work:
|
||||||
|
return
|
||||||
loop = _main_loop
|
loop = _main_loop
|
||||||
if loop is None:
|
if loop is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
asyncio.run_coroutine_threadsafe(_deliver_select(names_copy, preset_id), loop)
|
asyncio.run_coroutine_threadsafe(_deliver_select_batch(work), loop)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[beat-route] schedule failed: {e}")
|
print(f"[beat-route] schedule failed: {e}")
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import json
|
|||||||
import struct
|
import struct
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from util.espnow_message import wire_n6
|
||||||
|
|
||||||
BINARY_ENVELOPE_VERSION_1 = 1
|
BINARY_ENVELOPE_VERSION_1 = 1
|
||||||
BINARY_ENVELOPE_VERSION_2 = 2
|
BINARY_ENVELOPE_VERSION_2 = 2
|
||||||
HEADER_LEN = 5
|
HEADER_LEN = 5
|
||||||
@@ -108,7 +110,7 @@ def _pack_preset_dict(name: str, preset: Dict[str, Any]) -> bytes:
|
|||||||
n3 = _clamp_i16(preset.get("n3", 0))
|
n3 = _clamp_i16(preset.get("n3", 0))
|
||||||
n4 = _clamp_i16(preset.get("n4", 0))
|
n4 = _clamp_i16(preset.get("n4", 0))
|
||||||
n5 = _clamp_i16(preset.get("n5", 0))
|
n5 = _clamp_i16(preset.get("n5", 0))
|
||||||
n6 = _clamp_i16(preset.get("n6", 0))
|
n6 = _clamp_i16(wire_n6(preset))
|
||||||
parts.append(
|
parts.append(
|
||||||
struct.pack(
|
struct.pack(
|
||||||
"<HBBhhhhhh",
|
"<HBBhhhhhh",
|
||||||
|
|||||||
@@ -78,13 +78,64 @@ def build_select_message(device_name, preset_name, step=None):
|
|||||||
return {device_name: select_list}
|
return {device_name: select_list}
|
||||||
|
|
||||||
|
|
||||||
def build_preset_dict(preset_data):
|
def _hex_from_background_raw(bg_raw):
|
||||||
|
"""Coerce ``background`` / ``bg`` field to a ``#RRGGBB`` string (driver wire format)."""
|
||||||
|
if isinstance(bg_raw, str):
|
||||||
|
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
|
||||||
|
return bg
|
||||||
|
if isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
|
||||||
|
return f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
|
||||||
|
return "#000000"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_preset_background_hex(preset_data, palette_colors=None):
|
||||||
|
"""
|
||||||
|
Resolved background as ``#RRGGBB``. When ``palette_colors`` is a non-empty list and
|
||||||
|
``background_palette_ref`` is set, uses that palette index; otherwise stored ``background`` / ``bg``.
|
||||||
|
"""
|
||||||
|
if not isinstance(preset_data, dict):
|
||||||
|
return "#000000"
|
||||||
|
pal = list(palette_colors) if isinstance(palette_colors, list) else []
|
||||||
|
ref = preset_data.get("background_palette_ref", preset_data.get("backgroundPaletteRef"))
|
||||||
|
if pal and ref is not None:
|
||||||
|
try:
|
||||||
|
idx = int(ref)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
idx = None
|
||||||
|
else:
|
||||||
|
if isinstance(idx, int) and 0 <= idx < len(pal):
|
||||||
|
c = pal[idx]
|
||||||
|
if isinstance(c, str) and c.strip().startswith("#"):
|
||||||
|
s = c.strip()
|
||||||
|
if len(s) == 7 and all(ch in "0123456789abcdefABCDEF" for ch in s[1:]):
|
||||||
|
return s.upper()
|
||||||
|
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
|
||||||
|
return _hex_from_background_raw(bg_raw)
|
||||||
|
|
||||||
|
|
||||||
|
def wire_n6(preset_data, default=0):
|
||||||
|
"""Resolve style mode for the wire (``n6``); preset may store ``mode`` or ``n6``."""
|
||||||
|
if not isinstance(preset_data, dict):
|
||||||
|
return default
|
||||||
|
if preset_data.get("mode") is not None:
|
||||||
|
try:
|
||||||
|
return max(0, int(preset_data["mode"]))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return max(0, int(preset_data.get("n6", default) or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def build_preset_dict(preset_data, palette_colors=None):
|
||||||
"""
|
"""
|
||||||
Convert preset data to API-compliant format.
|
Convert preset data to API-compliant format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.)
|
preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.)
|
||||||
|
palette_colors: Optional list of ``#RRGGBB`` strings for ``background_palette_ref`` resolution.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with preset in API-compliant format (without name field)
|
Dictionary with preset in API-compliant format (without name field)
|
||||||
|
|
||||||
@@ -137,13 +188,7 @@ def build_preset_dict(preset_data):
|
|||||||
auto_raw = preset_data.get("auto", preset_data.get("a", True))
|
auto_raw = preset_data.get("auto", preset_data.get("a", True))
|
||||||
auto_bool = _coerce_auto(auto_raw)
|
auto_bool = _coerce_auto(auto_raw)
|
||||||
|
|
||||||
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
|
bg = resolve_preset_background_hex(preset_data, palette_colors)
|
||||||
if isinstance(bg_raw, str):
|
|
||||||
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
|
|
||||||
elif isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
|
|
||||||
bg = f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
|
|
||||||
else:
|
|
||||||
bg = "#000000"
|
|
||||||
|
|
||||||
# Build payload using the short keys expected by led-driver
|
# Build payload using the short keys expected by led-driver
|
||||||
preset = {
|
preset = {
|
||||||
@@ -158,19 +203,20 @@ def build_preset_dict(preset_data):
|
|||||||
"n3": preset_data.get("n3", 0),
|
"n3": preset_data.get("n3", 0),
|
||||||
"n4": preset_data.get("n4", 0),
|
"n4": preset_data.get("n4", 0),
|
||||||
"n5": preset_data.get("n5", 0),
|
"n5": preset_data.get("n5", 0),
|
||||||
"n6": preset_data.get("n6", 0)
|
"n6": wire_n6(preset_data),
|
||||||
}
|
}
|
||||||
|
|
||||||
return preset
|
return preset
|
||||||
|
|
||||||
|
|
||||||
def build_presets_dict(presets_data):
|
def build_presets_dict(presets_data, palette_colors=None):
|
||||||
"""
|
"""
|
||||||
Convert multiple presets to API-compliant format.
|
Convert multiple presets to API-compliant format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
presets_data: Dictionary mapping preset names to preset data
|
presets_data: Dictionary mapping preset names to preset data
|
||||||
|
palette_colors: Optional list of ``#RRGGBB`` strings for background palette ref resolution.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping preset names to API-compliant preset objects
|
Dictionary mapping preset names to API-compliant preset objects
|
||||||
|
|
||||||
@@ -190,7 +236,7 @@ def build_presets_dict(presets_data):
|
|||||||
"""
|
"""
|
||||||
result = {}
|
result = {}
|
||||||
for preset_name, preset_data in presets_data.items():
|
for preset_name, preset_data in presets_data.items():
|
||||||
result[preset_name] = build_preset_dict(preset_data)
|
result[preset_name] = build_preset_dict(preset_data, palette_colors)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
441
src/util/profile_bundle.py
Normal file
441
src/util/profile_bundle.py
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
"""Export/import profile bundles (profile, zones, presets, sequences, palette)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
BUNDLE_VERSION = 1
|
||||||
|
KIND_PROFILE = "profile"
|
||||||
|
KIND_PRESET = "preset"
|
||||||
|
KIND_SEQUENCE = "sequence"
|
||||||
|
|
||||||
|
|
||||||
|
def _allocate_id(model, cache: Dict[str, int]) -> str:
|
||||||
|
if "next" not in cache:
|
||||||
|
max_id = max((int(k) for k in model.keys() if str(k).isdigit()), default=0)
|
||||||
|
cache["next"] = max_id + 1
|
||||||
|
next_id = str(cache["next"])
|
||||||
|
cache["next"] += 1
|
||||||
|
return next_id
|
||||||
|
|
||||||
|
|
||||||
|
def _palette_colors(palette_model, palette_id) -> List:
|
||||||
|
if not palette_id:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
colors = palette_model.read(str(palette_id))
|
||||||
|
except Exception:
|
||||||
|
colors = None
|
||||||
|
if isinstance(colors, list):
|
||||||
|
return list(colors)
|
||||||
|
if isinstance(colors, dict) and isinstance(colors.get("colors"), list):
|
||||||
|
return list(colors["colors"])
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_preset_refs(value, out: Set[str]) -> None:
|
||||||
|
if isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
_walk_preset_refs(item, out)
|
||||||
|
elif value is not None and value != "":
|
||||||
|
out.add(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _preset_ids_in_zone(zone: Dict[str, Any]) -> Set[str]:
|
||||||
|
ids: Set[str] = set()
|
||||||
|
if not isinstance(zone, dict):
|
||||||
|
return ids
|
||||||
|
_walk_preset_refs(zone.get("presets"), ids)
|
||||||
|
_walk_preset_refs(zone.get("presets_flat"), ids)
|
||||||
|
if zone.get("default_preset") not in (None, ""):
|
||||||
|
ids.add(str(zone["default_preset"]))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _preset_ids_in_sequence(seq: Dict[str, Any]) -> Set[str]:
|
||||||
|
ids: Set[str] = set()
|
||||||
|
if not isinstance(seq, dict):
|
||||||
|
return ids
|
||||||
|
for lane in seq.get("lanes") or []:
|
||||||
|
if not isinstance(lane, list):
|
||||||
|
continue
|
||||||
|
for step in lane:
|
||||||
|
if isinstance(step, dict) and step.get("preset_id") not in (None, ""):
|
||||||
|
ids.add(str(step["preset_id"]))
|
||||||
|
for step in seq.get("steps") or []:
|
||||||
|
if isinstance(step, dict) and step.get("preset_id") not in (None, ""):
|
||||||
|
ids.add(str(step["preset_id"]))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _map_preset_container(
|
||||||
|
value,
|
||||||
|
id_map: Dict[str, str],
|
||||||
|
preset_cache: Dict[str, int],
|
||||||
|
new_profile_id: str,
|
||||||
|
new_presets: Dict[str, Dict[str, Any]],
|
||||||
|
presets_model,
|
||||||
|
) -> Any:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [
|
||||||
|
_map_preset_container(v, id_map, preset_cache, new_profile_id, new_presets, presets_model)
|
||||||
|
for v in value
|
||||||
|
]
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
preset_id = str(value)
|
||||||
|
if preset_id in id_map:
|
||||||
|
return id_map[preset_id]
|
||||||
|
preset_data = presets_model.read(preset_id)
|
||||||
|
if not preset_data:
|
||||||
|
return None
|
||||||
|
new_preset_id = _allocate_id(presets_model, preset_cache)
|
||||||
|
clone_data = dict(preset_data)
|
||||||
|
clone_data["profile_id"] = str(new_profile_id)
|
||||||
|
new_presets[new_preset_id] = clone_data
|
||||||
|
id_map[preset_id] = new_preset_id
|
||||||
|
return new_preset_id
|
||||||
|
|
||||||
|
|
||||||
|
def _map_sequence_lanes(
|
||||||
|
seq: Dict[str, Any],
|
||||||
|
preset_id_map: Dict[str, str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
out = copy.deepcopy(seq)
|
||||||
|
lanes = out.get("lanes")
|
||||||
|
if isinstance(lanes, list):
|
||||||
|
for lane in lanes:
|
||||||
|
if not isinstance(lane, list):
|
||||||
|
continue
|
||||||
|
for step in lane:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
pid = step.get("preset_id")
|
||||||
|
if pid is not None and str(pid) in preset_id_map:
|
||||||
|
step["preset_id"] = preset_id_map[str(pid)]
|
||||||
|
steps = out.get("steps")
|
||||||
|
if isinstance(steps, list):
|
||||||
|
for step in steps:
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
continue
|
||||||
|
pid = step.get("preset_id")
|
||||||
|
if pid is not None and str(pid) in preset_id_map:
|
||||||
|
step["preset_id"] = preset_id_map[str(pid)]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def export_profile_bundle(
|
||||||
|
profile_id: str,
|
||||||
|
profiles_model,
|
||||||
|
zones_model,
|
||||||
|
presets_model,
|
||||||
|
sequences_model,
|
||||||
|
palette_model,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
source = profiles_model.read(profile_id)
|
||||||
|
if not source:
|
||||||
|
raise ValueError("Profile not found")
|
||||||
|
|
||||||
|
zone_ids = source.get("zones")
|
||||||
|
if not isinstance(zone_ids, list) or not zone_ids:
|
||||||
|
zone_ids = source.get("zone_order") or []
|
||||||
|
zone_ids = [str(z) for z in zone_ids if z is not None]
|
||||||
|
|
||||||
|
zones_out: Dict[str, Any] = {}
|
||||||
|
preset_ids: Set[str] = set()
|
||||||
|
sequence_ids: Set[str] = set()
|
||||||
|
|
||||||
|
for zid in zone_ids:
|
||||||
|
zone = zones_model.read(zid)
|
||||||
|
if not zone:
|
||||||
|
continue
|
||||||
|
zones_out[zid] = copy.deepcopy(zone)
|
||||||
|
preset_ids |= _preset_ids_in_zone(zone)
|
||||||
|
for sid in zone.get("sequence_ids") or []:
|
||||||
|
if sid is not None and str(sid).strip():
|
||||||
|
sequence_ids.add(str(sid))
|
||||||
|
|
||||||
|
sequences_out: Dict[str, Any] = {}
|
||||||
|
for sid in sequence_ids:
|
||||||
|
seq = sequences_model.read(sid)
|
||||||
|
if not seq or str(seq.get("profile_id")) != str(profile_id):
|
||||||
|
continue
|
||||||
|
sequences_out[sid] = copy.deepcopy(seq)
|
||||||
|
preset_ids |= _preset_ids_in_sequence(seq)
|
||||||
|
|
||||||
|
presets_out: Dict[str, Any] = {}
|
||||||
|
for pid in preset_ids:
|
||||||
|
pdata = presets_model.read(pid)
|
||||||
|
if pdata and str(pdata.get("profile_id")) == str(profile_id):
|
||||||
|
presets_out[pid] = copy.deepcopy(pdata)
|
||||||
|
|
||||||
|
profile_doc = copy.deepcopy(source)
|
||||||
|
profile_doc.pop("palette", None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": BUNDLE_VERSION,
|
||||||
|
"kind": KIND_PROFILE,
|
||||||
|
"profile": profile_doc,
|
||||||
|
"palette": {"colors": _palette_colors(palette_model, source.get("palette_id"))},
|
||||||
|
"zones": zones_out,
|
||||||
|
"presets": presets_out,
|
||||||
|
"sequences": sequences_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def import_profile_bundle(
|
||||||
|
bundle: Dict[str, Any],
|
||||||
|
profiles_model,
|
||||||
|
zones_model,
|
||||||
|
presets_model,
|
||||||
|
sequences_model,
|
||||||
|
palette_model,
|
||||||
|
*,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> Tuple[str, Dict[str, Any]]:
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
raise ValueError("Invalid bundle")
|
||||||
|
if bundle.get("version") not in (BUNDLE_VERSION, str(BUNDLE_VERSION)):
|
||||||
|
raise ValueError("Unsupported bundle version")
|
||||||
|
if bundle.get("kind") not in (KIND_PROFILE, None):
|
||||||
|
raise ValueError("Not a profile bundle")
|
||||||
|
|
||||||
|
source_profile = bundle.get("profile")
|
||||||
|
if not isinstance(source_profile, dict):
|
||||||
|
raise ValueError("Bundle missing profile")
|
||||||
|
|
||||||
|
source_name = source_profile.get("name") or "Imported profile"
|
||||||
|
new_name = (name or source_name).strip() or source_name
|
||||||
|
profile_type = source_profile.get("type", "zones")
|
||||||
|
|
||||||
|
profile_cache: Dict[str, int] = {}
|
||||||
|
palette_cache: Dict[str, int] = {}
|
||||||
|
zone_cache: Dict[str, int] = {}
|
||||||
|
preset_cache: Dict[str, int] = {}
|
||||||
|
sequence_cache: Dict[str, int] = {}
|
||||||
|
|
||||||
|
new_profile_id = _allocate_id(profiles_model, profile_cache)
|
||||||
|
new_palette_id = _allocate_id(palette_model, palette_cache)
|
||||||
|
|
||||||
|
palette_in = bundle.get("palette") or {}
|
||||||
|
palette_colors = palette_in.get("colors") if isinstance(palette_in, dict) else []
|
||||||
|
if not isinstance(palette_colors, list):
|
||||||
|
palette_colors = []
|
||||||
|
|
||||||
|
preset_id_map: Dict[str, str] = {}
|
||||||
|
new_presets: Dict[str, Dict[str, Any]] = {}
|
||||||
|
new_zones: Dict[str, Dict[str, Any]] = {}
|
||||||
|
new_sequences: Dict[str, Dict[str, Any]] = {}
|
||||||
|
sequence_id_map: Dict[str, str] = {}
|
||||||
|
|
||||||
|
zones_in = bundle.get("zones") if isinstance(bundle.get("zones"), dict) else {}
|
||||||
|
presets_in = bundle.get("presets") if isinstance(bundle.get("presets"), dict) else {}
|
||||||
|
sequences_in = bundle.get("sequences") if isinstance(bundle.get("sequences"), dict) else {}
|
||||||
|
|
||||||
|
for old_pid, pdata in presets_in.items():
|
||||||
|
if not isinstance(pdata, dict):
|
||||||
|
continue
|
||||||
|
new_pid = _allocate_id(presets_model, preset_cache)
|
||||||
|
clone = copy.deepcopy(pdata)
|
||||||
|
clone["profile_id"] = str(new_profile_id)
|
||||||
|
new_presets[new_pid] = clone
|
||||||
|
preset_id_map[str(old_pid)] = new_pid
|
||||||
|
|
||||||
|
for old_sid, sdata in sequences_in.items():
|
||||||
|
if not isinstance(sdata, dict):
|
||||||
|
continue
|
||||||
|
new_sid = _allocate_id(sequences_model, sequence_cache)
|
||||||
|
clone = _map_sequence_lanes(sdata, preset_id_map)
|
||||||
|
clone["profile_id"] = str(new_profile_id)
|
||||||
|
new_sequences[new_sid] = clone
|
||||||
|
sequence_id_map[str(old_sid)] = new_sid
|
||||||
|
|
||||||
|
source_zone_order = source_profile.get("zones")
|
||||||
|
if not isinstance(source_zone_order, list):
|
||||||
|
source_zone_order = list(zones_in.keys())
|
||||||
|
|
||||||
|
cloned_zone_ids: List[str] = []
|
||||||
|
for old_zid in source_zone_order:
|
||||||
|
zone = zones_in.get(str(old_zid))
|
||||||
|
if not isinstance(zone, dict):
|
||||||
|
continue
|
||||||
|
new_zid = _allocate_id(zones_model, zone_cache)
|
||||||
|
clone_data: Dict[str, Any] = {
|
||||||
|
"name": zone.get("name") or f"Zone {old_zid}",
|
||||||
|
"names": list(zone.get("names") or []),
|
||||||
|
}
|
||||||
|
mapped_presets = _map_preset_container(
|
||||||
|
zone.get("presets"),
|
||||||
|
preset_id_map,
|
||||||
|
preset_cache,
|
||||||
|
new_profile_id,
|
||||||
|
new_presets,
|
||||||
|
presets_model,
|
||||||
|
)
|
||||||
|
if mapped_presets is not None:
|
||||||
|
clone_data["presets"] = mapped_presets
|
||||||
|
extra = {
|
||||||
|
k: v
|
||||||
|
for k, v in zone.items()
|
||||||
|
if k not in ("name", "names", "presets")
|
||||||
|
}
|
||||||
|
if "presets_flat" in extra:
|
||||||
|
extra["presets_flat"] = _map_preset_container(
|
||||||
|
extra.get("presets_flat"),
|
||||||
|
preset_id_map,
|
||||||
|
preset_cache,
|
||||||
|
new_profile_id,
|
||||||
|
new_presets,
|
||||||
|
presets_model,
|
||||||
|
)
|
||||||
|
if "default_preset" in extra and extra["default_preset"] is not None:
|
||||||
|
old_dp = str(extra["default_preset"])
|
||||||
|
if old_dp in preset_id_map:
|
||||||
|
extra["default_preset"] = preset_id_map[old_dp]
|
||||||
|
if "sequence_ids" in extra and isinstance(extra.get("sequence_ids"), list):
|
||||||
|
extra["sequence_ids"] = [
|
||||||
|
sequence_id_map.get(str(s), str(s))
|
||||||
|
for s in extra["sequence_ids"]
|
||||||
|
if s is not None
|
||||||
|
]
|
||||||
|
clone_data.update(extra)
|
||||||
|
new_zones[new_zid] = clone_data
|
||||||
|
cloned_zone_ids.append(new_zid)
|
||||||
|
|
||||||
|
new_profile_data = {
|
||||||
|
"name": new_name,
|
||||||
|
"type": profile_type,
|
||||||
|
"zones": cloned_zone_ids,
|
||||||
|
"scenes": list(source_profile.get("scenes", []))
|
||||||
|
if isinstance(source_profile.get("scenes"), list)
|
||||||
|
else [],
|
||||||
|
"palette_id": str(new_palette_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
palette_model[str(new_palette_id)] = list(palette_colors)
|
||||||
|
for pid, pdata in new_presets.items():
|
||||||
|
presets_model[pid] = pdata
|
||||||
|
for zid, zdata in new_zones.items():
|
||||||
|
zones_model[zid] = zdata
|
||||||
|
for sid, sdata in new_sequences.items():
|
||||||
|
sequences_model[sid] = sdata
|
||||||
|
profiles_model[str(new_profile_id)] = new_profile_data
|
||||||
|
|
||||||
|
palette_model.save()
|
||||||
|
presets_model.save()
|
||||||
|
zones_model.save()
|
||||||
|
sequences_model.save()
|
||||||
|
profiles_model.save()
|
||||||
|
|
||||||
|
return str(new_profile_id), new_profile_data
|
||||||
|
|
||||||
|
|
||||||
|
def export_preset_bundle(preset_id: str, presets_model) -> Dict[str, Any]:
|
||||||
|
preset = presets_model.read(preset_id)
|
||||||
|
if not preset:
|
||||||
|
raise ValueError("Preset not found")
|
||||||
|
return {
|
||||||
|
"version": BUNDLE_VERSION,
|
||||||
|
"kind": KIND_PRESET,
|
||||||
|
"preset": copy.deepcopy(preset),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def import_preset_bundle(
|
||||||
|
bundle: Dict[str, Any],
|
||||||
|
presets_model,
|
||||||
|
profile_id: str,
|
||||||
|
) -> Tuple[str, Dict[str, Any]]:
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
raise ValueError("Invalid bundle")
|
||||||
|
if bundle.get("kind") != KIND_PRESET:
|
||||||
|
raise ValueError("Not a preset bundle")
|
||||||
|
preset = bundle.get("preset")
|
||||||
|
if not isinstance(preset, dict):
|
||||||
|
raise ValueError("Bundle missing preset")
|
||||||
|
new_id = presets_model.create(profile_id)
|
||||||
|
data = copy.deepcopy(preset)
|
||||||
|
data["profile_id"] = str(profile_id)
|
||||||
|
presets_model.update(new_id, data)
|
||||||
|
return str(new_id), presets_model.read(new_id) or data
|
||||||
|
|
||||||
|
|
||||||
|
def export_sequence_bundle(
|
||||||
|
sequence_id: str,
|
||||||
|
sequences_model,
|
||||||
|
presets_model,
|
||||||
|
*,
|
||||||
|
profile_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
seq = sequences_model.read(sequence_id)
|
||||||
|
if not seq:
|
||||||
|
raise ValueError("Sequence not found")
|
||||||
|
if profile_id is not None and str(seq.get("profile_id")) != str(profile_id):
|
||||||
|
raise ValueError("Sequence not found")
|
||||||
|
|
||||||
|
pid = str(profile_id or seq.get("profile_id") or "")
|
||||||
|
preset_ids = _preset_ids_in_sequence(seq)
|
||||||
|
presets_out: Dict[str, Any] = {}
|
||||||
|
for old_pid in preset_ids:
|
||||||
|
pdata = presets_model.read(old_pid)
|
||||||
|
if pdata and (not pid or str(pdata.get("profile_id")) == pid):
|
||||||
|
presets_out[old_pid] = copy.deepcopy(pdata)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": BUNDLE_VERSION,
|
||||||
|
"kind": KIND_SEQUENCE,
|
||||||
|
"sequence": copy.deepcopy(seq),
|
||||||
|
"presets": presets_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def import_sequence_bundle(
|
||||||
|
bundle: Dict[str, Any],
|
||||||
|
sequences_model,
|
||||||
|
presets_model,
|
||||||
|
profile_id: str,
|
||||||
|
) -> Tuple[str, Dict[str, Any]]:
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
raise ValueError("Invalid bundle")
|
||||||
|
if bundle.get("kind") != KIND_SEQUENCE:
|
||||||
|
raise ValueError("Not a sequence bundle")
|
||||||
|
seq = bundle.get("sequence")
|
||||||
|
if not isinstance(seq, dict):
|
||||||
|
raise ValueError("Bundle missing sequence")
|
||||||
|
|
||||||
|
preset_cache: Dict[str, int] = {}
|
||||||
|
preset_id_map: Dict[str, str] = {}
|
||||||
|
new_presets: Dict[str, Dict[str, Any]] = {}
|
||||||
|
presets_in = bundle.get("presets") if isinstance(bundle.get("presets"), dict) else {}
|
||||||
|
|
||||||
|
for old_pid, pdata in presets_in.items():
|
||||||
|
if not isinstance(pdata, dict):
|
||||||
|
continue
|
||||||
|
new_pid = _allocate_id(presets_model, preset_cache)
|
||||||
|
clone = copy.deepcopy(pdata)
|
||||||
|
clone["profile_id"] = str(profile_id)
|
||||||
|
new_presets[new_pid] = clone
|
||||||
|
preset_id_map[str(old_pid)] = new_pid
|
||||||
|
|
||||||
|
for old_pid in _preset_ids_in_sequence(seq):
|
||||||
|
op = str(old_pid)
|
||||||
|
if op not in preset_id_map:
|
||||||
|
pdata = presets_model.read(op)
|
||||||
|
if pdata:
|
||||||
|
new_pid = _allocate_id(presets_model, preset_cache)
|
||||||
|
clone = copy.deepcopy(pdata)
|
||||||
|
clone["profile_id"] = str(profile_id)
|
||||||
|
new_presets[new_pid] = clone
|
||||||
|
preset_id_map[op] = new_pid
|
||||||
|
|
||||||
|
for pid, pdata in new_presets.items():
|
||||||
|
presets_model[pid] = pdata
|
||||||
|
if new_presets:
|
||||||
|
presets_model.save()
|
||||||
|
|
||||||
|
new_seq_id = sequences_model.create(profile_id)
|
||||||
|
mapped = _map_sequence_lanes(seq, preset_id_map)
|
||||||
|
mapped["profile_id"] = str(profile_id)
|
||||||
|
sequences_model.update(new_seq_id, mapped)
|
||||||
|
return str(new_seq_id), sequences_model.read(new_seq_id) or mapped
|
||||||
1343
src/util/sequence_playback.py
Normal file
1343
src/util/sequence_playback.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
tests/audio/beat_test_120bpm.wav
Normal file
BIN
tests/audio/beat_test_120bpm.wav
Normal file
Binary file not shown.
@@ -112,12 +112,6 @@ def parse_args() -> argparse.Namespace:
|
|||||||
default=0.12,
|
default=0.12,
|
||||||
help="Aubio detection threshold",
|
help="Aubio detection threshold",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--silence-gate-db",
|
|
||||||
type=float,
|
|
||||||
default=-58.0,
|
|
||||||
help="Ignore beat triggers when frame RMS is below this dB level",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -131,6 +125,141 @@ def _estimate_bpm(beat_times: Deque[float]) -> float | None:
|
|||||||
return 60.0 / float(np.median(valid))
|
return 60.0 / float(np.median(valid))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_plausible_ioi(
|
||||||
|
last_trigger_s: float,
|
||||||
|
beat_times: Deque[float],
|
||||||
|
now_s: float,
|
||||||
|
*,
|
||||||
|
min_ratio: float = 0.42,
|
||||||
|
max_ratio: float = 2.5,
|
||||||
|
) -> bool:
|
||||||
|
"""Reject double-time / half-time false triggers vs recent median interval."""
|
||||||
|
if last_trigger_s <= 0 or len(beat_times) < 2:
|
||||||
|
return True
|
||||||
|
ioi = now_s - last_trigger_s
|
||||||
|
if ioi <= 0:
|
||||||
|
return False
|
||||||
|
intervals = np.diff(np.array(list(beat_times)[-8:], dtype=np.float64))
|
||||||
|
if intervals.size == 0:
|
||||||
|
return True
|
||||||
|
med = float(np.median(intervals))
|
||||||
|
if med < 0.05:
|
||||||
|
return True
|
||||||
|
return (ioi >= med * min_ratio) and (ioi <= med * max_ratio)
|
||||||
|
|
||||||
|
|
||||||
|
class BarPhaseTracker:
|
||||||
|
"""Track beat-in-bar from downbeat counting (kick hints)."""
|
||||||
|
|
||||||
|
def __init__(self, beats_per_bar: int = 4, kick_conf_min: float = 1.15):
|
||||||
|
self.beats_per_bar = max(1, int(beats_per_bar))
|
||||||
|
self.kick_conf_min = float(kick_conf_min)
|
||||||
|
self.bar_beat = 1
|
||||||
|
self.is_downbeat = True
|
||||||
|
self.confidence = 0.0
|
||||||
|
self._last_downbeat_s = 0.0
|
||||||
|
self._aligned_kicks = 0
|
||||||
|
self._total_beats = 0
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.bar_beat = 1
|
||||||
|
self.is_downbeat = True
|
||||||
|
self.confidence = 0.0
|
||||||
|
self._last_downbeat_s = 0.0
|
||||||
|
self._aligned_kicks = 0
|
||||||
|
self._total_beats = 0
|
||||||
|
|
||||||
|
def anchor_downbeat(self, now_s: float) -> None:
|
||||||
|
self.bar_beat = 1
|
||||||
|
self.is_downbeat = True
|
||||||
|
self._last_downbeat_s = float(now_s)
|
||||||
|
self.confidence = max(self.confidence, 0.85)
|
||||||
|
|
||||||
|
def _bar_duration_s(
|
||||||
|
self, bpm: float | None, median_ioi: float | None
|
||||||
|
) -> float | None:
|
||||||
|
if bpm is not None and bpm > 0:
|
||||||
|
return (60.0 / float(bpm)) * self.beats_per_bar
|
||||||
|
if median_ioi is not None and median_ioi > 0:
|
||||||
|
return float(median_ioi) * self.beats_per_bar
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _near_whole_bars(elapsed: float, bar_dur: float, tol: float = 0.14) -> bool:
|
||||||
|
if bar_dur <= 0 or elapsed <= 0:
|
||||||
|
return False
|
||||||
|
n = elapsed / bar_dur
|
||||||
|
nearest = max(1, round(n))
|
||||||
|
return abs(n - nearest) <= tol
|
||||||
|
|
||||||
|
def on_beat(
|
||||||
|
self,
|
||||||
|
now_s: float,
|
||||||
|
beat_type: str,
|
||||||
|
beat_type_conf: float,
|
||||||
|
*,
|
||||||
|
bpm: float | None = None,
|
||||||
|
median_ioi: float | None = None,
|
||||||
|
) -> dict[str, int | float | bool | str]:
|
||||||
|
self._total_beats += 1
|
||||||
|
bar_dur = self._bar_duration_s(bpm, median_ioi)
|
||||||
|
is_kick = (
|
||||||
|
str(beat_type or "").lower() == "kick"
|
||||||
|
and float(beat_type_conf or 0.0) >= self.kick_conf_min
|
||||||
|
)
|
||||||
|
|
||||||
|
downbeat_locked = False
|
||||||
|
if is_kick:
|
||||||
|
if self._last_downbeat_s <= 0 or self._total_beats <= 2:
|
||||||
|
downbeat_locked = True
|
||||||
|
elif bar_dur and self._near_whole_bars(
|
||||||
|
now_s - self._last_downbeat_s, bar_dur
|
||||||
|
):
|
||||||
|
downbeat_locked = True
|
||||||
|
elif is_kick and self.bar_beat >= max(2, self.beats_per_bar - 1):
|
||||||
|
downbeat_locked = True
|
||||||
|
|
||||||
|
prev_bar_beat = int(self.bar_beat)
|
||||||
|
if downbeat_locked:
|
||||||
|
self.bar_beat = 1
|
||||||
|
self.is_downbeat = True
|
||||||
|
self._last_downbeat_s = float(now_s)
|
||||||
|
self._aligned_kicks += 1
|
||||||
|
elif self._total_beats <= 1:
|
||||||
|
self.bar_beat = 1
|
||||||
|
self.is_downbeat = True
|
||||||
|
else:
|
||||||
|
self.bar_beat = (prev_bar_beat % self.beats_per_bar) + 1
|
||||||
|
self.is_downbeat = self.bar_beat == 1
|
||||||
|
|
||||||
|
if self._total_beats >= self.beats_per_bar:
|
||||||
|
bars_seen = max(1, self._total_beats // self.beats_per_bar)
|
||||||
|
self.confidence = min(1.0, self._aligned_kicks / bars_seen)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bar_beat": int(self.bar_beat),
|
||||||
|
"beats_per_bar": int(self.beats_per_bar),
|
||||||
|
"is_downbeat": bool(self.is_downbeat),
|
||||||
|
"phase_confidence": round(float(self.confidence), 3),
|
||||||
|
"bar_phase_readout": f"{int(self.bar_beat)}/{int(self.beats_per_bar)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_bpm(
|
||||||
|
beat_times: Deque[float],
|
||||||
|
aubio_bpm: float | None,
|
||||||
|
) -> float | None:
|
||||||
|
estimated = _estimate_bpm(beat_times)
|
||||||
|
if estimated is None:
|
||||||
|
return aubio_bpm
|
||||||
|
if aubio_bpm is None or aubio_bpm <= 0:
|
||||||
|
return estimated
|
||||||
|
ratio = float(aubio_bpm) / estimated
|
||||||
|
if ratio > 1.75 or ratio < 0.57:
|
||||||
|
return estimated
|
||||||
|
return estimated
|
||||||
|
|
||||||
|
|
||||||
def _load_aubio_if_needed(mode: str):
|
def _load_aubio_if_needed(mode: str):
|
||||||
if mode == "custom":
|
if mode == "custom":
|
||||||
return None
|
return None
|
||||||
@@ -170,6 +299,8 @@ class BeatDetectRuntime:
|
|||||||
)
|
)
|
||||||
self.last_trigger_s = 0.0
|
self.last_trigger_s = 0.0
|
||||||
self.debounce_s = float(args.min_ioi_ms) / 1000.0
|
self.debounce_s = float(args.min_ioi_ms) / 1000.0
|
||||||
|
bpb = int(getattr(args, "beats_per_bar", 4) or 4)
|
||||||
|
self.bar_phase = BarPhaseTracker(beats_per_bar=bpb)
|
||||||
|
|
||||||
def setup(self, sample_rate: int):
|
def setup(self, sample_rate: int):
|
||||||
self.sample_rate = int(sample_rate)
|
self.sample_rate = int(sample_rate)
|
||||||
@@ -192,13 +323,37 @@ class BeatDetectRuntime:
|
|||||||
self.beat_times.clear()
|
self.beat_times.clear()
|
||||||
self.tempo = None
|
self.tempo = None
|
||||||
if self.aubio is not None:
|
if self.aubio is not None:
|
||||||
self.tempo = self.aubio.tempo(
|
self._init_aubio_tempo(win_size)
|
||||||
self.args.aubio_method, win_size, self.frame_size, self.sample_rate
|
|
||||||
)
|
def _init_aubio_tempo(self, win_size: int):
|
||||||
if hasattr(self.tempo, "set_threshold"):
|
self.tempo = self.aubio.tempo(
|
||||||
self.tempo.set_threshold(float(self.args.aubio_threshold))
|
self.args.aubio_method, win_size, self.frame_size, self.sample_rate
|
||||||
if hasattr(self.tempo, "set_minioi_ms"):
|
)
|
||||||
self.tempo.set_minioi_ms(float(self.args.min_ioi_ms))
|
if hasattr(self.tempo, "set_threshold"):
|
||||||
|
self.tempo.set_threshold(float(self.args.aubio_threshold))
|
||||||
|
if hasattr(self.tempo, "set_minioi_ms"):
|
||||||
|
self.tempo.set_minioi_ms(float(self.args.min_ioi_ms))
|
||||||
|
|
||||||
|
def reset_tempo_state(self) -> None:
|
||||||
|
"""Clear tempo/aubio history without losing bar phase."""
|
||||||
|
self.baseline = 1e-6
|
||||||
|
if self.prev_mag is not None:
|
||||||
|
self.prev_mag[:] = 0.0
|
||||||
|
self.beat_times.clear()
|
||||||
|
self.last_trigger_s = 0.0
|
||||||
|
if self.aubio is not None and self.sample_rate > 0:
|
||||||
|
win_size = max(1024, self.frame_size * max(2, self.args.win_mult))
|
||||||
|
self._init_aubio_tempo(win_size)
|
||||||
|
|
||||||
|
def reset_state(self):
|
||||||
|
"""Full reset (manual): tempo history and bar phase."""
|
||||||
|
self.reset_tempo_state()
|
||||||
|
self.bar_phase.reset()
|
||||||
|
|
||||||
|
def anchor_bar_phase(self, now_s: float | None = None) -> None:
|
||||||
|
if now_s is None:
|
||||||
|
now_s = time.time()
|
||||||
|
self.bar_phase.anchor_downbeat(now_s)
|
||||||
|
|
||||||
def _classify_hit(self, mag: np.ndarray):
|
def _classify_hit(self, mag: np.ndarray):
|
||||||
total = float(np.mean(mag) + 1e-9)
|
total = float(np.mean(mag) + 1e-9)
|
||||||
@@ -227,8 +382,6 @@ class BeatDetectRuntime:
|
|||||||
f32 = frame.astype(np.float32)
|
f32 = frame.astype(np.float32)
|
||||||
rms = float(np.sqrt(np.mean(f32 * f32) + 1e-12))
|
rms = float(np.sqrt(np.mean(f32 * f32) + 1e-12))
|
||||||
db = 20.0 * np.log10(max(rms, 1e-12))
|
db = 20.0 * np.log10(max(rms, 1e-12))
|
||||||
if db < float(self.args.silence_gate_db):
|
|
||||||
return None
|
|
||||||
mag = np.abs(np.fft.rfft(f32 * self.window)).astype(np.float32)
|
mag = np.abs(np.fft.rfft(f32 * self.window)).astype(np.float32)
|
||||||
band_energy = float(np.mean(mag[self.band_mask]))
|
band_energy = float(np.mean(mag[self.band_mask]))
|
||||||
flux = float(np.mean(np.maximum(0.0, mag - self.prev_mag)))
|
flux = float(np.mean(np.maximum(0.0, mag - self.prev_mag)))
|
||||||
@@ -260,14 +413,30 @@ class BeatDetectRuntime:
|
|||||||
should_trigger = aubio_hit
|
should_trigger = aubio_hit
|
||||||
else:
|
else:
|
||||||
should_trigger = custom_hit or aubio_hit
|
should_trigger = custom_hit or aubio_hit
|
||||||
|
if should_trigger and not _is_plausible_ioi(
|
||||||
|
self.last_trigger_s, self.beat_times, now_s
|
||||||
|
):
|
||||||
|
should_trigger = False
|
||||||
if not should_trigger:
|
if not should_trigger:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self.last_trigger_s = now_s
|
self.last_trigger_s = now_s
|
||||||
self.beat_times.append(now_s)
|
self.beat_times.append(now_s)
|
||||||
bpm = aubio_bpm if aubio_bpm is not None else _estimate_bpm(self.beat_times)
|
bpm = _resolve_bpm(self.beat_times, aubio_bpm)
|
||||||
strength = score / max(1e-9, self.baseline)
|
strength = score / max(1e-9, self.baseline)
|
||||||
beat_type, beat_type_conf = self._classify_hit(mag)
|
beat_type, beat_type_conf = self._classify_hit(mag)
|
||||||
|
median_ioi = None
|
||||||
|
if len(self.beat_times) >= 2:
|
||||||
|
intervals = np.diff(np.array(self.beat_times, dtype=np.float64))
|
||||||
|
if intervals.size > 0:
|
||||||
|
median_ioi = float(np.median(intervals))
|
||||||
|
phase = self.bar_phase.on_beat(
|
||||||
|
now_s,
|
||||||
|
beat_type,
|
||||||
|
beat_type_conf,
|
||||||
|
bpm=bpm,
|
||||||
|
median_ioi=median_ioi,
|
||||||
|
)
|
||||||
if self.args.mode == "custom":
|
if self.args.mode == "custom":
|
||||||
src = "custom"
|
src = "custom"
|
||||||
elif self.args.mode == "aubio":
|
elif self.args.mode == "aubio":
|
||||||
@@ -288,6 +457,7 @@ class BeatDetectRuntime:
|
|||||||
"beat_type": beat_type,
|
"beat_type": beat_type,
|
||||||
"beat_type_confidence": beat_type_conf,
|
"beat_type_confidence": beat_type_conf,
|
||||||
"db": db,
|
"db": db,
|
||||||
|
**phase,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,18 +25,20 @@ def test_preset():
|
|||||||
print("\nTesting update preset")
|
print("\nTesting update preset")
|
||||||
update_data = {
|
update_data = {
|
||||||
"name": "test_preset",
|
"name": "test_preset",
|
||||||
"pattern": "on",
|
"pattern": "colour_cycle",
|
||||||
"colors": ["#FF0000", "#00FF00"],
|
"colors": ["#FF0000", "#00FF00"],
|
||||||
"delay": 100,
|
"delay": 100,
|
||||||
"brightness": 127,
|
"brightness": 127,
|
||||||
"n1": 10,
|
"n1": 10,
|
||||||
"n2": 20
|
"n2": 20,
|
||||||
|
"mode": 1,
|
||||||
}
|
}
|
||||||
result = presets.update(preset_id, update_data)
|
result = presets.update(preset_id, update_data)
|
||||||
assert result is True
|
assert result is True
|
||||||
updated = presets.read(preset_id)
|
updated = presets.read(preset_id)
|
||||||
assert updated["name"] == "test_preset"
|
assert updated["name"] == "test_preset"
|
||||||
assert updated["pattern"] == "on"
|
assert updated["pattern"] == "colour_cycle"
|
||||||
|
assert updated["mode"] == 1
|
||||||
assert updated["delay"] == 100
|
assert updated["delay"] == 100
|
||||||
|
|
||||||
print("\nTesting list presets")
|
print("\nTesting list presets")
|
||||||
|
|||||||
@@ -1,62 +1,83 @@
|
|||||||
from models.squence import Sequence
|
from models.sequence import Sequence
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_PROJECT_DB = os.path.normpath(os.path.join(_HERE, "..", "..", "db", "sequence.json"))
|
||||||
|
|
||||||
|
|
||||||
def test_sequence():
|
def test_sequence():
|
||||||
"""Test Sequence model CRUD operations."""
|
"""Test Sequence model CRUD operations."""
|
||||||
# Clean up any existing test file
|
if os.path.exists(_PROJECT_DB):
|
||||||
if os.path.exists("Sequence.json"):
|
os.remove(_PROJECT_DB)
|
||||||
os.remove("Sequence.json")
|
|
||||||
|
|
||||||
sequences = Sequence()
|
sequences = Sequence()
|
||||||
|
|
||||||
print("Testing create sequence")
|
print("Testing create sequence")
|
||||||
sequence_id = sequences.create("test_group", ["preset1", "preset2"])
|
sequence_id = sequences.create("1")
|
||||||
print(f"Created sequence with ID: {sequence_id}")
|
print(f"Created sequence with ID: {sequence_id}")
|
||||||
assert sequence_id is not None
|
assert sequence_id is not None
|
||||||
assert sequence_id in sequences
|
assert sequence_id in sequences
|
||||||
|
|
||||||
print("\nTesting read sequence")
|
print("\nTesting read sequence")
|
||||||
sequence = sequences.read(sequence_id)
|
sequence = sequences.read(sequence_id)
|
||||||
print(f"Read: {sequence}")
|
print(f"Read: {sequence}")
|
||||||
assert sequence is not None
|
assert sequence is not None
|
||||||
assert sequence["group_name"] == "test_group"
|
assert sequence["profile_id"] == "1"
|
||||||
assert len(sequence["presets"]) == 2
|
assert sequence["steps"] == []
|
||||||
assert "sequence_duration" in sequence
|
assert sequence["lanes"] == [[]]
|
||||||
assert "sequence_loop" in sequence
|
assert sequence.get("lanes_group_ids") == [[]]
|
||||||
|
assert sequence.get("advance_mode") == "beats"
|
||||||
|
assert sequence.get("simulated_bpm") == 120
|
||||||
|
assert sequence["step_duration_ms"] == 3000
|
||||||
|
assert sequence["loop"] is True
|
||||||
|
assert sequence.get("sequence_transition") == 500
|
||||||
|
|
||||||
print("\nTesting update sequence")
|
print("\nTesting update sequence")
|
||||||
update_data = {
|
update_data = {
|
||||||
"group_name": "updated_group",
|
"name": "updated_seq",
|
||||||
"presets": ["preset3", "preset4", "preset5"],
|
"steps": [
|
||||||
"sequence_duration": 5000,
|
{"preset_id": "5", "group_ids": ["1"], "beats": 2},
|
||||||
"sequence_transition": 1000,
|
{"preset_id": "6", "group_ids": [], "beats": 4},
|
||||||
"sequence_loop": True,
|
],
|
||||||
"sequence_repeat_count": 3
|
"lanes_group_ids": [["1"]],
|
||||||
|
"step_duration_ms": 5000,
|
||||||
|
"loop": True,
|
||||||
|
"advance_mode": "beats",
|
||||||
|
"simulated_bpm": 128,
|
||||||
}
|
}
|
||||||
result = sequences.update(sequence_id, update_data)
|
result = sequences.update(sequence_id, update_data)
|
||||||
assert result is True
|
assert result is True
|
||||||
updated = sequences.read(sequence_id)
|
updated = sequences.read(sequence_id)
|
||||||
assert updated["group_name"] == "updated_group"
|
assert updated["name"] == "updated_seq"
|
||||||
assert len(updated["presets"]) == 3
|
assert len(updated["steps"]) == 2
|
||||||
assert updated["sequence_duration"] == 5000
|
assert updated["steps"][0]["preset_id"] == "5"
|
||||||
assert updated["sequence_loop"] is True
|
assert updated["steps"][0]["group_ids"] == ["1"]
|
||||||
|
assert updated["steps"][0].get("beats") == 2
|
||||||
|
assert isinstance(updated.get("lanes"), list)
|
||||||
|
assert len(updated["lanes"]) == 1
|
||||||
|
assert len(updated["lanes"][0]) == 2
|
||||||
|
assert updated["lanes"][0][0]["beats"] == 2
|
||||||
|
assert updated.get("advance_mode") == "beats"
|
||||||
|
assert updated.get("simulated_bpm") == 128
|
||||||
|
assert updated["step_duration_ms"] == 5000
|
||||||
|
assert updated["loop"] is True
|
||||||
|
|
||||||
print("\nTesting list sequences")
|
print("\nTesting list sequences")
|
||||||
sequence_list = sequences.list()
|
sequence_list = sequences.list()
|
||||||
print(f"Sequence list: {sequence_list}")
|
print(f"Sequence list: {sequence_list}")
|
||||||
assert sequence_id in sequence_list
|
assert sequence_id in sequence_list
|
||||||
|
|
||||||
print("\nTesting delete sequence")
|
print("\nTesting delete sequence")
|
||||||
deleted = sequences.delete(sequence_id)
|
deleted = sequences.delete(sequence_id)
|
||||||
assert deleted is True
|
assert deleted is True
|
||||||
assert sequence_id not in sequences
|
assert sequence_id not in sequences
|
||||||
|
|
||||||
print("\nTesting read after delete")
|
print("\nTesting read after delete")
|
||||||
sequence = sequences.read(sequence_id)
|
sequence = sequences.read(sequence_id)
|
||||||
assert sequence is None
|
assert sequence is None
|
||||||
|
|
||||||
print("\nAll sequence tests passed!")
|
print("\nAll sequence tests passed!")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
test_sequence()
|
test_sequence()
|
||||||
|
|||||||
61
tests/test_audio_reset_tracking.py
Normal file
61
tests/test_audio_reset_tracking.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Reset detector must not stop the stream or clear ``running``."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
|
||||||
|
if SRC_PATH not in sys.path:
|
||||||
|
sys.path.insert(0, SRC_PATH)
|
||||||
|
|
||||||
|
from util.audio_detector import AudioBeatDetector # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRuntime:
|
||||||
|
def __init__(self):
|
||||||
|
self.reset_calls = 0
|
||||||
|
|
||||||
|
def reset_state(self):
|
||||||
|
self.reset_calls += 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_tracking_false_when_not_running():
|
||||||
|
det = AudioBeatDetector()
|
||||||
|
assert det.reset_tracking() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_tracking_queues_on_audio_thread():
|
||||||
|
det = AudioBeatDetector()
|
||||||
|
rt = _FakeRuntime()
|
||||||
|
with det._lock:
|
||||||
|
det._running = True
|
||||||
|
det._runtime = rt
|
||||||
|
det._status["running"] = True
|
||||||
|
det._status["bpm"] = 128.0
|
||||||
|
det._status["beat_seq"] = 7
|
||||||
|
|
||||||
|
assert det.reset_tracking() is True
|
||||||
|
assert rt.reset_calls == 0
|
||||||
|
assert det._pending_reset is True
|
||||||
|
|
||||||
|
st = det.status()
|
||||||
|
assert st["running"] is True
|
||||||
|
assert st["bpm"] == 128.0
|
||||||
|
assert st["beat_seq"] == 7
|
||||||
|
|
||||||
|
det._process_pending_reset(rt)
|
||||||
|
assert rt.reset_calls == 1
|
||||||
|
assert det._pending_reset is False
|
||||||
|
assert det.status()["running"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_keeps_bpm_during_holdover():
|
||||||
|
det = AudioBeatDetector()
|
||||||
|
with det._lock:
|
||||||
|
det._running = True
|
||||||
|
det._holdover_active = True
|
||||||
|
det._status["running"] = True
|
||||||
|
det._status["bpm"] = 128.0
|
||||||
|
det._status["last_beat_ts"] = time.time() - 10.0
|
||||||
|
assert det.status()["bpm"] == 128.0
|
||||||
70
tests/test_bar_phase.py
Normal file
70
tests/test_bar_phase.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Bar phase (beat-in-bar) tracking for audio beat detection."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if PROJECT_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, PROJECT_ROOT)
|
||||||
|
|
||||||
|
from tests.beat_detect import BarPhaseTracker # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_bar_phase_increments_on_non_kick_beats():
|
||||||
|
tr = BarPhaseTracker(beats_per_bar=4)
|
||||||
|
r1 = tr.on_beat(1.0, "snare", 1.3, bpm=120.0)
|
||||||
|
assert r1["bar_beat"] == 1
|
||||||
|
r2 = tr.on_beat(1.5, "snare", 1.2, bpm=120.0)
|
||||||
|
assert r2["bar_beat"] == 2
|
||||||
|
r3 = tr.on_beat(2.0, "hat", 1.1, bpm=120.0)
|
||||||
|
assert r3["bar_beat"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_kick_near_bar_boundary_resets_to_downbeat():
|
||||||
|
tr = BarPhaseTracker(beats_per_bar=4)
|
||||||
|
tr.on_beat(0.0, "kick", 1.4, bpm=120.0)
|
||||||
|
tr.on_beat(0.5, "snare", 1.2, bpm=120.0)
|
||||||
|
tr.on_beat(1.0, "snare", 1.2, bpm=120.0)
|
||||||
|
tr.on_beat(1.5, "snare", 1.2, bpm=120.0)
|
||||||
|
r = tr.on_beat(2.0, "kick", 1.5, bpm=120.0)
|
||||||
|
assert r["bar_beat"] == 1
|
||||||
|
assert r["is_downbeat"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_downbeat_sets_confidence():
|
||||||
|
tr = BarPhaseTracker(beats_per_bar=4)
|
||||||
|
tr.anchor_downbeat(10.0)
|
||||||
|
assert tr.bar_beat == 1
|
||||||
|
assert tr.confidence >= 0.85
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_tempo_preserves_bar_phase():
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
from tests.beat_detect import BeatDetectRuntime # noqa: E402
|
||||||
|
|
||||||
|
args = Namespace(
|
||||||
|
mode="custom",
|
||||||
|
hop_size=256,
|
||||||
|
win_mult=2,
|
||||||
|
min_band_hz=45.0,
|
||||||
|
max_band_hz=180.0,
|
||||||
|
energy_weight=0.7,
|
||||||
|
flux_weight=0.3,
|
||||||
|
threshold_multiplier=1.35,
|
||||||
|
ema_alpha=0.08,
|
||||||
|
min_ioi_ms=100.0,
|
||||||
|
bpm_window=8,
|
||||||
|
aubio_method="default",
|
||||||
|
aubio_threshold=0.12,
|
||||||
|
beats_per_bar=4,
|
||||||
|
)
|
||||||
|
rt = BeatDetectRuntime(args)
|
||||||
|
rt.setup(44100)
|
||||||
|
rt.bar_phase.on_beat(0.0, "kick", 1.5, bpm=120.0)
|
||||||
|
rt.bar_phase.on_beat(0.5, "snare", 1.2, bpm=120.0)
|
||||||
|
assert rt.bar_phase.bar_beat == 2
|
||||||
|
rt.reset_tempo_state()
|
||||||
|
assert rt.bar_phase.bar_beat == 2
|
||||||
|
rt.reset_state()
|
||||||
|
assert rt.bar_phase.bar_beat == 1
|
||||||
28
tests/test_beat_detect_ioi.py
Normal file
28
tests/test_beat_detect_ioi.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Beat interval plausibility helpers (audio detector)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if PROJECT_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, PROJECT_ROOT)
|
||||||
|
|
||||||
|
from tests.beat_detect import _is_plausible_ioi, _resolve_bpm # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_plausible_ioi_rejects_double_time():
|
||||||
|
times = deque([0.0, 0.5, 1.0])
|
||||||
|
assert _is_plausible_ioi(1.0, times, 1.15) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_plausible_ioi_accepts_steady_grid():
|
||||||
|
times = deque([0.0, 0.5, 1.0])
|
||||||
|
assert _is_plausible_ioi(1.0, times, 1.5) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_bpm_prefers_intervals_over_wrong_aubio():
|
||||||
|
times = deque([0.0, 0.5, 1.0, 1.5, 2.0])
|
||||||
|
bpm = _resolve_bpm(times, 70.0)
|
||||||
|
assert bpm is not None
|
||||||
|
assert abs(bpm - 120.0) < 5.0
|
||||||
105
tests/test_beat_driver_route_suppress.py
Normal file
105
tests/test_beat_driver_route_suppress.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Manual beat route: suppress duplicate select after sequence step change."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
|
||||||
|
if SRC_PATH not in sys.path:
|
||||||
|
sys.path.insert(0, SRC_PATH)
|
||||||
|
|
||||||
|
from util import beat_driver_route as bdr # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_delivery(monkeypatch):
|
||||||
|
delivered = []
|
||||||
|
|
||||||
|
async def fake_batch(pairs):
|
||||||
|
delivered.extend(pairs)
|
||||||
|
|
||||||
|
def fake_schedule(coro, _loop):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(coro)
|
||||||
|
|
||||||
|
monkeypatch.setattr(bdr, "_deliver_select_batch", fake_batch)
|
||||||
|
monkeypatch.setattr(bdr, "_main_loop", object())
|
||||||
|
monkeypatch.setattr("asyncio.run_coroutine_threadsafe", fake_schedule)
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
|
||||||
|
def test_suppress_next_notify_skips_one_select(monkeypatch):
|
||||||
|
delivered = _patch_delivery(monkeypatch)
|
||||||
|
|
||||||
|
bdr.set_sequence_manual_lane_route(
|
||||||
|
0,
|
||||||
|
["desk"],
|
||||||
|
"5",
|
||||||
|
{"p": "chase", "a": False, "manual_beat_n": 1},
|
||||||
|
)
|
||||||
|
bdr.mark_sequence_manual_lane_select_sent(0)
|
||||||
|
|
||||||
|
bdr.notify_beat_detected()
|
||||||
|
assert delivered == []
|
||||||
|
|
||||||
|
bdr.notify_beat_detected()
|
||||||
|
assert delivered == [(["desk"], "5")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_suppress_does_not_advance_beat_counter(monkeypatch):
|
||||||
|
delivered = _patch_delivery(monkeypatch)
|
||||||
|
|
||||||
|
bdr.set_sequence_manual_lane_route(
|
||||||
|
0,
|
||||||
|
["desk"],
|
||||||
|
"42",
|
||||||
|
{"p": "radiate", "a": False, "manual_beat_n": 2},
|
||||||
|
)
|
||||||
|
bdr.mark_sequence_manual_lane_select_sent(0)
|
||||||
|
|
||||||
|
bdr.notify_beat_detected()
|
||||||
|
assert delivered == []
|
||||||
|
|
||||||
|
bdr.notify_beat_detected()
|
||||||
|
assert delivered == [(["desk"], "42")]
|
||||||
|
|
||||||
|
delivered.clear()
|
||||||
|
bdr.notify_beat_detected()
|
||||||
|
assert delivered == []
|
||||||
|
|
||||||
|
bdr.notify_beat_detected()
|
||||||
|
assert delivered == [(["desk"], "42")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_lanes_dedupe_to_one_select_per_beat(monkeypatch):
|
||||||
|
delivered = _patch_delivery(monkeypatch)
|
||||||
|
body = {"p": "radiate", "a": False, "manual_beat_n": 1}
|
||||||
|
entry = {
|
||||||
|
"device_names": ["desk"],
|
||||||
|
"wire_preset_id": "42",
|
||||||
|
"pattern": "radiate",
|
||||||
|
"manual_beat_n": 1,
|
||||||
|
"beat_counter": 0,
|
||||||
|
}
|
||||||
|
with bdr._route_lock:
|
||||||
|
bdr._lane_manual.clear()
|
||||||
|
bdr._lane_manual[-1] = dict(entry)
|
||||||
|
bdr._lane_manual[0] = dict(entry)
|
||||||
|
|
||||||
|
bdr.notify_beat_detected()
|
||||||
|
assert delivered == [(["desk"], "42")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_standalone_overlay_skipped_when_sequence_lane_covers(monkeypatch):
|
||||||
|
delivered = _patch_delivery(monkeypatch)
|
||||||
|
body = {"p": "radiate", "a": False, "manual_beat_n": 1}
|
||||||
|
|
||||||
|
bdr.set_sequence_manual_lane_route(1, ["desk"], "42", body)
|
||||||
|
bdr._apply_manual_beat_route_standalone_overlay(["desk"], "42", body)
|
||||||
|
|
||||||
|
with bdr._route_lock:
|
||||||
|
assert -1 not in bdr._lane_manual
|
||||||
|
assert 1 in bdr._lane_manual
|
||||||
|
|
||||||
|
bdr.notify_beat_detected()
|
||||||
|
assert delivered == [(["desk"], "42")]
|
||||||
@@ -29,6 +29,21 @@ def test_pack_parse_v2_brightness_only():
|
|||||||
assert data == {"v": "1", "b": 128}
|
assert data == {"v": "1", "b": 128}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_parse_v2_mode_maps_to_n6():
|
||||||
|
raw = pack_binary_envelope_v2(
|
||||||
|
presets={
|
||||||
|
"m": {
|
||||||
|
"p": "meteor",
|
||||||
|
"c": ["#aabbcc"],
|
||||||
|
"mode": 2,
|
||||||
|
"n6": 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data = parse_binary_envelope_v2(raw)
|
||||||
|
assert data["presets"]["m"]["n6"] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_pack_parse_v2_full():
|
def test_pack_parse_v2_full():
|
||||||
raw = pack_binary_envelope_v2(
|
raw = pack_binary_envelope_v2(
|
||||||
presets={
|
presets={
|
||||||
|
|||||||
@@ -750,6 +750,46 @@ def test_presets_ui(browser: BrowserTest) -> bool:
|
|||||||
print(f"\nBrowser presets UI tests: {passed}/{total} passed")
|
print(f"\nBrowser presets UI tests: {passed}/{total} passed")
|
||||||
return passed == total
|
return passed == total
|
||||||
|
|
||||||
|
def test_preset_editor_palette_child_modal_zindex(browser: BrowserTest) -> bool:
|
||||||
|
"""
|
||||||
|
Regression: preset editor 'From Palette' builds a body-appended .modal without an id.
|
||||||
|
It must use .modal-child-overlay so z-index clears #preset-editor-modal (1060).
|
||||||
|
"""
|
||||||
|
print("\n=== Testing preset child overlay z-index ===")
|
||||||
|
if not browser.setup():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if not browser.navigate("/"):
|
||||||
|
return False
|
||||||
|
z_editor, z_child = browser.driver.execute_script(
|
||||||
|
"""
|
||||||
|
const ed = document.getElementById('preset-editor-modal');
|
||||||
|
if (!ed) { return [0, 0]; }
|
||||||
|
ed.classList.add('active');
|
||||||
|
const m = document.createElement('div');
|
||||||
|
m.className = 'modal active modal-child-overlay';
|
||||||
|
m.innerHTML = '<div class="modal-content"><p>stack probe</p></div>';
|
||||||
|
document.body.appendChild(m);
|
||||||
|
const ze = parseInt(getComputedStyle(ed).zIndex, 10) || 0;
|
||||||
|
const zc = parseInt(getComputedStyle(m).zIndex, 10) || 0;
|
||||||
|
m.remove();
|
||||||
|
ed.classList.remove('active');
|
||||||
|
return [ze, zc];
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(f" preset-editor z-index={z_editor} modal-child-overlay z-index={z_child}")
|
||||||
|
if z_child > z_editor >= 1000:
|
||||||
|
print("✓ Child overlay stacks above preset editor")
|
||||||
|
return True
|
||||||
|
print("✗ Child overlay did not stack above preset editor")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ z-index probe failed: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
browser.teardown()
|
||||||
|
|
||||||
|
|
||||||
def test_color_palette_ui(browser: BrowserTest) -> bool:
|
def test_color_palette_ui(browser: BrowserTest) -> bool:
|
||||||
"""Test color palette UI in browser."""
|
"""Test color palette UI in browser."""
|
||||||
print("\n=== Testing Color Palette UI in Browser ===")
|
print("\n=== Testing Color Palette UI in Browser ===")
|
||||||
@@ -1105,6 +1145,9 @@ def main():
|
|||||||
results.append(("Zones UI", test_zones_ui(browser)))
|
results.append(("Zones UI", test_zones_ui(browser)))
|
||||||
results.append(("Profiles UI", test_profiles_ui(browser)))
|
results.append(("Profiles UI", test_profiles_ui(browser)))
|
||||||
results.append(("Presets UI", test_presets_ui(browser)))
|
results.append(("Presets UI", test_presets_ui(browser)))
|
||||||
|
results.append(
|
||||||
|
("Preset palette child z-index", test_preset_editor_palette_child_modal_zindex(browser))
|
||||||
|
)
|
||||||
results.append(("Color Palette UI", test_color_palette_ui(browser)))
|
results.append(("Color Palette UI", test_color_palette_ui(browser)))
|
||||||
results.append(("Preset Drag and Drop", test_preset_drag_and_drop(browser)))
|
results.append(("Preset Drag and Drop", test_preset_drag_and_drop(browser)))
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) ->
|
|||||||
raise AssertionError(f"Could not find id for {field}={value!r}")
|
raise AssertionError(f"Could not find id for {field}={value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
|
||||||
|
"""Sequences/scenes/presets need an active profile in session."""
|
||||||
|
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
|
||||||
|
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
profile_id = next(iter(resp.json().keys()))
|
||||||
|
resp = c.post(f"{base_url}/profiles/{profile_id}/apply")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return str(profile_id)
|
||||||
|
|
||||||
|
|
||||||
def _start_microdot_server(app: Microdot, host: str, port: int):
|
def _start_microdot_server(app: Microdot, host: str, port: int):
|
||||||
"""
|
"""
|
||||||
Start Microdot server on a background thread.
|
Start Microdot server on a background thread.
|
||||||
@@ -123,7 +134,7 @@ def server(monkeypatch, tmp_path_factory):
|
|||||||
import models.pallet as models_pallet # noqa: E402
|
import models.pallet as models_pallet # noqa: E402
|
||||||
import models.scene as models_scene # noqa: E402
|
import models.scene as models_scene # noqa: E402
|
||||||
import models.pattern as models_pattern # noqa: E402
|
import models.pattern as models_pattern # noqa: E402
|
||||||
import models.squence as models_sequence # noqa: E402
|
import models.sequence as models_sequence # noqa: E402
|
||||||
import models.device as models_device # noqa: E402
|
import models.device as models_device # noqa: E402
|
||||||
|
|
||||||
for cls in (
|
for cls in (
|
||||||
@@ -341,19 +352,27 @@ def test_settings_controller(server):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 11})
|
resp = c.put(f"{base_url}/settings", json={"wifi_channel": 11})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
|
resp = c.put(f"{base_url}/settings", json={"wifi_channel": 12})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 42})
|
resp = c.put(f"{base_url}/settings", json={"global_brightness": 42})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
resp = c.get(f"{base_url}/settings")
|
resp = c.get(f"{base_url}/settings")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json().get("global_brightness") == 42
|
assert resp.json().get("global_brightness") == 42
|
||||||
|
|
||||||
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 300})
|
resp = c.put(
|
||||||
|
f"{base_url}/settings",
|
||||||
|
json={"sequence_switch_wait": "downbeat"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = c.get(f"{base_url}/settings")
|
||||||
|
assert resp.json().get("sequence_switch_wait") == "downbeat"
|
||||||
|
|
||||||
|
resp = c.put(f"{base_url}/settings", json={"global_brightness": 300})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
@@ -474,6 +493,36 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
|||||||
resp = c.delete(f"{base_url}/zones/{zone_id}")
|
resp = c.delete(f"{base_url}/zones/{zone_id}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = c.get(f"{base_url}/profiles/{profile_id}/export")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
bundle = resp.json()
|
||||||
|
assert bundle.get("kind") == "profile"
|
||||||
|
assert isinstance(bundle.get("presets"), dict)
|
||||||
|
|
||||||
|
import_name = f"pytest-imported-{uuid.uuid4().hex[:8]}"
|
||||||
|
resp = c.post(
|
||||||
|
f"{base_url}/profiles/import",
|
||||||
|
json={"bundle": bundle, "name": import_name, "apply": False},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
imported_profile_id = resp.json().get("id") or next(
|
||||||
|
k for k in resp.json().keys() if k != "id"
|
||||||
|
)
|
||||||
|
resp = c.delete(f"{base_url}/profiles/{imported_profile_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = c.get(f"{base_url}/presets/{first_preset_id}/export")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json().get("kind") == "preset"
|
||||||
|
resp = c.post(
|
||||||
|
f"{base_url}/presets/import",
|
||||||
|
json={"bundle": resp.json()},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
imported_preset_id = next(iter(resp.json().keys()))
|
||||||
|
resp = c.delete(f"{base_url}/presets/{imported_preset_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
# Profile clone + update endpoints.
|
# Profile clone + update endpoints.
|
||||||
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
|
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
|
||||||
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
|
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
|
||||||
@@ -508,6 +557,8 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
|||||||
base_url: str = server["base_url"]
|
base_url: str = server["base_url"]
|
||||||
sender: DummySender = server["sender"]
|
sender: DummySender = server["sender"]
|
||||||
|
|
||||||
|
_create_and_apply_profile(c, base_url)
|
||||||
|
|
||||||
# Groups.
|
# Groups.
|
||||||
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
|
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
|
||||||
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
|
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
|
||||||
@@ -527,21 +578,24 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
# Sequences.
|
# Sequences.
|
||||||
unique_seq_group_name = f"pytest-seq-group-{uuid.uuid4().hex[:8]}"
|
unique_seq_name = f"pytest-seq-{uuid.uuid4().hex[:8]}"
|
||||||
resp = c.post(
|
resp = c.post(
|
||||||
f"{base_url}/sequences",
|
f"{base_url}/sequences",
|
||||||
json={"group_name": unique_seq_group_name, "presets": []},
|
json={
|
||||||
|
"name": unique_seq_name,
|
||||||
|
"steps": [{"preset_id": "1", "group_ids": []}],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
sequences_list = c.get(f"{base_url}/sequences").json()
|
sequences_list = c.get(f"{base_url}/sequences").json()
|
||||||
seq_id = _find_id_by_field(sequences_list, "group_name", unique_seq_group_name)
|
seq_id = _find_id_by_field(sequences_list, "name", unique_seq_name)
|
||||||
|
|
||||||
resp = c.get(f"{base_url}/sequences/{seq_id}")
|
resp = c.get(f"{base_url}/sequences/{seq_id}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
resp = c.put(f"{base_url}/sequences/{seq_id}", json={"sequence_duration": 1234})
|
resp = c.put(f"{base_url}/sequences/{seq_id}", json={"step_duration_ms": 1234})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["sequence_duration"] == 1234
|
assert resp.json()["step_duration_ms"] == 1234
|
||||||
|
|
||||||
resp = c.delete(f"{base_url}/sequences/{seq_id}")
|
resp = c.delete(f"{base_url}/sequences/{seq_id}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -712,6 +766,13 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
definitions = resp.json()
|
definitions = resp.json()
|
||||||
assert isinstance(definitions, dict)
|
assert isinstance(definitions, dict)
|
||||||
|
assert "colour_cycle" in definitions
|
||||||
|
cc_mode = definitions["colour_cycle"].get("mode")
|
||||||
|
assert isinstance(cc_mode, dict)
|
||||||
|
assert "0" in cc_mode and "1" in cc_mode
|
||||||
|
assert "blink" in definitions
|
||||||
|
blink_mode = definitions["blink"].get("mode")
|
||||||
|
assert not isinstance(blink_mode, dict) or len(blink_mode) < 2
|
||||||
|
|
||||||
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
|
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
|
||||||
resp = c.post(
|
resp = c.post(
|
||||||
|
|||||||
36
tests/test_pattern_direction.py
Normal file
36
tests/test_pattern_direction.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""LED strip reverse (n5) mapping for upside-down installs."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
DRIVER_SRC = os.path.join(PROJECT_ROOT, "led-driver", "src")
|
||||||
|
if DRIVER_SRC not in sys.path:
|
||||||
|
sys.path.insert(0, DRIVER_SRC)
|
||||||
|
|
||||||
|
from patterns.pattern_direction import is_reversed, led_i, signed # noqa: E402
|
||||||
|
from preset import Preset # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDriver:
|
||||||
|
num_leds = 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_reverse_sets_n5():
|
||||||
|
p = Preset({"p": "chase", "reverse": True})
|
||||||
|
assert p.n5 == 1
|
||||||
|
assert is_reversed(p) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_led_i_mirrors_index():
|
||||||
|
drv = _FakeDriver()
|
||||||
|
p = Preset({"p": "chase", "n5": 1})
|
||||||
|
assert led_i(drv, p, 0) == 9
|
||||||
|
assert led_i(drv, p, 9) == 0
|
||||||
|
assert led_i(drv, p, 3) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_negates_when_reversed():
|
||||||
|
p = Preset({"p": "chase", "n5": 1})
|
||||||
|
assert signed(p, 4) == -4
|
||||||
|
assert signed(Preset({"p": "chase", "n5": 0}), 4) == 4
|
||||||
51
tests/test_preset_wire_mode.py
Normal file
51
tests/test_preset_wire_mode.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Preset style mode: ``mode`` field, wire ``n6``, and pattern.json metadata."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "led-driver" / "src"))
|
||||||
|
|
||||||
|
from patterns.pattern_modes import style_mode # noqa: E402
|
||||||
|
from preset import Preset # noqa: E402
|
||||||
|
from util.espnow_message import build_preset_dict, wire_n6 # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_wire_n6_prefers_mode_over_n6():
|
||||||
|
assert wire_n6({"mode": 2, "n6": 0}) == 2
|
||||||
|
assert wire_n6({"n6": 1}) == 1
|
||||||
|
assert wire_n6({}) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_preset_dict_maps_mode_to_n6():
|
||||||
|
wire = build_preset_dict({"pattern": "meteor", "mode": 2, "colors": ["#ffffff"]})
|
||||||
|
assert wire["n6"] == 2
|
||||||
|
assert wire["p"] == "meteor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_edit_accepts_mode_alias():
|
||||||
|
p = Preset({"p": "colour_cycle", "mode": 1, "d": 100, "c": [(255, 255, 255)]})
|
||||||
|
assert p.n6 == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_mode_reads_mode_and_legacy_pattern_id():
|
||||||
|
p = Preset({"p": "colour_cycle", "mode": 0, "d": 100, "c": [(255, 0, 0)]})
|
||||||
|
assert style_mode(p, 0, {"rainbow": 1}) == 0
|
||||||
|
|
||||||
|
legacy = Preset({"p": "rainbow", "d": 100, "c": [(255, 0, 0)]})
|
||||||
|
assert style_mode(legacy, 0, {"rainbow": 1}) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_json_defines_mode_for_merged_patterns():
|
||||||
|
path = PROJECT_ROOT / "db" / "pattern.json"
|
||||||
|
definitions = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
for name in ("colour_cycle", "chase", "aurora", "meteor", "particles", "sparkle"):
|
||||||
|
assert name in definitions, name
|
||||||
|
mode = definitions[name].get("mode")
|
||||||
|
assert isinstance(mode, dict), name
|
||||||
|
assert len(mode) >= 2, name
|
||||||
|
|
||||||
|
blink = definitions.get("blink", {})
|
||||||
|
assert "mode" not in blink or not isinstance(blink.get("mode"), dict) or len(blink.get("mode", {})) < 2
|
||||||
133
tests/test_profile_bundle.py
Normal file
133
tests/test_profile_bundle.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""Unit tests for profile/preset/sequence bundle import/export."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||||
|
|
||||||
|
from models.pallet import Palette # noqa: E402
|
||||||
|
from models.preset import Preset # noqa: E402
|
||||||
|
from models.profile import Profile # noqa: E402
|
||||||
|
from models.sequence import Sequence # noqa: E402
|
||||||
|
from models.zone import Zone # noqa: E402
|
||||||
|
from util.profile_bundle import ( # noqa: E402
|
||||||
|
export_preset_bundle,
|
||||||
|
export_profile_bundle,
|
||||||
|
export_sequence_bundle,
|
||||||
|
import_preset_bundle,
|
||||||
|
import_profile_bundle,
|
||||||
|
import_sequence_bundle,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_models(tmp_path, monkeypatch):
|
||||||
|
import models.model as model_mod
|
||||||
|
|
||||||
|
db = tmp_path / "db"
|
||||||
|
db.mkdir()
|
||||||
|
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(db))
|
||||||
|
|
||||||
|
for cls in (Profile, Zone, Preset, Sequence, Palette):
|
||||||
|
if hasattr(cls, "_instance"):
|
||||||
|
delattr(cls, "_instance")
|
||||||
|
|
||||||
|
profiles = Profile()
|
||||||
|
zones = Zone()
|
||||||
|
presets = Preset()
|
||||||
|
sequences = Sequence()
|
||||||
|
palette = Palette()
|
||||||
|
return profiles, zones, presets, sequences, palette
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_export_import_round_trip(tmp_path, monkeypatch):
|
||||||
|
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
pid = profiles.create("Source")
|
||||||
|
zid = zones.create(name="main")
|
||||||
|
preset_id = presets.create(pid)
|
||||||
|
presets.update(
|
||||||
|
preset_id,
|
||||||
|
{
|
||||||
|
"name": "Test preset",
|
||||||
|
"pattern": "blink",
|
||||||
|
"colors": ["#ff0000"],
|
||||||
|
"brightness": 200,
|
||||||
|
"delay": 50,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
zones.update(
|
||||||
|
zid,
|
||||||
|
{
|
||||||
|
"presets_flat": [str(preset_id)],
|
||||||
|
"default_preset": str(preset_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
seq_id = sequences.create(pid)
|
||||||
|
sequences.update(
|
||||||
|
seq_id,
|
||||||
|
{
|
||||||
|
"name": "Beat seq",
|
||||||
|
"lanes": [[{"preset_id": str(preset_id), "group_ids": [], "beats": 2}]],
|
||||||
|
"lanes_group_ids": [[]],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
zones.update(zid, {"sequence_ids": [str(seq_id)]})
|
||||||
|
profiles.update(pid, {"zones": [str(zid)]})
|
||||||
|
palette_id = profiles.read(pid)["palette_id"]
|
||||||
|
palette.update(palette_id, {"colors": ["#112233", "#445566"]})
|
||||||
|
|
||||||
|
bundle = export_profile_bundle(
|
||||||
|
str(pid), profiles, zones, presets, sequences, palette
|
||||||
|
)
|
||||||
|
assert bundle["kind"] == "profile"
|
||||||
|
assert str(preset_id) in bundle["presets"]
|
||||||
|
assert str(seq_id) in bundle["sequences"]
|
||||||
|
assert bundle["palette"]["colors"] == ["#112233", "#445566"]
|
||||||
|
|
||||||
|
new_pid, _ = import_profile_bundle(
|
||||||
|
bundle, profiles, zones, presets, sequences, palette, name="Imported"
|
||||||
|
)
|
||||||
|
assert new_pid != str(pid)
|
||||||
|
found = [
|
||||||
|
presets.read(k)
|
||||||
|
for k in presets.list()
|
||||||
|
if isinstance(presets.read(k), dict)
|
||||||
|
and str(presets.read(k).get("profile_id")) == str(new_pid)
|
||||||
|
and presets.read(k).get("name") == "Test preset"
|
||||||
|
]
|
||||||
|
assert found
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_export_import(tmp_path, monkeypatch):
|
||||||
|
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
|
||||||
|
pid = profiles.create("P")
|
||||||
|
preset_id = presets.create(pid)
|
||||||
|
presets.update(preset_id, {"name": "Solo", "pattern": "on", "colors": ["#00ff00"]})
|
||||||
|
|
||||||
|
bundle = export_preset_bundle(str(preset_id), presets)
|
||||||
|
assert bundle["kind"] == "preset"
|
||||||
|
|
||||||
|
new_id, data = import_preset_bundle(bundle, presets, str(pid))
|
||||||
|
assert new_id != str(preset_id)
|
||||||
|
assert data["name"] == "Solo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sequence_export_import_with_presets(tmp_path, monkeypatch):
|
||||||
|
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
|
||||||
|
pid = profiles.create("P")
|
||||||
|
preset_id = presets.create(pid)
|
||||||
|
presets.update(preset_id, {"name": "Step", "pattern": "off"})
|
||||||
|
seq_id = sequences.create(pid)
|
||||||
|
sequences.update(
|
||||||
|
seq_id,
|
||||||
|
{"name": "S", "lanes": [[{"preset_id": str(preset_id), "beats": 1}]]},
|
||||||
|
)
|
||||||
|
|
||||||
|
bundle = export_sequence_bundle(str(seq_id), sequences, presets, profile_id=str(pid))
|
||||||
|
assert str(preset_id) in bundle["presets"]
|
||||||
|
|
||||||
|
new_seq_id, doc = import_sequence_bundle(bundle, sequences, presets, str(pid))
|
||||||
|
assert new_seq_id != str(seq_id)
|
||||||
|
assert doc["lanes"][0][0]["preset_id"] != str(preset_id)
|
||||||
43
tests/test_sequence_beat_phase_sync.py
Normal file
43
tests/test_sequence_beat_phase_sync.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Sequence beat phase alignment (sync to musical downbeat)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
|
||||||
|
if SRC_PATH not in sys.path:
|
||||||
|
sys.path.insert(0, SRC_PATH)
|
||||||
|
|
||||||
|
from util.sequence_playback import apply_beat_phase_sync # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(lane_states):
|
||||||
|
return {"lane_states": lane_states, "sequence_loop_beat": 5}
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_beat_phase_sync_step_resets_beat_count_only():
|
||||||
|
ctx = _ctx(
|
||||||
|
[
|
||||||
|
{"stepIdx": 2, "beatCount": 3, "done": False},
|
||||||
|
{"stepIdx": 1, "beatCount": 1, "done": True},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ok, resend = apply_beat_phase_sync(ctx, "step")
|
||||||
|
assert ok is True
|
||||||
|
assert resend is False
|
||||||
|
assert ctx["lane_states"][0]["stepIdx"] == 2
|
||||||
|
assert ctx["lane_states"][0]["beatCount"] == 0
|
||||||
|
assert ctx["lane_states"][1]["beatCount"] == 1
|
||||||
|
assert ctx["sequence_loop_beat"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_beat_phase_sync_pass_restarts_pass():
|
||||||
|
ctx = _ctx([{"stepIdx": 2, "beatCount": 3, "done": False}])
|
||||||
|
ok, resend = apply_beat_phase_sync(ctx, "pass")
|
||||||
|
assert ok is True
|
||||||
|
assert resend is True
|
||||||
|
st = ctx["lane_states"][0]
|
||||||
|
assert st["stepIdx"] == 0
|
||||||
|
assert st["beatCount"] == 0
|
||||||
|
assert st["done"] is False
|
||||||
|
assert ctx["sequence_loop_beat"] == 0
|
||||||
88
tests/test_sequence_pending_start.py
Normal file
88
tests/test_sequence_pending_start.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Deferred sequence start on beat / downbeat."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
|
||||||
|
if SRC_PATH not in sys.path:
|
||||||
|
sys.path.insert(0, SRC_PATH)
|
||||||
|
|
||||||
|
from util import sequence_playback as sp # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_wait_for():
|
||||||
|
assert sp._normalize_wait_for({"wait_for": "beat"}) == "beat"
|
||||||
|
assert sp._normalize_wait_for({"start_on": "downbeat"}) == "downbeat"
|
||||||
|
assert sp._normalize_wait_for({"wait_for": "next_beat"}) == "beat"
|
||||||
|
assert sp._normalize_wait_for({}) is None
|
||||||
|
assert sp._play_options_without_wait({"wait_for": "beat", "zone_id": "1"}) == {"zone_id": "1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_play_status_empty():
|
||||||
|
sp.clear_pending_play()
|
||||||
|
assert sp.pending_play_status() == {"pending": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_and_clear_pending():
|
||||||
|
sp.clear_pending_play()
|
||||||
|
sp._queue_pending_start("z1", "s1", "p1", {"simulated_bpm": 120}, "beat", bpm=120.0)
|
||||||
|
st = sp.pending_play_status()
|
||||||
|
assert st["pending"] is True
|
||||||
|
assert st["wait_for"] == "beat"
|
||||||
|
assert st["sequence_id"] == "s1"
|
||||||
|
sp.clear_pending_play()
|
||||||
|
assert sp.pending_play_status()["pending"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_consume_pending_beat():
|
||||||
|
sp.clear_pending_play()
|
||||||
|
sp._queue_pending_start("z1", "s1", "p1", None, "beat", bpm=120.0)
|
||||||
|
|
||||||
|
async def fake_start(*_a, **_k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
sp._start_immediate = fake_start # type: ignore[method-assign]
|
||||||
|
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is True
|
||||||
|
assert sp.pending_play_status()["pending"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_consume_pending_downbeat_skips_upbeat():
|
||||||
|
sp.clear_pending_play()
|
||||||
|
sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0)
|
||||||
|
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is False
|
||||||
|
assert sp.pending_play_status()["pending"] is True
|
||||||
|
|
||||||
|
async def fake_start(*_a, **_k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
sp._start_immediate = fake_start # type: ignore[method-assign]
|
||||||
|
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=True)) is True
|
||||||
|
sp.clear_pending_play()
|
||||||
|
|
||||||
|
|
||||||
|
def test_downbeat_start_counts_trigger_beat(monkeypatch):
|
||||||
|
"""The downbeat that starts playback is beat 1 of the step, not beat 0."""
|
||||||
|
sp.clear_pending_play()
|
||||||
|
sp.stop()
|
||||||
|
|
||||||
|
async def fake_start(_z, _s, _p, _opts):
|
||||||
|
sp._beat_run = {
|
||||||
|
"lanes": [[{"preset_id": "1", "beats": 4}]],
|
||||||
|
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
|
||||||
|
"num_lanes": 1,
|
||||||
|
"sequence_loop_beat": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(sp, "_start_immediate", fake_start)
|
||||||
|
sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
assert await sp._try_consume_pending_play(is_downbeat=True) is True
|
||||||
|
await sp.process_active_beat_advance()
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert sp._beat_run["lane_states"][0]["beatCount"] == 1
|
||||||
|
sp.stop()
|
||||||
|
|
||||||
30
tests/test_sequence_playback_loop.py
Normal file
30
tests/test_sequence_playback_loop.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Sequence playback loop flag coercion."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
|
||||||
|
if SRC_PATH not in sys.path:
|
||||||
|
sys.path.insert(0, SRC_PATH)
|
||||||
|
|
||||||
|
from util.sequence_playback import ( # noqa: E402
|
||||||
|
_coerce_loop,
|
||||||
|
_ordered_unique_preset_ids_in_lane,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_coerce_loop():
|
||||||
|
assert _coerce_loop({"loop": True}) is True
|
||||||
|
assert _coerce_loop({"loop": False}) is False
|
||||||
|
assert _coerce_loop({"sequence_loop": 0}) is False
|
||||||
|
assert _coerce_loop({}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordered_unique_preset_ids_in_lane():
|
||||||
|
lane = [
|
||||||
|
{"preset_id": "6", "beats": 1},
|
||||||
|
{"preset_id": "4", "beats": 2},
|
||||||
|
{"preset_id": "6", "beats": 1},
|
||||||
|
]
|
||||||
|
assert _ordered_unique_preset_ids_in_lane(lane) == ["6", "4"]
|
||||||
22
tests/test_ui_settings.py
Normal file
22
tests/test_ui_settings.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""Server-owned UI settings (no browser localStorage)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
|
||||||
|
if SRC_PATH not in sys.path:
|
||||||
|
sys.path.insert(0, SRC_PATH)
|
||||||
|
|
||||||
|
from util.audio_run_persist import read_audio_run_state # noqa: E402
|
||||||
|
from util.sequence_playback import _sequence_switch_wait_from_settings # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_run_state_includes_device_form_fields():
|
||||||
|
st = read_audio_run_state()
|
||||||
|
assert "device_override" in st
|
||||||
|
assert "device_select" in st
|
||||||
|
|
||||||
|
|
||||||
|
def test_sequence_switch_wait_from_settings():
|
||||||
|
assert _sequence_switch_wait_from_settings() in ("beat", "downbeat")
|
||||||
221
tools/generate_beat_test_track.py
Normal file
221
tools/generate_beat_test_track.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Metronome-style mono click track for testing the audio beat detector.
|
||||||
|
|
||||||
|
Without ``-o``: streams S16LE PCM to ``aplay`` (stdin) until you press Ctrl+C.
|
||||||
|
|
||||||
|
With ``-o``: writes a WAV file of fixed length and exits.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 tools/generate_beat_test_track.py
|
||||||
|
python3 tools/generate_beat_test_track.py --bpm 90
|
||||||
|
python3 tools/generate_beat_test_track.py -o tests/audio/beat_test_120bpm.wav --duration 30
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args() -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__)
|
||||||
|
p.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="If set, write this WAV file and exit (no live playback)",
|
||||||
|
)
|
||||||
|
p.add_argument("--bpm", type=float, default=120.0, help="Beats per minute (default: 120)")
|
||||||
|
p.add_argument(
|
||||||
|
"--duration",
|
||||||
|
type=float,
|
||||||
|
default=30.0,
|
||||||
|
help="With -o only: click section length in seconds after intro (default: 30)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--intro-silence",
|
||||||
|
type=float,
|
||||||
|
default=0.5,
|
||||||
|
help="Leading silence in seconds (default: 0.5)",
|
||||||
|
)
|
||||||
|
p.add_argument("--sample-rate", type=int, default=44100, help="Sample rate Hz (default: 44100)")
|
||||||
|
p.add_argument(
|
||||||
|
"--click-ms",
|
||||||
|
type=float,
|
||||||
|
default=18.0,
|
||||||
|
help="Approximate click length in ms (default: 18)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--freq",
|
||||||
|
type=float,
|
||||||
|
default=1000.0,
|
||||||
|
help="Click sine frequency Hz (default: 1000)",
|
||||||
|
)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _click_int16_samples(sr: int, click_ms: float, freq: float) -> tuple[list[int], int]:
|
||||||
|
"""One click; returns samples and click_len (same as len(samples))."""
|
||||||
|
click_len = max(1, int(sr * max(4.0, click_ms) / 1000.0))
|
||||||
|
freq_clamped = max(200.0, min(4000.0, float(freq)))
|
||||||
|
floats: list[float] = []
|
||||||
|
for i in range(click_len):
|
||||||
|
t = i / sr
|
||||||
|
env = math.sin(0.5 * math.pi * (i + 1) / click_len) ** 2
|
||||||
|
floats.append(env * math.sin(2.0 * math.pi * freq_clamped * t))
|
||||||
|
peak = max(abs(x) for x in floats) or 1.0
|
||||||
|
scale = 0.92 / peak
|
||||||
|
out: list[int] = []
|
||||||
|
for x in floats:
|
||||||
|
v = int(round(max(-1.0, min(1.0, x * scale)) * 32767.0))
|
||||||
|
out.append(max(-32767, min(32767, v)))
|
||||||
|
return out, click_len
|
||||||
|
|
||||||
|
|
||||||
|
def _render_scaled_samples(
|
||||||
|
sr: int,
|
||||||
|
bpm: float,
|
||||||
|
intro: float,
|
||||||
|
dur: float,
|
||||||
|
click_ms: float,
|
||||||
|
freq: float,
|
||||||
|
) -> tuple[list[float], int, float, int]:
|
||||||
|
beat_sec = 60.0 / bpm
|
||||||
|
click_len = max(1, int(sr * max(4.0, click_ms) / 1000.0))
|
||||||
|
freq_clamped = max(200.0, min(4000.0, float(freq)))
|
||||||
|
total_sec = intro + dur
|
||||||
|
n_samples = int(sr * total_sec)
|
||||||
|
intro_samples = int(sr * intro)
|
||||||
|
|
||||||
|
samples = [0.0] * n_samples
|
||||||
|
beat_samples = int(round(sr * beat_sec))
|
||||||
|
if beat_samples < click_len + 1:
|
||||||
|
raise SystemExit("BPM too high for this sample rate / click length")
|
||||||
|
|
||||||
|
beat_idx = 0
|
||||||
|
while True:
|
||||||
|
start = intro_samples + beat_idx * beat_samples
|
||||||
|
if start >= n_samples:
|
||||||
|
break
|
||||||
|
for i in range(click_len):
|
||||||
|
pos = start + i
|
||||||
|
if pos >= n_samples:
|
||||||
|
break
|
||||||
|
t = i / sr
|
||||||
|
env = math.sin(0.5 * math.pi * (i + 1) / click_len) ** 2
|
||||||
|
s = env * math.sin(2.0 * math.pi * freq_clamped * t)
|
||||||
|
samples[pos] += s
|
||||||
|
beat_idx += 1
|
||||||
|
|
||||||
|
peak = max(abs(x) for x in samples) or 1.0
|
||||||
|
scale = 0.92 / peak
|
||||||
|
for i in range(n_samples):
|
||||||
|
samples[i] = max(-1.0, min(1.0, samples[i] * scale))
|
||||||
|
|
||||||
|
return samples, sr, total_sec, beat_idx
|
||||||
|
|
||||||
|
|
||||||
|
def write_wav_mono16(path: Path, samples: list[float], sr: int) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with wave.open(str(path), "w") as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(sr)
|
||||||
|
for x in samples:
|
||||||
|
v = int(round(x * 32767.0))
|
||||||
|
w.writeframes(struct.pack("<h", v))
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_aplay_until_interrupt(
|
||||||
|
sr: int,
|
||||||
|
bpm: float,
|
||||||
|
intro_silence: float,
|
||||||
|
click_ms: float,
|
||||||
|
freq: float,
|
||||||
|
) -> None:
|
||||||
|
aplay = shutil.which("aplay")
|
||||||
|
if not aplay:
|
||||||
|
raise SystemExit("aplay not found; install alsa-utils, or use -o to write a WAV file.")
|
||||||
|
|
||||||
|
click_samps, click_len = _click_int16_samples(sr, click_ms, freq)
|
||||||
|
beat_samples = int(round(sr * 60.0 / bpm))
|
||||||
|
if beat_samples < click_len + 1:
|
||||||
|
raise SystemExit("BPM too high for this sample rate / click length")
|
||||||
|
|
||||||
|
silence_samples = beat_samples - click_len
|
||||||
|
beat_chunk = struct.pack("<" + "h" * len(click_samps), *click_samps) + (
|
||||||
|
b"\x00\x00" * silence_samples
|
||||||
|
)
|
||||||
|
|
||||||
|
intro_samples = int(sr * max(0.0, float(intro_silence)))
|
||||||
|
intro_chunk = b"\x00\x00" * intro_samples
|
||||||
|
|
||||||
|
argv = [aplay, "-q", "-t", "raw", "-f", "S16_LE", "-c", "1", "-r", str(sr)]
|
||||||
|
proc = subprocess.Popen(argv, stdin=subprocess.PIPE)
|
||||||
|
if proc.stdin is None:
|
||||||
|
raise SystemExit("aplay did not open stdin")
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Streaming {bpm} BPM, {sr} Hz mono -> aplay (raw). Ctrl+C to stop.",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if intro_chunk:
|
||||||
|
proc.stdin.write(intro_chunk)
|
||||||
|
beats_written = 0
|
||||||
|
# Write several beats per syscall to reduce overhead
|
||||||
|
batch = 8
|
||||||
|
multi = beat_chunk * batch
|
||||||
|
while True:
|
||||||
|
proc.stdin.write(multi)
|
||||||
|
beats_written += batch
|
||||||
|
if beats_written % 256 == 0:
|
||||||
|
proc.stdin.flush()
|
||||||
|
except BrokenPipeError:
|
||||||
|
print("aplay exited.", flush=True)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped.", flush=True)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
proc.stdin.close()
|
||||||
|
except BrokenPipeError:
|
||||||
|
pass
|
||||||
|
proc.wait(timeout=3)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = _parse_args()
|
||||||
|
sr = max(8000, min(96000, int(args.sample_rate)))
|
||||||
|
bpm = max(40.0, min(240.0, float(args.bpm)))
|
||||||
|
|
||||||
|
if args.output is not None:
|
||||||
|
intro = max(0.0, float(args.intro_silence))
|
||||||
|
dur = max(1.0, float(args.duration))
|
||||||
|
samples, sr_u, total_sec, beats = _render_scaled_samples(
|
||||||
|
sr, bpm, intro, dur, float(args.click_ms), float(args.freq)
|
||||||
|
)
|
||||||
|
write_wav_mono16(args.output, samples, sr_u)
|
||||||
|
print(
|
||||||
|
f"Wrote {args.output} ({len(samples)} samples, {total_sec:.1f}s, {sr_u} Hz mono): "
|
||||||
|
f"{bpm} BPM, ~{beats} beats"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_stream_aplay_until_interrupt(
|
||||||
|
sr,
|
||||||
|
bpm,
|
||||||
|
float(args.intro_silence),
|
||||||
|
float(args.click_ms),
|
||||||
|
float(args.freq),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user