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 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 13:39:27 +03:00
parent 638dc526f9
commit 242718a9a9
7 changed files with 362 additions and 205 deletions

View File

@@ -4,7 +4,11 @@ import httpx
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired 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 ( from wled_controller.api.dependencies import (
get_device_store, get_device_store,
get_picture_target_store, get_picture_target_store,
@@ -45,6 +49,7 @@ def _device_to_response(device) -> DeviceResponse:
device_type=device.device_type, device_type=device.device_type,
led_count=device.led_count, led_count=device.led_count,
enabled=device.enabled, enabled=device.enabled,
capabilities=sorted(get_device_capabilities(device.device_type)),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at, created_at=device.created_at,
updated_at=device.updated_at, updated_at=device.updated_at,
@@ -66,53 +71,44 @@ async def create_device(
logger.info(f"Creating {device_type} device: {device_data.name}") logger.info(f"Creating {device_type} device: {device_data.name}")
device_url = device_data.url.rstrip("/") device_url = device_data.url.rstrip("/")
wled_led_count = 0
if device_type == "wled": # Validate via provider
# Validate WLED device is reachable before adding
try: try:
async with httpx.AsyncClient(timeout=5) as client: provider = get_provider(device_type)
response = await client.get(f"{device_url}/json/info") except ValueError:
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( raise HTTPException(
status_code=422, status_code=400,
detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}" detail=f"Unsupported device type: {device_type}"
)
logger.info(
f"WLED device reachable: {wled_info.get('name', 'Unknown')} "
f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)"
) )
try:
result = await provider.validate_device(device_url)
led_count = result["led_count"]
except httpx.ConnectError: except httpx.ConnectError:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=f"Cannot reach WLED device at {device_url}. Check the URL and ensure the device is powered on." detail=f"Cannot reach {device_type} device at {device_url}. Check the URL and ensure the device is powered on."
) )
except httpx.TimeoutException: except httpx.TimeoutException:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=f"Connection to {device_url} timed out. Check network connectivity." 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=f"Failed to connect to WLED device at {device_url}: {e}" detail=f"Failed to connect to {device_type} device at {device_url}: {e}"
)
else:
raise HTTPException(
status_code=400,
detail=f"Unsupported device type: {device_type}"
) )
# Create device in storage # Create device in storage
device = store.create_device( device = store.create_device(
name=device_data.name, name=device_data.name,
url=device_data.url, url=device_data.url,
led_count=wled_led_count, led_count=led_count,
device_type=device_type, device_type=device_type,
) )
@@ -151,12 +147,17 @@ async def discover_devices(
store: DeviceStore = Depends(get_device_store), store: DeviceStore = Depends(get_device_store),
timeout: float = 3.0, 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 import time
from wled_controller.core.discovery import discover_wled_devices
start = time.time() 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 elapsed_ms = (time.time() - start) * 1000
existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()} 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") raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
try: try:
async with httpx.AsyncClient(timeout=5.0) as http_client: provider = get_provider(device.device_type)
resp = await http_client.get(f"{device.url}/json/state") bri = await provider.get_brightness(device.url)
resp.raise_for_status()
state = resp.json()
bri = state.get("bri", 255)
return {"brightness": bri} return {"brightness": bri}
except Exception as e: except Exception as e:
logger.error(f"Failed to get WLED brightness for {device_id}: {e}") logger.error(f"Failed to get brightness for {device_id}: {e}")
raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}") raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
@router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) @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") raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
try: try:
async with httpx.AsyncClient(timeout=5.0) as http_client: provider = get_provider(device.device_type)
resp = await http_client.post( await provider.set_brightness(device.url, bri)
f"{device.url}/json/state",
json={"bri": bri},
)
resp.raise_for_status()
return {"brightness": bri} return {"brightness": bri}
except Exception as e: except Exception as e:
logger.error(f"Failed to set WLED brightness for {device_id}: {e}") logger.error(f"Failed to set brightness for {device_id}: {e}")
raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}") raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
# ===== CALIBRATION ENDPOINTS ===== # ===== CALIBRATION ENDPOINTS =====

View File

@@ -85,6 +85,7 @@ class DeviceResponse(BaseModel):
device_type: str = Field(default="wled", description="LED device type") device_type: str = Field(default="wled", description="LED device type")
led_count: int = Field(description="Total number of LEDs") led_count: int = Field(description="Total number of LEDs")
enabled: bool = Field(description="Whether device is enabled") 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") calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")

View File

@@ -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

View File

