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 fastapi import APIRouter, HTTPException, Depends
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.core.led_client import get_device_capabilities
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
get_device_store,
|
get_device_store,
|
||||||
get_picture_target_store,
|
get_picture_target_store,
|
||||||
@@ -39,6 +40,7 @@ def _device_to_response(device) -> DeviceResponse:
|
|||||||
id=device.id,
|
id=device.id,
|
||||||
name=device.name,
|
name=device.name,
|
||||||
url=device.url,
|
url=device.url,
|
||||||
|
device_type=device.device_type,
|
||||||
led_count=device.led_count,
|
led_count=device.led_count,
|
||||||
enabled=device.enabled,
|
enabled=device.enabled,
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||||
@@ -56,50 +58,60 @@ async def create_device(
|
|||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Create and attach a new WLED device."""
|
"""Create and attach a new LED device."""
|
||||||
try:
|
try:
|
||||||
logger.info(f"Creating device: {device_data.name}")
|
device_type = device_data.device_type
|
||||||
|
logger.info(f"Creating {device_type} device: {device_data.name}")
|
||||||
|
|
||||||
# Validate WLED device is reachable before adding
|
|
||||||
device_url = device_data.url.rstrip("/")
|
device_url = device_data.url.rstrip("/")
|
||||||
try:
|
wled_led_count = 0
|
||||||
async with httpx.AsyncClient(timeout=5) as client:
|
|
||||||
response = await client.get(f"{device_url}/json/info")
|
if device_type == "wled":
|
||||||
response.raise_for_status()
|
# Validate WLED device is reachable before adding
|
||||||
wled_info = response.json()
|
try:
|
||||||
wled_led_count = wled_info.get("leds", {}).get("count")
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
if not wled_led_count or wled_led_count < 1:
|
response = await client.get(f"{device_url}/json/info")
|
||||||
raise HTTPException(
|
response.raise_for_status()
|
||||||
status_code=422,
|
wled_info = response.json()
|
||||||
detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}"
|
wled_led_count = wled_info.get("leds", {}).get("count")
|
||||||
|
if not wled_led_count or wled_led_count < 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"WLED device reachable: {wled_info.get('name', 'Unknown')} "
|
||||||
|
f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)"
|
||||||
)
|
)
|
||||||
logger.info(
|
except httpx.ConnectError:
|
||||||
f"WLED device reachable: {wled_info.get('name', 'Unknown')} "
|
raise HTTPException(
|
||||||
f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)"
|
status_code=422,
|
||||||
|
detail=f"Cannot reach WLED device at {device_url}. Check the URL and ensure the device is powered on."
|
||||||
)
|
)
|
||||||
except httpx.ConnectError:
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Connection to {device_url} timed out. Check network connectivity."
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Failed to connect to WLED device at {device_url}: {e}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=400,
|
||||||
detail=f"Cannot reach WLED device at {device_url}. Check the URL and ensure the device is powered on."
|
detail=f"Unsupported device type: {device_type}"
|
||||||
)
|
|
||||||
except httpx.TimeoutException:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Connection to {device_url} timed out. Check network connectivity."
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Failed to connect to WLED device at {device_url}: {e}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create device in storage (LED count auto-detected from WLED)
|
# Create device in storage
|
||||||
device = store.create_device(
|
device = store.create_device(
|
||||||
name=device_data.name,
|
name=device_data.name,
|
||||||
url=device_data.url,
|
url=device_data.url,
|
||||||
led_count=wled_led_count,
|
led_count=wled_led_count,
|
||||||
|
device_type=device_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register in processor manager for health monitoring
|
# Register in processor manager for health monitoring
|
||||||
@@ -108,6 +120,7 @@ async def create_device(
|
|||||||
device_url=device.url,
|
device_url=device.url,
|
||||||
led_count=device.led_count,
|
led_count=device.led_count,
|
||||||
calibration=device.calibration,
|
calibration=device.calibration,
|
||||||
|
device_type=device.device_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
return _device_to_response(device)
|
return _device_to_response(device)
|
||||||
@@ -234,6 +247,7 @@ async def get_device_state(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
state = manager.get_device_health_dict(device_id)
|
state = manager.get_device_health_dict(device_id)
|
||||||
|
state["device_type"] = device.device_type
|
||||||
return DeviceStateResponse(**state)
|
return DeviceStateResponse(**state)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
@@ -251,6 +265,8 @@ async def get_device_brightness(
|
|||||||
device = store.get_device(device_id)
|
device = store.get_device(device_id)
|
||||||
if not device:
|
if not device:
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||||
|
if "brightness_control" not in get_device_capabilities(device.device_type):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||||
@@ -275,6 +291,8 @@ async def set_device_brightness(
|
|||||||
device = store.get_device(device_id)
|
device = store.get_device(device_id)
|
||||||
if not device:
|
if not device:
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||||
|
if "brightness_control" not in get_device_capabilities(device.device_type):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||||
|
|
||||||
bri = body.get("brightness")
|
bri = body.get("brightness")
|
||||||
if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255:
|
if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255:
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings:
|
|||||||
settings = ProcessingSettings(
|
settings = ProcessingSettings(
|
||||||
display_index=schema.display_index,
|
display_index=schema.display_index,
|
||||||
fps=schema.fps,
|
fps=schema.fps,
|
||||||
border_width=schema.border_width,
|
|
||||||
interpolation_mode=schema.interpolation_mode,
|
interpolation_mode=schema.interpolation_mode,
|
||||||
brightness=schema.brightness,
|
brightness=schema.brightness,
|
||||||
smoothing=schema.smoothing,
|
smoothing=schema.smoothing,
|
||||||
@@ -86,7 +85,6 @@ def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchem
|
|||||||
return ProcessingSettingsSchema(
|
return ProcessingSettingsSchema(
|
||||||
display_index=settings.display_index,
|
display_index=settings.display_index,
|
||||||
fps=settings.fps,
|
fps=settings.fps,
|
||||||
border_width=settings.border_width,
|
|
||||||
interpolation_mode=settings.interpolation_mode,
|
interpolation_mode=settings.interpolation_mode,
|
||||||
brightness=settings.brightness,
|
brightness=settings.brightness,
|
||||||
smoothing=settings.smoothing,
|
smoothing=settings.smoothing,
|
||||||
@@ -468,7 +466,6 @@ async def update_target_settings(
|
|||||||
new_settings = ProcessingSettings(
|
new_settings = ProcessingSettings(
|
||||||
display_index=settings.display_index if 'display_index' in sent else existing.display_index,
|
display_index=settings.display_index if 'display_index' in sent else existing.display_index,
|
||||||
fps=settings.fps if 'fps' in sent else existing.fps,
|
fps=settings.fps if 'fps' in sent else existing.fps,
|
||||||
border_width=settings.border_width if 'border_width' in sent else existing.border_width,
|
|
||||||
interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode,
|
interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode,
|
||||||
brightness=settings.brightness if 'brightness' in sent else existing.brightness,
|
brightness=settings.brightness if 'brightness' in sent else existing.brightness,
|
||||||
gamma=existing.gamma,
|
gamma=existing.gamma,
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
|
|
||||||
class DeviceCreate(BaseModel):
|
class DeviceCreate(BaseModel):
|
||||||
"""Request to create/attach a WLED device."""
|
"""Request to create/attach an LED device."""
|
||||||
|
|
||||||
name: str = Field(description="Device name", min_length=1, max_length=100)
|
name: str = Field(description="Device name", min_length=1, max_length=100)
|
||||||
url: str = Field(description="WLED device URL (e.g., http://192.168.1.100)")
|
url: str = Field(description="Device URL (e.g., http://192.168.1.100)")
|
||||||
|
device_type: str = Field(default="wled", description="LED device type (e.g., wled)")
|
||||||
|
|
||||||
|
|
||||||
class DeviceUpdate(BaseModel):
|
class DeviceUpdate(BaseModel):
|
||||||
@@ -53,6 +54,7 @@ class Calibration(BaseModel):
|
|||||||
# Skip LEDs at start/end of strip
|
# Skip LEDs at start/end of strip
|
||||||
skip_leds_start: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the start of the strip")
|
skip_leds_start: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the start of the strip")
|
||||||
skip_leds_end: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the end of the strip")
|
skip_leds_end: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the end of the strip")
|
||||||
|
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for edge sampling")
|
||||||
|
|
||||||
|
|
||||||
class CalibrationTestModeRequest(BaseModel):
|
class CalibrationTestModeRequest(BaseModel):
|
||||||
@@ -79,7 +81,8 @@ class DeviceResponse(BaseModel):
|
|||||||
|
|
||||||
id: str = Field(description="Device ID")
|
id: str = Field(description="Device ID")
|
||||||
name: str = Field(description="Device name")
|
name: str = Field(description="Device name")
|
||||||
url: str = Field(description="WLED device URL")
|
url: str = Field(description="Device URL")
|
||||||
|
device_type: str = Field(default="wled", description="LED device type")
|
||||||
led_count: int = Field(description="Total number of LEDs")
|
led_count: int = Field(description="Total number of LEDs")
|
||||||
enabled: bool = Field(description="Whether device is enabled")
|
enabled: bool = Field(description="Whether device is enabled")
|
||||||
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
||||||
@@ -98,14 +101,15 @@ class DeviceStateResponse(BaseModel):
|
|||||||
"""Device health/connection state response."""
|
"""Device health/connection state response."""
|
||||||
|
|
||||||
device_id: str = Field(description="Device ID")
|
device_id: str = Field(description="Device ID")
|
||||||
wled_online: bool = Field(default=False, description="Whether WLED device is reachable")
|
device_type: str = Field(default="wled", description="LED device type")
|
||||||
wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms")
|
device_online: bool = Field(default=False, description="Whether device is reachable")
|
||||||
wled_name: Optional[str] = Field(None, description="WLED device name")
|
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
|
||||||
wled_version: Optional[str] = Field(None, description="WLED firmware version")
|
device_name: Optional[str] = Field(None, description="Device name reported by firmware")
|
||||||
wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device")
|
device_version: Optional[str] = Field(None, description="Firmware version")
|
||||||
wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs")
|
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
|
||||||
wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
|
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
|
||||||
wled_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
|
||||||
wled_error: Optional[str] = Field(None, description="Last health check error")
|
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
||||||
|
device_error: Optional[str] = Field(None, description="Last health check error")
|
||||||
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
|
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
|
||||||
test_mode_edges: List[str] = Field(default_factory=list, description="Currently lit edges in test mode")
|
test_mode_edges: List[str] = Field(default_factory=list, description="Currently lit edges in test mode")
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class ProcessingSettings(BaseModel):
|
|||||||
|
|
||||||
display_index: int = Field(default=0, description="Display to capture", ge=0)
|
display_index: int = Field(default=0, description="Display to capture", ge=0)
|
||||||
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
|
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
|
||||||
border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100)
|
|
||||||
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
|
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
|
||||||
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
|
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0)
|
smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0)
|
||||||
@@ -76,8 +75,8 @@ class PictureTargetCreate(BaseModel):
|
|||||||
"""Request to create a picture target."""
|
"""Request to create a picture target."""
|
||||||
|
|
||||||
name: str = Field(description="Target name", min_length=1, max_length=100)
|
name: str = Field(description="Target name", min_length=1, max_length=100)
|
||||||
target_type: str = Field(default="wled", description="Target type (wled, key_colors)")
|
target_type: str = Field(default="led", description="Target type (led, key_colors)")
|
||||||
device_id: str = Field(default="", description="WLED device ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID")
|
picture_source_id: str = Field(default="", description="Picture source ID")
|
||||||
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
|
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
||||||
@@ -132,15 +131,15 @@ class TargetProcessingState(BaseModel):
|
|||||||
display_index: int = Field(default=0, description="Current display index")
|
display_index: int = Field(default=0, description="Current display index")
|
||||||
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
||||||
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
||||||
wled_online: bool = Field(default=False, description="Whether WLED device is reachable")
|
device_online: bool = Field(default=False, description="Whether device is reachable")
|
||||||
wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms")
|
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
|
||||||
wled_name: Optional[str] = Field(None, description="WLED device name")
|
device_name: Optional[str] = Field(None, description="Device name reported by firmware")
|
||||||
wled_version: Optional[str] = Field(None, description="WLED firmware version")
|
device_version: Optional[str] = Field(None, description="Firmware version")
|
||||||
wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device")
|
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
|
||||||
wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs")
|
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
|
||||||
wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
|
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
|
||||||
wled_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
||||||
wled_error: Optional[str] = Field(None, description="Last health check error")
|
device_error: Optional[str] = Field(None, description="Last health check error")
|
||||||
|
|
||||||
|
|
||||||
class TargetMetricsResponse(BaseModel):
|
class TargetMetricsResponse(BaseModel):
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ class CalibrationConfig:
|
|||||||
# Skip LEDs: black out N LEDs at the start/end of the strip
|
# Skip LEDs: black out N LEDs at the start/end of the strip
|
||||||
skip_leds_start: int = 0
|
skip_leds_start: int = 0
|
||||||
skip_leds_end: int = 0
|
skip_leds_end: int = 0
|
||||||
|
# Border width: how many pixels from the screen edge to sample
|
||||||
|
border_width: int = 10
|
||||||
|
|
||||||
def build_segments(self) -> List[CalibrationSegment]:
|
def build_segments(self) -> List[CalibrationSegment]:
|
||||||
"""Derive segment list from core parameters."""
|
"""Derive segment list from core parameters."""
|
||||||
@@ -463,6 +465,7 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
|
|||||||
span_left_end=data.get("span_left_end", 1.0),
|
span_left_end=data.get("span_left_end", 1.0),
|
||||||
skip_leds_start=data.get("skip_leds_start", 0),
|
skip_leds_start=data.get("skip_leds_start", 0),
|
||||||
skip_leds_end=data.get("skip_leds_end", 0),
|
skip_leds_end=data.get("skip_leds_end", 0),
|
||||||
|
border_width=data.get("border_width", 10),
|
||||||
)
|
)
|
||||||
|
|
||||||
config.validate()
|
config.validate()
|
||||||
@@ -506,4 +509,6 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
|||||||
result["skip_leds_start"] = config.skip_leds_start
|
result["skip_leds_start"] = config.skip_leds_start
|
||||||
if config.skip_leds_end > 0:
|
if config.skip_leds_end > 0:
|
||||||
result["skip_leds_end"] = config.skip_leds_end
|
result["skip_leds_end"] = config.skip_leds_end
|
||||||
|
if config.border_width != 10:
|
||||||
|
result["border_width"] = config.border_width
|
||||||
return result
|
return result
|
||||||
|
|||||||
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,
|
calculate_median_color,
|
||||||
extract_border_pixels,
|
extract_border_pixels,
|
||||||
)
|
)
|
||||||
from wled_controller.core.wled_client import WLEDClient
|
from wled_controller.core.led_client import (
|
||||||
|
DeviceHealth,
|
||||||
|
LEDClient,
|
||||||
|
check_device_health,
|
||||||
|
create_led_client,
|
||||||
|
)
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -87,27 +92,12 @@ def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing):
|
|||||||
)
|
)
|
||||||
return colors
|
return colors
|
||||||
|
|
||||||
# WLED LED bus type codes from const.h → human-readable names
|
|
||||||
WLED_LED_TYPES: Dict[int, str] = {
|
|
||||||
18: "WS2812 1ch", 19: "WS2812 1ch x3", 20: "WS2812 CCT", 21: "WS2812 WWA",
|
|
||||||
22: "WS2812B", 23: "GS8608", 24: "WS2811 400kHz", 25: "TM1829",
|
|
||||||
26: "UCS8903", 27: "APA106", 28: "FW1906", 29: "UCS8904",
|
|
||||||
30: "SK6812 RGBW", 31: "TM1814", 32: "WS2805", 33: "TM1914", 34: "SM16825",
|
|
||||||
40: "On/Off", 41: "PWM 1ch", 42: "PWM 2ch", 43: "PWM 3ch",
|
|
||||||
44: "PWM 4ch", 45: "PWM 5ch", 46: "PWM 6ch",
|
|
||||||
50: "WS2801", 51: "APA102", 52: "LPD8806", 53: "P9813", 54: "LPD6803",
|
|
||||||
65: "HUB75 HS", 66: "HUB75 QS",
|
|
||||||
80: "DDP RGB", 81: "E1.31", 82: "Art-Net", 88: "DDP RGBW", 89: "Art-Net RGBW",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProcessingSettings:
|
class ProcessingSettings:
|
||||||
"""Settings for screen processing."""
|
"""Settings for screen processing."""
|
||||||
|
|
||||||
display_index: int = 0
|
display_index: int = 0
|
||||||
fps: int = 30
|
fps: int = 30
|
||||||
border_width: int = 10
|
|
||||||
brightness: float = 1.0
|
brightness: float = 1.0
|
||||||
gamma: float = 2.2
|
gamma: float = 2.2
|
||||||
saturation: float = 1.0
|
saturation: float = 1.0
|
||||||
@@ -117,21 +107,6 @@ class ProcessingSettings:
|
|||||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DeviceHealth:
|
|
||||||
"""Health check result for a WLED device (GET /json/info)."""
|
|
||||||
|
|
||||||
online: bool = False
|
|
||||||
latency_ms: Optional[float] = None
|
|
||||||
last_checked: Optional[datetime] = None
|
|
||||||
wled_name: Optional[str] = None
|
|
||||||
wled_version: Optional[str] = None
|
|
||||||
wled_led_count: Optional[int] = None
|
|
||||||
wled_rgbw: Optional[bool] = None
|
|
||||||
wled_led_type: Optional[str] = None
|
|
||||||
error: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProcessingMetrics:
|
class ProcessingMetrics:
|
||||||
"""Metrics for processing performance."""
|
"""Metrics for processing performance."""
|
||||||
@@ -150,12 +125,13 @@ class ProcessingMetrics:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DeviceState:
|
class DeviceState:
|
||||||
"""State for a registered WLED device (health monitoring + calibration)."""
|
"""State for a registered LED device (health monitoring + calibration)."""
|
||||||
|
|
||||||
device_id: str
|
device_id: str
|
||||||
device_url: str
|
device_url: str
|
||||||
led_count: int
|
led_count: int
|
||||||
calibration: CalibrationConfig
|
calibration: CalibrationConfig
|
||||||
|
device_type: str = "wled"
|
||||||
health: DeviceHealth = field(default_factory=DeviceHealth)
|
health: DeviceHealth = field(default_factory=DeviceHealth)
|
||||||
health_task: Optional[asyncio.Task] = None
|
health_task: Optional[asyncio.Task] = None
|
||||||
# Calibration test mode (works independently of target processing)
|
# Calibration test mode (works independently of target processing)
|
||||||
@@ -174,7 +150,7 @@ class TargetState:
|
|||||||
settings: ProcessingSettings
|
settings: ProcessingSettings
|
||||||
calibration: CalibrationConfig
|
calibration: CalibrationConfig
|
||||||
picture_source_id: str = ""
|
picture_source_id: str = ""
|
||||||
wled_client: Optional[WLEDClient] = None
|
led_client: Optional[LEDClient] = None
|
||||||
pixel_mapper: Optional[PixelMapper] = None
|
pixel_mapper: Optional[PixelMapper] = None
|
||||||
is_running: bool = False
|
is_running: bool = False
|
||||||
task: Optional[asyncio.Task] = None
|
task: Optional[asyncio.Task] = None
|
||||||
@@ -187,8 +163,8 @@ class TargetState:
|
|||||||
resolved_engine_config: Optional[dict] = None
|
resolved_engine_config: Optional[dict] = None
|
||||||
# LiveStream: runtime frame source (shared via LiveStreamManager)
|
# LiveStream: runtime frame source (shared via LiveStreamManager)
|
||||||
live_stream: Optional[LiveStream] = None
|
live_stream: Optional[LiveStream] = None
|
||||||
# WLED state snapshot taken before streaming starts (to restore on stop)
|
# Device state snapshot taken before streaming starts (to restore on stop)
|
||||||
wled_state_before: Optional[dict] = None
|
device_state_before: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -245,14 +221,16 @@ class ProcessorManager:
|
|||||||
device_url: str,
|
device_url: str,
|
||||||
led_count: int,
|
led_count: int,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
calibration: Optional[CalibrationConfig] = None,
|
||||||
|
device_type: str = "wled",
|
||||||
):
|
):
|
||||||
"""Register a device for health monitoring.
|
"""Register a device for health monitoring.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_id: Unique device identifier
|
device_id: Unique device identifier
|
||||||
device_url: WLED device URL
|
device_url: Device URL
|
||||||
led_count: Number of LEDs
|
led_count: Number of LEDs
|
||||||
calibration: Calibration config (creates default if None)
|
calibration: Calibration config (creates default if None)
|
||||||
|
device_type: LED device type (e.g. "wled")
|
||||||
"""
|
"""
|
||||||
if device_id in self._devices:
|
if device_id in self._devices:
|
||||||
raise ValueError(f"Device {device_id} already registered")
|
raise ValueError(f"Device {device_id} already registered")
|
||||||
@@ -265,6 +243,7 @@ class ProcessorManager:
|
|||||||
device_url=device_url,
|
device_url=device_url,
|
||||||
led_count=led_count,
|
led_count=led_count,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
|
device_type=device_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._devices[device_id] = state
|
self._devices[device_id] = state
|
||||||
@@ -356,11 +335,11 @@ class ProcessorManager:
|
|||||||
"online": h.online,
|
"online": h.online,
|
||||||
"latency_ms": h.latency_ms,
|
"latency_ms": h.latency_ms,
|
||||||
"last_checked": h.last_checked,
|
"last_checked": h.last_checked,
|
||||||
"wled_name": h.wled_name,
|
"device_name": h.device_name,
|
||||||
"wled_version": h.wled_version,
|
"device_version": h.device_version,
|
||||||
"wled_led_count": h.wled_led_count,
|
"device_led_count": h.device_led_count,
|
||||||
"wled_rgbw": h.wled_rgbw,
|
"device_rgbw": h.device_rgbw,
|
||||||
"wled_led_type": h.wled_led_type,
|
"device_led_type": h.device_led_type,
|
||||||
"error": h.error,
|
"error": h.error,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,15 +352,15 @@ class ProcessorManager:
|
|||||||
h = ds.health
|
h = ds.health
|
||||||
return {
|
return {
|
||||||
"device_id": device_id,
|
"device_id": device_id,
|
||||||
"wled_online": h.online,
|
"device_online": h.online,
|
||||||
"wled_latency_ms": h.latency_ms,
|
"device_latency_ms": h.latency_ms,
|
||||||
"wled_name": h.wled_name,
|
"device_name": h.device_name,
|
||||||
"wled_version": h.wled_version,
|
"device_version": h.device_version,
|
||||||
"wled_led_count": h.wled_led_count,
|
"device_led_count": h.device_led_count,
|
||||||
"wled_rgbw": h.wled_rgbw,
|
"device_rgbw": h.device_rgbw,
|
||||||
"wled_led_type": h.wled_led_type,
|
"device_led_type": h.device_led_type,
|
||||||
"wled_last_checked": h.last_checked,
|
"device_last_checked": h.last_checked,
|
||||||
"wled_error": h.error,
|
"device_error": h.error,
|
||||||
"test_mode": ds.test_mode_active,
|
"test_mode": ds.test_mode_active,
|
||||||
"test_mode_edges": list(ds.test_mode_edges.keys()),
|
"test_mode_edges": list(ds.test_mode_edges.keys()),
|
||||||
}
|
}
|
||||||
@@ -548,31 +527,22 @@ class ProcessorManager:
|
|||||||
# Resolve stream settings
|
# Resolve stream settings
|
||||||
self._resolve_stream_settings(state)
|
self._resolve_stream_settings(state)
|
||||||
|
|
||||||
# Snapshot WLED state before streaming changes it
|
# Determine device type from device state
|
||||||
try:
|
device_type = "wled"
|
||||||
async with httpx.AsyncClient(timeout=5) as http:
|
if state.device_id in self._devices:
|
||||||
resp = await http.get(f"{state.device_url}/json/state")
|
device_type = self._devices[state.device_id].device_type
|
||||||
resp.raise_for_status()
|
|
||||||
wled_state = resp.json()
|
|
||||||
state.wled_state_before = {
|
|
||||||
"on": wled_state.get("on", True),
|
|
||||||
"lor": wled_state.get("lor", 0),
|
|
||||||
}
|
|
||||||
if "AudioReactive" in wled_state:
|
|
||||||
state.wled_state_before["AudioReactive"] = wled_state["AudioReactive"]
|
|
||||||
logger.info(f"Saved WLED state before streaming: {state.wled_state_before}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not snapshot WLED state: {e}")
|
|
||||||
state.wled_state_before = None
|
|
||||||
|
|
||||||
# Connect to WLED device (always use DDP for low-latency UDP streaming)
|
# Connect to LED device via factory
|
||||||
try:
|
try:
|
||||||
state.wled_client = WLEDClient(state.device_url, use_ddp=True)
|
state.led_client = create_led_client(device_type, state.device_url, use_ddp=True)
|
||||||
await state.wled_client.connect()
|
await state.led_client.connect()
|
||||||
logger.info(f"Target {target_id} using DDP protocol ({state.led_count} LEDs)")
|
logger.info(f"Target {target_id} connected to {device_type} device ({state.led_count} LEDs)")
|
||||||
|
|
||||||
|
# Snapshot device state before streaming (type-specific, e.g. WLED saves on/lor/AudioReactive)
|
||||||
|
state.device_state_before = await state.led_client.snapshot_device_state()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to WLED device for target {target_id}: {e}")
|
logger.error(f"Failed to connect to LED device for target {target_id}: {e}")
|
||||||
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
raise RuntimeError(f"Failed to connect to LED device: {e}")
|
||||||
|
|
||||||
# Acquire live stream via LiveStreamManager (shared across targets)
|
# Acquire live stream via LiveStreamManager (shared across targets)
|
||||||
try:
|
try:
|
||||||
@@ -589,8 +559,8 @@ class ProcessorManager:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize live stream for target {target_id}: {e}")
|
logger.error(f"Failed to initialize live stream for target {target_id}: {e}")
|
||||||
if state.wled_client:
|
if state.led_client:
|
||||||
await state.wled_client.disconnect()
|
await state.led_client.close()
|
||||||
raise RuntimeError(f"Failed to initialize live stream: {e}")
|
raise RuntimeError(f"Failed to initialize live stream: {e}")
|
||||||
|
|
||||||
# Initialize pixel mapper
|
# Initialize pixel mapper
|
||||||
@@ -632,23 +602,15 @@ class ProcessorManager:
|
|||||||
pass
|
pass
|
||||||
state.task = None
|
state.task = None
|
||||||
|
|
||||||
# Restore WLED state
|
# Restore device state (type-specific, e.g. WLED restores on/lor/AudioReactive)
|
||||||
if state.wled_state_before:
|
if state.led_client and state.device_state_before:
|
||||||
try:
|
await state.led_client.restore_device_state(state.device_state_before)
|
||||||
async with httpx.AsyncClient(timeout=5) as http:
|
state.device_state_before = None
|
||||||
await http.post(
|
|
||||||
f"{state.device_url}/json/state",
|
|
||||||
json=state.wled_state_before,
|
|
||||||
)
|
|
||||||
logger.info(f"Restored WLED state: {state.wled_state_before}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not restore WLED state: {e}")
|
|
||||||
state.wled_state_before = None
|
|
||||||
|
|
||||||
# Close WLED connection
|
# Close LED connection
|
||||||
if state.wled_client:
|
if state.led_client:
|
||||||
await state.wled_client.close()
|
await state.led_client.close()
|
||||||
state.wled_client = None
|
state.led_client = None
|
||||||
|
|
||||||
# Release live stream
|
# Release live stream
|
||||||
if state.live_stream:
|
if state.live_stream:
|
||||||
@@ -667,8 +629,8 @@ class ProcessorManager:
|
|||||||
|
|
||||||
target_fps = settings.fps
|
target_fps = settings.fps
|
||||||
smoothing = settings.smoothing
|
smoothing = settings.smoothing
|
||||||
border_width = settings.border_width
|
border_width = state.calibration.border_width
|
||||||
wled_brightness = settings.brightness
|
led_brightness = settings.brightness
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Processing loop started for target {target_id} "
|
f"Processing loop started for target {target_id} "
|
||||||
@@ -708,15 +670,15 @@ class ProcessorManager:
|
|||||||
|
|
||||||
# Skip processing + send if the frame hasn't changed
|
# Skip processing + send if the frame hasn't changed
|
||||||
if capture is prev_capture:
|
if capture is prev_capture:
|
||||||
# Keepalive: resend last colors to prevent WLED exiting live mode
|
# Keepalive: resend last colors to prevent device exiting live mode
|
||||||
if state.previous_colors and (loop_start - last_send_time) >= standby_interval:
|
if state.previous_colors and (loop_start - last_send_time) >= standby_interval:
|
||||||
if not state.is_running or state.wled_client is None:
|
if not state.is_running or state.led_client is None:
|
||||||
break
|
break
|
||||||
brightness_value = int(wled_brightness * 255)
|
brightness_value = int(led_brightness * 255)
|
||||||
if state.wled_client.use_ddp:
|
if state.led_client.supports_fast_send:
|
||||||
state.wled_client.send_pixels_fast(state.previous_colors, brightness=brightness_value)
|
state.led_client.send_pixels_fast(state.previous_colors, brightness=brightness_value)
|
||||||
else:
|
else:
|
||||||
await state.wled_client.send_pixels(state.previous_colors, brightness=brightness_value)
|
await state.led_client.send_pixels(state.previous_colors, brightness=brightness_value)
|
||||||
last_send_time = time.time()
|
last_send_time = time.time()
|
||||||
send_timestamps.append(last_send_time)
|
send_timestamps.append(last_send_time)
|
||||||
state.metrics.frames_keepalive += 1
|
state.metrics.frames_keepalive += 1
|
||||||
@@ -737,14 +699,14 @@ class ProcessorManager:
|
|||||||
state.pixel_mapper, state.previous_colors, smoothing,
|
state.pixel_mapper, state.previous_colors, smoothing,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send to WLED with device brightness
|
# Send to LED device with brightness
|
||||||
if not state.is_running or state.wled_client is None:
|
if not state.is_running or state.led_client is None:
|
||||||
break
|
break
|
||||||
brightness_value = int(wled_brightness * 255)
|
brightness_value = int(led_brightness * 255)
|
||||||
if state.wled_client.use_ddp:
|
if state.led_client.supports_fast_send:
|
||||||
state.wled_client.send_pixels_fast(led_colors, brightness=brightness_value)
|
state.led_client.send_pixels_fast(led_colors, brightness=brightness_value)
|
||||||
else:
|
else:
|
||||||
await state.wled_client.send_pixels(led_colors, brightness=brightness_value)
|
await state.led_client.send_pixels(led_colors, brightness=brightness_value)
|
||||||
last_send_time = time.time()
|
last_send_time = time.time()
|
||||||
send_timestamps.append(last_send_time)
|
send_timestamps.append(last_send_time)
|
||||||
|
|
||||||
@@ -802,20 +764,20 @@ class ProcessorManager:
|
|||||||
state = self._targets[target_id]
|
state = self._targets[target_id]
|
||||||
metrics = state.metrics
|
metrics = state.metrics
|
||||||
|
|
||||||
# Include WLED health info from the device
|
# Include device health info
|
||||||
health_info = {}
|
health_info = {}
|
||||||
if state.device_id in self._devices:
|
if state.device_id in self._devices:
|
||||||
h = self._devices[state.device_id].health
|
h = self._devices[state.device_id].health
|
||||||
health_info = {
|
health_info = {
|
||||||
"wled_online": h.online,
|
"device_online": h.online,
|
||||||
"wled_latency_ms": h.latency_ms,
|
"device_latency_ms": h.latency_ms,
|
||||||
"wled_name": h.wled_name,
|
"device_name": h.device_name,
|
||||||
"wled_version": h.wled_version,
|
"device_version": h.device_version,
|
||||||
"wled_led_count": h.wled_led_count,
|
"device_led_count": h.device_led_count,
|
||||||
"wled_rgbw": h.wled_rgbw,
|
"device_rgbw": h.device_rgbw,
|
||||||
"wled_led_type": h.wled_led_type,
|
"device_led_type": h.device_led_type,
|
||||||
"wled_last_checked": h.last_checked,
|
"device_last_checked": h.last_checked,
|
||||||
"wled_error": h.error,
|
"device_error": h.error,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -913,38 +875,38 @@ class ProcessorManager:
|
|||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if a target is running for this device (use its WLED client)
|
# Check if a target is running for this device (use its LED client)
|
||||||
active_client = None
|
active_client = None
|
||||||
for ts in self._targets.values():
|
for ts in self._targets.values():
|
||||||
if ts.device_id == device_id and ts.is_running and ts.wled_client:
|
if ts.device_id == device_id and ts.is_running and ts.led_client:
|
||||||
active_client = ts.wled_client
|
active_client = ts.led_client
|
||||||
break
|
break
|
||||||
|
|
||||||
if active_client:
|
if active_client:
|
||||||
await active_client.send_pixels(pixels)
|
await active_client.send_pixels(pixels)
|
||||||
else:
|
else:
|
||||||
async with WLEDClient(ds.device_url, use_ddp=True) as wled:
|
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client:
|
||||||
await wled.send_pixels(pixels)
|
await client.send_pixels(pixels)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send test pixels for {device_id}: {e}")
|
logger.error(f"Failed to send test pixels for {device_id}: {e}")
|
||||||
|
|
||||||
async def _send_clear_pixels(self, device_id: str) -> None:
|
async def _send_clear_pixels(self, device_id: str) -> None:
|
||||||
"""Send all-black pixels to clear WLED output."""
|
"""Send all-black pixels to clear LED output."""
|
||||||
ds = self._devices[device_id]
|
ds = self._devices[device_id]
|
||||||
pixels = [(0, 0, 0)] * ds.led_count
|
pixels = [(0, 0, 0)] * ds.led_count
|
||||||
|
|
||||||
try:
|
try:
|
||||||
active_client = None
|
active_client = None
|
||||||
for ts in self._targets.values():
|
for ts in self._targets.values():
|
||||||
if ts.device_id == device_id and ts.is_running and ts.wled_client:
|
if ts.device_id == device_id and ts.is_running and ts.led_client:
|
||||||
active_client = ts.wled_client
|
active_client = ts.led_client
|
||||||
break
|
break
|
||||||
|
|
||||||
if active_client:
|
if active_client:
|
||||||
await active_client.send_pixels(pixels)
|
await active_client.send_pixels(pixels)
|
||||||
else:
|
else:
|
||||||
async with WLEDClient(ds.device_url, use_ddp=True) as wled:
|
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client:
|
||||||
await wled.send_pixels(pixels)
|
await client.send_pixels(pixels)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to clear pixels for {device_id}: {e}")
|
logger.error(f"Failed to clear pixels for {device_id}: {e}")
|
||||||
|
|
||||||
@@ -1050,58 +1012,14 @@ class ProcessorManager:
|
|||||||
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
|
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
|
||||||
|
|
||||||
async def _check_device_health(self, device_id: str):
|
async def _check_device_health(self, device_id: str):
|
||||||
"""Check device health via GET /json/info."""
|
"""Check device health via the LED client abstraction."""
|
||||||
state = self._devices.get(device_id)
|
state = self._devices.get(device_id)
|
||||||
if not state:
|
if not state:
|
||||||
return
|
return
|
||||||
url = state.device_url.rstrip("/")
|
client = await self._get_http_client()
|
||||||
start = time.time()
|
state.health = await check_device_health(
|
||||||
try:
|
state.device_type, state.device_url, client, state.health,
|
||||||
client = await self._get_http_client()
|
)
|
||||||
response = await client.get(f"{url}/json/info")
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
latency = (time.time() - start) * 1000
|
|
||||||
leds_info = data.get("leds", {})
|
|
||||||
wled_led_count = leds_info.get("count")
|
|
||||||
|
|
||||||
# Fetch LED type from /json/cfg once (it's static config)
|
|
||||||
wled_led_type = state.health.wled_led_type
|
|
||||||
if wled_led_type is None:
|
|
||||||
try:
|
|
||||||
cfg = await client.get(f"{url}/json/cfg")
|
|
||||||
cfg.raise_for_status()
|
|
||||||
cfg_data = cfg.json()
|
|
||||||
ins = cfg_data.get("hw", {}).get("led", {}).get("ins", [])
|
|
||||||
if ins:
|
|
||||||
type_code = ins[0].get("type", 0)
|
|
||||||
wled_led_type = WLED_LED_TYPES.get(type_code, f"Type {type_code}")
|
|
||||||
except Exception as cfg_err:
|
|
||||||
logger.debug(f"Could not fetch LED type for {device_id}: {cfg_err}")
|
|
||||||
|
|
||||||
state.health = DeviceHealth(
|
|
||||||
online=True,
|
|
||||||
latency_ms=round(latency, 1),
|
|
||||||
last_checked=datetime.utcnow(),
|
|
||||||
wled_name=data.get("name"),
|
|
||||||
wled_version=data.get("ver"),
|
|
||||||
wled_led_count=wled_led_count,
|
|
||||||
wled_rgbw=leds_info.get("rgbw", False),
|
|
||||||
wled_led_type=wled_led_type,
|
|
||||||
error=None,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
state.health = DeviceHealth(
|
|
||||||
online=False,
|
|
||||||
latency_ms=None,
|
|
||||||
last_checked=datetime.utcnow(),
|
|
||||||
wled_name=state.health.wled_name,
|
|
||||||
wled_version=state.health.wled_version,
|
|
||||||
wled_led_count=state.health.wled_led_count,
|
|
||||||
wled_rgbw=state.health.wled_rgbw,
|
|
||||||
wled_led_type=state.health.wled_led_type,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ===== KEY COLORS TARGET MANAGEMENT =====
|
# ===== KEY COLORS TARGET MANAGEMENT =====
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""WLED client for controlling LED devices via HTTP or DDP."""
|
"""WLED client for controlling LED devices via HTTP or DDP."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
from typing import List, Tuple, Optional, Dict, Any
|
from typing import List, Tuple, Optional, Dict, Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -10,9 +12,23 @@ import numpy as np
|
|||||||
|
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
from wled_controller.core.ddp_client import BusConfig, DDPClient
|
from wled_controller.core.ddp_client import BusConfig, DDPClient
|
||||||
|
from wled_controller.core.led_client import DeviceHealth, LEDClient
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# WLED LED bus type codes from const.h → human-readable names
|
||||||
|
WLED_LED_TYPES: Dict[int, str] = {
|
||||||
|
18: "WS2812 1ch", 19: "WS2812 1ch x3", 20: "WS2812 CCT", 21: "WS2812 WWA",
|
||||||
|
22: "WS2812B", 23: "GS8608", 24: "WS2811 400kHz", 25: "TM1829",
|
||||||
|
26: "UCS8903", 27: "APA106", 28: "FW1906", 29: "UCS8904",
|
||||||
|
30: "SK6812 RGBW", 31: "TM1814", 32: "WS2805", 33: "TM1914", 34: "SM16825",
|
||||||
|
40: "On/Off", 41: "PWM 1ch", 42: "PWM 2ch", 43: "PWM 3ch",
|
||||||
|
44: "PWM 4ch", 45: "PWM 5ch", 46: "PWM 6ch",
|
||||||
|
50: "WS2801", 51: "APA102", 52: "LPD8806", 53: "P9813", 54: "LPD6803",
|
||||||
|
65: "HUB75 HS", 66: "HUB75 QS",
|
||||||
|
80: "DDP RGB", 81: "E1.31", 82: "Art-Net", 88: "DDP RGBW", 89: "Art-Net RGBW",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WLEDInfo:
|
class WLEDInfo:
|
||||||
@@ -30,7 +46,7 @@ class WLEDInfo:
|
|||||||
buses: List[BusConfig] = field(default_factory=list) # Per-bus/GPIO config
|
buses: List[BusConfig] = field(default_factory=list) # Per-bus/GPIO config
|
||||||
|
|
||||||
|
|
||||||
class WLEDClient:
|
class WLEDClient(LEDClient):
|
||||||
"""Client for WLED devices supporting both HTTP and DDP protocols."""
|
"""Client for WLED devices supporting both HTTP and DDP protocols."""
|
||||||
|
|
||||||
# HTTP JSON API has ~10KB limit, ~500 LEDs max
|
# HTTP JSON API has ~10KB limit, ~500 LEDs max
|
||||||
@@ -67,15 +83,6 @@ class WLEDClient:
|
|||||||
self._ddp_client: Optional[DDPClient] = None
|
self._ddp_client: Optional[DDPClient] = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
"""Async context manager entry."""
|
|
||||||
await self.connect()
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
"""Async context manager exit."""
|
|
||||||
await self.close()
|
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
"""Establish connection to WLED device.
|
"""Establish connection to WLED device.
|
||||||
|
|
||||||
@@ -161,6 +168,11 @@ class WLEDClient:
|
|||||||
"""Check if connected to WLED device."""
|
"""Check if connected to WLED device."""
|
||||||
return self._connected and self._client is not None
|
return self._connected and self._client is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_fast_send(self) -> bool:
|
||||||
|
"""True when DDP is active and ready for fire-and-forget sends."""
|
||||||
|
return self.use_ddp and self._ddp_client is not None
|
||||||
|
|
||||||
async def _request(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -445,6 +457,96 @@ class WLEDClient:
|
|||||||
|
|
||||||
self._ddp_client.send_pixels_numpy(pixel_array)
|
self._ddp_client.send_pixels_numpy(pixel_array)
|
||||||
|
|
||||||
|
# ===== LEDClient abstraction methods =====
|
||||||
|
|
||||||
|
async def snapshot_device_state(self) -> Optional[dict]:
|
||||||
|
"""Snapshot WLED state before streaming (on, lor, AudioReactive)."""
|
||||||
|
if not self._client:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resp = await self._client.get(f"{self.url}/json/state")
|
||||||
|
resp.raise_for_status()
|
||||||
|
wled_state = resp.json()
|
||||||
|
state = {
|
||||||
|
"on": wled_state.get("on", True),
|
||||||
|
"lor": wled_state.get("lor", 0),
|
||||||
|
}
|
||||||
|
if "AudioReactive" in wled_state:
|
||||||
|
state["AudioReactive"] = wled_state["AudioReactive"]
|
||||||
|
logger.info(f"Saved WLED state before streaming: {state}")
|
||||||
|
return state
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not snapshot WLED state: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def restore_device_state(self, state: Optional[dict]) -> None:
|
||||||
|
"""Restore WLED state after streaming."""
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as http:
|
||||||
|
await http.post(f"{self.url}/json/state", json=state)
|
||||||
|
logger.info(f"Restored WLED state: {state}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not restore WLED state: {e}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def check_health(
|
||||||
|
cls,
|
||||||
|
url: str,
|
||||||
|
http_client,
|
||||||
|
prev_health: Optional[DeviceHealth] = None,
|
||||||
|
) -> DeviceHealth:
|
||||||
|
"""WLED health check via GET /json/info (+ /json/cfg for LED type)."""
|
||||||
|
url = url.rstrip("/")
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
response = await http_client.get(f"{url}/json/info")
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
latency = (time.time() - start) * 1000
|
||||||
|
leds_info = data.get("leds", {})
|
||||||
|
|
||||||
|
# Fetch LED type from /json/cfg once (it's static config)
|
||||||
|
device_led_type = prev_health.device_led_type if prev_health else None
|
||||||
|
if device_led_type is None:
|
||||||
|
try:
|
||||||
|
cfg = await http_client.get(f"{url}/json/cfg")
|
||||||
|
cfg.raise_for_status()
|
||||||
|
cfg_data = cfg.json()
|
||||||
|
ins = cfg_data.get("hw", {}).get("led", {}).get("ins", [])
|
||||||
|
if ins:
|
||||||
|
type_code = ins[0].get("type", 0)
|
||||||
|
device_led_type = WLED_LED_TYPES.get(type_code, f"Type {type_code}")
|
||||||
|
except Exception as cfg_err:
|
||||||
|
logger.debug(f"Could not fetch LED type: {cfg_err}")
|
||||||
|
|
||||||
|
return DeviceHealth(
|
||||||
|
online=True,
|
||||||
|
latency_ms=round(latency, 1),
|
||||||
|
last_checked=datetime.utcnow(),
|
||||||
|
device_name=data.get("name"),
|
||||||
|
device_version=data.get("ver"),
|
||||||
|
device_led_count=leds_info.get("count"),
|
||||||
|
device_rgbw=leds_info.get("rgbw", False),
|
||||||
|
device_led_type=device_led_type,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return DeviceHealth(
|
||||||
|
online=False,
|
||||||
|
latency_ms=None,
|
||||||
|
last_checked=datetime.utcnow(),
|
||||||
|
device_name=prev_health.device_name if prev_health else None,
|
||||||
|
device_version=prev_health.device_version if prev_health else None,
|
||||||
|
device_led_count=prev_health.device_led_count if prev_health else None,
|
||||||
|
device_rgbw=prev_health.device_rgbw if prev_health else None,
|
||||||
|
device_led_type=prev_health.device_led_type if prev_health else None,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ===== WLED-specific methods =====
|
||||||
|
|
||||||
async def set_power(self, on: bool) -> bool:
|
async def set_power(self, on: bool) -> bool:
|
||||||
"""Turn WLED device on or off.
|
"""Turn WLED device on or off.
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ async def lifespan(app: FastAPI):
|
|||||||
device_url=device.url,
|
device_url=device.url,
|
||||||
led_count=device.led_count,
|
led_count=device.led_count,
|
||||||
calibration=device.calibration,
|
calibration=device.calibration,
|
||||||
|
device_type=device.device_type,
|
||||||
)
|
)
|
||||||
logger.info(f"Registered device: {device.name} ({device.id})")
|
logger.info(f"Registered device: {device.name} ({device.id})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -614,33 +614,33 @@ async function loadDevices() {
|
|||||||
function createDeviceCard(device) {
|
function createDeviceCard(device) {
|
||||||
const state = device.state || {};
|
const state = device.state || {};
|
||||||
|
|
||||||
// WLED device health indicator
|
// Device health indicator
|
||||||
const wledOnline = state.wled_online || false;
|
const devOnline = state.device_online || false;
|
||||||
const wledLatency = state.wled_latency_ms;
|
const devLatency = state.device_latency_ms;
|
||||||
const wledName = state.wled_name;
|
const devName = state.device_name;
|
||||||
const wledVersion = state.wled_version;
|
const devVersion = state.device_version;
|
||||||
const wledLastChecked = state.wled_last_checked;
|
const devLastChecked = state.device_last_checked;
|
||||||
|
|
||||||
let healthClass, healthTitle, healthLabel;
|
let healthClass, healthTitle, healthLabel;
|
||||||
if (wledLastChecked === null || wledLastChecked === undefined) {
|
if (devLastChecked === null || devLastChecked === undefined) {
|
||||||
healthClass = 'health-unknown';
|
healthClass = 'health-unknown';
|
||||||
healthTitle = t('device.health.checking');
|
healthTitle = t('device.health.checking');
|
||||||
healthLabel = '';
|
healthLabel = '';
|
||||||
} else if (wledOnline) {
|
} else if (devOnline) {
|
||||||
healthClass = 'health-online';
|
healthClass = 'health-online';
|
||||||
healthTitle = `${t('device.health.online')}`;
|
healthTitle = `${t('device.health.online')}`;
|
||||||
if (wledName) healthTitle += ` - ${wledName}`;
|
if (devName) healthTitle += ` - ${devName}`;
|
||||||
if (wledVersion) healthTitle += ` v${wledVersion}`;
|
if (devVersion) healthTitle += ` v${devVersion}`;
|
||||||
if (wledLatency !== null && wledLatency !== undefined) healthTitle += ` (${Math.round(wledLatency)}ms)`;
|
if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
|
||||||
healthLabel = '';
|
healthLabel = '';
|
||||||
} else {
|
} else {
|
||||||
healthClass = 'health-offline';
|
healthClass = 'health-offline';
|
||||||
healthTitle = t('device.health.offline');
|
healthTitle = t('device.health.offline');
|
||||||
if (state.wled_error) healthTitle += `: ${state.wled_error}`;
|
if (state.device_error) healthTitle += `: ${state.device_error}`;
|
||||||
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
|
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ledCount = state.wled_led_count || device.led_count;
|
const ledCount = state.device_led_count || device.led_count;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card" data-device-id="${device.id}">
|
<div class="card" data-device-id="${device.id}">
|
||||||
@@ -654,9 +654,10 @@ function createDeviceCard(device) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-subtitle">
|
<div class="card-subtitle">
|
||||||
|
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
|
||||||
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
|
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
|
||||||
${state.wled_led_type ? `<span class="card-meta">🔌 ${state.wled_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||||
<span class="card-meta" title="${state.wled_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.wled_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
|
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
|
||||||
<input type="range" class="brightness-slider" min="0" max="255"
|
<input type="range" class="brightness-slider" min="0" max="255"
|
||||||
@@ -903,7 +904,8 @@ async function handleAddDevice(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = { name, url };
|
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
||||||
|
const body = { name, url, device_type: deviceType };
|
||||||
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
||||||
if (lastTemplateId) {
|
if (lastTemplateId) {
|
||||||
body.capture_template_id = lastTemplateId;
|
body.capture_template_id = lastTemplateId;
|
||||||
@@ -1060,6 +1062,10 @@ async function showCalibration(deviceId) {
|
|||||||
// Set skip LEDs
|
// Set skip LEDs
|
||||||
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
|
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
|
||||||
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
|
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
|
||||||
|
updateOffsetSkipLock();
|
||||||
|
|
||||||
|
// Set border width
|
||||||
|
document.getElementById('cal-border-width').value = calibration.border_width || 10;
|
||||||
|
|
||||||
// Initialize edge spans
|
// Initialize edge spans
|
||||||
window.edgeSpans = {
|
window.edgeSpans = {
|
||||||
@@ -1081,6 +1087,7 @@ async function showCalibration(deviceId) {
|
|||||||
spans: JSON.stringify(window.edgeSpans),
|
spans: JSON.stringify(window.edgeSpans),
|
||||||
skip_start: String(calibration.skip_leds_start || 0),
|
skip_start: String(calibration.skip_leds_start || 0),
|
||||||
skip_end: String(calibration.skip_leds_end || 0),
|
skip_end: String(calibration.skip_leds_end || 0),
|
||||||
|
border_width: String(calibration.border_width || 10),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize test mode state for this device
|
// Initialize test mode state for this device
|
||||||
@@ -1131,7 +1138,8 @@ function isCalibrationDirty() {
|
|||||||
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left ||
|
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left ||
|
||||||
JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans ||
|
JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans ||
|
||||||
document.getElementById('cal-skip-start').value !== calibrationInitialValues.skip_start ||
|
document.getElementById('cal-skip-start').value !== calibrationInitialValues.skip_start ||
|
||||||
document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end
|
document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end ||
|
||||||
|
document.getElementById('cal-border-width').value !== calibrationInitialValues.border_width
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,6 +1168,17 @@ async function closeCalibrationModal() {
|
|||||||
forceCloseCalibrationModal();
|
forceCloseCalibrationModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateOffsetSkipLock() {
|
||||||
|
const offsetEl = document.getElementById('cal-offset');
|
||||||
|
const skipStartEl = document.getElementById('cal-skip-start');
|
||||||
|
const skipEndEl = document.getElementById('cal-skip-end');
|
||||||
|
const hasOffset = parseInt(offsetEl.value || 0) > 0;
|
||||||
|
const hasSkip = parseInt(skipStartEl.value || 0) > 0 || parseInt(skipEndEl.value || 0) > 0;
|
||||||
|
skipStartEl.disabled = hasOffset;
|
||||||
|
skipEndEl.disabled = hasOffset;
|
||||||
|
offsetEl.disabled = hasSkip;
|
||||||
|
}
|
||||||
|
|
||||||
function updateCalibrationPreview() {
|
function updateCalibrationPreview() {
|
||||||
// Calculate total from edge inputs
|
// Calculate total from edge inputs
|
||||||
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
|
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
|
||||||
@@ -1774,6 +1793,7 @@ async function saveCalibration() {
|
|||||||
span_left_end: spans.left?.end ?? 1,
|
span_left_end: spans.left?.end ?? 1,
|
||||||
skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0),
|
skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0),
|
||||||
skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0),
|
skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0),
|
||||||
|
border_width: parseInt(document.getElementById('cal-border-width').value) || 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1935,11 +1955,13 @@ const calibrationTutorialSteps = [
|
|||||||
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
|
{ selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' },
|
||||||
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
|
{ selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' },
|
||||||
{ selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
|
{ selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' },
|
||||||
{ selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
|
|
||||||
{ selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
|
{ selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' },
|
||||||
{ selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
|
{ selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' },
|
||||||
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' },
|
{ selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' },
|
||||||
{ selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds', position: 'top' }
|
{ selector: '.preview-screen-border-width', textKey: 'calibration.tip.border_width', position: 'bottom' },
|
||||||
|
{ selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' },
|
||||||
|
{ selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds_start', position: 'top' },
|
||||||
|
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const deviceTutorialSteps = [
|
const deviceTutorialSteps = [
|
||||||
@@ -3820,13 +3842,14 @@ async function showTargetEditor(targetId = null) {
|
|||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = d.id;
|
opt.value = d.id;
|
||||||
const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
|
const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
|
||||||
opt.textContent = `${d.name}${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
const devType = (d.device_type || 'wled').toUpperCase();
|
||||||
|
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
||||||
deviceSelect.appendChild(opt);
|
deviceSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Populate source select
|
// Populate source select
|
||||||
const sourceSelect = document.getElementById('target-editor-source');
|
const sourceSelect = document.getElementById('target-editor-source');
|
||||||
sourceSelect.innerHTML = '<option value="">-- No source --</option>';
|
sourceSelect.innerHTML = '';
|
||||||
sources.forEach(s => {
|
sources.forEach(s => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = s.id;
|
opt.value = s.id;
|
||||||
@@ -3847,7 +3870,6 @@ async function showTargetEditor(targetId = null) {
|
|||||||
sourceSelect.value = target.picture_source_id || '';
|
sourceSelect.value = target.picture_source_id || '';
|
||||||
document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
|
document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
|
||||||
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
|
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
|
||||||
document.getElementById('target-editor-border-width').value = target.settings?.border_width ?? 10;
|
|
||||||
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
|
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
|
||||||
document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
|
document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
|
||||||
document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
|
document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
|
||||||
@@ -3862,7 +3884,6 @@ async function showTargetEditor(targetId = null) {
|
|||||||
sourceSelect.value = '';
|
sourceSelect.value = '';
|
||||||
document.getElementById('target-editor-fps').value = 30;
|
document.getElementById('target-editor-fps').value = 30;
|
||||||
document.getElementById('target-editor-fps-value').textContent = '30';
|
document.getElementById('target-editor-fps-value').textContent = '30';
|
||||||
document.getElementById('target-editor-border-width').value = 10;
|
|
||||||
document.getElementById('target-editor-interpolation').value = 'average';
|
document.getElementById('target-editor-interpolation').value = 'average';
|
||||||
document.getElementById('target-editor-smoothing').value = 0.3;
|
document.getElementById('target-editor-smoothing').value = 0.3;
|
||||||
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
|
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
|
||||||
@@ -3876,7 +3897,6 @@ async function showTargetEditor(targetId = null) {
|
|||||||
device: deviceSelect.value,
|
device: deviceSelect.value,
|
||||||
source: sourceSelect.value,
|
source: sourceSelect.value,
|
||||||
fps: document.getElementById('target-editor-fps').value,
|
fps: document.getElementById('target-editor-fps').value,
|
||||||
border_width: document.getElementById('target-editor-border-width').value,
|
|
||||||
interpolation: document.getElementById('target-editor-interpolation').value,
|
interpolation: document.getElementById('target-editor-interpolation').value,
|
||||||
smoothing: document.getElementById('target-editor-smoothing').value,
|
smoothing: document.getElementById('target-editor-smoothing').value,
|
||||||
standby_interval: document.getElementById('target-editor-standby-interval').value,
|
standby_interval: document.getElementById('target-editor-standby-interval').value,
|
||||||
@@ -3901,7 +3921,6 @@ function isTargetEditorDirty() {
|
|||||||
document.getElementById('target-editor-device').value !== targetEditorInitialValues.device ||
|
document.getElementById('target-editor-device').value !== targetEditorInitialValues.device ||
|
||||||
document.getElementById('target-editor-source').value !== targetEditorInitialValues.source ||
|
document.getElementById('target-editor-source').value !== targetEditorInitialValues.source ||
|
||||||
document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps ||
|
document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps ||
|
||||||
document.getElementById('target-editor-border-width').value !== targetEditorInitialValues.border_width ||
|
|
||||||
document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation ||
|
document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation ||
|
||||||
document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing ||
|
document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing ||
|
||||||
document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval
|
document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval
|
||||||
@@ -3929,7 +3948,6 @@ async function saveTargetEditor() {
|
|||||||
const deviceId = document.getElementById('target-editor-device').value;
|
const deviceId = document.getElementById('target-editor-device').value;
|
||||||
const sourceId = document.getElementById('target-editor-source').value;
|
const sourceId = document.getElementById('target-editor-source').value;
|
||||||
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
||||||
const borderWidth = parseInt(document.getElementById('target-editor-border-width').value) || 10;
|
|
||||||
const interpolation = document.getElementById('target-editor-interpolation').value;
|
const interpolation = document.getElementById('target-editor-interpolation').value;
|
||||||
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
|
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
|
||||||
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
|
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
|
||||||
@@ -3947,7 +3965,6 @@ async function saveTargetEditor() {
|
|||||||
picture_source_id: sourceId,
|
picture_source_id: sourceId,
|
||||||
settings: {
|
settings: {
|
||||||
fps: fps,
|
fps: fps,
|
||||||
border_width: borderWidth,
|
|
||||||
interpolation_mode: interpolation,
|
interpolation_mode: interpolation,
|
||||||
smoothing: smoothing,
|
smoothing: smoothing,
|
||||||
standby_interval: standbyInterval,
|
standby_interval: standbyInterval,
|
||||||
@@ -3963,7 +3980,7 @@ async function saveTargetEditor() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
payload.target_type = 'wled';
|
payload.target_type = 'led';
|
||||||
response = await fetch(`${API_BASE}/picture-targets`, {
|
response = await fetch(`${API_BASE}/picture-targets`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
@@ -4083,14 +4100,16 @@ async function loadTargetsTab() {
|
|||||||
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
|
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
|
||||||
|
|
||||||
// Group by type
|
// Group by type
|
||||||
const wledDevices = devicesWithState;
|
const ledDevices = devicesWithState;
|
||||||
const wledTargets = targetsWithState.filter(t => t.target_type === 'wled');
|
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
|
||||||
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
|
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
|
||||||
|
|
||||||
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled';
|
// Backward compat: map stored "wled" sub-tab to "led"
|
||||||
|
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
||||||
|
if (activeSubTab === 'wled') activeSubTab = 'led';
|
||||||
|
|
||||||
const subTabs = [
|
const subTabs = [
|
||||||
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
|
{ key: 'led', icon: '💡', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length },
|
||||||
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
|
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -4098,13 +4117,13 @@ async function loadTargetsTab() {
|
|||||||
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
|
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
|
||||||
).join('')}</div>`;
|
).join('')}</div>`;
|
||||||
|
|
||||||
// WLED panel: devices section + targets section
|
// LED panel: devices section + targets section
|
||||||
const wledPanel = `
|
const ledPanel = `
|
||||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'wled' ? ' active' : ''}" id="target-sub-tab-wled">
|
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
|
||||||
<div class="subtab-section">
|
<div class="subtab-section">
|
||||||
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
|
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
|
||||||
<div class="devices-grid">
|
<div class="devices-grid">
|
||||||
${wledDevices.map(device => createDeviceCard(device)).join('')}
|
${ledDevices.map(device => createDeviceCard(device)).join('')}
|
||||||
<div class="template-card add-template-card" onclick="showAddDevice()">
|
<div class="template-card add-template-card" onclick="showAddDevice()">
|
||||||
<div class="add-template-icon">+</div>
|
<div class="add-template-icon">+</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4113,7 +4132,7 @@ async function loadTargetsTab() {
|
|||||||
<div class="subtab-section">
|
<div class="subtab-section">
|
||||||
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
|
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
|
||||||
<div class="devices-grid">
|
<div class="devices-grid">
|
||||||
${wledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
|
${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
|
||||||
<div class="template-card add-template-card" onclick="showTargetEditor()">
|
<div class="template-card add-template-card" onclick="showTargetEditor()">
|
||||||
<div class="add-template-icon">+</div>
|
<div class="add-template-icon">+</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4144,9 +4163,9 @@ async function loadTargetsTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
container.innerHTML = tabBar + wledPanel + kcPanel;
|
container.innerHTML = tabBar + ledPanel + kcPanel;
|
||||||
|
|
||||||
// Attach event listeners and fetch WLED brightness for device cards
|
// Attach event listeners and fetch brightness for device cards
|
||||||
devicesWithState.forEach(device => {
|
devicesWithState.forEach(device => {
|
||||||
attachDeviceListeners(device.id);
|
attachDeviceListeners(device.id);
|
||||||
fetchDeviceBrightness(device.id);
|
fetchDeviceBrightness(device.id);
|
||||||
@@ -4186,12 +4205,12 @@ function createTargetCard(target, deviceMap, sourceMap) {
|
|||||||
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
|
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
|
||||||
|
|
||||||
// Health info from target state (forwarded from device)
|
// Health info from target state (forwarded from device)
|
||||||
const wledOnline = state.wled_online || false;
|
const devOnline = state.device_online || false;
|
||||||
let healthClass = 'health-unknown';
|
let healthClass = 'health-unknown';
|
||||||
let healthTitle = '';
|
let healthTitle = '';
|
||||||
if (state.wled_last_checked !== null && state.wled_last_checked !== undefined) {
|
if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
|
||||||
healthClass = wledOnline ? 'health-online' : 'health-offline';
|
healthClass = devOnline ? 'health-online' : 'health-offline';
|
||||||
healthTitle = wledOnline ? t('device.health.online') : t('device.health.offline');
|
healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
|||||||
@@ -95,6 +95,10 @@
|
|||||||
<span id="direction-icon">↻</span> <span id="direction-label">CW</span>
|
<span id="direction-icon">↻</span> <span id="direction-label">CW</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
|
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
|
||||||
|
<div class="preview-screen-border-width">
|
||||||
|
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label>
|
||||||
|
<input type="number" id="cal-border-width" min="1" max="100" value="10">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edge bars with span controls and LED count inputs -->
|
<!-- Edge bars with span controls and LED count inputs -->
|
||||||
@@ -171,7 +175,7 @@
|
|||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="calibration.offset.hint">Distance from physical LED 0 to the start corner (along strip direction)</small>
|
<small class="input-hint" style="display:none" data-i18n="calibration.offset.hint">Distance from physical LED 0 to the start corner (along strip direction)</small>
|
||||||
<input type="number" id="cal-offset" min="0" value="0" oninput="updateCalibrationPreview()">
|
<input type="number" id="cal-offset" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
@@ -179,7 +183,7 @@
|
|||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="calibration.skip_start.hint">Number of LEDs to turn off at the beginning of the strip (0 = none)</small>
|
<small class="input-hint" style="display:none" data-i18n="calibration.skip_start.hint">Number of LEDs to turn off at the beginning of the strip (0 = none)</small>
|
||||||
<input type="number" id="cal-skip-start" min="0" value="0" oninput="updateCalibrationPreview()">
|
<input type="number" id="cal-skip-start" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
@@ -187,7 +191,7 @@
|
|||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="calibration.skip_end.hint">Number of LEDs to turn off at the end of the strip (0 = none)</small>
|
<small class="input-hint" style="display:none" data-i18n="calibration.skip_end.hint">Number of LEDs to turn off at the end of the strip (0 = none)</small>
|
||||||
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateCalibrationPreview()">
|
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -235,10 +239,10 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="settings-device-url" data-i18n="device.url">WLED URL:</label>
|
<label for="settings-device-url" data-i18n="device.url">URL:</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
|
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of the device</small>
|
||||||
<input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
<input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -247,7 +251,7 @@
|
|||||||
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="settings.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small>
|
<small class="input-hint" style="display:none" data-i18n="settings.health_interval.hint">How often to check the device status (5-600 seconds)</small>
|
||||||
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -279,10 +283,10 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="target-editor-device" data-i18n="targets.device">WLED Device:</label>
|
<label for="target-editor-device" data-i18n="targets.device">Device:</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="targets.device.hint">Select the WLED device to stream to</small>
|
<small class="input-hint" style="display:none" data-i18n="targets.device.hint">Select the LED device to send data to</small>
|
||||||
<select id="target-editor-device"></select>
|
<select id="target-editor-device"></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -307,15 +311,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label for="target-editor-border-width" data-i18n="targets.border_width">Border Width (px):</label>
|
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
|
||||||
</div>
|
|
||||||
<small class="input-hint" style="display:none" data-i18n="targets.border_width.hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
|
|
||||||
<input type="number" id="target-editor-border-width" min="1" max="100" value="10">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="target-editor-interpolation" data-i18n="targets.interpolation">Interpolation Mode:</label>
|
<label for="target-editor-interpolation" data-i18n="targets.interpolation">Interpolation Mode:</label>
|
||||||
@@ -349,7 +344,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="targets.standby_interval.hint">How often to resend the last frame when the screen is static, to keep WLED in live mode (0.5-5.0s)</small>
|
<small class="input-hint" style="display:none" data-i18n="targets.standby_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
|
||||||
<input type="range" id="target-editor-standby-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-standby-interval-value').textContent = this.value">
|
<input type="range" id="target-editor-standby-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-standby-interval-value').textContent = this.value">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -516,7 +511,7 @@
|
|||||||
<div id="api-key-modal" class="modal">
|
<div id="api-key-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 data-i18n="auth.title">🔑 Login to WLED Controller</h2>
|
<h2 data-i18n="auth.title">🔑 Login to LED Grab</h2>
|
||||||
<button class="modal-close-btn" id="modal-close-x-btn" onclick="closeApiKeyModal()" title="Close">✕</button>
|
<button class="modal-close-btn" id="modal-close-x-btn" onclick="closeApiKeyModal()" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="api-key-form" onsubmit="submitApiKey(event)">
|
<form id="api-key-form" onsubmit="submitApiKey(event)">
|
||||||
@@ -579,12 +574,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form id="add-device-form">
|
<form id="add-device-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="device-type" data-i18n="device.type">Device Type:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="device.type.hint">Select the type of LED controller</small>
|
||||||
|
<select id="device-type">
|
||||||
|
<option value="wled">WLED</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="device-name" data-i18n="device.name">Device Name:</label>
|
<label for="device-name" data-i18n="device.name">Device Name:</label>
|
||||||
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
|
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="device-url" data-i18n="device.url">WLED URL:</label>
|
<div class="label-row">
|
||||||
|
<label for="device-url" data-i18n="device.url">URL:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small>
|
||||||
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
||||||
</div>
|
</div>
|
||||||
<div id="add-device-error" class="error-message" style="display: none;"></div>
|
<div id="add-device-error" class="error-message" style="display: none;"></div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"auth.login": "Login",
|
"auth.login": "Login",
|
||||||
"auth.logout": "Logout",
|
"auth.logout": "Logout",
|
||||||
"auth.authenticated": "● Authenticated",
|
"auth.authenticated": "● Authenticated",
|
||||||
"auth.title": "Login to WLED Controller",
|
"auth.title": "Login to LED Grab",
|
||||||
"auth.message": "Please enter your API key to authenticate and access the LED Grab.",
|
"auth.message": "Please enter your API key to authenticate and access the LED Grab.",
|
||||||
"auth.label": "API Key:",
|
"auth.label": "API Key:",
|
||||||
"auth.placeholder": "Enter your API key...",
|
"auth.placeholder": "Enter your API key...",
|
||||||
@@ -101,13 +101,16 @@
|
|||||||
"devices.wled_webui_link": "WLED Web UI",
|
"devices.wled_webui_link": "WLED Web UI",
|
||||||
"devices.wled_note_webui": "(open your device's IP in a browser).",
|
"devices.wled_note_webui": "(open your device's IP in a browser).",
|
||||||
"devices.wled_note2": "This controller sends pixel color data and controls brightness per device.",
|
"devices.wled_note2": "This controller sends pixel color data and controls brightness per device.",
|
||||||
|
"device.type": "Device Type:",
|
||||||
|
"device.type.hint": "Select the type of LED controller",
|
||||||
|
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
||||||
"device.name": "Device Name:",
|
"device.name": "Device Name:",
|
||||||
"device.name.placeholder": "Living Room TV",
|
"device.name.placeholder": "Living Room TV",
|
||||||
"device.url": "URL:",
|
"device.url": "URL:",
|
||||||
"device.url.placeholder": "http://192.168.1.100",
|
"device.url.placeholder": "http://192.168.1.100",
|
||||||
"device.led_count": "LED Count:",
|
"device.led_count": "LED Count:",
|
||||||
"device.led_count.hint": "Number of LEDs configured in your WLED device",
|
"device.led_count.hint": "Number of LEDs configured in the device",
|
||||||
"device.led_count.hint.auto": "Auto-detected from WLED device",
|
"device.led_count.hint.auto": "Auto-detected from device",
|
||||||
"device.button.add": "Add Device",
|
"device.button.add": "Add Device",
|
||||||
"device.button.start": "Start",
|
"device.button.start": "Start",
|
||||||
"device.button.stop": "Stop",
|
"device.button.stop": "Stop",
|
||||||
@@ -115,7 +118,7 @@
|
|||||||
"device.button.capture_settings": "Capture Settings",
|
"device.button.capture_settings": "Capture Settings",
|
||||||
"device.button.calibrate": "Calibrate",
|
"device.button.calibrate": "Calibrate",
|
||||||
"device.button.remove": "Remove",
|
"device.button.remove": "Remove",
|
||||||
"device.button.webui": "Open WLED Web UI",
|
"device.button.webui": "Open Device Web UI",
|
||||||
"device.status.connected": "Connected",
|
"device.status.connected": "Connected",
|
||||||
"device.status.disconnected": "Disconnected",
|
"device.status.disconnected": "Disconnected",
|
||||||
"device.status.error": "Error",
|
"device.status.error": "Error",
|
||||||
@@ -136,26 +139,26 @@
|
|||||||
"device.metrics.frames_skipped": "Skipped",
|
"device.metrics.frames_skipped": "Skipped",
|
||||||
"device.metrics.keepalive": "Keepalive",
|
"device.metrics.keepalive": "Keepalive",
|
||||||
"device.metrics.errors": "Errors",
|
"device.metrics.errors": "Errors",
|
||||||
"device.health.online": "WLED Online",
|
"device.health.online": "Online",
|
||||||
"device.health.offline": "WLED Offline",
|
"device.health.offline": "Offline",
|
||||||
"device.health.checking": "Checking...",
|
"device.health.checking": "Checking...",
|
||||||
"device.tutorial.start": "Start tutorial",
|
"device.tutorial.start": "Start tutorial",
|
||||||
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from WLED",
|
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
|
||||||
"device.tip.brightness": "Slide to adjust device brightness",
|
"device.tip.brightness": "Slide to adjust device brightness",
|
||||||
"device.tip.start": "Start or stop screen capture processing",
|
"device.tip.start": "Start or stop screen capture processing",
|
||||||
"device.tip.settings": "Configure general device settings (name, URL, health check)",
|
"device.tip.settings": "Configure general device settings (name, URL, health check)",
|
||||||
"device.tip.capture_settings": "Configure capture settings (display, capture template)",
|
"device.tip.capture_settings": "Configure capture settings (display, capture template)",
|
||||||
"device.tip.calibrate": "Calibrate LED positions, direction, and coverage",
|
"device.tip.calibrate": "Calibrate LED positions, direction, and coverage",
|
||||||
"device.tip.webui": "Open WLED's built-in web interface for advanced configuration",
|
"device.tip.webui": "Open the device's built-in web interface for advanced configuration",
|
||||||
"device.tip.add": "Click here to add a new WLED device",
|
"device.tip.add": "Click here to add a new LED device",
|
||||||
"settings.title": "Device Settings",
|
"settings.title": "Device Settings",
|
||||||
"settings.general.title": "General Settings",
|
"settings.general.title": "General Settings",
|
||||||
"settings.capture.title": "Capture Settings",
|
"settings.capture.title": "Capture Settings",
|
||||||
"settings.capture.saved": "Capture settings updated",
|
"settings.capture.saved": "Capture settings updated",
|
||||||
"settings.capture.failed": "Failed to save capture settings",
|
"settings.capture.failed": "Failed to save capture settings",
|
||||||
"settings.brightness": "Brightness:",
|
"settings.brightness": "Brightness:",
|
||||||
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
|
"settings.brightness.hint": "Global brightness for this device (0-100%)",
|
||||||
"settings.url.hint": "IP address or hostname of your WLED device",
|
"settings.url.hint": "IP address or hostname of the device",
|
||||||
"settings.display_index": "Display:",
|
"settings.display_index": "Display:",
|
||||||
"settings.display_index.hint": "Which screen to capture for this device",
|
"settings.display_index.hint": "Which screen to capture for this device",
|
||||||
"settings.fps": "Target FPS:",
|
"settings.fps": "Target FPS:",
|
||||||
@@ -164,7 +167,7 @@
|
|||||||
"settings.capture_template.hint": "Screen capture engine and configuration for this device",
|
"settings.capture_template.hint": "Screen capture engine and configuration for this device",
|
||||||
"settings.button.cancel": "Cancel",
|
"settings.button.cancel": "Cancel",
|
||||||
"settings.health_interval": "Health Check Interval (s):",
|
"settings.health_interval": "Health Check Interval (s):",
|
||||||
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)",
|
"settings.health_interval.hint": "How often to check the device status (5-600 seconds)",
|
||||||
"settings.button.save": "Save Changes",
|
"settings.button.save": "Save Changes",
|
||||||
"settings.saved": "Settings saved successfully",
|
"settings.saved": "Settings saved successfully",
|
||||||
"settings.failed": "Failed to save settings",
|
"settings.failed": "Failed to save settings",
|
||||||
@@ -176,7 +179,9 @@
|
|||||||
"calibration.tip.span": "Drag green bars to adjust coverage span",
|
"calibration.tip.span": "Drag green bars to adjust coverage span",
|
||||||
"calibration.tip.test": "Click an edge to toggle test LEDs",
|
"calibration.tip.test": "Click an edge to toggle test LEDs",
|
||||||
"calibration.tip.toggle_inputs": "Click total LED count to toggle edge inputs",
|
"calibration.tip.toggle_inputs": "Click total LED count to toggle edge inputs",
|
||||||
"calibration.tip.skip_leds": "Skip LEDs at the start or end of the strip — skipped LEDs stay off",
|
"calibration.tip.border_width": "How many pixels from the screen edge to sample for LED colors",
|
||||||
|
"calibration.tip.skip_leds_start": "Skip LEDs at the start of the strip — skipped LEDs stay off",
|
||||||
|
"calibration.tip.skip_leds_end": "Skip LEDs at the end of the strip — skipped LEDs stay off",
|
||||||
"calibration.tutorial.start": "Start tutorial",
|
"calibration.tutorial.start": "Start tutorial",
|
||||||
"calibration.start_position": "Starting Position:",
|
"calibration.start_position": "Starting Position:",
|
||||||
"calibration.position.bottom_left": "Bottom Left",
|
"calibration.position.bottom_left": "Bottom Left",
|
||||||
@@ -196,6 +201,8 @@
|
|||||||
"calibration.skip_start.hint": "Number of LEDs to turn off at the beginning of the strip (0 = none)",
|
"calibration.skip_start.hint": "Number of LEDs to turn off at the beginning of the strip (0 = none)",
|
||||||
"calibration.skip_end": "Skip LEDs (End):",
|
"calibration.skip_end": "Skip LEDs (End):",
|
||||||
"calibration.skip_end.hint": "Number of LEDs to turn off at the end of the strip (0 = none)",
|
"calibration.skip_end.hint": "Number of LEDs to turn off at the end of the strip (0 = none)",
|
||||||
|
"calibration.border_width": "Border (px):",
|
||||||
|
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
|
||||||
"calibration.button.cancel": "Cancel",
|
"calibration.button.cancel": "Cancel",
|
||||||
"calibration.button.save": "Save",
|
"calibration.button.save": "Save",
|
||||||
"calibration.saved": "Calibration saved successfully",
|
"calibration.saved": "Calibration saved successfully",
|
||||||
@@ -313,7 +320,8 @@
|
|||||||
"streams.validate_image.invalid": "Image not accessible",
|
"streams.validate_image.invalid": "Image not accessible",
|
||||||
"targets.title": "⚡ Targets",
|
"targets.title": "⚡ Targets",
|
||||||
"targets.description": "Targets bridge picture sources to output devices. Each target references a device and a source, with its own processing settings.",
|
"targets.description": "Targets bridge picture sources to output devices. Each target references a device and a source, with its own processing settings.",
|
||||||
"targets.subtab.wled": "WLED",
|
"targets.subtab.wled": "LED",
|
||||||
|
"targets.subtab.led": "LED",
|
||||||
"targets.section.devices": "💡 Devices",
|
"targets.section.devices": "💡 Devices",
|
||||||
"targets.section.targets": "⚡ Targets",
|
"targets.section.targets": "⚡ Targets",
|
||||||
"targets.add": "Add Target",
|
"targets.add": "Add Target",
|
||||||
@@ -324,7 +332,7 @@
|
|||||||
"targets.name": "Target Name:",
|
"targets.name": "Target Name:",
|
||||||
"targets.name.placeholder": "My Target",
|
"targets.name.placeholder": "My Target",
|
||||||
"targets.device": "Device:",
|
"targets.device": "Device:",
|
||||||
"targets.device.hint": "Which WLED device to send LED data to",
|
"targets.device.hint": "Select the LED device to send data to",
|
||||||
"targets.device.none": "-- Select a device --",
|
"targets.device.none": "-- Select a device --",
|
||||||
"targets.source": "Source:",
|
"targets.source": "Source:",
|
||||||
"targets.source.hint": "Which picture source to capture and process",
|
"targets.source.hint": "Which picture source to capture and process",
|
||||||
@@ -341,7 +349,7 @@
|
|||||||
"targets.smoothing": "Smoothing:",
|
"targets.smoothing": "Smoothing:",
|
||||||
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
||||||
"targets.standby_interval": "Standby Interval:",
|
"targets.standby_interval": "Standby Interval:",
|
||||||
"targets.standby_interval.hint": "How often to resend the last frame when the screen is static, keeping WLED in live mode (0.5-5.0s)",
|
"targets.standby_interval.hint": "How often to resend the last frame when the screen is static, keeping the device in live mode (0.5-5.0s)",
|
||||||
"targets.created": "Target created successfully",
|
"targets.created": "Target created successfully",
|
||||||
"targets.updated": "Target updated successfully",
|
"targets.updated": "Target updated successfully",
|
||||||
"targets.deleted": "Target deleted successfully",
|
"targets.deleted": "Target deleted successfully",
|
||||||
|
|||||||
@@ -101,13 +101,16 @@
|
|||||||
"devices.wled_webui_link": "веб-интерфейс WLED",
|
"devices.wled_webui_link": "веб-интерфейс WLED",
|
||||||
"devices.wled_note_webui": "(откройте IP устройства в браузере).",
|
"devices.wled_note_webui": "(откройте IP устройства в браузере).",
|
||||||
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
|
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
|
||||||
|
"device.type": "Тип устройства:",
|
||||||
|
"device.type.hint": "Выберите тип LED контроллера",
|
||||||
|
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
||||||
"device.name": "Имя Устройства:",
|
"device.name": "Имя Устройства:",
|
||||||
"device.name.placeholder": "ТВ в Гостиной",
|
"device.name.placeholder": "ТВ в Гостиной",
|
||||||
"device.url": "URL:",
|
"device.url": "URL:",
|
||||||
"device.url.placeholder": "http://192.168.1.100",
|
"device.url.placeholder": "http://192.168.1.100",
|
||||||
"device.led_count": "Количество Светодиодов:",
|
"device.led_count": "Количество Светодиодов:",
|
||||||
"device.led_count.hint": "Количество светодиодов, настроенных в вашем WLED устройстве",
|
"device.led_count.hint": "Количество светодиодов, настроенных в устройстве",
|
||||||
"device.led_count.hint.auto": "Автоматически определяется из WLED устройства",
|
"device.led_count.hint.auto": "Автоматически определяется из устройства",
|
||||||
"device.button.add": "Добавить Устройство",
|
"device.button.add": "Добавить Устройство",
|
||||||
"device.button.start": "Запустить",
|
"device.button.start": "Запустить",
|
||||||
"device.button.stop": "Остановить",
|
"device.button.stop": "Остановить",
|
||||||
@@ -115,7 +118,7 @@
|
|||||||
"device.button.capture_settings": "Настройки захвата",
|
"device.button.capture_settings": "Настройки захвата",
|
||||||
"device.button.calibrate": "Калибровка",
|
"device.button.calibrate": "Калибровка",
|
||||||
"device.button.remove": "Удалить",
|
"device.button.remove": "Удалить",
|
||||||
"device.button.webui": "Открыть веб-интерфейс WLED",
|
"device.button.webui": "Открыть веб-интерфейс устройства",
|
||||||
"device.status.connected": "Подключено",
|
"device.status.connected": "Подключено",
|
||||||
"device.status.disconnected": "Отключено",
|
"device.status.disconnected": "Отключено",
|
||||||
"device.status.error": "Ошибка",
|
"device.status.error": "Ошибка",
|
||||||
@@ -136,26 +139,26 @@
|
|||||||
"device.metrics.frames_skipped": "Пропущено",
|
"device.metrics.frames_skipped": "Пропущено",
|
||||||
"device.metrics.keepalive": "Keepalive",
|
"device.metrics.keepalive": "Keepalive",
|
||||||
"device.metrics.errors": "Ошибки",
|
"device.metrics.errors": "Ошибки",
|
||||||
"device.health.online": "WLED Онлайн",
|
"device.health.online": "Онлайн",
|
||||||
"device.health.offline": "WLED Недоступен",
|
"device.health.offline": "Недоступен",
|
||||||
"device.health.checking": "Проверка...",
|
"device.health.checking": "Проверка...",
|
||||||
"device.tutorial.start": "Начать обучение",
|
"device.tutorial.start": "Начать обучение",
|
||||||
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически из WLED",
|
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
|
||||||
"device.tip.brightness": "Перетащите для регулировки яркости",
|
"device.tip.brightness": "Перетащите для регулировки яркости",
|
||||||
"device.tip.start": "Запуск или остановка захвата экрана",
|
"device.tip.start": "Запуск или остановка захвата экрана",
|
||||||
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)",
|
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)",
|
||||||
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)",
|
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)",
|
||||||
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
|
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
|
||||||
"device.tip.webui": "Открыть встроенный веб-интерфейс WLED для расширенной настройки",
|
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
|
||||||
"device.tip.add": "Нажмите, чтобы добавить новое WLED устройство",
|
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
|
||||||
"settings.title": "Настройки Устройства",
|
"settings.title": "Настройки Устройства",
|
||||||
"settings.general.title": "Основные Настройки",
|
"settings.general.title": "Основные Настройки",
|
||||||
"settings.capture.title": "Настройки Захвата",
|
"settings.capture.title": "Настройки Захвата",
|
||||||
"settings.capture.saved": "Настройки захвата обновлены",
|
"settings.capture.saved": "Настройки захвата обновлены",
|
||||||
"settings.capture.failed": "Не удалось сохранить настройки захвата",
|
"settings.capture.failed": "Не удалось сохранить настройки захвата",
|
||||||
"settings.brightness": "Яркость:",
|
"settings.brightness": "Яркость:",
|
||||||
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
|
"settings.brightness.hint": "Общая яркость для этого устройства (0-100%)",
|
||||||
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
|
"settings.url.hint": "IP адрес или имя хоста устройства",
|
||||||
"settings.display_index": "Дисплей:",
|
"settings.display_index": "Дисплей:",
|
||||||
"settings.display_index.hint": "Какой экран захватывать для этого устройства",
|
"settings.display_index.hint": "Какой экран захватывать для этого устройства",
|
||||||
"settings.fps": "Целевой FPS:",
|
"settings.fps": "Целевой FPS:",
|
||||||
@@ -164,7 +167,7 @@
|
|||||||
"settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства",
|
"settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства",
|
||||||
"settings.button.cancel": "Отмена",
|
"settings.button.cancel": "Отмена",
|
||||||
"settings.health_interval": "Интервал Проверки (с):",
|
"settings.health_interval": "Интервал Проверки (с):",
|
||||||
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",
|
"settings.health_interval.hint": "Как часто проверять статус устройства (5-600 секунд)",
|
||||||
"settings.button.save": "Сохранить Изменения",
|
"settings.button.save": "Сохранить Изменения",
|
||||||
"settings.saved": "Настройки успешно сохранены",
|
"settings.saved": "Настройки успешно сохранены",
|
||||||
"settings.failed": "Не удалось сохранить настройки",
|
"settings.failed": "Не удалось сохранить настройки",
|
||||||
@@ -176,7 +179,9 @@
|
|||||||
"calibration.tip.span": "Перетащите зелёные полосы для настройки зоны покрытия",
|
"calibration.tip.span": "Перетащите зелёные полосы для настройки зоны покрытия",
|
||||||
"calibration.tip.test": "Нажмите на край для теста LED",
|
"calibration.tip.test": "Нажмите на край для теста LED",
|
||||||
"calibration.tip.toggle_inputs": "Нажмите на общее количество LED для скрытия боковых полей",
|
"calibration.tip.toggle_inputs": "Нажмите на общее количество LED для скрытия боковых полей",
|
||||||
"calibration.tip.skip_leds": "Пропуск LED в начале или конце ленты — пропущенные LED остаются выключенными",
|
"calibration.tip.border_width": "Сколько пикселей от края экрана использовать для цветов LED",
|
||||||
|
"calibration.tip.skip_leds_start": "Пропуск LED в начале ленты — пропущенные LED остаются выключенными",
|
||||||
|
"calibration.tip.skip_leds_end": "Пропуск LED в конце ленты — пропущенные LED остаются выключенными",
|
||||||
"calibration.tutorial.start": "Начать обучение",
|
"calibration.tutorial.start": "Начать обучение",
|
||||||
"calibration.start_position": "Начальная Позиция:",
|
"calibration.start_position": "Начальная Позиция:",
|
||||||
"calibration.position.bottom_left": "Нижний Левый",
|
"calibration.position.bottom_left": "Нижний Левый",
|
||||||
@@ -196,6 +201,8 @@
|
|||||||
"calibration.skip_start.hint": "Количество LED, которые будут выключены в начале ленты (0 = нет)",
|
"calibration.skip_start.hint": "Количество LED, которые будут выключены в начале ленты (0 = нет)",
|
||||||
"calibration.skip_end": "Пропуск LED (конец):",
|
"calibration.skip_end": "Пропуск LED (конец):",
|
||||||
"calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)",
|
"calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)",
|
||||||
|
"calibration.border_width": "Граница (px):",
|
||||||
|
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
||||||
"calibration.button.cancel": "Отмена",
|
"calibration.button.cancel": "Отмена",
|
||||||
"calibration.button.save": "Сохранить",
|
"calibration.button.save": "Сохранить",
|
||||||
"calibration.saved": "Калибровка успешно сохранена",
|
"calibration.saved": "Калибровка успешно сохранена",
|
||||||
@@ -313,7 +320,8 @@
|
|||||||
"streams.validate_image.invalid": "Изображение недоступно",
|
"streams.validate_image.invalid": "Изображение недоступно",
|
||||||
"targets.title": "⚡ Цели",
|
"targets.title": "⚡ Цели",
|
||||||
"targets.description": "Цели связывают источники изображений с устройствами вывода. Каждая цель ссылается на устройство и источник, с собственными настройками обработки.",
|
"targets.description": "Цели связывают источники изображений с устройствами вывода. Каждая цель ссылается на устройство и источник, с собственными настройками обработки.",
|
||||||
"targets.subtab.wled": "WLED",
|
"targets.subtab.wled": "LED",
|
||||||
|
"targets.subtab.led": "LED",
|
||||||
"targets.section.devices": "💡 Устройства",
|
"targets.section.devices": "💡 Устройства",
|
||||||
"targets.section.targets": "⚡ Цели",
|
"targets.section.targets": "⚡ Цели",
|
||||||
"targets.add": "Добавить Цель",
|
"targets.add": "Добавить Цель",
|
||||||
@@ -324,7 +332,7 @@
|
|||||||
"targets.name": "Имя Цели:",
|
"targets.name": "Имя Цели:",
|
||||||
"targets.name.placeholder": "Моя Цель",
|
"targets.name.placeholder": "Моя Цель",
|
||||||
"targets.device": "Устройство:",
|
"targets.device": "Устройство:",
|
||||||
"targets.device.hint": "На какое WLED устройство отправлять данные LED",
|
"targets.device.hint": "Выберите LED устройство для передачи данных",
|
||||||
"targets.device.none": "-- Выберите устройство --",
|
"targets.device.none": "-- Выберите устройство --",
|
||||||
"targets.source": "Источник:",
|
"targets.source": "Источник:",
|
||||||
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
|
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
|
||||||
@@ -341,7 +349,7 @@
|
|||||||
"targets.smoothing": "Сглаживание:",
|
"targets.smoothing": "Сглаживание:",
|
||||||
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
||||||
"targets.standby_interval": "Интервал ожидания:",
|
"targets.standby_interval": "Интервал ожидания:",
|
||||||
"targets.standby_interval.hint": "Как часто повторно отправлять последний кадр при статичном экране для удержания WLED в режиме live (0.5-5.0с)",
|
"targets.standby_interval.hint": "Как часто повторно отправлять последний кадр при статичном экране для удержания устройства в режиме live (0.5-5.0с)",
|
||||||
"targets.created": "Цель успешно создана",
|
"targets.created": "Цель успешно создана",
|
||||||
"targets.updated": "Цель успешно обновлена",
|
"targets.updated": "Цель успешно обновлена",
|
||||||
"targets.deleted": "Цель успешно удалена",
|
"targets.deleted": "Цель успешно удалена",
|
||||||
|
|||||||
@@ -343,6 +343,15 @@ section {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-type-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.channel-indicator {
|
.channel-indicator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -680,7 +689,14 @@ select {
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"]:disabled,
|
||||||
|
input[type="password"]:disabled,
|
||||||
|
select:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
@@ -1167,6 +1183,35 @@ input:-webkit-autofill:focus {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-screen-border-width {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-screen-border-width label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-screen-border-width input {
|
||||||
|
width: 52px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-screen-border-width input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(255, 255, 255, 0.7);
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.preview-screen-total {
|
.preview-screen-total {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class Device:
|
|||||||
url: str,
|
url: str,
|
||||||
led_count: int,
|
led_count: int,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
|
device_type: str = "wled",
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
calibration: Optional[CalibrationConfig] = None,
|
||||||
created_at: Optional[datetime] = None,
|
created_at: Optional[datetime] = None,
|
||||||
updated_at: Optional[datetime] = None,
|
updated_at: Optional[datetime] = None,
|
||||||
@@ -40,6 +41,7 @@ class Device:
|
|||||||
self.url = url
|
self.url = url
|
||||||
self.led_count = led_count
|
self.led_count = led_count
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
|
self.device_type = device_type
|
||||||
self.calibration = calibration or create_default_calibration(led_count)
|
self.calibration = calibration or create_default_calibration(led_count)
|
||||||
self.created_at = created_at or datetime.utcnow()
|
self.created_at = created_at or datetime.utcnow()
|
||||||
self.updated_at = updated_at or datetime.utcnow()
|
self.updated_at = updated_at or datetime.utcnow()
|
||||||
@@ -52,6 +54,7 @@ class Device:
|
|||||||
"url": self.url,
|
"url": self.url,
|
||||||
"led_count": self.led_count,
|
"led_count": self.led_count,
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
|
"device_type": self.device_type,
|
||||||
"calibration": calibration_to_dict(self.calibration),
|
"calibration": calibration_to_dict(self.calibration),
|
||||||
"created_at": self.created_at.isoformat(),
|
"created_at": self.created_at.isoformat(),
|
||||||
"updated_at": self.updated_at.isoformat(),
|
"updated_at": self.updated_at.isoformat(),
|
||||||
@@ -77,6 +80,7 @@ class Device:
|
|||||||
url=data["url"],
|
url=data["url"],
|
||||||
led_count=data["led_count"],
|
led_count=data["led_count"],
|
||||||
enabled=data.get("enabled", True),
|
enabled=data.get("enabled", True),
|
||||||
|
device_type=data.get("device_type", "wled"),
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||||
@@ -160,6 +164,7 @@ class DeviceStore:
|
|||||||
name: str,
|
name: str,
|
||||||
url: str,
|
url: str,
|
||||||
led_count: int,
|
led_count: int,
|
||||||
|
device_type: str = "wled",
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
calibration: Optional[CalibrationConfig] = None,
|
||||||
) -> Device:
|
) -> Device:
|
||||||
"""Create a new device."""
|
"""Create a new device."""
|
||||||
@@ -170,6 +175,7 @@ class DeviceStore:
|
|||||||
name=name,
|
name=name,
|
||||||
url=url,
|
url=url,
|
||||||
led_count=led_count,
|
led_count=led_count,
|
||||||
|
device_type=device_type,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ class PictureTarget:
|
|||||||
def from_dict(cls, data: dict) -> "PictureTarget":
|
def from_dict(cls, data: dict) -> "PictureTarget":
|
||||||
"""Create from dictionary, dispatching to the correct subclass."""
|
"""Create from dictionary, dispatching to the correct subclass."""
|
||||||
target_type = data.get("target_type", "wled")
|
target_type = data.get("target_type", "wled")
|
||||||
if target_type == "wled":
|
# "wled" and "led" both map to WledPictureTarget (backward compat)
|
||||||
|
if target_type in ("wled", "led"):
|
||||||
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
||||||
return WledPictureTarget.from_dict(data)
|
return WledPictureTarget.from_dict(data)
|
||||||
if target_type == "key_colors":
|
if target_type == "key_colors":
|
||||||
|
|||||||
@@ -119,8 +119,11 @@ class PictureTargetStore:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If validation fails
|
ValueError: If validation fails
|
||||||
"""
|
"""
|
||||||
if target_type not in ("wled", "key_colors"):
|
if target_type not in ("led", "wled", "key_colors"):
|
||||||
raise ValueError(f"Invalid target type: {target_type}")
|
raise ValueError(f"Invalid target type: {target_type}")
|
||||||
|
# Normalize legacy "wled" to "led"
|
||||||
|
if target_type == "wled":
|
||||||
|
target_type = "led"
|
||||||
|
|
||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
for target in self._targets.values():
|
for target in self._targets.values():
|
||||||
@@ -130,11 +133,11 @@ class PictureTargetStore:
|
|||||||
target_id = f"pt_{uuid.uuid4().hex[:8]}"
|
target_id = f"pt_{uuid.uuid4().hex[:8]}"
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
if target_type == "wled":
|
if target_type == "led":
|
||||||
target: PictureTarget = WledPictureTarget(
|
target: PictureTarget = WledPictureTarget(
|
||||||
id=target_id,
|
id=target_id,
|
||||||
name=name,
|
name=name,
|
||||||
target_type="wled",
|
target_type="led",
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
picture_source_id=picture_source_id,
|
picture_source_id=picture_source_id,
|
||||||
settings=settings or ProcessingSettings(),
|
settings=settings or ProcessingSettings(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""WLED picture target — streams a picture source to a WLED device."""
|
"""LED picture target — streams a picture source to an LED device."""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -10,7 +10,7 @@ from wled_controller.storage.picture_target import PictureTarget
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WledPictureTarget(PictureTarget):
|
class WledPictureTarget(PictureTarget):
|
||||||
"""WLED picture target — streams a picture source to a WLED device."""
|
"""LED picture target — streams a picture source to an LED device."""
|
||||||
|
|
||||||
device_id: str = ""
|
device_id: str = ""
|
||||||
picture_source_id: str = ""
|
picture_source_id: str = ""
|
||||||
@@ -24,7 +24,6 @@ class WledPictureTarget(PictureTarget):
|
|||||||
d["settings"] = {
|
d["settings"] = {
|
||||||
"display_index": self.settings.display_index,
|
"display_index": self.settings.display_index,
|
||||||
"fps": self.settings.fps,
|
"fps": self.settings.fps,
|
||||||
"border_width": self.settings.border_width,
|
|
||||||
"brightness": self.settings.brightness,
|
"brightness": self.settings.brightness,
|
||||||
"gamma": self.settings.gamma,
|
"gamma": self.settings.gamma,
|
||||||
"saturation": self.settings.saturation,
|
"saturation": self.settings.saturation,
|
||||||
@@ -44,7 +43,6 @@ class WledPictureTarget(PictureTarget):
|
|||||||
settings = ProcessingSettings(
|
settings = ProcessingSettings(
|
||||||
display_index=settings_data.get("display_index", 0),
|
display_index=settings_data.get("display_index", 0),
|
||||||
fps=settings_data.get("fps", 30),
|
fps=settings_data.get("fps", 30),
|
||||||
border_width=settings_data.get("border_width", 10),
|
|
||||||
brightness=settings_data.get("brightness", 1.0),
|
brightness=settings_data.get("brightness", 1.0),
|
||||||
gamma=settings_data.get("gamma", 2.2),
|
gamma=settings_data.get("gamma", 2.2),
|
||||||
saturation=settings_data.get("saturation", 1.0),
|
saturation=settings_data.get("saturation", 1.0),
|
||||||
@@ -57,7 +55,7 @@ class WledPictureTarget(PictureTarget):
|
|||||||
return cls(
|
return cls(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
target_type=data.get("target_type", "wled"),
|
target_type="led",
|
||||||
device_id=data.get("device_id", ""),
|
device_id=data.get("device_id", ""),
|
||||||
picture_source_id=data.get("picture_source_id", ""),
|
picture_source_id=data.get("picture_source_id", ""),
|
||||||
settings=settings,
|
settings=settings,
|
||||||
|
|||||||
Reference in New Issue
Block a user