/** * Targets tab — combined view of devices, LED targets, KC targets, pattern templates. */ import { apiKey, _targetEditorDevices, set_targetEditorDevices, _deviceBrightnessCache, kcWebSockets, ledPreviewWebSockets, _cachedValueSources, set_cachedValueSources, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm, formatUptime, setTabRefreshing } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, _computeMaxFps } from './devices.js'; import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js'; import { createColorStripCard } from './color-strips.js'; import { getValueSourceIcon, getTargetTypeIcon, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, } from '../core/icons.js'; import { CardSection } from '../core/card-sections.js'; import { updateSubTabHash, updateTabBadge } from './tabs.js'; // createPatternTemplateCard is imported via window.* to avoid circular deps // (pattern-templates.js calls window.loadTargetsTab) // ── Card section instances ── const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id' }); const csColorStrips = new CardSection('led-css', { titleKey: 'targets.section.color_strips', gridClass: 'devices-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' }); const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id' }); const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id' }); const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id' }); // Re-render targets tab when language changes (only if tab is active) document.addEventListener('languageChanged', () => { if (apiKey && localStorage.getItem('activeTab') === 'targets') loadTargetsTab(); }); // --- FPS sparkline history and chart instances for target cards --- const _TARGET_MAX_FPS_SAMPLES = 30; const _targetFpsHistory = {}; // fps_actual (rolling avg) const _targetFpsCurrentHistory = {}; // fps_current (sends/sec) const _targetFpsCharts = {}; function _pushTargetFps(targetId, actual, current) { if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = []; const h = _targetFpsHistory[targetId]; h.push(actual); if (h.length > _TARGET_MAX_FPS_SAMPLES) h.shift(); if (!_targetFpsCurrentHistory[targetId]) _targetFpsCurrentHistory[targetId] = []; const c = _targetFpsCurrentHistory[targetId]; c.push(current); if (c.length > _TARGET_MAX_FPS_SAMPLES) c.shift(); } function _createTargetFpsChart(canvasId, actualHistory, currentHistory, fpsTarget, maxHwFps) { const canvas = document.getElementById(canvasId); if (!canvas) return null; const labels = actualHistory.map(() => ''); const datasets = [ { data: [...actualHistory], borderColor: '#2196F3', backgroundColor: 'rgba(33,150,243,0.12)', borderWidth: 1.5, tension: 0.3, fill: true, pointRadius: 0, }, { data: [...currentHistory], borderColor: '#4CAF50', borderWidth: 1.5, tension: 0.3, fill: false, pointRadius: 0, }, ]; // Flat line showing hardware max FPS if (maxHwFps && maxHwFps < fpsTarget * 1.15) { datasets.push({ data: actualHistory.map(() => maxHwFps), borderColor: 'rgba(255,152,0,0.5)', borderWidth: 1, borderDash: [4, 3], pointRadius: 0, fill: false, }); } return new Chart(canvas, { type: 'line', data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { display: false }, tooltip: { display: false } }, scales: { x: { display: false }, y: { display: false, min: 0, max: fpsTarget * 1.15 }, }, }, }); } function _updateTargetFpsChart(targetId, fpsTarget) { const chart = _targetFpsCharts[targetId]; if (!chart) return; const actualH = _targetFpsHistory[targetId] || []; const currentH = _targetFpsCurrentHistory[targetId] || []; chart.data.labels = actualH.map(() => ''); chart.data.datasets[0].data = [...actualH]; chart.data.datasets[1].data = [...currentH]; chart.options.scales.y.max = fpsTarget * 1.15; chart.update('none'); } function _updateSubTabCounts(subTabs) { subTabs.forEach(tab => { const btn = document.querySelector(`.target-sub-tab-btn[data-target-sub-tab="${tab.key}"] .stream-tab-count`); if (btn) btn.textContent = tab.count; }); } // --- Editor state --- let _editorCssSources = []; // populated when editor opens class TargetEditorModal extends Modal { constructor() { super('target-editor-modal'); } snapshotValues() { return { name: document.getElementById('target-editor-name').value, device: document.getElementById('target-editor-device').value, css_source: document.getElementById('target-editor-css-source').value, brightness_vs: document.getElementById('target-editor-brightness-vs').value, brightness_threshold: document.getElementById('target-editor-brightness-threshold').value, fps: document.getElementById('target-editor-fps').value, keepalive_interval: document.getElementById('target-editor-keepalive-interval').value, }; } } const targetEditorModal = new TargetEditorModal(); let _targetNameManuallyEdited = false; function _autoGenerateTargetName() { if (_targetNameManuallyEdited) return; if (document.getElementById('target-editor-id').value) return; const deviceSelect = document.getElementById('target-editor-device'); const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || ''; const cssSelect = document.getElementById('target-editor-css-source'); const cssName = cssSelect?.selectedOptions[0]?.dataset?.name || ''; if (!deviceName || !cssName) return; document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`; } function _updateFpsRecommendation() { const el = document.getElementById('target-editor-fps-rec'); const deviceSelect = document.getElementById('target-editor-device'); const device = _targetEditorDevices.find(d => d.id === deviceSelect.value); if (!device || !device.led_count) { el.style.display = 'none'; return; } const fps = _computeMaxFps(device.baud_rate, device.led_count, device.device_type); if (fps !== null) { el.textContent = t('targets.fps.rec', { fps, leds: device.led_count }); el.style.display = ''; } else { el.style.display = 'none'; } } function _updateDeviceInfo() { const deviceSelect = document.getElementById('target-editor-device'); const el = document.getElementById('target-editor-device-info'); const device = _targetEditorDevices.find(d => d.id === deviceSelect.value); if (device && device.led_count) { el.textContent = `${device.led_count} LEDs`; el.style.display = ''; } else { el.style.display = 'none'; } } function _updateKeepaliveVisibility() { const deviceSelect = document.getElementById('target-editor-device'); const keepaliveGroup = document.getElementById('target-editor-keepalive-group'); const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value); const caps = selectedDevice?.capabilities || []; keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none'; } function _updateBrightnessThresholdVisibility() { // Always visible — threshold considers both brightness source and pixel content document.getElementById('target-editor-brightness-threshold-group').style.display = ''; } function _populateCssDropdown(selectedId = '') { const select = document.getElementById('target-editor-css-source'); select.innerHTML = _editorCssSources.map(s => `` ).join(''); } function _populateBrightnessVsDropdown(selectedId = '') { const select = document.getElementById('target-editor-brightness-vs'); let html = ``; _cachedValueSources.forEach(vs => { const icon = getValueSourceIcon(vs.source_type); html += ``; }); select.innerHTML = html; } export async function showTargetEditor(targetId = null, cloneData = null) { try { // Load devices, CSS sources, and value sources for dropdowns const [devicesResp, cssResp, vsResp] = await Promise.all([ fetch(`${API_BASE}/devices`, { headers: getHeaders() }), fetchWithAuth('/color-strip-sources'), fetchWithAuth('/value-sources'), ]); const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : []; const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : []; if (vsResp.ok) { const vsData = await vsResp.json(); set_cachedValueSources(vsData.sources || []); } set_targetEditorDevices(devices); _editorCssSources = cssSources; // Populate device select const deviceSelect = document.getElementById('target-editor-device'); deviceSelect.innerHTML = ''; devices.forEach(d => { const opt = document.createElement('option'); opt.value = d.id; opt.dataset.name = d.name; const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : ''; const devType = (d.device_type || 'wled').toUpperCase(); opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`; deviceSelect.appendChild(opt); }); if (targetId) { // Editing existing target const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() }); if (!resp.ok) throw new Error('Failed to load target'); const target = await resp.json(); document.getElementById('target-editor-id').value = target.id; document.getElementById('target-editor-name').value = target.name; deviceSelect.value = target.device_id || ''; const fps = target.fps ?? 30; document.getElementById('target-editor-fps').value = fps; document.getElementById('target-editor-fps-value').textContent = fps; document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0; document.getElementById('target-editor-title').textContent = t('targets.edit'); const thresh = target.min_brightness_threshold ?? 0; document.getElementById('target-editor-brightness-threshold').value = thresh; document.getElementById('target-editor-brightness-threshold-value').textContent = thresh; _populateCssDropdown(target.color_strip_source_id || ''); _populateBrightnessVsDropdown(target.brightness_value_source_id || ''); } else if (cloneData) { // Cloning — create mode but pre-filled from clone data document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-name').value = (cloneData.name || '') + ' (Copy)'; deviceSelect.value = cloneData.device_id || ''; const fps = cloneData.fps ?? 30; document.getElementById('target-editor-fps').value = fps; document.getElementById('target-editor-fps-value').textContent = fps; document.getElementById('target-editor-keepalive-interval').value = cloneData.keepalive_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0; document.getElementById('target-editor-title').textContent = t('targets.add'); const cloneThresh = cloneData.min_brightness_threshold ?? 0; document.getElementById('target-editor-brightness-threshold').value = cloneThresh; document.getElementById('target-editor-brightness-threshold-value').textContent = cloneThresh; _populateCssDropdown(cloneData.color_strip_source_id || ''); _populateBrightnessVsDropdown(cloneData.brightness_value_source_id || ''); } else { // Creating new target document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-name').value = ''; document.getElementById('target-editor-fps').value = 30; document.getElementById('target-editor-fps-value').textContent = '30'; document.getElementById('target-editor-keepalive-interval').value = 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0'; document.getElementById('target-editor-title').textContent = t('targets.add'); document.getElementById('target-editor-brightness-threshold').value = 0; document.getElementById('target-editor-brightness-threshold-value').textContent = '0'; _populateCssDropdown(''); _populateBrightnessVsDropdown(''); } // Auto-name generation _targetNameManuallyEdited = !!(targetId || cloneData); document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; window._targetAutoName = _autoGenerateTargetName; deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; document.getElementById('target-editor-css-source').onchange = () => { _autoGenerateTargetName(); }; document.getElementById('target-editor-brightness-vs').onchange = () => { _updateBrightnessThresholdVisibility(); }; if (!targetId && !cloneData) _autoGenerateTargetName(); // Show/hide conditional fields _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _updateBrightnessThresholdVisibility(); targetEditorModal.snapshot(); targetEditorModal.open(); document.getElementById('target-editor-error').style.display = 'none'; setTimeout(() => document.getElementById('target-editor-name').focus(), 100); } catch (error) { console.error('Failed to open target editor:', error); showToast('Failed to open target editor', 'error'); } } export function isTargetEditorDirty() { return targetEditorModal.isDirty(); } export async function closeTargetEditorModal() { await targetEditorModal.close(); } export function forceCloseTargetEditorModal() { targetEditorModal.forceClose(); } export async function saveTargetEditor() { const targetId = document.getElementById('target-editor-id').value; const name = document.getElementById('target-editor-name').value.trim(); const deviceId = document.getElementById('target-editor-device').value; const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value); if (!name) { targetEditorModal.showError(t('targets.error.name_required')); return; } const fps = parseInt(document.getElementById('target-editor-fps').value) || 30; const colorStripSourceId = document.getElementById('target-editor-css-source').value; const brightnessVsId = document.getElementById('target-editor-brightness-vs').value; const minBrightnessThreshold = parseInt(document.getElementById('target-editor-brightness-threshold').value) || 0; const payload = { name, device_id: deviceId, color_strip_source_id: colorStripSourceId, brightness_value_source_id: brightnessVsId, min_brightness_threshold: minBrightnessThreshold, fps, keepalive_interval: standbyInterval, }; try { let response; if (targetId) { response = await fetchWithAuth(`/picture-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload), }); } else { payload.target_type = 'led'; response = await fetchWithAuth('/picture-targets', { method: 'POST', body: JSON.stringify(payload), }); } if (!response.ok) { const err = await response.json(); throw new Error(err.detail || 'Failed to save'); } showToast(targetId ? t('targets.updated') : t('targets.created'), 'success'); targetEditorModal.forceClose(); await loadTargetsTab(); } catch (error) { if (error.isAuth) return; console.error('Error saving target:', error); targetEditorModal.showError(error.message); } } // ===== TARGETS TAB (WLED devices + targets combined) ===== export async function loadTargets() { // Alias for backward compatibility await loadTargetsTab(); } export function switchTargetSubTab(tabKey) { document.querySelectorAll('.target-sub-tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.targetSubTab === tabKey) ); document.querySelectorAll('.target-sub-tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`) ); localStorage.setItem('activeTargetSubTab', tabKey); updateSubTabHash('targets', tabKey); } export function expandAllTargetSections() { const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led'; const sections = activeSubTab === 'key_colors' ? [csKCTargets, csPatternTemplates] : [csDevices, csColorStrips, csLedTargets]; CardSection.expandAll(sections); } export function collapseAllTargetSections() { const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led'; const sections = activeSubTab === 'key_colors' ? [csKCTargets, csPatternTemplates] : [csDevices, csColorStrips, csLedTargets]; CardSection.collapseAll(sections); } let _loadTargetsLock = false; let _actionInFlight = false; export async function loadTargetsTab() { const container = document.getElementById('targets-panel-content'); if (!container) return; // Skip if another loadTargetsTab or a button action is already running if (_loadTargetsLock || _actionInFlight) return; _loadTargetsLock = true; setTabRefreshing('targets-panel-content', true); try { // Fetch devices, targets, CSS sources, picture sources, pattern templates, and value sources in parallel const [devicesResp, targetsResp, cssResp, psResp, patResp, vsResp, asResp] = await Promise.all([ fetchWithAuth('/devices'), fetchWithAuth('/picture-targets'), fetchWithAuth('/color-strip-sources').catch(() => null), fetchWithAuth('/picture-sources').catch(() => null), fetchWithAuth('/pattern-templates').catch(() => null), fetchWithAuth('/value-sources').catch(() => null), fetchWithAuth('/audio-sources').catch(() => null), ]); const devicesData = await devicesResp.json(); const devices = devicesData.devices || []; const targetsData = await targetsResp.json(); const targets = targetsData.targets || []; let colorStripSourceMap = {}; if (cssResp && cssResp.ok) { const cssData = await cssResp.json(); (cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; }); } let pictureSourceMap = {}; if (psResp && psResp.ok) { const psData = await psResp.json(); (psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; }); } let patternTemplates = []; let patternTemplateMap = {}; if (patResp && patResp.ok) { const patData = await patResp.json(); patternTemplates = patData.templates || []; patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; }); } let valueSourceMap = {}; if (vsResp && vsResp.ok) { const vsData = await vsResp.json(); (vsData.sources || []).forEach(s => { valueSourceMap[s.id] = s; }); } let audioSourceMap = {}; if (asResp && asResp.ok) { const asData = await asResp.json(); (asData.sources || []).forEach(s => { audioSourceMap[s.id] = s; }); } // Fetch all device states, target states, and target metrics in batch const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([ fetchWithAuth('/devices/batch/states'), fetchWithAuth('/picture-targets/batch/states'), fetchWithAuth('/picture-targets/batch/metrics'), ]); const allDeviceStates = batchDevStatesResp.ok ? (await batchDevStatesResp.json()).states : {}; const allTargetStates = batchTgtStatesResp.ok ? (await batchTgtStatesResp.json()).states : {}; const allTargetMetrics = batchTgtMetricsResp.ok ? (await batchTgtMetricsResp.json()).metrics : {}; const devicesWithState = devices.map(d => ({ ...d, state: allDeviceStates[d.id] || {} })); // Enrich targets with state/metrics; fetch colors only for running KC targets const targetsWithState = await Promise.all( targets.map(async (target) => { const state = allTargetStates[target.id] || {}; const metrics = allTargetMetrics[target.id] || {}; let latestColors = null; if (target.target_type === 'key_colors' && state.processing) { try { const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() }); if (colorsResp.ok) latestColors = await colorsResp.json(); } catch {} } return { ...target, state, metrics, latestColors }; }) ); // Build device map for target name resolution const deviceMap = {}; devicesWithState.forEach(d => { deviceMap[d.id] = d; }); // Group by type const ledDevices = devicesWithState; const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled'); const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors'); // Update tab badge with running target count const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length; updateTabBadge('targets', runningCount); // Backward compat: map stored "wled" sub-tab to "led" let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led'; if (activeSubTab === 'wled') activeSubTab = 'led'; const subTabs = [ { key: 'led', icon: getTargetTypeIcon('led'), titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length }, { key: 'key_colors', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length }, ]; const tabBar = `
${subTabs.map(tab => `` ).join('')}
`; // Use window.createPatternTemplateCard to avoid circular import const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); // Build items arrays for each section const deviceItems = ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })); const cssItems = Object.values(colorStripSourceMap).map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })); const ledTargetItems = ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })); const kcTargetItems = kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })); const patternItems = patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })); // Track which target cards were replaced/added (need chart re-init) let changedTargetIds = null; if (csDevices.isMounted()) { // ── Incremental update: reconcile cards in-place ── _updateSubTabCounts(subTabs); csDevices.reconcile(deviceItems); csColorStrips.reconcile(cssItems); const ledResult = csLedTargets.reconcile(ledTargetItems); const kcResult = csKCTargets.reconcile(kcTargetItems); csPatternTemplates.reconcile(patternItems); changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed, ...kcResult.added, ...kcResult.replaced, ...kcResult.removed]); // Re-render cached LED preview frames onto new canvas elements after reconciliation for (const id of ledResult.replaced) { const frame = _ledPreviewLastFrame[id]; if (frame && ledPreviewWebSockets[id]) { const canvas = document.getElementById(`led-preview-canvas-${id}`); if (canvas) _renderLedStrip(canvas, frame); } } } else { // ── First render: build full HTML ── const ledPanel = `
${csDevices.render(deviceItems)} ${csColorStrips.render(cssItems)} ${csLedTargets.render(ledTargetItems)}
`; const kcPanel = `
${csKCTargets.render(kcTargetItems)} ${csPatternTemplates.render(patternItems)}
`; container.innerHTML = tabBar + ledPanel + kcPanel; CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]); } // Patch volatile metrics in-place (avoids full card replacement on polls) for (const tgt of ledTargets) { if (tgt.state && tgt.state.processing) _patchTargetMetrics(tgt); } for (const tgt of kcTargets) { if (tgt.state && tgt.state.processing) patchKCTargetMetrics(tgt); } // Attach event listeners and fetch brightness for device cards devicesWithState.forEach(device => { attachDeviceListeners(device.id); if ((device.capabilities || []).includes('brightness_control')) { if (device.id in _deviceBrightnessCache) { const bri = _deviceBrightnessCache[device.id]; const slider = document.querySelector(`[data-device-brightness="${device.id}"]`); if (slider) { slider.value = bri; slider.title = Math.round(bri / 255 * 100) + '%'; slider.disabled = false; } const wrap = document.querySelector(`[data-brightness-wrap="${device.id}"]`); if (wrap) wrap.classList.remove('brightness-loading'); } else { fetchDeviceBrightness(device.id); } } }); // Manage KC WebSockets: connect for processing, disconnect for stopped const processingKCIds = new Set(); kcTargets.forEach(target => { if (target.state && target.state.processing) { processingKCIds.add(target.id); if (!kcWebSockets[target.id]) { connectKCWebSocket(target.id); } } }); Object.keys(kcWebSockets).forEach(id => { if (!processingKCIds.has(id)) disconnectKCWebSocket(id); }); // Auto-disconnect LED preview WebSockets for targets that stopped const processingLedIds = new Set(); ledTargets.forEach(target => { if (target.state && target.state.processing) processingLedIds.add(target.id); }); Object.keys(ledPreviewWebSockets).forEach(id => { if (!processingLedIds.has(id)) disconnectLedPreviewWS(id); }); // FPS charts: only destroy charts for replaced/removed cards (or all on first render) if (changedTargetIds) { // Incremental: destroy only charts whose cards were replaced or removed for (const id of changedTargetIds) { if (_targetFpsCharts[id]) { _targetFpsCharts[id].destroy(); delete _targetFpsCharts[id]; } } } else { // First render: destroy all old charts for (const id of Object.keys(_targetFpsCharts)) { _targetFpsCharts[id].destroy(); delete _targetFpsCharts[id]; } } // Push FPS samples and create/update charts for running targets const allTargets = [...ledTargets, ...kcTargets]; const runningIds = new Set(); allTargets.forEach(target => { if (target.state && target.state.processing) { runningIds.add(target.id); if (target.state.fps_actual != null) { _pushTargetFps(target.id, target.state.fps_actual, target.state.fps_current ?? 0); } // Create chart if it doesn't exist (new or replaced card) if (!_targetFpsCharts[target.id]) { const actualH = _targetFpsHistory[target.id] || []; const currentH = _targetFpsCurrentHistory[target.id] || []; const fpsTarget = target.state.fps_target || 30; const device = devices.find(d => d.id === target.device_id); const maxHwFps = device ? _computeMaxFps(device.baud_rate, device.led_count, device.device_type) : null; const chart = _createTargetFpsChart(`target-fps-${target.id}`, actualH, currentH, fpsTarget, maxHwFps); if (chart) _targetFpsCharts[target.id] = chart; } else { // Chart survived reconcile — just update data _updateTargetFpsChart(target.id, target.state.fps_target || 30); } } }); // Clean up history and charts for targets no longer running Object.keys(_targetFpsHistory).forEach(id => { if (!runningIds.has(id)) { delete _targetFpsHistory[id]; delete _targetFpsCurrentHistory[id]; } }); Object.keys(_targetFpsCharts).forEach(id => { if (!runningIds.has(id)) { _targetFpsCharts[id].destroy(); delete _targetFpsCharts[id]; } }); } catch (error) { if (error.isAuth) return; console.error('Failed to load targets tab:', error); container.innerHTML = `
${t('targets.failed')}
`; } finally { _loadTargetsLock = false; setTabRefreshing('targets-panel-content', false); } } function _cssSourceName(cssId, colorStripSourceMap) { if (!cssId) return t('targets.no_css'); const css = colorStripSourceMap[cssId]; return css ? escapeHtml(css.name) : escapeHtml(cssId); } // ─── In-place metric patching (avoids full card replacement on polls) ─── function _buildLedTimingHTML(state) { const isAudio = state.timing_audio_read_ms != null; return `
${t('device.metrics.timing')}
${state.timing_total_ms}ms
${isAudio ? ` ` : ` ${state.timing_extract_ms != null ? `` : ''} ${state.timing_map_leds_ms != null ? `` : ''} ${state.timing_smooth_ms != null ? `` : ''} `}
${isAudio ? ` read ${state.timing_audio_read_ms}ms fft ${state.timing_audio_fft_ms}ms render ${state.timing_audio_render_ms}ms ` : ` ${state.timing_extract_ms != null ? `extract ${state.timing_extract_ms}ms` : ''} ${state.timing_map_leds_ms != null ? `map ${state.timing_map_leds_ms}ms` : ''} ${state.timing_smooth_ms != null ? `smooth ${state.timing_smooth_ms}ms` : ''} `} send ${state.timing_send_ms}ms
`; } function _patchTargetMetrics(target) { const container = document.getElementById('targets-panel-content'); if (!container) return; const card = container.querySelector(`[data-target-id="${target.id}"]`); if (!card) return; const state = target.state || {}; const metrics = target.metrics || {}; const fps = card.querySelector('[data-tm="fps"]'); if (fps) fps.innerHTML = `${state.fps_current ?? 0}/${state.fps_target || 0}` + `avg ${state.fps_actual?.toFixed(1) || '0.0'}`; const timing = card.querySelector('[data-tm="timing"]'); if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state); const frames = card.querySelector('[data-tm="frames"]'); if (frames) frames.textContent = metrics.frames_processed || 0; const keepalive = card.querySelector('[data-tm="keepalive"]'); if (keepalive) keepalive.textContent = state.frames_keepalive ?? '-'; const errors = card.querySelector('[data-tm="errors"]'); if (errors) errors.textContent = metrics.errors_count || 0; const uptime = card.querySelector('[data-tm="uptime"]'); if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds); } export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSourceMap) { const state = target.state || {}; const isProcessing = state.processing || false; const device = deviceMap[target.device_id]; const deviceName = device ? device.name : (target.device_id || 'No device'); const cssId = target.color_strip_source_id || ''; const cssSummary = _cssSourceName(cssId, colorStripSourceMap); const bvsId = target.brightness_value_source_id || ''; const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null; // Determine if overlay is available (picture-based CSS) const css = cssId ? colorStripSourceMap[cssId] : null; const overlayAvailable = !css || css.source_type === 'picture'; // Health info from target state (forwarded from device) const devOnline = state.device_online || false; let healthClass = 'health-unknown'; let healthTitle = ''; if (state.device_last_checked !== null && state.device_last_checked !== undefined) { healthClass = devOnline ? 'health-online' : 'health-offline'; healthTitle = devOnline ? t('device.health.online') : t('device.health.offline'); } return `
${escapeHtml(target.name)} ${isProcessing ? `${t('device.status.processing')}` : ''}
${ICON_LED} ${escapeHtml(deviceName)} ${ICON_FPS} ${target.fps || 30} 🎞️ ${cssSummary} ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''} ${target.min_brightness_threshold > 0 ? `🔅 <${target.min_brightness_threshold} → off` : ''}
${isProcessing ? `
---
${state.timing_total_ms != null ? `
` : ''}
${t('device.metrics.frames')}
---
${state.needs_keepalive !== false ? `
${t('device.metrics.keepalive')}
---
` : ''}
${t('device.metrics.errors')}
---
${t('device.metrics.uptime')}
---
` : ''}
${isProcessing ? ` ` : ` `} ${isProcessing ? ` ` : ''} ${overlayAvailable ? (state.overlay_active ? ` ` : ` `) : ''}
`; } async function _targetAction(action) { _actionInFlight = true; try { await action(); } finally { _actionInFlight = false; _loadTargetsLock = false; // ensure next poll can run loadTargetsTab(); } } export async function startTargetProcessing(targetId) { await _targetAction(async () => { const response = await fetchWithAuth(`/picture-targets/${targetId}/start`, { method: 'POST', }); if (response.ok) { showToast(t('device.started'), 'success'); } else { const error = await response.json(); showToast(`Failed to start: ${error.detail}`, 'error'); } }); } export async function stopTargetProcessing(targetId) { await _targetAction(async () => { const response = await fetchWithAuth(`/picture-targets/${targetId}/stop`, { method: 'POST', }); if (response.ok) { showToast(t('device.stopped'), 'success'); } else { const error = await response.json(); showToast(`Failed to stop: ${error.detail}`, 'error'); } }); } export async function startTargetOverlay(targetId) { await _targetAction(async () => { const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/start`, { method: 'POST', }); if (response.ok) { showToast(t('overlay.started'), 'success'); } else { const error = await response.json(); showToast(t('overlay.error.start') + ': ' + error.detail, 'error'); } }); } export async function stopTargetOverlay(targetId) { await _targetAction(async () => { const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/stop`, { method: 'POST', }); if (response.ok) { showToast(t('overlay.stopped'), 'success'); } else { const error = await response.json(); showToast(t('overlay.error.stop') + ': ' + error.detail, 'error'); } }); } export async function cloneTarget(targetId) { try { const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() }); if (!resp.ok) throw new Error('Failed to load target'); const target = await resp.json(); showTargetEditor(null, target); } catch (error) { console.error('Failed to clone target:', error); showToast('Failed to clone target', 'error'); } } export async function toggleTargetAutoStart(targetId, enable) { try { const response = await fetchWithAuth(`/picture-targets/${targetId}`, { method: 'PUT', body: JSON.stringify({ auto_start: enable }), }); if (response.ok) { showToast(t(enable ? 'autostart.toggle.enabled' : 'autostart.toggle.disabled'), 'success'); loadTargetsTab(); } else { const error = await response.json(); showToast(`Failed: ${error.detail}`, 'error'); } } catch (error) { console.error('Failed to toggle auto-start:', error); showToast('Failed to toggle auto-start', 'error'); } } export async function deleteTarget(targetId) { const confirmed = await showConfirm(t('targets.delete.confirm')); if (!confirmed) return; await _targetAction(async () => { const response = await fetchWithAuth(`/picture-targets/${targetId}`, { method: 'DELETE', }); if (response.ok) { showToast(t('targets.deleted'), 'success'); } else { const error = await response.json(); showToast(`Failed to delete: ${error.detail}`, 'error'); } }); } /* ── LED Strip Preview ────────────────────────────────────────── */ const _ledPreviewLastFrame = {}; function _renderLedStrip(canvas, rgbBytes) { const ledCount = rgbBytes.length / 3; if (ledCount <= 0) return; // Set canvas resolution to match LED count (1px per LED) canvas.width = ledCount; canvas.height = 1; const ctx = canvas.getContext('2d'); const imageData = ctx.createImageData(ledCount, 1); const data = imageData.data; for (let i = 0; i < ledCount; i++) { const si = i * 3; const di = i * 4; data[di] = rgbBytes[si]; data[di + 1] = rgbBytes[si + 1]; data[di + 2] = rgbBytes[si + 2]; data[di + 3] = 255; } ctx.putImageData(imageData, 0, 0); } function connectLedPreviewWS(targetId) { disconnectLedPreviewWS(targetId); const key = localStorage.getItem('wled_api_key'); if (!key) return; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}${API_BASE}/picture-targets/${targetId}/led-preview/ws?token=${encodeURIComponent(key)}`; try { const ws = new WebSocket(wsUrl); ws.binaryType = 'arraybuffer'; ws.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { const raw = new Uint8Array(event.data); // Wire format: [brightness_byte] [R G B R G B ...] const brightness = raw[0]; const frame = raw.subarray(1); _ledPreviewLastFrame[targetId] = frame; const canvas = document.getElementById(`led-preview-canvas-${targetId}`); if (canvas) _renderLedStrip(canvas, frame); // Show brightness label: always when a brightness source is set, otherwise only below 100% const bLabel = document.getElementById(`led-preview-brightness-${targetId}`); if (bLabel) { const pct = Math.round(brightness / 255 * 100); if (pct < 100 || bLabel.dataset.hasBvs) { bLabel.textContent = `☀ ${pct}%`; bLabel.style.display = ''; } else { bLabel.style.display = 'none'; } } } }; ws.onclose = () => { delete ledPreviewWebSockets[targetId]; }; ws.onerror = (error) => { console.error(`LED preview WebSocket error for ${targetId}:`, error); }; ledPreviewWebSockets[targetId] = ws; } catch (error) { console.error(`Failed to connect LED preview WebSocket for ${targetId}:`, error); } } function disconnectLedPreviewWS(targetId) { const ws = ledPreviewWebSockets[targetId]; if (ws) { ws.onclose = null; ws.close(); delete ledPreviewWebSockets[targetId]; } delete _ledPreviewLastFrame[targetId]; const panel = document.getElementById(`led-preview-panel-${targetId}`); if (panel) panel.style.display = 'none'; } export function disconnectAllLedPreviewWS() { Object.keys(ledPreviewWebSockets).forEach(id => disconnectLedPreviewWS(id)); } export function toggleLedPreview(targetId) { const panel = document.getElementById(`led-preview-panel-${targetId}`); if (!panel) return; if (ledPreviewWebSockets[targetId]) { disconnectLedPreviewWS(targetId); } else { panel.style.display = ''; connectLedPreviewWS(targetId); } }