@@ -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 abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -22,6 +22,19 @@ class DeviceHealth:
error: Optional[str] = None 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): class LEDClient(ABC):
"""Abstract base for LED device communication. """Abstract base for LED device communication.
@@ -128,36 +141,97 @@ class LEDClient(ABC):
await self.close() await self.close()
# Per-device-type capability sets. # ===== LED DEVICE PROVIDER =====
# Used by API routes to gate type-specific features (e.g. brightness control).
DEVICE_TYPE_CAPABILITIES = {
"wled": {"brightness_control"},
}
class LEDDeviceProvider(ABC):
"""Encapsulates everything about a specific LED device type.
def get_device_capabilities(device_type: str) -> set: Implement one subclass per device type (WLED, etc.) and register it
"""Return the capability set for a device type.""" via register_provider(). All type-specific dispatch (client creation,
return DEVICE_TYPE_CAPABILITIES.get(device_type, set()) health checks, discovery, validation, brightness) goes through the provider.
"""
@property
@abstractmethod
def device_type(self) -> str:
"""Type identifier string, e.g. 'wled'."""
...
def create_led_client(device_type: str, url: str, **kwargs) -> LEDClient: @property
"""Factory: create the right LEDClient subclass for a device type. def capabilities(self) -> set:
"""Capability set for this device type. Override to add capabilities."""
return set()
Args: @abstractmethod
device_type: Device type identifier (e.g. "wled") def create_client(self, url: str, **kwargs) -> LEDClient:
url: Device URL """Create a connected-ready LEDClient for this device type."""
**kwargs: Passed to the client constructor ...
@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: Returns:
LEDClient instance dict with at least {'led_count': int}
Raises: Raises:
ValueError: If device_type is unknown Exception on validation failure (caller converts to HTTP error).
""" """
if device_type == "wled": ...
from wled_controller.core.wled_client import WLEDClient
return WLEDClient(url, **kwargs) 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
# ===== PROVIDER REGISTRY =====
_provider_registry: Dict[str, LEDDeviceProvider] = {}
def register_provider(provider: LEDDeviceProvider) -> None:
"""Register a device provider."""
_provider_registry[provider.device_type] = provider
def get_provider(device_type: str) -> LEDDeviceProvider:
"""Get the provider for a device type.
Raises:
ValueError: If device_type is unknown.
"""
provider = _provider_registry.get(device_type)
if not provider:
raise ValueError(f"Unknown LED device type: {device_type}") 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( async def check_device_health(
@@ -166,15 +240,23 @@ async def check_device_health(
http_client, http_client,
prev_health: Optional[DeviceHealth] = None, prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth: ) -> 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 def get_device_capabilities(device_type: str) -> set:
url: Device URL """Return the capability set for a device type."""
http_client: Shared httpx.AsyncClient try:
prev_health: Previous health result return get_provider(device_type).capabilities
""" except ValueError:
if device_type == "wled": return set()
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()) # ===== AUTO-REGISTER BUILT-IN PROVIDERS =====
def _register_builtin_providers():
from wled_controller.core.wled_provider import WLEDDeviceProvider
register_provider(WLEDDeviceProvider())
_register_builtin_providers()

View File

@@ -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()

View File

@@ -873,6 +873,8 @@ async function fetchDeviceBrightness(deviceId) {
} }
// Add device modal // Add device modal
let _discoveryScanRunning = false;
function showAddDevice() { function showAddDevice() {
const modal = document.getElementById('add-device-modal'); const modal = document.getElementById('add-device-modal');
const form = document.getElementById('add-device-form'); const form = document.getElementById('add-device-form');
@@ -892,6 +894,8 @@ function showAddDevice() {
modal.style.display = 'flex'; modal.style.display = 'flex';
lockBody(); lockBody();
setTimeout(() => document.getElementById('device-name').focus(), 100); setTimeout(() => document.getElementById('device-name').focus(), 100);
// Auto-start discovery on open
scanForDevices();
} }
function closeAddDeviceModal() { function closeAddDeviceModal() {
@@ -901,6 +905,9 @@ function closeAddDeviceModal() {
} }
async function scanForDevices() { async function scanForDevices() {
if (_discoveryScanRunning) return;
_discoveryScanRunning = true;
const loading = document.getElementById('discovery-loading'); const loading = document.getElementById('discovery-loading');
const list = document.getElementById('discovery-list'); const list = document.getElementById('discovery-list');
const empty = document.getElementById('discovery-empty'); const empty = document.getElementById('discovery-empty');
@@ -962,6 +969,8 @@ async function scanForDevices() {
empty.style.display = 'block'; empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error'); empty.querySelector('small').textContent = t('device.scan.error');
console.error('Device scan failed:', err); console.error('Device scan failed:', err);
} finally {
_discoveryScanRunning = false;
} }
} }
@@ -3906,6 +3915,15 @@ function closePPTemplateModal() {
// ===== TARGET EDITOR MODAL ===== // ===== TARGET EDITOR MODAL =====
let targetEditorInitialValues = {}; 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) { async function showTargetEditor(targetId = null) {
try { try {
@@ -3917,6 +3935,7 @@ async function showTargetEditor(targetId = null) {
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : []; const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : []; const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : [];
_targetEditorDevices = devices;
// Populate device select // Populate device select
const deviceSelect = document.getElementById('target-editor-device'); const deviceSelect = document.getElementById('target-editor-device');
@@ -3929,6 +3948,7 @@ async function showTargetEditor(targetId = null) {
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`; opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
deviceSelect.appendChild(opt); deviceSelect.appendChild(opt);
}); });
deviceSelect.onchange = _updateStandbyVisibility;
// Populate source select // Populate source select
const sourceSelect = document.getElementById('target-editor-source'); 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'); document.getElementById('target-editor-title').textContent = t('targets.add');
} }
// Show/hide standby interval based on selected device capabilities
_updateStandbyVisibility();
targetEditorInitialValues = { targetEditorInitialValues = {
name: document.getElementById('target-editor-name').value, name: document.getElementById('target-editor-name').value,
device: deviceSelect.value, device: deviceSelect.value,

View File

@@ -336,7 +336,7 @@
<input type="range" id="target-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('target-editor-smoothing-value').textContent = this.value"> <input type="range" id="target-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('target-editor-smoothing-value').textContent = this.value">
</div> </div>
<div class="form-group"> <div class="form-group" id="target-editor-standby-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-standby-interval"> <label for="target-editor-standby-interval">
<span data-i18n="targets.standby_interval">Standby Interval:</span> <span data-i18n="targets.standby_interval">Standby Interval:</span>