From fa81d6a6088c1f176cb84e5a86e3c71235c57fb6 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 28 Feb 2026 20:55:09 +0300 Subject: [PATCH] Add WebSocket device type, capability-driven settings, hide filter on collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/wled_controller/api/routes/devices.py | 63 ++++++++- .../core/devices/led_client.py | 3 + .../core/devices/serial_provider.py | 4 +- .../core/devices/wled_provider.py | 2 +- .../wled_controller/core/devices/ws_client.py | 130 ++++++++++++++++++ .../core/devices/ws_provider.py | 44 ++++++ .../core/processing/processor_manager.py | 6 + .../wled_controller/static/css/components.css | 9 ++ .../wled_controller/static/css/streams.css | 4 + server/src/wled_controller/static/js/app.js | 5 +- .../src/wled_controller/static/js/core/api.js | 4 + .../static/js/core/card-sections.js | 12 +- .../static/js/features/color-strips.js | 18 ++- .../static/js/features/device-discovery.js | 17 ++- .../static/js/features/devices.js | 45 +++++- .../static/js/features/targets.js | 2 +- .../wled_controller/static/locales/en.json | 2 + .../wled_controller/static/locales/ru.json | 2 + .../wled_controller/static/locales/zh.json | 2 + .../templates/modals/add-device.html | 1 + .../templates/modals/device-settings.html | 16 ++- 21 files changed, 375 insertions(+), 16 deletions(-) create mode 100644 server/src/wled_controller/core/devices/ws_client.py create mode 100644 server/src/wled_controller/core/devices/ws_provider.py diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index aac6b57..67196b0 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -1,7 +1,9 @@ -"""Device routes: CRUD, health state, brightness, power, calibration.""" +"""Device routes: CRUD, health state, brightness, power, calibration, WS stream.""" + +import secrets import httpx -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from wled_controller.api.auth import AuthRequired from wled_controller.core.devices.led_client import ( @@ -122,6 +124,11 @@ async def create_device( rgbw=device_data.rgbw or False, ) + # WS devices: auto-set URL to ws://{device_id} + if device_type == "ws": + store.update_device(device_id=device.id, url=f"ws://{device.id}") + device = store.get_device(device.id) + # Register in processor manager for health monitoring manager.add_device( device_id=device.id, @@ -486,3 +493,55 @@ async def set_device_power( raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") +# ===== WEBSOCKET DEVICE STREAM ===== + +@router.websocket("/api/v1/devices/{device_id}/ws") +async def device_ws_stream( + websocket: WebSocket, + device_id: str, + token: str = Query(""), +): + """WebSocket stream of LED pixel data for WS device type. + + Wire format: [brightness_byte][R G B R G B ...] + Auth via ?token=. + """ + from wled_controller.config import get_config + + authenticated = False + cfg = get_config() + if token and cfg.auth.api_keys: + for _label, api_key in cfg.auth.api_keys.items(): + if secrets.compare_digest(token, api_key): + authenticated = True + break + + if not authenticated: + await websocket.close(code=4001, reason="Unauthorized") + return + + store = get_device_store() + device = store.get_device(device_id) + if not device: + await websocket.close(code=4004, reason="Device not found") + return + if device.device_type != "ws": + await websocket.close(code=4003, reason="Device is not a WebSocket device") + return + + await websocket.accept() + + from wled_controller.core.devices.ws_client import get_ws_broadcaster + + broadcaster = get_ws_broadcaster() + broadcaster.add_client(device_id, websocket) + + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + pass + finally: + broadcaster.remove_client(device_id, websocket) + + diff --git a/server/src/wled_controller/core/devices/led_client.py b/server/src/wled_controller/core/devices/led_client.py index c540330..9de35bd 100644 --- a/server/src/wled_controller/core/devices/led_client.py +++ b/server/src/wled_controller/core/devices/led_client.py @@ -285,5 +285,8 @@ def _register_builtin_providers(): from wled_controller.core.devices.mqtt_provider import MQTTDeviceProvider register_provider(MQTTDeviceProvider()) + from wled_controller.core.devices.ws_provider import WSDeviceProvider + register_provider(WSDeviceProvider()) + _register_builtin_providers() diff --git a/server/src/wled_controller/core/devices/serial_provider.py b/server/src/wled_controller/core/devices/serial_provider.py index fd32930..c5df236 100644 --- a/server/src/wled_controller/core/devices/serial_provider.py +++ b/server/src/wled_controller/core/devices/serial_provider.py @@ -28,7 +28,9 @@ class SerialDeviceProvider(LEDDeviceProvider): # manual_led_count: user must specify LED count (can't auto-detect) # power_control: can blank LEDs by sending all-black pixels # brightness_control: software brightness (multiplies pixel values before sending) - return {"manual_led_count", "power_control", "brightness_control"} + # health_check: serial port availability probe + # auto_restore: blank LEDs when targets stop + return {"manual_led_count", "power_control", "brightness_control", "health_check", "auto_restore"} async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: # Generic serial port health check — enumerate COM ports diff --git a/server/src/wled_controller/core/devices/wled_provider.py b/server/src/wled_controller/core/devices/wled_provider.py index 98dce1b..c788236 100644 --- a/server/src/wled_controller/core/devices/wled_provider.py +++ b/server/src/wled_controller/core/devices/wled_provider.py @@ -49,7 +49,7 @@ class WLEDDeviceProvider(LEDDeviceProvider): @property def capabilities(self) -> set: - return {"brightness_control", "power_control", "standby_required", "static_color"} + return {"brightness_control", "power_control", "standby_required", "static_color", "health_check", "auto_restore"} def create_client(self, url: str, **kwargs) -> LEDClient: from wled_controller.core.devices.wled_client import WLEDClient diff --git a/server/src/wled_controller/core/devices/ws_client.py b/server/src/wled_controller/core/devices/ws_client.py new file mode 100644 index 0000000..2ee46e8 --- /dev/null +++ b/server/src/wled_controller/core/devices/ws_client.py @@ -0,0 +1,130 @@ +"""WebSocket LED client — broadcasts pixel data to connected WebSocket clients.""" + +import asyncio +from datetime import datetime +from typing import Dict, List, Optional, Tuple, Union + +import numpy as np + +from wled_controller.core.devices.led_client import DeviceHealth, LEDClient +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class WSDeviceBroadcaster: + """Global registry of WebSocket clients subscribed to WS device streams. + + Each WS device (identified by device_id) can have zero or more connected + WebSocket clients. The WSLEDClient.send_pixels() method uses this to + broadcast frames. + """ + + def __init__(self): + self._clients: Dict[str, List] = {} + + def add_client(self, device_id: str, ws) -> None: + self._clients.setdefault(device_id, []).append(ws) + logger.info( + "WS device %s: client connected (%d total)", + device_id, len(self._clients[device_id]), + ) + + def remove_client(self, device_id: str, ws) -> None: + clients = self._clients.get(device_id) + if clients and ws in clients: + clients.remove(ws) + logger.info( + "WS device %s: client disconnected (%d remaining)", + device_id, len(clients), + ) + + def get_clients(self, device_id: str) -> List: + return self._clients.get(device_id, []) + + +_broadcaster = WSDeviceBroadcaster() + + +def get_ws_broadcaster() -> WSDeviceBroadcaster: + return _broadcaster + + +def parse_ws_url(url: str) -> str: + """Extract device_id from a ws:// URL. + + Format: ws://device-id + """ + if url.startswith("ws://"): + return url[5:] + return url + + +class WSLEDClient(LEDClient): + """Broadcasts binary pixel data to WebSocket clients via the global broadcaster.""" + + def __init__(self, url: str, led_count: int = 0, **kwargs): + self._device_id = parse_ws_url(url) + self._led_count = led_count + self._connected = False + + async def connect(self) -> bool: + self._connected = True + logger.info("WS device client connected for device %s", self._device_id) + return True + + async def close(self) -> None: + self._connected = False + logger.info("WS device client closed for device %s", self._device_id) + + @property + def is_connected(self) -> bool: + return self._connected + + async def send_pixels( + self, + pixels: Union[List[Tuple[int, int, int]], np.ndarray], + brightness: int = 255, + ) -> bool: + if not self._connected: + return False + + clients = _broadcaster.get_clients(self._device_id) + if not clients: + return True + + # Build binary frame: [brightness_byte][R G B R G B ...] + if isinstance(pixels, np.ndarray): + pixel_bytes = pixels.astype(np.uint8).tobytes() + else: + pixel_bytes = bytes(c for rgb in pixels for c in rgb) + + data = bytes([brightness]) + pixel_bytes + + async def _send_safe(ws): + try: + await ws.send_bytes(data) + return True + except Exception: + return False + + results = await asyncio.gather(*[_send_safe(ws) for ws in clients]) + + disconnected = [ws for ws, ok in zip(clients, results) if not ok] + for ws in disconnected: + _broadcaster.remove_client(self._device_id, ws) + + return True + + @classmethod + async def check_health( + cls, + url: str, + http_client, + prev_health: Optional[DeviceHealth] = None, + ) -> DeviceHealth: + return DeviceHealth( + online=True, + latency_ms=0.0, + last_checked=datetime.utcnow(), + ) diff --git a/server/src/wled_controller/core/devices/ws_provider.py b/server/src/wled_controller/core/devices/ws_provider.py new file mode 100644 index 0000000..04f0809 --- /dev/null +++ b/server/src/wled_controller/core/devices/ws_provider.py @@ -0,0 +1,44 @@ +"""WebSocket device provider — factory, validation, health checks.""" + +from datetime import datetime +from typing import List + +from wled_controller.core.devices.led_client import ( + DeviceHealth, + DiscoveredDevice, + LEDClient, + LEDDeviceProvider, +) +from wled_controller.core.devices.ws_client import WSLEDClient, parse_ws_url +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class WSDeviceProvider(LEDDeviceProvider): + """Provider for WebSocket-based virtual LED devices.""" + + @property + def device_type(self) -> str: + return "ws" + + @property + def capabilities(self) -> set: + return {"manual_led_count"} + + def create_client(self, url: str, **kwargs) -> LEDClient: + return WSLEDClient(url, **kwargs) + + async def check_health( + self, url: str, http_client, prev_health=None, + ) -> DeviceHealth: + return DeviceHealth( + online=True, latency_ms=0.0, last_checked=datetime.utcnow(), + ) + + async def validate_device(self, url: str) -> dict: + """Validate WS device URL — accepts any URL since it will be auto-set.""" + return {} + + async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]: + return [] diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 30bcb86..d9dd2c9 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -11,6 +11,7 @@ from wled_controller.core.devices.led_client import ( DeviceHealth, check_device_health, create_led_client, + get_device_capabilities, get_provider, ) from wled_controller.core.audio.audio_capture import AudioCaptureManager @@ -810,6 +811,11 @@ class ProcessorManager: state = self._devices.get(device_id) if not state: return + # Skip periodic health checks for virtual devices (always online) + if "health_check" not in get_device_capabilities(state.device_type): + from datetime import datetime + state.health = DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.utcnow()) + return if state.health_task and not state.health_task.done(): return state.health_task = asyncio.create_task(self._health_check_loop(device_id)) diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 2d0f35b..29f57c2 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -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; diff --git a/server/src/wled_controller/static/css/streams.css b/server/src/wled_controller/static/css/streams.css index 0d77149..4a28b27 100644 --- a/server/src/wled_controller/static/css/streams.css +++ b/server/src/wled_controller/static/css/streams.css @@ -682,6 +682,10 @@ flex-shrink: 0; } +.cs-collapsed .cs-filter-wrap { + display: none; +} + .cs-filter-wrap { position: relative; margin-left: auto; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index ed42332..b101b82 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -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, diff --git a/server/src/wled_controller/static/js/core/api.js b/server/src/wled_controller/static/js/core/api.js index 9aad233..53c17ba 100644 --- a/server/src/wled_controller/static/js/core/api.js +++ b/server/src/wled_controller/static/js/core/api.js @@ -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'); diff --git a/server/src/wled_controller/static/js/core/card-sections.js b/server/src/wled_controller/static/js/core/card-sections.js index bb639f8..aad1519 100644 --- a/server/src/wled_controller/static/js/core/card-sections.js +++ b/server/src/wled_controller/static/js/core/card-sections.js @@ -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 ? `
+
` : ''; return ` -
+
${t(this.titleKey)} @@ -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)'; diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 5de767f..60967bb 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -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 = ` -
REST POST:
${base}/color-strip-sources/${cssId}/colors
-
WebSocket:
${wsBase}/color-strip-sources/${cssId}/ws?token=<api_key>
+ REST POST +
+ WebSocket +
`; } +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) { diff --git a/server/src/wled_controller/static/js/features/device-discovery.js b/server/src/wled_controller/static/js/features/device-discovery.js index f76e54d..03ec2ce 100644 --- a/server/src/wled_controller/static/js/features/device-discovery.js +++ b/server/src/wled_controller/static/js/features/device-discovery.js @@ -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; diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index eca93ac..2948159 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -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) {
${device.name || device.id} - ${device.url && device.url.startsWith('http') ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}${ICON_WEB}` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('http') ? `${escapeHtml(device.url)}` : '')} + ${device.url && device.url.startsWith('http') ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}${ICON_WEB}` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('http') ? `${escapeHtml(device.url)}` : '')} ${healthLabel}
@@ -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(); } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index ea402f9..a923d8d 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -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); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 1cbf4cf..4b5bba5 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 311c507..426cce1 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "ТВ в Гостиной", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 27565d5..273998e 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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": "客厅电视", diff --git a/server/src/wled_controller/templates/modals/add-device.html b/server/src/wled_controller/templates/modals/add-device.html index 2a6b426..9ddc731 100644 --- a/server/src/wled_controller/templates/modals/add-device.html +++ b/server/src/wled_controller/templates/modals/add-device.html @@ -31,6 +31,7 @@ +
diff --git a/server/src/wled_controller/templates/modals/device-settings.html b/server/src/wled_controller/templates/modals/device-settings.html index d2dd021..8aeec80 100644 --- a/server/src/wled_controller/templates/modals/device-settings.html +++ b/server/src/wled_controller/templates/modals/device-settings.html @@ -78,7 +78,7 @@
-
+
@@ -87,7 +87,7 @@
-
+
@@ -99,6 +99,18 @@
+ +