Add Key Colors target type for extracting colors from screen regions

Introduce a new "key_colors" target type alongside WLED targets, enabling
real-time color extraction from configurable screen rectangles with
average/median/dominant modes, temporal smoothing, and WebSocket streaming.

- Split WledPictureTarget into its own module, add KeyColorsPictureTarget
- Add KC target lifecycle to ProcessorManager (register, start/stop, processing loop)
- Extend API routes and schemas for KC targets (CRUD, settings, state, metrics, colors)
- Add WebSocket endpoint for real-time color updates with auth
- Add KC sub-tab in Targets UI with editor modal and live color swatches
- Add EN and RU translations for all key colors strings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 16:43:09 +03:00
parent 3d2393e474
commit 5f9bc9a37e
13 changed files with 1525 additions and 111 deletions

View File

@@ -1,6 +1,8 @@
"""Picture target routes: CRUD, processing control, settings, state, metrics.""" """Picture target routes: CRUD, processing control, settings, state, metrics."""
from fastapi import APIRouter, HTTPException, Depends import secrets
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from wled_controller.api.dependencies import (
@@ -9,6 +11,10 @@ from wled_controller.api.dependencies import (
get_processor_manager, get_processor_manager,
) )
from wled_controller.api.schemas.picture_targets import ( from wled_controller.api.schemas.picture_targets import (
ExtractedColorResponse,
KeyColorRectangleSchema,
KeyColorsResponse,
KeyColorsSettingsSchema,
PictureTargetCreate, PictureTargetCreate,
PictureTargetListResponse, PictureTargetListResponse,
PictureTargetResponse, PictureTargetResponse,
@@ -17,9 +23,15 @@ from wled_controller.api.schemas.picture_targets import (
TargetMetricsResponse, TargetMetricsResponse,
TargetProcessingState, TargetProcessingState,
) )
from wled_controller.config import config
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
from wled_controller.storage import DeviceStore from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_target import WledPictureTarget from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import (
KeyColorRectangle,
KeyColorsSettings,
KeyColorsPictureTarget,
)
from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -66,20 +78,66 @@ def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchem
) )
def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema:
"""Convert core KeyColorsSettings to schema."""
return KeyColorsSettingsSchema(
fps=settings.fps,
interpolation_mode=settings.interpolation_mode,
smoothing=settings.smoothing,
rectangles=[
KeyColorRectangleSchema(name=r.name, x=r.x, y=r.y, width=r.width, height=r.height)
for r in settings.rectangles
],
)
def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings:
"""Convert schema KeyColorsSettings to core."""
return KeyColorsSettings(
fps=schema.fps,
interpolation_mode=schema.interpolation_mode,
smoothing=schema.smoothing,
rectangles=[
KeyColorRectangle(name=r.name, x=r.x, y=r.y, width=r.width, height=r.height)
for r in schema.rectangles
],
)
def _target_to_response(target) -> PictureTargetResponse: def _target_to_response(target) -> PictureTargetResponse:
"""Convert a PictureTarget to PictureTargetResponse.""" """Convert a PictureTarget to PictureTargetResponse."""
settings_schema = _settings_to_schema(target.settings) if isinstance(target, WledPictureTarget) else ProcessingSettingsSchema() if isinstance(target, WledPictureTarget):
return PictureTargetResponse( return PictureTargetResponse(
id=target.id, id=target.id,
name=target.name, name=target.name,
target_type=target.target_type, target_type=target.target_type,
device_id=target.device_id if isinstance(target, WledPictureTarget) else "", device_id=target.device_id,
picture_source_id=target.picture_source_id if isinstance(target, WledPictureTarget) else "", picture_source_id=target.picture_source_id,
settings=settings_schema, settings=_settings_to_schema(target.settings),
description=target.description, description=target.description,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
) )
elif isinstance(target, KeyColorsPictureTarget):
return PictureTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
picture_source_id=target.picture_source_id,
key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description,
created_at=target.created_at,
updated_at=target.updated_at,
)
else:
return PictureTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
description=target.description,
created_at=target.created_at,
updated_at=target.updated_at,
)
# ===== CRUD ENDPOINTS ===== # ===== CRUD ENDPOINTS =====
@@ -102,6 +160,7 @@ async def create_target(
# Convert settings # Convert settings
core_settings = _settings_to_core(data.settings) if data.settings else None core_settings = _settings_to_core(data.settings) if data.settings else None
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
# Create in store # Create in store
target = target_store.create_target( target = target_store.create_target(
@@ -110,6 +169,7 @@ async def create_target(
device_id=data.device_id, device_id=data.device_id,
picture_source_id=data.picture_source_id, picture_source_id=data.picture_source_id,
settings=core_settings, settings=core_settings,
key_colors_settings=kc_settings,
description=data.description, description=data.description,
) )
@@ -124,6 +184,15 @@ async def create_target(
) )
except ValueError as e: except ValueError as e:
logger.warning(f"Could not register target {target.id} in processor manager: {e}") logger.warning(f"Could not register target {target.id} in processor manager: {e}")
elif isinstance(target, KeyColorsPictureTarget):
try:
manager.add_kc_target(
target_id=target.id,
picture_source_id=target.picture_source_id,
settings=target.settings,
)
except ValueError as e:
logger.warning(f"Could not register KC target {target.id}: {e}")
return _target_to_response(target) return _target_to_response(target)
@@ -180,6 +249,7 @@ async def update_target(
# Convert settings # Convert settings
core_settings = _settings_to_core(data.settings) if data.settings else None core_settings = _settings_to_core(data.settings) if data.settings else None
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
# Update in store # Update in store
target = target_store.update_target( target = target_store.update_target(
@@ -188,6 +258,7 @@ async def update_target(
device_id=data.device_id, device_id=data.device_id,
picture_source_id=data.picture_source_id, picture_source_id=data.picture_source_id,
settings=core_settings, settings=core_settings,
key_colors_settings=kc_settings,
description=data.description, description=data.description,
) )
@@ -201,7 +272,14 @@ async def update_target(
if data.device_id is not None: if data.device_id is not None:
manager.update_target_device(target_id, target.device_id) manager.update_target_device(target_id, target.device_id)
except ValueError: except ValueError:
# Target may not be registered in manager yet pass
elif isinstance(target, KeyColorsPictureTarget):
try:
if data.key_colors_settings is not None:
manager.update_kc_target_settings(target_id, target.settings)
if data.picture_source_id is not None:
manager.update_kc_target_source(target_id, target.picture_source_id)
except ValueError:
pass pass
return _target_to_response(target) return _target_to_response(target)
@@ -224,16 +302,21 @@ async def delete_target(
): ):
"""Delete a picture target. Stops processing first if active.""" """Delete a picture target. Stops processing first if active."""
try: try:
# Stop processing if running # Stop processing if running (WLED or KC)
try: try:
if manager.is_target_processing(target_id): if manager.is_kc_target(target_id):
await manager.stop_kc_processing(target_id)
elif manager.is_target_processing(target_id):
await manager.stop_processing(target_id) await manager.stop_processing(target_id)
except ValueError: except ValueError:
pass pass
# Remove from manager # Remove from manager (WLED or KC)
try: try:
manager.remove_target(target_id) if manager.is_kc_target(target_id):
manager.remove_kc_target(target_id)
else:
manager.remove_target(target_id)
except (ValueError, RuntimeError): except (ValueError, RuntimeError):
pass pass
@@ -260,10 +343,13 @@ async def start_processing(
): ):
"""Start processing for a picture target.""" """Start processing for a picture target."""
try: try:
# Verify target exists # Verify target exists and dispatch by type
target_store.get_target(target_id) target = target_store.get_target(target_id)
await manager.start_processing(target_id) if isinstance(target, KeyColorsPictureTarget):
await manager.start_kc_processing(target_id)
else:
await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}") logger.info(f"Started processing for target {target_id}")
return {"status": "started", "target_id": target_id} return {"status": "started", "target_id": target_id}
@@ -285,7 +371,10 @@ async def stop_processing(
): ):
"""Stop processing for a picture target.""" """Stop processing for a picture target."""
try: try:
await manager.stop_processing(target_id) if manager.is_kc_target(target_id):
await manager.stop_kc_processing(target_id)
else:
await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}") logger.info(f"Stopped processing for target {target_id}")
return {"status": "stopped", "target_id": target_id} return {"status": "stopped", "target_id": target_id}
@@ -307,7 +396,10 @@ async def get_target_state(
): ):
"""Get current processing state for a target.""" """Get current processing state for a target."""
try: try:
state = manager.get_target_state(target_id) if manager.is_kc_target(target_id):
state = manager.get_kc_target_state(target_id)
else:
state = manager.get_target_state(target_id)
return TargetProcessingState(**state) return TargetProcessingState(**state)
except ValueError as e: except ValueError as e:
@@ -317,7 +409,7 @@ async def get_target_state(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-targets/{target_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"]) @router.get("/api/v1/picture-targets/{target_id}/settings", tags=["Settings"])
async def get_target_settings( async def get_target_settings(
target_id: str, target_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -326,6 +418,8 @@ async def get_target_settings(
"""Get processing settings for a target.""" """Get processing settings for a target."""
try: try:
target = target_store.get_target(target_id) target = target_store.get_target(target_id)
if isinstance(target, KeyColorsPictureTarget):
return _kc_settings_to_schema(target.settings)
if isinstance(target, WledPictureTarget): if isinstance(target, WledPictureTarget):
return _settings_to_schema(target.settings) return _settings_to_schema(target.settings)
return ProcessingSettingsSchema() return ProcessingSettingsSchema()
@@ -405,7 +499,10 @@ async def get_target_metrics(
): ):
"""Get processing metrics for a target.""" """Get processing metrics for a target."""
try: try:
metrics = manager.get_target_metrics(target_id) if manager.is_kc_target(target_id):
metrics = manager.get_kc_target_metrics(target_id)
else:
metrics = manager.get_target_metrics(target_id)
return TargetMetricsResponse(**metrics) return TargetMetricsResponse(**metrics)
except ValueError as e: except ValueError as e:
@@ -413,3 +510,69 @@ async def get_target_metrics(
except Exception as e: except Exception as e:
logger.error(f"Failed to get target metrics: {e}") logger.error(f"Failed to get target metrics: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ===== KEY COLORS ENDPOINTS =====
@router.get("/api/v1/picture-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"])
async def get_target_colors(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get latest extracted colors for a key-colors target (polling)."""
try:
raw_colors = manager.get_kc_latest_colors(target_id)
colors = {}
for name, (r, g, b) in raw_colors.items():
colors[name] = ExtractedColorResponse(
r=r, g=g, b=b,
hex=f"#{r:02x}{g:02x}{b:02x}",
)
from datetime import datetime
return KeyColorsResponse(
target_id=target_id,
colors=colors,
timestamp=datetime.utcnow(),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.websocket("/api/v1/picture-targets/{target_id}/ws")
async def target_colors_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
# Authenticate
authenticated = False
if token and config.auth.api_keys:
for _label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
try:
manager.add_kc_ws_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
# Keep alive — wait for client messages (or disconnect)
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_kc_ws_client(target_id, websocket)

View File

@@ -35,14 +35,51 @@ class ProcessingSettings(BaseModel):
) )
class KeyColorRectangleSchema(BaseModel):
"""A named rectangle for key color extraction (relative coords 0.0-1.0)."""
name: str = Field(description="Rectangle name", min_length=1, max_length=50)
x: float = Field(default=0.0, description="Left edge (0.0-1.0)", ge=0.0, le=1.0)
y: float = Field(default=0.0, description="Top edge (0.0-1.0)", ge=0.0, le=1.0)
width: float = Field(default=1.0, description="Width (0.0-1.0)", gt=0.0, le=1.0)
height: float = Field(default=1.0, description="Height (0.0-1.0)", gt=0.0, le=1.0)
class KeyColorsSettingsSchema(BaseModel):
"""Settings for key colors extraction."""
fps: int = Field(default=10, description="Extraction rate (1-60)", ge=1, le=60)
interpolation_mode: str = Field(default="average", description="Color mode (average, median, dominant)")
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
rectangles: List[KeyColorRectangleSchema] = Field(default_factory=list, description="Rectangles to extract colors from")
class ExtractedColorResponse(BaseModel):
"""A single extracted color."""
r: int = Field(description="Red (0-255)")
g: int = Field(description="Green (0-255)")
b: int = Field(description="Blue (0-255)")
hex: str = Field(description="Hex color (#rrggbb)")
class KeyColorsResponse(BaseModel):
"""Extracted key colors for a target."""
target_id: str = Field(description="Target ID")
colors: Dict[str, ExtractedColorResponse] = Field(description="Rectangle name -> color")
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
class PictureTargetCreate(BaseModel): 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)") target_type: str = Field(default="wled", description="Target type (wled, key_colors)")
device_id: str = Field(default="", description="WLED device ID") device_id: str = Field(default="", description="WLED 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") 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)")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -52,7 +89,8 @@ class PictureTargetUpdate(BaseModel):
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100) name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
device_id: Optional[str] = Field(None, description="WLED device ID") device_id: Optional[str] = Field(None, description="WLED device ID")
picture_source_id: Optional[str] = Field(None, description="Picture source ID") picture_source_id: Optional[str] = Field(None, description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings") 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)")
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -64,7 +102,8 @@ class PictureTargetResponse(BaseModel):
target_type: str = Field(description="Target type") target_type: str = Field(description="Target type")
device_id: str = Field(default="", description="WLED device ID") device_id: str = Field(default="", description="WLED device ID")
picture_source_id: str = Field(default="", description="Picture source ID") picture_source_id: str = Field(default="", description="Picture source ID")
settings: ProcessingSettings = Field(description="Processing settings") settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (wled)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (key_colors)")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
@@ -81,11 +120,11 @@ class TargetProcessingState(BaseModel):
"""Processing state for a picture target.""" """Processing state for a picture target."""
target_id: str = Field(description="Target ID") target_id: str = Field(description="Target ID")
device_id: str = Field(description="Device ID") device_id: Optional[str] = Field(None, description="Device ID")
processing: bool = Field(description="Whether processing is active") processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_target: int = Field(description="Target FPS") fps_target: int = Field(default=0, description="Target FPS")
display_index: int = Field(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") wled_online: bool = Field(default=False, description="Whether WLED device is reachable")
@@ -103,7 +142,7 @@ class TargetMetricsResponse(BaseModel):
"""Target metrics response.""" """Target metrics response."""
target_id: str = Field(description="Target ID") target_id: str = Field(description="Target ID")
device_id: str = Field(description="Device ID") device_id: Optional[str] = Field(None, description="Device ID")
processing: bool = Field(description="Whether processing is active") processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS") fps_actual: Optional[float] = Field(None, description="Actual FPS")
fps_target: int = Field(description="Target FPS") fps_target: int = Field(description="Target FPS")

View File

@@ -1,6 +1,7 @@
"""Processing manager for coordinating screen capture and WLED updates.""" """Processing manager for coordinating screen capture and WLED updates."""
import asyncio import asyncio
import json
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -18,7 +19,12 @@ from wled_controller.core.capture_engines.base import ScreenCapture
from wled_controller.core.live_stream import LiveStream from wled_controller.core.live_stream import LiveStream
from wled_controller.core.live_stream_manager import LiveStreamManager from wled_controller.core.live_stream_manager import LiveStreamManager
from wled_controller.core.pixel_processor import smooth_colors from wled_controller.core.pixel_processor import smooth_colors
from wled_controller.core.screen_capture import extract_border_pixels from wled_controller.core.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
extract_border_pixels,
)
from wled_controller.core.wled_client import WLEDClient from wled_controller.core.wled_client import WLEDClient
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -125,6 +131,23 @@ class TargetState:
wled_state_before: Optional[dict] = None wled_state_before: Optional[dict] = None
@dataclass
class KeyColorsTargetState:
"""State of a running key-colors extractor processor."""
target_id: str
picture_source_id: str
settings: "KeyColorsSettings" # forward ref, resolved at runtime
is_running: bool = False
task: Optional[asyncio.Task] = None
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
live_stream: Optional[LiveStream] = None
previous_colors: Optional[Dict[str, Tuple[int, int, int]]] = None
latest_colors: Optional[Dict[str, Tuple[int, int, int]]] = None
ws_clients: list = field(default_factory=list) # List[WebSocket]
resolved_target_fps: Optional[int] = None
class ProcessorManager: class ProcessorManager:
"""Manages screen processing for multiple WLED devices. """Manages screen processing for multiple WLED devices.
@@ -136,6 +159,7 @@ class ProcessorManager:
"""Initialize processor manager.""" """Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {} self._devices: Dict[str, DeviceState] = {}
self._targets: Dict[str, TargetState] = {} self._targets: Dict[str, TargetState] = {}
self._kc_targets: Dict[str, KeyColorsTargetState] = {}
self._health_monitoring_active = False self._health_monitoring_active = False
self._http_client: Optional[httpx.AsyncClient] = None self._http_client: Optional[httpx.AsyncClient] = None
self._picture_source_store = picture_source_store self._picture_source_store = picture_source_store
@@ -849,7 +873,7 @@ class ProcessorManager:
# Stop health monitoring # Stop health monitoring
await self.stop_health_monitoring() await self.stop_health_monitoring()
# Stop all targets # Stop all WLED targets
target_ids = list(self._targets.keys()) target_ids = list(self._targets.keys())
for target_id in target_ids: for target_id in target_ids:
if self._targets[target_id].is_running: if self._targets[target_id].is_running:
@@ -858,6 +882,15 @@ class ProcessorManager:
except Exception as e: except Exception as e:
logger.error(f"Error stopping target {target_id}: {e}") logger.error(f"Error stopping target {target_id}: {e}")
# Stop all key-colors targets
kc_ids = list(self._kc_targets.keys())
for target_id in kc_ids:
if self._kc_targets[target_id].is_running:
try:
await self.stop_kc_processing(target_id)
except Exception as e:
logger.error(f"Error stopping KC target {target_id}: {e}")
# Safety net: release any remaining managed live streams # Safety net: release any remaining managed live streams
self._live_stream_manager.release_all() self._live_stream_manager.release_all()
@@ -971,3 +1004,302 @@ class ProcessorManager:
wled_led_type=state.health.wled_led_type, wled_led_type=state.health.wled_led_type,
error=str(e), error=str(e),
) )
# ===== KEY COLORS TARGET MANAGEMENT =====
def add_kc_target(self, target_id: str, picture_source_id: str, settings) -> None:
"""Register a key-colors target for processing."""
if target_id in self._kc_targets:
raise ValueError(f"KC target {target_id} already registered")
self._kc_targets[target_id] = KeyColorsTargetState(
target_id=target_id,
picture_source_id=picture_source_id,
settings=settings,
)
logger.info(f"Registered KC target: {target_id}")
def remove_kc_target(self, target_id: str) -> None:
"""Unregister a key-colors target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
state = self._kc_targets[target_id]
if state.is_running:
raise ValueError(f"Cannot remove KC target {target_id}: still running")
del self._kc_targets[target_id]
logger.info(f"Removed KC target: {target_id}")
def update_kc_target_settings(self, target_id: str, settings) -> None:
"""Update settings for a key-colors target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
self._kc_targets[target_id].settings = settings
logger.info(f"Updated KC target settings: {target_id}")
def update_kc_target_source(self, target_id: str, picture_source_id: str) -> None:
"""Update picture source for a key-colors target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
self._kc_targets[target_id].picture_source_id = picture_source_id
logger.info(f"Updated KC target source: {target_id}")
async def start_kc_processing(self, target_id: str) -> None:
"""Start key-colors extraction for a target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
state = self._kc_targets[target_id]
if state.is_running:
raise ValueError(f"KC target {target_id} is already running")
if not state.picture_source_id:
raise ValueError(f"KC target {target_id} has no picture source assigned")
if not state.settings.rectangles:
raise ValueError(f"KC target {target_id} has no rectangles defined")
# Acquire live stream
try:
live_stream = await asyncio.to_thread(
self._live_stream_manager.acquire, state.picture_source_id
)
state.live_stream = live_stream
state.resolved_target_fps = live_stream.target_fps
logger.info(
f"Acquired live stream for KC target {target_id} "
f"(picture_source={state.picture_source_id})"
)
except Exception as e:
logger.error(f"Failed to initialize live stream for KC target {target_id}: {e}")
raise RuntimeError(f"Failed to initialize live stream: {e}")
# Reset metrics
state.metrics = ProcessingMetrics(start_time=datetime.utcnow())
state.previous_colors = None
state.latest_colors = None
# Start processing task
state.task = asyncio.create_task(self._kc_processing_loop(target_id))
state.is_running = True
logger.info(f"Started KC processing for target {target_id}")
async def stop_kc_processing(self, target_id: str) -> None:
"""Stop key-colors extraction for a target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
state = self._kc_targets[target_id]
if not state.is_running:
logger.warning(f"KC processing not running for target {target_id}")
return
state.is_running = False
# Cancel task
if state.task:
state.task.cancel()
try:
await state.task
except asyncio.CancelledError:
pass
state.task = None
# Release live stream
if state.live_stream:
try:
self._live_stream_manager.release(state.picture_source_id)
except Exception as e:
logger.warning(f"Error releasing live stream for KC target: {e}")
state.live_stream = None
logger.info(f"Stopped KC processing for target {target_id}")
async def _kc_processing_loop(self, target_id: str) -> None:
"""Main processing loop for a key-colors target."""
state = self._kc_targets[target_id]
settings = state.settings
target_fps = state.resolved_target_fps or settings.fps
smoothing = settings.smoothing
# Select color calculation function
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
frame_time = 1.0 / target_fps
fps_samples: List[float] = []
logger.info(
f"KC processing loop started for target {target_id} "
f"(fps={target_fps}, rects={len(settings.rectangles)})"
)
try:
while state.is_running:
loop_start = time.time()
try:
capture = await asyncio.to_thread(state.live_stream.get_latest_frame)
if capture is None:
await asyncio.sleep(frame_time)
continue
img = capture.image
h, w = img.shape[:2]
colors: Dict[str, Tuple[int, int, int]] = {}
for rect in settings.rectangles:
# Convert relative coords to pixel coords
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
# Clamp to image bounds
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
# Extract sub-image and compute color
sub_img = img[px_y:px_y + px_h, px_x:px_x + px_w]
color = calc_fn(sub_img)
colors[rect.name] = color
# Apply per-rectangle temporal smoothing
if state.previous_colors and smoothing > 0:
for name, color in colors.items():
if name in state.previous_colors:
prev = state.previous_colors[name]
alpha = smoothing
colors[name] = (
int(color[0] * (1 - alpha) + prev[0] * alpha),
int(color[1] * (1 - alpha) + prev[1] * alpha),
int(color[2] * (1 - alpha) + prev[2] * alpha),
)
state.previous_colors = dict(colors)
state.latest_colors = dict(colors)
# Broadcast to WebSocket clients
await self._broadcast_kc_colors(target_id, colors)
# Update metrics
state.metrics.frames_processed += 1
state.metrics.last_update = datetime.utcnow()
loop_time = time.time() - loop_start
fps_samples.append(1.0 / loop_time if loop_time > 0 else 0)
if len(fps_samples) > 10:
fps_samples.pop(0)
state.metrics.fps_actual = sum(fps_samples) / len(fps_samples)
except Exception as e:
state.metrics.errors_count += 1
state.metrics.last_error = str(e)
logger.error(f"KC processing error for {target_id}: {e}", exc_info=True)
# FPS control
elapsed = time.time() - loop_start
sleep_time = max(0, frame_time - elapsed)
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except asyncio.CancelledError:
logger.info(f"KC processing loop cancelled for target {target_id}")
raise
except Exception as e:
logger.error(f"Fatal error in KC processing loop for target {target_id}: {e}")
state.is_running = False
raise
finally:
logger.info(f"KC processing loop ended for target {target_id}")
async def _broadcast_kc_colors(self, target_id: str, colors: Dict[str, Tuple[int, int, int]]) -> None:
"""Broadcast extracted colors to WebSocket clients."""
state = self._kc_targets.get(target_id)
if not state or not state.ws_clients:
return
message = json.dumps({
"type": "colors_update",
"target_id": target_id,
"colors": {
name: {"r": c[0], "g": c[1], "b": c[2]}
for name, c in colors.items()
},
"timestamp": datetime.utcnow().isoformat(),
})
disconnected = []
for ws in state.ws_clients:
try:
await ws.send_text(message)
except Exception:
disconnected.append(ws)
for ws in disconnected:
state.ws_clients.remove(ws)
def add_kc_ws_client(self, target_id: str, ws) -> None:
"""Add a WebSocket client for KC color updates."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
self._kc_targets[target_id].ws_clients.append(ws)
def remove_kc_ws_client(self, target_id: str, ws) -> None:
"""Remove a WebSocket client."""
state = self._kc_targets.get(target_id)
if state and ws in state.ws_clients:
state.ws_clients.remove(ws)
def get_kc_target_state(self, target_id: str) -> dict:
"""Get current state for a KC target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
state = self._kc_targets[target_id]
return {
"target_id": target_id,
"processing": state.is_running,
"fps_actual": round(state.metrics.fps_actual, 1) if state.is_running else None,
"fps_target": state.resolved_target_fps or state.settings.fps,
"last_update": state.metrics.last_update.isoformat() if state.metrics.last_update else None,
"errors": [state.metrics.last_error] if state.metrics.last_error else [],
}
def get_kc_target_metrics(self, target_id: str) -> dict:
"""Get metrics for a KC target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
state = self._kc_targets[target_id]
uptime = 0.0
if state.metrics.start_time and state.is_running:
uptime = (datetime.utcnow() - state.metrics.start_time).total_seconds()
return {
"target_id": target_id,
"processing": state.is_running,
"fps_actual": round(state.metrics.fps_actual, 1),
"fps_target": state.resolved_target_fps or state.settings.fps,
"uptime_seconds": round(uptime, 1),
"frames_processed": state.metrics.frames_processed,
"errors_count": state.metrics.errors_count,
"last_error": state.metrics.last_error,
"last_update": state.metrics.last_update.isoformat() if state.metrics.last_update else None,
}
def get_kc_latest_colors(self, target_id: str) -> Dict[str, Tuple[int, int, int]]:
"""Get latest extracted colors for a KC target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
return self._kc_targets[target_id].latest_colors or {}
def is_kc_target(self, target_id: str) -> bool:
"""Check if a target ID belongs to a KC target."""
return target_id in self._kc_targets

View File

@@ -19,7 +19,8 @@ from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.picture_target import WledPictureTarget from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import KeyColorsPictureTarget
from wled_controller.utils import setup_logging, get_logger from wled_controller.utils import setup_logging, get_logger
# Initialize logging # Initialize logging
@@ -172,6 +173,17 @@ async def lifespan(app: FastAPI):
logger.info(f"Registered target: {target.name} ({target.id})") logger.info(f"Registered target: {target.name} ({target.id})")
except Exception as e: except Exception as e:
logger.error(f"Failed to register target {target.id}: {e}") logger.error(f"Failed to register target {target.id}: {e}")
elif isinstance(target, KeyColorsPictureTarget):
try:
processor_manager.add_kc_target(
target_id=target.id,
picture_source_id=target.picture_source_id,
settings=target.settings,
)
registered_targets += 1
logger.info(f"Registered KC target: {target.name} ({target.id})")
except Exception as e:
logger.error(f"Failed to register KC target {target.id}: {e}")
logger.info(f"Registered {registered_targets} picture target(s)") logger.info(f"Registered {registered_targets} picture target(s)")

View File

@@ -3998,7 +3998,7 @@ async function loadTargetsTab() {
}) })
); );
// Fetch state + metrics for each target // Fetch state + metrics for each target (+ colors for KC targets)
const targetsWithState = await Promise.all( const targetsWithState = await Promise.all(
targets.map(async (target) => { targets.map(async (target) => {
try { try {
@@ -4006,7 +4006,14 @@ async function loadTargetsTab() {
const state = stateResp.ok ? await stateResp.json() : {}; const state = stateResp.ok ? await stateResp.json() : {};
const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }); const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() });
const metrics = metricsResp.ok ? await metricsResp.json() : {}; const metrics = metricsResp.ok ? await metricsResp.json() : {};
return { ...target, state, metrics }; let latestColors = null;
if (target.target_type === 'key_colors' && state.processing) {
try {
const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() });
if (colorsResp.ok) latestColors = await colorsResp.json();
} catch {}
}
return { ...target, state, metrics, latestColors };
} catch { } catch {
return target; return target;
} }
@@ -4017,14 +4024,16 @@ async function loadTargetsTab() {
const deviceMap = {}; const deviceMap = {};
devicesWithState.forEach(d => { deviceMap[d.id] = d; }); devicesWithState.forEach(d => { deviceMap[d.id] = d; });
// Group by type (currently only WLED) // Group by type
const wledDevices = devicesWithState; const wledDevices = devicesWithState;
const wledTargets = targetsWithState.filter(t => t.target_type === 'wled'); const wledTargets = targetsWithState.filter(t => t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled'; const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled';
const subTabs = [ const subTabs = [
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length }, { key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length },
]; ];
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab => const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
@@ -4054,7 +4063,21 @@ async function loadTargetsTab() {
</div> </div>
</div>`; </div>`;
container.innerHTML = tabBar + wledPanel; // Key Colors panel
const kcPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
<div class="devices-grid">
${kcTargets.map(target => createKCTargetCard(target, sourceMap)).join('')}
<div class="template-card add-template-card" onclick="showKCEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
</div>`;
container.innerHTML = tabBar + wledPanel + kcPanel;
// Attach event listeners and fetch WLED brightness for device cards // Attach event listeners and fetch WLED brightness for device cards
devicesWithState.forEach(device => { devicesWithState.forEach(device => {
@@ -4062,6 +4085,21 @@ async function loadTargetsTab() {
fetchDeviceBrightness(device.id); fetchDeviceBrightness(device.id);
}); });
// Manage KC WebSockets: connect for processing, disconnect for stopped
const processingKCIds = new Set();
kcTargets.forEach(target => {
if (target.state && target.state.processing) {
processingKCIds.add(target.id);
if (!kcWebSockets[target.id]) {
connectKCWebSocket(target.id);
}
}
});
// Disconnect WebSockets for targets no longer processing
Object.keys(kcWebSockets).forEach(id => {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
} catch (error) { } catch (error) {
console.error('Failed to load targets tab:', error); console.error('Failed to load targets tab:', error);
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`; container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
@@ -4203,3 +4241,425 @@ async function deleteTarget(targetId) {
showToast('Failed to delete target', 'error'); showToast('Failed to delete target', 'error');
} }
} }
// ===== KEY COLORS TARGET CARD =====
function createKCTargetCard(target, sourceMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const kcSettings = target.key_colors_settings || {};
const isProcessing = state.processing || false;
const source = sourceMap[target.picture_source_id];
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
const rectCount = (kcSettings.rectangles || []).length;
// Render initial color swatches from pre-fetched REST data
let swatchesHtml = '';
const latestColors = target.latestColors && target.latestColors.colors;
if (isProcessing && latestColors && Object.keys(latestColors).length > 0) {
swatchesHtml = Object.entries(latestColors).map(([name, color]) => `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`).join('');
} else if (isProcessing) {
swatchesHtml = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
}
return `
<div class="card" data-kc-target-id="${target.id}">
<button class="card-remove-btn" onclick="deleteKCTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
${escapeHtml(target.name)}
<span class="badge">KEY COLORS</span>
${isProcessing ? `<span class="badge processing">${t('targets.status.processing')}</span>` : ''}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop" title="${t('kc.rectangles')}">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
</div>
<div class="card-content">
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
${swatchesHtml}
</div>
${isProcessing ? `
<div class="metrics-grid">
<div class="metric">
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
<div class="metric-label">${t('targets.metrics.actual_fps')}</div>
</div>
<div class="metric">
<div class="metric-value">${state.fps_target || 0}</div>
<div class="metric-label">${t('targets.metrics.target_fps')}</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.frames_processed || 0}</div>
<div class="metric-label">${t('targets.metrics.frames')}</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.errors_count || 0}</div>
<div class="metric-label">${t('targets.metrics.errors')}</div>
</div>
</div>
` : ''}
</div>
<div class="card-actions">
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('targets.button.stop')}">
⏹️
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('targets.button.start')}">
▶️
</button>
`}
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
}
// ===== KEY COLORS EDITOR =====
let kcEditorRectangles = [];
let kcEditorInitialValues = {};
let _kcNameManuallyEdited = false;
function _autoGenerateKCName() {
if (_kcNameManuallyEdited) return;
if (document.getElementById('kc-editor-id').value) return; // editing, not creating
const sourceSelect = document.getElementById('kc-editor-source');
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
if (!sourceName) return;
const rectCount = kcEditorRectangles.length;
const mode = document.getElementById('kc-editor-interpolation').value || 'average';
const modeName = t(`kc.interpolation.${mode}`);
document.getElementById('kc-editor-name').value = `${sourceName} [${rectCount}](${modeName})`;
}
function addKCRectangle(name = '', x = 0.0, y = 0.0, width = 1.0, height = 1.0) {
kcEditorRectangles.push({ name: name || `Zone ${kcEditorRectangles.length + 1}`, x, y, width, height });
renderKCRectangles();
_autoGenerateKCName();
}
function removeKCRectangle(index) {
kcEditorRectangles.splice(index, 1);
renderKCRectangles();
_autoGenerateKCName();
}
function renderKCRectangles() {
const container = document.getElementById('kc-rect-list');
if (!container) return;
if (kcEditorRectangles.length === 0) {
container.innerHTML = `<div class="kc-rect-empty">${t('kc.rect.empty')}</div>`;
return;
}
const labels = `<div class="kc-rect-labels">
<span>${t('kc.rect.name')}</span>
<span>${t('kc.rect.x')}</span>
<span>${t('kc.rect.y')}</span>
<span>${t('kc.rect.width')}</span>
<span>${t('kc.rect.height')}</span>
<span></span>
</div>`;
const rows = kcEditorRectangles.map((rect, i) => `
<div class="kc-rect-row">
<input type="text" value="${escapeHtml(rect.name)}" placeholder="${t('kc.rect.name')}" onchange="kcEditorRectangles[${i}].name = this.value">
<input type="number" value="${rect.x}" min="0" max="1" step="0.01" onchange="kcEditorRectangles[${i}].x = parseFloat(this.value) || 0">
<input type="number" value="${rect.y}" min="0" max="1" step="0.01" onchange="kcEditorRectangles[${i}].y = parseFloat(this.value) || 0">
<input type="number" value="${rect.width}" min="0.01" max="1" step="0.01" onchange="kcEditorRectangles[${i}].width = parseFloat(this.value) || 0.01">
<input type="number" value="${rect.height}" min="0.01" max="1" step="0.01" onchange="kcEditorRectangles[${i}].height = parseFloat(this.value) || 0.01">
<button type="button" class="kc-rect-remove-btn" onclick="removeKCRectangle(${i})" title="${t('kc.rect.remove')}">&#x2715;</button>
</div>
`).join('');
container.innerHTML = labels + rows;
}
async function showKCEditor(targetId = null) {
try {
// Load sources for dropdown
const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
// Populate source select (no empty option — source is required for KC targets)
const sourceSelect = document.getElementById('kc-editor-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
opt.textContent = `${typeIcon} ${s.name}`;
sourceSelect.appendChild(opt);
});
if (targetId) {
// Editing existing target
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
const kcSettings = target.key_colors_settings || {};
document.getElementById('kc-editor-id').value = target.id;
document.getElementById('kc-editor-name').value = target.name;
sourceSelect.value = target.picture_source_id || '';
document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10;
document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10;
document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3;
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
document.getElementById('kc-editor-title').textContent = t('kc.edit');
kcEditorRectangles = (kcSettings.rectangles || []).map(r => ({ ...r }));
} else {
// Creating new target
document.getElementById('kc-editor-id').value = '';
document.getElementById('kc-editor-name').value = '';
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
document.getElementById('kc-editor-fps').value = 10;
document.getElementById('kc-editor-fps-value').textContent = '10';
document.getElementById('kc-editor-interpolation').value = 'average';
document.getElementById('kc-editor-smoothing').value = 0.3;
document.getElementById('kc-editor-smoothing-value').textContent = '0.3';
document.getElementById('kc-editor-title').textContent = t('kc.add');
kcEditorRectangles = [];
}
renderKCRectangles();
// Auto-name: reset flag and wire listeners
_kcNameManuallyEdited = !!targetId; // treat edit mode as manually edited
document.getElementById('kc-editor-name').oninput = () => { _kcNameManuallyEdited = true; };
sourceSelect.onchange = () => _autoGenerateKCName();
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
// Trigger auto-name after dropdowns are populated (create mode only)
if (!targetId) _autoGenerateKCName();
kcEditorInitialValues = {
name: document.getElementById('kc-editor-name').value,
source: sourceSelect.value,
fps: document.getElementById('kc-editor-fps').value,
interpolation: document.getElementById('kc-editor-interpolation').value,
smoothing: document.getElementById('kc-editor-smoothing').value,
rectangles: JSON.stringify(kcEditorRectangles),
};
const modal = document.getElementById('kc-editor-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closeKCEditorModal);
document.getElementById('kc-editor-error').style.display = 'none';
setTimeout(() => document.getElementById('kc-editor-name').focus(), 100);
} catch (error) {
console.error('Failed to open KC editor:', error);
showToast('Failed to open key colors editor', 'error');
}
}
function isKCEditorDirty() {
return (
document.getElementById('kc-editor-name').value !== kcEditorInitialValues.name ||
document.getElementById('kc-editor-source').value !== kcEditorInitialValues.source ||
document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps ||
document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation ||
document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing ||
JSON.stringify(kcEditorRectangles) !== kcEditorInitialValues.rectangles
);
}
async function closeKCEditorModal() {
if (isKCEditorDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseKCEditorModal();
}
function forceCloseKCEditorModal() {
document.getElementById('kc-editor-modal').style.display = 'none';
document.getElementById('kc-editor-error').style.display = 'none';
unlockBody();
kcEditorInitialValues = {};
kcEditorRectangles = [];
}
async function saveKCEditor() {
const targetId = document.getElementById('kc-editor-id').value;
const name = document.getElementById('kc-editor-name').value.trim();
const sourceId = document.getElementById('kc-editor-source').value;
const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10;
const interpolation = document.getElementById('kc-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value);
const errorEl = document.getElementById('kc-editor-error');
if (!name) {
errorEl.textContent = t('kc.error.required');
errorEl.style.display = 'block';
return;
}
if (kcEditorRectangles.length === 0) {
errorEl.textContent = t('kc.error.no_rectangles');
errorEl.style.display = 'block';
return;
}
const payload = {
name,
picture_source_id: sourceId,
key_colors_settings: {
fps,
interpolation_mode: interpolation,
smoothing,
rectangles: kcEditorRectangles.map(r => ({
name: r.name,
x: r.x,
y: r.y,
width: r.width,
height: r.height,
})),
},
};
try {
let response;
if (targetId) {
response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'key_colors';
response = await fetch(`${API_BASE}/picture-targets`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload),
});
}
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(targetId ? t('kc.updated') : t('kc.created'), 'success');
forceCloseKCEditorModal();
await loadTargets();
} catch (error) {
console.error('Error saving KC target:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
async function deleteKCTarget(targetId) {
const confirmed = await showConfirm(t('kc.delete.confirm'));
if (!confirmed) return;
try {
disconnectKCWebSocket(targetId);
const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'DELETE',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('kc.deleted'), 'success');
loadTargets();
} else {
const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to delete key colors target', 'error');
}
}
// ===== KEY COLORS WEBSOCKET =====
const kcWebSockets = {};
function connectKCWebSocket(targetId) {
// Disconnect existing connection if any
disconnectKCWebSocket(targetId);
const key = localStorage.getItem('wled_api_key');
if (!key) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/picture-targets/${targetId}/ws?token=${encodeURIComponent(key)}`;
try {
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
updateKCColorSwatches(targetId, data.colors || {});
} catch (e) {
console.error('Failed to parse KC WebSocket message:', e);
}
};
ws.onclose = () => {
delete kcWebSockets[targetId];
};
ws.onerror = (error) => {
console.error(`KC WebSocket error for ${targetId}:`, error);
};
kcWebSockets[targetId] = ws;
} catch (error) {
console.error(`Failed to connect KC WebSocket for ${targetId}:`, error);
}
}
function disconnectKCWebSocket(targetId) {
const ws = kcWebSockets[targetId];
if (ws) {
ws.close();
delete kcWebSockets[targetId];
}
}
function disconnectAllKCWebSockets() {
Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId));
}
function updateKCColorSwatches(targetId, colors) {
const container = document.getElementById(`kc-swatches-${targetId}`);
if (!container) return;
const entries = Object.entries(colors);
if (entries.length === 0) {
container.innerHTML = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
return;
}
container.innerHTML = entries.map(([name, color]) => `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`).join('');
}

View File

@@ -314,6 +314,90 @@
</div> </div>
</div> </div>
<!-- Key Colors Editor Modal -->
<div id="kc-editor-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="kc-editor-title" data-i18n="kc.add">🎨 Add Key Colors Target</h2>
<button class="modal-close-btn" onclick="closeKCEditorModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="kc-editor-form">
<input type="hidden" id="kc-editor-id">
<div class="form-group">
<label for="kc-editor-name" data-i18n="kc.name">Target Name:</label>
<input type="text" id="kc-editor-name" data-i18n-placeholder="kc.name.placeholder" placeholder="My Key Colors Target" required>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-source" data-i18n="kc.source">Picture Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.source.hint">Which picture source to extract colors from</small>
<select id="kc-editor-source"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-fps" data-i18n="kc.fps">Extraction FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.fps.hint">How many times per second to extract colors (1-60)</small>
<div class="slider-row">
<input type="range" id="kc-editor-fps" min="1" max="60" value="10" oninput="document.getElementById('kc-editor-fps-value').textContent = this.value">
<span id="kc-editor-fps-value" class="slider-value">10</span>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-interpolation" data-i18n="kc.interpolation">Color Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.interpolation.hint">How to compute the key color from pixels in each rectangle</small>
<select id="kc-editor-interpolation">
<option value="average">Average</option>
<option value="median">Median</option>
<option value="dominant">Dominant</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-smoothing">
<span data-i18n="kc.smoothing">Smoothing:</span>
<span id="kc-editor-smoothing-value">0.3</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.smoothing.hint">Temporal blending between extractions (0=none, 1=full)</small>
<input type="range" id="kc-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('kc-editor-smoothing-value').textContent = this.value">
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="kc.rectangles">Color Rectangles</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.rectangles.hint">Define named rectangles in relative coordinates (0.0-1.0) on the captured image</small>
<div id="kc-rect-list" class="kc-rect-list"></div>
<button type="button" class="btn btn-secondary kc-add-rect-btn" onclick="addKCRectangle()">
+ <span data-i18n="kc.rect.add">Add Rectangle</span>
</button>
</div>
<div id="kc-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeKCEditorModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveKCEditor()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Login Modal --> <!-- Login Modal -->
<div id="api-key-modal" class="modal"> <div id="api-key-modal" class="modal">
<div class="modal-content"> <div class="modal-content">

View File

@@ -341,5 +341,40 @@
"targets.metrics.actual_fps": "Actual FPS", "targets.metrics.actual_fps": "Actual FPS",
"targets.metrics.target_fps": "Target FPS", "targets.metrics.target_fps": "Target FPS",
"targets.metrics.frames": "Frames", "targets.metrics.frames": "Frames",
"targets.metrics.errors": "Errors" "targets.metrics.errors": "Errors",
"targets.subtab.key_colors": "Key Colors",
"targets.section.key_colors": "🎨 Key Colors Targets",
"kc.add": "Add Key Colors Target",
"kc.edit": "Edit Key Colors Target",
"kc.name": "Target Name:",
"kc.name.placeholder": "My Key Colors Target",
"kc.source": "Picture Source:",
"kc.source.hint": "Which picture source to extract colors from",
"kc.source.none": "-- No source assigned --",
"kc.fps": "Extraction FPS:",
"kc.fps.hint": "How many times per second to extract colors (1-60)",
"kc.interpolation": "Color Mode:",
"kc.interpolation.hint": "How to compute the key color from pixels in each rectangle",
"kc.interpolation.average": "Average",
"kc.interpolation.median": "Median",
"kc.interpolation.dominant": "Dominant",
"kc.smoothing": "Smoothing:",
"kc.smoothing.hint": "Temporal blending between extractions (0=none, 1=full)",
"kc.rectangles": "Color Rectangles",
"kc.rectangles.hint": "Define named rectangles in relative coordinates (0.01.0) on the captured image",
"kc.rect.name": "Name",
"kc.rect.x": "X",
"kc.rect.y": "Y",
"kc.rect.width": "W",
"kc.rect.height": "H",
"kc.rect.add": "Add Rectangle",
"kc.rect.remove": "Remove",
"kc.rect.empty": "No rectangles defined. Add at least one rectangle to extract colors.",
"kc.created": "Key colors target created successfully",
"kc.updated": "Key colors target updated successfully",
"kc.deleted": "Key colors target deleted successfully",
"kc.delete.confirm": "Are you sure you want to delete this key colors target?",
"kc.error.no_rectangles": "Please add at least one rectangle",
"kc.error.required": "Please fill in all required fields",
"kc.colors.none": "No colors extracted yet"
} }

View File

@@ -341,5 +341,40 @@
"targets.metrics.actual_fps": "Факт. FPS", "targets.metrics.actual_fps": "Факт. FPS",
"targets.metrics.target_fps": "Целев. FPS", "targets.metrics.target_fps": "Целев. FPS",
"targets.metrics.frames": "Кадры", "targets.metrics.frames": "Кадры",
"targets.metrics.errors": "Ошибки" "targets.metrics.errors": "Ошибки",
"targets.subtab.key_colors": "Ключевые Цвета",
"targets.section.key_colors": "🎨 Цели Ключевых Цветов",
"kc.add": "Добавить Цель Ключевых Цветов",
"kc.edit": "Редактировать Цель Ключевых Цветов",
"kc.name": "Имя Цели:",
"kc.name.placeholder": "Моя Цель Ключевых Цветов",
"kc.source": "Источник:",
"kc.source.hint": "Из какого источника извлекать цвета",
"kc.source.none": "-- Источник не назначен --",
"kc.fps": "FPS Извлечения:",
"kc.fps.hint": "Сколько раз в секунду извлекать цвета (1-60)",
"kc.interpolation": "Режим Цвета:",
"kc.interpolation.hint": "Как вычислять ключевой цвет из пикселей в каждом прямоугольнике",
"kc.interpolation.average": "Среднее",
"kc.interpolation.median": "Медиана",
"kc.interpolation.dominant": "Доминантный",
"kc.smoothing": "Сглаживание:",
"kc.smoothing.hint": "Временное смешивание между извлечениями (0=нет, 1=полное)",
"kc.rectangles": "Цветовые Прямоугольники",
"kc.rectangles.hint": "Определите именованные прямоугольники в относительных координатах (0.01.0) на захваченном изображении",
"kc.rect.name": "Имя",
"kc.rect.x": "X",
"kc.rect.y": "Y",
"kc.rect.width": "Ш",
"kc.rect.height": "В",
"kc.rect.add": "Добавить Прямоугольник",
"kc.rect.remove": "Удалить",
"kc.rect.empty": "Прямоугольники не определены. Добавьте хотя бы один для извлечения цветов.",
"kc.created": "Цель ключевых цветов успешно создана",
"kc.updated": "Цель ключевых цветов успешно обновлена",
"kc.deleted": "Цель ключевых цветов успешно удалена",
"kc.delete.confirm": "Вы уверены, что хотите удалить эту цель ключевых цветов?",
"kc.error.no_rectangles": "Пожалуйста, добавьте хотя бы один прямоугольник",
"kc.error.required": "Пожалуйста, заполните все обязательные поля",
"kc.colors.none": "Цвета пока не извлечены"
} }

View File

@@ -2583,3 +2583,126 @@ input:-webkit-autofill:focus {
font-size: 0.7rem; font-size: 0.7rem;
} }
/* Key Colors target styles */
.kc-rect-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.kc-rect-row {
display: flex;
align-items: center;
gap: 6px;
padding: 8px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.kc-rect-row input[type="text"] {
flex: 2;
min-width: 0;
}
.kc-rect-row input[type="number"] {
flex: 1;
min-width: 0;
width: 60px;
}
.kc-rect-row .kc-rect-remove-btn {
background: none;
border: none;
color: #777;
font-size: 1rem;
cursor: pointer;
padding: 4px;
border-radius: 4px;
flex-shrink: 0;
transition: color 0.2s, background 0.2s;
}
.kc-rect-row .kc-rect-remove-btn:hover {
color: var(--danger-color);
background: rgba(244, 67, 54, 0.1);
}
.kc-rect-labels {
display: flex;
gap: 6px;
padding: 0 8px;
margin-bottom: 4px;
font-size: 0.7rem;
color: var(--text-secondary);
font-weight: 600;
}
.kc-rect-labels span:first-child {
flex: 2;
}
.kc-rect-labels span {
flex: 1;
text-align: center;
}
.kc-rect-labels span:last-child {
width: 28px;
flex: 0 0 28px;
}
.kc-add-rect-btn {
width: 100%;
font-size: 0.85rem;
padding: 6px 12px;
}
.kc-rect-empty {
text-align: center;
color: var(--text-secondary);
font-size: 0.85rem;
padding: 12px;
font-style: italic;
}
.kc-color-swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.kc-swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.kc-swatch-color {
width: 32px;
height: 32px;
border-radius: 6px;
border: 2px solid var(--border-color);
transition: background-color 0.3s;
}
.kc-swatch-label {
font-size: 0.6rem;
color: var(--text-secondary);
max-width: 40px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.kc-no-colors {
color: var(--text-secondary);
font-size: 0.8rem;
font-style: italic;
padding: 4px 0;
}

View File

@@ -0,0 +1,97 @@
"""Key colors picture target — extracts key colors from image rectangles."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional
from wled_controller.storage.picture_target import PictureTarget
@dataclass
class KeyColorRectangle:
"""A named rectangle in relative coordinates (0.0 to 1.0)."""
name: str
x: float
y: float
width: float
height: float
def to_dict(self) -> dict:
return {
"name": self.name,
"x": self.x,
"y": self.y,
"width": self.width,
"height": self.height,
}
@classmethod
def from_dict(cls, data: dict) -> "KeyColorRectangle":
return cls(
name=data["name"],
x=float(data.get("x", 0.0)),
y=float(data.get("y", 0.0)),
width=float(data.get("width", 1.0)),
height=float(data.get("height", 1.0)),
)
@dataclass
class KeyColorsSettings:
"""Settings for key colors extraction."""
fps: int = 10
interpolation_mode: str = "average"
smoothing: float = 0.3
rectangles: List[KeyColorRectangle] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"fps": self.fps,
"interpolation_mode": self.interpolation_mode,
"smoothing": self.smoothing,
"rectangles": [r.to_dict() for r in self.rectangles],
}
@classmethod
def from_dict(cls, data: dict) -> "KeyColorsSettings":
return cls(
fps=data.get("fps", 10),
interpolation_mode=data.get("interpolation_mode", "average"),
smoothing=data.get("smoothing", 0.3),
rectangles=[
KeyColorRectangle.from_dict(r)
for r in data.get("rectangles", [])
],
)
@dataclass
class KeyColorsPictureTarget(PictureTarget):
"""Key colors extractor target — extracts key colors from image rectangles."""
picture_source_id: str = ""
settings: KeyColorsSettings = field(default_factory=KeyColorsSettings)
def to_dict(self) -> dict:
d = super().to_dict()
d["picture_source_id"] = self.picture_source_id
d["settings"] = self.settings.to_dict()
return d
@classmethod
def from_dict(cls, data: dict) -> "KeyColorsPictureTarget":
settings_data = data.get("settings", {})
settings = KeyColorsSettings.from_dict(settings_data)
return cls(
id=data["id"],
name=data["name"],
target_type="key_colors",
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)

View File

@@ -1,12 +1,9 @@
"""Picture target data models.""" """Picture target base data model."""
import uuid from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from wled_controller.core.processor_manager import ProcessingSettings
@dataclass @dataclass
class PictureTarget: class PictureTarget:
@@ -14,7 +11,7 @@ class PictureTarget:
id: str id: str
name: str name: str
target_type: str # "wled" (future: "artnet", "e131", ...) target_type: str # "wled", "key_colors", ...
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
@@ -35,62 +32,9 @@ class 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": if target_type == "wled":
from wled_controller.storage.wled_picture_target import WledPictureTarget
return WledPictureTarget.from_dict(data) return WledPictureTarget.from_dict(data)
if target_type == "key_colors":
from wled_controller.storage.key_colors_picture_target import KeyColorsPictureTarget
return KeyColorsPictureTarget.from_dict(data)
raise ValueError(f"Unknown target type: {target_type}") raise ValueError(f"Unknown target type: {target_type}")
@dataclass
class WledPictureTarget(PictureTarget):
"""WLED picture target — streams a picture source to a WLED device."""
device_id: str = ""
picture_source_id: str = ""
settings: ProcessingSettings = field(default_factory=ProcessingSettings)
def to_dict(self) -> dict:
"""Convert to dictionary."""
d = super().to_dict()
d["device_id"] = self.device_id
d["picture_source_id"] = self.picture_source_id
d["settings"] = {
"display_index": self.settings.display_index,
"fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness,
"gamma": self.settings.gamma,
"saturation": self.settings.saturation,
"smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode,
"state_check_interval": self.settings.state_check_interval,
}
return d
@classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary."""
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
settings_data = data.get("settings", {})
settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0),
smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"),
state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
)
return cls(
id=data["id"],
name=data["name"],
target_type=data.get("target_type", "wled"),
device_id=data.get("device_id", ""),
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)

View File

@@ -7,7 +7,12 @@ from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from wled_controller.core.processor_manager import ProcessingSettings from wled_controller.core.processor_manager import ProcessingSettings
from wled_controller.storage.picture_target import PictureTarget, WledPictureTarget from wled_controller.storage.picture_target import PictureTarget
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import (
KeyColorsSettings,
KeyColorsPictureTarget,
)
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -97,22 +102,24 @@ class PictureTargetStore:
device_id: str = "", device_id: str = "",
picture_source_id: str = "", picture_source_id: str = "",
settings: Optional[ProcessingSettings] = None, settings: Optional[ProcessingSettings] = None,
key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None, description: Optional[str] = None,
) -> PictureTarget: ) -> PictureTarget:
"""Create a new picture target. """Create a new picture target.
Args: Args:
name: Target name name: Target name
target_type: Target type ("wled") target_type: Target type ("wled", "key_colors")
device_id: WLED device ID (for wled targets) device_id: WLED device ID (for wled targets)
picture_source_id: Picture source ID picture_source_id: Picture source ID
settings: Processing settings settings: Processing settings (for wled targets)
key_colors_settings: Key colors settings (for key_colors targets)
description: Optional description description: Optional description
Raises: Raises:
ValueError: If validation fails ValueError: If validation fails
""" """
if target_type not in ("wled",): if target_type not in ("wled", "key_colors"):
raise ValueError(f"Invalid target type: {target_type}") raise ValueError(f"Invalid target type: {target_type}")
# Check for duplicate name # Check for duplicate name
@@ -135,6 +142,17 @@ class PictureTargetStore:
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
elif target_type == "key_colors":
target = KeyColorsPictureTarget(
id=target_id,
name=name,
target_type="key_colors",
picture_source_id=picture_source_id,
settings=key_colors_settings or KeyColorsSettings(),
description=description,
created_at=now,
updated_at=now,
)
else: else:
raise ValueError(f"Unknown target type: {target_type}") raise ValueError(f"Unknown target type: {target_type}")
@@ -151,6 +169,7 @@ class PictureTargetStore:
device_id: Optional[str] = None, device_id: Optional[str] = None,
picture_source_id: Optional[str] = None, picture_source_id: Optional[str] = None,
settings: Optional[ProcessingSettings] = None, settings: Optional[ProcessingSettings] = None,
key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None, description: Optional[str] = None,
) -> PictureTarget: ) -> PictureTarget:
"""Update a picture target. """Update a picture target.
@@ -181,6 +200,12 @@ class PictureTargetStore:
if settings is not None: if settings is not None:
target.settings = settings target.settings = settings
if isinstance(target, KeyColorsPictureTarget):
if picture_source_id is not None:
target.picture_source_id = picture_source_id
if key_colors_settings is not None:
target.settings = key_colors_settings
target.updated_at = datetime.utcnow() target.updated_at = datetime.utcnow()
self._save() self._save()
@@ -211,7 +236,7 @@ class PictureTargetStore:
def is_referenced_by_source(self, source_id: str) -> bool: def is_referenced_by_source(self, source_id: str) -> bool:
"""Check if any target references a picture source.""" """Check if any target references a picture source."""
for target in self._targets.values(): for target in self._targets.values():
if isinstance(target, WledPictureTarget) and target.picture_source_id == source_id: if isinstance(target, (WledPictureTarget, KeyColorsPictureTarget)) and target.picture_source_id == source_id:
return True return True
return False return False

View File

@@ -0,0 +1,65 @@
"""WLED picture target — streams a picture source to a WLED device."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from wled_controller.core.processor_manager import ProcessingSettings
from wled_controller.storage.picture_target import PictureTarget
@dataclass
class WledPictureTarget(PictureTarget):
"""WLED picture target — streams a picture source to a WLED device."""
device_id: str = ""
picture_source_id: str = ""
settings: ProcessingSettings = field(default_factory=ProcessingSettings)
def to_dict(self) -> dict:
"""Convert to dictionary."""
d = super().to_dict()
d["device_id"] = self.device_id
d["picture_source_id"] = self.picture_source_id
d["settings"] = {
"display_index": self.settings.display_index,
"fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness,
"gamma": self.settings.gamma,
"saturation": self.settings.saturation,
"smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode,
"state_check_interval": self.settings.state_check_interval,
}
return d
@classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary."""
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
settings_data = data.get("settings", {})
settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0),
smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"),
state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
)
return cls(
id=data["id"],
name=data["name"],
target_type=data.get("target_type", "wled"),
device_id=data.get("device_id", ""),
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)