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:
@@ -83,7 +83,12 @@ async def create_device(
|
||||
|
||||
try:
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
@@ -146,18 +151,28 @@ async def discover_devices(
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
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 time
|
||||
|
||||
start = time.time()
|
||||
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]
|
||||
|
||||
if device_type:
|
||||
# Discover from a single provider
|
||||
try:
|
||||
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
|
||||
|
||||
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,
|
||||
url=update_data.url,
|
||||
enabled=update_data.enabled,
|
||||
led_count=update_data.led_count,
|
||||
)
|
||||
|
||||
# Sync connection info in processor manager
|
||||
@@ -220,7 +236,7 @@ async def update_device(
|
||||
manager.update_device_info(
|
||||
device_id,
|
||||
device_url=update_data.url,
|
||||
led_count=None,
|
||||
led_count=update_data.led_count,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@@ -10,16 +10,18 @@ class DeviceCreate(BaseModel):
|
||||
"""Request to create/attach an LED device."""
|
||||
|
||||
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)")
|
||||
device_type: str = Field(default="wled", description="LED device type (e.g., wled)")
|
||||
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, adalight)")
|
||||
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (required for adalight)")
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
"""Request to update device information."""
|
||||
|
||||
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")
|
||||
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (for devices with manual_led_count capability)")
|
||||
|
||||
|
||||
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
|
||||
register_provider(WLEDDeviceProvider())
|
||||
|
||||
from wled_controller.core.adalight_provider import AdalightDeviceProvider
|
||||
register_provider(AdalightDeviceProvider())
|
||||
|
||||
|
||||
_register_builtin_providers()
|
||||
|
||||
@@ -534,7 +534,7 @@ class ProcessorManager:
|
||||
|
||||
# Connect to LED device via factory
|
||||
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()
|
||||
logger.info(f"Target {target_id} connected to {device_type} device ({state.led_count} LEDs)")
|
||||
|
||||
@@ -885,7 +885,7 @@ class ProcessorManager:
|
||||
if active_client:
|
||||
await active_client.send_pixels(pixels)
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send test pixels for {device_id}: {e}")
|
||||
@@ -905,7 +905,7 @@ class ProcessorManager:
|
||||
if active_client:
|
||||
await active_client.send_pixels(pixels)
|
||||
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)
|
||||
except Exception as 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:
|
||||
from wled_controller.core.wled_client import WLEDClient
|
||||
kwargs.pop("led_count", None)
|
||||
return WLEDClient(url, **kwargs)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
|
||||
@@ -649,7 +649,7 @@ function createDeviceCard(device) {
|
||||
<div class="card-title">
|
||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||
${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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -659,6 +659,7 @@ function createDeviceCard(device) {
|
||||
${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>
|
||||
</div>
|
||||
${(device.capabilities || []).includes('brightness_control') ? `
|
||||
<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"
|
||||
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
|
||||
@@ -666,7 +667,7 @@ function createDeviceCard(device) {
|
||||
onchange="saveCardBrightness('${device.id}', this.value)"
|
||||
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
|
||||
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="card-actions">
|
||||
<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 isAdalight = device.device_type === 'adalight';
|
||||
|
||||
// Populate fields
|
||||
document.getElementById('settings-device-id').value = device.id;
|
||||
document.getElementById('settings-device-name').value = device.name;
|
||||
document.getElementById('settings-device-url').value = device.url;
|
||||
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
|
||||
settingsInitialValues = {
|
||||
name: device.name,
|
||||
url: device.url,
|
||||
led_count: String(device.led_count || ''),
|
||||
device_type: device.device_type,
|
||||
capabilities: caps,
|
||||
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() {
|
||||
const ledCountDirty = (settingsInitialValues.capabilities || []).includes('manual_led_count')
|
||||
&& document.getElementById('settings-led-count').value !== settingsInitialValues.led_count;
|
||||
return (
|
||||
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
||||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
|
||||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
|
||||
_getSettingsUrl() !== settingsInitialValues.url ||
|
||||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
|
||||
ledCountDirty
|
||||
);
|
||||
}
|
||||
|
||||
@@ -787,7 +827,7 @@ async function closeDeviceSettingsModal() {
|
||||
async function saveDeviceSettings() {
|
||||
const deviceId = document.getElementById('settings-device-id').value;
|
||||
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');
|
||||
|
||||
// Validation
|
||||
@@ -798,11 +838,16 @@ async function saveDeviceSettings() {
|
||||
}
|
||||
|
||||
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}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ name, url })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (deviceResponse.status === 401) {
|
||||
@@ -874,6 +919,174 @@ async function fetchDeviceBrightness(deviceId) {
|
||||
|
||||
// Add device modal
|
||||
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() {
|
||||
const modal = document.getElementById('add-device-modal');
|
||||
@@ -881,6 +1094,7 @@ function showAddDevice() {
|
||||
const error = document.getElementById('add-device-error');
|
||||
form.reset();
|
||||
error.style.display = 'none';
|
||||
_discoveryCache = {};
|
||||
// Reset discovery section
|
||||
const section = document.getElementById('discovery-section');
|
||||
if (section) {
|
||||
@@ -889,13 +1103,14 @@ function showAddDevice() {
|
||||
document.getElementById('discovery-empty').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');
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
onDeviceTypeChanged();
|
||||
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
||||
// Auto-start discovery on open
|
||||
scanForDevices();
|
||||
}
|
||||
|
||||
function closeAddDeviceModal() {
|
||||
@@ -904,9 +1119,12 @@ function closeAddDeviceModal() {
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
async function scanForDevices() {
|
||||
if (_discoveryScanRunning) return;
|
||||
_discoveryScanRunning = true;
|
||||
async function scanForDevices(forceType) {
|
||||
const scanType = forceType || document.getElementById('device-type')?.value || 'wled';
|
||||
|
||||
// Per-type guard: prevent duplicate scans for the same type
|
||||
if (_discoveryScanRunning === scanType) return;
|
||||
_discoveryScanRunning = scanType;
|
||||
|
||||
const loading = document.getElementById('discovery-loading');
|
||||
const list = document.getElementById('discovery-list');
|
||||
@@ -914,14 +1132,26 @@ async function scanForDevices() {
|
||||
const section = document.getElementById('discovery-section');
|
||||
const scanBtn = document.getElementById('scan-network-btn');
|
||||
|
||||
section.style.display = 'block';
|
||||
loading.style.display = 'flex';
|
||||
list.innerHTML = '';
|
||||
empty.style.display = 'none';
|
||||
if (scanType === 'adalight') {
|
||||
// Show loading in the serial port dropdown
|
||||
const select = document.getElementById('device-serial-port');
|
||||
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;
|
||||
|
||||
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()
|
||||
});
|
||||
|
||||
@@ -931,54 +1161,46 @@ async function scanForDevices() {
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
|
||||
if (!response.ok) {
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
if (scanType !== 'adalight') {
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
_discoveryCache[scanType] = data.devices || [];
|
||||
|
||||
if (data.devices.length === 0) {
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
// Only render if the user is still on this type
|
||||
const currentType = document.getElementById('device-type')?.value;
|
||||
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) {
|
||||
loading.style.display = 'none';
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
if (scanType !== 'adalight') {
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
}
|
||||
console.error('Device scan failed:', err);
|
||||
} finally {
|
||||
_discoveryScanRunning = false;
|
||||
if (_discoveryScanRunning === scanType) {
|
||||
_discoveryScanRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectDiscoveredDevice(device) {
|
||||
document.getElementById('device-name').value = device.name;
|
||||
document.getElementById('device-url').value = device.url;
|
||||
const typeSelect = document.getElementById('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');
|
||||
}
|
||||
|
||||
@@ -986,7 +1208,10 @@ async function handleAddDevice(event) {
|
||||
event.preventDefault();
|
||||
|
||||
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');
|
||||
|
||||
if (!name || !url) {
|
||||
@@ -996,8 +1221,11 @@ async function handleAddDevice(event) {
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
||||
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');
|
||||
if (lastTemplateId) {
|
||||
body.capture_template_id = lastTemplateId;
|
||||
@@ -4274,7 +4502,9 @@ async function loadTargetsTab() {
|
||||
// Attach event listeners and fetch brightness for device cards
|
||||
devicesWithState.forEach(device => {
|
||||
attachDeviceListeners(device.id);
|
||||
fetchDeviceBrightness(device.id);
|
||||
if ((device.capabilities || []).includes('brightness_control')) {
|
||||
fetchDeviceBrightness(device.id);
|
||||
}
|
||||
});
|
||||
|
||||
// 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>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group" id="settings-url-group">
|
||||
<div class="label-row">
|
||||
<label for="settings-device-url" data-i18n="device.url">URL:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<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 class="form-group">
|
||||
@@ -582,7 +599,7 @@
|
||||
</div>
|
||||
<div id="discovery-list" class="discovery-list"></div>
|
||||
<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>
|
||||
<hr class="modal-divider">
|
||||
</div>
|
||||
@@ -593,21 +610,38 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<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="adalight">Adalight</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group" id="device-url-group">
|
||||
<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>
|
||||
</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>
|
||||
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
||||
<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="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 id="add-device-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
|
||||
@@ -102,12 +102,16 @@
|
||||
"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.",
|
||||
"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.already_added": "Already added",
|
||||
"device.scan.selected": "Device selected",
|
||||
"device.type": "Device Type:",
|
||||
"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.name": "Device Name:",
|
||||
"device.name.placeholder": "Living Room TV",
|
||||
|
||||
@@ -102,12 +102,16 @@
|
||||
"devices.wled_note_webui": "(откройте IP устройства в браузере).",
|
||||
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
|
||||
"device.scan": "Автопоиск",
|
||||
"device.scan.empty": "WLED устройства не найдены в сети",
|
||||
"device.scan.empty": "Устройства не найдены",
|
||||
"device.scan.error": "Ошибка сканирования сети",
|
||||
"device.scan.already_added": "Уже добавлено",
|
||||
"device.scan.selected": "Устройство выбрано",
|
||||
"device.type": "Тип устройства:",
|
||||
"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.name": "Имя Устройства:",
|
||||
"device.name.placeholder": "ТВ в Гостиной",
|
||||
|
||||
@@ -397,6 +397,16 @@ section {
|
||||
background: var(--border-color);
|
||||
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 {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
@@ -1244,7 +1254,7 @@ input:-webkit-autofill:focus {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 40px auto 20px;
|
||||
margin: 40px auto 40px;
|
||||
background: var(--card-bg);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
|
||||
Reference in New Issue
Block a user