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 wled_controller.api.auth import AuthRequired
from wled_controller.core.led_client import get_device_capabilities
from wled_controller.api.dependencies import (
get_device_store,
get_picture_target_store,
@@ -39,6 +40,7 @@ def _device_to_response(device) -> DeviceResponse:
id=device.id,
name=device.name,
url=device.url,
device_type=device.device_type,
led_count=device.led_count,
enabled=device.enabled,
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
@@ -56,12 +58,16 @@ async def create_device(
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Create and attach a new WLED device."""
"""Create and attach a new LED device."""
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("/")
wled_led_count = 0
if device_type == "wled":
# Validate WLED device is reachable before adding
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(f"{device_url}/json/info")
@@ -94,12 +100,18 @@ async def create_device(
status_code=422,
detail=f"Failed to connect to WLED device at {device_url}: {e}"
)
else:
raise HTTPException(
status_code=400,
detail=f"Unsupported device type: {device_type}"
)
# Create device in storage (LED count auto-detected from WLED)
# Create device in storage
device = store.create_device(
name=device_data.name,
url=device_data.url,
led_count=wled_led_count,
device_type=device_type,
)
# Register in processor manager for health monitoring
@@ -108,6 +120,7 @@ async def create_device(
device_url=device.url,
led_count=device.led_count,
calibration=device.calibration,
device_type=device.device_type,
)
return _device_to_response(device)
@@ -234,6 +247,7 @@ async def get_device_state(
try:
state = manager.get_device_health_dict(device_id)
state["device_type"] = device.device_type
return DeviceStateResponse(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -251,6 +265,8 @@ async def get_device_brightness(
device = store.get_device(device_id)
if not device:
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:
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)
if not device:
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")
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(
display_index=schema.display_index,
fps=schema.fps,
border_width=schema.border_width,
interpolation_mode=schema.interpolation_mode,
brightness=schema.brightness,
smoothing=schema.smoothing,
@@ -86,7 +85,6 @@ def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchem
return ProcessingSettingsSchema(
display_index=settings.display_index,
fps=settings.fps,
border_width=settings.border_width,
interpolation_mode=settings.interpolation_mode,
brightness=settings.brightness,
smoothing=settings.smoothing,
@@ -468,7 +466,6 @@ async def update_target_settings(
new_settings = ProcessingSettings(
display_index=settings.display_index if 'display_index' in sent else existing.display_index,
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,
brightness=settings.brightness if 'brightness' in sent else existing.brightness,
gamma=existing.gamma,

View File

@@ -7,10 +7,11 @@ from pydantic import BaseModel, Field
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)
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):
@@ -53,6 +54,7 @@ class Calibration(BaseModel):
# 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_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):
@@ -79,7 +81,8 @@ class DeviceResponse(BaseModel):
id: str = Field(description="Device ID")
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")
enabled: bool = Field(description="Whether device is enabled")
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
@@ -98,14 +101,15 @@ class DeviceStateResponse(BaseModel):
"""Device health/connection state response."""
device_id: str = Field(description="Device ID")
wled_online: bool = Field(default=False, description="Whether WLED device is reachable")
wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms")
wled_name: Optional[str] = Field(None, description="WLED device name")
wled_version: Optional[str] = Field(None, description="WLED firmware version")
wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device")
wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs")
wled_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")
wled_error: Optional[str] = Field(None, description="Last health check error")
device_type: str = Field(default="wled", description="LED device type")
device_online: bool = Field(default=False, description="Whether device is reachable")
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
device_name: Optional[str] = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
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_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)
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)")
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)
@@ -76,8 +75,8 @@ class PictureTargetCreate(BaseModel):
"""Request to create a picture target."""
name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="wled", description="Target type (wled, key_colors)")
device_id: str = Field(default="", description="WLED device ID")
target_type: str = Field(default="led", description="Target type (led, key_colors)")
device_id: str = Field(default="", description="LED device ID")
picture_source_id: str = Field(default="", description="Picture source ID")
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)")
@@ -132,15 +131,15 @@ class TargetProcessingState(BaseModel):
display_index: int = Field(default=0, description="Current display index")
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
wled_online: bool = Field(default=False, description="Whether WLED device is reachable")
wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms")
wled_name: Optional[str] = Field(None, description="WLED device name")
wled_version: Optional[str] = Field(None, description="WLED firmware version")
wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device")
wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs")
wled_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")
wled_error: Optional[str] = Field(None, description="Last health check error")
device_online: bool = Field(default=False, description="Whether device is reachable")
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
device_name: Optional[str] = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
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_start: 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]:
"""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),
skip_leds_start=data.get("skip_leds_start", 0),
skip_leds_end=data.get("skip_leds_end", 0),
border_width=data.get("border_width", 10),
)
config.validate()
@@ -506,4 +509,6 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["skip_leds_start"] = config.skip_leds_start
if config.skip_leds_end > 0:
result["skip_leds_end"] = config.skip_leds_end
if config.border_width != 10:
result["border_width"] = config.border_width
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,
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
logger = get_logger(__name__)
@@ -87,27 +92,12 @@ def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing):
)
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
class ProcessingSettings:
"""Settings for screen processing."""
display_index: int = 0
fps: int = 30
border_width: int = 10
brightness: float = 1.0
gamma: float = 2.2
saturation: float = 1.0
@@ -117,21 +107,6 @@ class ProcessingSettings:
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
class ProcessingMetrics:
"""Metrics for processing performance."""
@@ -150,12 +125,13 @@ class ProcessingMetrics:
@dataclass
class DeviceState:
"""State for a registered WLED device (health monitoring + calibration)."""
"""State for a registered LED device (health monitoring + calibration)."""
device_id: str
device_url: str
led_count: int
calibration: CalibrationConfig
device_type: str = "wled"
health: DeviceHealth = field(default_factory=DeviceHealth)
health_task: Optional[asyncio.Task] = None
# Calibration test mode (works independently of target processing)
@@ -174,7 +150,7 @@ class TargetState:
settings: ProcessingSettings
calibration: CalibrationConfig
picture_source_id: str = ""
wled_client: Optional[WLEDClient] = None
led_client: Optional[LEDClient] = None
pixel_mapper: Optional[PixelMapper] = None
is_running: bool = False
task: Optional[asyncio.Task] = None
@@ -187,8 +163,8 @@ class TargetState:
resolved_engine_config: Optional[dict] = None
# LiveStream: runtime frame source (shared via LiveStreamManager)
live_stream: Optional[LiveStream] = None
# WLED state snapshot taken before streaming starts (to restore on stop)
wled_state_before: Optional[dict] = None
# Device state snapshot taken before streaming starts (to restore on stop)
device_state_before: Optional[dict] = None
@dataclass
@@ -245,14 +221,16 @@ class ProcessorManager:
device_url: str,
led_count: int,
calibration: Optional[CalibrationConfig] = None,
device_type: str = "wled",
):
"""Register a device for health monitoring.
Args:
device_id: Unique device identifier
device_url: WLED device URL
device_url: Device URL
led_count: Number of LEDs
calibration: Calibration config (creates default if None)
device_type: LED device type (e.g. "wled")
"""
if device_id in self._devices:
raise ValueError(f"Device {device_id} already registered")
@@ -265,6 +243,7 @@ class ProcessorManager:
device_url=device_url,
led_count=led_count,
calibration=calibration,
device_type=device_type,
)
self._devices[device_id] = state
@@ -356,11 +335,11 @@ class ProcessorManager:
"online": h.online,
"latency_ms": h.latency_ms,
"last_checked": h.last_checked,
"wled_name": h.wled_name,
"wled_version": h.wled_version,
"wled_led_count": h.wled_led_count,
"wled_rgbw": h.wled_rgbw,
"wled_led_type": h.wled_led_type,
"device_name": h.device_name,
"device_version": h.device_version,
"device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type,
"error": h.error,
}
@@ -373,15 +352,15 @@ class ProcessorManager:
h = ds.health
return {
"device_id": device_id,
"wled_online": h.online,
"wled_latency_ms": h.latency_ms,
"wled_name": h.wled_name,
"wled_version": h.wled_version,
"wled_led_count": h.wled_led_count,
"wled_rgbw": h.wled_rgbw,
"wled_led_type": h.wled_led_type,
"wled_last_checked": h.last_checked,
"wled_error": h.error,
"device_online": h.online,
"device_latency_ms": h.latency_ms,
"device_name": h.device_name,
"device_version": h.device_version,
"device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type,
"device_last_checked": h.last_checked,
"device_error": h.error,
"test_mode": ds.test_mode_active,
"test_mode_edges": list(ds.test_mode_edges.keys()),
}
@@ -548,31 +527,22 @@ class ProcessorManager:
# Resolve stream settings
self._resolve_stream_settings(state)
# Snapshot WLED state before streaming changes it
try:
async with httpx.AsyncClient(timeout=5) as http:
resp = await http.get(f"{state.device_url}/json/state")
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
# Determine device type from device state
device_type = "wled"
if state.device_id in self._devices:
device_type = self._devices[state.device_id].device_type
# Connect to WLED device (always use DDP for low-latency UDP streaming)
# Connect to LED device via factory
try:
state.wled_client = WLEDClient(state.device_url, use_ddp=True)
await state.wled_client.connect()
logger.info(f"Target {target_id} using DDP protocol ({state.led_count} LEDs)")
state.led_client = create_led_client(device_type, state.device_url, use_ddp=True)
await state.led_client.connect()
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:
logger.error(f"Failed to connect to WLED device for target {target_id}: {e}")
raise RuntimeError(f"Failed to connect to WLED device: {e}")
logger.error(f"Failed to connect to LED device for target {target_id}: {e}")
raise RuntimeError(f"Failed to connect to LED device: {e}")
# Acquire live stream via LiveStreamManager (shared across targets)
try:
@@ -589,8 +559,8 @@ class ProcessorManager:
)
except Exception as e:
logger.error(f"Failed to initialize live stream for target {target_id}: {e}")
if state.wled_client:
await state.wled_client.disconnect()
if state.led_client:
await state.led_client.close()
raise RuntimeError(f"Failed to initialize live stream: {e}")
# Initialize pixel mapper
@@ -632,23 +602,15 @@ class ProcessorManager:
pass
state.task = None
# Restore WLED state
if state.wled_state_before:
try:
async with httpx.AsyncClient(timeout=5) as http:
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
# Restore device state (type-specific, e.g. WLED restores on/lor/AudioReactive)
if state.led_client and state.device_state_before:
await state.led_client.restore_device_state(state.device_state_before)
state.device_state_before = None
# Close WLED connection
if state.wled_client:
await state.wled_client.close()
state.wled_client = None
# Close LED connection
if state.led_client:
await state.led_client.close()
state.led_client = None
# Release live stream
if state.live_stream:
@@ -667,8 +629,8 @@ class ProcessorManager:
target_fps = settings.fps
smoothing = settings.smoothing
border_width = settings.border_width
wled_brightness = settings.brightness
border_width = state.calibration.border_width
led_brightness = settings.brightness
logger.info(
f"Processing loop started for target {target_id} "
@@ -708,15 +670,15 @@ class ProcessorManager:
# Skip processing + send if the frame hasn't changed
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 not state.is_running or state.wled_client is None:
if not state.is_running or state.led_client is None:
break
brightness_value = int(wled_brightness * 255)
if state.wled_client.use_ddp:
state.wled_client.send_pixels_fast(state.previous_colors, brightness=brightness_value)
brightness_value = int(led_brightness * 255)
if state.led_client.supports_fast_send:
state.led_client.send_pixels_fast(state.previous_colors, brightness=brightness_value)
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()
send_timestamps.append(last_send_time)
state.metrics.frames_keepalive += 1
@@ -737,14 +699,14 @@ class ProcessorManager:
state.pixel_mapper, state.previous_colors, smoothing,
)
# Send to WLED with device brightness
if not state.is_running or state.wled_client is None:
# Send to LED device with brightness
if not state.is_running or state.led_client is None:
break
brightness_value = int(wled_brightness * 255)
if state.wled_client.use_ddp:
state.wled_client.send_pixels_fast(led_colors, brightness=brightness_value)
brightness_value = int(led_brightness * 255)
if state.led_client.supports_fast_send:
state.led_client.send_pixels_fast(led_colors, brightness=brightness_value)
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()
send_timestamps.append(last_send_time)
@@ -802,20 +764,20 @@ class ProcessorManager:
state = self._targets[target_id]
metrics = state.metrics
# Include WLED health info from the device
# Include device health info
health_info = {}
if state.device_id in self._devices:
h = self._devices[state.device_id].health
health_info = {
"wled_online": h.online,
"wled_latency_ms": h.latency_ms,
"wled_name": h.wled_name,
"wled_version": h.wled_version,
"wled_led_count": h.wled_led_count,
"wled_rgbw": h.wled_rgbw,
"wled_led_type": h.wled_led_type,
"wled_last_checked": h.last_checked,
"wled_error": h.error,
"device_online": h.online,
"device_latency_ms": h.latency_ms,
"device_name": h.device_name,
"device_version": h.device_version,
"device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type,
"device_last_checked": h.last_checked,
"device_error": h.error,
}
return {
@@ -913,38 +875,38 @@ class ProcessorManager:
break
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
for ts in self._targets.values():
if ts.device_id == device_id and ts.is_running and ts.wled_client:
active_client = ts.wled_client
if ts.device_id == device_id and ts.is_running and ts.led_client:
active_client = ts.led_client
break
if active_client:
await active_client.send_pixels(pixels)
else:
async with WLEDClient(ds.device_url, use_ddp=True) as wled:
await wled.send_pixels(pixels)
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client:
await client.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to send test pixels for {device_id}: {e}")
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]
pixels = [(0, 0, 0)] * ds.led_count
try:
active_client = None
for ts in self._targets.values():
if ts.device_id == device_id and ts.is_running and ts.wled_client:
active_client = ts.wled_client
if ts.device_id == device_id and ts.is_running and ts.led_client:
active_client = ts.led_client
break
if active_client:
await active_client.send_pixels(pixels)
else:
async with WLEDClient(ds.device_url, use_ddp=True) as wled:
await wled.send_pixels(pixels)
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client:
await client.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to clear pixels for {device_id}: {e}")
@@ -1050,57 +1012,13 @@ class ProcessorManager:
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
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)
if not state:
return
url = state.device_url.rstrip("/")
start = time.time()
try:
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),
state.health = await check_device_health(
state.device_type, state.device_url, client, state.health,
)
# ===== KEY COLORS TARGET MANAGEMENT =====

