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:
@@ -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,50 +58,60 @@ 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("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get(f"{device_url}/json/info")
|
||||
response.raise_for_status()
|
||||
wled_info = response.json()
|
||||
wled_led_count = wled_info.get("leds", {}).get("count")
|
||||
if not wled_led_count or wled_led_count < 1:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}"
|
||||
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")
|
||||
response.raise_for_status()
|
||||
wled_info = response.json()
|
||||
wled_led_count = wled_info.get("leds", {}).get("count")
|
||||
if not wled_led_count or wled_led_count < 1:
|
||||
raise HTTPException(
|
||||
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(
|
||||
f"WLED device reachable: {wled_info.get('name', 'Unknown')} "
|
||||
f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)"
|
||||
except httpx.ConnectError:
|
||||
raise HTTPException(
|
||||
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(
|
||||
status_code=422,
|
||||
detail=f"Cannot reach WLED device at {device_url}. Check the URL and ensure the device is powered on."
|
||||
)
|
||||
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}"
|
||||
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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
180
server/src/wled_controller/core/led_client.py
Normal file
180
server/src/wled_controller/core/led_client.py
Normal 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())
|
||||
@@ -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,58 +1012,14 @@ 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),
|
||||
)
|
||||
client = await self._get_http_client()
|
||||
state.health = await check_device_health(
|
||||
state.device_type, state.device_url, client, state.health,
|
||||
)
|
||||
|
||||
# ===== KEY COLORS TARGET MANAGEMENT =====
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 `
|
||||
|
||||
@@ -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">✕</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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Цель успешно удалена",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user