- New CameraEngine using OpenCV VideoCapture for webcam capture - HAS_OWN_DISPLAYS class attribute on CaptureEngine base to distinguish engines with their own device lists from desktop monitor engines - Display picker renders device list for cameras/scrcpy, spatial layout for desktop monitors - Engine-aware display label formatting (camera name vs monitor index) - Stream modal properly loads engine-specific displays on template change, edit, and clone - Camera backend config rendered as dropdown (auto/dshow/msmf/v4l2) - Remove offline label from device cards (healthcheck indicator suffices) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
490 lines
21 KiB
JavaScript
490 lines
21 KiB
JavaScript
/**
|
||
* Device cards — settings modal, brightness, power, color.
|
||
*/
|
||
|
||
import {
|
||
_deviceBrightnessCache, updateDeviceBrightness,
|
||
} from '../core/state.js';
|
||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice } from '../core/api.js';
|
||
import { t } from '../core/i18n.js';
|
||
import { showToast, showConfirm } from '../core/ui.js';
|
||
import { Modal } from '../core/modal.js';
|
||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG } from '../core/icons.js';
|
||
import { wrapCard } from '../core/card-colors.js';
|
||
|
||
class DeviceSettingsModal extends Modal {
|
||
constructor() { super('device-settings-modal'); }
|
||
|
||
deviceType = '';
|
||
capabilities = [];
|
||
|
||
snapshotValues() {
|
||
return {
|
||
name: this.$('settings-device-name').value,
|
||
url: this._getUrl(),
|
||
state_check_interval: this.$('settings-health-interval').value,
|
||
auto_shutdown: this.$('settings-auto-shutdown').checked,
|
||
led_count: this.$('settings-led-count').value,
|
||
led_type: document.getElementById('settings-led-type')?.value || 'rgb',
|
||
send_latency: document.getElementById('settings-send-latency')?.value || '0',
|
||
};
|
||
}
|
||
|
||
_getUrl() {
|
||
if (isMockDevice(this.deviceType)) {
|
||
const deviceId = this.$('settings-device-id')?.value || '';
|
||
return `mock://${deviceId}`;
|
||
}
|
||
if (isWsDevice(this.deviceType)) {
|
||
const deviceId = this.$('settings-device-id')?.value || '';
|
||
return `ws://${deviceId}`;
|
||
}
|
||
if (isSerialDevice(this.deviceType)) {
|
||
return this.$('settings-serial-port').value;
|
||
}
|
||
return this.$('settings-device-url').value.trim();
|
||
}
|
||
}
|
||
|
||
const settingsModal = new DeviceSettingsModal();
|
||
|
||
export function createDeviceCard(device) {
|
||
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;
|
||
|
||
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">
|
||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||
${device.name || device.id}
|
||
${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('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>
|
||
${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>
|
||
${(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>` : ''}`,
|
||
actions: `
|
||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||
${ICON_SETTINGS}
|
||
</button>`,
|
||
});
|
||
}
|
||
|
||
export async function turnOffDevice(deviceId) {
|
||
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) {
|
||
if (error.isAuth) return;
|
||
showToast(t('device.error.power_off_failed'), 'error');
|
||
}
|
||
}
|
||
|
||
export function attachDeviceListeners(deviceId) {
|
||
// Add any specific event listeners here if needed
|
||
}
|
||
|
||
export async function removeDevice(deviceId) {
|
||
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');
|
||
window.loadDevices();
|
||
} else {
|
||
const error = await response.json();
|
||
showToast(error.detail || t('device.error.remove_failed'), 'error');
|
||
}
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to remove device:', error);
|
||
showToast(t('device.error.remove_failed'), 'error');
|
||
}
|
||
}
|
||
|
||
export async function showSettings(deviceId) {
|
||
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').value = device.id;
|
||
document.getElementById('settings-device-name').value = device.name;
|
||
document.getElementById('settings-health-interval').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');
|
||
const serialGroup = document.getElementById('settings-serial-port-group');
|
||
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]');
|
||
const urlHint = urlGroup.querySelector('.input-hint');
|
||
const urlInput = document.getElementById('settings-device-url');
|
||
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';
|
||
} 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');
|
||
if (caps.includes('manual_led_count')) {
|
||
ledCountGroup.style.display = '';
|
||
document.getElementById('settings-led-count').value = device.led_count || '';
|
||
} else {
|
||
ledCountGroup.style.display = 'none';
|
||
}
|
||
|
||
const baudRateGroup = document.getElementById('settings-baud-rate-group');
|
||
if (isAdalight) {
|
||
baudRateGroup.style.display = '';
|
||
const baudSelect = document.getElementById('settings-baud-rate');
|
||
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.style.display = '';
|
||
document.getElementById('settings-led-type').value = device.rgbw ? 'rgbw' : 'rgb';
|
||
}
|
||
if (sendLatencyGroup) {
|
||
sendLatencyGroup.style.display = '';
|
||
document.getElementById('settings-send-latency').value = device.send_latency_ms || 0;
|
||
}
|
||
} else {
|
||
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||
}
|
||
|
||
// WS connection URL
|
||
const wsUrlGroup = document.getElementById('settings-ws-url-group');
|
||
if (wsUrlGroup) {
|
||
if (isWs) {
|
||
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const apiKey = localStorage.getItem('wled_api_key') || '';
|
||
const wsUrl = `${wsProto}//${location.host}/api/v1/devices/${device.id}/ws?token=${encodeURIComponent(apiKey)}`;
|
||
document.getElementById('settings-ws-url').value = wsUrl;
|
||
wsUrlGroup.style.display = '';
|
||
} else {
|
||
wsUrlGroup.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Hide health check for devices without health_check capability
|
||
const healthIntervalGroup = document.getElementById('settings-health-interval-group');
|
||
if (healthIntervalGroup) {
|
||
healthIntervalGroup.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.style.display = caps.includes('auto_restore') ? '' : 'none';
|
||
}
|
||
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
|
||
settingsModal.snapshot();
|
||
settingsModal.open();
|
||
|
||
setTimeout(() => {
|
||
document.getElementById('settings-device-name').focus();
|
||
}, 100);
|
||
|
||
} catch (error) {
|
||
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() { settingsModal.forceClose(); }
|
||
export function closeDeviceSettingsModal() { settingsModal.close(); }
|
||
|
||
export async function saveDeviceSettings() {
|
||
const deviceId = document.getElementById('settings-device-id').value;
|
||
const name = document.getElementById('settings-device-name').value.trim();
|
||
const url = settingsModal._getUrl();
|
||
|
||
if (!name || !url) {
|
||
settingsModal.showError(t('device.error.required'));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const body = {
|
||
name, url,
|
||
auto_shutdown: document.getElementById('settings-auto-shutdown').checked,
|
||
state_check_interval: parseInt(document.getElementById('settings-health-interval').value, 10) || 30,
|
||
};
|
||
const ledCountInput = document.getElementById('settings-led-count');
|
||
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').value;
|
||
if (baudVal) body.baud_rate = parseInt(baudVal, 10);
|
||
}
|
||
if (isMockDevice(settingsModal.deviceType)) {
|
||
const sendLatency = document.getElementById('settings-send-latency')?.value;
|
||
if (sendLatency !== undefined) body.send_latency_ms = parseInt(sendLatency, 10);
|
||
const ledType = document.getElementById('settings-led-type')?.value;
|
||
body.rgbw = ledType === 'rgbw';
|
||
}
|
||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(body)
|
||
});
|
||
|
||
if (!deviceResponse.ok) {
|
||
const errorData = await deviceResponse.json();
|
||
settingsModal.showError(t('device.error.update'));
|
||
return;
|
||
}
|
||
|
||
showToast(t('settings.saved'), 'success');
|
||
settingsModal.forceClose();
|
||
window.loadDevices();
|
||
} catch (err) {
|
||
if (err.isAuth) return;
|
||
console.error('Failed to save device settings:', err);
|
||
settingsModal.showError(t('device.error.save'));
|
||
}
|
||
}
|
||
|
||
// Brightness
|
||
export function updateBrightnessLabel(deviceId, value) {
|
||
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
|
||
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
|
||
}
|
||
|
||
export async function saveCardBrightness(deviceId, value) {
|
||
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) {
|
||
showToast(t('device.error.brightness'), 'error');
|
||
}
|
||
} catch (err) {
|
||
if (err.isAuth) return;
|
||
showToast(t('device.error.brightness'), 'error');
|
||
}
|
||
}
|
||
|
||
const _brightnessFetchInFlight = new Set();
|
||
export async function fetchDeviceBrightness(deviceId) {
|
||
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}"]`);
|
||
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}"]`);
|
||
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, ledCount, deviceType) {
|
||
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, baudRate, ledCount, deviceType) {
|
||
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').value, 10);
|
||
const ledCount = parseInt(document.getElementById('settings-led-count').value, 10);
|
||
_renderFpsHint(hintEl, baudRate, ledCount, settingsModal.deviceType);
|
||
}
|
||
|
||
// Settings serial port population (used from showSettings)
|
||
async function _populateSettingsSerialPorts(currentUrl) {
|
||
const select = document.getElementById('settings-serial-port');
|
||
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 => {
|
||
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');
|
||
if (input && input.value) {
|
||
navigator.clipboard.writeText(input.value).then(() => {
|
||
showToast(t('settings.copied') || 'Copied!', 'success');
|
||
});
|
||
}
|
||
}
|
||
|
||
export async function loadDevices() {
|
||
await window.loadTargetsTab();
|
||
}
|