Add LED device abstraction layer for multi-controller support

Introduce abstract LEDClient base class with factory pattern so new
LED controller types can plug in alongside WLED. ProcessorManager is
now fully type-agnostic — all device-specific logic (health checks,
state snapshot/restore, fast send) lives behind the LEDClient interface.

- New led_client.py: LEDClient ABC, DeviceHealth, factory functions
- WLEDClient inherits LEDClient, encapsulates WLED health checks and state management
- device_type field on Device storage model (defaults to "wled")
- Rename target_type "wled" → "led" with backward-compat migration
- Frontend: "WLED" tab → "LED", device type badge, type selector in
  add-device modal, device type shown in target device dropdown
- All wled_* API fields renamed to device_* for generic naming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 12:41:02 +03:00
parent afce183f79
commit b5a6885126
18 changed files with 667 additions and 346 deletions

View File

@@ -4,6 +4,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
from wled_controller.core.led_client import get_device_capabilities
from wled_controller.api.dependencies import ( from wled_controller.api.dependencies import (
get_device_store, get_device_store,
get_picture_target_store, get_picture_target_store,
@@ -39,6 +40,7 @@ def _device_to_response(device) -> DeviceResponse:
id=device.id, id=device.id,
name=device.name, name=device.name,
url=device.url, url=device.url,
device_type=device.device_type,
led_count=device.led_count, led_count=device.led_count,
enabled=device.enabled, enabled=device.enabled,
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
@@ -56,50 +58,60 @@ async def create_device(
store: DeviceStore = Depends(get_device_store), store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager), manager: ProcessorManager = Depends(get_processor_manager),
): ):
"""Create and attach a new WLED device.""" """Create and attach a new LED device."""
try: try:
logger.info(f"Creating device: {device_data.name}") device_type = device_data.device_type
logger.info(f"Creating {device_type} device: {device_data.name}")
# Validate WLED device is reachable before adding
device_url = device_data.url.rstrip("/") device_url = device_data.url.rstrip("/")
try: wled_led_count = 0
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(f"{device_url}/json/info") if device_type == "wled":
response.raise_for_status() # Validate WLED device is reachable before adding
wled_info = response.json() try:
wled_led_count = wled_info.get("leds", {}).get("count") async with httpx.AsyncClient(timeout=5) as client:
if not wled_led_count or wled_led_count < 1: response = await client.get(f"{device_url}/json/info")
raise HTTPException( response.raise_for_status()
status_code=422, wled_info = response.json()
detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}" wled_led_count = wled_info.get("leds", {}).get("count")
if not wled_led_count or wled_led_count < 1:
raise HTTPException(
status_code=422,
detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}"
)
logger.info(
f"WLED device reachable: {wled_info.get('name', 'Unknown')} "
f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)"
) )
logger.info( except httpx.ConnectError:
f"WLED device reachable: {wled_info.get('name', 'Unknown')} " raise HTTPException(
f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)" status_code=422,
detail=f"Cannot reach WLED device at {device_url}. Check the URL and ensure the device is powered on."
) )
except httpx.ConnectError: except httpx.TimeoutException:
raise HTTPException(
status_code=422,
detail=f"Connection to {device_url} timed out. Check network connectivity."
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=422,
detail=f"Failed to connect to WLED device at {device_url}: {e}"
)
else:
raise HTTPException( raise HTTPException(
status_code=422, status_code=400,
detail=f"Cannot reach WLED device at {device_url}. Check the URL and ensure the device is powered on." detail=f"Unsupported device type: {device_type}"
)
except httpx.TimeoutException:
raise HTTPException(
status_code=422,
detail=f"Connection to {device_url} timed out. Check network connectivity."
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=422,
detail=f"Failed to connect to WLED device at {device_url}: {e}"
) )
# Create device in storage (LED count auto-detected from WLED) # Create device in storage
device = store.create_device( device = store.create_device(
name=device_data.name, name=device_data.name,
url=device_data.url, url=device_data.url,
led_count=wled_led_count, led_count=wled_led_count,
device_type=device_type,
) )
# Register in processor manager for health monitoring # Register in processor manager for health monitoring
@@ -108,6 +120,7 @@ async def create_device(
device_url=device.url, device_url=device.url,
led_count=device.led_count, led_count=device.led_count,
calibration=device.calibration, calibration=device.calibration,
device_type=device.device_type,
) )
return _device_to_response(device) return _device_to_response(device)
@@ -234,6 +247,7 @@ async def get_device_state(
try: try:
state = manager.get_device_health_dict(device_id) state = manager.get_device_health_dict(device_id)
state["device_type"] = device.device_type
return DeviceStateResponse(**state) return DeviceStateResponse(**state)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -251,6 +265,8 @@ async def get_device_brightness(
device = store.get_device(device_id) device = store.get_device(device_id)
if not device: if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found") raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
if "brightness_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
try: try:
async with httpx.AsyncClient(timeout=5.0) as http_client: async with httpx.AsyncClient(timeout=5.0) as http_client:
@@ -275,6 +291,8 @@ async def set_device_brightness(
device = store.get_device(device_id) device = store.get_device(device_id)
if not device: if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found") raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
if "brightness_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
bri = body.get("brightness") bri = body.get("brightness")
if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255: if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255:

View File

@@ -65,7 +65,6 @@ def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings:
settings = ProcessingSettings( settings = ProcessingSettings(
display_index=schema.display_index, display_index=schema.display_index,
fps=schema.fps, fps=schema.fps,
border_width=schema.border_width,
interpolation_mode=schema.interpolation_mode, interpolation_mode=schema.interpolation_mode,
brightness=schema.brightness, brightness=schema.brightness,
smoothing=schema.smoothing, smoothing=schema.smoothing,
@@ -86,7 +85,6 @@ def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchem
return ProcessingSettingsSchema( return ProcessingSettingsSchema(
display_index=settings.display_index, display_index=settings.display_index,
fps=settings.fps, fps=settings.fps,
border_width=settings.border_width,
interpolation_mode=settings.interpolation_mode, interpolation_mode=settings.interpolation_mode,
brightness=settings.brightness, brightness=settings.brightness,
smoothing=settings.smoothing, smoothing=settings.smoothing,
@@ -468,7 +466,6 @@ async def update_target_settings(
new_settings = ProcessingSettings( new_settings = ProcessingSettings(
display_index=settings.display_index if 'display_index' in sent else existing.display_index, display_index=settings.display_index if 'display_index' in sent else existing.display_index,
fps=settings.fps if 'fps' in sent else existing.fps, fps=settings.fps if 'fps' in sent else existing.fps,
border_width=settings.border_width if 'border_width' in sent else existing.border_width,
interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode, interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode,
brightness=settings.brightness if 'brightness' in sent else existing.brightness, brightness=settings.brightness if 'brightness' in sent else existing.brightness,
gamma=existing.gamma, gamma=existing.gamma,

View File

@@ -7,10 +7,11 @@ from pydantic import BaseModel, Field
class DeviceCreate(BaseModel): class DeviceCreate(BaseModel):
"""Request to create/attach a WLED 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="WLED device URL (e.g., http://192.168.1.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)")
class DeviceUpdate(BaseModel): class DeviceUpdate(BaseModel):
@@ -53,6 +54,7 @@ class Calibration(BaseModel):
# Skip LEDs at start/end of strip # Skip LEDs at start/end of strip
skip_leds_start: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the start of the strip") skip_leds_start: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the start of the strip")
skip_leds_end: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the end of the strip") skip_leds_end: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the end of the strip")
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for edge sampling")
class CalibrationTestModeRequest(BaseModel): class CalibrationTestModeRequest(BaseModel):
@@ -79,7 +81,8 @@ class DeviceResponse(BaseModel):
id: str = Field(description="Device ID") id: str = Field(description="Device ID")
name: str = Field(description="Device name") name: str = Field(description="Device name")
url: str = Field(description="WLED device URL") url: str = Field(description="Device URL")
device_type: str = Field(default="wled", description="LED device type")
led_count: int = Field(description="Total number of LEDs") led_count: int = Field(description="Total number of LEDs")
enabled: bool = Field(description="Whether device is enabled") enabled: bool = Field(description="Whether device is enabled")
calibration: Optional[Calibration] = Field(None, description="Calibration configuration") calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
@@ -98,14 +101,15 @@ class DeviceStateResponse(BaseModel):
"""Device health/connection state response.""" """Device health/connection state response."""
device_id: str = Field(description="Device ID") device_id: str = Field(description="Device ID")
wled_online: bool = Field(default=False, description="Whether WLED device is reachable") device_type: str = Field(default="wled", description="LED device type")
wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms") device_online: bool = Field(default=False, description="Whether device is reachable")
wled_name: Optional[str] = Field(None, description="WLED device name") device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
wled_version: Optional[str] = Field(None, description="WLED firmware version") device_name: Optional[str] = Field(None, description="Device name reported by firmware")
wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device") device_version: Optional[str] = Field(None, description="Firmware version")
wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs") device_led_count: Optional[int] = Field(None, description="LED count reported by device")
wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)") device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
wled_last_checked: Optional[datetime] = Field(None, description="Last health check time") device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
wled_error: Optional[str] = Field(None, description="Last health check error") device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
test_mode: bool = Field(default=False, description="Whether calibration test mode is active") test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
test_mode_edges: List[str] = Field(default_factory=list, description="Currently lit edges in test mode") test_mode_edges: List[str] = Field(default_factory=list, description="Currently lit edges in test mode")

View File

@@ -21,7 +21,6 @@ class ProcessingSettings(BaseModel):
display_index: int = Field(default=0, description="Display to capture", ge=0) display_index: int = Field(default=0, description="Display to capture", ge=0)
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90) fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0) brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0) smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0)
@@ -76,8 +75,8 @@ class PictureTargetCreate(BaseModel):
"""Request to create a picture target.""" """Request to create a picture target."""
name: str = Field(description="Target name", min_length=1, max_length=100) name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="wled", description="Target type (wled, key_colors)") target_type: str = Field(default="led", description="Target type (led, key_colors)")
device_id: str = Field(default="", description="WLED device ID") device_id: str = Field(default="", description="LED device ID")
picture_source_id: str = Field(default="", description="Picture source ID") picture_source_id: str = Field(default="", description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)") settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
@@ -132,15 +131,15 @@ class TargetProcessingState(BaseModel):
display_index: int = Field(default=0, description="Current display index") display_index: int = Field(default=0, description="Current display index")
last_update: Optional[datetime] = Field(None, description="Last successful update") last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors") errors: List[str] = Field(default_factory=list, description="Recent errors")
wled_online: bool = Field(default=False, description="Whether WLED device is reachable") device_online: bool = Field(default=False, description="Whether device is reachable")
wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms") device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
wled_name: Optional[str] = Field(None, description="WLED device name") device_name: Optional[str] = Field(None, description="Device name reported by firmware")
wled_version: Optional[str] = Field(None, description="WLED firmware version") device_version: Optional[str] = Field(None, description="Firmware version")
wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device") device_led_count: Optional[int] = Field(None, description="LED count reported by device")
wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs") device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)") device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
wled_last_checked: Optional[datetime] = Field(None, description="Last health check time") device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
wled_error: Optional[str] = Field(None, description="Last health check error") device_error: Optional[str] = Field(None, description="Last health check error")
class TargetMetricsResponse(BaseModel): class TargetMetricsResponse(BaseModel):

View File

@@ -79,6 +79,8 @@ class CalibrationConfig:
# Skip LEDs: black out N LEDs at the start/end of the strip # Skip LEDs: black out N LEDs at the start/end of the strip
skip_leds_start: int = 0 skip_leds_start: int = 0
skip_leds_end: int = 0 skip_leds_end: int = 0
# Border width: how many pixels from the screen edge to sample
border_width: int = 10
def build_segments(self) -> List[CalibrationSegment]: def build_segments(self) -> List[CalibrationSegment]:
"""Derive segment list from core parameters.""" """Derive segment list from core parameters."""
@@ -463,6 +465,7 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
span_left_end=data.get("span_left_end", 1.0), span_left_end=data.get("span_left_end", 1.0),
skip_leds_start=data.get("skip_leds_start", 0), skip_leds_start=data.get("skip_leds_start", 0),
skip_leds_end=data.get("skip_leds_end", 0), skip_leds_end=data.get("skip_leds_end", 0),
border_width=data.get("border_width", 10),
) )
config.validate() config.validate()
@@ -506,4 +509,6 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["skip_leds_start"] = config.skip_leds_start result["skip_leds_start"] = config.skip_leds_start
if config.skip_leds_end > 0: if config.skip_leds_end > 0:
result["skip_leds_end"] = config.skip_leds_end result["skip_leds_end"] = config.skip_leds_end
if config.border_width != 10:
result["border_width"] = config.border_width
return result return result

View File

@@ -0,0 +1,180 @@
"""Abstract base class for LED device communication clients."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional, Tuple
@dataclass
class DeviceHealth:
"""Health check result for an LED device."""
online: bool = False
latency_ms: Optional[float] = None
last_checked: Optional[datetime] = None
# Device-reported metadata (populated by type-specific health check)
device_name: Optional[str] = None
device_version: Optional[str] = None
device_led_count: Optional[int] = None
device_rgbw: Optional[bool] = None
device_led_type: Optional[str] = None
error: Optional[str] = None
class LEDClient(ABC):
"""Abstract base for LED device communication.
Lifecycle:
client = SomeLEDClient(url, ...)
await client.connect()
state = await client.snapshot_device_state() # save before streaming
client.send_pixels_fast(pixels, brightness) # if supports_fast_send
await client.send_pixels(pixels, brightness)
await client.restore_device_state(state) # restore after streaming
await client.close()
Or as async context manager:
async with SomeLEDClient(url, ...) as client:
...
"""
@abstractmethod
async def connect(self) -> bool:
"""Establish connection. Returns True on success, raises on failure."""
...
@abstractmethod
async def close(self) -> None:
"""Close the connection and release resources."""
...
@property
@abstractmethod
def is_connected(self) -> bool:
"""Check if connected."""
...
@abstractmethod
async def send_pixels(
self,
pixels: List[Tuple[int, int, int]],
brightness: int = 255,
) -> bool:
"""Send pixel colors to the LED device (async).
Args:
pixels: List of (R, G, B) tuples
brightness: Global brightness (0-255)
"""
...
@property
def supports_fast_send(self) -> bool:
"""Whether send_pixels_fast() is available (e.g. DDP UDP)."""
return False
def send_pixels_fast(
self,
pixels: List[Tuple[int, int, int]],
brightness: int = 255,
) -> None:
"""Synchronous fire-and-forget send for the hot loop.
Override in subclasses that support a fast protocol (e.g. DDP).
"""
raise NotImplementedError("send_pixels_fast not supported for this device type")
async def snapshot_device_state(self) -> Optional[dict]:
"""Snapshot device state before streaming starts.
Override in subclasses that need to save/restore state around streaming.
Returns a state dict to pass to restore_device_state(), or None.
"""
return None
async def restore_device_state(self, state: Optional[dict]) -> None:
"""Restore device state after streaming stops.
Args:
state: State dict returned by snapshot_device_state(), or None.
"""
pass
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Check device health without a full client connection.
Override in subclasses for type-specific health probes.
Default: mark as online with no metadata.
Args:
url: Device URL
http_client: Shared httpx.AsyncClient for HTTP requests
prev_health: Previous health result (for preserving cached metadata)
"""
return DeviceHealth(online=True, last_checked=datetime.utcnow())
async def __aenter__(self):
await self.connect()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()
# Per-device-type capability sets.
# Used by API routes to gate type-specific features (e.g. brightness control).
DEVICE_TYPE_CAPABILITIES = {
"wled": {"brightness_control"},
}
def get_device_capabilities(device_type: str) -> set:
"""Return the capability set for a device type."""
return DEVICE_TYPE_CAPABILITIES.get(device_type, set())
def create_led_client(device_type: str, url: str, **kwargs) -> LEDClient:
"""Factory: create the right LEDClient subclass for a device type.
Args:
device_type: Device type identifier (e.g. "wled")
url: Device URL
**kwargs: Passed to the client constructor
Returns:
LEDClient instance
Raises:
ValueError: If device_type is unknown
"""
if device_type == "wled":
from wled_controller.core.wled_client import WLEDClient
return WLEDClient(url, **kwargs)
raise ValueError(f"Unknown LED device type: {device_type}")
async def check_device_health(
device_type: str,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""Factory: dispatch health check to the right LEDClient subclass.
Args:
device_type: Device type identifier
url: Device URL
http_client: Shared httpx.AsyncClient
prev_health: Previous health result
"""
if device_type == "wled":
from wled_controller.core.wled_client import WLEDClient
return await WLEDClient.check_health(url, http_client, prev_health)
return DeviceHealth(online=True, last_checked=datetime.utcnow())

View File

@@ -26,7 +26,12 @@ from wled_controller.core.screen_capture import (
calculate_median_color, calculate_median_color,
extract_border_pixels, extract_border_pixels,
) )
from wled_controller.core.wled_client import WLEDClient from wled_controller.core.led_client import (
DeviceHealth,
LEDClient,
check_device_health,
create_led_client,
)
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -87,27 +92,12 @@ def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing):
) )
return colors return colors
# WLED LED bus type codes from const.h → human-readable names
WLED_LED_TYPES: Dict[int, str] = {
18: "WS2812 1ch", 19: "WS2812 1ch x3", 20: "WS2812 CCT", 21: "WS2812 WWA",
22: "WS2812B", 23: "GS8608", 24: "WS2811 400kHz", 25: "TM1829",
26: "UCS8903", 27: "APA106", 28: "FW1906", 29: "UCS8904",
30: "SK6812 RGBW", 31: "TM1814", 32: "WS2805", 33: "TM1914", 34: "SM16825",
40: "On/Off", 41: "PWM 1ch", 42: "PWM 2ch", 43: "PWM 3ch",
44: "PWM 4ch", 45: "PWM 5ch", 46: "PWM 6ch",
50: "WS2801", 51: "APA102", 52: "LPD8806", 53: "P9813", 54: "LPD6803",
65: "HUB75 HS", 66: "HUB75 QS",
80: "DDP RGB", 81: "E1.31", 82: "Art-Net", 88: "DDP RGBW", 89: "Art-Net RGBW",
}
@dataclass @dataclass
class ProcessingSettings: class ProcessingSettings:
"""Settings for screen processing.""" """Settings for screen processing."""
display_index: int = 0 display_index: int = 0
fps: int = 30 fps: int = 30
border_width: int = 10
brightness: float = 1.0 brightness: float = 1.0
gamma: float = 2.2 gamma: float = 2.2
saturation: float = 1.0 saturation: float = 1.0
@@ -117,21 +107,6 @@ class ProcessingSettings:
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
@dataclass
class DeviceHealth:
"""Health check result for a WLED device (GET /json/info)."""
online: bool = False
latency_ms: Optional[float] = None
last_checked: Optional[datetime] = None
wled_name: Optional[str] = None
wled_version: Optional[str] = None
wled_led_count: Optional[int] = None
wled_rgbw: Optional[bool] = None
wled_led_type: Optional[str] = None
error: Optional[str] = None
@dataclass @dataclass
class ProcessingMetrics: class ProcessingMetrics:
"""Metrics for processing performance.""" """Metrics for processing performance."""
@@ -150,12 +125,13 @@ class ProcessingMetrics:
@dataclass @dataclass
class DeviceState: class DeviceState:
"""State for a registered WLED device (health monitoring + calibration).""" """State for a registered LED device (health monitoring + calibration)."""
device_id: str device_id: str
device_url: str device_url: str
led_count: int led_count: int
calibration: CalibrationConfig calibration: CalibrationConfig
device_type: str = "wled"
health: DeviceHealth = field(default_factory=DeviceHealth) health: DeviceHealth = field(default_factory=DeviceHealth)
health_task: Optional[asyncio.Task] = None health_task: Optional[asyncio.Task] = None
# Calibration test mode (works independently of target processing) # Calibration test mode (works independently of target processing)
@@ -174,7 +150,7 @@ class TargetState:
settings: ProcessingSettings settings: ProcessingSettings
calibration: CalibrationConfig calibration: CalibrationConfig
picture_source_id: str = "" picture_source_id: str = ""
wled_client: Optional[WLEDClient] = None led_client: Optional[LEDClient] = None
pixel_mapper: Optional[PixelMapper] = None pixel_mapper: Optional[PixelMapper] = None
is_running: bool = False is_running: bool = False
task: Optional[asyncio.Task] = None task: Optional[asyncio.Task] = None
@@ -187,8 +163,8 @@ class TargetState:
resolved_engine_config: Optional[dict] = None resolved_engine_config: Optional[dict] = None
# LiveStream: runtime frame source (shared via LiveStreamManager) # LiveStream: runtime frame source (shared via LiveStreamManager)
live_stream: Optional[LiveStream] = None live_stream: Optional[LiveStream] = None
# WLED state snapshot taken before streaming starts (to restore on stop) # Device state snapshot taken before streaming starts (to restore on stop)
wled_state_before: Optional[dict] = None device_state_before: Optional[dict] = None
@dataclass @dataclass
@@ -245,14 +221,16 @@ class ProcessorManager:
device_url: str, device_url: str,
led_count: int, led_count: int,
calibration: Optional[CalibrationConfig] = None, calibration: Optional[CalibrationConfig] = None,
device_type: str = "wled",
): ):
"""Register a device for health monitoring. """Register a device for health monitoring.
Args: Args:
device_id: Unique device identifier device_id: Unique device identifier
device_url: WLED device URL device_url: Device URL
led_count: Number of LEDs led_count: Number of LEDs
calibration: Calibration config (creates default if None) calibration: Calibration config (creates default if None)
device_type: LED device type (e.g. "wled")
""" """
if device_id in self._devices: if device_id in self._devices:
raise ValueError(f"Device {device_id} already registered") raise ValueError(f"Device {device_id} already registered")
@@ -265,6 +243,7 @@ class ProcessorManager:
device_url=device_url, device_url=device_url,
led_count=led_count, led_count=led_count,
calibration=calibration, calibration=calibration,
device_type=device_type,
) )
self._devices[device_id] = state self._devices[device_id] = state
@@ -356,11 +335,11 @@ class ProcessorManager:
"online": h.online, "online": h.online,
"latency_ms": h.latency_ms, "latency_ms": h.latency_ms,
"last_checked": h.last_checked, "last_checked": h.last_checked,
"wled_name": h.wled_name, "device_name": h.device_name,
"wled_version": h.wled_version, "device_version": h.device_version,
"wled_led_count": h.wled_led_count, "device_led_count": h.device_led_count,
"wled_rgbw": h.wled_rgbw, "device_rgbw": h.device_rgbw,
"wled_led_type": h.wled_led_type, "device_led_type": h.device_led_type,
"error": h.error, "error": h.error,
} }
@@ -373,15 +352,15 @@ class ProcessorManager:
h = ds.health h = ds.health
return { return {
"device_id": device_id, "device_id": device_id,
"wled_online": h.online, "device_online": h.online,
"wled_latency_ms": h.latency_ms, "device_latency_ms": h.latency_ms,
"wled_name": h.wled_name, "device_name": h.device_name,
"wled_version": h.wled_version, "device_version": h.device_version,
"wled_led_count": h.wled_led_count, "device_led_count": h.device_led_count,
"wled_rgbw": h.wled_rgbw, "device_rgbw": h.device_rgbw,
"wled_led_type": h.wled_led_type, "device_led_type": h.device_led_type,
"wled_last_checked": h.last_checked, "device_last_checked": h.last_checked,
"wled_error": h.error, "device_error": h.error,
"test_mode": ds.test_mode_active, "test_mode": ds.test_mode_active,
"test_mode_edges": list(ds.test_mode_edges.keys()), "test_mode_edges": list(ds.test_mode_edges.keys()),
} }
@@ -548,31 +527,22 @@ class ProcessorManager:
# Resolve stream settings # Resolve stream settings
self._resolve_stream_settings(state) self._resolve_stream_settings(state)
# Snapshot WLED state before streaming changes it # Determine device type from device state
try: device_type = "wled"
async with httpx.AsyncClient(timeout=5) as http: if state.device_id in self._devices:
resp = await http.get(f"{state.device_url}/json/state") device_type = self._devices[state.device_id].device_type
resp.raise_for_status()
wled_state = resp.json()
state.wled_state_before = {
"on": wled_state.get("on", True),
"lor": wled_state.get("lor", 0),
}
if "AudioReactive" in wled_state:
state.wled_state_before["AudioReactive"] = wled_state["AudioReactive"]
logger.info(f"Saved WLED state before streaming: {state.wled_state_before}")
except Exception as e:
logger.warning(f"Could not snapshot WLED state: {e}")
state.wled_state_before = None
# Connect to WLED device (always use DDP for low-latency UDP streaming) # Connect to LED device via factory
try: try:
state.wled_client = WLEDClient(state.device_url, use_ddp=True) state.led_client = create_led_client(device_type, state.device_url, use_ddp=True)
await state.wled_client.connect() await state.led_client.connect()
logger.info(f"Target {target_id} using DDP protocol ({state.led_count} LEDs)") logger.info(f"Target {target_id} connected to {device_type} device ({state.led_count} LEDs)")
# Snapshot device state before streaming (type-specific, e.g. WLED saves on/lor/AudioReactive)
state.device_state_before = await state.led_client.snapshot_device_state()
except Exception as e: except Exception as e:
logger.error(f"Failed to connect to WLED device for target {target_id}: {e}") logger.error(f"Failed to connect to LED device for target {target_id}: {e}")
raise RuntimeError(f"Failed to connect to WLED device: {e}") raise RuntimeError(f"Failed to connect to LED device: {e}")
# Acquire live stream via LiveStreamManager (shared across targets) # Acquire live stream via LiveStreamManager (shared across targets)
try: try:
@@ -589,8 +559,8 @@ class ProcessorManager:
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize live stream for target {target_id}: {e}") logger.error(f"Failed to initialize live stream for target {target_id}: {e}")
if state.wled_client: if state.led_client:
await state.wled_client.disconnect() await state.led_client.close()
raise RuntimeError(f"Failed to initialize live stream: {e}") raise RuntimeError(f"Failed to initialize live stream: {e}")
# Initialize pixel mapper # Initialize pixel mapper
@@ -632,23 +602,15 @@ class ProcessorManager:
pass pass
state.task = None state.task = None
# Restore WLED state # Restore device state (type-specific, e.g. WLED restores on/lor/AudioReactive)
if state.wled_state_before: if state.led_client and state.device_state_before:
try: await state.led_client.restore_device_state(state.device_state_before)
async with httpx.AsyncClient(timeout=5) as http: state.device_state_before = None
await http.post(
f"{state.device_url}/json/state",
json=state.wled_state_before,
)
logger.info(f"Restored WLED state: {state.wled_state_before}")
except Exception as e:
logger.warning(f"Could not restore WLED state: {e}")
state.wled_state_before = None
# Close WLED connection # Close LED connection
if state.wled_client: if state.led_client:
await state.wled_client.close() await state.led_client.close()
state.wled_client = None state.led_client = None
# Release live stream # Release live stream
if state.live_stream: if state.live_stream:
@@ -667,8 +629,8 @@ class ProcessorManager:
target_fps = settings.fps target_fps = settings.fps
smoothing = settings.smoothing smoothing = settings.smoothing
border_width = settings.border_width border_width = state.calibration.border_width
wled_brightness = settings.brightness led_brightness = settings.brightness
logger.info( logger.info(
f"Processing loop started for target {target_id} " f"Processing loop started for target {target_id} "
@@ -708,15 +670,15 @@ class ProcessorManager:
# Skip processing + send if the frame hasn't changed # Skip processing + send if the frame hasn't changed
if capture is prev_capture: if capture is prev_capture:
# Keepalive: resend last colors to prevent WLED exiting live mode # Keepalive: resend last colors to prevent device exiting live mode
if state.previous_colors and (loop_start - last_send_time) >= standby_interval: if state.previous_colors and (loop_start - last_send_time) >= standby_interval:
if not state.is_running or state.wled_client is None: if not state.is_running or state.led_client is None:
break break
brightness_value = int(wled_brightness * 255) brightness_value = int(led_brightness * 255)
if state.wled_client.use_ddp: if state.led_client.supports_fast_send:
state.wled_client.send_pixels_fast(state.previous_colors, brightness=brightness_value) state.led_client.send_pixels_fast(state.previous_colors, brightness=brightness_value)
else: else:
await state.wled_client.send_pixels(state.previous_colors, brightness=brightness_value) await state.led_client.send_pixels(state.previous_colors, brightness=brightness_value)
last_send_time = time.time() last_send_time = time.time()
send_timestamps.append(last_send_time) send_timestamps.append(last_send_time)
state.metrics.frames_keepalive += 1 state.metrics.frames_keepalive += 1
@@ -737,14 +699,14 @@ class ProcessorManager:
state.pixel_mapper, state.previous_colors, smoothing, state.pixel_mapper, state.previous_colors, smoothing,
) )
# Send to WLED with device brightness # Send to LED device with brightness
if not state.is_running or state.wled_client is None: if not state.is_running or state.led_client is None:
break break
brightness_value = int(wled_brightness * 255) brightness_value = int(led_brightness * 255)
if state.wled_client.use_ddp: if state.led_client.supports_fast_send:
state.wled_client.send_pixels_fast(led_colors, brightness=brightness_value) state.led_client.send_pixels_fast(led_colors, brightness=brightness_value)
else: else:
await state.wled_client.send_pixels(led_colors, brightness=brightness_value) await state.led_client.send_pixels(led_colors, brightness=brightness_value)
last_send_time = time.time() last_send_time = time.time()
send_timestamps.append(last_send_time) send_timestamps.append(last_send_time)
@@ -802,20 +764,20 @@ class ProcessorManager:
state = self._targets[target_id] state = self._targets[target_id]
metrics = state.metrics metrics = state.metrics
# Include WLED health info from the device # Include device health info
health_info = {} health_info = {}
if state.device_id in self._devices: if state.device_id in self._devices:
h = self._devices[state.device_id].health h = self._devices[state.device_id].health
health_info = { health_info = {
"wled_online": h.online, "device_online": h.online,
"wled_latency_ms": h.latency_ms, "device_latency_ms": h.latency_ms,
"wled_name": h.wled_name, "device_name": h.device_name,
"wled_version": h.wled_version, "device_version": h.device_version,
"wled_led_count": h.wled_led_count, "device_led_count": h.device_led_count,
"wled_rgbw": h.wled_rgbw, "device_rgbw": h.device_rgbw,
"wled_led_type": h.wled_led_type, "device_led_type": h.device_led_type,
"wled_last_checked": h.last_checked, "device_last_checked": h.last_checked,
"wled_error": h.error, "device_error": h.error,
} }
return { return {
@@ -913,38 +875,38 @@ class ProcessorManager:
break break
try: try:
# Check if a target is running for this device (use its WLED client) # Check if a target is running for this device (use its LED client)
active_client = None active_client = None
for ts in self._targets.values(): for ts in self._targets.values():
if ts.device_id == device_id and ts.is_running and ts.wled_client: if ts.device_id == device_id and ts.is_running and ts.led_client:
active_client = ts.wled_client active_client = ts.led_client
break break
if active_client: if active_client:
await active_client.send_pixels(pixels) await active_client.send_pixels(pixels)
else: else:
async with WLEDClient(ds.device_url, use_ddp=True) as wled: async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client:
await wled.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}")
async def _send_clear_pixels(self, device_id: str) -> None: async def _send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to clear WLED output.""" """Send all-black pixels to clear LED output."""
ds = self._devices[device_id] ds = self._devices[device_id]
pixels = [(0, 0, 0)] * ds.led_count pixels = [(0, 0, 0)] * ds.led_count
try: try:
active_client = None active_client = None
for ts in self._targets.values(): for ts in self._targets.values():
if ts.device_id == device_id and ts.is_running and ts.wled_client: if ts.device_id == device_id and ts.is_running and ts.led_client:
active_client = ts.wled_client active_client = ts.led_client
break break
if active_client: if active_client:
await active_client.send_pixels(pixels) await active_client.send_pixels(pixels)
else: else:
async with WLEDClient(ds.device_url, use_ddp=True) as wled: async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client:
await wled.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}")
@@ -1050,58 +1012,14 @@ class ProcessorManager:
logger.error(f"Fatal error in health check loop for {device_id}: {e}") logger.error(f"Fatal error in health check loop for {device_id}: {e}")
async def _check_device_health(self, device_id: str): async def _check_device_health(self, device_id: str):
"""Check device health via GET /json/info.""" """Check device health via the LED client abstraction."""
state = self._devices.get(device_id) state = self._devices.get(device_id)
if not state: if not state:
return return
url = state.device_url.rstrip("/") client = await self._get_http_client()
start = time.time() state.health = await check_device_health(
try: state.device_type, state.device_url, client, state.health,
client = await self._get_http_client() )
response = await client.get(f"{url}/json/info")
response.raise_for_status()
data = response.json()
latency = (time.time() - start) * 1000
leds_info = data.get("leds", {})
wled_led_count = leds_info.get("count")
# Fetch LED type from /json/cfg once (it's static config)
wled_led_type = state.health.wled_led_type
if wled_led_type is None:
try:
cfg = await client.get(f"{url}/json/cfg")
cfg.raise_for_status()
cfg_data = cfg.json()
ins = cfg_data.get("hw", {}).get("led", {}).get("ins", [])
if ins:
type_code = ins[0].get("type", 0)
wled_led_type = WLED_LED_TYPES.get(type_code, f"Type {type_code}")
except Exception as cfg_err:
logger.debug(f"Could not fetch LED type for {device_id}: {cfg_err}")
state.health = DeviceHealth(
online=True,
latency_ms=round(latency, 1),
last_checked=datetime.utcnow(),
wled_name=data.get("name"),
wled_version=data.get("ver"),
wled_led_count=wled_led_count,
wled_rgbw=leds_info.get("rgbw", False),
wled_led_type=wled_led_type,
error=None,
)
except Exception as e:
state.health = DeviceHealth(
online=False,
latency_ms=None,
last_checked=datetime.utcnow(),
wled_name=state.health.wled_name,
wled_version=state.health.wled_version,
wled_led_count=state.health.wled_led_count,
wled_rgbw=state.health.wled_rgbw,
wled_led_type=state.health.wled_led_type,
error=str(e),
)
# ===== KEY COLORS TARGET MANAGEMENT ===== # ===== KEY COLORS TARGET MANAGEMENT =====

View File

@@ -1,7 +1,9 @@
"""WLED client for controlling LED devices via HTTP or DDP.""" """WLED client for controlling LED devices via HTTP or DDP."""
import asyncio import asyncio
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Tuple, Optional, Dict, Any from typing import List, Tuple, Optional, Dict, Any
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -10,9 +12,23 @@ import numpy as np
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.core.ddp_client import BusConfig, DDPClient from wled_controller.core.ddp_client import BusConfig, DDPClient
from wled_controller.core.led_client import DeviceHealth, LEDClient
logger = get_logger(__name__) logger = get_logger(__name__)
# WLED LED bus type codes from const.h → human-readable names
WLED_LED_TYPES: Dict[int, str] = {
18: "WS2812 1ch", 19: "WS2812 1ch x3", 20: "WS2812 CCT", 21: "WS2812 WWA",
22: "WS2812B", 23: "GS8608", 24: "WS2811 400kHz", 25: "TM1829",
26: "UCS8903", 27: "APA106", 28: "FW1906", 29: "UCS8904",
30: "SK6812 RGBW", 31: "TM1814", 32: "WS2805", 33: "TM1914", 34: "SM16825",
40: "On/Off", 41: "PWM 1ch", 42: "PWM 2ch", 43: "PWM 3ch",
44: "PWM 4ch", 45: "PWM 5ch", 46: "PWM 6ch",
50: "WS2801", 51: "APA102", 52: "LPD8806", 53: "P9813", 54: "LPD6803",
65: "HUB75 HS", 66: "HUB75 QS",
80: "DDP RGB", 81: "E1.31", 82: "Art-Net", 88: "DDP RGBW", 89: "Art-Net RGBW",
}
@dataclass @dataclass
class WLEDInfo: class WLEDInfo:
@@ -30,7 +46,7 @@ class WLEDInfo:
buses: List[BusConfig] = field(default_factory=list) # Per-bus/GPIO config buses: List[BusConfig] = field(default_factory=list) # Per-bus/GPIO config
class WLEDClient: class WLEDClient(LEDClient):
"""Client for WLED devices supporting both HTTP and DDP protocols.""" """Client for WLED devices supporting both HTTP and DDP protocols."""
# HTTP JSON API has ~10KB limit, ~500 LEDs max # HTTP JSON API has ~10KB limit, ~500 LEDs max
@@ -67,15 +83,6 @@ class WLEDClient:
self._ddp_client: Optional[DDPClient] = None self._ddp_client: Optional[DDPClient] = None
self._connected = False self._connected = False
async def __aenter__(self):
"""Async context manager entry."""
await self.connect()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def connect(self) -> bool: async def connect(self) -> bool:
"""Establish connection to WLED device. """Establish connection to WLED device.
@@ -161,6 +168,11 @@ class WLEDClient:
"""Check if connected to WLED device.""" """Check if connected to WLED device."""
return self._connected and self._client is not None return self._connected and self._client is not None
@property
def supports_fast_send(self) -> bool:
"""True when DDP is active and ready for fire-and-forget sends."""
return self.use_ddp and self._ddp_client is not None
async def _request( async def _request(
self, self,
method: str, method: str,
@@ -445,6 +457,96 @@ class WLEDClient:
self._ddp_client.send_pixels_numpy(pixel_array) self._ddp_client.send_pixels_numpy(pixel_array)
# ===== LEDClient abstraction methods =====
async def snapshot_device_state(self) -> Optional[dict]:
"""Snapshot WLED state before streaming (on, lor, AudioReactive)."""
if not self._client:
return None
try:
resp = await self._client.get(f"{self.url}/json/state")
resp.raise_for_status()
wled_state = resp.json()
state = {
"on": wled_state.get("on", True),
"lor": wled_state.get("lor", 0),
}
if "AudioReactive" in wled_state:
state["AudioReactive"] = wled_state["AudioReactive"]
logger.info(f"Saved WLED state before streaming: {state}")
return state
except Exception as e:
logger.warning(f"Could not snapshot WLED state: {e}")
return None
async def restore_device_state(self, state: Optional[dict]) -> None:
"""Restore WLED state after streaming."""
if not state:
return
try:
async with httpx.AsyncClient(timeout=5) as http:
await http.post(f"{self.url}/json/state", json=state)
logger.info(f"Restored WLED state: {state}")
except Exception as e:
logger.warning(f"Could not restore WLED state: {e}")
@classmethod
async def check_health(
cls,
url: str,
http_client,
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""WLED health check via GET /json/info (+ /json/cfg for LED type)."""
url = url.rstrip("/")
start = time.time()
try:
response = await http_client.get(f"{url}/json/info")
response.raise_for_status()
data = response.json()
latency = (time.time() - start) * 1000
leds_info = data.get("leds", {})
# Fetch LED type from /json/cfg once (it's static config)
device_led_type = prev_health.device_led_type if prev_health else None
if device_led_type is None:
try:
cfg = await http_client.get(f"{url}/json/cfg")
cfg.raise_for_status()
cfg_data = cfg.json()
ins = cfg_data.get("hw", {}).get("led", {}).get("ins", [])
if ins:
type_code = ins[0].get("type", 0)
device_led_type = WLED_LED_TYPES.get(type_code, f"Type {type_code}")
except Exception as cfg_err:
logger.debug(f"Could not fetch LED type: {cfg_err}")
return DeviceHealth(
online=True,
latency_ms=round(latency, 1),
last_checked=datetime.utcnow(),
device_name=data.get("name"),
device_version=data.get("ver"),
device_led_count=leds_info.get("count"),
device_rgbw=leds_info.get("rgbw", False),
device_led_type=device_led_type,
error=None,
)
except Exception as e:
return DeviceHealth(
online=False,
latency_ms=None,
last_checked=datetime.utcnow(),
device_name=prev_health.device_name if prev_health else None,
device_version=prev_health.device_version if prev_health else None,
device_led_count=prev_health.device_led_count if prev_health else None,
device_rgbw=prev_health.device_rgbw if prev_health else None,
device_led_type=prev_health.device_led_type if prev_health else None,
error=str(e),
)
# ===== WLED-specific methods =====
async def set_power(self, on: bool) -> bool: async def set_power(self, on: bool) -> bool:
"""Turn WLED device on or off. """Turn WLED device on or off.

View File

@@ -154,6 +154,7 @@ async def lifespan(app: FastAPI):
device_url=device.url, device_url=device.url,
led_count=device.led_count, led_count=device.led_count,
calibration=device.calibration, calibration=device.calibration,
device_type=device.device_type,
) )
logger.info(f"Registered device: {device.name} ({device.id})") logger.info(f"Registered device: {device.name} ({device.id})")
except Exception as e: except Exception as e:

