/** * Targets tab â combined view of devices, LED targets, KC targets, pattern templates. */ import { apiKey, _targetEditorDevices, set_targetEditorDevices, _deviceBrightnessCache, kcWebSockets, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, _computeMaxFps } from './devices.js'; import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js'; import { createColorStripCard } from './color-strips.js'; // createPatternTemplateCard is imported via window.* to avoid circular deps // (pattern-templates.js calls window.loadTargetsTab) // 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 = {}; const _targetFpsCharts = {}; function _pushTargetFps(targetId, value) { if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = []; const h = _targetFpsHistory[targetId]; h.push(value); if (h.length > _TARGET_MAX_FPS_SAMPLES) h.shift(); } function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) { const canvas = document.getElementById(canvasId); if (!canvas) return null; const datasets = [{ data: [...history], borderColor: '#2196F3', backgroundColor: 'rgba(33,150,243,0.12)', borderWidth: 1.5, tension: 0.3, fill: true, pointRadius: 0, }]; // Flat line showing hardware max FPS if (maxHwFps && maxHwFps < fpsTarget * 1.15) { datasets.push({ data: history.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: history.map(() => ''), 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 }, }, }, }); } 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: document.getElementById('target-editor-css').value, fps: document.getElementById('target-editor-fps').value, standby_interval: document.getElementById('target-editor-standby-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 cssSelect = document.getElementById('target-editor-css'); const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || ''; 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 _updateStandbyVisibility() { const deviceSelect = document.getElementById('target-editor-device'); const standbyGroup = document.getElementById('target-editor-standby-group'); const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value); const caps = selectedDevice?.capabilities || []; standbyGroup.style.display = caps.includes('standby_required') ? '' : 'none'; } export async function showTargetEditor(targetId = null) { try { // Load devices and CSS sources for dropdowns const [devicesResp, cssResp] = await Promise.all([ fetch(`${API_BASE}/devices`, { headers: getHeaders() }), fetchWithAuth('/color-strip-sources'), ]); const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : []; const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : []; set_targetEditorDevices(devices); // 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); }); // Populate color strip source select const cssSelect = document.getElementById('target-editor-css'); cssSelect.innerHTML = ''; cssSources.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.dataset.name = s.name; opt.textContent = `đī¸ ${s.name}`; cssSelect.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 || ''; cssSelect.value = target.color_strip_source_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-standby-interval').value = target.standby_interval ?? 1.0; document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0; document.getElementById('target-editor-title').textContent = t('targets.edit'); } else { // Creating new target â first option is selected by default 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-standby-interval').value = 1.0; document.getElementById('target-editor-standby-interval-value').textContent = '1.0'; document.getElementById('target-editor-title').textContent = t('targets.add'); } // Auto-name generation _targetNameManuallyEdited = !!targetId; document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; deviceSelect.onchange = () => { _updateStandbyVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; cssSelect.onchange = () => _autoGenerateTargetName(); if (!targetId) _autoGenerateTargetName(); // Show/hide standby interval based on selected device capabilities _updateStandbyVisibility(); _updateFpsRecommendation(); 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 cssId = document.getElementById('target-editor-css').value; const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value); if (!name) { targetEditorModal.showError(t('targets.error.name_required')); return; } const fps = parseInt(document.getElementById('target-editor-fps').value) || 30; const payload = { name, device_id: deviceId, color_strip_source_id: cssId, fps, standby_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); } 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; try { // Fetch devices, targets, CSS sources, picture sources, and pattern templates in parallel const [devicesResp, targetsResp, cssResp, psResp, patResp] = await Promise.all([ fetchWithAuth('/devices'), fetchWithAuth('/picture-targets'), fetchWithAuth('/color-strip-sources').catch(() => null), fetchWithAuth('/picture-sources').catch(() => null), fetchWithAuth('/pattern-templates').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; }); } // 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'); // 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: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length }, { key: 'key_colors', icon: '\uD83C\uDFA8', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length }, ]; const tabBar = `
`; // Use window.createPatternTemplateCard to avoid circular import const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); // LED panel: devices section + color strip sources section + targets section const ledPanel = `