/** * Targets tab — combined view of devices, LED targets, KC targets, pattern templates. */ import { apiKey, _targetEditorDevices, set_targetEditorDevices, _deviceBrightnessCache, kcWebSockets, ledPreviewWebSockets, _cachedValueSources, valueSourcesCache, streamsCache, audioSourcesCache, syncClocksCache, colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache, } from '../core/state.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, desktopFocus } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts'; import { _splitOpenrgbZone } from './device-discovery.ts'; import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts'; import { getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP, ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, ICON_TRASH, } from '../core/icons.ts'; import { EntitySelect } from '../core/entity-palette.ts'; import { IconSelect } from '../core/icon-select.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; import { CardSection } from '../core/card-sections.ts'; import { TreeNav } from '../core/tree-nav.ts'; import { updateSubTabHash, updateTabBadge } from './tabs.ts'; import type { OutputTarget } from '../types.ts'; // createPatternTemplateCard is imported via window.* to avoid circular deps // (pattern-templates.js calls window.loadTargetsTab) // ── Bulk action handlers ── async function _bulkStartTargets(ids: any) { const results = await Promise.allSettled(ids.map(id => fetchWithAuth(`/output-targets/${id}/start`, { method: 'POST' }) )); const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; if (failed) showToast(`${ids.length - failed}/${ids.length} started`, 'warning'); else showToast(t('device.started'), 'success'); } async function _bulkStopTargets(ids: any) { const results = await Promise.allSettled(ids.map(id => fetchWithAuth(`/output-targets/${id}/stop`, { method: 'POST' }) )); const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; if (failed) showToast(`${ids.length - failed}/${ids.length} stopped`, 'warning'); else showToast(t('device.stopped'), 'success'); } async function _bulkDeleteTargets(ids: any) { const results = await Promise.allSettled(ids.map(id => fetchWithAuth(`/output-targets/${id}`, { method: 'DELETE' }) )); const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); else showToast(t('targets.deleted'), 'success'); outputTargetsCache.invalidate(); await loadTargetsTab(); } async function _bulkDeleteDevices(ids: any) { const results = await Promise.allSettled(ids.map(id => fetchWithAuth(`/devices/${id}`, { method: 'DELETE' }) )); const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); else showToast(t('device.removed'), 'success'); devicesCache.invalidate(); await loadTargetsTab(); } async function _bulkDeletePatternTemplates(ids: any) { const results = await Promise.allSettled(ids.map(id => fetchWithAuth(`/pattern-templates/${id}`, { method: 'DELETE' }) )); const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); else showToast(t('targets.deleted'), 'success'); patternTemplatesCache.invalidate(); await loadTargetsTab(); } const _targetBulkActions = [ { key: 'start', labelKey: 'bulk.start', icon: ICON_START, handler: _bulkStartTargets }, { key: 'stop', labelKey: 'bulk.stop', icon: ICON_STOP, handler: _bulkStopTargets }, { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteTargets }, ]; // ── Card section instances ── const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id', emptyKey: 'section.empty.devices', bulkActions: [ { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteDevices }, ] }); const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: ``, bulkActions: _targetBulkActions }); const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: ``, bulkActions: _targetBulkActions }); const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates', bulkActions: [ { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeletePatternTemplates }, ] }); // 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: Record = {}; // fps_actual (rolling avg) const _targetFpsCurrentHistory: Record = {}; // fps_current (sends/sec) const _targetFpsCharts: Record = {}; function _pushTargetFps(targetId: any, actual: any, current: any) { 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: any, actualHistory: any, currentHistory: any, fpsTarget: any, maxHwFps: any) { return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, { maxHwFps, maxSamples: _TARGET_MAX_FPS_SAMPLES }); } function _updateTargetFpsChart(targetId: any, fpsTarget: any) { const chart = _targetFpsCharts[targetId]; if (!chart) return; const actualH = _targetFpsHistory[targetId] || []; const currentH = _targetFpsCurrentHistory[targetId] || []; const ds0 = chart.data.datasets[0].data; ds0.length = 0; ds0.push(...actualH); const ds1 = chart.data.datasets[1].data; ds1.length = 0; ds1.push(...currentH); while (chart.data.labels.length < ds0.length) chart.data.labels.push(''); chart.data.labels.length = ds0.length; chart.options.scales.y.max = fpsTarget * 1.15; chart.update('none'); } // --- Editor state --- let _editorCssSources: any[] = []; // populated when editor opens let _targetTagsInput: TagInput | null = null; class TargetEditorModal extends Modal { constructor() { super('target-editor-modal'); } snapshotValues() { return { name: (document.getElementById('target-editor-name') as HTMLInputElement).value, device: (document.getElementById('target-editor-device') as HTMLSelectElement).value, protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value, css_source: (document.getElementById('target-editor-css-source') as HTMLSelectElement).value, brightness_vs: (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value, brightness_threshold: (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value, fps: (document.getElementById('target-editor-fps') as HTMLInputElement).value, keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value, adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked, tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []), }; } } const targetEditorModal = new TargetEditorModal(); function _protocolBadge(device: any, target: any) { const dt = device?.device_type; if (!dt || dt === 'wled') { const proto = target.protocol === 'http' ? 'HTTP' : 'DDP'; return `${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${proto}`; } const map = { openrgb: [ICON_PALETTE, 'OpenRGB SDK'], adalight: [ICON_PLUG, t('targets.protocol.serial')], ambiled: [ICON_PLUG, t('targets.protocol.serial')], mqtt: [ICON_GLOBE, 'MQTT'], ws: [ICON_GLOBE, 'WebSocket'], mock: [ICON_WRENCH, 'Mock'], }; const [icon, label] = map[dt] || [ICON_PLUG, dt]; return `${icon} ${label}`; } let _targetNameManuallyEdited = false; function _autoGenerateTargetName() { if (_targetNameManuallyEdited) return; if ((document.getElementById('target-editor-id') as HTMLInputElement).value) return; const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement; const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || ''; const cssSelect = document.getElementById('target-editor-css-source') as HTMLSelectElement; const cssName = cssSelect?.selectedOptions[0]?.dataset?.name || ''; if (!deviceName || !cssName) return; (document.getElementById('target-editor-name') as HTMLInputElement).value = `${deviceName} \u00b7 ${cssName}`; } function _updateFpsRecommendation() { const el = document.getElementById('target-editor-fps-rec') as HTMLElement; const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement; 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') as HTMLSelectElement; const el = document.getElementById('target-editor-device-info') as HTMLElement; 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') as HTMLSelectElement; const keepaliveGroup = document.getElementById('target-editor-keepalive-group') as HTMLElement; const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value); const caps = selectedDevice?.capabilities || []; keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none'; } function _updateSpecificSettingsVisibility() { const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement; const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value); const isWled = !selectedDevice || selectedDevice.device_type === 'wled'; // Hide WLED-only controls (protocol + keepalive) for non-WLED devices const protocolGroup = document.getElementById('target-editor-protocol-group'); if (protocolGroup) protocolGroup.style.display = isWled ? '' : 'none'; // keepalive is controlled further by _updateKeepaliveVisibility const keepaliveGroup = document.getElementById('target-editor-keepalive-group'); if (keepaliveGroup && !isWled) keepaliveGroup.style.display = 'none'; } function _updateBrightnessThresholdVisibility() { // Always visible — threshold considers both brightness source and pixel content (document.getElementById('target-editor-brightness-threshold-group') as HTMLElement).style.display = ''; } // ── EntitySelect instances for target editor ── let _deviceEntitySelect: EntitySelect | null = null; let _cssEntitySelect: EntitySelect | null = null; let _brightnessVsEntitySelect: EntitySelect | null = null; let _protocolIconSelect: IconSelect | null = null; function _populateCssDropdown(selectedId = '') { const select = document.getElementById('target-editor-css-source') as HTMLSelectElement; select.innerHTML = _editorCssSources.map(s => `` ).join(''); } function _populateBrightnessVsDropdown(selectedId = '') { const select = document.getElementById('target-editor-brightness-vs') as HTMLSelectElement; let html = ``; _cachedValueSources.forEach(vs => { html += ``; }); select.innerHTML = html; } function _ensureTargetEntitySelects() { // Device if (_deviceEntitySelect) _deviceEntitySelect.destroy(); _deviceEntitySelect = new EntitySelect({ target: document.getElementById('target-editor-device') as HTMLSelectElement, getItems: () => _targetEditorDevices.map(d => ({ value: d.id, label: d.name, icon: getDeviceTypeIcon(d.device_type), desc: (d.device_type || 'wled').toUpperCase() + (d.url ? ` · ${d.url.replace(/^https?:\/\//, '')}` : ''), })), placeholder: t('palette.search'), }); // CSS source if (_cssEntitySelect) _cssEntitySelect.destroy(); _cssEntitySelect = new EntitySelect({ target: document.getElementById('target-editor-css-source') as HTMLSelectElement, getItems: () => _editorCssSources.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type), desc: s.source_type, })), placeholder: t('palette.search'), }); // Brightness value source if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = new EntitySelect({ target: document.getElementById('target-editor-brightness-vs') as HTMLSelectElement, getItems: () => _cachedValueSources.map(vs => ({ value: vs.id, label: vs.name, icon: getValueSourceIcon(vs.source_type), desc: vs.source_type, })), placeholder: t('palette.search'), allowNone: true, noneLabel: t('targets.brightness_vs.none'), }); } const _pIcon = (d: any) => `${d}`; function _ensureProtocolIconSelect() { const sel = document.getElementById('target-editor-protocol'); if (!sel) return; const items = [ { value: 'ddp', icon: _pIcon(P.radio), label: t('targets.protocol.ddp'), desc: t('targets.protocol.ddp.desc') }, { value: 'http', icon: _pIcon(P.globe), label: t('targets.protocol.http'), desc: t('targets.protocol.http.desc') }, ]; if (_protocolIconSelect) { _protocolIconSelect.updateItems(items); return; } _protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 }); } export async function showTargetEditor(targetId: string | null = null, cloneData: any = null) { try { // Load devices, CSS sources, and value sources for dropdowns const [devices, cssSources] = await Promise.all([ devicesCache.fetch().catch((): any[] => []), colorStripSourcesCache.fetch().catch((): any[] => []), valueSourcesCache.fetch(), ]); set_targetEditorDevices(devices); _editorCssSources = cssSources; // Populate device select const deviceSelect = document.getElementById('target-editor-device') as HTMLSelectElement; 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.startsWith('http') ? d.url.replace(/^https?:\/\//, '') : ''; const devType = (d.device_type || 'wled').toUpperCase(); opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`; deviceSelect.appendChild(opt); }); let _editorTags: string[] = []; if (targetId) { // Editing existing target const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() }); if (!resp.ok) throw new Error('Failed to load target'); const target = await resp.json(); _editorTags = target.tags || []; (document.getElementById('target-editor-id') as HTMLInputElement).value = target.id; (document.getElementById('target-editor-name') as HTMLInputElement).value = target.name; deviceSelect.value = target.device_id || ''; const fps = target.fps ?? 30; (document.getElementById('target-editor-fps') as HTMLInputElement).value = fps; (document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps; (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = target.keepalive_interval ?? 1.0; (document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = target.keepalive_interval ?? 1.0; (document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`; const thresh = target.min_brightness_threshold ?? 0; (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = thresh; (document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = thresh; (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp'; _populateCssDropdown(target.color_strip_source_id || ''); _populateBrightnessVsDropdown(target.brightness_value_source_id || ''); } else if (cloneData) { // Cloning — create mode but pre-filled from clone data _editorTags = cloneData.tags || []; (document.getElementById('target-editor-id') as HTMLInputElement).value = ''; (document.getElementById('target-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)'; deviceSelect.value = cloneData.device_id || ''; const fps = cloneData.fps ?? 30; (document.getElementById('target-editor-fps') as HTMLInputElement).value = fps; (document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps; (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = cloneData.keepalive_interval ?? 1.0; (document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = cloneData.keepalive_interval ?? 1.0; (document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`; const cloneThresh = cloneData.min_brightness_threshold ?? 0; (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = cloneThresh; (document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = cloneThresh; (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp'; _populateCssDropdown(cloneData.color_strip_source_id || ''); _populateBrightnessVsDropdown(cloneData.brightness_value_source_id || ''); } else { // Creating new target (document.getElementById('target-editor-id') as HTMLInputElement).value = ''; (document.getElementById('target-editor-name') as HTMLInputElement).value = ''; (document.getElementById('target-editor-fps') as HTMLInputElement).value = 30 as any; (document.getElementById('target-editor-fps-value') as HTMLElement).textContent = '30'; (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = 1.0 as any; (document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = '1.0'; (document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`; (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = 0 as any; (document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = '0'; (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp'; _populateCssDropdown(''); _populateBrightnessVsDropdown(''); } // Entity palette selectors _ensureTargetEntitySelects(); _ensureProtocolIconSelect(); if (_protocolIconSelect) _protocolIconSelect.setValue((document.getElementById('target-editor-protocol') as HTMLSelectElement).value); // Auto-name generation _targetNameManuallyEdited = !!(targetId || cloneData); (document.getElementById('target-editor-name') as HTMLInputElement).oninput = () => { _targetNameManuallyEdited = true; }; window._targetAutoName = _autoGenerateTargetName; deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; (document.getElementById('target-editor-css-source') as HTMLSelectElement).onchange = () => { _autoGenerateTargetName(); }; (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).onchange = () => { _updateBrightnessThresholdVisibility(); }; if (!targetId && !cloneData) _autoGenerateTargetName(); // Show/hide conditional fields _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _updateBrightnessThresholdVisibility(); // Tags if (_targetTagsInput) _targetTagsInput.destroy(); _targetTagsInput = new TagInput(document.getElementById('target-tags-container'), { placeholder: t('tags.placeholder'), }); _targetTagsInput.setValue(_editorTags); targetEditorModal.snapshot(); targetEditorModal.open(); (document.getElementById('target-editor-error') as HTMLElement).style.display = 'none'; setTimeout(() => desktopFocus(document.getElementById('target-editor-name')), 100); } catch (error) { console.error('Failed to open target editor:', error); showToast(t('target.error.editor_open_failed'), 'error'); } } export function isTargetEditorDirty() { return targetEditorModal.isDirty(); } export async function closeTargetEditorModal() { await targetEditorModal.close(); } export function forceCloseTargetEditorModal() { if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; } targetEditorModal.forceClose(); } export async function saveTargetEditor() { const targetId = (document.getElementById('target-editor-id') as HTMLInputElement).value; const name = (document.getElementById('target-editor-name') as HTMLInputElement).value.trim(); const deviceId = (document.getElementById('target-editor-device') as HTMLSelectElement).value; const standbyInterval = parseFloat((document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value); if (!name) { targetEditorModal.showError(t('targets.error.name_required')); return; } const fps = parseInt((document.getElementById('target-editor-fps') as HTMLInputElement).value) || 30; const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value; const brightnessVsId = (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value; const minBrightnessThreshold = parseInt((document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value) || 0; const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked; const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value; const payload: any = { name, device_id: deviceId, color_strip_source_id: colorStripSourceId, brightness_value_source_id: brightnessVsId, min_brightness_threshold: minBrightnessThreshold, fps, keepalive_interval: standbyInterval, adaptive_fps: adaptiveFps, protocol, tags: _targetTagsInput ? _targetTagsInput.getValue() : [], }; try { let response; if (targetId) { response = await fetchWithAuth(`/output-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload), }); } else { payload.target_type = 'led'; response = await fetchWithAuth('/output-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'); outputTargetsCache.invalidate(); 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) ===== let _treeTriggered = false; const _targetsTree = new TreeNav('targets-tree-nav', { onSelect: (key: any) => { _treeTriggered = true; switchTargetSubTab(key); _treeTriggered = false; } }); export function switchTargetSubTab(tabKey: any) { document.querySelectorAll('.target-sub-tab-panel').forEach(panel => (panel as HTMLElement).classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`) ); localStorage.setItem('activeTargetSubTab', tabKey); updateSubTabHash('targets', tabKey); // Update tree active state (unless the tree triggered this switch) if (!_treeTriggered) { _targetsTree.setActive(tabKey); } } const _targetSectionMap = { 'led-devices': [csDevices], 'led-targets': [csLedTargets], 'kc-targets': [csKCTargets], 'kc-patterns': [csPatternTemplates], }; 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; if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true); try { // Fetch all entities via DataCache const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([ devicesCache.fetch().catch((): any[] => []), outputTargetsCache.fetch().catch((): any[] => []), colorStripSourcesCache.fetch().catch((): any[] => []), patternTemplatesCache.fetch().catch((): any[] => []), streamsCache.fetch().catch((): any[] => []), valueSourcesCache.fetch().catch((): any[] => []), audioSourcesCache.fetch().catch((): any[] => []), syncClocksCache.fetch().catch((): any[] => []), ]); const colorStripSourceMap = {}; cssArr.forEach(s => { colorStripSourceMap[s.id] = s; }); let pictureSourceMap = {}; psArr.forEach(s => { pictureSourceMap[s.id] = s; }); let patternTemplateMap = {}; patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; }); let valueSourceMap = {}; valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; }); let audioSourceMap = {}; asSrcArr.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('/output-targets/batch/states'), fetchWithAuth('/output-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}/output-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); const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led'; // Build tree navigation structure const treeGroups = [ { key: 'led_group', icon: getTargetTypeIcon('led'), titleKey: 'targets.subtab.led', children: [ { key: 'led-devices', titleKey: 'targets.section.devices', icon: getDeviceTypeIcon('wled'), count: ledDevices.length }, { key: 'led-targets', titleKey: 'targets.section.targets', icon: getTargetTypeIcon('led'), count: ledTargets.length }, ] }, { key: 'kc_group', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors', children: [ { key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length }, { key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length }, ] } ]; // Determine which tree leaf is active — migrate old values const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns']; const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab : activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices'; // Use window.createPatternTemplateCard to avoid circular import const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); // Build items arrays for each section (apply saved drag order) const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) }))); const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) }))); const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) }))); const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) }))); // Track which target cards were replaced/added (need chart re-init) let changedTargetIds: Set | null = null; if (csDevices.isMounted()) { // ── Incremental update: reconcile cards in-place ── _targetsTree.updateCounts({ 'led-devices': ledDevices.length, 'led-targets': ledTargets.length, 'kc-targets': kcTargets.length, 'kc-patterns': patternTemplates.length, }); csDevices.reconcile(deviceItems); const ledResult = csLedTargets.reconcile(ledTargetItems); const kcResult = csKCTargets.reconcile(kcTargetItems); csPatternTemplates.reconcile(patternItems); changedTargetIds = new Set([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[]), ...(kcResult.added as unknown as string[]), ...(kcResult.replaced as unknown as string[]), ...(kcResult.removed as unknown as string[])]); // Restore LED preview state on replaced cards (panel hidden by default in HTML) for (const id of Array.from(ledResult.replaced) as any[]) { if (ledPreviewWebSockets[id]) { _restoreLedPreviewState(id); } } } else { // ── First render: build full HTML ── const panels = [ { key: 'led-devices', html: csDevices.render(deviceItems) }, { key: 'led-targets', html: csLedTargets.render(ledTargetItems) }, { key: 'kc-targets', html: csKCTargets.render(kcTargetItems) }, { key: 'kc-patterns', html: csPatternTemplates.render(patternItems) }, ].map(p => `
${p.html}
`).join(''); container.innerHTML = panels; CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates]); // Render tree sidebar with expand/collapse buttons _targetsTree.setExtraHtml(``); _targetsTree.update(treeGroups, activeLeaf); _targetsTree.observeSections('targets-panel-content'); } // Show/hide stop-all buttons based on running state const ledRunning = ledTargets.some(t => t.state && t.state.processing); const kcRunning = kcTargets.some(t => t.state && t.state.processing); const ledStopBtn = container.querySelector('[data-stop-all="led"]') as HTMLElement | null; const kcStopBtn = container.querySelector('[data-stop-all="kc"]') as HTMLElement | null; if (ledStopBtn) { ledStopBtn.style.display = ledRunning ? '' : 'none'; if (!ledStopBtn.title) { ledStopBtn.title = t('targets.stop_all.button'); ledStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } } if (kcStopBtn) { kcStopBtn.style.display = kcRunning ? '' : 'none'; if (!kcStopBtn.title) { kcStopBtn.title = t('targets.stop_all.button'); kcStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } } // 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="${CSS.escape(device.id)}"]`) as HTMLInputElement | null; if (slider) { slider.value = String(bri); slider.title = Math.round(bri / 255 * 100) + '%'; slider.disabled = false; } const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(device.id)}"]`); if (wrap) wrap.classList.remove('brightness-loading'); } else { fetchDeviceBrightness(device.id); } } // Enrich OpenRGB zone badges with per-zone LED counts if (device.device_type === 'openrgb') { enrichOpenrgbZoneBadges(device.id, device.url); } }); // Patch "Last seen" labels in-place (avoids full card re-render on relative time changes) for (const device of devicesWithState) { const el = container.querySelector(`[data-last-seen="${CSS.escape(device.id)}"]`) as HTMLElement | null; if (el) { const ts = device.state?.device_last_checked; const label = ts ? formatRelativeTime(ts) : null; el.textContent = label ? `\u23F1 ${t('device.last_seen.label')}: ${label}` : ''; if (ts) el.title = ts; } } // 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(); const runningTargetIds = allTargets.filter(t => t.state?.processing).map(t => t.id); // Seed FPS history from server if empty (first load / page reload) if (runningTargetIds.length > 0 && runningTargetIds.some(id => !_targetFpsHistory[id]?.length)) { const data = await fetchMetricsHistory(); if (data) { const serverTargets = data.targets || {}; for (const id of runningTargetIds) { if (!_targetFpsHistory[id]?.length) { const samples = serverTargets[id] || []; const actual = samples.map((s: any) => s.fps).filter((v: any) => v != null); const current = samples.map((s: any) => s.fps_current).filter((v: any) => v != null); _targetFpsHistory[id] = actual.slice(-_TARGET_MAX_FPS_SAMPLES); _targetFpsCurrentHistory[id] = current.slice(-_TARGET_MAX_FPS_SAMPLES); } } } } 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: any, colorStripSourceMap: any) { 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: any) { 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: any) { const container = document.getElementById('targets-panel-content'); if (!container) return; const card = container.querySelector(`[data-target-id="${CSS.escape(target.id)}"]`); if (!card) return; const state = target.state || {}; const metrics = target.metrics || {}; const fps = card.querySelector('[data-tm="fps"]') as HTMLElement | null; if (fps) { const effFps = state.fps_effective; const tgtFps = state.fps_target || 0; const fpsLabel = (effFps != null && effFps < tgtFps) ? `${state.fps_current ?? 0}/${effFps}↓${tgtFps}` : `${state.fps_current ?? 0}/${tgtFps}`; const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : ''; fps.innerHTML = `${fpsLabel}` + `avg ${state.fps_actual?.toFixed(1) || '0.0'}`; } // Update health dot to reflect streaming reachability when processing const healthDot = card.querySelector('.health-dot') as HTMLElement | null; if (healthDot && state.processing) { const reachable = state.device_streaming_reachable; if (reachable === false) { healthDot.className = 'health-dot health-offline'; healthDot.title = t('device.health.streaming_unreachable') || 'Unreachable during streaming'; } else if (reachable === true) { healthDot.className = 'health-dot health-online'; healthDot.title = t('device.health.online'); } } const timing = card.querySelector('[data-tm="timing"]') as HTMLElement | null; if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state); const frames = card.querySelector('[data-tm="frames"]') as HTMLElement | null; if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); } const keepalive = card.querySelector('[data-tm="keepalive"]') as HTMLElement | null; if (keepalive) { keepalive.textContent = formatCompact(state.frames_keepalive ?? 0); keepalive.title = String(state.frames_keepalive ?? 0); } const errors = card.querySelector('[data-tm="errors"]') as HTMLElement | null; if (errors) { errors.textContent = formatCompact(metrics.errors_count || 0); errors.title = String(metrics.errors_count || 0); } // Error indicator near target name const errorIndicator = card.querySelector('.target-error-indicator'); if (errorIndicator) { const hasErrors = (metrics.errors_count || 0) > 0; errorIndicator.classList.toggle('visible', hasErrors); } const uptime = card.querySelector('[data-tm="uptime"]') as HTMLElement | null; if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds); } export function createTargetCard(target: OutputTarget & { state?: any; metrics?: any }, deviceMap: Record, colorStripSourceMap: Record, valueSourceMap: Record) { 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 wrapCard({ dataAttr: 'data-target-id', id: target.id, classes: isProcessing ? 'card-running' : '', removeOnclick: `deleteTarget('${target.id}')`, removeTitle: t('common.delete'), content: `
${escapeHtml(target.name)} ${ICON_WARNING}
${ICON_LED} ${escapeHtml(deviceName)} ${ICON_FPS} ${target.fps || 30} ${_protocolBadge(device, target)} ${ICON_FILM} ${cssSummary} ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''} ${(target.min_brightness_threshold ?? 0) > 0 ? `${ICON_SUN_DIM} <${target.min_brightness_threshold} → off` : ''}
${renderTagChips(target.tags)}
${isProcessing ? `
---
${t('device.metrics.frames')}
---
${state.needs_keepalive !== false ? `
${t('device.metrics.keepalive')}
---
` : ''}
${t('device.metrics.errors')}
---
${t('device.metrics.uptime')}
---
` : ''}
${_buildLedPreviewHtml(target.id, device, bvsId, css, colorStripSourceMap)}`, actions: ` ${isProcessing ? ` ` : ` `} ${isProcessing ? ` ` : ''} `, }); } async function _targetAction(action: any) { _actionInFlight = true; try { await action(); } finally { _actionInFlight = false; _loadTargetsLock = false; // ensure next poll can run loadTargetsTab(); } } export async function startTargetProcessing(targetId: any) { await _targetAction(async () => { const response = await fetchWithAuth(`/output-targets/${targetId}/start`, { method: 'POST', }); if (response.ok) { showToast(t('device.started'), 'success'); } else { const error = await response.json(); showToast(error.detail || t('target.error.start_failed'), 'error'); } }); } export async function stopTargetProcessing(targetId: any) { await _targetAction(async () => { const response = await fetchWithAuth(`/output-targets/${targetId}/stop`, { method: 'POST', }); if (response.ok) { showToast(t('device.stopped'), 'success'); } else { const error = await response.json(); showToast(error.detail || t('target.error.stop_failed'), 'error'); } }); } export async function stopAllLedTargets() { const confirmed = await showConfirm(t('confirm.stop_all')); if (!confirmed) return; await _stopAllByType('led'); } export async function stopAllKCTargets() { const confirmed = await showConfirm(t('confirm.stop_all')); if (!confirmed) return; await _stopAllByType('key_colors'); } async function _stopAllByType(targetType: any) { try { const [allTargets, statesResp] = await Promise.all([ outputTargetsCache.fetch().catch((): any[] => []), fetchWithAuth('/output-targets/batch/states'), ]); const statesData = statesResp.ok ? await statesResp.json() : { states: {} }; const states = statesData.states || {}; const typeMatch = targetType === 'led' ? t => t.target_type === 'led' || t.target_type === 'wled' : t => t.target_type === targetType; const running = allTargets.filter(t => typeMatch(t) && states[t.id]?.processing); if (!running.length) { showToast(t('targets.stop_all.none_running'), 'info'); return; } await Promise.all(running.map(t => fetchWithAuth(`/output-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {}) )); showToast(t('targets.stop_all.stopped', { count: running.length }), 'success'); loadTargetsTab(); } catch (error) { if (error.isAuth) return; showToast(t('targets.stop_all.error'), 'error'); } } export async function startTargetOverlay(targetId: any) { await _targetAction(async () => { const response = await fetchWithAuth(`/output-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: any) { await _targetAction(async () => { const response = await fetchWithAuth(`/output-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: any) { try { const resp = await fetch(`${API_BASE}/output-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(t('target.error.clone_failed'), 'error'); } } export async function deleteTarget(targetId: any) { const confirmed = await showConfirm(t('targets.delete.confirm')); if (!confirmed) return; await _targetAction(async () => { const response = await fetchWithAuth(`/output-targets/${targetId}`, { method: 'DELETE', }); if (response.ok) { showToast(t('targets.deleted'), 'success'); outputTargetsCache.invalidate(); } else { const error = await response.json(); showToast(error.detail || t('target.error.delete_failed'), 'error'); } }); } /* ── LED Strip Preview ────────────────────────────────────────── */ const _ledPreviewLastFrame = {}; /** * Build the LED preview panel HTML for a target card. * For OpenRGB devices in "separate" zone mode with 2+ zones, renders * one canvas per zone with labels. Otherwise, a single canvas. */ function _buildLedPreviewHtml(targetId: any, device: any, bvsId: any, cssSource: any, colorStripSourceMap: any) { // Always render hidden — JS toggles visibility. This keeps card HTML stable // so reconciliation doesn't replace the card when preview is toggled. const visible = 'none'; const bvsAttr = bvsId ? ' data-has-bvs="1"' : ''; // Check for per-zone preview if (device && isOpenrgbDevice(device.device_type) && device.zone_mode === 'separate') { const { baseUrl, zones } = _splitOpenrgbZone(device.url); if (zones.length > 1) { const zoneCanvases = zones.map(z => `
` + `` + `${escapeHtml(z)}` + `
` ).join(''); return `
` + zoneCanvases + `` + `
`; } } // Check for composite source with per-layer preview if (cssSource && cssSource.source_type === 'composite' && cssSource.layers && cssSource.layers.length > 1) { const layerCanvases = cssSource.layers.filter(l => l.enabled !== false).map((l, i) => { const layerSrc = colorStripSourceMap ? colorStripSourceMap[l.source_id] : null; const layerName = layerSrc ? layerSrc.name : l.source_id; return `
` + `` + `${escapeHtml(layerName)}` + `
`; }).join(''); return `
` + `
` + `` + `${escapeHtml(cssSource.name || 'Composite')}` + `
` + layerCanvases + `` + `
`; } // Default: single canvas return `
` + `` + `` + `
`; } /** * Resample an RGB byte array from srcCount pixels to dstCount pixels * using linear interpolation (matches backend np.interp behavior). */ function _resampleStrip(srcBytes: any, srcCount: any, dstCount: any) { if (dstCount === srcCount) return srcBytes; const dst = new Uint8Array(dstCount * 3); for (let i = 0; i < dstCount; i++) { const t = dstCount > 1 ? i / (dstCount - 1) : 0; const srcPos = t * (srcCount - 1); const lo = Math.floor(srcPos); const hi = Math.min(lo + 1, srcCount - 1); const frac = srcPos - lo; for (let ch = 0; ch < 3; ch++) { dst[i * 3 + ch] = Math.round( srcBytes[lo * 3 + ch] * (1 - frac) + srcBytes[hi * 3 + ch] * frac ); } } return dst; } /** * Render per-zone LED previews: resample the full frame independently * for each zone canvas (matching the backend's separate-mode behavior). */ function _renderLedStripZones(panel: any, rgbBytes: any) { const baseUrl = panel.dataset.zoneBaseUrl; const cache = baseUrl ? getZoneCountCache(baseUrl) : null; const srcCount = Math.floor(rgbBytes.length / 3); if (srcCount < 1) return; const zoneCanvases = panel.querySelectorAll('.led-preview-zone-canvas'); if (!cache) { // Zone sizes unknown — render full frame to all canvases for (const canvas of zoneCanvases) { _renderLedStrip(canvas, rgbBytes); } return; } // Separate mode: each zone gets the full source frame resampled to its LED count // (matches backend OpenRGB separate-mode behavior) for (const canvas of zoneCanvases) { const zoneName = canvas.dataset.zoneName; const zoneSize = cache[zoneName.toLowerCase()]; if (!zoneSize || zoneSize < 1) continue; const resampled = _resampleStrip(rgbBytes, srcCount, zoneSize); _renderLedStrip(canvas, resampled); } } function _renderLedStrip(canvas: any, rgbBytes: any) { 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: any) { // Close existing WS without touching DOM (caller manages panel/button state) const oldWs = ledPreviewWebSockets[targetId]; if (oldWs) { oldWs.onclose = null; oldWs.close(); delete ledPreviewWebSockets[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}/output-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); const brightness = raw[0]; const panel = document.getElementById(`led-preview-panel-${targetId}`); // Detect composite wire format: [brightness] [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...] const isCompositeWire = raw.length > 4 && raw[1] === 0xFE; if (isCompositeWire) { const layerCount = raw[2]; const ledCount = (raw[3] << 8) | raw[4]; const rgbSize = ledCount * 3; let offset = 5; // Render per-layer canvases if panel supports it if (panel && panel.dataset.composite === '1') { const layerCanvases = panel.querySelectorAll('.led-preview-layer-canvas[data-layer-idx]'); for (let i = 0; i < layerCount; i++) { const layerRgb = raw.subarray(offset, offset + rgbSize); offset += rgbSize; // layer canvases: idx 0 = "composite", idx 1..N = individual layers const canvas = layerCanvases[i + 1]; // +1 because composite canvas is first if (canvas) _renderLedStrip(canvas, layerRgb); } } else { // Skip layer data (panel doesn't have layer canvases) offset += layerCount * rgbSize; } // Final composite result const compositeRgb = raw.subarray(offset, offset + rgbSize); _ledPreviewLastFrame[targetId] = compositeRgb; if (panel) { if (panel.dataset.composite === '1') { const compositeCanvas = panel.querySelector('[data-layer-idx="composite"]'); if (compositeCanvas) _renderLedStrip(compositeCanvas, compositeRgb); } else if (panel.dataset.zoneMode === 'separate') { _renderLedStripZones(panel, compositeRgb); } else { const canvas = panel.querySelector('.led-preview-canvas'); if (canvas) _renderLedStrip(canvas, compositeRgb); } } } else { // Standard wire format: [brightness_byte] [R G B R G B ...] const frame = raw.subarray(1); _ledPreviewLastFrame[targetId] = frame; if (panel) { if (panel.dataset.zoneMode === 'separate') { _renderLedStripZones(panel, frame); } else { const canvas = panel.querySelector('.led-preview-canvas'); 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.innerHTML = `${ICON_SUN_DIM} ${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 _setPreviewButtonState(targetId: any, active: boolean) { const btn = document.querySelector(`[data-led-preview-btn="${CSS.escape(targetId)}"]`); if (btn) { btn.classList.toggle('btn-warning', active); btn.classList.toggle('btn-secondary', !active); } } /** Restore preview panel visibility, button state, and last frame after card replacement. */ function _restoreLedPreviewState(targetId: any) { const panel = document.getElementById(`led-preview-panel-${targetId}`); if (panel) panel.style.display = ''; _setPreviewButtonState(targetId, true); // Re-render cached frame onto the new canvas const frame = _ledPreviewLastFrame[targetId]; if (frame && panel) { if (panel.dataset.zoneMode === 'separate') { _renderLedStripZones(panel, frame); } else { const canvas = panel.querySelector('.led-preview-canvas'); if (canvas) _renderLedStrip(canvas, frame); } } } function disconnectLedPreviewWS(targetId: any) { 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'; _setPreviewButtonState(targetId, false); } export function disconnectAllLedPreviewWS() { Object.keys(ledPreviewWebSockets).forEach(id => disconnectLedPreviewWS(id)); } export function toggleLedPreview(targetId: any) { const panel = document.getElementById(`led-preview-panel-${targetId}`); if (!panel) return; if (ledPreviewWebSockets[targetId]) { disconnectLedPreviewWS(targetId); } else { panel.style.display = ''; _setPreviewButtonState(targetId, true); connectLedPreviewWS(targetId); } }