View File

@@ -614,33 +614,33 @@ async function loadDevices() {
function createDeviceCard(device) { function createDeviceCard(device) {
const state = device.state || {}; const state = device.state || {};
// WLED device health indicator // Device health indicator
const wledOnline = state.wled_online || false; const devOnline = state.device_online || false;
const wledLatency = state.wled_latency_ms; const devLatency = state.device_latency_ms;
const wledName = state.wled_name; const devName = state.device_name;
const wledVersion = state.wled_version; const devVersion = state.device_version;
const wledLastChecked = state.wled_last_checked; const devLastChecked = state.device_last_checked;
let healthClass, healthTitle, healthLabel; let healthClass, healthTitle, healthLabel;
if (wledLastChecked === null || wledLastChecked === undefined) { if (devLastChecked === null || devLastChecked === undefined) {
healthClass = 'health-unknown'; healthClass = 'health-unknown';
healthTitle = t('device.health.checking'); healthTitle = t('device.health.checking');
healthLabel = ''; healthLabel = '';
} else if (wledOnline) { } else if (devOnline) {
healthClass = 'health-online'; healthClass = 'health-online';
healthTitle = `${t('device.health.online')}`; healthTitle = `${t('device.health.online')}`;
if (wledName) healthTitle += ` - ${wledName}`; if (devName) healthTitle += ` - ${devName}`;
if (wledVersion) healthTitle += ` v${wledVersion}`; if (devVersion) healthTitle += ` v${devVersion}`;
if (wledLatency !== null && wledLatency !== undefined) healthTitle += ` (${Math.round(wledLatency)}ms)`; if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
healthLabel = ''; healthLabel = '';
} else { } else {
healthClass = 'health-offline'; healthClass = 'health-offline';
healthTitle = t('device.health.offline'); healthTitle = t('device.health.offline');
if (state.wled_error) healthTitle += `: ${state.wled_error}`; if (state.device_error) healthTitle += `: ${state.device_error}`;
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`; healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
} }
const ledCount = state.wled_led_count || device.led_count; const ledCount = state.device_led_count || device.led_count;
return ` return `
<div class="card" data-device-id="${device.id}"> <div class="card" data-device-id="${device.id}">
@@ -654,9 +654,10 @@ function createDeviceCard(device) {
</div> </div>
</div> </div>
<div class="card-subtitle"> <div class="card-subtitle">
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''} ${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
${state.wled_led_type ? `<span class="card-meta">🔌 ${state.wled_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.wled_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.wled_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>
<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"
@@ -903,7 +904,8 @@ async function handleAddDevice(event) {
} }
try { try {
const body = { name, url }; const deviceType = document.getElementById('device-type')?.value || 'wled';
const body = { name, url, device_type: deviceType };
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
if (lastTemplateId) { if (lastTemplateId) {
body.capture_template_id = lastTemplateId; body.capture_template_id = lastTemplateId;
@@ -1060,6 +1062,10 @@ async function showCalibration(deviceId) {
// Set skip LEDs // Set skip LEDs
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0; document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0; document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
updateOffsetSkipLock();
// Set border width
document.getElementById('cal-border-width').value = calibration.border_width || 10;
// Initialize edge spans // Initialize edge spans
window.edgeSpans = { window.edgeSpans = {
@@ -1081,6 +1087,7 @@ async function showCalibration(deviceId) {
spans: JSON.stringify(window.edgeSpans), spans: JSON.stringify(window.edgeSpans),
skip_start: String(calibration.skip_leds_start || 0), skip_start: String(calibration.skip_leds_start || 0),
skip_end: String(calibration.skip_leds_end || 0), skip_end: String(calibration.skip_leds_end || 0),
border_width: String(calibration.border_width || 10),
}; };
// Initialize test mode state for this device // Initialize test mode state for this device
@@ -1131,7 +1138,8 @@ function isCalibrationDirty() {
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left || document.getElementById('cal-left-leds').value !== calibrationInitialValues.left ||
JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans || JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans ||
document.getElementById('cal-skip-start').value !== calibrationInitialValues.skip_start || document.getElementById('cal-skip-start').value !== calibrationInitialValues.skip_start ||
document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end ||
document.getElementById('cal-border-width').value !== calibrationInitialValues.border_width
); );
} }
@@ -1160,6 +1168,17 @@ async function closeCalibrationModal() {
forceCloseCalibrationModal(); forceCloseCalibrationModal();
} }
function updateOffsetSkipLock() {
const offsetEl = document.getElementById('cal-offset');
const skipStartEl = document.getElementById('cal-skip-start');
const skipEndEl = document.getElementById('cal-skip-end');
const hasOffset = parseInt(offsetEl.value || 0) > 0;
const hasSkip = parseInt(skipStartEl.value || 0) > 0 || parseInt(skipEndEl.value || 0) > 0;
skipStartEl.disabled = hasOffset;
skipEndEl.disabled = hasOffset;
offsetEl.disabled = hasSkip;
}
function updateCalibrationPreview() { function updateCalibrationPreview() {
// Calculate total from edge inputs // Calculate total from edge inputs
const total = parseInt(document.getElementById('cal-top-leds').value || 0) + const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
@@ -1774,6 +1793,7 @@ async function saveCalibration() {
span_left_end: spans.left?.end ?? 1, span_left_end: spans.left?.end ?? 1,
skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0), skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0),
skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0), skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0),
border_width: parseInt(document.getElementById('cal-border-width').value) || 10,
}; };
try { try {
@@ -1935,11 +1955,13 @@ const calibrationTutorialSteps = [
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' }, { selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' }, { selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
{ selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' }, { selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
{ selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
{ selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' }, { selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
{ selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' }, { selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' }, { selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' },
{ selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds', position: 'top' } { selector: '.preview-screen-border-width', textKey: 'calibration.tip.border_width', position: 'bottom' },
{ selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
{ selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds_start', position: 'top' },
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
]; ];
const deviceTutorialSteps = [ const deviceTutorialSteps = [
@@ -3820,13 +3842,14 @@ async function showTargetEditor(targetId = null) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = d.id; opt.value = d.id;
const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : ''; const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
opt.textContent = `${d.name}${shortUrl ? ' (' + shortUrl + ')' : ''}`; const devType = (d.device_type || 'wled').toUpperCase();
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
deviceSelect.appendChild(opt); deviceSelect.appendChild(opt);
}); });
// Populate source select // Populate source select
const sourceSelect = document.getElementById('target-editor-source'); const sourceSelect = document.getElementById('target-editor-source');
sourceSelect.innerHTML = '<option value="">-- No source --</option>'; sourceSelect.innerHTML = '';
sources.forEach(s => { sources.forEach(s => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = s.id; opt.value = s.id;
@@ -3847,7 +3870,6 @@ async function showTargetEditor(targetId = null) {
sourceSelect.value = target.picture_source_id || ''; sourceSelect.value = target.picture_source_id || '';
document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30; document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30; document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
document.getElementById('target-editor-border-width').value = target.settings?.border_width ?? 10;
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average'; document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3; document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3; document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
@@ -3862,7 +3884,6 @@ async function showTargetEditor(targetId = null) {
sourceSelect.value = ''; sourceSelect.value = '';
document.getElementById('target-editor-fps').value = 30; document.getElementById('target-editor-fps').value = 30;
document.getElementById('target-editor-fps-value').textContent = '30'; document.getElementById('target-editor-fps-value').textContent = '30';
document.getElementById('target-editor-border-width').value = 10;
document.getElementById('target-editor-interpolation').value = 'average'; document.getElementById('target-editor-interpolation').value = 'average';
document.getElementById('target-editor-smoothing').value = 0.3; document.getElementById('target-editor-smoothing').value = 0.3;
document.getElementById('target-editor-smoothing-value').textContent = '0.3'; document.getElementById('target-editor-smoothing-value').textContent = '0.3';
@@ -3876,7 +3897,6 @@ async function showTargetEditor(targetId = null) {
device: deviceSelect.value, device: deviceSelect.value,
source: sourceSelect.value, source: sourceSelect.value,
fps: document.getElementById('target-editor-fps').value, fps: document.getElementById('target-editor-fps').value,
border_width: document.getElementById('target-editor-border-width').value,
interpolation: document.getElementById('target-editor-interpolation').value, interpolation: document.getElementById('target-editor-interpolation').value,
smoothing: document.getElementById('target-editor-smoothing').value, smoothing: document.getElementById('target-editor-smoothing').value,
standby_interval: document.getElementById('target-editor-standby-interval').value, standby_interval: document.getElementById('target-editor-standby-interval').value,
@@ -3901,7 +3921,6 @@ function isTargetEditorDirty() {
document.getElementById('target-editor-device').value !== targetEditorInitialValues.device || document.getElementById('target-editor-device').value !== targetEditorInitialValues.device ||
document.getElementById('target-editor-source').value !== targetEditorInitialValues.source || document.getElementById('target-editor-source').value !== targetEditorInitialValues.source ||
document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps || document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps ||
document.getElementById('target-editor-border-width').value !== targetEditorInitialValues.border_width ||
document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation || document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation ||
document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing || document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing ||
document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval
@@ -3929,7 +3948,6 @@ async function saveTargetEditor() {
const deviceId = document.getElementById('target-editor-device').value; const deviceId = document.getElementById('target-editor-device').value;
const sourceId = document.getElementById('target-editor-source').value; const sourceId = document.getElementById('target-editor-source').value;
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30; const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
const borderWidth = parseInt(document.getElementById('target-editor-border-width').value) || 10;
const interpolation = document.getElementById('target-editor-interpolation').value; const interpolation = document.getElementById('target-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value); const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value); const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
@@ -3947,7 +3965,6 @@ async function saveTargetEditor() {
picture_source_id: sourceId, picture_source_id: sourceId,
settings: { settings: {
fps: fps, fps: fps,
border_width: borderWidth,
interpolation_mode: interpolation, interpolation_mode: interpolation,
smoothing: smoothing, smoothing: smoothing,
standby_interval: standbyInterval, standby_interval: standbyInterval,
@@ -3963,7 +3980,7 @@ async function saveTargetEditor() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} else { } else {
payload.target_type = 'wled'; payload.target_type = 'led';
response = await fetch(`${API_BASE}/picture-targets`, { response = await fetch(`${API_BASE}/picture-targets`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
@@ -4083,14 +4100,16 @@ async function loadTargetsTab() {
devicesWithState.forEach(d => { deviceMap[d.id] = d; }); devicesWithState.forEach(d => { deviceMap[d.id] = d; });
// Group by type // Group by type
const wledDevices = devicesWithState; const ledDevices = devicesWithState;
const wledTargets = targetsWithState.filter(t => t.target_type === 'wled'); const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors'); const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled'; // Backward compat: map stored "wled" sub-tab to "led"
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
if (activeSubTab === 'wled') activeSubTab = 'led';
const subTabs = [ const subTabs = [
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length }, { key: 'led', icon: '💡', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length },
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length }, { key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
]; ];
@@ -4098,13 +4117,13 @@ async function loadTargetsTab() {
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>` `<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
).join('')}</div>`; ).join('')}</div>`;
// WLED panel: devices section + targets section // LED panel: devices section + targets section
const wledPanel = ` const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'wled' ? ' active' : ''}" id="target-sub-tab-wled"> <div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
<div class="subtab-section"> <div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3> <h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
<div class="devices-grid"> <div class="devices-grid">
${wledDevices.map(device => createDeviceCard(device)).join('')} ${ledDevices.map(device => createDeviceCard(device)).join('')}
<div class="template-card add-template-card" onclick="showAddDevice()"> <div class="template-card add-template-card" onclick="showAddDevice()">
<div class="add-template-icon">+</div> <div class="add-template-icon">+</div>
</div> </div>
@@ -4113,7 +4132,7 @@ async function loadTargetsTab() {
<div class="subtab-section"> <div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3> <h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
<div class="devices-grid"> <div class="devices-grid">
${wledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')} ${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
<div class="template-card add-template-card" onclick="showTargetEditor()"> <div class="template-card add-template-card" onclick="showTargetEditor()">
<div class="add-template-icon">+</div> <div class="add-template-icon">+</div>
</div> </div>
@@ -4144,9 +4163,9 @@ async function loadTargetsTab() {
</div> </div>
</div>`; </div>`;
container.innerHTML = tabBar + wledPanel + kcPanel; container.innerHTML = tabBar + ledPanel + kcPanel;
// Attach event listeners and fetch WLED 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); fetchDeviceBrightness(device.id);
@@ -4186,12 +4205,12 @@ function createTargetCard(target, deviceMap, sourceMap) {
const sourceName = source ? source.name : (target.picture_source_id || 'No source'); const sourceName = source ? source.name : (target.picture_source_id || 'No source');
// Health info from target state (forwarded from device) // Health info from target state (forwarded from device)
const wledOnline = state.wled_online || false; const devOnline = state.device_online || false;
let healthClass = 'health-unknown'; let healthClass = 'health-unknown';
let healthTitle = ''; let healthTitle = '';
if (state.wled_last_checked !== null && state.wled_last_checked !== undefined) { if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
healthClass = wledOnline ? 'health-online' : 'health-offline'; healthClass = devOnline ? 'health-online' : 'health-offline';
healthTitle = wledOnline ? t('device.health.online') : t('device.health.offline'); healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
} }
return ` return `

View File

@@ -95,6 +95,10 @@
<span id="direction-icon"></span> <span id="direction-label">CW</span> <span id="direction-icon"></span> <span id="direction-label">CW</span>
</button> </button>
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div> <div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
<div class="preview-screen-border-width">
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label>
<input type="number" id="cal-border-width" min="1" max="100" value="10">
</div>
</div> </div>
<!-- Edge bars with span controls and LED count inputs --> <!-- Edge bars with span controls and LED count inputs -->
@@ -171,7 +175,7 @@
<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="calibration.offset.hint">Distance from physical LED 0 to the start corner (along strip direction)</small> <small class="input-hint" style="display:none" data-i18n="calibration.offset.hint">Distance from physical LED 0 to the start corner (along strip direction)</small>
<input type="number" id="cal-offset" min="0" value="0" oninput="updateCalibrationPreview()"> <input type="number" id="cal-offset" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -179,7 +183,7 @@
<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="calibration.skip_start.hint">Number of LEDs to turn off at the beginning of the strip (0 = none)</small> <small class="input-hint" style="display:none" data-i18n="calibration.skip_start.hint">Number of LEDs to turn off at the beginning of the strip (0 = none)</small>
<input type="number" id="cal-skip-start" min="0" value="0" oninput="updateCalibrationPreview()"> <input type="number" id="cal-skip-start" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -187,7 +191,7 @@
<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="calibration.skip_end.hint">Number of LEDs to turn off at the end of the strip (0 = none)</small> <small class="input-hint" style="display:none" data-i18n="calibration.skip_end.hint">Number of LEDs to turn off at the end of the strip (0 = none)</small>
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateCalibrationPreview()"> <input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div> </div>
</div> </div>
@@ -235,10 +239,10 @@
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="settings-device-url" data-i18n="device.url">WLED 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 your WLED 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="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div> </div>
@@ -247,7 +251,7 @@
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label> <label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</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.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small> <small class="input-hint" style="display:none" data-i18n="settings.health_interval.hint">How often to check the device status (5-600 seconds)</small>
<input type="number" id="settings-health-interval" min="5" max="600" value="30"> <input type="number" id="settings-health-interval" min="5" max="600" value="30">
</div> </div>
@@ -279,10 +283,10 @@
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-device" data-i18n="targets.device">WLED Device:</label> <label for="target-editor-device" data-i18n="targets.device">Device:</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="targets.device.hint">Select the WLED device to stream to</small> <small class="input-hint" style="display:none" data-i18n="targets.device.hint">Select the LED device to send data to</small>
<select id="target-editor-device"></select> <select id="target-editor-device"></select>
</div> </div>
@@ -307,15 +311,6 @@
</div> </div>
</div> </div>
<div class="form-group">
<div class="label-row">
<label for="target-editor-border-width" data-i18n="targets.border_width">Border Width (px):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.border_width.hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
<input type="number" id="target-editor-border-width" min="1" max="100" value="10">
</div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-interpolation" data-i18n="targets.interpolation">Interpolation Mode:</label> <label for="target-editor-interpolation" data-i18n="targets.interpolation">Interpolation Mode:</label>
@@ -349,7 +344,7 @@
</label> </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="targets.standby_interval.hint">How often to resend the last frame when the screen is static, to keep WLED in live mode (0.5-5.0s)</small> <small class="input-hint" style="display:none" data-i18n="targets.standby_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
<input type="range" id="target-editor-standby-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-standby-interval-value').textContent = this.value"> <input type="range" id="target-editor-standby-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-standby-interval-value').textContent = this.value">
</div> </div>
@@ -516,7 +511,7 @@
<div id="api-key-modal" class="modal"> <div id="api-key-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 data-i18n="auth.title">🔑 Login to WLED Controller</h2> <h2 data-i18n="auth.title">🔑 Login to LED Grab</h2>
<button class="modal-close-btn" id="modal-close-x-btn" onclick="closeApiKeyModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" id="modal-close-x-btn" onclick="closeApiKeyModal()" title="Close">&#x2715;</button>
</div> </div>
<form id="api-key-form" onsubmit="submitApiKey(event)"> <form id="api-key-form" onsubmit="submitApiKey(event)">
@@ -579,12 +574,26 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="add-device-form"> <form id="add-device-form">
<div class="form-group">
<div class="label-row">
<label for="device-type" data-i18n="device.type">Device Type:</label>
<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">
<option value="wled">WLED</option>
</select>
</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">
<label for="device-url" data-i18n="device.url">WLED URL:</label> <div class="label-row">
<label for="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="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="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</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>

View File

@@ -7,7 +7,7 @@
"auth.login": "Login", "auth.login": "Login",
"auth.logout": "Logout", "auth.logout": "Logout",
"auth.authenticated": "● Authenticated", "auth.authenticated": "● Authenticated",
"auth.title": "Login to WLED Controller", "auth.title": "Login to LED Grab",
"auth.message": "Please enter your API key to authenticate and access the LED Grab.", "auth.message": "Please enter your API key to authenticate and access the LED Grab.",
"auth.label": "API Key:", "auth.label": "API Key:",
"auth.placeholder": "Enter your API key...", "auth.placeholder": "Enter your API key...",
@@ -101,13 +101,16 @@
"devices.wled_webui_link": "WLED Web UI", "devices.wled_webui_link": "WLED Web UI",
"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.type": "Device Type:",
"device.type.hint": "Select the type of LED controller",
"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",
"device.url": "URL:", "device.url": "URL:",
"device.url.placeholder": "http://192.168.1.100", "device.url.placeholder": "http://192.168.1.100",
"device.led_count": "LED Count:", "device.led_count": "LED Count:",
"device.led_count.hint": "Number of LEDs configured in your WLED device", "device.led_count.hint": "Number of LEDs configured in the device",
"device.led_count.hint.auto": "Auto-detected from WLED device", "device.led_count.hint.auto": "Auto-detected from device",
"device.button.add": "Add Device", "device.button.add": "Add Device",
"device.button.start": "Start", "device.button.start": "Start",
"device.button.stop": "Stop", "device.button.stop": "Stop",
@@ -115,7 +118,7 @@
"device.button.capture_settings": "Capture Settings", "device.button.capture_settings": "Capture Settings",
"device.button.calibrate": "Calibrate", "device.button.calibrate": "Calibrate",
"device.button.remove": "Remove", "device.button.remove": "Remove",
"device.button.webui": "Open WLED Web UI", "device.button.webui": "Open Device Web UI",
"device.status.connected": "Connected", "device.status.connected": "Connected",
"device.status.disconnected": "Disconnected", "device.status.disconnected": "Disconnected",
"device.status.error": "Error", "device.status.error": "Error",
@@ -136,26 +139,26 @@
"device.metrics.frames_skipped": "Skipped", "device.metrics.frames_skipped": "Skipped",
"device.metrics.keepalive": "Keepalive", "device.metrics.keepalive": "Keepalive",
"device.metrics.errors": "Errors", "device.metrics.errors": "Errors",
"device.health.online": "WLED Online", "device.health.online": "Online",
"device.health.offline": "WLED Offline", "device.health.offline": "Offline",
"device.health.checking": "Checking...", "device.health.checking": "Checking...",
"device.tutorial.start": "Start tutorial", "device.tutorial.start": "Start tutorial",
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from WLED", "device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
"device.tip.brightness": "Slide to adjust device brightness", "device.tip.brightness": "Slide to adjust device brightness",
"device.tip.start": "Start or stop screen capture processing", "device.tip.start": "Start or stop screen capture processing",
"device.tip.settings": "Configure general device settings (name, URL, health check)", "device.tip.settings": "Configure general device settings (name, URL, health check)",
"device.tip.capture_settings": "Configure capture settings (display, capture template)", "device.tip.capture_settings": "Configure capture settings (display, capture template)",
"device.tip.calibrate": "Calibrate LED positions, direction, and coverage", "device.tip.calibrate": "Calibrate LED positions, direction, and coverage",
"device.tip.webui": "Open WLED's built-in web interface for advanced configuration", "device.tip.webui": "Open the device's built-in web interface for advanced configuration",
"device.tip.add": "Click here to add a new WLED device", "device.tip.add": "Click here to add a new LED device",
"settings.title": "Device Settings", "settings.title": "Device Settings",
"settings.general.title": "General Settings", "settings.general.title": "General Settings",
"settings.capture.title": "Capture Settings", "settings.capture.title": "Capture Settings",
"settings.capture.saved": "Capture settings updated", "settings.capture.saved": "Capture settings updated",
"settings.capture.failed": "Failed to save capture settings", "settings.capture.failed": "Failed to save capture settings",
"settings.brightness": "Brightness:", "settings.brightness": "Brightness:",
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)", "settings.brightness.hint": "Global brightness for this device (0-100%)",
"settings.url.hint": "IP address or hostname of your WLED device", "settings.url.hint": "IP address or hostname of the device",
"settings.display_index": "Display:", "settings.display_index": "Display:",
"settings.display_index.hint": "Which screen to capture for this device", "settings.display_index.hint": "Which screen to capture for this device",
"settings.fps": "Target FPS:", "settings.fps": "Target FPS:",
@@ -164,7 +167,7 @@
"settings.capture_template.hint": "Screen capture engine and configuration for this device", "settings.capture_template.hint": "Screen capture engine and configuration for this device",
"settings.button.cancel": "Cancel", "settings.button.cancel": "Cancel",
"settings.health_interval": "Health Check Interval (s):", "settings.health_interval": "Health Check Interval (s):",
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)", "settings.health_interval.hint": "How often to check the device status (5-600 seconds)",
"settings.button.save": "Save Changes", "settings.button.save": "Save Changes",
"settings.saved": "Settings saved successfully", "settings.saved": "Settings saved successfully",
"settings.failed": "Failed to save settings", "settings.failed": "Failed to save settings",
@@ -176,7 +179,9 @@
"calibration.tip.span": "Drag green bars to adjust coverage span", "calibration.tip.span": "Drag green bars to adjust coverage span",
"calibration.tip.test": "Click an edge to toggle test LEDs", "calibration.tip.test": "Click an edge to toggle test LEDs",
"calibration.tip.toggle_inputs": "Click total LED count to toggle edge inputs", "calibration.tip.toggle_inputs": "Click total LED count to toggle edge inputs",
"calibration.tip.skip_leds": "Skip LEDs at the start or end of the strip — skipped LEDs stay off", "calibration.tip.border_width": "How many pixels from the screen edge to sample for LED colors",
"calibration.tip.skip_leds_start": "Skip LEDs at the start of the strip — skipped LEDs stay off",
"calibration.tip.skip_leds_end": "Skip LEDs at the end of the strip — skipped LEDs stay off",
"calibration.tutorial.start": "Start tutorial", "calibration.tutorial.start": "Start tutorial",
"calibration.start_position": "Starting Position:", "calibration.start_position": "Starting Position:",
"calibration.position.bottom_left": "Bottom Left", "calibration.position.bottom_left": "Bottom Left",
@@ -196,6 +201,8 @@
"calibration.skip_start.hint": "Number of LEDs to turn off at the beginning of the strip (0 = none)", "calibration.skip_start.hint": "Number of LEDs to turn off at the beginning of the strip (0 = none)",
"calibration.skip_end": "Skip LEDs (End):", "calibration.skip_end": "Skip LEDs (End):",
"calibration.skip_end.hint": "Number of LEDs to turn off at the end of the strip (0 = none)", "calibration.skip_end.hint": "Number of LEDs to turn off at the end of the strip (0 = none)",
"calibration.border_width": "Border (px):",
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
"calibration.button.cancel": "Cancel", "calibration.button.cancel": "Cancel",
"calibration.button.save": "Save", "calibration.button.save": "Save",
"calibration.saved": "Calibration saved successfully", "calibration.saved": "Calibration saved successfully",
@@ -313,7 +320,8 @@
"streams.validate_image.invalid": "Image not accessible", "streams.validate_image.invalid": "Image not accessible",
"targets.title": "⚡ Targets", "targets.title": "⚡ Targets",
"targets.description": "Targets bridge picture sources to output devices. Each target references a device and a source, with its own processing settings.", "targets.description": "Targets bridge picture sources to output devices. Each target references a device and a source, with its own processing settings.",
"targets.subtab.wled": "WLED", "targets.subtab.wled": "LED",
"targets.subtab.led": "LED",
"targets.section.devices": "💡 Devices", "targets.section.devices": "💡 Devices",
"targets.section.targets": "⚡ Targets", "targets.section.targets": "⚡ Targets",
"targets.add": "Add Target", "targets.add": "Add Target",
@@ -324,7 +332,7 @@
"targets.name": "Target Name:", "targets.name": "Target Name:",
"targets.name.placeholder": "My Target", "targets.name.placeholder": "My Target",
"targets.device": "Device:", "targets.device": "Device:",
"targets.device.hint": "Which WLED device to send LED data to", "targets.device.hint": "Select the LED device to send data to",
"targets.device.none": "-- Select a device --", "targets.device.none": "-- Select a device --",
"targets.source": "Source:", "targets.source": "Source:",
"targets.source.hint": "Which picture source to capture and process", "targets.source.hint": "Which picture source to capture and process",
@@ -341,7 +349,7 @@
"targets.smoothing": "Smoothing:", "targets.smoothing": "Smoothing:",
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", "targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
"targets.standby_interval": "Standby Interval:", "targets.standby_interval": "Standby Interval:",
"targets.standby_interval.hint": "How often to resend the last frame when the screen is static, keeping WLED in live mode (0.5-5.0s)", "targets.standby_interval.hint": "How often to resend the last frame when the screen is static, keeping the device in live mode (0.5-5.0s)",
"targets.created": "Target created successfully", "targets.created": "Target created successfully",
"targets.updated": "Target updated successfully", "targets.updated": "Target updated successfully",
"targets.deleted": "Target deleted successfully", "targets.deleted": "Target deleted successfully",

View File

@@ -101,13 +101,16 @@
"devices.wled_webui_link": "веб-интерфейс WLED", "devices.wled_webui_link": "веб-интерфейс WLED",
"devices.wled_note_webui": "(откройте IP устройства в браузере).", "devices.wled_note_webui": "(откройте IP устройства в браузере).",
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.", "devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
"device.type": "Тип устройства:",
"device.type.hint": "Выберите тип LED контроллера",
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
"device.name": "Имя Устройства:", "device.name": "Имя Устройства:",
"device.name.placeholder": "ТВ в Гостиной", "device.name.placeholder": "ТВ в Гостиной",
"device.url": "URL:", "device.url": "URL:",
"device.url.placeholder": "http://192.168.1.100", "device.url.placeholder": "http://192.168.1.100",
"device.led_count": "Количество Светодиодов:", "device.led_count": "Количество Светодиодов:",
"device.led_count.hint": "Количество светодиодов, настроенных в вашем WLED устройстве", "device.led_count.hint": "Количество светодиодов, настроенных в устройстве",
"device.led_count.hint.auto": "Автоматически определяется из WLED устройства", "device.led_count.hint.auto": "Автоматически определяется из устройства",
"device.button.add": "Добавить Устройство", "device.button.add": "Добавить Устройство",
"device.button.start": "Запустить", "device.button.start": "Запустить",
"device.button.stop": "Остановить", "device.button.stop": "Остановить",
@@ -115,7 +118,7 @@
"device.button.capture_settings": "Настройки захвата", "device.button.capture_settings": "Настройки захвата",
"device.button.calibrate": "Калибровка", "device.button.calibrate": "Калибровка",
"device.button.remove": "Удалить", "device.button.remove": "Удалить",
"device.button.webui": "Открыть веб-интерфейс WLED", "device.button.webui": "Открыть веб-интерфейс устройства",
"device.status.connected": "Подключено", "device.status.connected": "Подключено",
"device.status.disconnected": "Отключено", "device.status.disconnected": "Отключено",
"device.status.error": "Ошибка", "device.status.error": "Ошибка",
@@ -136,26 +139,26 @@
"device.metrics.frames_skipped": "Пропущено", "device.metrics.frames_skipped": "Пропущено",
"device.metrics.keepalive": "Keepalive", "device.metrics.keepalive": "Keepalive",
"device.metrics.errors": "Ошибки", "device.metrics.errors": "Ошибки",
"device.health.online": "WLED Онлайн", "device.health.online": "Онлайн",
"device.health.offline": "WLED Недоступен", "device.health.offline": "Недоступен",
"device.health.checking": "Проверка...", "device.health.checking": "Проверка...",
"device.tutorial.start": "Начать обучение", "device.tutorial.start": "Начать обучение",
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически из WLED", "device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
"device.tip.brightness": "Перетащите для регулировки яркости", "device.tip.brightness": "Перетащите для регулировки яркости",
"device.tip.start": "Запуск или остановка захвата экрана", "device.tip.start": "Запуск или остановка захвата экрана",
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)", "device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)",
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)", "device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)",
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия", "device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
"device.tip.webui": "Открыть встроенный веб-интерфейс WLED для расширенной настройки", "device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
"device.tip.add": "Нажмите, чтобы добавить новое WLED устройство", "device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
"settings.title": "Настройки Устройства", "settings.title": "Настройки Устройства",
"settings.general.title": "Основные Настройки", "settings.general.title": "Основные Настройки",
"settings.capture.title": "Настройки Захвата", "settings.capture.title": "Настройки Захвата",
"settings.capture.saved": "Настройки захвата обновлены", "settings.capture.saved": "Настройки захвата обновлены",
"settings.capture.failed": "Не удалось сохранить настройки захвата", "settings.capture.failed": "Не удалось сохранить настройки захвата",
"settings.brightness": "Яркость:", "settings.brightness": "Яркость:",
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)", "settings.brightness.hint": "Общая яркость для этого устройства (0-100%)",
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства", "settings.url.hint": "IP адрес или имя хоста устройства",
"settings.display_index": "Дисплей:", "settings.display_index": "Дисплей:",
"settings.display_index.hint": "Какой экран захватывать для этого устройства", "settings.display_index.hint": "Какой экран захватывать для этого устройства",
"settings.fps": "Целевой FPS:", "settings.fps": "Целевой FPS:",
@@ -164,7 +167,7 @@
"settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства", "settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства",
"settings.button.cancel": "Отмена", "settings.button.cancel": "Отмена",
"settings.health_interval": "Интервал Проверки (с):", "settings.health_interval": "Интервал Проверки (с):",
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)", "settings.health_interval.hint": "Как часто проверять статус устройства (5-600 секунд)",
"settings.button.save": "Сохранить Изменения", "settings.button.save": "Сохранить Изменения",
"settings.saved": "Настройки успешно сохранены", "settings.saved": "Настройки успешно сохранены",
"settings.failed": "Не удалось сохранить настройки", "settings.failed": "Не удалось сохранить настройки",
@@ -176,7 +179,9 @@
"calibration.tip.span": "Перетащите зелёные полосы для настройки зоны покрытия", "calibration.tip.span": "Перетащите зелёные полосы для настройки зоны покрытия",
"calibration.tip.test": "Нажмите на край для теста LED", "calibration.tip.test": "Нажмите на край для теста LED",
"calibration.tip.toggle_inputs": "Нажмите на общее количество LED для скрытия боковых полей", "calibration.tip.toggle_inputs": "Нажмите на общее количество LED для скрытия боковых полей",
"calibration.tip.skip_leds": "Пропуск LED в начале или конце ленты — пропущенные LED остаются выключенными", "calibration.tip.border_width": "Сколько пикселей от края экрана использовать для цветов LED",
"calibration.tip.skip_leds_start": "Пропуск LED в начале ленты — пропущенные LED остаются выключенными",
"calibration.tip.skip_leds_end": "Пропуск LED в конце ленты — пропущенные LED остаются выключенными",
"calibration.tutorial.start": "Начать обучение", "calibration.tutorial.start": "Начать обучение",
"calibration.start_position": "Начальная Позиция:", "calibration.start_position": "Начальная Позиция:",
"calibration.position.bottom_left": "Нижний Левый", "calibration.position.bottom_left": "Нижний Левый",
@@ -196,6 +201,8 @@
"calibration.skip_start.hint": "Количество LED, которые будут выключены в начале ленты (0 = нет)", "calibration.skip_start.hint": "Количество LED, которые будут выключены в начале ленты (0 = нет)",
"calibration.skip_end": "Пропуск LED (конец):", "calibration.skip_end": "Пропуск LED (конец):",
"calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)", "calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)",
"calibration.border_width": "Граница (px):",
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
"calibration.button.cancel": "Отмена", "calibration.button.cancel": "Отмена",
"calibration.button.save": "Сохранить", "calibration.button.save": "Сохранить",
"calibration.saved": "Калибровка успешно сохранена", "calibration.saved": "Калибровка успешно сохранена",
@@ -313,7 +320,8 @@
"streams.validate_image.invalid": "Изображение недоступно", "streams.validate_image.invalid": "Изображение недоступно",
"targets.title": "⚡ Цели", "targets.title": "⚡ Цели",
"targets.description": "Цели связывают источники изображений с устройствами вывода. Каждая цель ссылается на устройство и источник, с собственными настройками обработки.", "targets.description": "Цели связывают источники изображений с устройствами вывода. Каждая цель ссылается на устройство и источник, с собственными настройками обработки.",
"targets.subtab.wled": "WLED", "targets.subtab.wled": "LED",
"targets.subtab.led": "LED",
"targets.section.devices": "💡 Устройства", "targets.section.devices": "💡 Устройства",
"targets.section.targets": "⚡ Цели", "targets.section.targets": "⚡ Цели",
"targets.add": "Добавить Цель", "targets.add": "Добавить Цель",
@@ -324,7 +332,7 @@
"targets.name": "Имя Цели:", "targets.name": "Имя Цели:",
"targets.name.placeholder": "Моя Цель", "targets.name.placeholder": "Моя Цель",
"targets.device": "Устройство:", "targets.device": "Устройство:",
"targets.device.hint": "На какое WLED устройство отправлять данные LED", "targets.device.hint": "Выберите LED устройство для передачи данных",
"targets.device.none": "-- Выберите устройство --", "targets.device.none": "-- Выберите устройство --",
"targets.source": "Источник:", "targets.source": "Источник:",
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать", "targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
@@ -341,7 +349,7 @@
"targets.smoothing": "Сглаживание:", "targets.smoothing": "Сглаживание:",
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", "targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
"targets.standby_interval": "Интервал ожидания:", "targets.standby_interval": "Интервал ожидания:",
"targets.standby_interval.hint": "Как часто повторно отправлять последний кадр при статичном экране для удержания WLED в режиме live (0.5-5.0с)", "targets.standby_interval.hint": "Как часто повторно отправлять последний кадр при статичном экране для удержания устройства в режиме live (0.5-5.0с)",
"targets.created": "Цель успешно создана", "targets.created": "Цель успешно создана",
"targets.updated": "Цель успешно обновлена", "targets.updated": "Цель успешно обновлена",
"targets.deleted": "Цель успешно удалена", "targets.deleted": "Цель успешно удалена",

View File

@@ -343,6 +343,15 @@ section {
gap: 4px; gap: 4px;
} }
.device-type-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.15);
letter-spacing: 0.5px;
}
.channel-indicator { .channel-indicator {
display: inline-flex; display: inline-flex;
gap: 2px; gap: 2px;
@@ -680,7 +689,14 @@ select {
color: var(--text-color); color: var(--text-color);
font-size: 1rem; font-size: 1rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s, opacity 0.2s;
}
input[type="number"]:disabled,
input[type="password"]:disabled,
select:disabled {
opacity: 0.4;
cursor: not-allowed;
} }
input[type="range"] { input[type="range"] {
@@ -1167,6 +1183,35 @@ input:-webkit-autofill:focus {
font-size: 14px; font-size: 14px;
} }
.preview-screen-border-width {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
}
.preview-screen-border-width label {
white-space: nowrap;
}
.preview-screen-border-width input {
width: 52px;
padding: 2px 4px;
font-size: 12px;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 3px;
background: rgba(255, 255, 255, 0.15);
color: white;
text-align: center;
}
.preview-screen-border-width input:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.25);
}
.preview-screen-total { .preview-screen-total {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;

View File

@@ -31,6 +31,7 @@ class Device:
url: str, url: str,
led_count: int, led_count: int,
enabled: bool = True, enabled: bool = True,
device_type: str = "wled",
calibration: Optional[CalibrationConfig] = None, calibration: Optional[CalibrationConfig] = None,
created_at: Optional[datetime] = None, created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None, updated_at: Optional[datetime] = None,
@@ -40,6 +41,7 @@ class Device:
self.url = url self.url = url
self.led_count = led_count self.led_count = led_count
self.enabled = enabled self.enabled = enabled
self.device_type = device_type
self.calibration = calibration or create_default_calibration(led_count) self.calibration = calibration or create_default_calibration(led_count)
self.created_at = created_at or datetime.utcnow() self.created_at = created_at or datetime.utcnow()
self.updated_at = updated_at or datetime.utcnow() self.updated_at = updated_at or datetime.utcnow()
@@ -52,6 +54,7 @@ class Device:
"url": self.url, "url": self.url,
"led_count": self.led_count, "led_count": self.led_count,
"enabled": self.enabled, "enabled": self.enabled,
"device_type": self.device_type,
"calibration": calibration_to_dict(self.calibration), "calibration": calibration_to_dict(self.calibration),
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
@@ -77,6 +80,7 @@ class Device:
url=data["url"], url=data["url"],
led_count=data["led_count"], led_count=data["led_count"],
enabled=data.get("enabled", True), enabled=data.get("enabled", True),
device_type=data.get("device_type", "wled"),
calibration=calibration, calibration=calibration,
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
@@ -160,6 +164,7 @@ class DeviceStore:
name: str, name: str,
url: str, url: str,
led_count: int, led_count: int,
device_type: str = "wled",
calibration: Optional[CalibrationConfig] = None, calibration: Optional[CalibrationConfig] = None,
) -> Device: ) -> Device:
"""Create a new device.""" """Create a new device."""
@@ -170,6 +175,7 @@ class DeviceStore:
name=name, name=name,
url=url, url=url,
led_count=led_count, led_count=led_count,
device_type=device_type,
calibration=calibration, calibration=calibration,
) )

View File

@@ -31,7 +31,8 @@ class PictureTarget:
def from_dict(cls, data: dict) -> "PictureTarget": def from_dict(cls, data: dict) -> "PictureTarget":
"""Create from dictionary, dispatching to the correct subclass.""" """Create from dictionary, dispatching to the correct subclass."""
target_type = data.get("target_type", "wled") target_type = data.get("target_type", "wled")
if target_type == "wled": # "wled" and "led" both map to WledPictureTarget (backward compat)
if target_type in ("wled", "led"):
from wled_controller.storage.wled_picture_target import WledPictureTarget from wled_controller.storage.wled_picture_target import WledPictureTarget
return WledPictureTarget.from_dict(data) return WledPictureTarget.from_dict(data)
if target_type == "key_colors": if target_type == "key_colors":

View File

@@ -119,8 +119,11 @@ class PictureTargetStore:
Raises: Raises:
ValueError: If validation fails ValueError: If validation fails
""" """
if target_type not in ("wled", "key_colors"): if target_type not in ("led", "wled", "key_colors"):
raise ValueError(f"Invalid target type: {target_type}") raise ValueError(f"Invalid target type: {target_type}")
# Normalize legacy "wled" to "led"
if target_type == "wled":
target_type = "led"
# Check for duplicate name # Check for duplicate name
for target in self._targets.values(): for target in self._targets.values():
@@ -130,11 +133,11 @@ class PictureTargetStore:
target_id = f"pt_{uuid.uuid4().hex[:8]}" target_id = f"pt_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow() now = datetime.utcnow()
if target_type == "wled": if target_type == "led":
target: PictureTarget = WledPictureTarget( target: PictureTarget = WledPictureTarget(
id=target_id, id=target_id,
name=name, name=name,
target_type="wled", target_type="led",
device_id=device_id, device_id=device_id,
picture_source_id=picture_source_id, picture_source_id=picture_source_id,
settings=settings or ProcessingSettings(), settings=settings or ProcessingSettings(),

View File

@@ -1,4 +1,4 @@
"""WLED picture target — streams a picture source to a WLED device.""" """LED picture target — streams a picture source to an LED device."""
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -10,7 +10,7 @@ from wled_controller.storage.picture_target import PictureTarget
@dataclass @dataclass
class WledPictureTarget(PictureTarget): class WledPictureTarget(PictureTarget):
"""WLED picture target — streams a picture source to a WLED device.""" """LED picture target — streams a picture source to an LED device."""
device_id: str = "" device_id: str = ""
picture_source_id: str = "" picture_source_id: str = ""
@@ -24,7 +24,6 @@ class WledPictureTarget(PictureTarget):
d["settings"] = { d["settings"] = {
"display_index": self.settings.display_index, "display_index": self.settings.display_index,
"fps": self.settings.fps, "fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness, "brightness": self.settings.brightness,
"gamma": self.settings.gamma, "gamma": self.settings.gamma,
"saturation": self.settings.saturation, "saturation": self.settings.saturation,
@@ -44,7 +43,6 @@ class WledPictureTarget(PictureTarget):
settings = ProcessingSettings( settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0), display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30), fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0), brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2), gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0), saturation=settings_data.get("saturation", 1.0),
@@ -57,7 +55,7 @@ class WledPictureTarget(PictureTarget):
return cls( return cls(
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
target_type=data.get("target_type", "wled"), target_type="led",
device_id=data.get("device_id", ""), device_id=data.get("device_id", ""),
picture_source_id=data.get("picture_source_id", ""), picture_source_id=data.get("picture_source_id", ""),
settings=settings, settings=settings,