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); } });