/** * Device cards — settings modal, brightness, power, color. */ import { _deviceBrightnessCache, updateDeviceBrightness, csptCache, } from '../core/state.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice } from '../core/api.ts'; import { devicesCache } from '../core/state.ts'; import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect } from './device-discovery.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, desktopFocus } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE, ICON_CLONE } from '../core/icons.ts'; import { wrapCard } from '../core/card-colors.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { EntitySelect } from '../core/entity-palette.ts'; import { getBaseOrigin } from './settings.ts'; import type { Device } from '../types.ts'; let _deviceTagsInput: any = null; let _settingsCsptEntitySelect: any = null; function _ensureSettingsCsptSelect() { const sel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null; if (!sel) return; const templates = csptCache.data || []; sel.innerHTML = `` + templates.map((tp: any) => ``).join(''); if (_settingsCsptEntitySelect) _settingsCsptEntitySelect.destroy(); if (templates.length > 0) { _settingsCsptEntitySelect = new EntitySelect({ target: sel, getItems: () => (csptCache.data || []).map((tp: any) => ({ value: tp.id, label: tp.name, icon: ICON_TEMPLATE, desc: '', })), placeholder: t('palette.search'), allowNone: true, noneLabel: t('common.none_no_cspt'), } as any); } } class DeviceSettingsModal extends Modal { constructor() { super('device-settings-modal'); } deviceType = ''; capabilities: string[] = []; snapshotValues() { return { name: (this.$('settings-device-name') as HTMLInputElement).value, url: this._getUrl(), state_check_interval: (this.$('settings-health-interval') as HTMLInputElement).value, auto_shutdown: (this.$('settings-auto-shutdown') as HTMLInputElement).checked, led_count: (this.$('settings-led-count') as HTMLInputElement).value, led_type: (document.getElementById('settings-led-type') as HTMLSelectElement | null)?.value || 'rgb', send_latency: (document.getElementById('settings-send-latency') as HTMLInputElement | null)?.value || '0', zones: JSON.stringify(_getCheckedZones('settings-zone-list')), zoneMode: _getZoneMode('settings-zone-mode'), tags: JSON.stringify(_deviceTagsInput ? _deviceTagsInput.getValue() : []), dmxProtocol: (document.getElementById('settings-dmx-protocol') as HTMLSelectElement | null)?.value || 'artnet', dmxStartUniverse: (document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0', dmxStartChannel: (document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1', csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '', }; } _getUrl() { if (isMockDevice(this.deviceType)) { const deviceId = (this.$('settings-device-id') as HTMLInputElement | null)?.value || ''; return `mock://${deviceId}`; } if (isWsDevice(this.deviceType)) { const deviceId = (this.$('settings-device-id') as HTMLInputElement | null)?.value || ''; return `ws://${deviceId}`; } if (isSerialDevice(this.deviceType)) { return (this.$('settings-serial-port') as HTMLSelectElement).value; } let url = (this.$('settings-device-url') as HTMLInputElement).value.trim(); // Append selected zones for OpenRGB if (isOpenrgbDevice(this.deviceType)) { const zones = _getCheckedZones('settings-zone-list'); if (zones.length > 0) { const { baseUrl } = _splitOpenrgbZone(url); url = baseUrl + '/' + zones.join('+'); } } return url; } } const settingsModal = new DeviceSettingsModal(); export function formatRelativeTime(isoString: any) { if (!isoString) return null; const then = new Date(isoString); const diffMs = Date.now() - then.getTime(); if (diffMs < 0) return null; const diffSec = Math.floor(diffMs / 1000); if (diffSec < 5) return t('device.last_seen.just_now'); if (diffSec < 60) return t('device.last_seen.seconds').replace('%d', diffSec); const diffMin = Math.floor(diffSec / 60); if (diffMin < 60) return t('device.last_seen.minutes').replace('%d', diffMin); const diffHr = Math.floor(diffMin / 60); if (diffHr < 24) return t('device.last_seen.hours').replace('%d', diffHr); const diffDay = Math.floor(diffHr / 24); return t('device.last_seen.days').replace('%d', diffDay); } export function createDeviceCard(device: Device & { state?: any }) { const state = device.state || {}; const devOnline = state.device_online || false; const devLatency = state.device_latency_ms; const devName = state.device_name; const devVersion = state.device_version; const devLastChecked = state.device_last_checked; let healthClass, healthTitle, healthLabel; if (devLastChecked === null || devLastChecked === undefined) { healthClass = 'health-unknown'; healthTitle = t('device.health.checking'); healthLabel = ''; } else if (devOnline) { healthClass = 'health-online'; healthTitle = `${t('device.health.online')}`; if (devName) healthTitle += ` - ${devName}`; if (devVersion) healthTitle += ` v${devVersion}`; if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`; healthLabel = ''; } else { healthClass = 'health-offline'; healthTitle = t('device.health.offline'); if (state.device_error) healthTitle += `: ${state.device_error}`; healthLabel = ''; } const ledCount = state.device_led_count || device.led_count; // Parse zone names from OpenRGB URL for badge display const openrgbZones = isOpenrgbDevice(device.device_type) ? _splitOpenrgbZone(device.url).zones : []; return wrapCard({ dataAttr: 'data-device-id', id: device.id, topButtons: (device.capabilities || []).includes('power_control') ? `` : '', removeOnclick: `removeDevice('${device.id}')`, removeTitle: t('device.button.remove'), content: `
${device.name || device.id} ${device.url && device.url.startsWith('http') ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}${ICON_WEB}` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `${escapeHtml(device.url)}` : '')} ${healthLabel}
${(device.device_type || 'wled').toUpperCase()} ${openrgbZones.length ? openrgbZones.map((z: any) => `${ICON_LED} ${escapeHtml(z)}`).join('') : (ledCount ? `${ICON_LED} ${ledCount}` : '')} ${state.device_led_type ? `${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}` : ''} ${state.device_rgbw ? '' : ''}
${(device.capabilities || []).includes('brightness_control') ? `
` : ''} ${renderTagChips(device.tags)}`, actions: ` `, }); } export async function turnOffDevice(deviceId: any) { const confirmed = await showConfirm(t('confirm.turn_off_device')); if (!confirmed) return; try { const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, { method: 'PUT', body: JSON.stringify({ on: false }) }); if (setResp.ok) { showToast(t('device.power.off_success'), 'success'); } else { const error = await setResp.json(); showToast(error.detail || 'Failed', 'error'); } } catch (error: any) { if (error.isAuth) return; showToast(t('device.error.power_off_failed'), 'error'); } } export async function pingDevice(deviceId: any) { const btn = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"] .card-ping-btn`) as HTMLElement | null; if (btn) btn.classList.add('spinning'); try { const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' }); if (resp.ok) { const data = await resp.json(); const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?'; showToast(data.device_online ? t('device.ping.online', { ms }) : t('device.ping.offline'), data.device_online ? 'success' : 'error'); // Refresh device cards to update health dot devicesCache.invalidate(); await window.loadDevices(); } else { const err = await resp.json(); showToast(err.detail || 'Ping failed', 'error'); } } catch (error: any) { if (error.isAuth) return; showToast(t('device.ping.error'), 'error'); } finally { if (btn) btn.classList.remove('spinning'); } } export function attachDeviceListeners(deviceId: any) { // Add any specific event listeners here if needed } export async function removeDevice(deviceId: any) { const confirmed = await showConfirm(t('device.remove.confirm')); if (!confirmed) return; try { const response = await fetchWithAuth(`/devices/${deviceId}`, { method: 'DELETE', }); if (response.ok) { showToast(t('device.removed'), 'success'); devicesCache.invalidate(); window.loadDevices(); } else { const error = await response.json(); showToast(error.detail || t('device.error.remove_failed'), 'error'); } } catch (error: any) { if (error.isAuth) return; console.error('Failed to remove device:', error); showToast(t('device.error.remove_failed'), 'error'); } } export async function showSettings(deviceId: any) { try { const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`); if (!deviceResponse.ok) { showToast(t('device.error.settings_load_failed'), 'error'); return; } const device = await deviceResponse.json(); const isAdalight = isSerialDevice(device.device_type); const caps = device.capabilities || []; // Set modal state before populating fields (so async helpers read correct type) settingsModal.deviceType = device.device_type; settingsModal.capabilities = caps; (document.getElementById('settings-device-id') as HTMLInputElement).value = device.id; (document.getElementById('settings-device-name') as HTMLInputElement).value = device.name; (document.getElementById('settings-health-interval') as HTMLInputElement).value = device.state_check_interval ?? 30; const isMock = isMockDevice(device.device_type); const isWs = isWsDevice(device.device_type); const isMqtt = isMqttDevice(device.device_type); const urlGroup = document.getElementById('settings-url-group') as HTMLElement; const serialGroup = document.getElementById('settings-serial-port-group') as HTMLElement; const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null; const urlHint = urlGroup.querySelector('.input-hint') as HTMLElement | null; const urlInput = document.getElementById('settings-device-url') as HTMLInputElement; if (isMock || isWs) { urlGroup.style.display = 'none'; urlInput.removeAttribute('required'); serialGroup.style.display = 'none'; } else if (isAdalight) { urlGroup.style.display = 'none'; urlInput.removeAttribute('required'); serialGroup.style.display = ''; _populateSettingsSerialPorts(device.url); } else { urlGroup.style.display = ''; urlInput.setAttribute('required', ''); urlInput.value = device.url; serialGroup.style.display = 'none'; // Relabel for MQTT if (isMqtt) { if (urlLabel) urlLabel.textContent = t('device.mqtt_topic'); if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint'); urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room'; } else if (isOpenrgbDevice(device.device_type)) { if (urlLabel) urlLabel.textContent = t('device.openrgb.url'); if (urlHint) urlHint.textContent = t('device.openrgb.url.hint'); urlInput.placeholder = 'openrgb://localhost:6742/0'; // Parse zone from URL and show base URL only const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url); urlInput.value = baseUrl; } else { if (urlLabel) urlLabel.textContent = t('device.url'); if (urlHint) urlHint.textContent = t('settings.url.hint'); urlInput.placeholder = t('device.url.placeholder') || 'http://192.168.1.100'; } } const ledCountGroup = document.getElementById('settings-led-count-group') as HTMLElement; if (caps.includes('manual_led_count')) { ledCountGroup.style.display = ''; (document.getElementById('settings-led-count') as HTMLInputElement).value = device.led_count || ''; } else { ledCountGroup.style.display = 'none'; } const baudRateGroup = document.getElementById('settings-baud-rate-group') as HTMLElement; if (isAdalight) { baudRateGroup.style.display = ''; const baudSelect = document.getElementById('settings-baud-rate') as HTMLSelectElement; if (device.baud_rate) { baudSelect.value = String(device.baud_rate); } else { baudSelect.value = '115200'; } updateSettingsBaudFpsHint(); } else { baudRateGroup.style.display = 'none'; } // Mock-specific fields const ledTypeGroup = document.getElementById('settings-led-type-group'); const sendLatencyGroup = document.getElementById('settings-send-latency-group'); if (isMock) { if (ledTypeGroup) { (ledTypeGroup as HTMLElement).style.display = ''; (document.getElementById('settings-led-type') as HTMLSelectElement).value = device.rgbw ? 'rgbw' : 'rgb'; } if (sendLatencyGroup) { (sendLatencyGroup as HTMLElement).style.display = ''; (document.getElementById('settings-send-latency') as HTMLInputElement).value = device.send_latency_ms || 0; } } else { if (ledTypeGroup) (ledTypeGroup as HTMLElement).style.display = 'none'; if (sendLatencyGroup) (sendLatencyGroup as HTMLElement).style.display = 'none'; } // WS connection URL const wsUrlGroup = document.getElementById('settings-ws-url-group'); if (wsUrlGroup) { if (isWs) { const origin = getBaseOrigin(); const wsProto = origin.startsWith('https') ? 'wss:' : 'ws:'; const hostPart = origin.replace(/^https?:\/\//, ''); const apiKey = localStorage.getItem('wled_api_key') || ''; const wsUrl = `${wsProto}//${hostPart}/api/v1/devices/${device.id}/ws?token=${encodeURIComponent(apiKey)}`; (document.getElementById('settings-ws-url') as HTMLInputElement).value = wsUrl; (wsUrlGroup as HTMLElement).style.display = ''; } else { (wsUrlGroup as HTMLElement).style.display = 'none'; } } // Hide health check for devices without health_check capability const healthIntervalGroup = document.getElementById('settings-health-interval-group'); if (healthIntervalGroup) { (healthIntervalGroup as HTMLElement).style.display = caps.includes('health_check') ? '' : 'none'; } // Hide auto-restore for devices without auto_restore capability const autoShutdownGroup = document.getElementById('settings-auto-shutdown-group'); if (autoShutdownGroup) { (autoShutdownGroup as HTMLElement).style.display = caps.includes('auto_restore') ? '' : 'none'; } (document.getElementById('settings-auto-shutdown') as HTMLInputElement).checked = !!device.auto_shutdown; // OpenRGB zone picker + mode toggle const settingsZoneGroup = document.getElementById('settings-zone-group'); const settingsZoneModeGroup = document.getElementById('settings-zone-mode-group'); if (settingsZoneModeGroup) (settingsZoneModeGroup as HTMLElement).style.display = 'none'; if (settingsZoneGroup) { if (isOpenrgbDevice(device.device_type)) { (settingsZoneGroup as HTMLElement).style.display = ''; const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url); // Set zone mode radio from device const savedMode = device.zone_mode || 'combined'; const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${CSS.escape(savedMode)}"]`) as HTMLInputElement | null; if (modeRadio) modeRadio.checked = true; _fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => { // Re-snapshot after zones are loaded so dirty-check baseline includes them settingsModal.snapshot(); }); } else { (settingsZoneGroup as HTMLElement).style.display = 'none'; (document.getElementById('settings-zone-list') as HTMLElement).innerHTML = ''; } } // DMX-specific fields const dmxProtocolGroup = document.getElementById('settings-dmx-protocol-group'); const dmxStartUniverseGroup = document.getElementById('settings-dmx-start-universe-group'); const dmxStartChannelGroup = document.getElementById('settings-dmx-start-channel-group'); if (isDmxDevice(device.device_type)) { if (dmxProtocolGroup) (dmxProtocolGroup as HTMLElement).style.display = ''; if (dmxStartUniverseGroup) (dmxStartUniverseGroup as HTMLElement).style.display = ''; if (dmxStartChannelGroup) (dmxStartChannelGroup as HTMLElement).style.display = ''; (document.getElementById('settings-dmx-protocol') as HTMLSelectElement).value = device.dmx_protocol || 'artnet'; ensureDmxProtocolIconSelect('settings-dmx-protocol'); (document.getElementById('settings-dmx-start-universe') as HTMLInputElement).value = device.dmx_start_universe ?? 0; (document.getElementById('settings-dmx-start-channel') as HTMLInputElement).value = device.dmx_start_channel ?? 1; // Relabel URL field as IP Address const urlLabel2 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null; const urlHint2 = urlGroup.querySelector('.input-hint') as HTMLElement | null; if (urlLabel2) urlLabel2.textContent = t('device.dmx.url'); if (urlHint2) urlHint2.textContent = t('device.dmx.url.hint'); urlInput.placeholder = t('device.dmx.url.placeholder') || '192.168.1.50'; } else { destroyDmxProtocolIconSelect('settings-dmx-protocol'); if (dmxProtocolGroup) (dmxProtocolGroup as HTMLElement).style.display = 'none'; if (dmxStartUniverseGroup) (dmxStartUniverseGroup as HTMLElement).style.display = 'none'; if (dmxStartChannelGroup) (dmxStartChannelGroup as HTMLElement).style.display = 'none'; } // Tags if (_deviceTagsInput) _deviceTagsInput.destroy(); _deviceTagsInput = new TagInput(document.getElementById('device-tags-container'), { placeholder: t('tags.placeholder'), }); _deviceTagsInput.setValue(device.tags || []); // CSPT template selector await csptCache.fetch(); _ensureSettingsCsptSelect(); const csptSel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null; if (csptSel) csptSel.value = device.default_css_processing_template_id || ''; settingsModal.snapshot(); settingsModal.open(); setTimeout(() => desktopFocus(document.getElementById('settings-device-name')), 100); } catch (error: any) { if (error.isAuth) return; console.error('Failed to load device settings:', error); showToast(t('device.error.settings_load_failed'), 'error'); } } export function isSettingsDirty() { return settingsModal.isDirty(); } export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } settingsModal.forceClose(); } export function closeDeviceSettingsModal() { settingsModal.close(); } export async function saveDeviceSettings() { const deviceId = (document.getElementById('settings-device-id') as HTMLInputElement).value; const name = (document.getElementById('settings-device-name') as HTMLInputElement).value.trim(); const url = settingsModal._getUrl(); if (!name || !url) { settingsModal.showError(t('device.error.required')); return; } try { const body: any = { name, url, auto_shutdown: (document.getElementById('settings-auto-shutdown') as HTMLInputElement).checked, state_check_interval: parseInt((document.getElementById('settings-health-interval') as HTMLInputElement).value, 10) || 30, tags: _deviceTagsInput ? _deviceTagsInput.getValue() : [], }; const ledCountInput = document.getElementById('settings-led-count') as HTMLInputElement; if (settingsModal.capabilities.includes('manual_led_count') && ledCountInput.value) { body.led_count = parseInt(ledCountInput.value, 10); } if (isSerialDevice(settingsModal.deviceType)) { const baudVal = (document.getElementById('settings-baud-rate') as HTMLSelectElement).value; if (baudVal) body.baud_rate = parseInt(baudVal, 10); } if (isMockDevice(settingsModal.deviceType)) { const sendLatency = (document.getElementById('settings-send-latency') as HTMLInputElement | null)?.value; if (sendLatency !== undefined) body.send_latency_ms = parseInt(sendLatency, 10); const ledType = (document.getElementById('settings-led-type') as HTMLSelectElement | null)?.value; body.rgbw = ledType === 'rgbw'; } if (isOpenrgbDevice(settingsModal.deviceType)) { body.zone_mode = _getZoneMode('settings-zone-mode'); } if (isDmxDevice(settingsModal.deviceType)) { body.dmx_protocol = (document.getElementById('settings-dmx-protocol') as HTMLSelectElement | null)?.value || 'artnet'; body.dmx_start_universe = parseInt((document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0', 10); body.dmx_start_channel = parseInt((document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1', 10); } const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || ''; body.default_css_processing_template_id = csptId; const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, { method: 'PUT', body: JSON.stringify(body) }); if (!deviceResponse.ok) { const errorData = await deviceResponse.json(); const detail = errorData.detail || errorData.message || ''; const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail); settingsModal.showError(detailStr || t('device.error.update')); return; } showToast(t('settings.saved'), 'success'); devicesCache.invalidate(); settingsModal.forceClose(); window.loadDevices(); } catch (err: any) { if (err.isAuth) return; console.error('Failed to save device settings:', err); settingsModal.showError(err.message || t('device.error.save')); } } // Brightness export function updateBrightnessLabel(deviceId: any, value: any) { const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLElement | null; if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%'; } export async function saveCardBrightness(deviceId: any, value: any) { const bri = parseInt(value); updateDeviceBrightness(deviceId, bri); try { const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`, { method: 'PUT', body: JSON.stringify({ brightness: bri }) }); if (!resp.ok) { const errData = await resp.json().catch(() => ({})); const detail = errData.detail || errData.message || ''; const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail); showToast(detailStr || t('device.error.brightness'), 'error'); } } catch (err: any) { if (err.isAuth) return; showToast(err.message || t('device.error.brightness'), 'error'); } } const _brightnessFetchInFlight = new Set(); export async function fetchDeviceBrightness(deviceId: any) { if (_brightnessFetchInFlight.has(deviceId)) return; _brightnessFetchInFlight.add(deviceId); try { const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`); if (!resp.ok) return; const data = await resp.json(); updateDeviceBrightness(deviceId, data.brightness); const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null; if (slider) { slider.value = data.brightness; slider.title = Math.round(data.brightness / 255 * 100) + '%'; slider.disabled = false; } const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(deviceId)}"]`) as HTMLElement | null; if (wrap) wrap.classList.remove('brightness-loading'); } catch (err) { // Silently fail — device may be offline } finally { _brightnessFetchInFlight.delete(deviceId); } } // LED protocol timing constants const LED_US_PER_BIT = 1.25; // SK6812/WS2812B bit time (μs) const LED_BITS_PER_PIXEL = 32; // RGBW worst case (4 channels × 8 bits) const LED_US_PER_PIXEL = LED_BITS_PER_PIXEL * LED_US_PER_BIT; // 40μs const LED_RESET_US = 80; // reset/latch pulse (μs) const US_PER_SECOND = 1_000_000; // Serial protocol constants const SERIAL_BITS_PER_BYTE = 10; // 8N1: 1 start + 8 data + 1 stop const SERIAL_RGB_BYTES_PER_LED = 3; const ADALIGHT_HEADER_BYTES = 6; // 'Ada' + count_hi + count_lo + checksum const AMBILED_HEADER_BYTES = 1; // FPS hint helpers (shared with device-discovery, targets) export function _computeMaxFps(baudRate: any, ledCount: any, deviceType: any) { if (!ledCount || ledCount < 1) return null; if (deviceType === 'wled') { const frameUs = ledCount * LED_US_PER_PIXEL + LED_RESET_US; return Math.floor(US_PER_SECOND / frameUs); } if (!baudRate) return null; const overhead = deviceType === 'ambiled' ? AMBILED_HEADER_BYTES : ADALIGHT_HEADER_BYTES; const bitsPerFrame = (ledCount * SERIAL_RGB_BYTES_PER_LED + overhead) * SERIAL_BITS_PER_BYTE; return Math.floor(baudRate / bitsPerFrame); } export function _renderFpsHint(hintEl: any, baudRate: any, ledCount: any, deviceType: any) { const fps = _computeMaxFps(baudRate, ledCount, deviceType); if (fps !== null) { hintEl.textContent = `Max FPS ≈ ${fps}`; hintEl.style.display = ''; } else { hintEl.style.display = 'none'; } } export function updateSettingsBaudFpsHint() { const hintEl = document.getElementById('settings-baud-fps-hint'); const baudRate = parseInt((document.getElementById('settings-baud-rate') as HTMLSelectElement).value, 10); const ledCount = parseInt((document.getElementById('settings-led-count') as HTMLInputElement).value, 10); _renderFpsHint(hintEl, baudRate, ledCount, settingsModal.deviceType); } // Settings serial port population (used from showSettings) async function _populateSettingsSerialPorts(currentUrl: any) { const select = document.getElementById('settings-serial-port') as HTMLSelectElement; select.innerHTML = ''; const loadingOpt = document.createElement('option'); loadingOpt.value = currentUrl; loadingOpt.textContent = currentUrl + ' ⏳'; select.appendChild(loadingOpt); try { const discoverType = settingsModal.deviceType || 'adalight'; const resp = await fetchWithAuth(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`); if (!resp.ok) return; const data = await resp.json(); const devices = data.devices || []; select.innerHTML = ''; let currentFound = false; devices.forEach((device: any) => { const opt = document.createElement('option'); opt.value = device.url; opt.textContent = device.name; if (device.url === currentUrl) currentFound = true; select.appendChild(opt); }); if (!currentFound) { const opt = document.createElement('option'); opt.value = currentUrl; opt.textContent = currentUrl; select.insertBefore(opt, select.firstChild); } select.value = currentUrl; } catch (err) { console.error('Failed to discover serial ports:', err); } } export function copyWsUrl() { const input = document.getElementById('settings-ws-url') as HTMLInputElement | null; if (!input || !input.value) return; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(input.value).then(() => { showToast(t('settings.copied') || 'Copied!', 'success'); }); } else { input.select(); document.execCommand('copy'); showToast(t('settings.copied') || 'Copied!', 'success'); } } export async function loadDevices() { await window.loadTargetsTab(); } document.addEventListener('auth:keyChanged', () => loadDevices()); // ===== OpenRGB zone count enrichment ===== // Cache: baseUrl → { zoneName: ledCount, ... } const _zoneCountCache: any = {}; /** Return cached zone LED counts for a base URL, or null if not cached. */ export function getZoneCountCache(baseUrl: any) { return _zoneCountCache[baseUrl] || null; } const _zoneCountInFlight = new Set(); /** * Fetch zone LED counts for an OpenRGB device and update zone badges on the card. * Called after cards are rendered (same pattern as fetchDeviceBrightness). */ export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) { const { baseUrl, zones } = _splitOpenrgbZone(deviceUrl); if (!zones.length) return; // Use cache if available if (_zoneCountCache[baseUrl]) { _applyZoneCounts(deviceId, zones, _zoneCountCache[baseUrl]); return; } // Deduplicate in-flight requests per base URL if (_zoneCountInFlight.has(baseUrl)) return; _zoneCountInFlight.add(baseUrl); try { const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`); if (!resp.ok) return; const data = await resp.json(); const counts: any = {}; for (const z of data.zones) { counts[z.name.toLowerCase()] = z.led_count; } _zoneCountCache[baseUrl] = counts; _applyZoneCounts(deviceId, zones, counts); } catch { // Silently fail — device may be offline } finally { _zoneCountInFlight.delete(baseUrl); } } function _applyZoneCounts(deviceId: any, zones: any, counts: any) { const card = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"]`); if (!card) return; for (const zoneName of zones) { const badge = card.querySelector(`[data-zone-name="${CSS.escape(zoneName)}"]`); if (!badge) continue; const ledCount = counts[zoneName.toLowerCase()]; if (ledCount != null) { badge.innerHTML = `${ICON_LED} ${escapeHtml(zoneName)} · ${ledCount}`; } } }