Add Adalight serial LED device support with per-type discovery and capability-based UI
Implements the second device provider (Adalight) for Arduino-based serial LED controllers, validating the LEDDeviceProvider abstraction. Adds serial port auto-discovery, per-type discovery caching with lazy-load, capability-driven UI (brightness control, manual LED count, standby), and serial port combobox in both Add Device and General Settings modals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ dependencies = [
|
|||||||
"python-multipart>=0.0.12",
|
"python-multipart>=0.0.12",
|
||||||
"wmi>=1.5.1; sys_platform == 'win32'",
|
"wmi>=1.5.1; sys_platform == 'win32'",
|
||||||
"zeroconf>=0.131.0",
|
"zeroconf>=0.131.0",
|
||||||
|
"pyserial>=3.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ async def create_device(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = await provider.validate_device(device_url)
|
result = await provider.validate_device(device_url)
|
||||||
led_count = result["led_count"]
|
led_count = result.get("led_count") or device_data.led_count
|
||||||
|
if not led_count or led_count < 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="LED count is required for this device type.",
|
||||||
|
)
|
||||||
except httpx.ConnectError:
|
except httpx.ConnectError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
@@ -146,18 +151,28 @@ async def discover_devices(
|
|||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
timeout: float = 3.0,
|
timeout: float = 3.0,
|
||||||
|
device_type: str | None = None,
|
||||||
):
|
):
|
||||||
"""Scan the local network for LED devices via all registered providers."""
|
"""Scan for LED devices. Optionally filter by device_type (e.g. wled, adalight)."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
capped_timeout = min(timeout, 10.0)
|
capped_timeout = min(timeout, 10.0)
|
||||||
# Discover from all providers in parallel
|
|
||||||
providers = get_all_providers()
|
if device_type:
|
||||||
discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()]
|
# Discover from a single provider
|
||||||
all_results = await asyncio.gather(*discover_tasks)
|
try:
|
||||||
discovered = [d for batch in all_results for d in batch]
|
provider = get_provider(device_type)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown device type: {device_type}")
|
||||||
|
discovered = await provider.discover(timeout=capped_timeout)
|
||||||
|
else:
|
||||||
|
# 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()}
|
||||||
@@ -213,6 +228,7 @@ async def update_device(
|
|||||||
name=update_data.name,
|
name=update_data.name,
|
||||||
url=update_data.url,
|
url=update_data.url,
|
||||||
enabled=update_data.enabled,
|
enabled=update_data.enabled,
|
||||||
|
led_count=update_data.led_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync connection info in processor manager
|
# Sync connection info in processor manager
|
||||||
@@ -220,7 +236,7 @@ async def update_device(
|
|||||||
manager.update_device_info(
|
manager.update_device_info(
|
||||||
device_id,
|
device_id,
|
||||||
device_url=update_data.url,
|
device_url=update_data.url,
|
||||||
led_count=None,
|
led_count=update_data.led_count,
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -10,16 +10,18 @@ class DeviceCreate(BaseModel):
|
|||||||
"""Request to create/attach an LED device."""
|
"""Request to create/attach an LED device."""
|
||||||
|
|
||||||
name: str = Field(description="Device name", min_length=1, max_length=100)
|
name: str = Field(description="Device name", min_length=1, max_length=100)
|
||||||
url: str = Field(description="Device URL (e.g., http://192.168.1.100)")
|
url: str = Field(description="Device URL (e.g., http://192.168.1.100 or COM3)")
|
||||||
device_type: str = Field(default="wled", description="LED device type (e.g., wled)")
|
device_type: str = Field(default="wled", description="LED device type (e.g., wled, adalight)")
|
||||||
|
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (required for adalight)")
|
||||||
|
|
||||||
|
|
||||||
class DeviceUpdate(BaseModel):
|
class DeviceUpdate(BaseModel):
|
||||||
"""Request to update device information."""
|
"""Request to update device information."""
|
||||||
|
|
||||||
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
|
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
|
||||||
url: Optional[str] = Field(None, description="WLED device URL")
|
url: Optional[str] = Field(None, description="Device URL or serial port")
|
||||||
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
|
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
|
||||||
|
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (for devices with manual_led_count capability)")
|
||||||
|
|
||||||
|
|
||||||
class Calibration(BaseModel):
|
class Calibration(BaseModel):
|
||||||
|
|||||||
204
server/src/wled_controller/core/adalight_client.py
Normal file
204
server/src/wled_controller/core/adalight_client.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""Adalight serial LED client — sends pixel data over serial using the Adalight protocol."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.led_client import DeviceHealth, LEDClient
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_BAUD_RATE = 115200
|
||||||
|
ARDUINO_RESET_DELAY = 2.0 # seconds to wait after opening serial for Arduino bootloader
|
||||||
|
|
||||||
|
|
||||||
|
def parse_adalight_url(url: str) -> Tuple[str, int]:
|
||||||
|
"""Parse an Adalight URL into (port, baud_rate).
|
||||||
|
|
||||||
|
Formats:
|
||||||
|
"COM3" -> ("COM3", 115200)
|
||||||
|
"COM3:230400" -> ("COM3", 230400)
|
||||||
|
"/dev/ttyUSB0" -> ("/dev/ttyUSB0", 115200)
|
||||||
|
"""
|
||||||
|
url = url.strip()
|
||||||
|
if ":" in url and not url.startswith("/"):
|
||||||
|
# Windows COM port with baud: "COM3:230400"
|
||||||
|
parts = url.rsplit(":", 1)
|
||||||
|
try:
|
||||||
|
baud = int(parts[1])
|
||||||
|
return parts[0], baud
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif ":" in url and url.startswith("/"):
|
||||||
|
# Unix path with baud: "/dev/ttyUSB0:230400"
|
||||||
|
parts = url.rsplit(":", 1)
|
||||||
|
try:
|
||||||
|
baud = int(parts[1])
|
||||||
|
return parts[0], baud
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return url, DEFAULT_BAUD_RATE
|
||||||
|
|
||||||
|
|
||||||
|
def _build_adalight_header(led_count: int) -> bytes:
|
||||||
|
"""Build the 6-byte Adalight protocol header.
|
||||||
|
|
||||||
|
Format: 'A' 'd' 'a' <count_hi> <count_lo> <checksum>
|
||||||
|
where count = led_count - 1 (zero-indexed).
|
||||||
|
"""
|
||||||
|
count = led_count - 1
|
||||||
|
hi = (count >> 8) & 0xFF
|
||||||
|
lo = count & 0xFF
|
||||||
|
checksum = hi ^ lo ^ 0x55
|
||||||
|
return bytes([ord("A"), ord("d"), ord("a"), hi, lo, checksum])
|
||||||
|
|
||||||
|
|
||||||
|
class AdalightClient(LEDClient):
|
||||||
|
"""LED client for Arduino Adalight serial devices."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, led_count: int = 0, **kwargs):
|
||||||
|
"""Initialize Adalight client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Serial port string, e.g. "COM3" or "COM3:230400"
|
||||||
|
led_count: Number of LEDs on the strip (required for Adalight header)
|
||||||
|
"""
|
||||||
|
self._port, self._baud_rate = parse_adalight_url(url)
|
||||||
|
self._led_count = led_count
|
||||||
|
self._serial = None
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
# Pre-compute Adalight header if led_count is known
|
||||||
|
self._header = _build_adalight_header(led_count) if led_count > 0 else b""
|
||||||
|
|
||||||
|
# Pre-allocate numpy buffer for brightness scaling
|
||||||
|
self._pixel_buf = None
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
"""Open serial port and wait for Arduino reset."""
|
||||||
|
import serial
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._serial = serial.Serial(
|
||||||
|
port=self._port,
|
||||||
|
baudrate=self._baud_rate,
|
||||||
|
timeout=1,
|
||||||
|
)
|
||||||
|
# Wait for Arduino to finish bootloader reset
|
||||||
|
time.sleep(ARDUINO_RESET_DELAY)
|
||||||
|
self._connected = True
|
||||||
|
logger.info(
|
||||||
|
f"Adalight connected: {self._port} @ {self._baud_rate} baud "
|
||||||
|
f"({self._led_count} LEDs)"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to open serial port {self._port}: {e}")
|
||||||
|
raise RuntimeError(f"Failed to open serial port {self._port}: {e}")
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the serial port."""
|
||||||
|
self._connected = False
|
||||||
|
if self._serial and self._serial.is_open:
|
||||||
|
try:
|
||||||
|
self._serial.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing serial port: {e}")
|
||||||
|
self._serial = None
|
||||||
|
logger.info(f"Adalight disconnected: {self._port}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._connected and self._serial is not None and self._serial.is_open
|
||||||
|
|
||||||
|
async def send_pixels(
|
||||||
|
self,
|
||||||
|
pixels: List[Tuple[int, int, int]],
|
||||||
|
brightness: int = 255,
|
||||||
|
) -> bool:
|
||||||
|
"""Send pixel data over serial using Adalight protocol."""
|
||||||
|
if not self.is_connected:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = self._build_frame(pixels, brightness)
|
||||||
|
self._serial.write(frame)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Adalight send_pixels error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_fast_send(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def send_pixels_fast(
|
||||||
|
self,
|
||||||
|
pixels: List[Tuple[int, int, int]],
|
||||||
|
brightness: int = 255,
|
||||||
|
) -> None:
|
||||||
|
"""Synchronous fire-and-forget serial send."""
|
||||||
|
if not self.is_connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = self._build_frame(pixels, brightness)
|
||||||
|
self._serial.write(frame)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Adalight send_pixels_fast error: {e}")
|
||||||
|
|
||||||
|
def _build_frame(self, pixels: List[Tuple[int, int, int]], brightness: int) -> bytes:
|
||||||
|
"""Build a complete Adalight frame: header + brightness-scaled RGB data."""
|
||||||
|
arr = np.array(pixels, dtype=np.uint16)
|
||||||
|
|
||||||
|
if brightness < 255:
|
||||||
|
arr = arr * brightness // 255
|
||||||
|
|
||||||
|
np.clip(arr, 0, 255, out=arr)
|
||||||
|
rgb_bytes = arr.astype(np.uint8).tobytes()
|
||||||
|
return self._header + rgb_bytes
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def check_health(
|
||||||
|
cls,
|
||||||
|
url: str,
|
||||||
|
http_client,
|
||||||
|
prev_health: Optional[DeviceHealth] = None,
|
||||||
|
) -> DeviceHealth:
|
||||||
|
"""Check if the serial port exists without opening it.
|
||||||
|
|
||||||
|
Enumerates COM ports to avoid exclusive-access conflicts on Windows.
|
||||||
|
"""
|
||||||
|
port, _baud = parse_adalight_url(url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
available_ports = [p.device for p in serial.tools.list_ports.comports()]
|
||||||
|
port_upper = port.upper()
|
||||||
|
found = any(p.upper() == port_upper for p in available_ports)
|
||||||
|
|
||||||
|
if found:
|
||||||
|
return DeviceHealth(
|
||||||
|
online=True,
|
||||||
|
latency_ms=0.0,
|
||||||
|
last_checked=datetime.utcnow(),
|
||||||
|
device_name=prev_health.device_name if prev_health else None,
|
||||||
|
device_version=None,
|
||||||
|
device_led_count=prev_health.device_led_count if prev_health else None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return DeviceHealth(
|
||||||
|
online=False,
|
||||||
|
last_checked=datetime.utcnow(),
|
||||||
|
error=f"Serial port {port} not found",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return DeviceHealth(
|
||||||
|
online=False,
|
||||||
|
last_checked=datetime.utcnow(),
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
93
server/src/wled_controller/core/adalight_provider.py
Normal file
93
server/src/wled_controller/core/adalight_provider.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Adalight device provider — serial LED controller support."""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from wled_controller.core.led_client import (
|
||||||
|
DeviceHealth,
|
||||||
|
DiscoveredDevice,
|
||||||
|
LEDClient,
|
||||||
|
LEDDeviceProvider,
|
||||||
|
)
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AdalightDeviceProvider(LEDDeviceProvider):
|
||||||
|
"""Provider for Adalight serial LED controllers."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_type(self) -> str:
|
||||||
|
return "adalight"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set:
|
||||||
|
# No hardware brightness control, no standby required
|
||||||
|
# manual_led_count: user must specify LED count (can't auto-detect)
|
||||||
|
return {"manual_led_count"}
|
||||||
|
|
||||||
|
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||||
|
from wled_controller.core.adalight_client import AdalightClient
|
||||||
|
|
||||||
|
led_count = kwargs.pop("led_count", 0)
|
||||||
|
kwargs.pop("use_ddp", None) # Not applicable for serial
|
||||||
|
return AdalightClient(url, led_count=led_count, **kwargs)
|
||||||
|
|
||||||
|
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||||
|
from wled_controller.core.adalight_client import AdalightClient
|
||||||
|
|
||||||
|
return await AdalightClient.check_health(url, http_client, prev_health)
|
||||||
|
|
||||||
|
async def validate_device(self, url: str) -> dict:
|
||||||
|
"""Validate that the serial port exists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Empty dict — Adalight devices don't report LED count,
|
||||||
|
so it must be provided by the user.
|
||||||
|
"""
|
||||||
|
from wled_controller.core.adalight_client import parse_adalight_url
|
||||||
|
|
||||||
|
port, _baud = parse_adalight_url(url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
available_ports = [p.device for p in serial.tools.list_ports.comports()]
|
||||||
|
port_upper = port.upper()
|
||||||
|
if not any(p.upper() == port_upper for p in available_ports):
|
||||||
|
raise ValueError(
|
||||||
|
f"Serial port {port} not found. "
|
||||||
|
f"Available ports: {', '.join(available_ports) or 'none'}"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to enumerate serial ports: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Adalight device validated: port {port}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||||
|
"""Discover serial ports that could be Adalight devices."""
|
||||||
|
try:
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
ports = serial.tools.list_ports.comports()
|
||||||
|
results = []
|
||||||
|
for port_info in ports:
|
||||||
|
results.append(
|
||||||
|
DiscoveredDevice(
|
||||||
|
name=port_info.description or port_info.device,
|
||||||
|
url=port_info.device,
|
||||||
|
device_type="adalight",
|
||||||
|
ip=port_info.device,
|
||||||
|
mac="",
|
||||||
|
led_count=None,
|
||||||
|
version=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(f"Serial port scan found {len(results)} port(s)")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Serial port discovery failed: {e}")
|
||||||
|
return []
|
||||||
@@ -258,5 +258,8 @@ def _register_builtin_providers():
|
|||||||
from wled_controller.core.wled_provider import WLEDDeviceProvider
|
from wled_controller.core.wled_provider import WLEDDeviceProvider
|
||||||
register_provider(WLEDDeviceProvider())
|
register_provider(WLEDDeviceProvider())
|
||||||
|
|
||||||
|
from wled_controller.core.adalight_provider import AdalightDeviceProvider
|
||||||
|
register_provider(AdalightDeviceProvider())
|
||||||
|
|
||||||
|
|
||||||
_register_builtin_providers()
|
_register_builtin_providers()
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ class ProcessorManager:
|
|||||||
|
|
||||||
# Connect to LED device via factory
|
# Connect to LED device via factory
|
||||||
try:
|
try:
|
||||||
state.led_client = create_led_client(device_type, state.device_url, use_ddp=True)
|
state.led_client = create_led_client(device_type, state.device_url, use_ddp=True, led_count=state.led_count)
|
||||||
await state.led_client.connect()
|
await state.led_client.connect()
|
||||||
logger.info(f"Target {target_id} connected to {device_type} device ({state.led_count} LEDs)")
|
logger.info(f"Target {target_id} connected to {device_type} device ({state.led_count} LEDs)")
|
||||||
|
|
||||||
@@ -885,7 +885,7 @@ class ProcessorManager:
|
|||||||
if active_client:
|
if active_client:
|
||||||
await active_client.send_pixels(pixels)
|
await active_client.send_pixels(pixels)
|
||||||
else:
|
else:
|
||||||
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client:
|
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True, led_count=ds.led_count) as client:
|
||||||
await client.send_pixels(pixels)
|
await client.send_pixels(pixels)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send test pixels for {device_id}: {e}")
|
logger.error(f"Failed to send test pixels for {device_id}: {e}")
|
||||||
@@ -905,7 +905,7 @@ class ProcessorManager:
|
|||||||
if active_client:
|
if active_client:
|
||||||
await active_client.send_pixels(pixels)
|
await active_client.send_pixels(pixels)
|
||||||
else:
|
else:
|
||||||
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client:
|
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True, led_count=ds.led_count) as client:
|
||||||
await client.send_pixels(pixels)
|
await client.send_pixels(pixels)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to clear pixels for {device_id}: {e}")
|
logger.error(f"Failed to clear pixels for {device_id}: {e}")
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
|||||||
|
|
||||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||||
from wled_controller.core.wled_client import WLEDClient
|
from wled_controller.core.wled_client import WLEDClient
|
||||||
|
kwargs.pop("led_count", None)
|
||||||
return WLEDClient(url, **kwargs)
|
return WLEDClient(url, **kwargs)
|
||||||
|
|
||||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ function createDeviceCard(device) {
|
|||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||||
${device.name || device.id}
|
${device.name || device.id}
|
||||||
${device.url ? `<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">🌐</span></a>` : ''}
|
${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">🌐</span></a>` : (device.url && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
||||||
${healthLabel}
|
${healthLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -659,6 +659,7 @@ function createDeviceCard(device) {
|
|||||||
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||||
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
${(device.capabilities || []).includes('brightness_control') ? `
|
||||||
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
|
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
|
||||||
<input type="range" class="brightness-slider" min="0" max="255"
|
<input type="range" class="brightness-slider" min="0" max="255"
|
||||||
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
|
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
|
||||||
@@ -666,7 +667,7 @@ function createDeviceCard(device) {
|
|||||||
onchange="saveCardBrightness('${device.id}', this.value)"
|
onchange="saveCardBrightness('${device.id}', this.value)"
|
||||||
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
|
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
|
||||||
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
|
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
|
||||||
</div>
|
</div>` : ''}
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||||||
⚙️
|
⚙️
|
||||||
@@ -729,17 +730,46 @@ async function showSettings(deviceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const device = await deviceResponse.json();
|
const device = await deviceResponse.json();
|
||||||
|
const isAdalight = device.device_type === 'adalight';
|
||||||
|
|
||||||
// Populate fields
|
// Populate fields
|
||||||
document.getElementById('settings-device-id').value = device.id;
|
document.getElementById('settings-device-id').value = device.id;
|
||||||
document.getElementById('settings-device-name').value = device.name;
|
document.getElementById('settings-device-name').value = device.name;
|
||||||
document.getElementById('settings-device-url').value = device.url;
|
|
||||||
document.getElementById('settings-health-interval').value = 30;
|
document.getElementById('settings-health-interval').value = 30;
|
||||||
|
|
||||||
|
// Toggle URL vs serial port field
|
||||||
|
const urlGroup = document.getElementById('settings-url-group');
|
||||||
|
const serialGroup = document.getElementById('settings-serial-port-group');
|
||||||
|
if (isAdalight) {
|
||||||
|
urlGroup.style.display = 'none';
|
||||||
|
document.getElementById('settings-device-url').removeAttribute('required');
|
||||||
|
serialGroup.style.display = '';
|
||||||
|
// Populate serial port dropdown via discovery
|
||||||
|
_populateSettingsSerialPorts(device.url);
|
||||||
|
} else {
|
||||||
|
urlGroup.style.display = '';
|
||||||
|
document.getElementById('settings-device-url').setAttribute('required', '');
|
||||||
|
document.getElementById('settings-device-url').value = device.url;
|
||||||
|
serialGroup.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show LED count field for devices with manual_led_count capability
|
||||||
|
const caps = device.capabilities || [];
|
||||||
|
const ledCountGroup = document.getElementById('settings-led-count-group');
|
||||||
|
if (caps.includes('manual_led_count')) {
|
||||||
|
ledCountGroup.style.display = '';
|
||||||
|
document.getElementById('settings-led-count').value = device.led_count || '';
|
||||||
|
} else {
|
||||||
|
ledCountGroup.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot initial values for dirty checking
|
// Snapshot initial values for dirty checking
|
||||||
settingsInitialValues = {
|
settingsInitialValues = {
|
||||||
name: device.name,
|
name: device.name,
|
||||||
url: device.url,
|
url: device.url,
|
||||||
|
led_count: String(device.led_count || ''),
|
||||||
|
device_type: device.device_type,
|
||||||
|
capabilities: caps,
|
||||||
state_check_interval: '30',
|
state_check_interval: '30',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -759,11 +789,21 @@ async function showSettings(deviceId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _getSettingsUrl() {
|
||||||
|
if (settingsInitialValues.device_type === 'adalight') {
|
||||||
|
return document.getElementById('settings-serial-port').value;
|
||||||
|
}
|
||||||
|
return document.getElementById('settings-device-url').value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function isSettingsDirty() {
|
function isSettingsDirty() {
|
||||||
|
const ledCountDirty = (settingsInitialValues.capabilities || []).includes('manual_led_count')
|
||||||
|
&& document.getElementById('settings-led-count').value !== settingsInitialValues.led_count;
|
||||||
return (
|
return (
|
||||||
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
||||||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
|
_getSettingsUrl() !== settingsInitialValues.url ||
|
||||||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
|
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
|
||||||
|
ledCountDirty
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,7 +827,7 @@ async function closeDeviceSettingsModal() {
|
|||||||
async function saveDeviceSettings() {
|
async function saveDeviceSettings() {
|
||||||
const deviceId = document.getElementById('settings-device-id').value;
|
const deviceId = document.getElementById('settings-device-id').value;
|
||||||
const name = document.getElementById('settings-device-name').value.trim();
|
const name = document.getElementById('settings-device-name').value.trim();
|
||||||
const url = document.getElementById('settings-device-url').value.trim();
|
const url = _getSettingsUrl();
|
||||||
const error = document.getElementById('settings-error');
|
const error = document.getElementById('settings-error');
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
@@ -798,11 +838,16 @@ async function saveDeviceSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update device info (name, url)
|
// Update device info (name, url, optionally led_count)
|
||||||
|
const body = { name, url };
|
||||||
|
const ledCountInput = document.getElementById('settings-led-count');
|
||||||
|
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
|
||||||
|
body.led_count = parseInt(ledCountInput.value, 10);
|
||||||
|
}
|
||||||
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
body: JSON.stringify({ name, url })
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deviceResponse.status === 401) {
|
if (deviceResponse.status === 401) {
|
||||||
@@ -874,6 +919,174 @@ async function fetchDeviceBrightness(deviceId) {
|
|||||||
|
|
||||||
// Add device modal
|
// Add device modal
|
||||||
let _discoveryScanRunning = false;
|
let _discoveryScanRunning = false;
|
||||||
|
let _discoveryCache = {}; // { deviceType: [...devices] } — per-type discovery cache
|
||||||
|
|
||||||
|
function onDeviceTypeChanged() {
|
||||||
|
const deviceType = document.getElementById('device-type').value;
|
||||||
|
const urlGroup = document.getElementById('device-url-group');
|
||||||
|
const urlInput = document.getElementById('device-url');
|
||||||
|
const serialGroup = document.getElementById('device-serial-port-group');
|
||||||
|
const serialSelect = document.getElementById('device-serial-port');
|
||||||
|
const ledCountGroup = document.getElementById('device-led-count-group');
|
||||||
|
const discoverySection = document.getElementById('discovery-section');
|
||||||
|
|
||||||
|
if (deviceType === 'adalight') {
|
||||||
|
urlGroup.style.display = 'none';
|
||||||
|
urlInput.removeAttribute('required');
|
||||||
|
serialGroup.style.display = '';
|
||||||
|
serialSelect.setAttribute('required', '');
|
||||||
|
ledCountGroup.style.display = '';
|
||||||
|
// Hide discovery list — serial port dropdown replaces it
|
||||||
|
if (discoverySection) discoverySection.style.display = 'none';
|
||||||
|
// Populate from cache or show placeholder (lazy-load on focus)
|
||||||
|
if (deviceType in _discoveryCache) {
|
||||||
|
_populateSerialPortDropdown(_discoveryCache[deviceType]);
|
||||||
|
} else {
|
||||||
|
serialSelect.innerHTML = '';
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = '';
|
||||||
|
opt.textContent = t('device.serial_port.hint') || 'Click to discover ports...';
|
||||||
|
opt.disabled = true;
|
||||||
|
serialSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
urlGroup.style.display = '';
|
||||||
|
urlInput.setAttribute('required', '');
|
||||||
|
serialGroup.style.display = 'none';
|
||||||
|
serialSelect.removeAttribute('required');
|
||||||
|
ledCountGroup.style.display = 'none';
|
||||||
|
// Show cached results or trigger scan for WLED
|
||||||
|
if (deviceType in _discoveryCache) {
|
||||||
|
_renderDiscoveryList();
|
||||||
|
} else {
|
||||||
|
scanForDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderDiscoveryList() {
|
||||||
|
const selectedType = document.getElementById('device-type').value;
|
||||||
|
const devices = _discoveryCache[selectedType];
|
||||||
|
|
||||||
|
// Adalight: populate serial port dropdown instead of discovery list
|
||||||
|
if (selectedType === 'adalight') {
|
||||||
|
_populateSerialPortDropdown(devices || []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WLED and others: render discovery list cards
|
||||||
|
const list = document.getElementById('discovery-list');
|
||||||
|
const empty = document.getElementById('discovery-empty');
|
||||||
|
const section = document.getElementById('discovery-section');
|
||||||
|
if (!list || !section) return;
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
if (!devices) {
|
||||||
|
section.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.style.display = 'block';
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
empty.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
empty.style.display = 'none';
|
||||||
|
devices.forEach(device => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
|
||||||
|
const meta = [device.ip];
|
||||||
|
if (device.led_count) meta.push(device.led_count + ' LEDs');
|
||||||
|
if (device.version) meta.push('v' + device.version);
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="discovery-item-info">
|
||||||
|
<strong>${escapeHtml(device.name)}</strong>
|
||||||
|
<small>${escapeHtml(meta.join(' \u00b7 '))}</small>
|
||||||
|
</div>
|
||||||
|
${device.already_added
|
||||||
|
? '<span class="discovery-badge">' + t('device.scan.already_added') + '</span>'
|
||||||
|
: ''}
|
||||||
|
`;
|
||||||
|
if (!device.already_added) {
|
||||||
|
card.onclick = () => selectDiscoveredDevice(device);
|
||||||
|
}
|
||||||
|
list.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _populateSerialPortDropdown(devices) {
|
||||||
|
const select = document.getElementById('device-serial-port');
|
||||||
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = '';
|
||||||
|
opt.textContent = t('device.serial_port.none') || 'No serial ports found';
|
||||||
|
opt.disabled = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.forEach(device => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = device.url;
|
||||||
|
opt.textContent = device.name;
|
||||||
|
if (device.already_added) {
|
||||||
|
opt.textContent += ' (' + t('device.scan.already_added') + ')';
|
||||||
|
}
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSerialPortFocus() {
|
||||||
|
// Lazy-load: trigger discovery when user opens the serial port dropdown
|
||||||
|
if (!('adalight' in _discoveryCache)) {
|
||||||
|
scanForDevices('adalight');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _populateSettingsSerialPorts(currentUrl) {
|
||||||
|
const select = document.getElementById('settings-serial-port');
|
||||||
|
select.innerHTML = '';
|
||||||
|
// Show loading placeholder
|
||||||
|
const loadingOpt = document.createElement('option');
|
||||||
|
loadingOpt.value = currentUrl;
|
||||||
|
loadingOpt.textContent = currentUrl + ' ⏳';
|
||||||
|
select.appendChild(loadingOpt);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=adalight`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
const devices = data.devices || [];
|
||||||
|
|
||||||
|
select.innerHTML = '';
|
||||||
|
// Always include current port even if not discovered
|
||||||
|
let currentFound = false;
|
||||||
|
devices.forEach(device => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = device.url;
|
||||||
|
opt.textContent = device.name;
|
||||||
|
if (device.url === currentUrl) currentFound = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (!currentFound) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = currentUrl;
|
||||||
|
opt.textContent = currentUrl;
|
||||||
|
select.insertBefore(opt, select.firstChild);
|
||||||
|
}
|
||||||
|
select.value = currentUrl;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to discover serial ports:', err);
|
||||||
|
// Keep the current URL as fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showAddDevice() {
|
function showAddDevice() {
|
||||||
const modal = document.getElementById('add-device-modal');
|
const modal = document.getElementById('add-device-modal');
|
||||||
@@ -881,6 +1094,7 @@ function showAddDevice() {
|
|||||||
const error = document.getElementById('add-device-error');
|
const error = document.getElementById('add-device-error');
|
||||||
form.reset();
|
form.reset();
|
||||||
error.style.display = 'none';
|
error.style.display = 'none';
|
||||||
|
_discoveryCache = {};
|
||||||
// Reset discovery section
|
// Reset discovery section
|
||||||
const section = document.getElementById('discovery-section');
|
const section = document.getElementById('discovery-section');
|
||||||
if (section) {
|
if (section) {
|
||||||
@@ -889,13 +1103,14 @@ function showAddDevice() {
|
|||||||
document.getElementById('discovery-empty').style.display = 'none';
|
document.getElementById('discovery-empty').style.display = 'none';
|
||||||
document.getElementById('discovery-loading').style.display = 'none';
|
document.getElementById('discovery-loading').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
// Reset serial port dropdown
|
||||||
|
document.getElementById('device-serial-port').innerHTML = '';
|
||||||
const scanBtn = document.getElementById('scan-network-btn');
|
const scanBtn = document.getElementById('scan-network-btn');
|
||||||
if (scanBtn) scanBtn.disabled = false;
|
if (scanBtn) scanBtn.disabled = false;
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
lockBody();
|
lockBody();
|
||||||
|
onDeviceTypeChanged();
|
||||||
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
||||||
// Auto-start discovery on open
|
|
||||||
scanForDevices();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAddDeviceModal() {
|
function closeAddDeviceModal() {
|
||||||
@@ -904,9 +1119,12 @@ function closeAddDeviceModal() {
|
|||||||
unlockBody();
|
unlockBody();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanForDevices() {
|
async function scanForDevices(forceType) {
|
||||||
if (_discoveryScanRunning) return;
|
const scanType = forceType || document.getElementById('device-type')?.value || 'wled';
|
||||||
_discoveryScanRunning = true;
|
|
||||||
|
// Per-type guard: prevent duplicate scans for the same type
|
||||||
|
if (_discoveryScanRunning === scanType) return;
|
||||||
|
_discoveryScanRunning = scanType;
|
||||||
|
|
||||||
const loading = document.getElementById('discovery-loading');
|
const loading = document.getElementById('discovery-loading');
|
||||||
const list = document.getElementById('discovery-list');
|
const list = document.getElementById('discovery-list');
|
||||||
@@ -914,14 +1132,26 @@ async function scanForDevices() {
|
|||||||
const section = document.getElementById('discovery-section');
|
const section = document.getElementById('discovery-section');
|
||||||
const scanBtn = document.getElementById('scan-network-btn');
|
const scanBtn = document.getElementById('scan-network-btn');
|
||||||
|
|
||||||
section.style.display = 'block';
|
if (scanType === 'adalight') {
|
||||||
loading.style.display = 'flex';
|
// Show loading in the serial port dropdown
|
||||||
list.innerHTML = '';
|
const select = document.getElementById('device-serial-port');
|
||||||
empty.style.display = 'none';
|
select.innerHTML = '';
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = '';
|
||||||
|
opt.textContent = '⏳';
|
||||||
|
opt.disabled = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
} else {
|
||||||
|
// Show the discovery section with loading spinner
|
||||||
|
section.style.display = 'block';
|
||||||
|
loading.style.display = 'flex';
|
||||||
|
list.innerHTML = '';
|
||||||
|
empty.style.display = 'none';
|
||||||
|
}
|
||||||
if (scanBtn) scanBtn.disabled = true;
|
if (scanBtn) scanBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices/discover?timeout=3`, {
|
const response = await fetch(`${API_BASE}/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`, {
|
||||||
headers: getHeaders()
|
headers: getHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -931,54 +1161,46 @@ async function scanForDevices() {
|
|||||||
if (scanBtn) scanBtn.disabled = false;
|
if (scanBtn) scanBtn.disabled = false;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
empty.style.display = 'block';
|
if (scanType !== 'adalight') {
|
||||||
empty.querySelector('small').textContent = t('device.scan.error');
|
empty.style.display = 'block';
|
||||||
|
empty.querySelector('small').textContent = t('device.scan.error');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
_discoveryCache[scanType] = data.devices || [];
|
||||||
|
|
||||||
if (data.devices.length === 0) {
|
// Only render if the user is still on this type
|
||||||
empty.style.display = 'block';
|
const currentType = document.getElementById('device-type')?.value;
|
||||||
return;
|
if (currentType === scanType) {
|
||||||
|
_renderDiscoveryList();
|
||||||
}
|
}
|
||||||
|
|
||||||
data.devices.forEach(device => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
|
|
||||||
const meta = [device.ip];
|
|
||||||
if (device.led_count) meta.push(device.led_count + ' LEDs');
|
|
||||||
if (device.version) meta.push('v' + device.version);
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="discovery-item-info">
|
|
||||||
<strong>${escapeHtml(device.name)}</strong>
|
|
||||||
<small>${escapeHtml(meta.join(' \u00b7 '))}</small>
|
|
||||||
</div>
|
|
||||||
${device.already_added
|
|
||||||
? '<span class="discovery-badge">' + t('device.scan.already_added') + '</span>'
|
|
||||||
: ''}
|
|
||||||
`;
|
|
||||||
if (!device.already_added) {
|
|
||||||
card.onclick = () => selectDiscoveredDevice(device);
|
|
||||||
}
|
|
||||||
list.appendChild(card);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loading.style.display = 'none';
|
loading.style.display = 'none';
|
||||||
if (scanBtn) scanBtn.disabled = false;
|
if (scanBtn) scanBtn.disabled = false;
|
||||||
empty.style.display = 'block';
|
if (scanType !== 'adalight') {
|
||||||
empty.querySelector('small').textContent = t('device.scan.error');
|
empty.style.display = 'block';
|
||||||
|
empty.querySelector('small').textContent = t('device.scan.error');
|
||||||
|
}
|
||||||
console.error('Device scan failed:', err);
|
console.error('Device scan failed:', err);
|
||||||
} finally {
|
} finally {
|
||||||
_discoveryScanRunning = false;
|
if (_discoveryScanRunning === scanType) {
|
||||||
|
_discoveryScanRunning = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectDiscoveredDevice(device) {
|
function selectDiscoveredDevice(device) {
|
||||||
document.getElementById('device-name').value = device.name;
|
document.getElementById('device-name').value = device.name;
|
||||||
document.getElementById('device-url').value = device.url;
|
|
||||||
const typeSelect = document.getElementById('device-type');
|
const typeSelect = document.getElementById('device-type');
|
||||||
if (typeSelect) typeSelect.value = device.device_type;
|
if (typeSelect) typeSelect.value = device.device_type;
|
||||||
|
onDeviceTypeChanged();
|
||||||
|
if (device.device_type === 'adalight') {
|
||||||
|
document.getElementById('device-serial-port').value = device.url;
|
||||||
|
} else {
|
||||||
|
document.getElementById('device-url').value = device.url;
|
||||||
|
}
|
||||||
showToast(t('device.scan.selected'), 'info');
|
showToast(t('device.scan.selected'), 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,7 +1208,10 @@ async function handleAddDevice(event) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const name = document.getElementById('device-name').value.trim();
|
const name = document.getElementById('device-name').value.trim();
|
||||||
const url = document.getElementById('device-url').value.trim();
|
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
||||||
|
const url = deviceType === 'adalight'
|
||||||
|
? document.getElementById('device-serial-port').value
|
||||||
|
: document.getElementById('device-url').value.trim();
|
||||||
const error = document.getElementById('add-device-error');
|
const error = document.getElementById('add-device-error');
|
||||||
|
|
||||||
if (!name || !url) {
|
if (!name || !url) {
|
||||||
@@ -996,8 +1221,11 @@ async function handleAddDevice(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
|
||||||
const body = { name, url, device_type: deviceType };
|
const body = { name, url, device_type: deviceType };
|
||||||
|
const ledCountInput = document.getElementById('device-led-count');
|
||||||
|
if (ledCountInput && ledCountInput.value) {
|
||||||
|
body.led_count = parseInt(ledCountInput.value, 10);
|
||||||
|
}
|
||||||
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
||||||
if (lastTemplateId) {
|
if (lastTemplateId) {
|
||||||
body.capture_template_id = lastTemplateId;
|
body.capture_template_id = lastTemplateId;
|
||||||
@@ -4274,7 +4502,9 @@ async function loadTargetsTab() {
|
|||||||
// Attach event listeners and fetch brightness for device cards
|
// Attach event listeners and fetch brightness for device cards
|
||||||
devicesWithState.forEach(device => {
|
devicesWithState.forEach(device => {
|
||||||
attachDeviceListeners(device.id);
|
attachDeviceListeners(device.id);
|
||||||
fetchDeviceBrightness(device.id);
|
if ((device.capabilities || []).includes('brightness_control')) {
|
||||||
|
fetchDeviceBrightness(device.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manage KC WebSockets: connect for processing, disconnect for stopped
|
// Manage KC WebSockets: connect for processing, disconnect for stopped
|
||||||
|
|||||||
@@ -237,13 +237,30 @@
|
|||||||
<input type="text" id="settings-device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
|
<input type="text" id="settings-device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" id="settings-url-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="settings-device-url" data-i18n="device.url">URL:</label>
|
<label for="settings-device-url" data-i18n="device.url">URL:</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of the device</small>
|
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of the device</small>
|
||||||
<input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
<input type="text" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="settings-serial-port-group" style="display: none;">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="settings-serial-port" data-i18n="device.serial_port">Serial Port:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="device.serial_port.hint">Select the COM port of the Adalight device</small>
|
||||||
|
<select id="settings-serial-port"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="settings-led-count-group" style="display: none;">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="settings-led-count" data-i18n="device.led_count">LED Count:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="device.led_count_manual.hint">Number of LEDs on the strip (must match your Arduino sketch)</small>
|
||||||
|
<input type="number" id="settings-led-count" min="1" max="10000">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -582,7 +599,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="discovery-list" class="discovery-list"></div>
|
<div id="discovery-list" class="discovery-list"></div>
|
||||||
<div id="discovery-empty" style="display: none;">
|
<div id="discovery-empty" style="display: none;">
|
||||||
<small data-i18n="device.scan.empty">No WLED devices found on the network</small>
|
<small data-i18n="device.scan.empty">No devices found</small>
|
||||||
</div>
|
</div>
|
||||||
<hr class="modal-divider">
|
<hr class="modal-divider">
|
||||||
</div>
|
</div>
|
||||||
@@ -593,21 +610,38 @@
|
|||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="device.type.hint">Select the type of LED controller</small>
|
<small class="input-hint" style="display:none" data-i18n="device.type.hint">Select the type of LED controller</small>
|
||||||
<select id="device-type">
|
<select id="device-type" onchange="onDeviceTypeChanged()">
|
||||||
<option value="wled">WLED</option>
|
<option value="wled">WLED</option>
|
||||||
|
<option value="adalight">Adalight</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="device-name" data-i18n="device.name">Device Name:</label>
|
<label for="device-name" data-i18n="device.name">Device Name:</label>
|
||||||
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
|
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group" id="device-url-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="device-url" data-i18n="device.url">URL:</label>
|
<label for="device-url" id="device-url-label" data-i18n="device.url">URL:</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small>
|
<small class="input-hint" style="display:none" id="device-url-hint" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small>
|
||||||
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
<input type="text" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="device-serial-port-group" style="display: none;">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="device-serial-port" id="device-serial-port-label" data-i18n="device.serial_port">Serial Port:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="device.serial_port.hint">Select the COM port of the Adalight device</small>
|
||||||
|
<select id="device-serial-port" onfocus="onSerialPortFocus()"></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="device-led-count-group" style="display: none;">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="device-led-count" data-i18n="device.led_count">LED Count:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="device.led_count_manual.hint">Number of LEDs on the strip (must match your Arduino sketch)</small>
|
||||||
|
<input type="number" id="device-led-count" min="1" max="10000" placeholder="60">
|
||||||
</div>
|
</div>
|
||||||
<div id="add-device-error" class="error-message" style="display: none;"></div>
|
<div id="add-device-error" class="error-message" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -102,12 +102,16 @@
|
|||||||
"devices.wled_note_webui": "(open your device's IP in a browser).",
|
"devices.wled_note_webui": "(open your device's IP in a browser).",
|
||||||
"devices.wled_note2": "This controller sends pixel color data and controls brightness per device.",
|
"devices.wled_note2": "This controller sends pixel color data and controls brightness per device.",
|
||||||
"device.scan": "Auto Discovery",
|
"device.scan": "Auto Discovery",
|
||||||
"device.scan.empty": "No WLED devices found on the network",
|
"device.scan.empty": "No devices found",
|
||||||
"device.scan.error": "Network scan failed",
|
"device.scan.error": "Network scan failed",
|
||||||
"device.scan.already_added": "Already added",
|
"device.scan.already_added": "Already added",
|
||||||
"device.scan.selected": "Device selected",
|
"device.scan.selected": "Device selected",
|
||||||
"device.type": "Device Type:",
|
"device.type": "Device Type:",
|
||||||
"device.type.hint": "Select the type of LED controller",
|
"device.type.hint": "Select the type of LED controller",
|
||||||
|
"device.serial_port": "Serial Port:",
|
||||||
|
"device.serial_port.hint": "Select the COM port of the Adalight device",
|
||||||
|
"device.serial_port.none": "No serial ports found",
|
||||||
|
"device.led_count_manual.hint": "Number of LEDs on the strip (must match your Arduino sketch)",
|
||||||
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
||||||
"device.name": "Device Name:",
|
"device.name": "Device Name:",
|
||||||
"device.name.placeholder": "Living Room TV",
|
"device.name.placeholder": "Living Room TV",
|
||||||
|
|||||||
@@ -102,12 +102,16 @@
|
|||||||
"devices.wled_note_webui": "(откройте IP устройства в браузере).",
|
"devices.wled_note_webui": "(откройте IP устройства в браузере).",
|
||||||
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
|
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
|
||||||
"device.scan": "Автопоиск",
|
"device.scan": "Автопоиск",
|
||||||
"device.scan.empty": "WLED устройства не найдены в сети",
|
"device.scan.empty": "Устройства не найдены",
|
||||||
"device.scan.error": "Ошибка сканирования сети",
|
"device.scan.error": "Ошибка сканирования сети",
|
||||||
"device.scan.already_added": "Уже добавлено",
|
"device.scan.already_added": "Уже добавлено",
|
||||||
"device.scan.selected": "Устройство выбрано",
|
"device.scan.selected": "Устройство выбрано",
|
||||||
"device.type": "Тип устройства:",
|
"device.type": "Тип устройства:",
|
||||||
"device.type.hint": "Выберите тип LED контроллера",
|
"device.type.hint": "Выберите тип LED контроллера",
|
||||||
|
"device.serial_port": "Серийный порт:",
|
||||||
|
"device.serial_port.hint": "Выберите COM порт устройства Adalight",
|
||||||
|
"device.serial_port.none": "Серийные порты не найдены",
|
||||||
|
"device.led_count_manual.hint": "Количество светодиодов на ленте (должно совпадать с вашим скетчем Arduino)",
|
||||||
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
||||||
"device.name": "Имя Устройства:",
|
"device.name": "Имя Устройства:",
|
||||||
"device.name.placeholder": "ТВ в Гостиной",
|
"device.name.placeholder": "ТВ в Гостиной",
|
||||||
|
|||||||
@@ -397,6 +397,16 @@ section {
|
|||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.discovery-type-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
.modal-divider {
|
.modal-divider {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
@@ -1244,7 +1254,7 @@ input:-webkit-autofill:focus {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
margin: 40px auto 20px;
|
margin: 40px auto 40px;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 2px solid var(--border-color);
|
border: 2px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user