Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/devices.js
alexei.dolgolyov 8fe9c6489b Add camera/webcam capture engine with engine-aware display picker
- 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>
2026-03-01 12:46:28 +03:00

490 lines
21 KiB
JavaScript
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,
} 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();
}