diff --git a/Pipfile b/Pipfile index 052f39c..6908fa0 100644 --- a/Pipfile +++ b/Pipfile @@ -27,7 +27,7 @@ python_version = "3.11" web = "python tests/web.py" watch = "python -m watchfiles \"python tests/web.py\" src tests" 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-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'" diff --git a/db/pattern.json b/db/pattern.json index 6215840..e038d35 100644 --- a/db/pattern.json +++ b/db/pattern.json @@ -104,7 +104,7 @@ "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, - "max_colors": 2, + "max_colors": 10, "has_background": true, "supports_manual": true }, diff --git a/db/preset.json b/db/preset.json index b122b0c..c2b4579 100644 --- a/db/preset.json +++ b/db/preset.json @@ -1 +1 @@ -{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 300, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null]}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FFFF00", "#FF00FF"], "brightness": 255, "delay": 200, "auto": false, "n1": 30, "n2": 30, "n3": 30, "n4": 30, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [3, 4], "manual_beat_n": 1}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#FF00FF"], "brightness": 255, "delay": 1000, "auto": false, "n1": 100, "n2": 0, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [4], "background_color": "#ec0909", "background_palette_ref": null, "manual_beat_n": 1, "background": "#090a00"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": false, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null]}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}, "38": {"name": "Colour Cycle", "pattern": "colour_cycle", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "39": {"name": "flicker", "pattern": "flicker", "colors": ["#ae00ff"], "brightness": 255, "delay": 50, "auto": false, "n1": 100, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "40": {"name": "flame", "pattern": "flame", "colors": ["#ffc800"], "brightness": 128, "delay": 50, "auto": true, "n1": 35, "n2": 2600, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null]}, "41": {"name": "twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null, null], "background": "#000000", "manual_beat_n": 1}, "42": {"name": "radiate", "pattern": "radiate", "colors": ["#a600ff"], "brightness": 255, "delay": 2000, "auto": false, "n1": 60, "n2": 200, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "manual_beat_n": 1, "background": "#0a0a00"}, "43": {"name": "test meteor rain", "pattern": "meteor_rain", "colors": ["#FF5000", "#0080FF"], "brightness": 200, "delay": 40, "auto": true, "n1": 50, "n2": 1, "n3": 200, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "44": {"name": "test scanner", "pattern": "scanner", "colors": ["#FF0000"], "brightness": 255, "delay": 30, "auto": true, "n1": 4, "n2": 2, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "45": {"name": "test gradient scroll", "pattern": "gradient_scroll", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 220, "delay": 60, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "46": {"name": "test comet dual", "pattern": "comet_dual", "colors": ["#FFAA00", "#00AAFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 8, "n2": 1, "n3": 3, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#0b000f", "manual_beat_n": 1}, "47": {"name": "test sparkle trail", "pattern": "sparkle_trail", "colors": ["#88CCFF", "#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 24, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "48": {"name": "test wave", "pattern": "wave", "colors": ["#00B4FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 12, "n2": 180, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "49": {"name": "test plasma", "pattern": "plasma", "colors": ["#FF0066"], "brightness": 200, "delay": 60, "auto": true, "n1": 6, "n2": 2, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "50": {"name": "test segment chase", "pattern": "segment_chase", "colors": ["#FF0000", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 4, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "51": {"name": "test bar graph", "pattern": "bar_graph", "colors": ["#00FF00", "#102010"], "brightness": 200, "delay": 60, "auto": true, "n1": 60, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "52": {"name": "test breathing dual", "pattern": "breathing_dual", "colors": ["#FF0088", "#00AAFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 128, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "53": {"name": "test strobe burst", "pattern": "strobe_burst", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": false, "n1": 2, "n2": 10, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "54": {"name": "test rain drops", "pattern": "rain_drops", "colors": ["#7cbdfe"], "brightness": 200, "delay": 60, "auto": true, "n1": 32, "n2": 3, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "55": {"name": "test fireflies", "pattern": "fireflies", "colors": ["#FFD060", "#90FF90"], "brightness": 200, "delay": 60, "auto": false, "n1": 6, "n2": 8, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "56": {"name": "test clock sweep", "pattern": "clock_sweep", "colors": ["#FFFFFF", "#202020"], "brightness": 200, "delay": 60, "auto": true, "n1": 1, "n2": 5, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "57": {"name": "test marquee", "pattern": "marquee", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 2, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "58": {"name": "test aurora", "pattern": "aurora", "colors": ["#2CC88C", "#5078FF", "#A050DC"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 40, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "59": {"name": "test snowfall", "pattern": "snowfall", "colors": ["#FFFFFF", "#B0DCFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 20, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "60": {"name": "test heartbeat", "pattern": "heartbeat", "colors": ["#FF2840"], "brightness": 200, "delay": 60, "auto": false, "n1": 200, "n2": 50, "n3": 500, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "61": {"name": "test orbit", "pattern": "orbit", "colors": ["#FFFFFF", "#00B4FF", "#FF0077"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null]}, "62": {"name": "test palette morph", "pattern": "palette_morph", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 1200, "n2": 200, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}} \ No newline at end of file +{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 300, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#000000", "manual_beat_n": 1}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FFFF00", "#FF00FF"], "brightness": 255, "delay": 200, "auto": false, "n1": 30, "n2": 30, "n3": 30, "n4": 30, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [3, 4], "manual_beat_n": 1}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#FF00FF", "#00FF00", "#00FFFF", "#0000FF"], "brightness": 255, "delay": 1000, "auto": false, "n1": 100, "n2": 0, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [4, 1, 5, 2], "background_color": "#ec0909", "background_palette_ref": null, "manual_beat_n": 1, "background": "#090a00"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": false, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null]}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}, "38": {"name": "Colour Cycle", "pattern": "colour_cycle", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "39": {"name": "flicker", "pattern": "flicker", "colors": ["#ae00ff"], "brightness": 255, "delay": 50, "auto": false, "n1": 100, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "40": {"name": "flame", "pattern": "flame", "colors": ["#ffc800"], "brightness": 128, "delay": 50, "auto": true, "n1": 35, "n2": 2600, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null]}, "41": {"name": "twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null, null], "background": "#000000", "manual_beat_n": 1}, "42": {"name": "radiate", "pattern": "radiate", "colors": ["#a600ff"], "brightness": 255, "delay": 2000, "auto": false, "n1": 60, "n2": 200, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "manual_beat_n": 1, "background": "#0a0a00"}, "43": {"name": "test meteor rain", "pattern": "meteor_rain", "colors": ["#FF5000", "#0080FF"], "brightness": 200, "delay": 40, "auto": true, "n1": 50, "n2": 1, "n3": 200, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "44": {"name": "test scanner", "pattern": "scanner", "colors": ["#FF0000"], "brightness": 255, "delay": 30, "auto": true, "n1": 4, "n2": 2, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "45": {"name": "test gradient scroll", "pattern": "gradient_scroll", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 220, "delay": 60, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "46": {"name": "test comet dual", "pattern": "comet_dual", "colors": ["#FFAA00", "#00AAFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 8, "n2": 1, "n3": 3, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#0b000f", "manual_beat_n": 1}, "47": {"name": "test sparkle trail", "pattern": "sparkle_trail", "colors": ["#88CCFF", "#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 24, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "48": {"name": "test wave", "pattern": "wave", "colors": ["#00B4FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 12, "n2": 180, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "49": {"name": "test plasma", "pattern": "plasma", "colors": ["#FF0066"], "brightness": 200, "delay": 60, "auto": true, "n1": 6, "n2": 2, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "50": {"name": "test segment chase", "pattern": "segment_chase", "colors": ["#FF0000", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 4, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "51": {"name": "test bar graph", "pattern": "bar_graph", "colors": ["#00FF00", "#102010"], "brightness": 200, "delay": 60, "auto": true, "n1": 60, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "52": {"name": "test breathing dual", "pattern": "breathing_dual", "colors": ["#FF0088", "#00AAFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 128, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "53": {"name": "test strobe burst", "pattern": "strobe_burst", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": false, "n1": 2, "n2": 10, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "54": {"name": "test rain drops", "pattern": "rain_drops", "colors": ["#7cbdfe"], "brightness": 200, "delay": 60, "auto": true, "n1": 32, "n2": 3, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "55": {"name": "test fireflies", "pattern": "fireflies", "colors": ["#FFD060", "#90FF90"], "brightness": 200, "delay": 60, "auto": false, "n1": 6, "n2": 8, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "56": {"name": "test clock sweep", "pattern": "clock_sweep", "colors": ["#FFFFFF", "#202020"], "brightness": 200, "delay": 60, "auto": true, "n1": 1, "n2": 5, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "57": {"name": "test marquee", "pattern": "marquee", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 2, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "58": {"name": "test aurora", "pattern": "aurora", "colors": ["#2CC88C", "#5078FF", "#A050DC"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 40, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "59": {"name": "test snowfall", "pattern": "snowfall", "colors": ["#FFFFFF", "#B0DCFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 20, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "60": {"name": "test heartbeat", "pattern": "heartbeat", "colors": ["#FF2840"], "brightness": 200, "delay": 60, "auto": false, "n1": 200, "n2": 50, "n3": 500, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "61": {"name": "test orbit", "pattern": "orbit", "colors": ["#FFFFFF", "#00B4FF", "#FF0077"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null]}, "62": {"name": "test palette morph", "pattern": "palette_morph", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 1200, "n2": 200, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "63": {"name": "off", "pattern": "off", "colors": [], "background": "#000000", "brightness": 0, "delay": 0, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "2", "palette_refs": [], "auto": true}} \ No newline at end of file diff --git a/src/main.py b/src/main.py index 076cf0c..9cf9c45 100644 --- a/src/main.py +++ b/src/main.py @@ -2,6 +2,7 @@ import asyncio import errno import json import os +import secrets import signal import socket import threading @@ -38,6 +39,11 @@ _tcp_device_lock = threading.Lock() 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( device_name: str, peer_ip: str, mac, device_type=None ) -> None: @@ -248,9 +254,22 @@ async def main(port=80): app = Microdot() audio_detector = AudioBeatDetector() + 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 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 secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production') @@ -284,11 +303,42 @@ async def main(port=80): tcp_client_registry.set_settings(settings) 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) - @app.route('/') + @app.route("/") def index(request): """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 = '' + if "" in html: + html = html.replace("", tag + "\n", 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) @app.route('/favicon.ico') @@ -319,6 +369,9 @@ async def main(port=80): pass try: audio_detector.start(device=device) + from util.audio_run_persist import write_audio_run_state + + write_audio_run_state(enabled=True, device=device) return {"ok": True, "status": audio_detector.status()} except Exception as e: return {"ok": False, "error": str(e)}, 500 @@ -327,12 +380,47 @@ async def main(port=80): async def audio_stop(request): _ = request 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/status') async def audio_status(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 + return {"status": st} # Static file route @app.route("/static/") diff --git a/src/models/zone.py b/src/models/zone.py index ae53236..bb5beee 100644 --- a/src/models/zone.py +++ b/src/models/zone.py @@ -36,6 +36,9 @@ class Zone(Model): if "group_ids" not in doc: doc["group_ids"] = [] 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 changed: self.save() @@ -48,6 +51,7 @@ class Zone(Model): "name": name, "names": names if names else [], "group_ids": gid_list, + "preset_group_ids": {}, "presets": presets if presets else [], "default_preset": None, "brightness": 255, diff --git a/src/static/audio.js b/src/static/audio.js index 4dc12c3..7986ef8 100644 --- a/src/static/audio.js +++ b/src/static/audio.js @@ -1,8 +1,21 @@ (() => { let pollTimer = null; let lastBeatSeq = 0; + let lastLoggedSequenceBeatFractions = ""; + /** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */ + let prevZoneSequencePlaybackActive = false; + /** + * After sequence playback ends/stops while audio keeps running, keep header # idle until the + * next beat bumps `beat_seq` (avoids the stuck final cumulative value vs sequence readout). + */ + let headerBeatStickyIdleAfterSeq = false; + /** Suppresses duplicate `console.log` when the same `beat_seq` + server `beat_readout` repeats. */ + let lastBeatConsoleKey = ""; + /** @type {Set>} */ + const pendingBeatPhaseTimers = new Set(); const STORAGE_KEY = "led-controller-audio-restore"; + const PHASE_MS_KEY = "led-controller-audio-beat-phase-ms"; const STORAGE_VERSION = 1; function readRestorePrefs() { @@ -48,6 +61,45 @@ return document.getElementById(id); } + /** @param {Record} 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} 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|undefined} */ (status && status.sequence); + const seqBeats = + !!seq && + !!seq.active && + String(seq.advance_mode || "").toLowerCase() === "beats"; + 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) { const node = el("audio-bpm-value"); if (!node) return; @@ -58,11 +110,45 @@ } } - function updateBeatCounter(seq) { - const topNode = el("audio-top-beat-count"); - if (!topNode) return; - const n = Number(seq); - topNode.textContent = Number.isFinite(n) && n >= 0 ? `#${Math.floor(n)}` : "#0"; + /** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */ + function sequencePlaybackActiveFromStatus(status) { + const seq = /** @type {Record|undefined} */ ( + status && status.sequence + ); + return !!(seq && seq.active); + } + + /** Build sequence beat fractions for debug logging (browser console only). */ + function formatSequenceBeatFractionsForLog(status) { + const seq = /** @type {Record|undefined} */ (status && status.sequence); + if (!seq || !seq.active) return null; + if (seq.advance_mode !== "beats") 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) { @@ -91,15 +177,67 @@ } } + 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)); + } + try { + const v = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10); + return Number.isFinite(v) ? Math.min(500, Math.max(0, v)) : 0; + } catch { + return 0; + } + } + + function persistBeatPhaseMs() { + try { + localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs())); + } 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). */ async function stopAudioOnly() { setTopBpmVisible(false); + clearBeatPhaseTimers(); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } lastBeatSeq = 0; - updateBeatCounter(0); + prevZoneSequencePlaybackActive = false; + headerBeatStickyIdleAfterSeq = false; + lastBeatConsoleKey = ""; + updateBeatReadoutDisplays({}); try { await fetch("/api/audio/stop", { method: "POST" }); } catch (e) { @@ -115,7 +253,7 @@ async function pollStatus() { try { - const res = await fetch("/api/audio/status"); + const res = await fetch("/api/audio/status", { cache: "no-store" }); const data = await res.json(); const status = data?.status || {}; if (status.error && String(status.error).trim()) { @@ -123,6 +261,7 @@ if (node) { node.textContent = String(status.error).trim().slice(0, 120); } + updateBeatReadoutDisplays({}); updateBpmDisplay(null); setTopBpmVisible(!!status.running); if (!status.running && pollTimer) { @@ -134,12 +273,46 @@ setTopBpmVisible(!!status.running); updateBpmDisplay(status.bpm); updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence)); - const seq = Number(status.beat_seq || 0); - updateBeatCounter(seq); - if (seq > lastBeatSeq) { - lastBeatSeq = seq; - 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 zoneSeqActive = sequencePlaybackActiveFromStatus(status); + 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) { console.warn("audio status poll failed", e); } @@ -164,7 +337,6 @@ writeRestorePrefs(override, selected); updateBpmDisplay(null); updateHitTypeDisplay("unknown", NaN); - updateBeatCounter(0); pollTimer = setInterval(pollStatus, 250); await pollStatus(); } @@ -252,17 +424,30 @@ }); } + const phaseInp = el("audio-beat-phase-ms"); + if (phaseInp) { + try { + const stored = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10); + if (Number.isFinite(stored)) { + phaseInp.value = String(Math.min(500, Math.max(0, stored))); + } + } catch { + /* ignore */ + } + phaseInp.addEventListener("change", () => persistBeatPhaseMs()); + phaseInp.addEventListener("input", () => persistBeatPhaseMs()); + } } async function resumePollingIfDetectorRunning() { try { - const res = await fetch("/api/audio/status"); + const res = await fetch("/api/audio/status", { cache: "no-store" }); const data = await res.json(); const status = data?.status || {}; if (status.running && !pollTimer) { pollTimer = setInterval(pollStatus, 250); lastBeatSeq = Number(status.beat_seq || 0); - updateBeatCounter(lastBeatSeq); + prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status); await pollStatus(); } } catch (e) { @@ -270,8 +455,11 @@ } } - async function restoreAudioIfNeeded() { - if (pollTimer) return; + /** + * Apply browser-stored device fields only (GET /devices list); does not start detection. + * Beat detector run/stop is server-owned (`db/audio_run.json` + explicit Start/Stop in UI). + */ + async function applySavedAudioDeviceFormOnly() { const prefs = readRestorePrefs(); if (!prefs) return; const ov = el("audio-device-override"); @@ -280,20 +468,14 @@ try { await refreshDevices(); } catch (e) { - console.warn("audio restore refresh devices failed", e); + console.warn("audio device list refresh failed", e); } if (sel && prefs.select) sel.value = prefs.select; - try { - await startAudio(); - } catch (e) { - console.warn("audio auto-restart failed", e); - clearRestorePrefs(); - } } document.addEventListener("DOMContentLoaded", async () => { bind(); await resumePollingIfDetectorRunning(); - await restoreAudioIfNeeded(); + await applySavedAudioDeviceFormOnly(); }); })(); diff --git a/src/static/dev-live-reload.js b/src/static/dev-live-reload.js new file mode 100644 index 0000000..64f3ec6 --- /dev/null +++ b/src/static/dev-live-reload.js @@ -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(); +})(); diff --git a/src/static/presets.js b/src/static/presets.js index e57bc79..b36105e 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -29,6 +29,9 @@ const filterPresetsForCurrentProfile = async (presetsObj) => { }), ); }; +try { + window.filterPresetsForCurrentProfile = filterPresetsForCurrentProfile; +} catch (e) {} const getCurrentProfileData = async () => { try { @@ -154,7 +157,44 @@ function tabDeviceNamesFromSection(section) { : []; } -async function postDriverSequence(sequence, targetMacs, delayS) { +/** Device names for ``presetId`` on the current zone tab (per-preset groups or zone default). */ +async function deviceNamesForPresetOnCurrentZone(presetId) { + const section = document.querySelector('.presets-section[data-zone-id]'); + const fallback = tabDeviceNamesFromSection(section); + if (!section || !presetId) return fallback; + const zm = window.zonesManager; + if (!zm || typeof zm.resolveDeviceNamesForZonePreset !== 'function') return fallback; + const zoneId = section.dataset.zoneId; + try { + const res = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); + if (!res.ok) return fallback; + const zd = await res.json(); + const names = await zm.resolveDeviceNamesForZonePreset(zd, String(presetId)); + return names.length ? names : fallback; + } catch (_) { + return fallback; + } +} + +function formatPresetTargetGroupsLine(zoneDoc, presetId, groupsMap) { + const zm = window.zonesManager; + const gids = + zm && typeof zm.effectiveGroupIdsForZonePreset === 'function' + ? zm.effectiveGroupIdsForZonePreset(zoneDoc, presetId) + : Array.isArray(zoneDoc && zoneDoc.group_ids) + ? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0) + : []; + const parts = (gids || []) + .map((id) => { + const g = groupsMap && groupsMap[id]; + const gn = g && g.name ? String(g.name).trim() : ''; + return gn; + }) + .filter(Boolean); + return parts.length ? parts.join(', ') : ''; +} + +async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) { const body = { sequence, targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined, @@ -1169,7 +1209,7 @@ document.addEventListener('DOMContentLoaded', () => { // Create modal const modal = document.createElement('div'); - modal.className = 'modal active'; + modal.className = 'modal active modal-child-overlay'; modal.id = 'add-preset-to-zone-modal'; modal.innerHTML = `