View File

@@ -1,7 +1,9 @@
"""WLED client for controlling LED devices via HTTP or DDP."""
import asyncio
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Tuple, Optional, Dict, Any
from urllib.parse import urlparse
@@ -10,9 +12,23 @@ import numpy as np
from wled_controller.utils import get_logger
from wled_controller.core.ddp_client import BusConfig, DDPClient
from wled_controller.core.led_client import DeviceHealth, LEDClient
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
class WLEDInfo:
@@ -30,7 +46,7 @@ class WLEDInfo:
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."""
# HTTP JSON API has ~10KB limit, ~500 LEDs max
@@ -67,15 +83,6 @@ class WLEDClient:
self._ddp_client: Optional[DDPClient] = None
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:
"""Establish connection to WLED device.
@@ -161,6 +168,11 @@ class WLEDClient:
"""Check if connected to WLED device."""
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(
self,
method: str,
@@ -445,6 +457,96 @@ class WLEDClient:
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:
"""Turn WLED device on or off.

View File

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

View File

@@ -614,33 +614,33 @@ async function loadDevices() {
function createDeviceCard(device) {
const state = device.state || {};
// WLED device health indicator
const wledOnline = state.wled_online || false;
const wledLatency = state.wled_latency_ms;
const wledName = state.wled_name;
const wledVersion = state.wled_version;
const wledLastChecked = state.wled_last_checked;
// Device health indicator
const devOnline = state.device_online || false;
const devLatency = state.device_latency_ms;
const devName = state.device_name;
const devVersion = state.device_version;
const devLastChecked = state.device_last_checked;
let healthClass, healthTitle, healthLabel;
if (wledLastChecked === null || wledLastChecked === undefined) {
if (devLastChecked === null || devLastChecked === undefined) {
healthClass = 'health-unknown';
healthTitle = t('device.health.checking');
healthLabel = '';
} else if (wledOnline) {
} else if (devOnline) {
healthClass = 'health-online';
healthTitle = `${t('device.health.online')}`;
if (wledName) healthTitle += ` - ${wledName}`;
if (wledVersion) healthTitle += ` v${wledVersion}`;
if (wledLatency !== null && wledLatency !== undefined) healthTitle += ` (${Math.round(wledLatency)}ms)`;
if (devName) healthTitle += ` - ${devName}`;
if (devVersion) healthTitle += ` v${devVersion}`;
if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
healthLabel = '';
} else {
healthClass = '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>`;
}
const ledCount = state.wled_led_count || device.led_count;
const ledCount = state.device_led_count || device.led_count;
return `
<div class="card" data-device-id="${device.id}">
@@ -654,9 +654,10 @@ function createDeviceCard(device) {
</div>
</div>
<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>` : ''}
${state.wled_led_type ? `<span class="card-meta">🔌 ${state.wled_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>
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
</div>
<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"
@@ -903,7 +904,8 @@ async function handleAddDevice(event) {
}
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');
if (lastTemplateId) {
body.capture_template_id = lastTemplateId;
@@ -1060,6 +1062,10 @@ async function showCalibration(deviceId) {
// Set skip LEDs
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 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
window.edgeSpans = {
@@ -1081,6 +1087,7 @@ async function showCalibration(deviceId) {
spans: JSON.stringify(window.edgeSpans),
skip_start: String(calibration.skip_leds_start || 0),
skip_end: String(calibration.skip_leds_end || 0),
border_width: String(calibration.border_width || 10),
};
// Initialize test mode state for this device
@@ -1131,7 +1138,8 @@ function isCalibrationDirty() {
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left ||
JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans ||
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();
}
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() {
// Calculate total from edge inputs
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
@@ -1774,6 +1793,7 @@ async function saveCalibration() {
span_left_end: spans.left?.end ?? 1,
skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0),
skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0),
border_width: parseInt(document.getElementById('cal-border-width').value) || 10,
};
try {
@@ -1935,11 +1955,13 @@ const calibrationTutorialSteps = [
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
{ 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: '.toggle-top', textKey: 'calibration.tip.test', 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 = [
@@ -3820,13 +3842,14 @@ async function showTargetEditor(targetId = null) {
const opt = document.createElement('option');
opt.value = d.id;
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);
});
// Populate source select
const sourceSelect = document.getElementById('target-editor-source');
sourceSelect.innerHTML = '<option value="">-- No source --</option>';
sourceSelect.innerHTML = '';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
@@ -3847,7 +3870,6 @@ async function showTargetEditor(targetId = null) {
sourceSelect.value = target.picture_source_id || '';
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-border-width').value = target.settings?.border_width ?? 10;
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').textContent = target.settings?.smoothing ?? 0.3;
@@ -3862,7 +3884,6 @@ async function showTargetEditor(targetId = null) {
sourceSelect.value = '';
document.getElementById('target-editor-fps').value = 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-smoothing').value = 0.3;
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
@@ -3876,7 +3897,6 @@ async function showTargetEditor(targetId = null) {
device: deviceSelect.value,
source: sourceSelect.value,
fps: document.getElementById('target-editor-fps').value,
border_width: document.getElementById('target-editor-border-width').value,
interpolation: document.getElementById('target-editor-interpolation').value,
smoothing: document.getElementById('target-editor-smoothing').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-source').value !== targetEditorInitialValues.source ||
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-smoothing').value !== targetEditorInitialValues.smoothing ||
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 sourceId = document.getElementById('target-editor-source').value;
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 smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
@@ -3947,7 +3965,6 @@ async function saveTargetEditor() {
picture_source_id: sourceId,
settings: {
fps: fps,
border_width: borderWidth,
interpolation_mode: interpolation,
smoothing: smoothing,
standby_interval: standbyInterval,
@@ -3963,7 +3980,7 @@ async function saveTargetEditor() {
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'wled';
payload.target_type = 'led';
response = await fetch(`${API_BASE}/picture-targets`, {
method: 'POST',
headers: getHeaders(),
@@ -4083,14 +4100,16 @@ async function loadTargetsTab() {
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
// Group by type
const wledDevices = devicesWithState;
const wledTargets = targetsWithState.filter(t => t.target_type === 'wled');
const ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
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 = [
{ 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 },
];
@@ -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>`
).join('')}</div>`;
// WLED panel: devices section + targets section
const wledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'wled' ? ' active' : ''}" id="target-sub-tab-wled">
// LED panel: devices section + targets section
const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
<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="add-template-icon">+</div>
</div>
@@ -4113,7 +4132,7 @@ async function loadTargetsTab() {
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
<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="add-template-icon">+</div>
</div>
@@ -4144,9 +4163,9 @@ async function loadTargetsTab() {
</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 => {
attachDeviceListeners(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');
// 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 healthTitle = '';
if (state.wled_last_checked !== null && state.wled_last_checked !== undefined) {
healthClass = wledOnline ? 'health-online' : 'health-offline';
healthTitle = wledOnline ? t('device.health.online') : t('device.health.offline');
if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
healthClass = devOnline ? 'health-online' : 'health-offline';
healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
}
return `

View File

@@ -95,6 +95,10 @@
<span id="direction-icon"></span> <span id="direction-label">CW</span>
</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-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>
<!-- Edge bars with span controls and LED count inputs -->
@@ -171,7 +175,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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>
<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 class="form-group">
<div class="label-row">
@@ -179,7 +183,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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>
<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 class="form-group">
<div class="label-row">
@@ -187,7 +191,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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>
<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>
@@ -235,10 +239,10 @@
<div class="form-group">
<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>
</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>
</div>
@@ -247,7 +251,7 @@
<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>
</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">
</div>
@@ -279,10 +283,10 @@
<div class="form-group">
<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>
</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>
</div>
@@ -307,15 +311,6 @@
</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="label-row">
<label for="target-editor-interpolation" data-i18n="targets.interpolation">Interpolation Mode:</label>
@@ -349,7 +344,7 @@
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</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">
</div>
@@ -516,7 +511,7 @@
<div id="api-key-modal" class="modal">
<div class="modal-content">
<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>
</div>
<form id="api-key-form" onsubmit="submitApiKey(event)">
@@ -579,12 +574,26 @@
</div>
<div class="modal-body">
<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">
<label for="device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<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>
</div>
<div id="add-device-error" class="error-message" style="display: none;"></div>

View File

@@ -7,7 +7,7 @@
"auth.login": "Login",
"auth.logout": "Logout",
"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.label": "API Key:",
"auth.placeholder": "Enter your API key...",
@@ -101,13 +101,16 @@
"devices.wled_webui_link": "WLED Web UI",
"devices.wled_note_webui": "(open your device's IP in a browser).",
"devices.wled_note2": "This controller sends pixel color data and controls brightness per device.",
"device.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.placeholder": "Living Room TV",
"device.url": "URL:",
"device.url.placeholder": "http://192.168.1.100",
"device.led_count": "LED Count:",
"device.led_count.hint": "Number of LEDs configured in your WLED device",
"device.led_count.hint.auto": "Auto-detected from WLED device",
"device.led_count.hint": "Number of LEDs configured in the device",
"device.led_count.hint.auto": "Auto-detected from device",
"device.button.add": "Add Device",
"device.button.start": "Start",
"device.button.stop": "Stop",
@@ -115,7 +118,7 @@
"device.button.capture_settings": "Capture Settings",
"device.button.calibrate": "Calibrate",
"device.button.remove": "Remove",
"device.button.webui": "Open WLED Web UI",
"device.button.webui": "Open Device Web UI",
"device.status.connected": "Connected",
"device.status.disconnected": "Disconnected",
"device.status.error": "Error",
@@ -136,26 +139,26 @@
"device.metrics.frames_skipped": "Skipped",
"device.metrics.keepalive": "Keepalive",
"device.metrics.errors": "Errors",
"device.health.online": "WLED Online",
"device.health.offline": "WLED Offline",
"device.health.online": "Online",
"device.health.offline": "Offline",
"device.health.checking": "Checking...",
"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.start": "Start or stop screen capture processing",
"device.tip.settings": "Configure general device settings (name, URL, health check)",
"device.tip.capture_settings": "Configure capture settings (display, capture template)",
"device.tip.calibrate": "Calibrate LED positions, direction, and coverage",
"device.tip.webui": "Open WLED's built-in web interface for advanced configuration",
"device.tip.add": "Click here to add a new WLED device",
"device.tip.webui": "Open the device's built-in web interface for advanced configuration",
"device.tip.add": "Click here to add a new LED device",
"settings.title": "Device Settings",
"settings.general.title": "General Settings",
"settings.capture.title": "Capture Settings",
"settings.capture.saved": "Capture settings updated",
"settings.capture.failed": "Failed to save capture settings",
"settings.brightness": "Brightness:",
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
"settings.url.hint": "IP address or hostname of your WLED device",
"settings.brightness.hint": "Global brightness for this device (0-100%)",
"settings.url.hint": "IP address or hostname of the device",
"settings.display_index": "Display:",
"settings.display_index.hint": "Which screen to capture for this device",
"settings.fps": "Target FPS:",
@@ -164,7 +167,7 @@
"settings.capture_template.hint": "Screen capture engine and configuration for this device",
"settings.button.cancel": "Cancel",
"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.saved": "Settings saved successfully",
"settings.failed": "Failed to save settings",
@@ -176,7 +179,9 @@
"calibration.tip.span": "Drag green bars to adjust coverage span",
"calibration.tip.test": "Click an edge to toggle test LEDs",
"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.start_position": "Starting Position:",
"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_end": "Skip LEDs (End):",
"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.save": "Save",
"calibration.saved": "Calibration saved successfully",
@@ -313,7 +320,8 @@
"streams.validate_image.invalid": "Image not accessible",
"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.subtab.wled": "WLED",
"targets.subtab.wled": "LED",
"targets.subtab.led": "LED",
"targets.section.devices": "💡 Devices",
"targets.section.targets": "⚡ Targets",
"targets.add": "Add Target",
@@ -324,7 +332,7 @@
"targets.name": "Target Name:",
"targets.name.placeholder": "My Target",
"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.source": "Source:",
"targets.source.hint": "Which picture source to capture and process",
@@ -341,7 +349,7 @@
"targets.smoothing": "Smoothing:",
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
"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.updated": "Target updated successfully",
"targets.deleted": "Target deleted successfully",

View File

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

View File

@@ -343,6 +343,15 @@ section {
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 {
display: inline-flex;
gap: 2px;
@@ -680,7 +689,14 @@ select {
color: var(--text-color);
font-size: 1rem;
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"] {
@@ -1167,6 +1183,35 @@ input:-webkit-autofill:focus {
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 {
font-size: 16px;
font-weight: 600;

View File

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

View File

@@ -31,7 +31,8 @@ class PictureTarget:
def from_dict(cls, data: dict) -> "PictureTarget":
"""Create from dictionary, dispatching to the correct subclass."""
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
return WledPictureTarget.from_dict(data)
if target_type == "key_colors":

View File

@@ -119,8 +119,11 @@ class PictureTargetStore:
Raises:
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}")
# Normalize legacy "wled" to "led"
if target_type == "wled":
target_type = "led"
# Check for duplicate name
for target in self._targets.values():
@@ -130,11 +133,11 @@ class PictureTargetStore:
target_id = f"pt_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
if target_type == "wled":
if target_type == "led":
target: PictureTarget = WledPictureTarget(
id=target_id,
name=name,
target_type="wled",
target_type="led",
device_id=device_id,
picture_source_id=picture_source_id,
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 datetime import datetime
@@ -10,7 +10,7 @@ from wled_controller.storage.picture_target import PictureTarget
@dataclass
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 = ""
picture_source_id: str = ""
@@ -24,7 +24,6 @@ class WledPictureTarget(PictureTarget):
d["settings"] = {
"display_index": self.settings.display_index,
"fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness,
"gamma": self.settings.gamma,
"saturation": self.settings.saturation,
@@ -44,7 +43,6 @@ class WledPictureTarget(PictureTarget):
settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0),
@@ -57,7 +55,7 @@ class WledPictureTarget(PictureTarget):
return cls(
id=data["id"],
name=data["name"],
target_type=data.get("target_type", "wled"),
target_type="led",
device_id=data.get("device_id", ""),
picture_source_id=data.get("picture_source_id", ""),
settings=settings,