From 242718a9a91f726141e74938832c32e0be33e6ed Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 16 Feb 2026 13:39:27 +0300 Subject: [PATCH] Add LEDDeviceProvider abstraction and standby capability flag Consolidate all device-type-specific logic into LEDDeviceProvider ABC with provider registry. WLEDDeviceProvider handles client creation, health checks, validation, mDNS discovery, and brightness control. Routes now delegate to providers instead of using if/else type checks. Add standby_required capability and expose device capabilities in API. Target editor conditionally shows standby interval based on selected device's capabilities. Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/api/routes/devices.py | 110 ++++++------ .../wled_controller/api/schemas/devices.py | 1 + server/src/wled_controller/core/discovery.py | 113 ------------ server/src/wled_controller/core/led_client.py | 148 +++++++++++---- .../src/wled_controller/core/wled_provider.py | 170 ++++++++++++++++++ server/src/wled_controller/static/app.js | 23 +++ server/src/wled_controller/static/index.html | 2 +- 7 files changed, 362 insertions(+), 205 deletions(-) delete mode 100644 server/src/wled_controller/core/discovery.py create mode 100644 server/src/wled_controller/core/wled_provider.py diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 68b57a1..cb658ef 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -4,7 +4,11 @@ import httpx from fastapi import APIRouter, HTTPException, Depends from wled_controller.api.auth import AuthRequired -from wled_controller.core.led_client import get_device_capabilities +from wled_controller.core.led_client import ( + get_all_providers, + get_device_capabilities, + get_provider, +) from wled_controller.api.dependencies import ( get_device_store, get_picture_target_store, @@ -45,6 +49,7 @@ def _device_to_response(device) -> DeviceResponse: device_type=device.device_type, led_count=device.led_count, enabled=device.enabled, + capabilities=sorted(get_device_capabilities(device.device_type)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), created_at=device.created_at, updated_at=device.updated_at, @@ -66,53 +71,44 @@ async def create_device( logger.info(f"Creating {device_type} device: {device_data.name}") device_url = device_data.url.rstrip("/") - wled_led_count = 0 - if device_type == "wled": - # Validate WLED device is reachable before adding - try: - async with httpx.AsyncClient(timeout=5) as client: - response = await client.get(f"{device_url}/json/info") - response.raise_for_status() - wled_info = response.json() - wled_led_count = wled_info.get("leds", {}).get("count") - if not wled_led_count or wled_led_count < 1: - raise HTTPException( - status_code=422, - detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}" - ) - logger.info( - f"WLED device reachable: {wled_info.get('name', 'Unknown')} " - f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)" - ) - except httpx.ConnectError: - raise HTTPException( - status_code=422, - detail=f"Cannot reach WLED device at {device_url}. Check the URL and ensure the device is powered on." - ) - except httpx.TimeoutException: - raise HTTPException( - status_code=422, - detail=f"Connection to {device_url} timed out. Check network connectivity." - ) - except HTTPException: - raise - except Exception as e: - raise HTTPException( - status_code=422, - detail=f"Failed to connect to WLED device at {device_url}: {e}" - ) - else: + # Validate via provider + try: + provider = get_provider(device_type) + except ValueError: raise HTTPException( status_code=400, detail=f"Unsupported device type: {device_type}" ) + try: + result = await provider.validate_device(device_url) + led_count = result["led_count"] + except httpx.ConnectError: + raise HTTPException( + status_code=422, + detail=f"Cannot reach {device_type} device at {device_url}. Check the URL and ensure the device is powered on." + ) + except httpx.TimeoutException: + raise HTTPException( + status_code=422, + detail=f"Connection to {device_url} timed out. Check network connectivity." + ) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=422, + detail=f"Failed to connect to {device_type} device at {device_url}: {e}" + ) + # Create device in storage device = store.create_device( name=device_data.name, url=device_data.url, - led_count=wled_led_count, + led_count=led_count, device_type=device_type, ) @@ -151,12 +147,17 @@ async def discover_devices( store: DeviceStore = Depends(get_device_store), timeout: float = 3.0, ): - """Scan the local network for WLED devices via mDNS.""" + """Scan the local network for LED devices via all registered providers.""" + import asyncio import time - from wled_controller.core.discovery import discover_wled_devices start = time.time() - discovered = await discover_wled_devices(timeout=min(timeout, 10.0)) + capped_timeout = min(timeout, 10.0) + # Discover from all providers in parallel + providers = get_all_providers() + discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()] + all_results = await asyncio.gather(*discover_tasks) + discovered = [d for batch in all_results for d in batch] elapsed_ms = (time.time() - start) * 1000 existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()} @@ -310,15 +311,12 @@ async def get_device_brightness( raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices") try: - async with httpx.AsyncClient(timeout=5.0) as http_client: - resp = await http_client.get(f"{device.url}/json/state") - resp.raise_for_status() - state = resp.json() - bri = state.get("bri", 255) - return {"brightness": bri} + provider = get_provider(device.device_type) + bri = await provider.get_brightness(device.url) + return {"brightness": bri} except Exception as e: - logger.error(f"Failed to get WLED brightness for {device_id}: {e}") - raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}") + logger.error(f"Failed to get brightness for {device_id}: {e}") + raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") @router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) @@ -340,16 +338,12 @@ async def set_device_brightness( raise HTTPException(status_code=400, detail="brightness must be an integer 0-255") try: - async with httpx.AsyncClient(timeout=5.0) as http_client: - resp = await http_client.post( - f"{device.url}/json/state", - json={"bri": bri}, - ) - resp.raise_for_status() - return {"brightness": bri} + provider = get_provider(device.device_type) + await provider.set_brightness(device.url, bri) + return {"brightness": bri} except Exception as e: - logger.error(f"Failed to set WLED brightness for {device_id}: {e}") - raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}") + logger.error(f"Failed to set brightness for {device_id}: {e}") + raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") # ===== CALIBRATION ENDPOINTS ===== diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index 59c55ae..d4ad90f 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -85,6 +85,7 @@ class DeviceResponse(BaseModel): device_type: str = Field(default="wled", description="LED device type") led_count: int = Field(description="Total number of LEDs") enabled: bool = Field(description="Whether device is enabled") + capabilities: List[str] = Field(default_factory=list, description="Device type capabilities") calibration: Optional[Calibration] = Field(None, description="Calibration configuration") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/wled_controller/core/discovery.py b/server/src/wled_controller/core/discovery.py deleted file mode 100644 index df20d4a..0000000 --- a/server/src/wled_controller/core/discovery.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Network discovery for LED devices (mDNS/DNS-SD).""" - -import asyncio -from dataclasses import dataclass -from typing import List, Optional - -import httpx -from zeroconf import ServiceStateChange, Zeroconf -from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf - -from wled_controller.utils import get_logger - -logger = get_logger(__name__) - -WLED_MDNS_TYPE = "_wled._tcp.local." -DEFAULT_SCAN_TIMEOUT = 3.0 - - -@dataclass -class DiscoveredDevice: - """A device found via network discovery.""" - - name: str - url: str - device_type: str - ip: str - mac: str - led_count: Optional[int] - version: Optional[str] - - -async def _enrich_wled_device( - url: str, fallback_name: str -) -> tuple[str, Optional[str], Optional[int], str]: - """Probe a WLED device's /json/info to get name, version, LED count, MAC.""" - try: - async with httpx.AsyncClient(timeout=2) as client: - resp = await client.get(f"{url}/json/info") - resp.raise_for_status() - data = resp.json() - return ( - data.get("name", fallback_name), - data.get("ver"), - data.get("leds", {}).get("count"), - data.get("mac", ""), - ) - except Exception as e: - logger.debug(f"Could not fetch WLED info from {url}: {e}") - return fallback_name, None, None, "" - - -async def discover_wled_devices( - timeout: float = DEFAULT_SCAN_TIMEOUT, -) -> List[DiscoveredDevice]: - """Scan the local network for WLED devices via mDNS.""" - discovered: dict[str, AsyncServiceInfo] = {} - - def on_state_change(**kwargs): - service_type = kwargs.get("service_type", "") - name = kwargs.get("name", "") - state_change = kwargs.get("state_change") - if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated): - discovered[name] = AsyncServiceInfo(service_type, name) - - aiozc = AsyncZeroconf() - browser = AsyncServiceBrowser( - aiozc.zeroconf, WLED_MDNS_TYPE, handlers=[on_state_change] - ) - - await asyncio.sleep(timeout) - - # Resolve all discovered services - for info in discovered.values(): - await info.async_request(aiozc.zeroconf, timeout=2000) - - await browser.async_cancel() - - # Build raw list with IPs, then enrich in parallel - raw: list[tuple[str, str, str]] = [] # (service_name, ip, url) - for name, info in discovered.items(): - addrs = info.parsed_addresses() - if not addrs: - continue - ip = addrs[0] - port = info.port or 80 - url = f"http://{ip}" if port == 80 else f"http://{ip}:{port}" - service_name = name.replace(f".{WLED_MDNS_TYPE}", "") - raw.append((service_name, ip, url)) - - # Enrich all devices in parallel - enrichment = await asyncio.gather( - *[_enrich_wled_device(url, sname) for sname, _, url in raw] - ) - - results: List[DiscoveredDevice] = [] - for (service_name, ip, url), (wled_name, version, led_count, mac) in zip( - raw, enrichment - ): - results.append( - DiscoveredDevice( - name=wled_name, - url=url, - device_type="wled", - ip=ip, - mac=mac, - led_count=led_count, - version=version, - ) - ) - - await aiozc.async_close() - logger.info(f"mDNS scan found {len(results)} WLED device(s)") - return results diff --git a/server/src/wled_controller/core/led_client.py b/server/src/wled_controller/core/led_client.py index 04bd185..61eefd1 100644 --- a/server/src/wled_controller/core/led_client.py +++ b/server/src/wled_controller/core/led_client.py @@ -1,4 +1,4 @@ -"""Abstract base class for LED device communication clients.""" +"""Abstract base class for LED device communication clients and provider registry.""" from abc import ABC, abstractmethod from dataclasses import dataclass, field @@ -22,6 +22,19 @@ class DeviceHealth: error: Optional[str] = None +@dataclass +class DiscoveredDevice: + """A device found via network discovery.""" + + name: str + url: str + device_type: str + ip: str + mac: str + led_count: Optional[int] + version: Optional[str] + + class LEDClient(ABC): """Abstract base for LED device communication. @@ -128,36 +141,97 @@ class LEDClient(ABC): await self.close() -# Per-device-type capability sets. -# Used by API routes to gate type-specific features (e.g. brightness control). -DEVICE_TYPE_CAPABILITIES = { - "wled": {"brightness_control"}, -} +# ===== LED DEVICE PROVIDER ===== + +class LEDDeviceProvider(ABC): + """Encapsulates everything about a specific LED device type. + + Implement one subclass per device type (WLED, etc.) and register it + via register_provider(). All type-specific dispatch (client creation, + health checks, discovery, validation, brightness) goes through the provider. + """ + + @property + @abstractmethod + def device_type(self) -> str: + """Type identifier string, e.g. 'wled'.""" + ... + + @property + def capabilities(self) -> set: + """Capability set for this device type. Override to add capabilities.""" + return set() + + @abstractmethod + def create_client(self, url: str, **kwargs) -> LEDClient: + """Create a connected-ready LEDClient for this device type.""" + ... + + @abstractmethod + async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: + """Check device health without a full client connection.""" + ... + + @abstractmethod + async def validate_device(self, url: str) -> dict: + """Validate a device URL before adding it. + + Returns: + dict with at least {'led_count': int} + + Raises: + Exception on validation failure (caller converts to HTTP error). + """ + ... + + async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]: + """Discover devices on the network. + + Override in providers that support discovery. Default: empty list. + """ + return [] + + async def get_brightness(self, url: str) -> int: + """Get device brightness (0-255). Override if capabilities include brightness_control.""" + raise NotImplementedError + + async def set_brightness(self, url: str, brightness: int) -> None: + """Set device brightness (0-255). Override if capabilities include brightness_control.""" + raise NotImplementedError -def get_device_capabilities(device_type: str) -> set: - """Return the capability set for a device type.""" - return DEVICE_TYPE_CAPABILITIES.get(device_type, set()) +# ===== PROVIDER REGISTRY ===== + +_provider_registry: Dict[str, LEDDeviceProvider] = {} -def create_led_client(device_type: str, url: str, **kwargs) -> LEDClient: - """Factory: create the right LEDClient subclass for a device type. +def register_provider(provider: LEDDeviceProvider) -> None: + """Register a device provider.""" + _provider_registry[provider.device_type] = provider - Args: - device_type: Device type identifier (e.g. "wled") - url: Device URL - **kwargs: Passed to the client constructor - Returns: - LEDClient instance +def get_provider(device_type: str) -> LEDDeviceProvider: + """Get the provider for a device type. Raises: - ValueError: If device_type is unknown + ValueError: If device_type is unknown. """ - if device_type == "wled": - from wled_controller.core.wled_client import WLEDClient - return WLEDClient(url, **kwargs) - raise ValueError(f"Unknown LED device type: {device_type}") + provider = _provider_registry.get(device_type) + if not provider: + raise ValueError(f"Unknown LED device type: {device_type}") + return provider + + +def get_all_providers() -> Dict[str, LEDDeviceProvider]: + """Return all registered providers.""" + return dict(_provider_registry) + + +# ===== FACTORY FUNCTIONS (delegate to providers) ===== + +def create_led_client(device_type: str, url: str, **kwargs) -> LEDClient: + """Factory: create the right LEDClient subclass for a device type.""" + return get_provider(device_type).create_client(url, **kwargs) async def check_device_health( @@ -166,15 +240,23 @@ async def check_device_health( http_client, prev_health: Optional[DeviceHealth] = None, ) -> DeviceHealth: - """Factory: dispatch health check to the right LEDClient subclass. + """Factory: dispatch health check to the right provider.""" + return await get_provider(device_type).check_health(url, http_client, prev_health) - Args: - device_type: Device type identifier - url: Device URL - http_client: Shared httpx.AsyncClient - prev_health: Previous health result - """ - if device_type == "wled": - from wled_controller.core.wled_client import WLEDClient - return await WLEDClient.check_health(url, http_client, prev_health) - return DeviceHealth(online=True, last_checked=datetime.utcnow()) + +def get_device_capabilities(device_type: str) -> set: + """Return the capability set for a device type.""" + try: + return get_provider(device_type).capabilities + except ValueError: + return set() + + +# ===== AUTO-REGISTER BUILT-IN PROVIDERS ===== + +def _register_builtin_providers(): + from wled_controller.core.wled_provider import WLEDDeviceProvider + register_provider(WLEDDeviceProvider()) + + +_register_builtin_providers() diff --git a/server/src/wled_controller/core/wled_provider.py b/server/src/wled_controller/core/wled_provider.py new file mode 100644 index 0000000..c42d772 --- /dev/null +++ b/server/src/wled_controller/core/wled_provider.py @@ -0,0 +1,170 @@ +"""WLED device provider — consolidates all WLED-specific dispatch logic.""" + +import asyncio +from typing import List, Optional + +import httpx +from zeroconf import ServiceStateChange +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf + +from wled_controller.core.led_client import ( + DeviceHealth, + DiscoveredDevice, + LEDClient, + LEDDeviceProvider, +) +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +WLED_MDNS_TYPE = "_wled._tcp.local." +DEFAULT_SCAN_TIMEOUT = 3.0 + + +class WLEDDeviceProvider(LEDDeviceProvider): + """Provider for WLED LED controllers.""" + + @property + def device_type(self) -> str: + return "wled" + + @property + def capabilities(self) -> set: + return {"brightness_control", "standby_required"} + + def create_client(self, url: str, **kwargs) -> LEDClient: + from wled_controller.core.wled_client import WLEDClient + return WLEDClient(url, **kwargs) + + async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: + from wled_controller.core.wled_client import WLEDClient + return await WLEDClient.check_health(url, http_client, prev_health) + + async def validate_device(self, url: str) -> dict: + """Validate a WLED device URL by probing /json/info. + + Returns: + dict with 'led_count' key. + + Raises: + httpx.ConnectError: Device unreachable. + httpx.TimeoutException: Connection timed out. + ValueError: Invalid LED count. + """ + url = url.rstrip("/") + async with httpx.AsyncClient(timeout=5) as client: + response = await client.get(f"{url}/json/info") + response.raise_for_status() + wled_info = response.json() + led_count = wled_info.get("leds", {}).get("count") + if not led_count or led_count < 1: + raise ValueError( + f"WLED device at {url} reported invalid LED count: {led_count}" + ) + logger.info( + f"WLED device reachable: {wled_info.get('name', 'Unknown')} " + f"v{wled_info.get('ver', '?')} ({led_count} LEDs)" + ) + return {"led_count": led_count} + + # ===== DISCOVERY ===== + + async def discover(self, timeout: float = DEFAULT_SCAN_TIMEOUT) -> List[DiscoveredDevice]: + """Scan the local network for WLED devices via mDNS.""" + discovered: dict[str, AsyncServiceInfo] = {} + + def on_state_change(**kwargs): + service_type = kwargs.get("service_type", "") + name = kwargs.get("name", "") + state_change = kwargs.get("state_change") + if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated): + discovered[name] = AsyncServiceInfo(service_type, name) + + aiozc = AsyncZeroconf() + browser = AsyncServiceBrowser( + aiozc.zeroconf, WLED_MDNS_TYPE, handlers=[on_state_change] + ) + + await asyncio.sleep(timeout) + + # Resolve all discovered services + for info in discovered.values(): + await info.async_request(aiozc.zeroconf, timeout=2000) + + await browser.async_cancel() + + # Build raw list with IPs, then enrich in parallel + raw: list[tuple[str, str, str]] = [] # (service_name, ip, url) + for name, info in discovered.items(): + addrs = info.parsed_addresses() + if not addrs: + continue + ip = addrs[0] + port = info.port or 80 + url = f"http://{ip}" if port == 80 else f"http://{ip}:{port}" + service_name = name.replace(f".{WLED_MDNS_TYPE}", "") + raw.append((service_name, ip, url)) + + # Enrich all devices in parallel + enrichment = await asyncio.gather( + *[self._enrich_device(url, sname) for sname, _, url in raw] + ) + + results: List[DiscoveredDevice] = [] + for (service_name, ip, url), (wled_name, version, led_count, mac) in zip( + raw, enrichment + ): + results.append( + DiscoveredDevice( + name=wled_name, + url=url, + device_type="wled", + ip=ip, + mac=mac, + led_count=led_count, + version=version, + ) + ) + + await aiozc.async_close() + logger.info(f"mDNS scan found {len(results)} WLED device(s)") + return results + + @staticmethod + async def _enrich_device( + url: str, fallback_name: str + ) -> tuple[str, Optional[str], Optional[int], str]: + """Probe a WLED device's /json/info to get name, version, LED count, MAC.""" + try: + async with httpx.AsyncClient(timeout=2) as client: + resp = await client.get(f"{url}/json/info") + resp.raise_for_status() + data = resp.json() + return ( + data.get("name", fallback_name), + data.get("ver"), + data.get("leds", {}).get("count"), + data.get("mac", ""), + ) + except Exception as e: + logger.debug(f"Could not fetch WLED info from {url}: {e}") + return fallback_name, None, None, "" + + # ===== BRIGHTNESS ===== + + async def get_brightness(self, url: str) -> int: + url = url.rstrip("/") + async with httpx.AsyncClient(timeout=5.0) as http_client: + resp = await http_client.get(f"{url}/json/state") + resp.raise_for_status() + state = resp.json() + return state.get("bri", 255) + + async def set_brightness(self, url: str, brightness: int) -> None: + url = url.rstrip("/") + async with httpx.AsyncClient(timeout=5.0) as http_client: + resp = await http_client.post( + f"{url}/json/state", + json={"bri": brightness}, + ) + resp.raise_for_status() diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 061dd2a..912a632 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -873,6 +873,8 @@ async function fetchDeviceBrightness(deviceId) { } // Add device modal +let _discoveryScanRunning = false; + function showAddDevice() { const modal = document.getElementById('add-device-modal'); const form = document.getElementById('add-device-form'); @@ -892,6 +894,8 @@ function showAddDevice() { modal.style.display = 'flex'; lockBody(); setTimeout(() => document.getElementById('device-name').focus(), 100); + // Auto-start discovery on open + scanForDevices(); } function closeAddDeviceModal() { @@ -901,6 +905,9 @@ function closeAddDeviceModal() { } async function scanForDevices() { + if (_discoveryScanRunning) return; + _discoveryScanRunning = true; + const loading = document.getElementById('discovery-loading'); const list = document.getElementById('discovery-list'); const empty = document.getElementById('discovery-empty'); @@ -962,6 +969,8 @@ async function scanForDevices() { empty.style.display = 'block'; empty.querySelector('small').textContent = t('device.scan.error'); console.error('Device scan failed:', err); + } finally { + _discoveryScanRunning = false; } } @@ -3906,6 +3915,15 @@ function closePPTemplateModal() { // ===== TARGET EDITOR MODAL ===== let targetEditorInitialValues = {}; +let _targetEditorDevices = []; // cached devices list for capability checks + +function _updateStandbyVisibility() { + const deviceSelect = document.getElementById('target-editor-device'); + const standbyGroup = document.getElementById('target-editor-standby-group'); + const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value); + const caps = selectedDevice?.capabilities || []; + standbyGroup.style.display = caps.includes('standby_required') ? '' : 'none'; +} async function showTargetEditor(targetId = null) { try { @@ -3917,6 +3935,7 @@ async function showTargetEditor(targetId = null) { const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : []; const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : []; + _targetEditorDevices = devices; // Populate device select const deviceSelect = document.getElementById('target-editor-device'); @@ -3929,6 +3948,7 @@ async function showTargetEditor(targetId = null) { opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`; deviceSelect.appendChild(opt); }); + deviceSelect.onchange = _updateStandbyVisibility; // Populate source select const sourceSelect = document.getElementById('target-editor-source'); @@ -3975,6 +3995,9 @@ async function showTargetEditor(targetId = null) { document.getElementById('target-editor-title').textContent = t('targets.add'); } + // Show/hide standby interval based on selected device capabilities + _updateStandbyVisibility(); + targetEditorInitialValues = { name: document.getElementById('target-editor-name').value, device: deviceSelect.value, diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 952a04e..153811d 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -336,7 +336,7 @@ -
+