Migrate frontend from JavaScript to TypeScript
- Rename all 54 .js files to .ts, update esbuild entry point - Add tsconfig.json, TypeScript devDependency, typecheck script - Create types.ts with 25+ interfaces matching backend Pydantic schemas (Device, OutputTarget, ColorStripSource, PatternTemplate, ValueSource, AudioSource, PictureSource, ScenePreset, SyncClock, Automation, etc.) - Make DataCache generic (DataCache<T>) with typed state instances - Type all state variables in state.ts with proper entity types - Type all create*Card functions with proper entity interfaces - Type all function parameters and return types across all 54 files - Type core component constructors (CardSection, IconSelect, EntitySelect, FilterList, TagInput, TreeNav, Modal) with exported option interfaces - Add comprehensive global.d.ts for window function declarations - Type fetchWithAuth with FetchAuthOpts interface - Remove all (window as any) casts in favor of global.d.ts declarations - Zero tsc errors, esbuild bundle unchanged Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
744
server/src/wled_controller/static/js/features/devices.ts
Normal file
744
server/src/wled_controller/static/js/features/devices.ts
Normal file
@@ -0,0 +1,744 @@
|
||||
/**
|
||||
* 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: window.t ? t('palette.search') : '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}"></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="${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="${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: window.t ? t('tags.placeholder') : 'Add tag...'
|
||||
});
|
||||
_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="${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="${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="${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="${deviceId}"]`);
|
||||
if (!card) return;
|
||||
for (const zoneName of zones) {
|
||||
const badge = card.querySelector(`[data-zone-name="${zoneName}"]`);
|
||||
if (!badge) continue;
|
||||
const ledCount = counts[zoneName.toLowerCase()];
|
||||
if (ledCount != null) {
|
||||
badge.innerHTML = `${ICON_LED} ${escapeHtml(zoneName)} · ${ledCount}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user