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.
583 lines
20 KiB
JavaScript
583 lines
20 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
const patternsButton = document.getElementById('patterns-btn');
|
|
const patternsModal = document.getElementById('patterns-modal');
|
|
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');
|
|
const patternCreateName = document.getElementById('pattern-create-name');
|
|
const patternCreateMinDelay = document.getElementById('pattern-create-min-delay');
|
|
const patternCreateMaxDelay = document.getElementById('pattern-create-max-delay');
|
|
const patternCreateMaxColors = document.getElementById('pattern-create-max-colors');
|
|
const patternCreateFile = document.getElementById('pattern-create-file');
|
|
const patternCreateCode = document.getElementById('pattern-create-code');
|
|
const patternCreateOverwrite = document.getElementById('pattern-create-overwrite');
|
|
const patternCreateN = [1, 2, 3, 4, 5, 6, 7, 8].map((i) =>
|
|
document.getElementById(`pattern-create-n${i}`),
|
|
);
|
|
const patternCreateNSection = document.getElementById('pattern-create-n-section');
|
|
const patternCreateNEmpty = document.getElementById('pattern-create-n-empty');
|
|
|
|
if (!patternsButton || !patternsModal || !patternsList) {
|
|
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 '';
|
|
}
|
|
const pm = meta.parameter_mappings;
|
|
if (pm && typeof pm === 'object' && typeof pm[key] === 'string') {
|
|
const s = pm[key].trim();
|
|
if (s) {
|
|
return s;
|
|
}
|
|
}
|
|
if (typeof meta[key] === 'string') {
|
|
return meta[key].trim();
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const setPatternEditorNFields = (mode, data) => {
|
|
const meta = data && typeof data === 'object' ? data : {};
|
|
let visible = 0;
|
|
const grid = patternCreateNSection && patternCreateNSection.querySelector('.n-params-grid');
|
|
const h3 = patternCreateNSection && patternCreateNSection.querySelector('h3');
|
|
|
|
for (let i = 1; i <= 8; i += 1) {
|
|
const key = `n${i}`;
|
|
const labelEl = document.querySelector(`label[for="pattern-create-${key}"]`);
|
|
const inputEl = document.getElementById(`pattern-create-${key}`);
|
|
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
|
|
|
|
if (mode === 'create') {
|
|
if (labelEl) {
|
|
labelEl.textContent = `${key}:`;
|
|
labelEl.style.display = '';
|
|
}
|
|
if (inputEl) {
|
|
inputEl.value = '';
|
|
inputEl.placeholder = 'Readable name (optional)';
|
|
inputEl.removeAttribute('aria-label');
|
|
}
|
|
if (groupEl) {
|
|
groupEl.style.display = '';
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const readable = nReadableStringFromMeta(meta, key);
|
|
const show = Boolean(readable);
|
|
if (labelEl) {
|
|
labelEl.textContent = '';
|
|
labelEl.style.display = 'none';
|
|
}
|
|
if (inputEl) {
|
|
inputEl.value = show ? readable : '';
|
|
inputEl.placeholder = '';
|
|
if (show) {
|
|
inputEl.setAttribute('aria-label', readable);
|
|
} else {
|
|
inputEl.removeAttribute('aria-label');
|
|
inputEl.value = '';
|
|
}
|
|
}
|
|
if (groupEl) {
|
|
groupEl.style.display = show ? '' : 'none';
|
|
}
|
|
if (show) {
|
|
visible += 1;
|
|
}
|
|
}
|
|
|
|
if (mode === 'create') {
|
|
if (patternCreateNEmpty) {
|
|
patternCreateNEmpty.style.display = 'none';
|
|
}
|
|
if (grid) {
|
|
grid.style.display = '';
|
|
}
|
|
if (h3) {
|
|
h3.style.display = '';
|
|
}
|
|
if (patternCreateNSection) {
|
|
patternCreateNSection.style.display = '';
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (patternCreateNEmpty) {
|
|
patternCreateNEmpty.style.display = visible === 0 ? '' : 'none';
|
|
}
|
|
if (grid) {
|
|
grid.style.display = visible === 0 ? 'none' : '';
|
|
}
|
|
if (h3) {
|
|
h3.style.display = visible === 0 ? 'none' : '';
|
|
}
|
|
};
|
|
|
|
const readFileAsText = (file) =>
|
|
new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(String(reader.result || ''));
|
|
reader.onerror = () => reject(reader.error || new Error('read failed'));
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
const collectCreatePayload = async () => {
|
|
const name = patternCreateName ? patternCreateName.value.trim() : '';
|
|
if (!name) {
|
|
throw new Error('Pattern name is required.');
|
|
}
|
|
let code = '';
|
|
const fileInput = patternCreateFile && patternCreateFile.files && patternCreateFile.files[0];
|
|
if (fileInput) {
|
|
code = await readFileAsText(fileInput);
|
|
} else if (patternCreateCode && patternCreateCode.value.trim()) {
|
|
code = patternCreateCode.value;
|
|
}
|
|
if (!code.trim()) {
|
|
throw new Error('Choose a .py file or paste source code.');
|
|
}
|
|
|
|
const payload = {
|
|
name,
|
|
code,
|
|
min_delay: parseInt(patternCreateMinDelay && patternCreateMinDelay.value, 10) || 0,
|
|
max_delay: parseInt(patternCreateMaxDelay && patternCreateMaxDelay.value, 10) || 0,
|
|
max_colors: parseInt(patternCreateMaxColors && patternCreateMaxColors.value, 10) || 0,
|
|
overwrite: !!(patternCreateOverwrite && patternCreateOverwrite.checked),
|
|
};
|
|
|
|
patternCreateN.forEach((el, idx) => {
|
|
const key = `n${idx + 1}`;
|
|
if (el && el.value.trim()) {
|
|
payload[key] = el.value.trim();
|
|
}
|
|
});
|
|
|
|
return payload;
|
|
};
|
|
|
|
const resetCreateForm = () => {
|
|
if (patternCreateName) patternCreateName.value = '';
|
|
if (patternCreateFile) patternCreateFile.value = '';
|
|
if (patternCreateCode) patternCreateCode.value = '';
|
|
if (patternCreateMinDelay) patternCreateMinDelay.value = '10';
|
|
if (patternCreateMaxDelay) patternCreateMaxDelay.value = '10000';
|
|
if (patternCreateMaxColors) patternCreateMaxColors.value = '10';
|
|
patternCreateN.forEach((el) => {
|
|
if (el) el.value = '';
|
|
});
|
|
if (patternCreateOverwrite) patternCreateOverwrite.checked = true;
|
|
setPatternEditorNFields('create', {});
|
|
};
|
|
|
|
if (patternCreateBtn) {
|
|
patternCreateBtn.addEventListener('click', async () => {
|
|
try {
|
|
const payload = await collectCreatePayload();
|
|
const response = await fetch('/patterns/driver', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error((data && data.error) || 'Create failed');
|
|
}
|
|
alert(data.message || 'Pattern created.');
|
|
resetCreateForm();
|
|
if (patternEditorModal) {
|
|
patternEditorModal.classList.remove('active');
|
|
}
|
|
await loadPatterns();
|
|
} catch (e) {
|
|
console.error('Create pattern failed:', e);
|
|
alert(e.message || 'Failed to create pattern.');
|
|
}
|
|
});
|
|
}
|
|
|
|
/** on/off are implemented in driver firmware (presets.py), not as OTA ``.py`` files. */
|
|
const FIRMWARE_BUILTIN_PATTERNS = new Set(['on', 'off']);
|
|
|
|
const isFirmwareBuiltinPattern = (patternName) => {
|
|
const id = String(patternName || '')
|
|
.trim()
|
|
.replace(/\.py$/i, '')
|
|
.toLowerCase();
|
|
return FIRMWARE_BUILTIN_PATTERNS.has(id);
|
|
};
|
|
|
|
const sendPatternToDevices = async (patternName) => {
|
|
const response = await fetch(`/patterns/${encodeURIComponent(patternName)}/send`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error((data && data.error) || 'Failed to send pattern');
|
|
}
|
|
const sentCount = data && typeof data.sent_count === 'number' ? data.sent_count : null;
|
|
if (sentCount === null) {
|
|
alert(`Sent "${patternName}" to devices.`);
|
|
} else {
|
|
alert(`Sent "${patternName}" to ${sentCount} device(s).`);
|
|
}
|
|
};
|
|
|
|
const loadPatternMetadata = async (patternName, fallbackData) => {
|
|
const raw = String(patternName || '').trim();
|
|
const norm = raw.endsWith('.py') ? raw.slice(0, -3).trim() : raw;
|
|
try {
|
|
const response = await fetch('/patterns/definitions', {
|
|
cache: 'no-store',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load pattern definitions');
|
|
}
|
|
const definitions = await response.json();
|
|
if (definitions && typeof definitions === 'object') {
|
|
if (definitions[raw]) {
|
|
return definitions[raw];
|
|
}
|
|
if (norm && definitions[norm]) {
|
|
return definitions[norm];
|
|
}
|
|
if (norm) {
|
|
const lower = norm.toLowerCase();
|
|
const matched = Object.keys(definitions).find(
|
|
(k) => String(k).toLowerCase() === lower,
|
|
);
|
|
if (matched) {
|
|
return definitions[matched];
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Load pattern definitions failed:', error);
|
|
}
|
|
return fallbackData || {};
|
|
};
|
|
|
|
const loadPatternIntoEditor = async (patternName, fallbackData) => {
|
|
const data = await loadPatternMetadata(patternName, fallbackData);
|
|
if (patternCreateName) {
|
|
patternCreateName.value = patternName;
|
|
}
|
|
if (patternCreateMinDelay) {
|
|
patternCreateMinDelay.value =
|
|
data && data.min_delay !== undefined ? String(data.min_delay) : '10';
|
|
}
|
|
if (patternCreateMaxDelay) {
|
|
patternCreateMaxDelay.value =
|
|
data && data.max_delay !== undefined ? String(data.max_delay) : '10000';
|
|
}
|
|
if (patternCreateMaxColors) {
|
|
patternCreateMaxColors.value =
|
|
data && data.max_colors !== undefined ? String(data.max_colors) : '10';
|
|
}
|
|
setPatternEditorNFields('edit', data);
|
|
if (patternCreateOverwrite) {
|
|
patternCreateOverwrite.checked = true;
|
|
}
|
|
if (patternCreateFile) {
|
|
patternCreateFile.value = '';
|
|
}
|
|
|
|
try {
|
|
const raw = String(patternName || '').trim();
|
|
const fileSegment = /\.py$/i.test(raw) ? raw : `${raw}.py`;
|
|
const response = await fetch(`/patterns/ota/file/${encodeURIComponent(fileSegment)}`, {
|
|
headers: { Accept: 'text/plain' },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load pattern file');
|
|
}
|
|
const source = await response.text();
|
|
if (patternCreateCode) {
|
|
patternCreateCode.value = source || '';
|
|
patternCreateCode.focus();
|
|
}
|
|
} catch (error) {
|
|
console.error('Load pattern source failed:', error);
|
|
alert('Could not load pattern source into editor.');
|
|
}
|
|
};
|
|
|
|
const renderPatterns = (patterns) => {
|
|
patternsList.innerHTML = '';
|
|
const entries = Object.entries(patterns || {});
|
|
if (!entries.length) {
|
|
const empty = document.createElement('p');
|
|
empty.className = 'muted-text';
|
|
empty.textContent = 'No patterns found.';
|
|
patternsList.appendChild(empty);
|
|
return;
|
|
}
|
|
entries.forEach(([patternName, data]) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'profiles-row';
|
|
|
|
const label = document.createElement('span');
|
|
label.textContent = patternName;
|
|
|
|
row.appendChild(label);
|
|
|
|
if (isFirmwareBuiltinPattern(patternName)) {
|
|
const note = document.createElement('span');
|
|
note.className = 'muted-text';
|
|
note.style.fontSize = '0.85em';
|
|
note.textContent = 'Built-in (no OTA module)';
|
|
row.appendChild(note);
|
|
} else {
|
|
const sendBtn = document.createElement('button');
|
|
sendBtn.className = 'btn btn-primary btn-small';
|
|
sendBtn.textContent = 'Send';
|
|
sendBtn.addEventListener('click', async () => {
|
|
try {
|
|
await sendPatternToDevices(patternName);
|
|
} catch (error) {
|
|
console.error('Send pattern failed:', error);
|
|
alert(error.message || 'Failed to send pattern.');
|
|
}
|
|
});
|
|
|
|
const editBtn = document.createElement('button');
|
|
editBtn.className = 'btn btn-secondary btn-small';
|
|
editBtn.textContent = 'Edit';
|
|
editBtn.addEventListener('click', async () => {
|
|
if (patternEditorModal) {
|
|
patternEditorModal.classList.add('active');
|
|
}
|
|
await loadPatternIntoEditor(patternName, data || {});
|
|
});
|
|
|
|
row.appendChild(editBtn);
|
|
row.appendChild(sendBtn);
|
|
}
|
|
|
|
patternsList.appendChild(row);
|
|
});
|
|
};
|
|
|
|
async function loadPatterns() {
|
|
patternsList.innerHTML = '';
|
|
const loading = document.createElement('p');
|
|
loading.className = 'muted-text';
|
|
loading.textContent = 'Loading patterns...';
|
|
patternsList.appendChild(loading);
|
|
|
|
try {
|
|
const response = await fetch('/patterns', {
|
|
cache: 'no-store',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load patterns');
|
|
}
|
|
const patterns = await response.json();
|
|
renderPatterns(patterns);
|
|
} catch (error) {
|
|
console.error('Load patterns failed:', error);
|
|
patternsList.innerHTML = '';
|
|
const errorMessage = document.createElement('p');
|
|
errorMessage.className = 'muted-text';
|
|
errorMessage.textContent = 'Failed to load patterns.';
|
|
patternsList.appendChild(errorMessage);
|
|
}
|
|
}
|
|
|
|
const openModal = () => {
|
|
patternsModal.classList.add('active');
|
|
loadPatterns();
|
|
};
|
|
|
|
const closeModal = () => {
|
|
patternsModal.classList.remove('active');
|
|
};
|
|
|
|
patternsButton.addEventListener('click', openModal);
|
|
if (patternAddButton) {
|
|
patternAddButton.addEventListener('click', () => {
|
|
resetCreateForm();
|
|
if (patternEditorModal) {
|
|
patternEditorModal.classList.add('active');
|
|
}
|
|
});
|
|
}
|
|
if (patternEditorCloseButton) {
|
|
patternEditorCloseButton.addEventListener('click', () => {
|
|
if (patternEditorModal) {
|
|
patternEditorModal.classList.remove('active');
|
|
}
|
|
});
|
|
}
|
|
if (patternsCloseButton) {
|
|
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.');
|
|
}
|
|
});
|
|
}
|
|
|
|
});
|