Add WebSocket device type, capability-driven settings, hide filter on collapse

- New WS device type: broadcaster singleton + LEDClient that sends binary
  frames to connected WebSocket clients during processing
- FastAPI WS endpoint at /api/v1/devices/{device_id}/ws with token auth
- Frontend: add/edit WS devices, connection URL with copy button in settings
- Add health_check and auto_restore capabilities to WLED and Serial providers;
  hide health interval and auto-restore toggle for devices without them
- Skip health check loop for virtual devices (Mock, MQTT, WS) — set always-online
- Copy buttons and labels for API CSS push endpoints (REST POST / WebSocket)
- Hide mock:// and ws:// URLs in target device dropdown
- Hide filter textbox when card section is collapsed (cs-collapsed CSS class)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 20:55:09 +03:00
parent 175a2c6c10
commit fa81d6a608
21 changed files with 375 additions and 16 deletions

View File

@@ -414,6 +414,15 @@ textarea:focus-visible {
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
/* WS device connection URL */
.ws-url-row {
display: flex;
gap: 6px;
}
.ws-url-row input { flex: 1; }
.ws-url-row .btn { padding: 4px 10px; min-width: 0; flex: 0 0 auto; }
.endpoint-label { display: block; font-weight: 600; margin-bottom: 2px; opacity: 0.7; font-size: 0.8em; }
/* Scene target selector */
.scene-target-add-row {
display: flex;

View File

@@ -682,6 +682,10 @@
flex-shrink: 0;
}
.cs-collapsed .cs-filter-wrap {
display: none;
}
.cs-filter-wrap {
position: relative;
margin-left: auto;

View File

@@ -32,7 +32,7 @@ import {
showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal,
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
turnOffDevice, removeDevice, loadDevices,
updateSettingsBaudFpsHint,
updateSettingsBaudFpsHint, copyWsUrl,
} from './features/devices.js';
import {
loadDashboard, stopUptimeTimer,
@@ -113,6 +113,7 @@ import {
onAudioVizChange,
applyGradientPreset,
cloneColorStrip,
copyEndpointUrl,
} from './features/color-strips.js';
// Layer 5: audio sources
@@ -201,6 +202,7 @@ Object.assign(window, {
removeDevice,
loadDevices,
updateSettingsBaudFpsHint,
copyWsUrl,
// dashboard
loadDashboard,
@@ -368,6 +370,7 @@ Object.assign(window, {
onAudioVizChange,
applyGradientPreset,
cloneColorStrip,
copyEndpointUrl,
// audio sources
showAudioSourceModal,

View File

@@ -82,6 +82,10 @@ export function isMqttDevice(type) {
return type === 'mqtt';
}
export function isWsDevice(type) {
return type === 'ws';
}
export function handle401Error() {
if (!apiKey) return; // Already handled or no session
localStorage.removeItem('wled_api_key');

View File

@@ -74,13 +74,14 @@ export class CardSection {
const isCollapsed = !!_getCollapsedMap()[this.sectionKey];
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
const contentDisplay = isCollapsed ? ' style="display:none"' : '';
const collapsedClass = isCollapsed ? ' cs-collapsed' : '';
const addCard = this.addCardOnclick
? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>`
: '';
return `
<div class="subtab-section" data-card-section="${this.sectionKey}">
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
<div class="subtab-section-header cs-header" data-cs-toggle="${this.sectionKey}">
<span class="cs-chevron"${chevronStyle}>&#9654;</span>
<span class="cs-title">${t(this.titleKey)}</span>
@@ -277,7 +278,9 @@ export class CardSection {
map[s.sectionKey] = false;
const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`);
const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`);
const section = document.querySelector(`[data-card-section="${s.sectionKey}"]`);
if (content) content.style.display = '';
if (section) section.classList.remove('cs-collapsed');
if (header) {
const chevron = header.querySelector('.cs-chevron');
if (chevron) chevron.style.transform = 'rotate(90deg)';
@@ -293,7 +296,9 @@ export class CardSection {
map[s.sectionKey] = true;
const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`);
const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`);
const section = document.querySelector(`[data-card-section="${s.sectionKey}"]`);
if (content) content.style.display = 'none';
if (section) section.classList.add('cs-collapsed');
if (header) {
const chevron = header.querySelector('.cs-chevron');
if (chevron) chevron.style.transform = '';
@@ -312,6 +317,8 @@ export class CardSection {
map[this.sectionKey] = false;
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
content.style.display = '';
const section = header.closest('[data-card-section]');
if (section) section.classList.remove('cs-collapsed');
const chevron = header.querySelector('.cs-chevron');
if (chevron) chevron.style.transform = 'rotate(90deg)';
}
@@ -374,6 +381,9 @@ export class CardSection {
map[this.sectionKey] = nowCollapsed;
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
const section = header.closest('[data-card-section]');
if (section) section.classList.toggle('cs-collapsed', nowCollapsed);
const chevron = header.querySelector('.cs-chevron');
if (chevron) chevron.style.transform = nowCollapsed ? '' : 'rotate(90deg)';

View File

@@ -1080,12 +1080,26 @@ function _showApiInputEndpoints(cssId) {
const base = `${window.location.origin}/api/v1`;
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsBase = `${wsProto}//${window.location.host}/api/v1`;
const restUrl = `${base}/color-strip-sources/${cssId}/colors`;
const apiKey = localStorage.getItem('wled_api_key') || '';
const wsUrl = `${wsBase}/color-strip-sources/${cssId}/ws?token=${encodeURIComponent(apiKey)}`;
el.innerHTML = `
<div style="margin-bottom:4px"><strong>REST POST:</strong><br>${base}/color-strip-sources/${cssId}/colors</div>
<div><strong>WebSocket:</strong><br>${wsBase}/color-strip-sources/${cssId}/ws?token=&lt;api_key&gt;</div>
<small class="endpoint-label">REST POST</small>
<div class="ws-url-row" style="margin-bottom:6px"><input type="text" value="${restUrl}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">&#x1F4CB;</button></div>
<small class="endpoint-label">WebSocket</small>
<div class="ws-url-row"><input type="text" value="${wsUrl}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">&#x1F4CB;</button></div>
`;
}
export function copyEndpointUrl(btn) {
const input = btn.parentElement.querySelector('input');
if (input && input.value) {
navigator.clipboard.writeText(input.value).then(() => {
showToast(t('settings.copied') || 'Copied!', 'success');
});
}
}
/* ── Clone ────────────────────────────────────────────────────── */
export async function cloneColorStrip(cssId) {

View File

@@ -6,7 +6,7 @@ import {
_discoveryScanRunning, set_discoveryScanRunning,
_discoveryCache, set_discoveryCache,
} from '../core/state.js';
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, escapeHtml } from '../core/api.js';
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast } from '../core/ui.js';
import { Modal } from '../core/modal.js';
@@ -76,6 +76,17 @@ export function onDeviceTypeChanged() {
if (sendLatencyGroup) sendLatencyGroup.style.display = '';
if (discoverySection) discoverySection.style.display = 'none';
if (scanBtn) scanBtn.style.display = 'none';
} else if (isWsDevice(deviceType)) {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
serialGroup.style.display = 'none';
serialSelect.removeAttribute('required');
ledCountGroup.style.display = '';
baudRateGroup.style.display = 'none';
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
if (discoverySection) discoverySection.style.display = 'none';
if (scanBtn) scanBtn.style.display = 'none';
} else if (isSerialDevice(deviceType)) {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
@@ -338,6 +349,8 @@ export async function handleAddDevice(event) {
let url;
if (isMockDevice(deviceType)) {
url = 'mock://';
} else if (isWsDevice(deviceType)) {
url = 'ws://';
} else if (isSerialDevice(deviceType)) {
url = document.getElementById('device-serial-port').value;
} else {
@@ -349,7 +362,7 @@ export async function handleAddDevice(event) {
url = 'mqtt://' + url;
}
if (!name || (!isMockDevice(deviceType) && !url)) {
if (!name || (!isMockDevice(deviceType) && !isWsDevice(deviceType) && !url)) {
error.textContent = t('device_discovery.error.fill_all_fields');
error.style.display = 'block';
return;

View File

@@ -5,7 +5,7 @@
import {
_deviceBrightnessCache, updateDeviceBrightness,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice } from '../core/api.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
@@ -34,6 +34,10 @@ class DeviceSettingsModal extends Modal {
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;
}
@@ -83,7 +87,7 @@ export function createDeviceCard(device) {
<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('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></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('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${healthLabel}
</div>
</div>
@@ -174,13 +178,14 @@ export async function showSettings(deviceId) {
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) {
if (isMock || isWs) {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
serialGroup.style.display = 'none';
@@ -245,6 +250,31 @@ export async function showSettings(deviceId) {
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();
@@ -442,6 +472,15 @@ async function _populateSettingsSerialPorts(currentUrl) {
}
}
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();
}

View File

@@ -259,7 +259,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
const opt = document.createElement('option');
opt.value = d.id;
opt.dataset.name = d.name;
const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
const shortUrl = d.url && d.url.startsWith('http') ? d.url.replace(/^https?:\/\//, '') : '';
const devType = (d.device_type || 'wled').toUpperCase();
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
deviceSelect.appendChild(opt);

View File

@@ -131,6 +131,8 @@
"device.mqtt_topic": "MQTT Topic:",
"device.mqtt_topic.hint": "MQTT topic path for publishing pixel data (e.g. mqtt://ledgrab/device/name)",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room",
"device.ws_url": "Connection URL:",
"device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data",
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
"device.name": "Device Name:",
"device.name.placeholder": "Living Room TV",

View File

@@ -131,6 +131,8 @@
"device.mqtt_topic": "MQTT Топик:",
"device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная",
"device.ws_url": "URL подключения:",
"device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных",
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
"device.name": "Имя Устройства:",
"device.name.placeholder": "ТВ в Гостиной",

View File

@@ -131,6 +131,8 @@
"device.mqtt_topic": "MQTT 主题:",
"device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name",
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅",
"device.ws_url": "连接 URL",
"device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL",
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100",
"device.name": "设备名称:",
"device.name.placeholder": "客厅电视",