Files
led-controller/src/static/patterns.js
pi e67de6215a feat(patterns,api): pattern OTA, graceful shutdown, driver delivery updates
- Pattern controller/UI and presets patterns tab for OTA to Wi-Fi drivers
- Device controller extensions; driver_delivery chunk handling
- main: SIGINT/SIGTERM shutdown, TCP/UDP server close coordination
- Submodule led-driver: Wi-Fi default transport, lazy espnow import, dynamic patterns

Made-with: Cursor
2026-04-11 15:10:23 +12:00

411 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 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 = '';
labelEl.style.display = 'none';
}
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.');
}
});
}
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', {
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 response = await fetch(`/patterns/ota/file/${encodeURIComponent(patternName)}.py`, {
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;
const details = document.createElement('span');
const minDelay = data && data.min_delay !== undefined ? data.min_delay : '-';
const maxDelay = data && data.max_delay !== undefined ? data.max_delay : '-';
details.textContent = `${minDelay}${maxDelay} ms`;
details.style.color = '#aaa';
details.style.fontSize = '0.85em';
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(label);
row.appendChild(details);
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', {
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);
}
});