diff --git a/db/group.json b/db/group.json index 858b5d0..2ca8913 100644 --- a/db/group.json +++ b/db/group.json @@ -1 +1 @@ -{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}} \ No newline at end of file +{"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}} \ No newline at end of file diff --git a/db/pattern.json b/db/pattern.json index 8e7a73e..6215840 100644 --- a/db/pattern.json +++ b/db/pattern.json @@ -96,7 +96,7 @@ "max_delay": 10000, "max_colors": 10, "has_background": true, - "supports_manual": false + "supports_manual": true }, "radiate": { "n1": "Node spacing (LEDs)", diff --git a/db/preset.json b/db/preset.json index 428146b..b122b0c 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": true, "n1": 100, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null]}, "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]}, "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", "#00FF00"], "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, 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"}, "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": true, "n1": 3, "n2": 60, "n3": 400, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "54": {"name": "test rain drops", "pattern": "rain_drops", "colors": ["#90C8FF"], "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"}, "55": {"name": "test fireflies", "pattern": "fireflies", "colors": ["#FFD060", "#90FF90"], "brightness": 200, "delay": 60, "auto": true, "n1": 6, "n2": 8, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "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": true, "n1": 120, "n2": 80, "n3": 500, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "61": {"name": "test orbit", "pattern": "orbit", "colors": ["#FFFFFF", "#00B4FF", "#FF0077"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null]}, "62": {"name": "test palette morph", "pattern": "palette_morph", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 1200, "n2": 200, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}} \ 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]}, "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 diff --git a/led-driver b/led-driver index 170a0e0..2a76837 160000 --- a/led-driver +++ b/led-driver @@ -1 +1 @@ -Subproject commit 170a0e05ab592f8e183213ae9095f17c4252c924 +Subproject commit 2a768376d05573b7865113123a1b7ecc1c602b78 diff --git a/src/controllers/device.py b/src/controllers/device.py index cafc75a..8ba3d0c 100644 --- a/src/controllers/device.py +++ b/src/controllers/device.py @@ -2,10 +2,14 @@ from microdot import Microdot from models.device import ( Device, derive_device_mac, + normalize_mac, validate_device_transport, validate_device_type, ) +from models.group import Group from models.transport import get_current_sender +from settings import Settings +from util.brightness_combine import effective_brightness_for_mac from models.wifi_ws_clients import ( normalize_tcp_peer_ip, send_json_line_to_ip, @@ -52,8 +56,28 @@ def _compact_v1_json(*, presets=None, select=None, save=False): # Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch). IDENTIFY_OFF_DELAY_S = 2.0 + +def _validate_output_brightness(value): + if value is None: + return None + try: + b = int(value) + except (TypeError, ValueError): + raise ValueError("output_brightness must be an integer 0–255") + if b < 0 or b > 255: + raise ValueError("output_brightness must be between 0 and 255") + return b + + +def _brightness_save_message_json(b_val: int) -> str: + b_val = max(0, min(255, int(b_val))) + return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":")) + + controller = Microdot() devices = Device() +_group_registry = Group() +_pi_settings = Settings() def _device_live_connected(dev_dict): @@ -154,6 +178,42 @@ async def list_devices(request): return json.dumps(devices_data), 200, {"Content-Type": "application/json"} +@controller.post("/resolve-brightness") +async def resolve_brightness_batch(request): + """ + POST JSON ``{ \"macs\": [\"..\"], \"zone_brightness\": optional 0–255 }``. + Returns ``{ \"values\": { mac: combined_int } }`` — global × group(s) × device × zone (optional). + """ + try: + data = request.json or {} + except Exception: + data = {} + macs = data.get("macs") + if not isinstance(macs, list): + return json.dumps({"error": "macs must be an array"}), 400, { + "Content-Type": "application/json", + } + zb = None + if isinstance(data, dict) and data.get("zone_brightness") is not None: + try: + zb = _validate_output_brightness(data.get("zone_brightness")) + except ValueError as e: + return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + values = {} + for raw in macs: + m = normalize_mac(str(raw)) + if not m: + continue + values[m] = effective_brightness_for_mac( + _pi_settings, + _group_registry, + devices, + m, + zone_brightness=zb, + ) + return json.dumps({"values": values}), 200, {"Content-Type": "application/json"} + + @controller.get("/") async def get_device(request, id): """Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence).""" @@ -239,7 +299,17 @@ async def update_device(request, id): data["transport"] = validate_device_transport(data.get("transport")) if "zones" in data and isinstance(data["zones"], list): data["zones"] = [str(t) for t in data["zones"]] + if "output_brightness" in data: + data["output_brightness"] = _validate_output_brightness(data.get("output_brightness")) + prev_doc = devices.read(id) if devices.update(id, data): + if prev_doc and "name" in data: + on = str(prev_doc.get("name") or "").strip() + nn = str(data.get("name") or "").strip() + if on and nn and on != nn: + from util.beat_driver_route import remap_beat_route_device_name + + remap_beat_route_device_name(on, nn) return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"} return json.dumps({"error": "Device not found"}), 404, { "Content-Type": "application/json", @@ -320,6 +390,120 @@ async def identify_device(request, id): } +@controller.post("//brightness") +async def push_device_output_brightness(request, id): + """ + Push combined brightness to the driver: global × group(s) × device × optional ``zone_brightness`` + in JSON body — single ``b`` (``v``/``b``/``save``). Wi‑Fi or ESP‑NOW. + """ + dev = devices.read(id) + if not dev: + return json.dumps({"error": "Device not found"}), 404, { + "Content-Type": "application/json", + } + body = request.json or {} + zb = None + if isinstance(body, dict) and body.get("zone_brightness") is not None: + try: + zb = _validate_output_brightness(body.get("zone_brightness")) + except ValueError as e: + return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + b_val = effective_brightness_for_mac( + _pi_settings, + _group_registry, + devices, + id, + zone_brightness=zb, + ) + + msg = _brightness_save_message_json(b_val) + transport = (dev.get("transport") or "espnow").strip().lower() + + if transport == "wifi": + ip = normalize_tcp_peer_ip(str(dev.get("address") or "")) + if not ip: + return json.dumps({"error": "Device has no IP address"}), 400, { + "Content-Type": "application/json", + } + ok = await send_json_line_to_ip(ip, msg) + if not ok: + return json.dumps({"error": "Wi-Fi driver not connected"}), 503, { + "Content-Type": "application/json", + } + else: + sender = get_current_sender() + if not sender: + return json.dumps({"error": "Transport not configured"}), 503, { + "Content-Type": "application/json", + } + try: + await sender.send(msg, addr=id) + except Exception as e: + return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"} + + return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, { + "Content-Type": "application/json", + } + + +@controller.post("//driver-config") +async def push_driver_config(request, id): + """ + Push ``device_config`` to a Wi‑Fi LED driver over WebSocket. + Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off). + """ + dev = devices.read(id) + if not dev: + return json.dumps({"error": "Device not found"}), 404, { + "Content-Type": "application/json", + } + if (dev.get("transport") or "").lower() != "wifi": + return json.dumps({"error": "driver-config is only for Wi-Fi devices"}), 400, { + "Content-Type": "application/json", + } + wifi_ip = str(dev.get("address") or "").strip() + if not wifi_ip: + return json.dumps({"error": "Device has no IP address"}), 400, { + "Content-Type": "application/json", + } + body = request.json or {} + dc = {} + if isinstance(body.get("name"), str) and body["name"].strip(): + dc["name"] = body["name"].strip() + if "num_leds" in body: + try: + n = int(body["num_leds"]) + if 1 <= n <= 2048: + dc["num_leds"] = n + except (TypeError, ValueError): + pass + if isinstance(body.get("color_order"), str): + co = body["color_order"].strip().lower() + if co in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"): + dc["color_order"] = co + if isinstance(body.get("startup_mode"), str): + sm = body["startup_mode"].strip().lower() + if sm in ("default", "last", "off"): + dc["startup_mode"] = sm + if not dc: + return json.dumps( + { + "error": "Provide at least one of name, num_leds, color_order, startup_mode" + } + ), 400, {"Content-Type": "application/json"} + msg = json.dumps( + {"v": "1", "device_config": dc, "save": True}, separators=(",", ":") + ) + 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", + } + return json.dumps({"message": "driver-config sent"}), 200, { + "Content-Type": "application/json", + } + + @controller.post("//patterns/push") async def push_patterns_ota(request, id): """ diff --git a/src/controllers/group.py b/src/controllers/group.py index d033492..835918c 100644 --- a/src/controllers/group.py +++ b/src/controllers/group.py @@ -1,9 +1,16 @@ from microdot import Microdot from models.group import Group +from models.device import Device +from models.transport import get_current_sender +from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip +from settings import Settings +from util.brightness_combine import effective_brightness_for_mac import json controller = Microdot() groups = Group() +devices = Device() +_pi_settings = Settings() @controller.get('') async def list_groups(request): @@ -48,3 +55,150 @@ async def delete_group(request, id): if groups.delete(id): return json.dumps({"message": "Group deleted successfully"}), 200 return json.dumps({"error": "Group not found"}), 404 + + +def _group_driver_config_payload(doc): + """Build ``device_config`` dict from stored group Wi‑Fi defaults (non-empty only).""" + dc = {} + if not isinstance(doc, dict): + return dc + nm = doc.get("wifi_driver_display_name") + if isinstance(nm, str) and nm.strip(): + dc["name"] = nm.strip() + nled = doc.get("wifi_driver_num_leds") + if nled is not None: + try: + n = int(nled) + if 1 <= n <= 2048: + dc["num_leds"] = n + except (TypeError, ValueError): + pass + co = doc.get("wifi_color_order") + if isinstance(co, str): + c = co.strip().lower() + if c in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"): + dc["color_order"] = c + sm = doc.get("wifi_startup_mode") + if isinstance(sm, str): + s = sm.strip().lower() + if s in ("default", "last", "off"): + dc["startup_mode"] = s + return dc + + +@controller.post('//driver-config') +async def push_group_driver_config(request, id): + """ + 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. + """ + gdoc = groups.read(id) + if not gdoc: + return json.dumps({"error": "Group not found"}), 404 + + body = request.json or {} + merged = dict(gdoc) + if isinstance(body, dict): + for k in ( + "wifi_driver_display_name", + "wifi_driver_num_leds", + "wifi_color_order", + "wifi_startup_mode", + ): + if k in body: + merged[k] = body[k] + dc = _group_driver_config_payload(merged) + if not dc: + return json.dumps( + {"error": "No driver defaults on this group (set display name, LEDs, colour order, or power-on pattern)"} + ), 400, {"Content-Type": "application/json"} + + mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else [] + sent = 0 + errors = [] + 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 + if (dev.get("transport") or "").lower() != "wifi": + continue + ip = normalize_tcp_peer_ip(str(dev.get("address") or "")) + if not ip: + errors.append({"mac": m, "error": "no IP"}) + continue + msg = json.dumps( + {"v": "1", "device_config": dc, "save": True}, separators=(",", ":") + ) + ok = await send_json_line_to_ip(ip, msg) + if ok: + sent += 1 + else: + errors.append({"mac": m, "error": "driver not connected"}) + + return json.dumps( + {"message": "driver-config sent", "sent": sent, "errors": errors} + ), 200, {"Content-Type": "application/json"} + + +def _brightness_save_message_json(b_val: int) -> str: + b_val = max(0, min(255, int(b_val))) + return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":")) + + +@controller.post('//brightness') +async def push_group_output_brightness(request, id): + """ + Push combined brightness (global × group(s) × device) to each member — one ``b`` per device. + """ + gdoc = groups.read(id) + if not gdoc: + return json.dumps({"error": "Group not found"}), 404 + + mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else [] + sent = 0 + errors = [] + 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 + b_val = effective_brightness_for_mac( + _pi_settings, + groups, + devices, + m, + zone_brightness=None, + ) + msg = _brightness_save_message_json(b_val) + transport = (dev.get("transport") or "espnow").strip().lower() + if transport == "wifi": + ip = normalize_tcp_peer_ip(str(dev.get("address") or "")) + if not ip: + errors.append({"mac": m, "error": "no IP"}) + continue + ok = await send_json_line_to_ip(ip, msg) + if ok: + sent += 1 + else: + errors.append({"mac": m, "error": "driver not connected"}) + 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( + {"message": "brightness sent", "sent": sent, "errors": errors} + ), 200, {"Content-Type": "application/json"} diff --git a/src/controllers/preset.py b/src/controllers/preset.py index cf42552..dd31dff 100644 --- a/src/controllers/preset.py +++ b/src/controllers/preset.py @@ -318,7 +318,7 @@ async def push_driver_messages(request, session): try: from util.beat_driver_route import sync_beat_route_from_push_sequence - sync_beat_route_from_push_sequence(seq) + sync_beat_route_from_push_sequence(seq, target_macs=target_list) except Exception: pass diff --git a/src/controllers/zone.py b/src/controllers/zone.py index a85b3d7..418a412 100644 --- a/src/controllers/zone.py +++ b/src/controllers/zone.py @@ -290,6 +290,7 @@ async def create_zone(request, session): ids_str = request.form.get("ids", "1").strip() names = [i.strip() for i in ids_str.split(",") if i.strip()] preset_ids = None + group_ids = [] else: data = request.json or {} name = data.get("name", "") @@ -297,11 +298,18 @@ async def create_zone(request, session): if names is None: names = data.get("ids") preset_ids = data.get("presets", None) + group_ids = data.get("group_ids") + if group_ids is None: + group_ids = [] + if isinstance(group_ids, list): + group_ids = [str(x) for x in group_ids if x is not None] + else: + group_ids = [] if not name: return json.dumps({"error": "Zone name cannot be empty"}), 400 - zid = zones.create(name, names, preset_ids) + zid = zones.create(name, names, preset_ids, group_ids) profile_id = get_current_profile_id(session) if profile_id: @@ -333,7 +341,12 @@ async def clone_zone(request, session, id): data = request.json or {} source_name = source.get("name") or f"Zone {id}" new_name = data.get("name") or f"{source_name} Copy" - clone_id = zones.create(new_name, source.get("names"), source.get("presets")) + clone_id = zones.create( + new_name, + source.get("names"), + source.get("presets"), + source.get("group_ids"), + ) extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")} if extra: zones.update(clone_id, extra) diff --git a/src/models/device.py b/src/models/device.py index 156d16e..81abe14 100644 --- a/src/models/device.py +++ b/src/models/device.py @@ -38,6 +38,29 @@ def normalize_mac(mac): return None +def resolve_device_mac_for_select_routing(devices, name_key): + """ + Map a v1 ``select`` map key to device storage id (MAC). + + Matches the registry **name**, or ``led-<12hex>`` as a MAC hint (default driver + name form) so routing still works after the device is renamed in the registry. + """ + k = str(name_key or "").strip() + if not k: + return None + for did in devices.list(): + doc = devices.read(did) or {} + if str(doc.get("name") or "").strip() == k: + m = normalize_mac(did) + if m: + return m + if k.startswith("led-"): + m = normalize_mac(k[4:]) + if m and devices.read(m): + return m + return None + + def derive_device_mac(mac=None, address=None, transport="espnow"): """ Resolve the device MAC used as storage id. diff --git a/src/models/group.py b/src/models/group.py index 06bec27..b426847 100644 --- a/src/models/group.py +++ b/src/models/group.py @@ -1,14 +1,66 @@ from models.model import Model + class Group(Model): + """Device groups (members + optional Wi‑Fi driver defaults); also pattern fields for sequences.""" + def __init__(self): super().__init__() + def load(self): + super().load() + changed = False + for gid, doc in list(self.items()): + if not isinstance(doc, dict): + continue + if self._migrate_record(doc): + changed = True + if changed: + self.save() + + def _migrate_record(self, doc): + changed = False + raw_dev = doc.get("devices") + if raw_dev is None: + doc["devices"] = [] + changed = True + elif isinstance(raw_dev, list): + norm = [] + for x in raw_dev: + if x is None: + continue + s = str(x).strip().lower().replace(":", "").replace("-", "") + if len(s) == 12 and all(c in "0123456789abcdef" for c in s): + norm.append(s) + else: + norm.append(str(x).strip()) + if norm != raw_dev: + doc["devices"] = norm + changed = True + for key in ( + "wifi_driver_display_name", + "wifi_driver_num_leds", + "wifi_color_order", + "wifi_startup_mode", + ): + if key not in doc: + doc[key] = None + changed = True + if "output_brightness" not in doc: + doc["output_brightness"] = 255 + changed = True + return changed + def create(self, name=""): next_id = self.get_next_id() self[next_id] = { "name": name, "devices": [], + "wifi_driver_display_name": None, + "wifi_driver_num_leds": None, + "wifi_color_order": None, + "wifi_startup_mode": None, + "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, @@ -22,7 +74,7 @@ class Group(Model): "n5": 0, "n6": 0, "n7": 0, - "n8": 0 + "n8": 0, } self.save() return next_id diff --git a/src/models/wifi_ws_clients.py b/src/models/wifi_ws_clients.py index 2516675..629d9d1 100644 --- a/src/models/wifi_ws_clients.py +++ b/src/models/wifi_ws_clients.py @@ -261,33 +261,33 @@ async def _driver_connection_loop(ip: str) -> None: retry_interval_s = 2.0 retry_interval_s = max(0.2, retry_interval_s) try: - retry_window_s = float(_settings.get("wifi_driver_connect_retry_window_s", 120.0)) + max_boot_attempts = int(_settings.get("wifi_driver_initial_connect_attempts", 4)) except (TypeError, ValueError): - retry_window_s = 120.0 - retry_window_s = max(5.0, retry_window_s) + max_boot_attempts = 4 + max_boot_attempts = max(1, max_boot_attempts) try: open_timeout = float(_settings.get("wifi_driver_ws_open_timeout", 45.0)) except (TypeError, ValueError): open_timeout = 45.0 open_timeout = max(5.0, open_timeout) - loop = asyncio.get_running_loop() stagger = _stagger_delay_s_for_ip(ip) if stagger > 0: await asyncio.sleep(stagger) # Only bound boot-time: after we have connected once, keep retrying (Wi-Fi drops, reboots). connected_once = False - deadline = loop.time() + retry_window_s + boot_attempts = 0 try: while True: - now = loop.time() - if not connected_once and now >= deadline: - print( - f"[WS] driver {ip} still unreachable after {int(retry_window_s)}s " - f"(initial window); stopping until next UDP hello / registry prime" - ) - break + 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: print(f"[WS] connecting to {uri!r}") async with websockets.connect( diff --git a/src/models/zone.py b/src/models/zone.py index 14b6547..ae53236 100644 --- a/src/models/zone.py +++ b/src/models/zone.py @@ -27,11 +27,27 @@ class Zone(Model): Zone._migration_checked = True super().__init__() - def create(self, name="", names=None, presets=None): + def load(self): + super().load() + changed = False + for zid, doc in list(self.items()): + if not isinstance(doc, dict): + continue + if "group_ids" not in doc: + doc["group_ids"] = [] + changed = True + if changed: + self.save() + + def create(self, name="", names=None, presets=None, group_ids=None): next_id = self.get_next_id() + gid_list = [] + if isinstance(group_ids, list): + gid_list = [str(x) for x in group_ids if x is not None] self[next_id] = { "name": name, "names": names if names else [], + "group_ids": gid_list, "presets": presets if presets else [], "default_preset": None, "brightness": 255, diff --git a/src/settings.py b/src/settings.py index c28a894..26246c3 100644 --- a/src/settings.py +++ b/src/settings.py @@ -57,8 +57,8 @@ class Settings(dict): # down (0 disables). Helps drivers that reconnect after seeing traffic on 8766. if 'wifi_driver_hello_interval_s' not in self: self['wifi_driver_hello_interval_s'] = 10.0 - # Outbound WebSocket dial: total seconds to keep trying before first success - # (many devices booting at once need more than a short window). + # 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: 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. @@ -70,6 +70,9 @@ class Settings(dict): # Pause between outbound WebSocket dial attempts (seconds). if 'wifi_driver_connect_retry_interval_s' not in self: 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. + if 'wifi_driver_initial_connect_attempts' not in self: + self['wifi_driver_initial_connect_attempts'] = 4 # UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial). if 'serial_enabled' not in self: self['serial_enabled'] = False diff --git a/src/static/audio.js b/src/static/audio.js index e5f6ca6..4dc12c3 100644 --- a/src/static/audio.js +++ b/src/static/audio.js @@ -2,6 +2,48 @@ let pollTimer = null; let lastBeatSeq = 0; + const STORAGE_KEY = "led-controller-audio-restore"; + const STORAGE_VERSION = 1; + + function readRestorePrefs() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const o = JSON.parse(raw); + if (!o || o.v !== STORAGE_VERSION || !o.restore) return null; + return { + override: typeof o.override === "string" ? o.override : "", + 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) { return document.getElementById(id); } @@ -31,19 +73,27 @@ node.textContent = `${label}${conf}`; } + function setTopBpmVisible(on) { + const top = el("audio-top-indicator"); + if (!top) return; + top.classList.toggle("audio-running", !!on); + } + function flashBeat() { const node = el("audio-beat-flash"); if (!node) return; node.classList.add("active"); setTimeout(() => node.classList.remove("active"), 80); const top = el("audio-top-indicator"); - if (top) { + if (top && top.classList.contains("audio-running")) { top.classList.add("flash"); setTimeout(() => top.classList.remove("flash"), 90); } } - async function stopAudio() { + /** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */ + async function stopAudioOnly() { + setTopBpmVisible(false); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; @@ -57,6 +107,12 @@ } } + /** User-initiated stop: also forget auto-restart on next page load. */ + async function stopAudio() { + await stopAudioOnly(); + clearRestorePrefs(); + } + async function pollStatus() { try { const res = await fetch("/api/audio/status"); @@ -68,12 +124,14 @@ node.textContent = String(status.error).trim().slice(0, 120); } updateBpmDisplay(null); + setTopBpmVisible(!!status.running); if (!status.running && pollTimer) { clearInterval(pollTimer); pollTimer = null; } return; } + setTopBpmVisible(!!status.running); updateBpmDisplay(status.bpm); updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence)); const seq = Number(status.beat_seq || 0); @@ -88,7 +146,7 @@ } async function startAudio() { - await stopAudio(); + await stopAudioOnly(); const override = (el("audio-device-override")?.value || "").trim(); const selected = el("audio-device-select")?.value || ""; const rawDevice = override !== "" ? override : selected; @@ -103,6 +161,7 @@ const data = await res.json().catch(() => ({})); throw new Error(data.error || "Failed to start audio detector"); } + writeRestorePrefs(override, selected); updateBpmDisplay(null); updateHitTypeDisplay("unknown", NaN); updateBeatCounter(0); @@ -211,8 +270,30 @@ } } - document.addEventListener("DOMContentLoaded", () => { + async function restoreAudioIfNeeded() { + if (pollTimer) return; + const prefs = readRestorePrefs(); + if (!prefs) return; + const ov = el("audio-device-override"); + const sel = el("audio-device-select"); + if (ov) ov.value = prefs.override || ""; + try { + await refreshDevices(); + } catch (e) { + console.warn("audio restore refresh devices 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(); - resumePollingIfDetectorRunning(); + await resumePollingIfDetectorRunning(); + await restoreAudioIfNeeded(); }); })(); diff --git a/src/static/devices.js b/src/static/devices.js index 3bb5b78..5315b32 100644 --- a/src/static/devices.js +++ b/src/static/devices.js @@ -149,8 +149,10 @@ function applyTransportVisibility(transport) { const isWifi = transport === 'wifi'; const esp = document.getElementById('edit-device-address-espnow'); const wifiWrap = document.getElementById('edit-device-address-wifi-wrap'); + const drvWrap = document.getElementById('edit-device-wifi-driver-wrap'); if (esp) esp.hidden = isWifi; if (wifiWrap) wifiWrap.hidden = !isWifi; + if (drvWrap) drvWrap.hidden = !isWifi; } function getAddressForPayload(transport) { @@ -166,6 +168,63 @@ function getAddressForPayload(transport) { return hex || null; } +function collectDeviceEditPayload() { + const idInput = document.getElementById('edit-device-id'); + const nameInput = document.getElementById('edit-device-name'); + const typeSel = document.getElementById('edit-device-type'); + const transportSel = document.getElementById('edit-device-transport'); + const devId = idInput && idInput.value; + const transport = (transportSel && transportSel.value) || 'espnow'; + const address = getAddressForPayload(transport); + const obr = document.getElementById('edit-device-output-brightness'); + let output_brightness = 255; + if (obr && obr.value !== '') { + const n = parseInt(obr.value, 10); + output_brightness = !Number.isNaN(n) ? Math.max(0, Math.min(255, n)) : 255; + } + const payload = { + name: nameInput ? nameInput.value.trim() : '', + type: (typeSel && typeSel.value) || 'led', + transport, + address, + output_brightness, + }; + if (transport === 'wifi') { + const dn = document.getElementById('edit-device-wifi-driver-name'); + const nl = document.getElementById('edit-device-wifi-num-leds'); + const co = document.getElementById('edit-device-wifi-color-order'); + const ws = document.getElementById('edit-device-wifi-startup-mode'); + if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim(); + if (nl && nl.value !== '') { + const n = parseInt(nl.value, 10); + if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n; + } + if (co && co.value) payload.wifi_color_order = co.value; + if (ws && ws.value) payload.wifi_startup_mode = ws.value; + } + return { devId, payload }; +} + +function refreshEditDeviceDebug() { + const ta = document.getElementById('edit-device-debug'); + if (!ta) return; + try { + const { devId, payload } = collectDeviceEditPayload(); + const loaded = window.__editDeviceLoadedSnapshot; + ta.value = JSON.stringify( + { + device_id: devId || null, + loaded_from_server: loaded != null ? loaded : null, + save_payload_preview: payload, + }, + null, + 2, + ); + } catch (e) { + ta.value = String(e); + } +} + async function loadDevicesModal() { const container = document.getElementById('devices-list-modal'); if (!container) return; @@ -307,6 +366,11 @@ function renderDevicesList(devices) { } function openEditDeviceModal(devId, dev) { + try { + window.__editDeviceLoadedSnapshot = dev ? JSON.parse(JSON.stringify(dev)) : null; + } catch (e) { + window.__editDeviceLoadedSnapshot = dev || null; + } const modal = document.getElementById('edit-device-modal'); const idInput = document.getElementById('edit-device-id'); const storageLabel = document.getElementById('edit-device-storage-id'); @@ -325,20 +389,83 @@ function openEditDeviceModal(devId, dev) { applyTransportVisibility(tr); setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : ''); if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : ''; + const wName = document.getElementById('edit-device-wifi-driver-name'); + const wLeds = document.getElementById('edit-device-wifi-num-leds'); + const wCo = document.getElementById('edit-device-wifi-color-order'); + const wStart = document.getElementById('edit-device-wifi-startup-mode'); + if (wName) { + const savedDisp = + dev && Object.prototype.hasOwnProperty.call(dev, 'wifi_driver_display_name') + ? dev.wifi_driver_display_name + : undefined; + if (savedDisp != null && String(savedDisp).trim() !== '') { + wName.value = String(savedDisp).trim(); + } else { + wName.value = dev && dev.name ? String(dev.name) : ''; + } + } + if (wLeds) { + wLeds.value = + dev && dev.wifi_driver_num_leds != null && dev.wifi_driver_num_leds !== '' + ? String(dev.wifi_driver_num_leds) + : ''; + } + if (wCo) { + const co = (dev && dev.wifi_color_order) || 'rgb'; + wCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase()) + ? String(co).toLowerCase() + : 'rgb'; + } + if (wStart) { + const sm = (dev && dev.wifi_startup_mode) || 'default'; + wStart.value = ['default', 'last', 'off'].includes(String(sm).toLowerCase()) + ? String(sm).toLowerCase() + : 'default'; + } + const obr = document.getElementById('edit-device-output-brightness'); + const obv = document.getElementById('edit-device-output-brightness-value'); + if (obr) { + let bv = 255; + if (dev && dev.output_brightness != null && dev.output_brightness !== '') { + const n = parseInt(String(dev.output_brightness), 10); + if (!Number.isNaN(n)) bv = Math.max(0, Math.min(255, n)); + } + obr.value = String(bv); + if (obv) obv.textContent = String(bv); + } + refreshEditDeviceDebug(); modal.classList.add('active'); } -async function updateDevice(devId, name, type, transport, address) { +async function updateDevice(devId, name, type, transport, address, wifiDriverFields, outputBrightness) { try { + const payload = { + name, + type: type || 'led', + transport: transport || 'espnow', + address, + }; + if (typeof outputBrightness === 'number') { + payload.output_brightness = Math.max(0, Math.min(255, Math.round(outputBrightness))); + } + if (transport === 'wifi' && wifiDriverFields && typeof wifiDriverFields === 'object') { + if (wifiDriverFields.wifi_driver_display_name != null) { + payload.wifi_driver_display_name = wifiDriverFields.wifi_driver_display_name; + } + if (wifiDriverFields.wifi_driver_num_leds != null) { + payload.wifi_driver_num_leds = wifiDriverFields.wifi_driver_num_leds; + } + if (wifiDriverFields.wifi_color_order != null) { + payload.wifi_color_order = wifiDriverFields.wifi_color_order; + } + if (wifiDriverFields.wifi_startup_mode != null) { + payload.wifi_startup_mode = wifiDriverFields.wifi_startup_mode; + } + } const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name, - type: type || 'led', - transport: transport || 'espnow', - address, - }), + body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); if (res.ok) { @@ -354,6 +481,41 @@ async function updateDevice(devId, name, type, transport, address) { } } +async function pushWifiDriverConfig(devId, fields) { + const push = {}; + if (fields.name != null && String(fields.name).trim()) push.name = String(fields.name).trim(); + if (fields.num_leds != null && fields.num_leds !== '') { + const n = parseInt(String(fields.num_leds), 10); + if (!Number.isNaN(n) && n >= 1) push.num_leds = n; + } + if (fields.color_order != null && String(fields.color_order).trim()) { + push.color_order = String(fields.color_order).trim().toLowerCase(); + } + if (fields.startup_mode != null && String(fields.startup_mode).trim()) { + const sm = String(fields.startup_mode).trim().toLowerCase(); + if (sm === 'default' || sm === 'last' || sm === 'off') push.startup_mode = sm; + } + if (Object.keys(push).length === 0) return { ok: true, skipped: true }; + try { + const res = await fetch(`/devices/${encodeURIComponent(devId)}/driver-config`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(push), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || 'Could not send settings to the driver (is it connected?)'); + return { ok: false }; + } + return { ok: true }; + } catch (e) { + console.error('pushWifiDriverConfig:', e); + alert('Could not send settings to the driver'); + return { ok: false }; + } +} + document.addEventListener('DOMContentLoaded', () => { window.addEventListener('deviceTcpStatus', (ev) => { const { ip, connected } = ev.detail || {}; @@ -380,10 +542,19 @@ document.addEventListener('DOMContentLoaded', () => { makeHexAddressBoxes(document.getElementById('edit-device-address-boxes')); + const devOutBr = document.getElementById('edit-device-output-brightness'); + const devOutBrVal = document.getElementById('edit-device-output-brightness-value'); + if (devOutBr && devOutBrVal) { + devOutBr.addEventListener('input', () => { + devOutBrVal.textContent = devOutBr.value; + }); + } + const transportEdit = document.getElementById('edit-device-transport'); if (transportEdit) { transportEdit.addEventListener('change', () => { applyTransportVisibility(transportEdit.value); + refreshEditDeviceDebug(); }); } @@ -420,24 +591,67 @@ document.addEventListener('DOMContentLoaded', () => { } if (editForm) { + editForm.addEventListener('input', () => refreshEditDeviceDebug()); + editForm.addEventListener('change', () => refreshEditDeviceDebug()); editForm.addEventListener('submit', async (e) => { e.preventDefault(); - const idInput = document.getElementById('edit-device-id'); - const nameInput = document.getElementById('edit-device-name'); - const typeSel = document.getElementById('edit-device-type'); - const transportSel = document.getElementById('edit-device-transport'); - const devId = idInput && idInput.value; + const { devId, payload } = collectDeviceEditPayload(); if (!devId) return; - const transport = (transportSel && transportSel.value) || 'espnow'; - const address = getAddressForPayload(transport); + const transport = payload.transport || 'espnow'; + let wifiDriverFields = null; + if (transport === 'wifi') { + wifiDriverFields = {}; + if (payload.wifi_driver_display_name != null) { + wifiDriverFields.wifi_driver_display_name = payload.wifi_driver_display_name; + } + if (payload.wifi_driver_num_leds != null) { + wifiDriverFields.wifi_driver_num_leds = payload.wifi_driver_num_leds; + } + if (payload.wifi_color_order != null) { + wifiDriverFields.wifi_color_order = payload.wifi_color_order; + } + if (payload.wifi_startup_mode != null) { + wifiDriverFields.wifi_startup_mode = payload.wifi_startup_mode; + } + } const ok = await updateDevice( devId, - nameInput ? nameInput.value.trim() : '', - (typeSel && typeSel.value) || 'led', + payload.name, + payload.type, transport, - address + payload.address, + wifiDriverFields, + payload.output_brightness, ); - if (ok) editDeviceModal.classList.remove('active'); + if (!ok) return; + try { + const brRes = await fetch(`/devices/${encodeURIComponent(devId)}/brightness`, { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!brRes.ok && brRes.status !== 503) { + const brData = await brRes.json().catch(() => ({})); + console.warn('brightness push:', brData.error || brRes.status); + } + } catch (e) { + console.warn('brightness push failed', e); + } + if (transport === 'wifi' && wifiDriverFields) { + const dn = document.getElementById('edit-device-wifi-driver-name'); + const nl = document.getElementById('edit-device-wifi-num-leds'); + const co = document.getElementById('edit-device-wifi-color-order'); + const ws = document.getElementById('edit-device-wifi-startup-mode'); + const pushRes = await pushWifiDriverConfig(devId, { + name: dn ? dn.value : '', + num_leds: nl ? nl.value : '', + color_order: co ? co.value : '', + startup_mode: ws ? ws.value : '', + }); + if (!pushRes.ok) return; + } + editDeviceModal.classList.remove('active'); }); } if (editCloseBtn) { diff --git a/src/static/groups.js b/src/static/groups.js new file mode 100644 index 0000000..fc30671 --- /dev/null +++ b/src/static/groups.js @@ -0,0 +1,452 @@ +// Device groups: members (MAC ids) + Wi‑Fi driver defaults; persisted via /groups. + +async function fetchGroupsMap() { + try { + const response = await fetch('/groups', { headers: { Accept: 'application/json' } }); + if (!response.ok) return {}; + const data = await response.json(); + return data && typeof data === 'object' ? data : {}; + } catch (e) { + console.error('fetchGroupsMap:', e); + return {}; + } +} + +async function fetchDevicesMapForGroups() { + try { + const response = await fetch('/devices', { headers: { Accept: 'application/json' } }); + if (!response.ok) return {}; + const data = await response.json(); + return data && typeof data === 'object' ? data : {}; + } catch (e) { + console.error('fetchDevicesMapForGroups:', e); + return {}; + } +} + +function renderGroupDevicesEditor(containerEl, macRows, devicesMap) { + if (!containerEl) return; + containerEl.innerHTML = ''; + const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b)); + + macRows.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.label || row.mac || '—'; + label.appendChild(strong); + label.appendChild(document.createTextNode(' ')); + const sub = document.createElement('span'); + sub.className = 'muted-text'; + sub.textContent = row.mac || ''; + label.appendChild(sub); + + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'btn btn-danger btn-small'; + rm.textContent = 'Remove'; + rm.addEventListener('click', () => { + macRows.splice(idx, 1); + renderGroupDevicesEditor(containerEl, macRows, devicesMap); + }); + div.appendChild(label); + div.appendChild(rm); + containerEl.appendChild(div); + }); + + const macsInRows = new Set(macRows.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); + macRows.push({ mac, label: n }); + sel.value = ''; + renderGroupDevicesEditor(containerEl, macRows, devicesMap); + }); + addWrap.appendChild(sel); + addWrap.appendChild(addBtn); + containerEl.appendChild(addWrap); + refreshEditGroupDebug(); +} + +function collectGroupEditPayload() { + const idInput = document.getElementById('edit-group-id'); + const nameInput = document.getElementById('edit-group-name'); + const gid = idInput && idInput.value; + const rows = window.__editGroupDeviceRows || []; + const devices = rows.map((r) => r.mac).filter(Boolean); + const payload = { + name: nameInput ? nameInput.value.trim() : '', + devices, + }; + const dn = document.getElementById('edit-group-wifi-driver-name'); + const nl = document.getElementById('edit-group-wifi-num-leds'); + const co = document.getElementById('edit-group-wifi-color-order'); + const ws = document.getElementById('edit-group-wifi-startup-mode'); + if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim(); + else payload.wifi_driver_display_name = null; + if (nl && nl.value !== '') { + const n = parseInt(nl.value, 10); + if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n; + else payload.wifi_driver_num_leds = null; + } else payload.wifi_driver_num_leds = null; + if (co && co.value) payload.wifi_color_order = co.value; + if (ws && ws.value) payload.wifi_startup_mode = ws.value; + const gob = document.getElementById('edit-group-output-brightness'); + if (gob && gob.value !== '') { + const nb = parseInt(gob.value, 10); + if (!Number.isNaN(nb)) payload.output_brightness = Math.max(0, Math.min(255, nb)); + } + return { gid, payload }; +} + +function refreshEditGroupDebug() { + const ta = document.getElementById('edit-group-debug'); + if (!ta) return; + try { + const { gid, payload } = collectGroupEditPayload(); + const loaded = window.__editGroupLoadedSnapshot; + ta.value = JSON.stringify( + { + group_id: gid || null, + loaded_from_server: loaded != null ? loaded : null, + save_payload_preview: payload, + }, + null, + 2, + ); + } catch (e) { + ta.value = String(e); + } +} + +function loadWifiFieldsFromGroup(g) { + const wName = document.getElementById('edit-group-wifi-driver-name'); + const wLeds = document.getElementById('edit-group-wifi-num-leds'); + const wCo = document.getElementById('edit-group-wifi-color-order'); + const wStart = document.getElementById('edit-group-wifi-startup-mode'); + if (wName) { + const v = g && Object.prototype.hasOwnProperty.call(g, 'wifi_driver_display_name') + ? g.wifi_driver_display_name + : null; + wName.value = v != null && String(v).trim() !== '' ? String(v).trim() : ''; + } + if (wLeds) { + const v = g && g.wifi_driver_num_leds; + wLeds.value = + v != null && v !== '' && String(v).trim() !== '' + ? String(v) + : ''; + } + if (wCo) { + const co = (g && g.wifi_color_order) || 'rgb'; + wCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase()) + ? String(co).toLowerCase() + : 'rgb'; + } + if (wStart) { + const sm = (g && g.wifi_startup_mode) || 'default'; + wStart.value = ['default', 'last', 'off'].includes(String(sm).toLowerCase()) + ? String(sm).toLowerCase() + : 'default'; + } + const gob = document.getElementById('edit-group-output-brightness'); + const gobv = document.getElementById('edit-group-output-brightness-value'); + if (gob) { + let bv = 255; + if (g && g.output_brightness != null && g.output_brightness !== '') { + const n = parseInt(String(g.output_brightness), 10); + if (!Number.isNaN(n)) bv = Math.max(0, Math.min(255, n)); + } + gob.value = String(bv); + if (gobv) gobv.textContent = String(bv); + } +} + +async function openEditGroupModal(groupId, groupDoc) { + const modal = document.getElementById('edit-group-modal'); + const idInput = document.getElementById('edit-group-id'); + const nameInput = document.getElementById('edit-group-name'); + const editor = document.getElementById('edit-group-devices-editor'); + + let g = groupDoc; + if (!g || typeof g !== 'object') { + try { + const response = await fetch(`/groups/${encodeURIComponent(groupId)}`); + if (response.ok) g = await response.json(); + } catch (e) { + console.error(e); + } + } + g = g || {}; + try { + window.__editGroupLoadedSnapshot = JSON.parse(JSON.stringify(g)); + } catch (e) { + window.__editGroupLoadedSnapshot = g; + } + + if (idInput) idInput.value = groupId; + if (nameInput) nameInput.value = g.name || ''; + + const dm = await fetchDevicesMapForGroups(); + const macs = Array.isArray(g.devices) ? g.devices : []; + window.__editGroupDeviceRows = macs.map((m) => { + const mac = String(m).trim().toLowerCase().replace(/:/g, '').replace(/-/g, ''); + const d = dm[mac]; + return { + mac, + label: d && d.name ? String(d.name).trim() : mac, + }; + }); + renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm); + loadWifiFieldsFromGroup(g); + refreshEditGroupDebug(); + if (modal) modal.classList.add('active'); +} + +async function loadGroupsModal() { + const container = document.getElementById('groups-list-modal'); + if (!container) return; + container.innerHTML = 'Loading...'; + try { + const data = await fetchGroupsMap(); + renderGroupsList(data || {}); + } catch (e) { + console.error('loadGroupsModal:', e); + container.innerHTML = 'Failed to load groups.'; + } +} + +function renderGroupsList(groups) { + const container = document.getElementById('groups-list-modal'); + if (!container) return; + container.innerHTML = ''; + const ids = Object.keys(groups).filter((k) => groups[k] && typeof groups[k] === 'object'); + if (ids.length === 0) { + const p = document.createElement('p'); + p.className = 'muted-text'; + p.textContent = 'No groups yet. Create one to assign devices and Wi‑Fi defaults.'; + container.appendChild(p); + return; + } + ids.sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); + ids.forEach((gid) => { + const g = groups[gid]; + const row = document.createElement('div'); + row.className = 'profiles-row'; + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '0.5rem'; + row.style.flexWrap = 'wrap'; + + const label = document.createElement('span'); + const devs = Array.isArray(g.devices) ? g.devices : []; + label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`; + label.style.flex = '1'; + + const editBtn = document.createElement('button'); + editBtn.className = 'btn btn-secondary btn-small'; + editBtn.textContent = 'Edit'; + editBtn.addEventListener('click', () => openEditGroupModal(gid, g)); + + const brightBtn = document.createElement('button'); + brightBtn.className = 'btn btn-secondary btn-small'; + brightBtn.type = 'button'; + brightBtn.textContent = 'Apply brightness'; + brightBtn.title = 'Push group output brightness to Wi‑Fi drivers in this group'; + brightBtn.addEventListener('click', async () => { + try { + const res = await fetch(`/groups/${encodeURIComponent(gid)}/brightness`, { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || 'Apply brightness failed'); + return; + } + const n = typeof data.sent === 'number' ? data.sent : 0; + alert( + n + ? `Sent brightness to ${n} driver(s).` + : 'No Wi‑Fi drivers received brightness (check connections).', + ); + } catch (err) { + console.error(err); + alert('Apply brightness failed'); + } + }); + + const applyBtn = document.createElement('button'); + applyBtn.className = 'btn btn-primary btn-small'; + applyBtn.type = 'button'; + applyBtn.textContent = 'Apply defaults to drivers'; + applyBtn.title = 'Push Wi‑Fi defaults to each connected driver in this group'; + applyBtn.addEventListener('click', async () => { + try { + const res = await fetch(`/groups/${encodeURIComponent(gid)}/driver-config`, { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || 'Apply failed'); + return; + } + const n = typeof data.sent === 'number' ? data.sent : 0; + alert( + n + ? `Sent defaults to ${n} driver(s).` + : 'No Wi‑Fi drivers received the config (check defaults and connections).', + ); + } catch (err) { + console.error(err); + alert('Apply failed'); + } + }); + + const delBtn = document.createElement('button'); + delBtn.className = 'btn btn-danger btn-small'; + delBtn.textContent = 'Delete'; + delBtn.addEventListener('click', async () => { + if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return; + try { + const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'DELETE' }); + if (res.ok) await loadGroupsModal(); + else { + const data = await res.json().catch(() => ({})); + alert(data.error || 'Delete failed'); + } + } catch (err) { + console.error(err); + alert('Delete failed'); + } + }); + + row.appendChild(label); + row.appendChild(editBtn); + row.appendChild(brightBtn); + row.appendChild(applyBtn); + row.appendChild(delBtn); + container.appendChild(row); + }); +} + +document.addEventListener('DOMContentLoaded', () => { + const groupsBtn = document.getElementById('groups-btn'); + const groupsModal = document.getElementById('groups-modal'); + const groupsCloseBtn = document.getElementById('groups-close-btn'); + const newNameInput = document.getElementById('new-group-name'); + const createBtn = document.getElementById('create-group-btn'); + const editForm = document.getElementById('edit-group-form'); + const editCloseBtn = document.getElementById('edit-group-close-btn'); + const editModal = document.getElementById('edit-group-modal'); + + if (groupsBtn && groupsModal) { + groupsBtn.addEventListener('click', () => { + groupsModal.classList.add('active'); + loadGroupsModal(); + }); + } + if (groupsCloseBtn && groupsModal) { + groupsCloseBtn.addEventListener('click', () => groupsModal.classList.remove('active')); + } + + const grpOutBr = document.getElementById('edit-group-output-brightness'); + const grpOutBrVal = document.getElementById('edit-group-output-brightness-value'); + if (grpOutBr && grpOutBrVal) { + grpOutBr.addEventListener('input', () => { + grpOutBrVal.textContent = grpOutBr.value; + }); + } + + const createHandler = async () => { + const name = newNameInput && newNameInput.value.trim(); + if (!name) return; + try { + const res = await fetch('/groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ name }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || 'Create failed'); + return; + } + if (newNameInput) newNameInput.value = ''; + await loadGroupsModal(); + } catch (e) { + console.error(e); + alert('Create failed'); + } + }; + if (createBtn) createBtn.addEventListener('click', createHandler); + if (newNameInput) { + newNameInput.addEventListener('keypress', (ev) => { + if (ev.key === 'Enter') createHandler(); + }); + } + + if (editForm) { + editForm.addEventListener('input', () => refreshEditGroupDebug()); + editForm.addEventListener('change', () => refreshEditGroupDebug()); + editForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const { gid, payload } = collectGroupEditPayload(); + if (!gid) return; + + try { + const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || 'Save failed'); + return; + } + try { + await fetch(`/groups/${encodeURIComponent(gid)}/brightness`, { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + } catch (_) { + /* ignore push errors after save */ + } + if (editModal) editModal.classList.remove('active'); + await loadGroupsModal(); + } catch (err) { + console.error(err); + alert('Save failed'); + } + }); + } + if (editCloseBtn && editModal) { + editCloseBtn.addEventListener('click', () => editModal.classList.remove('active')); + } +}); diff --git a/src/static/presets.js b/src/static/presets.js index a9b5488..e57bc79 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -1694,7 +1694,8 @@ const sendPresetViaEspNow = async ( : []; const sequence = [presetMessage]; - if (names.length > 0) { + // Auto: apply preset immediately via select. Manual: load definition only — first step is on the next audio beat. + if (names.length > 0 && presetAuto) { const select = {}; names.forEach((name) => { if (name) { diff --git a/src/static/style.css b/src/static/style.css index 16f3241..abf67ef 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -94,10 +94,11 @@ header { background-color: #1a1a1a; padding: 0.75rem 1rem; display: flex; - justify-content: space-between; - align-items: center; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; border-bottom: 2px solid #4a4a4a; - gap: 0.75rem; + gap: 0.65rem; } header h1 { @@ -105,14 +106,15 @@ header h1 { font-weight: 600; } -/* BPM + desktop actions + mobile menu share one row; BPM stays visible on mobile. */ +/* Second header row: BPM, brightness, desktop buttons / mobile menu */ .header-end { display: flex; align-items: center; gap: 0.5rem; - flex-wrap: nowrap; + flex-wrap: wrap; justify-content: flex-end; - margin-left: auto; + margin-left: 0; + width: 100%; min-width: 0; } @@ -196,7 +198,7 @@ header h1 { } .audio-top-indicator { - display: inline-flex; + display: none; align-items: center; gap: 0.4rem; padding: 0.25rem 0.55rem; @@ -206,6 +208,10 @@ header h1 { min-width: 6.5rem; } +.audio-top-indicator.audio-running { + display: inline-flex; +} + .audio-top-indicator-label { font-size: 0.72rem; color: #bdbdbd; @@ -294,8 +300,9 @@ body.preset-ui-run .edit-mode-only { .zones-container { background-color: transparent; - padding: 0.5rem 0; - flex: 1; + padding: 0.35rem 0 0; + flex: 0 0 auto; + width: 100%; min-width: 0; align-self: stretch; display: flex; @@ -1087,12 +1094,16 @@ body.preset-ui-run .edit-mode-only { /* Mobile-friendly layout */ @media (max-width: 1000px) { header { - flex-direction: row; - align-items: center; - gap: 0.25rem; - } header h1 { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + + header h1 { font-size: 1.1rem; - } /* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */ + } + + /* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */ .header-actions { display: none; } @@ -1123,8 +1134,9 @@ body.preset-ui-run .edit-mode-only { } .zones-container { - padding: 0.5rem 0; + padding: 0.35rem 0 0; border-bottom: none; + width: 100%; } .zone-content { diff --git a/src/static/zones.js b/src/static/zones.js index 79b1e08..c9005e2 100644 --- a/src/static/zones.js +++ b/src/static/zones.js @@ -64,6 +64,47 @@ function sendZoneBrightness(zoneId, value) { ? await window.tabsManager.resolveTabDeviceMacs(names) : []; if (typeof window.postDriverSequence === 'function') { + if (targetMacs.length > 0) { + let resolved = {}; + try { + const rr = await fetch('/devices/resolve-brightness', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + macs: targetMacs, + zone_brightness: val, + }), + }); + if (rr.ok) { + const pack = await rr.json().catch(() => ({})); + if (pack && pack.values && typeof pack.values === 'object') { + resolved = pack.values; + } + } + } catch (re) { + console.warn('resolve-brightness failed:', re); + } + for (const mac of targetMacs) { + const k = String(mac).toLowerCase(); + const b = + resolved[k] != null && resolved[k] !== '' + ? parseInt(resolved[k], 10) + : val; + const bv = Number.isNaN(b) + ? val + : Math.max(0, Math.min(255, b)); + await window.postDriverSequence( + [{ v: '1', b: bv, save: true }], + [mac], + 0, + ); + } + return; + } await window.postDriverSequence([{ v: '1', b: val, save: true }], targetMacs, 0); return; } @@ -107,8 +148,81 @@ async function fetchDevicesMap() { } } +async function fetchGroupsMap() { + try { + const response = await fetch("/groups", { headers: { Accept: "application/json" } }); + if (!response.ok) return {}; + const data = await response.json(); + return data && typeof data === "object" ? data : {}; + } catch (e) { + console.error("fetchGroupsMap:", e); + return {}; + } +} + +/** + * Resolve registry names + MACs for a zone document (``group_ids`` expands groups; + * otherwise legacy ``names``). + */ +async function computeZoneTargets(zone) { + const dm = await fetchDevicesMap(); + 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 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 = String(raw || "") + .trim() + .toLowerCase() + .replace(/:/g, "") + .replace(/-/g, ""); + 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 }; + } + 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))], + }; +} + +async function resolveZoneDeviceMacsFromZoneData(zone) { + const t = await computeZoneTargets(zone); + return t.macs; +} + /** Registry MACs for zone device names (order matches zone names; skips unknown names). */ async function resolveZoneDeviceMacs(zoneNames) { + const section = document.querySelector(".presets-section[data-zone-id]"); + if (section) { + const enc = section.getAttribute("data-zone-target-macs-json"); + if (enc) { + try { + const macs = JSON.parse(decodeURIComponent(enc)); + if (Array.isArray(macs) && macs.length) { + return [...new Set(macs.map((m) => String(m).toLowerCase()))]; + } + } catch (e) { + /* fall through */ + } + } + } const dm = await fetchDevicesMap(); const rows = namesToRows(Array.isArray(zoneNames) ? zoneNames : [], dm); const macs = rows.map((r) => r.mac).filter(Boolean); @@ -197,15 +311,72 @@ function renderZoneDevicesEditor(containerEl, rows, devicesMap) { containerEl.appendChild(addWrap); } -/** Default device name list when creating a zone (refined in Edit zone). */ -async function defaultDeviceNamesForNewTab() { - const dm = await fetchDevicesMap(); - const macs = Object.keys(dm); - if (macs.length > 0) { - const m0 = macs[0]; - return [String((dm[m0].name || "").trim() || m0)]; - } - return ["1"]; +function renderZoneGroupsEditor(containerEl, rows, groupsMap) { + if (!containerEl) return; + containerEl.innerHTML = ""; + const entries = Object.entries(groupsMap || {}).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 || row.id || "—"; + label.appendChild(strong); + label.appendChild(document.createTextNode(" ")); + const sub = document.createElement("span"); + sub.className = "muted-text"; + sub.textContent = `group ${row.id}`; + 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); + renderZoneGroupsEditor(containerEl, rows, groupsMap); + }); + div.appendChild(label); + div.appendChild(rm); + containerEl.appendChild(div); + }); + + const idsInRows = new Set(rows.map((r) => String(r.id))); + 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 group…", "")); + entries.forEach(([gid, g]) => { + if (idsInRows.has(gid)) return; + const gn = g && g.name ? String(g.name).trim() : ""; + const optLabel = gn ? `${gn} (${gid})` : `Group ${gid}`; + sel.appendChild(new Option(optLabel, gid)); + }); + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "btn btn-primary btn-small"; + addBtn.textContent = "Add"; + addBtn.addEventListener("click", () => { + const gid = sel.value; + if (!gid || !groupsMap[gid]) return; + const gn = groupsMap[gid].name ? String(groupsMap[gid].name).trim() : gid; + rows.push({ id: gid, name: gn }); + sel.value = ""; + renderZoneGroupsEditor(containerEl, rows, groupsMap); + }); + addWrap.appendChild(sel); + addWrap.appendChild(addBtn); + 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). */ @@ -539,12 +710,16 @@ async function loadZoneContent(zoneId) { // Render zone content (presets section) const tabName = zone.name || `Zone ${zoneId}`; - const names = Array.isArray(zone.names) ? zone.names : []; - const namesJsonAttr = encodeURIComponent(JSON.stringify(names)); - const legacyOk = names.length > 0 && !names.some((n) => /[",]/.test(String(n))); - const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : ""; + const targets = await computeZoneTargets(zone); + const namesJsonAttr = encodeURIComponent(JSON.stringify(targets.names)); + const macsJsonAttr = encodeURIComponent(JSON.stringify(targets.macs)); + const legacyOk = + targets.names.length > 0 && !targets.names.some((n) => /[",]/.test(String(n))); + const legacyAttr = legacyOk + ? ` data-device-names="${escapeHtmlAttr(targets.names.join(","))}"` + : ""; container.innerHTML = ` -
+
@@ -639,8 +814,7 @@ async function sendProfilePresets() { continue; } zonesWithPresets += 1; - const zoneNames = Array.isArray(tabData.names) ? tabData.names : []; - const targets = await resolveZoneDeviceMacs(zoneNames); + const targets = await resolveZoneDeviceMacsFromZoneData(tabData); const payload = { preset_ids: presetIds }; if (tabData.default_preset) { payload.default = tabData.default_preset; @@ -831,31 +1005,25 @@ async function openEditZoneModal(zoneId, zone) { if (idInput) idInput.value = zoneId; if (nameInput) nameInput.value = tabData.name || ""; - const devicesMap = await fetchDevicesMap(); - const zoneNames = - Array.isArray(tabData.names) && tabData.names.length > 0 ? tabData.names : ["1"]; - window.__editTabDeviceRows = namesToRows(zoneNames, devicesMap); - renderZoneDevicesEditor(editor, window.__editTabDeviceRows, devicesMap); + const groupsMap = await fetchGroupsMap(); + const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : []; + window.__editTabGroupRows = rawGids.map((gid) => { + const id = String(gid); + const g = groupsMap[id]; + return { id, name: g && g.name ? String(g.name).trim() : id }; + }); + renderZoneGroupsEditor(editor, window.__editTabGroupRows, groupsMap); if (modal) modal.classList.add("active"); await refreshEditTabPresetsUi(zoneId); } -function normalizeTabNamesArg(namesOrString) { - if (Array.isArray(namesOrString)) { - return namesOrString.map((n) => String(n).trim()).filter((n) => n.length > 0); - } - if (typeof namesOrString === "string" && namesOrString.trim()) { - return namesOrString.split(",").map((id) => id.trim()).filter((id) => id.length > 0); - } - return ["1"]; -} - // Update an existing zone -async function updateZone(zoneId, name, namesOrString) { +async function updateZone(zoneId, name, groupIds) { try { - let names = normalizeTabNamesArg(namesOrString); - if (!names.length) names = ["1"]; + const gids = Array.isArray(groupIds) + ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) + : []; const response = await fetch(`/zones/${zoneId}`, { method: 'PUT', headers: { @@ -863,7 +1031,8 @@ async function updateZone(zoneId, name, namesOrString) { }, body: JSON.stringify({ name: name, - names: names + group_ids: gids, + names: [], }) }); @@ -887,10 +1056,11 @@ async function updateZone(zoneId, name, namesOrString) { } // Create a new zone -async function createZone(name, namesOrString) { +async function createZone(name, groupIds) { try { - let names = normalizeTabNamesArg(namesOrString); - if (!names.length) names = ["1"]; + const gids = Array.isArray(groupIds) + ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) + : []; const response = await fetch('/zones', { method: 'POST', headers: { @@ -898,7 +1068,8 @@ async function createZone(name, namesOrString) { }, body: JSON.stringify({ name: name, - names: names + group_ids: gids, + names: [], }) }); @@ -979,8 +1150,8 @@ document.addEventListener('DOMContentLoaded', () => { const name = newTabNameInput.value.trim(); if (name) { - const deviceNames = await defaultDeviceNamesForNewTab(); - await createZone(name, deviceNames); + const groupIds = await defaultGroupIdsForNewTab(); + await createZone(name, groupIds); if (newTabNameInput) newTabNameInput.value = ""; } }; @@ -1007,15 +1178,15 @@ document.addEventListener('DOMContentLoaded', () => { const zoneId = idInput ? idInput.value : null; const name = nameInput ? nameInput.value.trim() : ""; - const rows = window.__editTabDeviceRows || []; - const deviceNames = rowsToNames(rows); + const rows = window.__editTabGroupRows || []; + const groupIds = rows.map((r) => r.id).filter(Boolean); if (zoneId && name) { - if (deviceNames.length === 0) { - alert("Add at least one device."); + if (groupIds.length === 0) { + alert("Add at least one device group."); return; } - await updateZone(zoneId, name, deviceNames); + await updateZone(zoneId, name, groupIds); editZoneForm.reset(); } }); @@ -1066,6 +1237,7 @@ window.zonesManager = { updateZone, openEditZoneModal, resolveZoneDeviceMacs, + resolveZoneDeviceMacsFromZoneData, resolveTabDeviceMacs: resolveZoneDeviceMacs, getCurrentZoneId: () => currentZoneId, }; diff --git a/src/templates/index.html b/src/templates/index.html index f9b26dc..9bfb6a9 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -27,6 +27,7 @@
+ @@ -47,6 +48,7 @@
+ @@ -96,7 +98,7 @@ - +
@@ -138,6 +140,67 @@ + + + + +