diff --git a/.cursor/rules/pattern-workflow.mdc b/.cursor/rules/pattern-workflow.mdc new file mode 100644 index 0000000..7460321 --- /dev/null +++ b/.cursor/rules/pattern-workflow.mdc @@ -0,0 +1,12 @@ +--- +description: Require test pattern, pattern metadata, and test preset for new patterns +alwaysApply: true +--- + +# Pattern workflow requirements + +1. When creating a new pattern under `led-driver/src/patterns/`, also add/update a corresponding test file in `led-driver/tests/patterns/`. + +2. When adding a new pattern, ensure led-controller has `db/pattern.json`; if it does not exist, create it. Add the new pattern metadata and parameter mappings there. + +3. When adding a new pattern, add at least one test preset entry in `db/preset.json` in led-controller that uses the new pattern. diff --git a/db/pattern.json b/db/pattern.json index 3b240ba..a437e8c 100644 --- a/db/pattern.json +++ b/db/pattern.json @@ -1,92 +1 @@ -{ - "on": { - "min_delay": 10, - "max_delay": 10000, - "max_colors": 1 - }, - "off": { - "min_delay": 10, - "max_delay": 10000, - "max_colors": 0 - }, - "rainbow": { - "n1": "Step Rate", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 0 - }, - "colour_cycle": { - "n1": "Step Rate", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 10 - }, - "transition": { - "min_delay": 10, - "max_delay": 10000, - "max_colors": 10 - }, - "chase": { - "n1": "Colour 1 Length", - "n2": "Colour 2 Length", - "n3": "Step 1", - "n4": "Step 2", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 2 - }, - "pulse": { - "n1": "Attack", - "n2": "Hold", - "n3": "Decay", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 10 - }, - "circle": { - "n1": "Head Rate", - "n2": "Max Length", - "n3": "Tail Rate", - "n4": "Min Length", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 2 - }, - "blink": { - "min_delay": 10, - "max_delay": 10000, - "max_colors": 10 - }, - "flicker": { - "n1": "Min brightness", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 10 - }, - "flame": { - "n1": "Min brightness", - "n2": "Breath period (ms)", - "n3": "Spark gap min (ms, 0=default 10–30 s, -1=off)", - "n4": "Spark gap max (ms)", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 10 - }, - "twinkle": { - "n1": "Twinkle activity (1–255, higher = more changes)", - "n2": "Density (0–255, higher = more of the strip lit)", - "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", - "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 10 - }, - "radiate": { - "n1": "Node spacing (LEDs)", - "n2": "Out time (ms)", - "n3": "In time (ms)", - "min_delay": 10, - "max_delay": 10000, - "max_colors": 2 - } -} \ No newline at end of file +{"on": {"min_delay": 10, "max_delay": 10000, "max_colors": 1}, "off": {"min_delay": 10, "max_delay": 10000, "max_colors": 0}, "rainbow": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 0}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "transition": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "chase": {"n1": "Colour 1 Length", "n2": "Colour 2 Length", "n3": "Step 1", "n4": "Step 2", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "pulse": {"n1": "Attack", "n2": "Hold", "n3": "Decay", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "circle": {"n1": "Head Rate", "n2": "Max Length", "n3": "Tail Rate", "n4": "Min Length", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "blink": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flicker": {"n1": "Min brightness", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flame": {"n1": "Min brightness", "n2": "Breath period (ms)", "n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1\u2013255, higher = more changes)", "n2": "Density (0\u2013255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "radiate": {"n1": "Node spacing (LEDs)", "n2": "Out time (ms)", "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "meteor_rain": {"n1": "Tail length", "n2": "Speed (LEDs per frame)", "n3": "Fade amount (1-255)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "scanner": {"n1": "Eye width", "n2": "End pause (frames)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "gradient_scroll": {"n1": "Scroll step rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "comet_dual": {"n1": "Tail length", "n2": "Speed", "n3": "Gap", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "sparkle_trail": {"n1": "Spark density", "n2": "Decay", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "wave": {"n1": "Wavelength", "n2": "Amplitude", "n3": "Drift speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "plasma": {"n1": "Scale", "n2": "Speed", "n3": "Contrast", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "segment_chase": {"n1": "Segment size", "n2": "Phase step", "n3": "Segment phase offset", "n4": "Gap per segment", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "breathing_dual": {"n1": "Phase offset", "n2": "Ease", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "strobe_burst": {"n1": "Burst count", "n2": "Burst gap", "n3": "Cooldown", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "aurora": {"n1": "Band count", "n2": "Shimmer", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "snowfall": {"n1": "Flake density", "n2": "Fall speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "palette_morph": {"n1": "Morph ms", "n2": "Warp rate", "n3": "Turbulence", "max_colors": 10, "min_delay": 10, "max_delay": 10000}} \ No newline at end of file diff --git a/db/preset.json b/db/preset.json index 7fbb8cb..e42d91c 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", "#FFFFFF", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 5000, "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, 6, 2, 3]}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 200, "auto": true, "n1": 5, "n2": 5, "n3": 1, "n4": 1, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 1000, "n2": 500, "n3": 1000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "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": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "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", "#000a0a"], "brightness": 255, "delay": 5000, "auto": true, "n1": 30, "n2": 900, "n3": 4000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}} \ 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", "#FFFFFF", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 5000, "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, 6, 2, 3]}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 200, "auto": true, "n1": 5, "n2": 5, "n3": 1, "n4": 1, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 1000, "n2": 500, "n3": 1000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "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": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "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", "#000a0a"], "brightness": 255, "delay": 5000, "auto": true, "n1": 30, "n2": 900, "n3": 4000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "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"}, "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"}, "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/db/zone.json b/db/zone.json index b5baad2..1552682 100644 --- a/db/zone.json +++ b/db/zone.json @@ -1 +1 @@ -{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41"], "default_preset": "4"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}} \ No newline at end of file +{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"], ["43", "44", "45"], ["46", "47", "48"], ["49", "50", "51"], ["52", "53", "54"], ["55", "56", "57"], ["58", "59", "60"], ["61", "62"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62"], "default_preset": "41"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null, "presets_flat": []}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}} \ No newline at end of file diff --git a/led-driver b/led-driver index 428ed8b..4575ef1 160000 --- a/led-driver +++ b/led-driver @@ -1 +1 @@ -Subproject commit 428ed8b8849e44123da48fff5644fa65d6cc6953 +Subproject commit 4575ef16ad1474a3892ed57eb54c392c397d6e82 diff --git a/led-simulator b/led-simulator new file mode 160000 index 0000000..7ce56b6 --- /dev/null +++ b/led-simulator @@ -0,0 +1 @@ +Subproject commit 7ce56b64df6d154d5d4cf103a9ab8b291e85c248 diff --git a/src/static/patterns.js b/src/static/patterns.js index 4a9daa1..8fb95f9 100644 --- a/src/static/patterns.js +++ b/src/static/patterns.js @@ -4,6 +4,7 @@ document.addEventListener('DOMContentLoaded', () => { const patternsCloseButton = document.getElementById('patterns-close-btn'); const patternsList = document.getElementById('patterns-list'); const patternAddButton = document.getElementById('pattern-add-btn'); + const patternSendAllButton = document.getElementById('pattern-send-all-btn'); const patternEditorModal = document.getElementById('pattern-editor-modal'); const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn'); const patternCreateBtn = document.getElementById('pattern-create-btn'); @@ -24,6 +25,71 @@ document.addEventListener('DOMContentLoaded', () => { return; } + const coercePresetInt = (v, def = 0) => { + if (typeof v === 'number' && Number.isFinite(v)) { + return v; + } + const t = parseInt(String(v), 10); + return Number.isFinite(t) ? t : def; + }; + + const getCurrentProfileId = async () => { + try { + const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } }); + if (!response.ok) { + return null; + } + const data = await response.json(); + return data && (data.id || (data.profile && data.profile.id)) ? String(data.id || data.profile.id) : null; + } catch (_) { + return null; + } + }; + + const filterPresetsForCurrentProfile = async (presetsObj) => { + const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {}; + const currentProfileId = await getCurrentProfileId(); + if (!currentProfileId) { + return scoped; + } + return Object.fromEntries( + Object.entries(scoped).filter(([, preset]) => { + if (!preset || typeof preset !== 'object') return false; + if (!('profile_id' in preset)) return true; + return String(preset.profile_id) === String(currentProfileId); + }), + ); + }; + + const tabDeviceNamesFromSection = (section) => { + if (typeof window.parseTabDeviceNames === 'function') { + return window.parseTabDeviceNames(section); + } + const namesAttr = section && section.getAttribute('data-device-names'); + return namesAttr + ? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0) + : []; + }; + + const postDriverSequence = async (sequence, targetMacs, delayS = 0.05) => { + const body = { + sequence, + targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined, + delay_s: delayS, + }; + const res = await fetch('/presets/push', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err && err.error) || res.statusText || 'Send failed'); + } + return res.json().catch(() => ({})); + }; + const nReadableStringFromMeta = (meta, key) => { if (!meta || typeof meta !== 'object') { return ''; @@ -424,4 +490,93 @@ document.addEventListener('DOMContentLoaded', () => { patternsCloseButton.addEventListener('click', closeModal); } + if (patternSendAllButton) { + patternSendAllButton.addEventListener('click', async () => { + const section = document.querySelector('.presets-section[data-zone-id]'); + const zoneId = section ? section.dataset.zoneId : null; + if (!zoneId) { + alert('Could not determine current zone.'); + return; + } + const deviceNames = tabDeviceNamesFromSection(section); + if (!deviceNames.length) { + alert('No devices found in the current zone.'); + return; + } + try { + const [zoneRes, presetsRes] = await Promise.all([ + fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }), + fetch('/presets', { headers: { Accept: 'application/json' } }), + ]); + if (!zoneRes.ok || !presetsRes.ok) { + throw new Error('Failed to load zone presets'); + } + const zoneData = await zoneRes.json(); + const allPresetsRaw = await presetsRes.json(); + const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw); + const zonePresetIds = Array.isArray(zoneData.presets_flat) + ? zoneData.presets_flat.map((id) => String(id)) + : []; + if (!zonePresetIds.length) { + alert('No presets found in this zone.'); + return; + } + + const wirePresets = {}; + zonePresetIds.forEach((presetId) => { + const preset = allPresets[presetId]; + if (!preset) { + return; + } + const colors = Array.isArray(preset.colors) && preset.colors.length + ? preset.colors + : ['#FFFFFF']; + wirePresets[presetId] = { + pattern: preset.pattern || 'off', + colors, + delay: typeof preset.delay === 'number' ? preset.delay : 100, + brightness: typeof preset.brightness === 'number' + ? preset.brightness + : (typeof preset.br === 'number' ? preset.br : 127), + auto: typeof preset.auto === 'boolean' ? preset.auto : true, + n1: coercePresetInt(preset.n1), + n2: coercePresetInt(preset.n2), + n3: coercePresetInt(preset.n3), + n4: coercePresetInt(preset.n4), + n5: coercePresetInt(preset.n5), + n6: coercePresetInt(preset.n6), + }; + }); + if (!Object.keys(wirePresets).length) { + alert('No matching presets found to send.'); + return; + } + + const select = {}; + deviceNames.forEach((name) => { + if (name) { + select[name] = zonePresetIds.slice(); + } + }); + const targetMacs = + typeof window.tabsManager !== 'undefined' && + typeof window.tabsManager.resolveTabDeviceMacs === 'function' + ? await window.tabsManager.resolveTabDeviceMacs(deviceNames) + : []; + + const sequence = [ + { v: '1', clear_presets: true, save: true }, + { v: '1', presets: wirePresets, save: true }, + ]; + if (Object.keys(select).length) { + sequence.push({ v: '1', select }); + } + await postDriverSequence(sequence, targetMacs, 0.05); + } catch (error) { + console.error('Send all patterns failed:', error); + alert('Failed to send all patterns.'); + } + }); + } + }); diff --git a/src/static/presets.js b/src/static/presets.js index c5ee2f4..f412b75 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -175,39 +175,6 @@ async function postDriverSequence(sequence, targetMacs, delayS) { return res.json().catch(() => ({})); } -// Send a select message for a preset to all devices on the current zone (ESP-NOW or Wi-Fi). -const sendSelectForCurrentTabDevices = async (presetId, sectionEl) => { - const section = sectionEl || document.querySelector('.presets-section[data-zone-id]'); - if (!section || !presetId) { - return; - } - const deviceNames = tabDeviceNamesFromSection(section); - - if (!deviceNames.length) { - return; - } - - const select = {}; - deviceNames.forEach((name) => { - if (name) { - select[name] = [presetId]; - } - }); - - const targetMacs = - typeof window.tabsManager !== 'undefined' && - typeof window.tabsManager.resolveTabDeviceMacs === 'function' - ? await window.tabsManager.resolveTabDeviceMacs(deviceNames) - : []; - - try { - await postDriverSequence([{ v: '1', select }], targetMacs); - } catch (err) { - console.error('sendSelectForCurrentTabDevices:', err); - alert('Failed to send preset selection to devices.'); - } -}; - document.addEventListener('DOMContentLoaded', () => { const presetsButton = document.getElementById('presets-btn'); const presetsModal = document.getElementById('presets-modal'); @@ -1332,7 +1299,7 @@ document.addEventListener('DOMContentLoaded', () => { // Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name const presetId = currentEditId || payload.name; // Try sends preset first, then select; never persist on device. - await sendPresetViaEspNow(presetId, payload, deviceNames, false, false); + await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2'); }); } @@ -1346,8 +1313,9 @@ document.addEventListener('DOMContentLoaded', () => { const section = document.querySelector('.presets-section[data-zone-id]'); const deviceNames = tabDeviceNamesFromSection(section); const presetId = currentEditId || payload.name; + await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1'); await updateTabDefaultPreset(presetId); - await sendDefaultPreset(presetId, deviceNames); + await sendDefaultPreset('1', deviceNames); }); } @@ -1379,7 +1347,7 @@ document.addEventListener('DOMContentLoaded', () => { throw new Error('Failed to save preset'); } - // Same device targeting as Try: zone tab supplies names → /presets/push gets targets + select. + // Same device targeting as Try: zone tab supplies names and selection without persistence. const section = document.querySelector('.presets-section[data-zone-id]'); const deviceNames = tabDeviceNamesFromSection(section); @@ -1388,18 +1356,18 @@ document.addEventListener('DOMContentLoaded', () => { if (saved && typeof saved === 'object') { if (currentEditId) { // PUT returns the preset object directly; use the existing ID - await sendPresetViaEspNow(currentEditId, saved, deviceNames, true, false); + await sendPresetViaEspNow(currentEditId, saved, deviceNames, false, false, '2'); } else { // POST returns { id: preset } const entries = Object.entries(saved); if (entries.length > 0) { const [newId, presetData] = entries[0]; - await sendPresetViaEspNow(newId, presetData, deviceNames, true, false); + await sendPresetViaEspNow(newId, presetData, deviceNames, false, false, '2'); } } } else { // Fallback: send what we just built - await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, true, false); + await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, false, false, '2'); } await loadPresets(); @@ -1454,7 +1422,14 @@ const coercePresetInt = (v, def = 0) => { // 1) preset payload (optionally with save) // 2) optional select for device names (never with save) // saveToDevice defaults to true. -const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => { +const sendPresetViaEspNow = async ( + presetId, + preset, + deviceNames, + saveToDevice = true, + setDefault = false, + devicePresetId = null, +) => { try { const baseColors = Array.isArray(preset.colors) && preset.colors.length ? preset.colors @@ -1462,10 +1437,11 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = const paletteColors = await getCurrentProfilePaletteColors(); const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors); + const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId); const presetMessage = { v: '1', presets: { - [presetId]: { + [wirePresetId]: { pattern: preset.pattern || 'off', colors, delay: typeof preset.delay === 'number' ? preset.delay : 100, @@ -1486,7 +1462,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = presetMessage.save = true; } if (setDefault) { - presetMessage.default = presetId; + presetMessage.default = wirePresetId; } const names = Array.isArray(deviceNames) ? deviceNames : []; @@ -1502,7 +1478,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = const select = {}; names.forEach((name) => { if (name) { - select[name] = [presetId]; + select[name] = [wirePresetId]; } }); if (Object.keys(select).length > 0) { @@ -1879,7 +1855,8 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => { button.classList.add('active'); selectedPresets[zoneId] = presetId; const section = row.closest('.presets-section'); - sendSelectForCurrentTabDevices(presetId, section).catch((err) => { + const deviceNames = tabDeviceNamesFromSection(section); + sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => { console.error(err); }); }); diff --git a/src/static/style.css b/src/static/style.css index a7afa11..d6335d4 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -149,6 +149,40 @@ header h1 { background-color: #333; } +.menu-brightness-control { + padding: 0.45rem 0.75rem 0.55rem; + border-bottom: 1px solid #333; +} + +.menu-brightness-control label { + display: block; + font-size: 0.78rem; + color: #bdbdbd; + margin-bottom: 0.3rem; +} + +.menu-brightness-control input[type="range"] { + width: 100%; +} + +.header-brightness-control { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 13rem; + padding: 0.2rem 0.1rem; +} + +.header-brightness-control label { + font-size: 0.8rem; + color: #bdbdbd; + white-space: nowrap; +} + +.header-brightness-control input[type="range"] { + width: 8.5rem; +} + /* Header/menu actions that should only appear in Edit mode */ body.preset-ui-run .edit-mode-only { display: none !important; @@ -248,7 +282,8 @@ body.preset-ui-run .edit-mode-only { display: block; overflow-y: auto; overflow-x: hidden; - padding: 0.5rem 1rem 1rem; + padding: 0.5rem 1rem calc(1rem + env(safe-area-inset-bottom, 0px) + 3.5rem); + -webkit-overflow-scrolling: touch; } .presets-toolbar { @@ -528,6 +563,12 @@ body.preset-ui-run .edit-mode-only { row-gap: 0.3rem; align-content: start; width: 100%; + padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.5rem); + scroll-padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.5rem); +} + +#presets-list-zone > :last-child { + margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 2.5rem); } /* Settings modal layout */ @@ -949,7 +990,7 @@ body.preset-ui-run .edit-mode-only { } /* Mobile-friendly layout */ -@media (max-width: 800px) { +@media (max-width: 1000px) { header { flex-direction: row; align-items: center; @@ -1001,6 +1042,9 @@ body.preset-ui-run .edit-mode-only { min-width: 280px; max-width: 95vw; padding: 1.25rem; + max-height: calc(100dvh - 1rem); + overflow-y: auto; + padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px)); } .form-row { @@ -1018,6 +1062,10 @@ body.preset-ui-run .edit-mode-only { width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); + overflow-y: auto; + padding: 1rem; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } .modal.active { display: flex; @@ -1030,6 +1078,20 @@ body.preset-ui-run .edit-mode-only { border-radius: 8px; min-width: 400px; max-width: 600px; + max-height: calc(100dvh - 2rem); + overflow-y: auto; + padding-bottom: calc(2rem + env(safe-area-inset-bottom, 0px)); + -webkit-overflow-scrolling: touch; +} + +/* Real-phone viewport fallback for browsers with unstable 100dvh behavior. */ +@supports (-webkit-touch-callout: none) { + .modal { + min-height: -webkit-fill-available; + } + .modal-content { + max-height: calc(-webkit-fill-available - 2rem); + } } .modal-content label { display: block; @@ -1200,9 +1262,11 @@ body.preset-ui-run .edit-mode-only { min-height: 80px; } /* Presets list: 3 columns and vertical scroll (defined above); mobile same */ -@media (max-width: 800px) { +@media (max-width: 1000px) { #presets-list-zone { grid-template-columns: repeat(3, minmax(0, 1fr)); + padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem); + scroll-padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem); } } /* Help modal readability */ diff --git a/src/static/zones.js b/src/static/zones.js index b4dd4ca..f665285 100644 --- a/src/static/zones.js +++ b/src/static/zones.js @@ -1,5 +1,47 @@ // Zone management JavaScript let currentZoneId = null; +let brightnessSendTimeout = null; + +function sendZoneBrightness(value) { + const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0)); + const headerSlider = document.getElementById('header-brightness-slider'); + const menuSlider = document.getElementById('menu-brightness-slider'); + if (headerSlider && String(headerSlider.value) !== String(val)) { + headerSlider.value = String(val); + } + if (menuSlider && String(menuSlider.value) !== String(val)) { + menuSlider.value = String(val); + } + if (brightnessSendTimeout) { + clearTimeout(brightnessSendTimeout); + } + brightnessSendTimeout = setTimeout(() => { + (async () => { + try { + const section = document.querySelector('.presets-section[data-zone-id]'); + const names = typeof window.parseTabDeviceNames === 'function' + ? window.parseTabDeviceNames(section) + : []; + const targetMacs = + names.length > 0 && + typeof window.tabsManager !== 'undefined' && + typeof window.tabsManager.resolveTabDeviceMacs === 'function' + ? await window.tabsManager.resolveTabDeviceMacs(names) + : []; + if (typeof window.postDriverSequence === 'function') { + await window.postDriverSequence([{ v: '1', b: val, save: true }], targetMacs, 0); + return; + } + // Fallback to raw websocket sender if presets.js helper isn't available yet. + if (typeof window.sendEspnowRaw === 'function') { + window.sendEspnowRaw({ v: '1', b: val, save: true }); + } + } catch (err) { + console.error('Failed to send brightness via driver sequence:', err); + } + })(); + }, 150); +} const isEditModeActive = () => { const toggle = document.querySelector('.ui-mode-toggle'); @@ -468,37 +510,17 @@ async function loadZoneContent(zoneId) { const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : ""; container.innerHTML = `
-
-
- - -
-
`; - // Wire up per-zone brightness slider to send global brightness via ESPNow. - const brightnessSlider = container.querySelector('#zone-brightness-slider'); - let brightnessSendTimeout = null; - if (brightnessSlider) { - brightnessSlider.addEventListener('input', (e) => { - const val = parseInt(e.target.value, 10) || 0; - if (brightnessSendTimeout) { - clearTimeout(brightnessSendTimeout); - } - brightnessSendTimeout = setTimeout(() => { - if (typeof window.sendEspnowRaw === 'function') { - try { - window.sendEspnowRaw({ v: '1', b: val, save: true }); - } catch (err) { - console.error('Failed to send brightness via ESPNow:', err); - } - } - }, 150); - }); + // Keep header and menu brightness controls in sync. + const brightnessSlider = document.getElementById('header-brightness-slider'); + const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); + if (menuBrightnessSlider && brightnessSlider) { + menuBrightnessSlider.value = brightnessSlider.value; } // Trigger presets loading if the function exists @@ -967,6 +989,21 @@ document.addEventListener('DOMContentLoaded', () => { }); } + const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); + if (menuBrightnessSlider) { + menuBrightnessSlider.addEventListener('input', (e) => { + sendZoneBrightness(e.target.value); + }); + } + const headerBrightnessSlider = document.getElementById('header-brightness-slider'); + if (headerBrightnessSlider) { + headerBrightnessSlider.addEventListener('input', (e) => { + sendZoneBrightness(e.target.value); + }); + // Initial sync so both controls start aligned. + sendZoneBrightness(headerBrightnessSlider.value); + } + // When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately. document.querySelectorAll('.ui-mode-toggle').forEach((btn) => { btn.addEventListener('click', async () => { diff --git a/src/templates/index.html b/src/templates/index.html index b39d59a..9c82cda 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -15,6 +15,10 @@
+
+ + +
@@ -30,6 +34,10 @@