- 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
411 lines
14 KiB
JavaScript
411 lines
14 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 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);
|
||
}
|
||
|
||
});
|