2 Commits

Author SHA1 Message Date
0da30b6d6b fix(submodule): update led-tool pointer to existing commit 2026-04-30 23:28:39 +12:00
6cbb728d9a feat(patterns): add new pattern suite and improve mobile controls
Add a broad set of LED patterns with metadata/tests and update zone/profile preset seeding, while refining mobile/desktop UI behavior for scrolling, brightness controls, and bulk pattern sending.
2026-04-23 20:07:55 +12:00
12 changed files with 332 additions and 168 deletions

View File

@@ -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.

View File

@@ -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\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}}
"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 1030 s, -1=off)",
"n4": "Spark gap max (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"twinkle": {
"n1": "Twinkle activity (1255, higher = more changes)",
"n2": "Density (0255, 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
}
}

File diff suppressed because one or more lines are too long

View File

@@ -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"]}} {"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"]}}

1
led-simulator Submodule

Submodule led-simulator added at 7ce56b64df

View File

@@ -4,6 +4,7 @@ document.addEventListener('DOMContentLoaded', () => {
const patternsCloseButton = document.getElementById('patterns-close-btn'); const patternsCloseButton = document.getElementById('patterns-close-btn');
const patternsList = document.getElementById('patterns-list'); const patternsList = document.getElementById('patterns-list');
const patternAddButton = document.getElementById('pattern-add-btn'); const patternAddButton = document.getElementById('pattern-add-btn');
const patternSendAllButton = document.getElementById('pattern-send-all-btn');
const patternEditorModal = document.getElementById('pattern-editor-modal'); const patternEditorModal = document.getElementById('pattern-editor-modal');
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn'); const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
const patternCreateBtn = document.getElementById('pattern-create-btn'); const patternCreateBtn = document.getElementById('pattern-create-btn');
@@ -24,6 +25,71 @@ document.addEventListener('DOMContentLoaded', () => {
return; 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) => { const nReadableStringFromMeta = (meta, key) => {
if (!meta || typeof meta !== 'object') { if (!meta || typeof meta !== 'object') {
return ''; return '';
@@ -424,4 +490,93 @@ document.addEventListener('DOMContentLoaded', () => {
patternsCloseButton.addEventListener('click', closeModal); 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.');
}
});
}
}); });

View File

@@ -175,39 +175,6 @@ async function postDriverSequence(sequence, targetMacs, delayS) {
return res.json().catch(() => ({})); 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', () => { document.addEventListener('DOMContentLoaded', () => {
const presetsButton = document.getElementById('presets-btn'); const presetsButton = document.getElementById('presets-btn');
const presetsModal = document.getElementById('presets-modal'); 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 // Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
const presetId = currentEditId || payload.name; const presetId = currentEditId || payload.name;
// Try sends preset first, then select; never persist on device. // 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 section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section); const deviceNames = tabDeviceNamesFromSection(section);
const presetId = currentEditId || payload.name; const presetId = currentEditId || payload.name;
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
await updateTabDefaultPreset(presetId); 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'); 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 section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section); const deviceNames = tabDeviceNamesFromSection(section);
@@ -1388,18 +1356,18 @@ document.addEventListener('DOMContentLoaded', () => {
if (saved && typeof saved === 'object') { if (saved && typeof saved === 'object') {
if (currentEditId) { if (currentEditId) {
// PUT returns the preset object directly; use the existing ID // 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 { } else {
// POST returns { id: preset } // POST returns { id: preset }
const entries = Object.entries(saved); const entries = Object.entries(saved);
if (entries.length > 0) { if (entries.length > 0) {
const [newId, presetData] = entries[0]; const [newId, presetData] = entries[0];
await sendPresetViaEspNow(newId, presetData, deviceNames, true, false); await sendPresetViaEspNow(newId, presetData, deviceNames, false, false, '2');
} }
} }
} else { } else {
// Fallback: send what we just built // 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(); await loadPresets();
@@ -1454,7 +1422,14 @@ const coercePresetInt = (v, def = 0) => {
// 1) preset payload (optionally with save) // 1) preset payload (optionally with save)
// 2) optional select for device names (never with save) // 2) optional select for device names (never with save)
// saveToDevice defaults to true. // 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 { try {
const baseColors = Array.isArray(preset.colors) && preset.colors.length const baseColors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors ? preset.colors
@@ -1462,10 +1437,11 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
const paletteColors = await getCurrentProfilePaletteColors(); const paletteColors = await getCurrentProfilePaletteColors();
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors); const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
const presetMessage = { const presetMessage = {
v: '1', v: '1',
presets: { presets: {
[presetId]: { [wirePresetId]: {
pattern: preset.pattern || 'off', pattern: preset.pattern || 'off',
colors, colors,
delay: typeof preset.delay === 'number' ? preset.delay : 100, delay: typeof preset.delay === 'number' ? preset.delay : 100,
@@ -1486,7 +1462,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
presetMessage.save = true; presetMessage.save = true;
} }
if (setDefault) { if (setDefault) {
presetMessage.default = presetId; presetMessage.default = wirePresetId;
} }
const names = Array.isArray(deviceNames) ? deviceNames : []; const names = Array.isArray(deviceNames) ? deviceNames : [];
@@ -1502,7 +1478,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
const select = {}; const select = {};
names.forEach((name) => { names.forEach((name) => {
if (name) { if (name) {
select[name] = [presetId]; select[name] = [wirePresetId];
} }
}); });
if (Object.keys(select).length > 0) { if (Object.keys(select).length > 0) {
@@ -1879,7 +1855,8 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
button.classList.add('active'); button.classList.add('active');
selectedPresets[zoneId] = presetId; selectedPresets[zoneId] = presetId;
const section = row.closest('.presets-section'); 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); console.error(err);
}); });
}); });

View File

@@ -149,6 +149,40 @@ header h1 {
background-color: #333; 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 */ /* Header/menu actions that should only appear in Edit mode */
body.preset-ui-run .edit-mode-only { body.preset-ui-run .edit-mode-only {
display: none !important; display: none !important;
@@ -248,7 +282,8 @@ body.preset-ui-run .edit-mode-only {
display: block; display: block;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; 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 { .presets-toolbar {
@@ -528,6 +563,12 @@ body.preset-ui-run .edit-mode-only {
row-gap: 0.3rem; row-gap: 0.3rem;
align-content: start; align-content: start;
width: 100%; 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 */ /* Settings modal layout */
@@ -949,7 +990,7 @@ body.preset-ui-run .edit-mode-only {
} }
/* Mobile-friendly layout */ /* Mobile-friendly layout */
@media (max-width: 800px) { @media (max-width: 1000px) {
header { header {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -1001,6 +1042,9 @@ body.preset-ui-run .edit-mode-only {
min-width: 280px; min-width: 280px;
max-width: 95vw; max-width: 95vw;
padding: 1.25rem; padding: 1.25rem;
max-height: calc(100dvh - 1rem);
overflow-y: auto;
padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
} }
.form-row { .form-row {
@@ -1018,6 +1062,10 @@ body.preset-ui-run .edit-mode-only {
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(0,0,0,0.7); background-color: rgba(0,0,0,0.7);
overflow-y: auto;
padding: 1rem;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
} }
.modal.active { .modal.active {
display: flex; display: flex;
@@ -1030,6 +1078,20 @@ body.preset-ui-run .edit-mode-only {
border-radius: 8px; border-radius: 8px;
min-width: 400px; min-width: 400px;
max-width: 600px; 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 { .modal-content label {
display: block; display: block;
@@ -1200,9 +1262,11 @@ body.preset-ui-run .edit-mode-only {
min-height: 80px; min-height: 80px;
} }
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */ /* Presets list: 3 columns and vertical scroll (defined above); mobile same */
@media (max-width: 800px) { @media (max-width: 1000px) {
#presets-list-zone { #presets-list-zone {
grid-template-columns: repeat(3, minmax(0, 1fr)); 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 */ /* Help modal readability */

View File

@@ -1,5 +1,47 @@
// Zone management JavaScript // Zone management JavaScript
let currentZoneId = null; 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 isEditModeActive = () => {
const toggle = document.querySelector('.ui-mode-toggle'); const toggle = document.querySelector('.ui-mode-toggle');
@@ -468,37 +510,17 @@ async function loadZoneContent(zoneId) {
const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : ""; const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : "";
container.innerHTML = ` container.innerHTML = `
<div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}> <div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}>
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
<div class="zone-brightness-group">
<label for="zone-brightness-slider">Brightness</label>
<input type="range" id="zone-brightness-slider" min="0" max="255" value="255">
</div>
</div>
<div id="presets-list-zone" class="presets-list"> <div id="presets-list-zone" class="presets-list">
<!-- Presets will be loaded here by presets.js --> <!-- Presets will be loaded here by presets.js -->
</div> </div>
</div> </div>
`; `;
// Wire up per-zone brightness slider to send global brightness via ESPNow. // Keep header and menu brightness controls in sync.
const brightnessSlider = container.querySelector('#zone-brightness-slider'); const brightnessSlider = document.getElementById('header-brightness-slider');
let brightnessSendTimeout = null; const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
if (brightnessSlider) { if (menuBrightnessSlider && brightnessSlider) {
brightnessSlider.addEventListener('input', (e) => { menuBrightnessSlider.value = brightnessSlider.value;
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);
});
} }
// Trigger presets loading if the function exists // 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. // When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => { document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {

View File

@@ -15,6 +15,10 @@
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<div class="header-brightness-control">
<label for="header-brightness-slider">Brightness</label>
<input type="range" id="header-brightness-slider" min="0" max="255" value="255">
</div>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button> <button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button> <button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button> <button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
@@ -30,6 +34,10 @@
<button class="btn btn-secondary" id="main-menu-btn">Menu</button> <button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown"> <div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button> <button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<div class="menu-brightness-control">
<label for="menu-brightness-slider">Brightness</label>
<input type="range" id="menu-brightness-slider" min="0" max="255" value="255">
</div>
<button type="button" data-target="profiles-btn">Profiles</button> <button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button> <button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button> <button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
@@ -245,6 +253,7 @@
<h2>Patterns</h2> <h2>Patterns</h2>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button> <button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="pattern-send-all-btn">Send All Patterns</button>
</div> </div>
<div id="patterns-list" class="profiles-list"></div> <div id="patterns-list" class="profiles-list"></div>
<div class="modal-actions"> <div class="modal-actions">