Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/devices.ts
alexei.dolgolyov 1111ab7355 fix: resolve all TypeScript strict null check errors
Fix ~68 pre-existing strict null errors across 13 feature modules.
Add non-null assertions for DOM element lookups, null coalescing for
optional values, and type guards for nullable properties. Zero tsc
errors now with --noEmit.
2026-03-24 13:59:07 +03:00

745 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `<option value="">${t('common.none_no_cspt')}</option>` +
templates.map((tp: any) => `<option value="${tp.id}">${tp.name}</option>`).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') ? `<button class="card-top-btn card-power-btn" onclick="turnOffDevice('${device.id}')" title="${t('device.button.power_off')}">${ICON_STOP_PLAIN}</button>` : '',
removeOnclick: `removeDevice('${device.id}')`,
removeTitle: t('device.button.remove'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(device.name || device.id)}">
<span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
<span class="card-title-text">${device.name || device.id}</span>
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${healthLabel}
</div>
</div>
<div class="card-subtitle">
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
${openrgbZones.length
? openrgbZones.map((z: any) => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('')
: (ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : '')}
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
</div>
<div class="stream-card-props"><span class="stream-card-prop" style="opacity:0.65;" data-last-seen="${device.id}"></span></div>
${(device.capabilities || []).includes('brightness_control') ? `
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
<input type="range" class="brightness-slider" min="0" max="255"
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
oninput="updateBrightnessLabel('${device.id}', this.value)"
onchange="saveCardBrightness('${device.id}', this.value)"
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
</div>` : ''}
${renderTagChips(device.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary card-ping-btn" onclick="event.stopPropagation(); pingDevice('${device.id}')" title="${t('device.button.ping')}">
${ICON_REFRESH}
</button>
<button class="btn btn-icon btn-secondary" onclick="cloneDevice('${device.id}')" title="${t('common.clone')}">
${ICON_CLONE}
</button>
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
${ICON_SETTINGS}
</button>`,
});
}
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}`;
}
}
}