428 lines
14 KiB
JavaScript
428 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 = `${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);
|
|
}
|
|
|
|
});
|