Refactor core/ into logical sub-packages and split filter files
Reorganize the flat core/ directory (17 files) into three sub-packages: - core/devices/ — LED device communication (led_client, wled/adalight clients, providers, DDP) - core/processing/ — target processing pipeline (processor_manager, target processors, live streams, settings) - core/capture/ — screen capture & calibration (screen_capture, calibration, pixel_processor, overlay) Also split the monolithic filters/builtin.py (460 lines, 8 filters) into individual files: brightness, saturation, gamma, downscaler, pixelate, auto_crop, flip, color_correction. Includes the ProcessorManager refactor from target-centric architecture: ProcessorManager slimmed from ~1600 to ~490 lines with unified _processors dict replacing duplicate _targets/_kc_targets dicts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"""Dependency injection for API routes."""
|
||||
|
||||
from wled_controller.core.processor_manager import ProcessorManager
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
|
||||
@@ -4,7 +4,7 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.core.led_client import (
|
||||
from wled_controller.core.devices.led_client import (
|
||||
get_all_providers,
|
||||
get_device_capabilities,
|
||||
get_provider,
|
||||
@@ -26,11 +26,11 @@ from wled_controller.api.schemas.devices import (
|
||||
DiscoveredDeviceResponse,
|
||||
DiscoverDevicesResponse,
|
||||
)
|
||||
from wled_controller.core.calibration import (
|
||||
from wled_controller.core.capture.calibration import (
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
)
|
||||
from wled_controller.core.processor_manager import ProcessorManager
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -50,6 +50,7 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
led_count=device.led_count,
|
||||
enabled=device.enabled,
|
||||
baud_rate=device.baud_rate,
|
||||
auto_shutdown=device.auto_shutdown,
|
||||
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||
created_at=device.created_at,
|
||||
@@ -110,6 +111,11 @@ async def create_device(
|
||||
detail=f"Failed to connect to {device_type} device at {device_url}: {e}"
|
||||
)
|
||||
|
||||
# Resolve auto_shutdown default: True for adalight, False otherwise
|
||||
auto_shutdown = device_data.auto_shutdown
|
||||
if auto_shutdown is None:
|
||||
auto_shutdown = device_type == "adalight"
|
||||
|
||||
# Create device in storage
|
||||
device = store.create_device(
|
||||
name=device_data.name,
|
||||
@@ -117,6 +123,7 @@ async def create_device(
|
||||
led_count=led_count,
|
||||
device_type=device_type,
|
||||
baud_rate=device_data.baud_rate,
|
||||
auto_shutdown=auto_shutdown,
|
||||
)
|
||||
|
||||
# Register in processor manager for health monitoring
|
||||
@@ -127,6 +134,7 @@ async def create_device(
|
||||
calibration=device.calibration,
|
||||
device_type=device.device_type,
|
||||
baud_rate=device.baud_rate,
|
||||
auto_shutdown=device.auto_shutdown,
|
||||
)
|
||||
|
||||
return _device_to_response(device)
|
||||
@@ -233,6 +241,7 @@ async def update_device(
|
||||
enabled=update_data.enabled,
|
||||
led_count=update_data.led_count,
|
||||
baud_rate=update_data.baud_rate,
|
||||
auto_shutdown=update_data.auto_shutdown,
|
||||
)
|
||||
|
||||
# Sync connection info in processor manager
|
||||
@@ -246,6 +255,10 @@ async def update_device(
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Sync auto_shutdown in runtime state
|
||||
if update_data.auto_shutdown is not None and device_id in manager._devices:
|
||||
manager._devices[device_id].auto_shutdown = update_data.auto_shutdown
|
||||
|
||||
return _device_to_response(device)
|
||||
|
||||
except ValueError as e:
|
||||
@@ -409,7 +422,6 @@ async def set_device_power(
|
||||
body: dict,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Turn device on or off."""
|
||||
device = store.get_device(device_id)
|
||||
@@ -423,13 +435,11 @@ async def set_device_power(
|
||||
raise HTTPException(status_code=400, detail="'on' must be a boolean")
|
||||
|
||||
try:
|
||||
if device.device_type == "adalight":
|
||||
if not on:
|
||||
await manager.send_black_frame(device_id)
|
||||
# "on" is a no-op for Adalight — next processing frame lights them up
|
||||
else:
|
||||
provider = get_provider(device.device_type)
|
||||
await provider.set_power(device.url, on)
|
||||
provider = get_provider(device.device_type)
|
||||
await provider.set_power(
|
||||
device.url, on,
|
||||
led_count=device.led_count, baud_rate=device.baud_rate,
|
||||
)
|
||||
return {"on": on}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set power for {device_id}: {e}")
|
||||
|
||||
@@ -35,7 +35,7 @@ from wled_controller.api.schemas.picture_sources import (
|
||||
)
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||
from wled_controller.core.processor_manager import ProcessorManager
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
|
||||
@@ -36,8 +36,9 @@ from wled_controller.api.schemas.picture_targets import (
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
|
||||
from wled_controller.core.screen_capture import (
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.core.processing.processing_settings import ProcessingSettings
|
||||
from wled_controller.core.capture.screen_capture import (
|
||||
calculate_average_color,
|
||||
calculate_dominant_color,
|
||||
calculate_median_color,
|
||||
@@ -276,25 +277,16 @@ async def update_target(
|
||||
description=data.description,
|
||||
)
|
||||
|
||||
# Sync processor manager
|
||||
if isinstance(target, WledPictureTarget):
|
||||
try:
|
||||
if data.settings is not None:
|
||||
manager.update_target_settings(target_id, target.settings)
|
||||
if data.picture_source_id is not None:
|
||||
manager.update_target_source(target_id, target.picture_source_id)
|
||||
if data.device_id is not None:
|
||||
manager.update_target_device(target_id, target.device_id)
|
||||
except ValueError:
|
||||
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
|
||||
# Sync processor manager (unified API handles both target types)
|
||||
try:
|
||||
if data.settings is not None or data.key_colors_settings is not None:
|
||||
manager.update_target_settings(target_id, target.settings)
|
||||
if data.picture_source_id is not None:
|
||||
manager.update_target_source(target_id, target.picture_source_id)
|
||||
if data.device_id is not None and isinstance(target, WledPictureTarget):
|
||||
manager.update_target_device(target_id, target.device_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return _target_to_response(target)
|
||||
|
||||
@@ -316,21 +308,15 @@ async def delete_target(
|
||||
):
|
||||
"""Delete a picture target. Stops processing first if active."""
|
||||
try:
|
||||
# Stop processing if running (WLED or KC)
|
||||
# Stop processing if running
|
||||
try:
|
||||
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:
|
||||
pass
|
||||
|
||||
# Remove from manager (WLED or KC)
|
||||
# Remove from manager
|
||||
try:
|
||||
if manager.is_kc_target(target_id):
|
||||
manager.remove_kc_target(target_id)
|
||||
else:
|
||||
manager.remove_target(target_id)
|
||||
manager.remove_target(target_id)
|
||||
except (ValueError, RuntimeError):
|
||||
pass
|
||||
|
||||
@@ -357,13 +343,10 @@ async def start_processing(
|
||||
):
|
||||
"""Start processing for a picture target."""
|
||||
try:
|
||||
# Verify target exists and dispatch by type
|
||||
target = target_store.get_target(target_id)
|
||||
# Verify target exists in store
|
||||
target_store.get_target(target_id)
|
||||
|
||||
if isinstance(target, KeyColorsPictureTarget):
|
||||
await manager.start_kc_processing(target_id)
|
||||
else:
|
||||
await manager.start_processing(target_id)
|
||||
await manager.start_processing(target_id)
|
||||
|
||||
logger.info(f"Started processing for target {target_id}")
|
||||
return {"status": "started", "target_id": target_id}
|
||||
@@ -385,10 +368,7 @@ async def stop_processing(
|
||||
):
|
||||
"""Stop processing for a picture target."""
|
||||
try:
|
||||
if manager.is_kc_target(target_id):
|
||||
await manager.stop_kc_processing(target_id)
|
||||
else:
|
||||
await manager.stop_processing(target_id)
|
||||
await manager.stop_processing(target_id)
|
||||
|
||||
logger.info(f"Stopped processing for target {target_id}")
|
||||
return {"status": "stopped", "target_id": target_id}
|
||||
@@ -410,10 +390,7 @@ async def get_target_state(
|
||||
):
|
||||
"""Get current processing state for a target."""
|
||||
try:
|
||||
if manager.is_kc_target(target_id):
|
||||
state = manager.get_kc_target_state(target_id)
|
||||
else:
|
||||
state = manager.get_target_state(target_id)
|
||||
state = manager.get_target_state(target_id)
|
||||
return TargetProcessingState(**state)
|
||||
|
||||
except ValueError as e:
|
||||
@@ -513,10 +490,7 @@ async def get_target_metrics(
|
||||
):
|
||||
"""Get processing metrics for a target."""
|
||||
try:
|
||||
if manager.is_kc_target(target_id):
|
||||
metrics = manager.get_kc_target_metrics(target_id)
|
||||
else:
|
||||
metrics = manager.get_target_metrics(target_id)
|
||||
metrics = manager.get_target_metrics(target_id)
|
||||
return TargetMetricsResponse(**metrics)
|
||||
|
||||
except ValueError as e:
|
||||
|
||||
@@ -32,7 +32,7 @@ from wled_controller.api.schemas.postprocessing import (
|
||||
)
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry, FilterInstance, ImagePool
|
||||
from wled_controller.core.processor_manager import ProcessorManager
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
|
||||
@@ -13,7 +13,7 @@ from wled_controller.api.schemas.system import (
|
||||
HealthResponse,
|
||||
VersionResponse,
|
||||
)
|
||||
from wled_controller.core.screen_capture import get_available_displays
|
||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -36,7 +36,7 @@ from wled_controller.api.schemas.filters import (
|
||||
)
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry
|
||||
from wled_controller.core.processor_manager import ProcessorManager
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
|
||||
@@ -14,6 +14,7 @@ class DeviceCreate(BaseModel):
|
||||
device_type: str = Field(default="wled", description="LED device type (e.g., wled, adalight)")
|
||||
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (required for adalight)")
|
||||
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
|
||||
auto_shutdown: Optional[bool] = Field(default=None, description="Turn off device when server stops (defaults to true for adalight)")
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
@@ -24,6 +25,7 @@ class DeviceUpdate(BaseModel):
|
||||
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
|
||||
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (for devices with manual_led_count capability)")
|
||||
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
|
||||
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops")
|
||||
|
||||
|
||||
class Calibration(BaseModel):
|
||||
@@ -90,6 +92,7 @@ class DeviceResponse(BaseModel):
|
||||
led_count: int = Field(description="Total number of LEDs")
|
||||
enabled: bool = Field(description="Whether device is enabled")
|
||||
baud_rate: Optional[int] = Field(None, description="Serial baud rate")
|
||||
auto_shutdown: bool = Field(default=False, description="Turn off device when server stops")
|
||||
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
|
||||
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
|
||||
from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL
|
||||
|
||||
|
||||
class ColorCorrection(BaseModel):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Core functionality for screen capture and WLED control."""
|
||||
|
||||
from .screen_capture import (
|
||||
from wled_controller.core.capture.screen_capture import (
|
||||
get_available_displays,
|
||||
capture_display,
|
||||
extract_border_pixels,
|
||||
|
||||
25
server/src/wled_controller/core/capture/__init__.py
Normal file
25
server/src/wled_controller/core/capture/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Screen capture and calibration."""
|
||||
|
||||
from wled_controller.core.capture.screen_capture import (
|
||||
BorderPixels,
|
||||
ScreenCapture,
|
||||
capture_display,
|
||||
extract_border_pixels,
|
||||
get_available_displays,
|
||||
)
|
||||
from wled_controller.core.capture.calibration import (
|
||||
CalibrationConfig,
|
||||
PixelMapper,
|
||||
create_default_calibration,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BorderPixels",
|
||||
"CalibrationConfig",
|
||||
"PixelMapper",
|
||||
"ScreenCapture",
|
||||
"capture_display",
|
||||
"create_default_calibration",
|
||||
"extract_border_pixels",
|
||||
"get_available_displays",
|
||||
]
|
||||
@@ -5,7 +5,7 @@ from typing import Dict, List, Literal, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.screen_capture import (
|
||||
from wled_controller.core.capture.screen_capture import (
|
||||
BorderPixels,
|
||||
get_edge_segments,
|
||||
calculate_average_color,
|
||||
@@ -8,7 +8,7 @@ import time
|
||||
import tkinter as tk
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
|
||||
from wled_controller.core.calibration import CalibrationConfig
|
||||
from wled_controller.core.capture.calibration import CalibrationConfig
|
||||
from wled_controller.core.capture_engines.base import DisplayInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
25
server/src/wled_controller/core/devices/__init__.py
Normal file
25
server/src/wled_controller/core/devices/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""LED device communication layer."""
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
check_device_health,
|
||||
create_led_client,
|
||||
get_all_providers,
|
||||
get_device_capabilities,
|
||||
get_provider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeviceHealth",
|
||||
"DiscoveredDevice",
|
||||
"LEDClient",
|
||||
"LEDDeviceProvider",
|
||||
"check_device_health",
|
||||
"create_led_client",
|
||||
"get_all_providers",
|
||||
"get_device_capabilities",
|
||||
"get_provider",
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from typing import List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.led_client import DeviceHealth, LEDClient
|
||||
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -59,7 +59,7 @@ def _build_adalight_header(led_count: int) -> bytes:
|
||||
class AdalightClient(LEDClient):
|
||||
"""LED client for Arduino Adalight serial devices."""
|
||||
|
||||
def __init__(self, url: str, led_count: int = 0, baud_rate: int = None, **kwargs):
|
||||
def __init__(self, url: str, led_count: int = 0, baud_rate: Optional[int] = None, **kwargs):
|
||||
"""Initialize Adalight client.
|
||||
|
||||
Args:
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from typing import List
|
||||
|
||||
from wled_controller.core.led_client import (
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
@@ -28,7 +30,7 @@ class AdalightDeviceProvider(LEDDeviceProvider):
|
||||
return {"manual_led_count", "power_control", "brightness_control"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
from wled_controller.core.adalight_client import AdalightClient
|
||||
from wled_controller.core.devices.adalight_client import AdalightClient
|
||||
|
||||
led_count = kwargs.pop("led_count", 0)
|
||||
baud_rate = kwargs.pop("baud_rate", None)
|
||||
@@ -36,7 +38,7 @@ class AdalightDeviceProvider(LEDDeviceProvider):
|
||||
return AdalightClient(url, led_count=led_count, baud_rate=baud_rate, **kwargs)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
from wled_controller.core.adalight_client import AdalightClient
|
||||
from wled_controller.core.devices.adalight_client import AdalightClient
|
||||
|
||||
return await AdalightClient.check_health(url, http_client, prev_health)
|
||||
|
||||
@@ -47,7 +49,7 @@ class AdalightDeviceProvider(LEDDeviceProvider):
|
||||
Empty dict — Adalight devices don't report LED count,
|
||||
so it must be provided by the user.
|
||||
"""
|
||||
from wled_controller.core.adalight_client import parse_adalight_url
|
||||
from wled_controller.core.devices.adalight_client import parse_adalight_url
|
||||
|
||||
port, _baud = parse_adalight_url(url)
|
||||
|
||||
@@ -94,11 +96,30 @@ class AdalightDeviceProvider(LEDDeviceProvider):
|
||||
logger.error(f"Serial port discovery failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_power(self, url: str) -> bool:
|
||||
async def get_power(self, url: str, **kwargs) -> bool:
|
||||
# Adalight has no hardware power query; assume on
|
||||
return True
|
||||
|
||||
async def set_power(self, url: str, on: bool) -> None:
|
||||
# Adalight power control is handled at the API layer via processor manager
|
||||
# because it needs access to the active serial client or device info.
|
||||
raise NotImplementedError("Use API-level set_power for Adalight")
|
||||
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||
"""Turn Adalight device on/off by sending an all-black frame (off) or no-op (on).
|
||||
|
||||
Requires kwargs: led_count (int), baud_rate (int | None).
|
||||
"""
|
||||
if on:
|
||||
return # "on" is a no-op — next processing frame lights LEDs up
|
||||
|
||||
led_count = kwargs.get("led_count", 0)
|
||||
baud_rate = kwargs.get("baud_rate")
|
||||
if led_count <= 0:
|
||||
raise ValueError("led_count is required to send black frame to Adalight device")
|
||||
|
||||
from wled_controller.core.devices.adalight_client import AdalightClient
|
||||
|
||||
client = AdalightClient(url, led_count=led_count, baud_rate=baud_rate)
|
||||
try:
|
||||
await client.connect()
|
||||
black = np.zeros((led_count, 3), dtype=np.uint8)
|
||||
await client.send_pixels(black, brightness=255)
|
||||
logger.info(f"Adalight power off: sent black frame to {url}")
|
||||
finally:
|
||||
await client.close()
|
||||
@@ -3,7 +3,9 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -72,7 +74,7 @@ class LEDClient(ABC):
|
||||
@abstractmethod
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Send pixel colors to the LED device (async).
|
||||
@@ -200,11 +202,11 @@ class LEDDeviceProvider(ABC):
|
||||
"""Set device brightness (0-255). Override if capabilities include brightness_control."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_power(self, url: str) -> bool:
|
||||
async def get_power(self, url: str, **kwargs) -> bool:
|
||||
"""Get device power state. Override if capabilities include power_control."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def set_power(self, url: str, on: bool) -> None:
|
||||
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||
"""Set device power state. Override if capabilities include power_control."""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -264,10 +266,10 @@ def get_device_capabilities(device_type: str) -> set:
|
||||
# ===== AUTO-REGISTER BUILT-IN PROVIDERS =====
|
||||
|
||||
def _register_builtin_providers():
|
||||
from wled_controller.core.wled_provider import WLEDDeviceProvider
|
||||
from wled_controller.core.devices.wled_provider import WLEDDeviceProvider
|
||||
register_provider(WLEDDeviceProvider())
|
||||
|
||||
from wled_controller.core.adalight_provider import AdalightDeviceProvider
|
||||
from wled_controller.core.devices.adalight_provider import AdalightDeviceProvider
|
||||
register_provider(AdalightDeviceProvider())
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import httpx
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.core.ddp_client import BusConfig, DDPClient
|
||||
from wled_controller.core.led_client import DeviceHealth, LEDClient
|
||||
from wled_controller.core.devices.ddp_client import BusConfig, DDPClient
|
||||
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -7,7 +7,7 @@ import httpx
|
||||
from zeroconf import ServiceStateChange
|
||||
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
||||
|
||||
from wled_controller.core.led_client import (
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
@@ -33,13 +33,13 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
return {"brightness_control", "power_control", "standby_required"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
from wled_controller.core.wled_client import WLEDClient
|
||||
from wled_controller.core.devices.wled_client import WLEDClient
|
||||
kwargs.pop("led_count", None)
|
||||
kwargs.pop("baud_rate", None)
|
||||
return WLEDClient(url, **kwargs)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
from wled_controller.core.wled_client import WLEDClient
|
||||
from wled_controller.core.devices.wled_client import WLEDClient
|
||||
return await WLEDClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
@@ -171,14 +171,14 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def get_power(self, url: str) -> bool:
|
||||
async def get_power(self, url: str, **kwargs) -> bool:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.get(f"{url}/json/state")
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("on", False)
|
||||
|
||||
async def set_power(self, url: str, on: bool) -> None:
|
||||
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.post(
|
||||
@@ -9,8 +9,15 @@ from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
# Import builtin filters to trigger auto-registration
|
||||
import wled_controller.core.filters.builtin # noqa: F401
|
||||
# Import individual filters to trigger auto-registration
|
||||
import wled_controller.core.filters.brightness # noqa: F401
|
||||
import wled_controller.core.filters.saturation # noqa: F401
|
||||
import wled_controller.core.filters.gamma # noqa: F401
|
||||
import wled_controller.core.filters.downscaler # noqa: F401
|
||||
import wled_controller.core.filters.pixelate # noqa: F401
|
||||
import wled_controller.core.filters.auto_crop # noqa: F401
|
||||
import wled_controller.core.filters.flip # noqa: F401
|
||||
import wled_controller.core.filters.color_correction # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"FilterOptionDef",
|
||||
|
||||
100
server/src/wled_controller/core/filters/auto_crop.py
Normal file
100
server/src/wled_controller/core/filters/auto_crop.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Auto-crop postprocessing filter."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class AutoCropFilter(PostprocessingFilter):
|
||||
"""Detects and crops black bars (letterboxing/pillarboxing) from the image."""
|
||||
|
||||
filter_id = "auto_crop"
|
||||
filter_name = "Auto Crop"
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="threshold",
|
||||
label="Black Threshold",
|
||||
option_type="int",
|
||||
default=15,
|
||||
min_value=0,
|
||||
max_value=50,
|
||||
step=1,
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="min_bar_size",
|
||||
label="Min Bar Size (px)",
|
||||
option_type="int",
|
||||
default=20,
|
||||
min_value=0,
|
||||
max_value=200,
|
||||
step=5,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
threshold = self.options.get("threshold", 15)
|
||||
min_bar_size = self.options.get("min_bar_size", 20)
|
||||
|
||||
h, w = image.shape[:2]
|
||||
min_h = max(1, h // 10)
|
||||
min_w = max(1, w // 10)
|
||||
|
||||
# Compute max channel value per row and per column (vectorized)
|
||||
row_max = image.max(axis=(1, 2)) # shape (h,)
|
||||
col_max = image.max(axis=(0, 2)) # shape (w,)
|
||||
|
||||
# Scan from top
|
||||
top = 0
|
||||
while top < h and row_max[top] <= threshold:
|
||||
top += 1
|
||||
|
||||
# Scan from bottom
|
||||
bottom = h
|
||||
while bottom > top and row_max[bottom - 1] <= threshold:
|
||||
bottom -= 1
|
||||
|
||||
# Scan from left
|
||||
left = 0
|
||||
while left < w and col_max[left] <= threshold:
|
||||
left += 1
|
||||
|
||||
# Scan from right
|
||||
right = w
|
||||
while right > left and col_max[right - 1] <= threshold:
|
||||
right -= 1
|
||||
|
||||
# Apply min_bar_size: only crop if the detected bar is large enough
|
||||
if top < min_bar_size:
|
||||
top = 0
|
||||
if (h - bottom) < min_bar_size:
|
||||
bottom = h
|
||||
if left < min_bar_size:
|
||||
left = 0
|
||||
if (w - right) < min_bar_size:
|
||||
right = w
|
||||
|
||||
# Safety: don't crop if remaining content is too small
|
||||
if (bottom - top) < min_h:
|
||||
top, bottom = 0, h
|
||||
if (right - left) < min_w:
|
||||
left, right = 0, w
|
||||
|
||||
# No crop needed
|
||||
if top == 0 and bottom == h and left == 0 and right == w:
|
||||
return None
|
||||
|
||||
cropped_h = bottom - top
|
||||
cropped_w = right - left
|
||||
channels = image.shape[2] if image.ndim == 3 else 3
|
||||
|
||||
result = image_pool.acquire(cropped_h, cropped_w, channels)
|
||||
np.copyto(result, image[top:bottom, left:right])
|
||||
return result
|
||||
43
server/src/wled_controller/core/filters/brightness.py
Normal file
43
server/src/wled_controller/core/filters/brightness.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Brightness postprocessing filter."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class BrightnessFilter(PostprocessingFilter):
|
||||
"""Adjusts image brightness by multiplying pixel values."""
|
||||
|
||||
filter_id = "brightness"
|
||||
filter_name = "Brightness"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
value = self.options["value"]
|
||||
lut = np.clip(np.arange(256, dtype=np.float32) * value, 0, 255)
|
||||
self._lut = lut.astype(np.uint8)
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="value",
|
||||
label="Brightness",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.05,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
if self.options["value"] == 1.0:
|
||||
return None
|
||||
image[:] = self._lut[image]
|
||||
return None
|
||||
@@ -1,459 +0,0 @@
|
||||
"""Built-in postprocessing filters."""
|
||||
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class BrightnessFilter(PostprocessingFilter):
|
||||
"""Adjusts image brightness by multiplying pixel values."""
|
||||
|
||||
filter_id = "brightness"
|
||||
filter_name = "Brightness"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
value = self.options["value"]
|
||||
lut = np.clip(np.arange(256, dtype=np.float32) * value, 0, 255)
|
||||
self._lut = lut.astype(np.uint8)
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="value",
|
||||
label="Brightness",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.05,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
if self.options["value"] == 1.0:
|
||||
return None
|
||||
image[:] = self._lut[image]
|
||||
return None
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class SaturationFilter(PostprocessingFilter):
|
||||
"""Adjusts color saturation via luminance blending."""
|
||||
|
||||
filter_id = "saturation"
|
||||
filter_name = "Saturation"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._float_buf: Optional[np.ndarray] = None
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="value",
|
||||
label="Saturation",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.1,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
value = self.options["value"]
|
||||
if value == 1.0:
|
||||
return None
|
||||
h, w, c = image.shape
|
||||
if self._float_buf is None or self._float_buf.shape != (h, w, c):
|
||||
self._float_buf = np.empty((h, w, c), dtype=np.float32)
|
||||
arr = self._float_buf
|
||||
np.copyto(arr, image)
|
||||
arr *= (1.0 / 255.0)
|
||||
lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis]
|
||||
arr[..., :3] = lum + (arr[..., :3] - lum) * value
|
||||
np.clip(arr, 0, 1.0, out=arr)
|
||||
arr *= 255.0
|
||||
np.copyto(image, arr, casting='unsafe')
|
||||
return None
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class GammaFilter(PostprocessingFilter):
|
||||
"""Applies gamma correction."""
|
||||
|
||||
filter_id = "gamma"
|
||||
filter_name = "Gamma"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
value = self.options["value"]
|
||||
lut = np.arange(256, dtype=np.float32) / 255.0
|
||||
np.power(lut, 1.0 / value, out=lut)
|
||||
self._lut = np.clip(lut * 255.0, 0, 255).astype(np.uint8)
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="value",
|
||||
label="Gamma",
|
||||
option_type="float",
|
||||
default=2.2,
|
||||
min_value=0.1,
|
||||
max_value=5.0,
|
||||
step=0.1,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
if self.options["value"] == 1.0:
|
||||
return None
|
||||
image[:] = self._lut[image]
|
||||
return None
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class DownscalerFilter(PostprocessingFilter):
|
||||
"""Downscales image by a factor. Returns a new image from the pool."""
|
||||
|
||||
filter_id = "downscaler"
|
||||
filter_name = "Downscaler"
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="factor",
|
||||
label="Scale Factor",
|
||||
option_type="float",
|
||||
default=0.5,
|
||||
min_value=0.1,
|
||||
max_value=1.0,
|
||||
step=0.05,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
factor = self.options["factor"]
|
||||
if factor >= 1.0:
|
||||
return None
|
||||
|
||||
h, w = image.shape[:2]
|
||||
new_h = max(1, int(h * factor))
|
||||
new_w = max(1, int(w * factor))
|
||||
|
||||
if new_h == h and new_w == w:
|
||||
return None
|
||||
|
||||
# Use OpenCV for fast downscaling (10-20x faster than PIL LANCZOS)
|
||||
# INTER_AREA is optimal for downscaling - high quality and fast
|
||||
import cv2
|
||||
|
||||
downscaled = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
||||
|
||||
result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
|
||||
np.copyto(result, downscaled)
|
||||
return result
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class PixelateFilter(PostprocessingFilter):
|
||||
"""Pixelates the image by averaging blocks of pixels."""
|
||||
|
||||
filter_id = "pixelate"
|
||||
filter_name = "Pixelate"
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="block_size",
|
||||
label="Block Size",
|
||||
option_type="int",
|
||||
default=8,
|
||||
min_value=2,
|
||||
max_value=64,
|
||||
step=1,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
block_size = self.options["block_size"]
|
||||
if block_size <= 1:
|
||||
return None
|
||||
|
||||
h, w = image.shape[:2]
|
||||
|
||||
# Process each block: compute mean and fill
|
||||
for y in range(0, h, block_size):
|
||||
for x in range(0, w, block_size):
|
||||
y_end = min(y + block_size, h)
|
||||
x_end = min(x + block_size, w)
|
||||
block = image[y:y_end, x:x_end]
|
||||
mean_color = block.mean(axis=(0, 1)).astype(np.uint8)
|
||||
image[y:y_end, x:x_end] = mean_color
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class AutoCropFilter(PostprocessingFilter):
|
||||
"""Detects and crops black bars (letterboxing/pillarboxing) from the image."""
|
||||
|
||||
filter_id = "auto_crop"
|
||||
filter_name = "Auto Crop"
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="threshold",
|
||||
label="Black Threshold",
|
||||
option_type="int",
|
||||
default=15,
|
||||
min_value=0,
|
||||
max_value=50,
|
||||
step=1,
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="min_bar_size",
|
||||
label="Min Bar Size (px)",
|
||||
option_type="int",
|
||||
default=20,
|
||||
min_value=0,
|
||||
max_value=200,
|
||||
step=5,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
threshold = self.options.get("threshold", 15)
|
||||
min_bar_size = self.options.get("min_bar_size", 20)
|
||||
|
||||
h, w = image.shape[:2]
|
||||
min_h = max(1, h // 10)
|
||||
min_w = max(1, w // 10)
|
||||
|
||||
# Compute max channel value per row and per column (vectorized)
|
||||
row_max = image.max(axis=(1, 2)) # shape (h,)
|
||||
col_max = image.max(axis=(0, 2)) # shape (w,)
|
||||
|
||||
# Scan from top
|
||||
top = 0
|
||||
while top < h and row_max[top] <= threshold:
|
||||
top += 1
|
||||
|
||||
# Scan from bottom
|
||||
bottom = h
|
||||
while bottom > top and row_max[bottom - 1] <= threshold:
|
||||
bottom -= 1
|
||||
|
||||
# Scan from left
|
||||
left = 0
|
||||
while left < w and col_max[left] <= threshold:
|
||||
left += 1
|
||||
|
||||
# Scan from right
|
||||
right = w
|
||||
while right > left and col_max[right - 1] <= threshold:
|
||||
right -= 1
|
||||
|
||||
# Apply min_bar_size: only crop if the detected bar is large enough
|
||||
if top < min_bar_size:
|
||||
top = 0
|
||||
if (h - bottom) < min_bar_size:
|
||||
bottom = h
|
||||
if left < min_bar_size:
|
||||
left = 0
|
||||
if (w - right) < min_bar_size:
|
||||
right = w
|
||||
|
||||
# Safety: don't crop if remaining content is too small
|
||||
if (bottom - top) < min_h:
|
||||
top, bottom = 0, h
|
||||
if (right - left) < min_w:
|
||||
left, right = 0, w
|
||||
|
||||
# No crop needed
|
||||
if top == 0 and bottom == h and left == 0 and right == w:
|
||||
return None
|
||||
|
||||
cropped_h = bottom - top
|
||||
cropped_w = right - left
|
||||
channels = image.shape[2] if image.ndim == 3 else 3
|
||||
|
||||
result = image_pool.acquire(cropped_h, cropped_w, channels)
|
||||
np.copyto(result, image[top:bottom, left:right])
|
||||
return result
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class FlipFilter(PostprocessingFilter):
|
||||
"""Flips the image horizontally and/or vertically."""
|
||||
|
||||
filter_id = "flip"
|
||||
filter_name = "Flip"
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="horizontal",
|
||||
label="Horizontal",
|
||||
option_type="bool",
|
||||
default=False,
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="vertical",
|
||||
label="Vertical",
|
||||
option_type="bool",
|
||||
default=False,
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
h = self.options.get("horizontal", False)
|
||||
v = self.options.get("vertical", False)
|
||||
if not h and not v:
|
||||
return None
|
||||
height, width, c = image.shape
|
||||
result = image_pool.acquire(height, width, c)
|
||||
if h and v:
|
||||
np.copyto(result, image[::-1, ::-1])
|
||||
elif h:
|
||||
np.copyto(result, image[:, ::-1])
|
||||
else:
|
||||
np.copyto(result, image[::-1])
|
||||
return result
|
||||
|
||||
|
||||
def _kelvin_to_rgb(kelvin: int) -> tuple:
|
||||
"""Convert color temperature in Kelvin to normalized RGB multipliers.
|
||||
|
||||
Uses Tanner Helland's approximation, normalized so 6500K = (1, 1, 1).
|
||||
"""
|
||||
t = kelvin / 100.0
|
||||
|
||||
# Red
|
||||
if t <= 66:
|
||||
r = 255.0
|
||||
else:
|
||||
r = 329.698727446 * ((t - 60) ** -0.1332047592)
|
||||
|
||||
# Green
|
||||
if t <= 66:
|
||||
g = 99.4708025861 * math.log(t) - 161.1195681661
|
||||
else:
|
||||
g = 288.1221695283 * ((t - 60) ** -0.0755148492)
|
||||
|
||||
# Blue
|
||||
if t >= 66:
|
||||
b = 255.0
|
||||
elif t <= 19:
|
||||
b = 0.0
|
||||
else:
|
||||
b = 138.5177312231 * math.log(t - 10) - 305.0447927307
|
||||
|
||||
r = max(0.0, min(255.0, r))
|
||||
g = max(0.0, min(255.0, g))
|
||||
b = max(0.0, min(255.0, b))
|
||||
|
||||
return r / 255.0, g / 255.0, b / 255.0
|
||||
|
||||
|
||||
# Pre-compute 6500K reference for normalization
|
||||
_REF_R, _REF_G, _REF_B = _kelvin_to_rgb(6500)
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class ColorCorrectionFilter(PostprocessingFilter):
|
||||
"""Adjusts color temperature and per-channel RGB gains using LUTs."""
|
||||
|
||||
filter_id = "color_correction"
|
||||
filter_name = "Color Correction"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
temp = self.options["temperature"]
|
||||
rg = self.options["red_gain"]
|
||||
gg = self.options["green_gain"]
|
||||
bg = self.options["blue_gain"]
|
||||
|
||||
# Color temperature → RGB multipliers, normalized to 6500K = (1,1,1)
|
||||
tr, tg, tb = _kelvin_to_rgb(temp)
|
||||
r_mult = (tr / _REF_R) * rg
|
||||
g_mult = (tg / _REF_G) * gg
|
||||
b_mult = (tb / _REF_B) * bg
|
||||
|
||||
# Build per-channel LUTs
|
||||
src = np.arange(256, dtype=np.float32)
|
||||
self._lut_r = np.clip(src * r_mult, 0, 255).astype(np.uint8)
|
||||
self._lut_g = np.clip(src * g_mult, 0, 255).astype(np.uint8)
|
||||
self._lut_b = np.clip(src * b_mult, 0, 255).astype(np.uint8)
|
||||
|
||||
self._is_neutral = (temp == 6500 and rg == 1.0 and gg == 1.0 and bg == 1.0)
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="temperature",
|
||||
label="Color Temperature (K)",
|
||||
option_type="int",
|
||||
default=6500,
|
||||
min_value=2000,
|
||||
max_value=10000,
|
||||
step=100,
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="red_gain",
|
||||
label="Red Gain",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.05,
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="green_gain",
|
||||
label="Green Gain",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.05,
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="blue_gain",
|
||||
label="Blue Gain",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.05,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
if self._is_neutral:
|
||||
return None
|
||||
image[:, :, 0] = self._lut_r[image[:, :, 0]]
|
||||
image[:, :, 1] = self._lut_g[image[:, :, 1]]
|
||||
image[:, :, 2] = self._lut_b[image[:, :, 2]]
|
||||
return None
|
||||
126
server/src/wled_controller/core/filters/color_correction.py
Normal file
126
server/src/wled_controller/core/filters/color_correction.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Color correction postprocessing filter."""
|
||||
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
def _kelvin_to_rgb(kelvin: int) -> tuple:
|
||||
"""Convert color temperature in Kelvin to normalized RGB multipliers.
|
||||
|
||||
Uses Tanner Helland's approximation, normalized so 6500K = (1, 1, 1).
|
||||
"""
|
||||
t = kelvin / 100.0
|
||||
|
||||
# Red
|
||||
if t <= 66:
|
||||
r = 255.0
|
||||
else:
|
||||
r = 329.698727446 * ((t - 60) ** -0.1332047592)
|
||||
|
||||
# Green
|
||||
if t <= 66:
|
||||
g = 99.4708025861 * math.log(t) - 161.1195681661
|
||||
else:
|
||||
g = 288.1221695283 * ((t - 60) ** -0.0755148492)
|
||||
|
||||
# Blue
|
||||
if t >= 66:
|
||||
b = 255.0
|
||||
elif t <= 19:
|
||||
b = 0.0
|
||||
else:
|
||||
b = 138.5177312231 * math.log(t - 10) - 305.0447927307
|
||||
|
||||
r = max(0.0, min(255.0, r))
|
||||
g = max(0.0, min(255.0, g))
|
||||
b = max(0.0, min(255.0, b))
|
||||
|
||||
return r / 255.0, g / 255.0, b / 255.0
|
||||
|
||||
|
||||
# Pre-compute 6500K reference for normalization
|
||||
_REF_R, _REF_G, _REF_B = _kelvin_to_rgb(6500)
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class ColorCorrectionFilter(PostprocessingFilter):
|
||||
"""Adjusts color temperature and per-channel RGB gains using LUTs."""
|
||||
|
||||
filter_id = "color_correction"
|
||||
filter_name = "Color Correction"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
temp = self.options["temperature"]
|
||||
rg = self.options["red_gain"]
|
||||
gg = self.options["green_gain"]
|
||||
bg = self.options["blue_gain"]
|
||||
|
||||
# Color temperature -> RGB multipliers, normalized to 6500K = (1,1,1)
|
||||
tr, tg, tb = _kelvin_to_rgb(temp)
|
||||
r_mult = (tr / _REF_R) * rg
|
||||
g_mult = (tg / _REF_G) * gg
|
||||
b_mult = (tb / _REF_B) * bg
|
||||
|
||||
# Build per-channel LUTs
|
||||
src = np.arange(256, dtype=np.float32)
|
||||
self._lut_r = np.clip(src * r_mult, 0, 255).astype(np.uint8)
|
||||
self._lut_g = np.clip(src * g_mult, 0, 255).astype(np.uint8)
|
||||
self._lut_b = np.clip(src * b_mult, 0, 255).astype(np.uint8)
|
||||
|
||||
self._is_neutral = (temp == 6500 and rg == 1.0 and gg == 1.0 and bg == 1.0)
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="temperature",
|
||||
label="Color Temperature (K)",
|
||||
option_type="int",
|
||||
default=6500,
|
||||
min_value=2000,
|
||||
max_value=10000,
|
||||
step=100,
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="red_gain",
|
||||
label="Red Gain",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.05,
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="green_gain",
|
||||
label="Green Gain",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.05,
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="blue_gain",
|
||||
label="Blue Gain",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.05,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
if self._is_neutral:
|
||||
return None
|
||||
image[:, :, 0] = self._lut_r[image[:, :, 0]]
|
||||
image[:, :, 1] = self._lut_g[image[:, :, 1]]
|
||||
image[:, :, 2] = self._lut_b[image[:, :, 2]]
|
||||
return None
|
||||
50
server/src/wled_controller/core/filters/downscaler.py
Normal file
50
server/src/wled_controller/core/filters/downscaler.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Downscaler postprocessing filter."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class DownscalerFilter(PostprocessingFilter):
|
||||
"""Downscales image by a factor. Returns a new image from the pool."""
|
||||
|
||||
filter_id = "downscaler"
|
||||
filter_name = "Downscaler"
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="factor",
|
||||
label="Scale Factor",
|
||||
option_type="float",
|
||||
default=0.5,
|
||||
min_value=0.1,
|
||||
max_value=1.0,
|
||||
step=0.05,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
factor = self.options["factor"]
|
||||
if factor >= 1.0:
|
||||
return None
|
||||
|
||||
h, w = image.shape[:2]
|
||||
new_h = max(1, int(h * factor))
|
||||
new_w = max(1, int(w * factor))
|
||||
|
||||
if new_h == h and new_w == w:
|
||||
return None
|
||||
|
||||
downscaled = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
||||
|
||||
result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
|
||||
np.copyto(result, downscaled)
|
||||
return result
|
||||
55
server/src/wled_controller/core/filters/flip.py
Normal file
55
server/src/wled_controller/core/filters/flip.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Flip postprocessing filter."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class FlipFilter(PostprocessingFilter):
|
||||
"""Flips the image horizontally and/or vertically."""
|
||||
|
||||
filter_id = "flip"
|
||||
filter_name = "Flip"
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="horizontal",
|
||||
label="Horizontal",
|
||||
option_type="bool",
|
||||
default=False,
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="vertical",
|
||||
label="Vertical",
|
||||
option_type="bool",
|
||||
default=False,
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
step=None,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
h = self.options.get("horizontal", False)
|
||||
v = self.options.get("vertical", False)
|
||||
if not h and not v:
|
||||
return None
|
||||
height, width, c = image.shape
|
||||
result = image_pool.acquire(height, width, c)
|
||||
if h and v:
|
||||
np.copyto(result, image[::-1, ::-1])
|
||||
elif h:
|
||||
np.copyto(result, image[:, ::-1])
|
||||
else:
|
||||
np.copyto(result, image[::-1])
|
||||
return result
|
||||
44
server/src/wled_controller/core/filters/gamma.py
Normal file
44
server/src/wled_controller/core/filters/gamma.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Gamma correction postprocessing filter."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class GammaFilter(PostprocessingFilter):
|
||||
"""Applies gamma correction."""
|
||||
|
||||
filter_id = "gamma"
|
||||
filter_name = "Gamma"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
value = self.options["value"]
|
||||
lut = np.arange(256, dtype=np.float32) / 255.0
|
||||
np.power(lut, 1.0 / value, out=lut)
|
||||
self._lut = np.clip(lut * 255.0, 0, 255).astype(np.uint8)
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="value",
|
||||
label="Gamma",
|
||||
option_type="float",
|
||||
default=2.2,
|
||||
min_value=0.1,
|
||||
max_value=5.0,
|
||||
step=0.1,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
if self.options["value"] == 1.0:
|
||||
return None
|
||||
image[:] = self._lut[image]
|
||||
return None
|
||||
48
server/src/wled_controller/core/filters/pixelate.py
Normal file
48
server/src/wled_controller/core/filters/pixelate.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Pixelate postprocessing filter."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class PixelateFilter(PostprocessingFilter):
|
||||
"""Pixelates the image by averaging blocks of pixels."""
|
||||
|
||||
filter_id = "pixelate"
|
||||
filter_name = "Pixelate"
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="block_size",
|
||||
label="Block Size",
|
||||
option_type="int",
|
||||
default=8,
|
||||
min_value=2,
|
||||
max_value=64,
|
||||
step=1,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
block_size = self.options["block_size"]
|
||||
if block_size <= 1:
|
||||
return None
|
||||
|
||||
h, w = image.shape[:2]
|
||||
|
||||
for y in range(0, h, block_size):
|
||||
for x in range(0, w, block_size):
|
||||
y_end = min(y + block_size, h)
|
||||
x_end = min(x + block_size, w)
|
||||
block = image[y:y_end, x:x_end]
|
||||
mean_color = block.mean(axis=(0, 1)).astype(np.uint8)
|
||||
image[y:y_end, x:x_end] = mean_color
|
||||
|
||||
return None
|
||||
52
server/src/wled_controller/core/filters/saturation.py
Normal file
52
server/src/wled_controller/core/filters/saturation.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Saturation postprocessing filter."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class SaturationFilter(PostprocessingFilter):
|
||||
"""Adjusts color saturation via luminance blending."""
|
||||
|
||||
filter_id = "saturation"
|
||||
filter_name = "Saturation"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._float_buf: Optional[np.ndarray] = None
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="value",
|
||||
label="Saturation",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.1,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
value = self.options["value"]
|
||||
if value == 1.0:
|
||||
return None
|
||||
h, w, c = image.shape
|
||||
if self._float_buf is None or self._float_buf.shape != (h, w, c):
|
||||
self._float_buf = np.empty((h, w, c), dtype=np.float32)
|
||||
arr = self._float_buf
|
||||
np.copyto(arr, image)
|
||||
arr *= (1.0 / 255.0)
|
||||
lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis]
|
||||
arr[..., :3] = lum + (arr[..., :3] - lum) * value
|
||||
np.clip(arr, 0, 1.0, out=arr)
|
||||
arr *= 255.0
|
||||
np.copyto(image, arr, casting='unsafe')
|
||||
return None
|
||||
23
server/src/wled_controller/core/processing/__init__.py
Normal file
23
server/src/wled_controller/core/processing/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Target processing pipeline."""
|
||||
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.core.processing.processing_settings import (
|
||||
DEFAULT_STATE_CHECK_INTERVAL,
|
||||
ProcessingSettings,
|
||||
)
|
||||
from wled_controller.core.processing.target_processor import (
|
||||
DeviceInfo,
|
||||
ProcessingMetrics,
|
||||
TargetContext,
|
||||
TargetProcessor,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_STATE_CHECK_INTERVAL",
|
||||
"DeviceInfo",
|
||||
"ProcessingMetrics",
|
||||
"ProcessingSettings",
|
||||
"ProcessorManager",
|
||||
"TargetContext",
|
||||
"TargetProcessor",
|
||||
]
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Key Colors target processor — extracts dominant colors from screen regions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.processing.live_stream import LiveStream
|
||||
from wled_controller.core.capture.screen_capture import (
|
||||
calculate_average_color,
|
||||
calculate_dominant_color,
|
||||
calculate_median_color,
|
||||
)
|
||||
from wled_controller.core.processing.target_processor import (
|
||||
ProcessingMetrics,
|
||||
TargetContext,
|
||||
TargetProcessor,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
KC_WORK_SIZE = (160, 90) # (width, height) — small enough for fast color calc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CPU-bound frame processing (runs in thread pool via asyncio.to_thread)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _process_kc_frame(capture, rect_names, rect_bounds, calc_fn, prev_colors_arr, smoothing):
|
||||
"""All CPU-bound work for one KC frame.
|
||||
|
||||
Returns (colors, colors_arr, timing_ms) where:
|
||||
- colors is a dict {name: (r, g, b)}
|
||||
- colors_arr is a (N, 3) float64 array for smoothing continuity
|
||||
- timing_ms is a dict with per-stage timing in milliseconds.
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Downsample to working resolution — 144x fewer pixels at 1080p
|
||||
small = cv2.resize(capture.image, KC_WORK_SIZE, interpolation=cv2.INTER_AREA)
|
||||
|
||||
# Extract colors for each rectangle from the small image
|
||||
n = len(rect_names)
|
||||
colors_arr = np.empty((n, 3), dtype=np.float64)
|
||||
for i, (y1, y2, x1, x2) in enumerate(rect_bounds):
|
||||
colors_arr[i] = calc_fn(small[y1:y2, x1:x2])
|
||||
|
||||
t1 = time.perf_counter()
|
||||
|
||||
# Vectorized smoothing on (N, 3) array
|
||||
if prev_colors_arr is not None and smoothing > 0:
|
||||
colors_arr = colors_arr * (1 - smoothing) + prev_colors_arr * smoothing
|
||||
|
||||
colors_u8 = np.clip(colors_arr, 0, 255).astype(np.uint8)
|
||||
t2 = time.perf_counter()
|
||||
|
||||
# Build output dict
|
||||
colors = {rect_names[i]: tuple(int(c) for c in colors_u8[i]) for i in range(n)}
|
||||
|
||||
timing_ms = {
|
||||
"calc_colors": (t1 - t0) * 1000,
|
||||
"smooth": (t2 - t1) * 1000,
|
||||
"total": (t2 - t0) * 1000,
|
||||
}
|
||||
return colors, colors_arr, timing_ms
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KCTargetProcessor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class KCTargetProcessor(TargetProcessor):
|
||||
"""Extracts key colors from screen capture regions and broadcasts via WebSocket."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target_id: str,
|
||||
picture_source_id: str,
|
||||
settings, # KeyColorsSettings
|
||||
ctx: TargetContext,
|
||||
):
|
||||
super().__init__(target_id, picture_source_id, ctx)
|
||||
self._settings = settings
|
||||
|
||||
# Runtime state
|
||||
self._live_stream: Optional[LiveStream] = None
|
||||
self._previous_colors: Optional[Dict[str, Tuple[int, int, int]]] = None
|
||||
self._latest_colors: Optional[Dict[str, Tuple[int, int, int]]] = None
|
||||
self._ws_clients: List = []
|
||||
self._resolved_target_fps: Optional[int] = None
|
||||
self._resolved_rectangles = None
|
||||
|
||||
# ----- Properties -----
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
return self._settings
|
||||
|
||||
# ----- Lifecycle -----
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._is_running:
|
||||
logger.debug(f"KC target {self._target_id} is already running")
|
||||
return
|
||||
|
||||
if not self._picture_source_id:
|
||||
raise ValueError(f"KC target {self._target_id} has no picture source assigned")
|
||||
|
||||
if not self._settings.pattern_template_id:
|
||||
raise ValueError(f"KC target {self._target_id} has no pattern template assigned")
|
||||
|
||||
# Resolve pattern template to get rectangles
|
||||
try:
|
||||
pattern_template = self._ctx.pattern_template_store.get_template(
|
||||
self._settings.pattern_template_id
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
raise ValueError(
|
||||
f"Pattern template {self._settings.pattern_template_id} not found"
|
||||
)
|
||||
|
||||
if not pattern_template.rectangles:
|
||||
raise ValueError(
|
||||
f"Pattern template {self._settings.pattern_template_id} has no rectangles"
|
||||
)
|
||||
|
||||
self._resolved_rectangles = pattern_template.rectangles
|
||||
|
||||
# Acquire live stream
|
||||
try:
|
||||
live_stream = await asyncio.to_thread(
|
||||
self._ctx.live_stream_manager.acquire, self._picture_source_id
|
||||
)
|
||||
self._live_stream = live_stream
|
||||
self._resolved_target_fps = live_stream.target_fps
|
||||
logger.info(
|
||||
f"Acquired live stream for KC target {self._target_id} "
|
||||
f"(picture_source={self._picture_source_id})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize live stream for KC target {self._target_id}: {e}")
|
||||
raise RuntimeError(f"Failed to initialize live stream: {e}")
|
||||
|
||||
# Reset metrics
|
||||
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
|
||||
self._previous_colors = None
|
||||
self._latest_colors = None
|
||||
|
||||
# Start processing task
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
self._is_running = True
|
||||
|
||||
logger.info(f"Started KC processing for target {self._target_id}")
|
||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True})
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._is_running:
|
||||
logger.warning(f"KC processing not running for target {self._target_id}")
|
||||
return
|
||||
|
||||
self._is_running = False
|
||||
|
||||
# Cancel task
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
# Release live stream
|
||||
if self._live_stream:
|
||||
try:
|
||||
self._ctx.live_stream_manager.release(self._picture_source_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error releasing live stream for KC target: {e}")
|
||||
self._live_stream = None
|
||||
|
||||
logger.info(f"Stopped KC processing for target {self._target_id}")
|
||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
|
||||
|
||||
# ----- Settings -----
|
||||
|
||||
def update_settings(self, settings) -> None:
|
||||
self._settings = settings
|
||||
logger.info(f"Updated KC target settings: {self._target_id}")
|
||||
|
||||
# ----- State / Metrics -----
|
||||
|
||||
def get_state(self) -> dict:
|
||||
metrics = self._metrics
|
||||
return {
|
||||
"target_id": self._target_id,
|
||||
"processing": self._is_running,
|
||||
"fps_actual": round(metrics.fps_actual, 1) if self._is_running else None,
|
||||
"fps_potential": metrics.fps_potential if self._is_running else None,
|
||||
"fps_target": self._settings.fps,
|
||||
"frames_skipped": metrics.frames_skipped if self._is_running else None,
|
||||
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
|
||||
"fps_current": metrics.fps_current if self._is_running else None,
|
||||
"timing_calc_colors_ms": round(metrics.timing_calc_colors_ms, 1) if self._is_running else None,
|
||||
"timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if self._is_running else None,
|
||||
"timing_broadcast_ms": round(metrics.timing_broadcast_ms, 1) if self._is_running else None,
|
||||
"timing_total_ms": round(metrics.timing_total_ms, 1) if self._is_running else None,
|
||||
"last_update": metrics.last_update,
|
||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||
}
|
||||
|
||||
def get_metrics(self) -> dict:
|
||||
metrics = self._metrics
|
||||
uptime = 0.0
|
||||
if metrics.start_time and self._is_running:
|
||||
uptime = (datetime.utcnow() - metrics.start_time).total_seconds()
|
||||
|
||||
return {
|
||||
"target_id": self._target_id,
|
||||
"processing": self._is_running,
|
||||
"fps_actual": round(metrics.fps_actual, 1),
|
||||
"fps_target": self._settings.fps,
|
||||
"uptime_seconds": round(uptime, 1),
|
||||
"frames_processed": metrics.frames_processed,
|
||||
"errors_count": metrics.errors_count,
|
||||
"last_error": metrics.last_error,
|
||||
"last_update": metrics.last_update.isoformat() if metrics.last_update else None,
|
||||
}
|
||||
|
||||
# ----- WebSocket -----
|
||||
|
||||
def supports_websocket(self) -> bool:
|
||||
return True
|
||||
|
||||
def add_ws_client(self, ws) -> None:
|
||||
self._ws_clients.append(ws)
|
||||
|
||||
def remove_ws_client(self, ws) -> None:
|
||||
if ws in self._ws_clients:
|
||||
self._ws_clients.remove(ws)
|
||||
|
||||
def get_latest_colors(self) -> Dict[str, Tuple[int, int, int]]:
|
||||
return self._latest_colors or {}
|
||||
|
||||
# ----- Private: processing loop -----
|
||||
|
||||
async def _processing_loop(self) -> None:
|
||||
"""Main processing loop for key-colors extraction."""
|
||||
settings = self._settings
|
||||
|
||||
target_fps = 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] = []
|
||||
timing_samples: collections.deque = collections.deque(maxlen=10)
|
||||
prev_frame_time_stamp = time.time()
|
||||
prev_capture = None
|
||||
last_broadcast_time = 0.0
|
||||
send_timestamps: collections.deque = collections.deque()
|
||||
|
||||
rectangles = self._resolved_rectangles
|
||||
|
||||
# Pre-compute pixel bounds at working resolution (160x90)
|
||||
kc_w, kc_h = KC_WORK_SIZE
|
||||
rect_names = [r.name for r in rectangles]
|
||||
rect_bounds = []
|
||||
for rect in rectangles:
|
||||
px_x = max(0, int(rect.x * kc_w))
|
||||
px_y = max(0, int(rect.y * kc_h))
|
||||
px_w = max(1, int(rect.width * kc_w))
|
||||
px_h = max(1, int(rect.height * kc_h))
|
||||
px_x = min(px_x, kc_w - 1)
|
||||
px_y = min(px_y, kc_h - 1)
|
||||
px_w = min(px_w, kc_w - px_x)
|
||||
px_h = min(px_h, kc_h - px_y)
|
||||
rect_bounds.append((px_y, px_y + px_h, px_x, px_x + px_w))
|
||||
prev_colors_arr = None
|
||||
|
||||
logger.info(
|
||||
f"KC processing loop started for target {self._target_id} "
|
||||
f"(fps={target_fps}, rects={len(rectangles)})"
|
||||
)
|
||||
|
||||
try:
|
||||
while self._is_running:
|
||||
loop_start = time.time()
|
||||
|
||||
try:
|
||||
capture = self._live_stream.get_latest_frame()
|
||||
|
||||
if capture is None:
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
|
||||
# Skip processing if the frame hasn't changed
|
||||
if capture is prev_capture:
|
||||
# Keepalive: re-broadcast last colors
|
||||
if self._latest_colors and (loop_start - last_broadcast_time) >= 1.0:
|
||||
await self._broadcast_colors(self._latest_colors)
|
||||
last_broadcast_time = time.time()
|
||||
send_timestamps.append(last_broadcast_time)
|
||||
self._metrics.frames_keepalive += 1
|
||||
self._metrics.frames_skipped += 1
|
||||
now_ts = time.time()
|
||||
while send_timestamps and send_timestamps[0] < now_ts - 1.0:
|
||||
send_timestamps.popleft()
|
||||
self._metrics.fps_current = len(send_timestamps)
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
prev_capture = capture
|
||||
|
||||
# CPU-bound work in thread pool
|
||||
colors, colors_arr, frame_timing = await asyncio.to_thread(
|
||||
_process_kc_frame,
|
||||
capture, rect_names, rect_bounds, calc_fn,
|
||||
prev_colors_arr, smoothing,
|
||||
)
|
||||
|
||||
prev_colors_arr = colors_arr
|
||||
self._latest_colors = dict(colors)
|
||||
|
||||
# Broadcast to WebSocket clients
|
||||
t_broadcast_start = time.perf_counter()
|
||||
await self._broadcast_colors(colors)
|
||||
broadcast_ms = (time.perf_counter() - t_broadcast_start) * 1000
|
||||
last_broadcast_time = time.time()
|
||||
send_timestamps.append(last_broadcast_time)
|
||||
|
||||
# Per-stage timing (rolling average over last 10 frames)
|
||||
frame_timing["broadcast"] = broadcast_ms
|
||||
timing_samples.append(frame_timing)
|
||||
n = len(timing_samples)
|
||||
self._metrics.timing_calc_colors_ms = sum(s["calc_colors"] for s in timing_samples) / n
|
||||
self._metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n
|
||||
self._metrics.timing_broadcast_ms = sum(s["broadcast"] for s in timing_samples) / n
|
||||
self._metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + broadcast_ms
|
||||
|
||||
# Update metrics
|
||||
self._metrics.frames_processed += 1
|
||||
self._metrics.last_update = datetime.utcnow()
|
||||
|
||||
# Calculate actual FPS
|
||||
now = time.time()
|
||||
interval = now - prev_frame_time_stamp
|
||||
prev_frame_time_stamp = now
|
||||
fps_samples.append(1.0 / interval if interval > 0 else 0)
|
||||
if len(fps_samples) > 10:
|
||||
fps_samples.pop(0)
|
||||
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
|
||||
|
||||
# Potential FPS
|
||||
processing_time = now - loop_start
|
||||
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
|
||||
|
||||
# fps_current
|
||||
while send_timestamps and send_timestamps[0] < now - 1.0:
|
||||
send_timestamps.popleft()
|
||||
self._metrics.fps_current = len(send_timestamps)
|
||||
|
||||
except Exception as e:
|
||||
self._metrics.errors_count += 1
|
||||
self._metrics.last_error = str(e)
|
||||
logger.error(f"KC processing error for {self._target_id}: {e}", exc_info=True)
|
||||
|
||||
# Throttle to target FPS
|
||||
elapsed = time.time() - loop_start
|
||||
remaining = frame_time - elapsed
|
||||
if remaining > 0:
|
||||
await asyncio.sleep(remaining)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"KC processing loop cancelled for target {self._target_id}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error in KC processing loop for target {self._target_id}: {e}")
|
||||
self._is_running = False
|
||||
raise
|
||||
finally:
|
||||
logger.info(f"KC processing loop ended for target {self._target_id}")
|
||||
|
||||
async def _broadcast_colors(self, colors: Dict[str, Tuple[int, int, int]]) -> None:
|
||||
"""Broadcast extracted colors to WebSocket clients."""
|
||||
if not self._ws_clients:
|
||||
return
|
||||
|
||||
message = json.dumps({
|
||||
"type": "colors_update",
|
||||
"target_id": self._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 self._ws_clients:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
except Exception:
|
||||
disconnected.append(ws)
|
||||
|
||||
for ws in disconnected:
|
||||
self._ws_clients.remove(ws)
|
||||
@@ -16,7 +16,7 @@ import numpy as np
|
||||
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry, PostprocessingFilter
|
||||
from wled_controller.core.live_stream import (
|
||||
from wled_controller.core.processing.live_stream import (
|
||||
LiveStream,
|
||||
ProcessedLiveStream,
|
||||
ScreenCaptureLiveStream,
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Processing settings shared across target types."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessingSettings:
|
||||
"""Settings for screen processing."""
|
||||
|
||||
display_index: int = 0
|
||||
fps: int = 30
|
||||
brightness: float = 1.0
|
||||
gamma: float = 2.2
|
||||
saturation: float = 1.0
|
||||
smoothing: float = 0.3
|
||||
interpolation_mode: str = "average"
|
||||
standby_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||
701
server/src/wled_controller/core/processing/processor_manager.py
Normal file
701
server/src/wled_controller/core/processing/processor_manager.py
Normal file
@@ -0,0 +1,701 @@
|
||||
"""Processing manager — thin orchestrator for devices and target processors."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from wled_controller.core.capture.calibration import (
|
||||
CalibrationConfig,
|
||||
create_default_calibration,
|
||||
)
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
check_device_health,
|
||||
create_led_client,
|
||||
get_provider,
|
||||
)
|
||||
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
|
||||
from wled_controller.core.capture.screen_overlay import OverlayManager
|
||||
from wled_controller.core.processing.processing_settings import (
|
||||
DEFAULT_STATE_CHECK_INTERVAL,
|
||||
ProcessingSettings,
|
||||
)
|
||||
from wled_controller.core.processing.target_processor import (
|
||||
DeviceInfo,
|
||||
TargetContext,
|
||||
TargetProcessor,
|
||||
)
|
||||
from wled_controller.core.processing.wled_target_processor import WledTargetProcessor
|
||||
from wled_controller.core.processing.kc_target_processor import KCTargetProcessor
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceState:
|
||||
"""State for a registered LED device (health monitoring + calibration)."""
|
||||
|
||||
device_id: str
|
||||
device_url: str
|
||||
led_count: int
|
||||
calibration: CalibrationConfig
|
||||
device_type: str = "wled"
|
||||
baud_rate: Optional[int] = None
|
||||
health: DeviceHealth = field(default_factory=DeviceHealth)
|
||||
health_task: Optional[asyncio.Task] = None
|
||||
# Software brightness for devices without hardware brightness (e.g. Adalight)
|
||||
software_brightness: int = 255
|
||||
# Auto-shutdown: turn off device when server stops
|
||||
auto_shutdown: bool = False
|
||||
# Calibration test mode (works independently of target processing)
|
||||
test_mode_active: bool = False
|
||||
test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ProcessorManager:
|
||||
"""Manages devices and delegates target processing to TargetProcessor instances.
|
||||
|
||||
Devices are registered for health monitoring and calibration.
|
||||
Targets are registered for processing via polymorphic TargetProcessor subclasses.
|
||||
"""
|
||||
|
||||
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None):
|
||||
"""Initialize processor manager."""
|
||||
self._devices: Dict[str, DeviceState] = {}
|
||||
self._processors: Dict[str, TargetProcessor] = {}
|
||||
self._health_monitoring_active = False
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
self._picture_source_store = picture_source_store
|
||||
self._capture_template_store = capture_template_store
|
||||
self._pp_template_store = pp_template_store
|
||||
self._pattern_template_store = pattern_template_store
|
||||
self._device_store = device_store
|
||||
self._live_stream_manager = LiveStreamManager(
|
||||
picture_source_store, capture_template_store, pp_template_store
|
||||
)
|
||||
self._overlay_manager = OverlayManager()
|
||||
self._event_queues: List[asyncio.Queue] = []
|
||||
logger.info("Processor manager initialized")
|
||||
|
||||
# ===== SHARED CONTEXT (passed to target processors) =====
|
||||
|
||||
def _build_context(self) -> TargetContext:
|
||||
"""Build a TargetContext for target processors."""
|
||||
return TargetContext(
|
||||
live_stream_manager=self._live_stream_manager,
|
||||
overlay_manager=self._overlay_manager,
|
||||
picture_source_store=self._picture_source_store,
|
||||
capture_template_store=self._capture_template_store,
|
||||
pp_template_store=self._pp_template_store,
|
||||
pattern_template_store=self._pattern_template_store,
|
||||
device_store=self._device_store,
|
||||
fire_event=self._fire_event,
|
||||
get_device_info=self._get_device_info,
|
||||
)
|
||||
|
||||
def _get_device_info(self, device_id: str) -> Optional[DeviceInfo]:
|
||||
"""Create a DeviceInfo snapshot from the current device state."""
|
||||
ds = self._devices.get(device_id)
|
||||
if ds is None:
|
||||
return None
|
||||
return DeviceInfo(
|
||||
device_id=ds.device_id,
|
||||
device_url=ds.device_url,
|
||||
led_count=ds.led_count,
|
||||
calibration=ds.calibration,
|
||||
device_type=ds.device_type,
|
||||
baud_rate=ds.baud_rate,
|
||||
software_brightness=ds.software_brightness,
|
||||
test_mode_active=ds.test_mode_active,
|
||||
)
|
||||
|
||||
# ===== EVENT SYSTEM (state change notifications) =====
|
||||
|
||||
def subscribe_events(self) -> asyncio.Queue:
|
||||
"""Subscribe to state change events. Returns queue to read from."""
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
|
||||
self._event_queues.append(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe_events(self, queue: asyncio.Queue) -> None:
|
||||
"""Unsubscribe from events."""
|
||||
if queue in self._event_queues:
|
||||
self._event_queues.remove(queue)
|
||||
|
||||
def _fire_event(self, event: dict) -> None:
|
||||
"""Push event to all subscribers (non-blocking)."""
|
||||
for q in self._event_queues:
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create a shared HTTP client for health checks."""
|
||||
if self._http_client is None or self._http_client.is_closed:
|
||||
self._http_client = httpx.AsyncClient(timeout=5)
|
||||
return self._http_client
|
||||
|
||||
# ===== DEVICE MANAGEMENT (health monitoring + calibration) =====
|
||||
|
||||
def add_device(
|
||||
self,
|
||||
device_id: str,
|
||||
device_url: str,
|
||||
led_count: int,
|
||||
calibration: Optional[CalibrationConfig] = None,
|
||||
device_type: str = "wled",
|
||||
baud_rate: Optional[int] = None,
|
||||
software_brightness: int = 255,
|
||||
auto_shutdown: bool = False,
|
||||
):
|
||||
"""Register a device for health monitoring."""
|
||||
if device_id in self._devices:
|
||||
raise ValueError(f"Device {device_id} already registered")
|
||||
|
||||
if calibration is None:
|
||||
calibration = create_default_calibration(led_count)
|
||||
|
||||
state = DeviceState(
|
||||
device_id=device_id,
|
||||
device_url=device_url,
|
||||
led_count=led_count,
|
||||
calibration=calibration,
|
||||
device_type=device_type,
|
||||
baud_rate=baud_rate,
|
||||
software_brightness=software_brightness,
|
||||
auto_shutdown=auto_shutdown,
|
||||
)
|
||||
|
||||
self._devices[device_id] = state
|
||||
|
||||
if self._health_monitoring_active:
|
||||
self._start_device_health_check(device_id)
|
||||
|
||||
logger.info(f"Registered device {device_id} with {led_count} LEDs")
|
||||
|
||||
def remove_device(self, device_id: str):
|
||||
"""Unregister a device."""
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
# Check if any processor is using this device
|
||||
for proc in self._processors.values():
|
||||
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id:
|
||||
raise RuntimeError(
|
||||
f"Cannot remove device {device_id}: target {proc.target_id} is using it"
|
||||
)
|
||||
|
||||
self._stop_device_health_check(device_id)
|
||||
del self._devices[device_id]
|
||||
logger.info(f"Unregistered device {device_id}")
|
||||
|
||||
def update_device_info(self, device_id: str, device_url: Optional[str] = None, led_count: Optional[int] = None, baud_rate: Optional[int] = None):
|
||||
"""Update device connection info."""
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
ds = self._devices[device_id]
|
||||
if device_url is not None:
|
||||
ds.device_url = device_url
|
||||
if led_count is not None:
|
||||
ds.led_count = led_count
|
||||
if baud_rate is not None:
|
||||
ds.baud_rate = baud_rate
|
||||
|
||||
def update_calibration(self, device_id: str, calibration: CalibrationConfig):
|
||||
"""Update calibration for a device.
|
||||
|
||||
Also propagates to any WledTargetProcessor using this device.
|
||||
"""
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
calibration.validate()
|
||||
|
||||
ds = self._devices[device_id]
|
||||
if calibration.get_total_leds() != ds.led_count:
|
||||
raise ValueError(
|
||||
f"Calibration LED count ({calibration.get_total_leds()}) "
|
||||
f"does not match device LED count ({ds.led_count})"
|
||||
)
|
||||
|
||||
ds.calibration = calibration
|
||||
|
||||
# Propagate to active WLED processors
|
||||
for proc in self._processors.values():
|
||||
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id:
|
||||
proc.update_calibration(calibration)
|
||||
|
||||
logger.info(f"Updated calibration for device {device_id}")
|
||||
|
||||
def get_device_state(self, device_id: str) -> DeviceState:
|
||||
"""Get device state (for health/calibration info)."""
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
return self._devices[device_id]
|
||||
|
||||
def get_device_health(self, device_id: str) -> dict:
|
||||
"""Get health status for a device."""
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
h = self._devices[device_id].health
|
||||
return {
|
||||
"online": h.online,
|
||||
"latency_ms": h.latency_ms,
|
||||
"last_checked": h.last_checked,
|
||||
"device_name": h.device_name,
|
||||
"device_version": h.device_version,
|
||||
"device_led_count": h.device_led_count,
|
||||
"device_rgbw": h.device_rgbw,
|
||||
"device_led_type": h.device_led_type,
|
||||
"device_fps": h.device_fps,
|
||||
"error": h.error,
|
||||
}
|
||||
|
||||
def get_device_health_dict(self, device_id: str) -> dict:
|
||||
"""Get device connection/health state as a state response dict."""
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
ds = self._devices[device_id]
|
||||
h = ds.health
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"device_online": h.online,
|
||||
"device_latency_ms": h.latency_ms,
|
||||
"device_name": h.device_name,
|
||||
"device_version": h.device_version,
|
||||
"device_led_count": h.device_led_count,
|
||||
"device_rgbw": h.device_rgbw,
|
||||
"device_led_type": h.device_led_type,
|
||||
"device_fps": h.device_fps,
|
||||
"device_last_checked": h.last_checked,
|
||||
"device_error": h.error,
|
||||
"test_mode": ds.test_mode_active,
|
||||
"test_mode_edges": list(ds.test_mode_edges.keys()),
|
||||
}
|
||||
|
||||
def get_all_devices(self) -> List[str]:
|
||||
"""Get list of all registered device IDs."""
|
||||
return list(self._devices.keys())
|
||||
|
||||
# ===== TARGET REGISTRATION =====
|
||||
|
||||
def add_target(
|
||||
self,
|
||||
target_id: str,
|
||||
device_id: str,
|
||||
settings: Optional[ProcessingSettings] = None,
|
||||
picture_source_id: str = "",
|
||||
):
|
||||
"""Register a WLED target processor."""
|
||||
if target_id in self._processors:
|
||||
raise ValueError(f"Target {target_id} already registered")
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not registered")
|
||||
|
||||
proc = WledTargetProcessor(
|
||||
target_id=target_id,
|
||||
device_id=device_id,
|
||||
settings=settings or ProcessingSettings(),
|
||||
picture_source_id=picture_source_id,
|
||||
ctx=self._build_context(),
|
||||
)
|
||||
self._processors[target_id] = proc
|
||||
logger.info(f"Registered target {target_id} for device {device_id}")
|
||||
|
||||
def add_kc_target(self, target_id: str, picture_source_id: str, settings) -> None:
|
||||
"""Register a key-colors target processor."""
|
||||
if target_id in self._processors:
|
||||
raise ValueError(f"KC target {target_id} already registered")
|
||||
|
||||
proc = KCTargetProcessor(
|
||||
target_id=target_id,
|
||||
picture_source_id=picture_source_id,
|
||||
settings=settings,
|
||||
ctx=self._build_context(),
|
||||
)
|
||||
self._processors[target_id] = proc
|
||||
logger.info(f"Registered KC target: {target_id}")
|
||||
|
||||
def remove_target(self, target_id: str):
|
||||
"""Unregister a target (any type)."""
|
||||
if target_id not in self._processors:
|
||||
raise ValueError(f"Target {target_id} not found")
|
||||
proc = self._processors[target_id]
|
||||
if proc.is_running:
|
||||
raise RuntimeError(f"Cannot remove target {target_id} while processing")
|
||||
del self._processors[target_id]
|
||||
logger.info(f"Unregistered target {target_id}")
|
||||
|
||||
# Backward-compat alias
|
||||
def remove_kc_target(self, target_id: str) -> None:
|
||||
self.remove_target(target_id)
|
||||
|
||||
# ===== UNIFIED TARGET OPERATIONS =====
|
||||
|
||||
def update_target_settings(self, target_id: str, settings):
|
||||
"""Update processing settings for a target (any type)."""
|
||||
proc = self._get_processor(target_id)
|
||||
proc.update_settings(settings)
|
||||
|
||||
def update_target_source(self, target_id: str, picture_source_id: str):
|
||||
"""Update the picture source for a target (any type)."""
|
||||
proc = self._get_processor(target_id)
|
||||
proc.update_source(picture_source_id)
|
||||
|
||||
def update_target_device(self, target_id: str, device_id: str):
|
||||
"""Update the device for a WLED target."""
|
||||
proc = self._get_processor(target_id)
|
||||
if not isinstance(proc, WledTargetProcessor):
|
||||
raise ValueError(f"Target {target_id} is not a WLED target")
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not registered")
|
||||
proc.update_device(device_id)
|
||||
|
||||
async def start_processing(self, target_id: str):
|
||||
"""Start processing for a target (any type)."""
|
||||
proc = self._get_processor(target_id)
|
||||
|
||||
# Enforce one-target-per-device for WLED targets
|
||||
if isinstance(proc, WledTargetProcessor):
|
||||
for other_id, other in self._processors.items():
|
||||
if (
|
||||
other_id != target_id
|
||||
and isinstance(other, WledTargetProcessor)
|
||||
and other.device_id == proc.device_id
|
||||
and other.is_running
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Device {proc.device_id} is already being processed by target {other_id}"
|
||||
)
|
||||
|
||||
await proc.start()
|
||||
|
||||
async def stop_processing(self, target_id: str):
|
||||
"""Stop processing for a target (any type)."""
|
||||
proc = self._get_processor(target_id)
|
||||
await proc.stop()
|
||||
|
||||
def get_target_state(self, target_id: str) -> dict:
|
||||
"""Get current processing state for a target (any type).
|
||||
|
||||
For WLED targets, device health info is merged in.
|
||||
"""
|
||||
proc = self._get_processor(target_id)
|
||||
state = proc.get_state()
|
||||
|
||||
# Merge device health for WLED targets
|
||||
if isinstance(proc, WledTargetProcessor) and proc.device_id in self._devices:
|
||||
h = self._devices[proc.device_id].health
|
||||
state.update({
|
||||
"device_online": h.online,
|
||||
"device_latency_ms": h.latency_ms,
|
||||
"device_name": h.device_name,
|
||||
"device_version": h.device_version,
|
||||
"device_led_count": h.device_led_count,
|
||||
"device_rgbw": h.device_rgbw,
|
||||
"device_led_type": h.device_led_type,
|
||||
"device_fps": h.device_fps,
|
||||
"device_last_checked": h.last_checked,
|
||||
"device_error": h.error,
|
||||
})
|
||||
|
||||
return state
|
||||
|
||||
def get_target_metrics(self, target_id: str) -> dict:
|
||||
"""Get detailed metrics for a target (any type)."""
|
||||
return self._get_processor(target_id).get_metrics()
|
||||
|
||||
def is_target_processing(self, target_id: str) -> bool:
|
||||
"""Check if target is currently processing."""
|
||||
return self._get_processor(target_id).is_running
|
||||
|
||||
def is_device_processing(self, device_id: str) -> bool:
|
||||
"""Check if any target is processing for a device."""
|
||||
for proc in self._processors.values():
|
||||
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id and proc.is_running:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_processing_target_for_device(self, device_id: str) -> Optional[str]:
|
||||
"""Get the target_id that is currently processing for a device."""
|
||||
for proc in self._processors.values():
|
||||
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id and proc.is_running:
|
||||
return proc.target_id
|
||||
return None
|
||||
|
||||
# Backward-compat aliases for KC-specific operations
|
||||
def update_kc_target_settings(self, target_id: str, settings) -> None:
|
||||
self.update_target_settings(target_id, settings)
|
||||
|
||||
def update_kc_target_source(self, target_id: str, picture_source_id: str) -> None:
|
||||
self.update_target_source(target_id, picture_source_id)
|
||||
|
||||
async def start_kc_processing(self, target_id: str) -> None:
|
||||
await self.start_processing(target_id)
|
||||
|
||||
async def stop_kc_processing(self, target_id: str) -> None:
|
||||
await self.stop_processing(target_id)
|
||||
|
||||
def get_kc_target_state(self, target_id: str) -> dict:
|
||||
return self.get_target_state(target_id)
|
||||
|
||||
def get_kc_target_metrics(self, target_id: str) -> dict:
|
||||
return self.get_target_metrics(target_id)
|
||||
|
||||
def is_kc_target(self, target_id: str) -> bool:
|
||||
"""Check if a target ID belongs to a KC target."""
|
||||
return isinstance(self._processors.get(target_id), KCTargetProcessor)
|
||||
|
||||
# ===== OVERLAY VISUALIZATION (delegates to processor) =====
|
||||
|
||||
async def start_overlay(self, target_id: str, target_name: str = None) -> None:
|
||||
proc = self._get_processor(target_id)
|
||||
if not proc.supports_overlay():
|
||||
raise ValueError(f"Target {target_id} does not support overlays")
|
||||
await proc.start_overlay(target_name)
|
||||
|
||||
async def stop_overlay(self, target_id: str) -> None:
|
||||
proc = self._get_processor(target_id)
|
||||
await proc.stop_overlay()
|
||||
|
||||
def is_overlay_active(self, target_id: str) -> bool:
|
||||
return self._get_processor(target_id).is_overlay_active()
|
||||
|
||||
# ===== WEBSOCKET (delegates to processor) =====
|
||||
|
||||
def add_kc_ws_client(self, target_id: str, ws) -> None:
|
||||
proc = self._get_processor(target_id)
|
||||
proc.add_ws_client(ws)
|
||||
|
||||
def remove_kc_ws_client(self, target_id: str, ws) -> None:
|
||||
proc = self._processors.get(target_id)
|
||||
if proc:
|
||||
proc.remove_ws_client(ws)
|
||||
|
||||
def get_kc_latest_colors(self, target_id: str) -> Dict[str, Tuple[int, int, int]]:
|
||||
proc = self._get_processor(target_id)
|
||||
return proc.get_latest_colors()
|
||||
|
||||
# ===== CALIBRATION TEST MODE (on device) =====
|
||||
|
||||
async def set_test_mode(self, device_id: str, edges: Dict[str, List[int]]) -> None:
|
||||
"""Set or clear calibration test mode for a device."""
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
ds = self._devices[device_id]
|
||||
|
||||
if edges:
|
||||
ds.test_mode_active = True
|
||||
ds.test_mode_edges = {
|
||||
edge: tuple(color) for edge, color in edges.items()
|
||||
}
|
||||
await self._send_test_pixels(device_id)
|
||||
else:
|
||||
ds.test_mode_active = False
|
||||
ds.test_mode_edges = {}
|
||||
await self._send_clear_pixels(device_id)
|
||||
|
||||
async def _send_test_pixels(self, device_id: str) -> None:
|
||||
"""Build and send test pixel array for active test edges."""
|
||||
ds = self._devices[device_id]
|
||||
pixels = [(0, 0, 0)] * ds.led_count
|
||||
|
||||
for edge_name, color in ds.test_mode_edges.items():
|
||||
for seg in ds.calibration.segments:
|
||||
if seg.edge == edge_name:
|
||||
for i in range(seg.led_start, seg.led_start + seg.led_count):
|
||||
if i < ds.led_count:
|
||||
pixels[i] = color
|
||||
break
|
||||
|
||||
try:
|
||||
active_client = self._find_active_led_client(device_id)
|
||||
if active_client:
|
||||
await active_client.send_pixels(pixels)
|
||||
else:
|
||||
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True, led_count=ds.led_count, baud_rate=ds.baud_rate) as client:
|
||||
await client.send_pixels(pixels)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send test pixels for {device_id}: {e}")
|
||||
|
||||
async def _send_clear_pixels(self, device_id: str) -> None:
|
||||
"""Send all-black pixels to clear LED output."""
|
||||
ds = self._devices[device_id]
|
||||
pixels = [(0, 0, 0)] * ds.led_count
|
||||
|
||||
try:
|
||||
active_client = self._find_active_led_client(device_id)
|
||||
if active_client:
|
||||
await active_client.send_pixels(pixels)
|
||||
else:
|
||||
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True, led_count=ds.led_count, baud_rate=ds.baud_rate) as client:
|
||||
await client.send_pixels(pixels)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear pixels for {device_id}: {e}")
|
||||
|
||||
def _find_active_led_client(self, device_id: str):
|
||||
"""Find an active LED client for a device (from a running WLED processor)."""
|
||||
for proc in self._processors.values():
|
||||
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id and proc.is_running and proc.led_client:
|
||||
return proc.led_client
|
||||
return None
|
||||
|
||||
# ===== DISPLAY LOCK INFO =====
|
||||
|
||||
def is_display_locked(self, display_index: int) -> bool:
|
||||
"""Check if a display is currently being captured by any target."""
|
||||
for proc in self._processors.values():
|
||||
if isinstance(proc, WledTargetProcessor) and proc.is_running and proc.settings.display_index == display_index:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_display_lock_info(self, display_index: int) -> Optional[str]:
|
||||
"""Get the device ID that is currently capturing from a display."""
|
||||
for proc in self._processors.values():
|
||||
if isinstance(proc, WledTargetProcessor) and proc.is_running and proc.settings.display_index == display_index:
|
||||
return proc.device_id
|
||||
return None
|
||||
|
||||
# ===== LIFECYCLE =====
|
||||
|
||||
async def stop_all(self):
|
||||
"""Stop processing and health monitoring for all targets and devices."""
|
||||
await self.stop_health_monitoring()
|
||||
|
||||
# Stop all processors
|
||||
for target_id, proc in list(self._processors.items()):
|
||||
if proc.is_running:
|
||||
try:
|
||||
await proc.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping target {target_id}: {e}")
|
||||
|
||||
# Auto-shutdown devices that have the flag enabled
|
||||
for device_id, ds in self._devices.items():
|
||||
if not ds.auto_shutdown:
|
||||
continue
|
||||
try:
|
||||
provider = get_provider(ds.device_type)
|
||||
await provider.set_power(
|
||||
ds.device_url, False,
|
||||
led_count=ds.led_count, baud_rate=ds.baud_rate,
|
||||
)
|
||||
logger.info(f"Auto-shutdown: powered off {ds.device_type} device {device_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-shutdown failed for device {device_id}: {e}")
|
||||
|
||||
# Safety net: release any remaining managed live streams
|
||||
self._live_stream_manager.release_all()
|
||||
|
||||
# Close shared HTTP client
|
||||
if self._http_client and not self._http_client.is_closed:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
logger.info("Stopped all processors")
|
||||
|
||||
# ===== HEALTH MONITORING =====
|
||||
|
||||
async def start_health_monitoring(self):
|
||||
"""Start background health checks for all registered devices."""
|
||||
self._health_monitoring_active = True
|
||||
for device_id in self._devices:
|
||||
self._start_device_health_check(device_id)
|
||||
logger.info("Started health monitoring for all devices")
|
||||
|
||||
async def stop_health_monitoring(self):
|
||||
"""Stop all background health checks."""
|
||||
self._health_monitoring_active = False
|
||||
for device_id in list(self._devices.keys()):
|
||||
self._stop_device_health_check(device_id)
|
||||
logger.info("Stopped health monitoring for all devices")
|
||||
|
||||
def _start_device_health_check(self, device_id: str):
|
||||
state = self._devices.get(device_id)
|
||||
if not state:
|
||||
return
|
||||
if state.health_task and not state.health_task.done():
|
||||
return
|
||||
state.health_task = asyncio.create_task(self._health_check_loop(device_id))
|
||||
|
||||
def _stop_device_health_check(self, device_id: str):
|
||||
state = self._devices.get(device_id)
|
||||
if not state or not state.health_task:
|
||||
return
|
||||
state.health_task.cancel()
|
||||
state.health_task = None
|
||||
|
||||
def _device_is_processing(self, device_id: str) -> bool:
|
||||
"""Check if any target is actively streaming to this device."""
|
||||
return any(
|
||||
isinstance(p, WledTargetProcessor) and p.is_running
|
||||
for p in self._processors.values()
|
||||
if isinstance(p, WledTargetProcessor) and p.device_id == device_id
|
||||
)
|
||||
|
||||
async def _health_check_loop(self, device_id: str):
|
||||
"""Background loop that periodically checks a device."""
|
||||
state = self._devices.get(device_id)
|
||||
if not state:
|
||||
return
|
||||
|
||||
check_interval = DEFAULT_STATE_CHECK_INTERVAL
|
||||
|
||||
try:
|
||||
while self._health_monitoring_active:
|
||||
if not self._device_is_processing(device_id):
|
||||
await self._check_device_health(device_id)
|
||||
else:
|
||||
if state.health:
|
||||
state.health.online = True
|
||||
await asyncio.sleep(check_interval)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
|
||||
|
||||
async def _check_device_health(self, device_id: str):
|
||||
"""Check device health. Also auto-syncs LED count if changed."""
|
||||
state = self._devices.get(device_id)
|
||||
if not state:
|
||||
return
|
||||
client = await self._get_http_client()
|
||||
state.health = await check_device_health(
|
||||
state.device_type, state.device_url, client, state.health,
|
||||
)
|
||||
|
||||
# Auto-sync LED count
|
||||
reported = state.health.device_led_count
|
||||
if reported and reported != state.led_count and self._device_store:
|
||||
old_count = state.led_count
|
||||
logger.info(
|
||||
f"Device {device_id} LED count changed: {old_count} → {reported}, "
|
||||
f"updating calibration"
|
||||
)
|
||||
try:
|
||||
device = self._device_store.update_device(device_id, led_count=reported)
|
||||
state.led_count = reported
|
||||
state.calibration = device.calibration
|
||||
# Propagate to WLED processors using this device
|
||||
for proc in self._processors.values():
|
||||
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id:
|
||||
proc.update_calibration(device.calibration)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync LED count for {device_id}: {e}")
|
||||
|
||||
# ===== HELPERS =====
|
||||
|
||||
def _get_processor(self, target_id: str) -> TargetProcessor:
|
||||
"""Look up a processor by target_id, raising ValueError if not found."""
|
||||
proc = self._processors.get(target_id)
|
||||
if proc is None:
|
||||
raise ValueError(f"Target {target_id} not found")
|
||||
return proc
|
||||
204
server/src/wled_controller/core/processing/target_processor.py
Normal file
204
server/src/wled_controller/core/processing/target_processor.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Abstract base class for target processors.
|
||||
|
||||
A TargetProcessor encapsulates the processing loop and state for a single
|
||||
picture target. Concrete subclasses (WledTargetProcessor, KCTargetProcessor)
|
||||
implement the target-specific capture→process→output pipeline.
|
||||
|
||||
ProcessorManager creates and owns TargetProcessor instances, delegating
|
||||
all target-specific operations through the uniform interface defined here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
from wled_controller.core.capture.calibration import CalibrationConfig
|
||||
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
|
||||
from wled_controller.core.capture.screen_overlay import OverlayManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ProcessingMetrics:
|
||||
"""Metrics for processing performance."""
|
||||
|
||||
frames_processed: int = 0
|
||||
frames_skipped: int = 0
|
||||
frames_keepalive: int = 0
|
||||
errors_count: int = 0
|
||||
last_error: Optional[str] = None
|
||||
last_update: Optional[datetime] = None
|
||||
start_time: Optional[datetime] = None
|
||||
fps_actual: float = 0.0
|
||||
fps_potential: float = 0.0
|
||||
fps_current: int = 0
|
||||
# Per-stage timing (ms), averaged over last 10 frames
|
||||
# LED targets
|
||||
timing_extract_ms: float = 0.0
|
||||
timing_map_leds_ms: float = 0.0
|
||||
timing_smooth_ms: float = 0.0
|
||||
timing_send_ms: float = 0.0
|
||||
timing_total_ms: float = 0.0
|
||||
# KC targets
|
||||
timing_calc_colors_ms: float = 0.0
|
||||
timing_broadcast_ms: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Read-only snapshot of device state, passed to target processors."""
|
||||
|
||||
device_id: str
|
||||
device_url: str
|
||||
led_count: int
|
||||
calibration: "CalibrationConfig"
|
||||
device_type: str = "wled"
|
||||
baud_rate: Optional[int] = None
|
||||
software_brightness: int = 255
|
||||
test_mode_active: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class TargetContext:
|
||||
"""Shared infrastructure bag passed to every TargetProcessor.
|
||||
|
||||
Avoids circular imports — processors never import ProcessorManager.
|
||||
"""
|
||||
|
||||
live_stream_manager: "LiveStreamManager"
|
||||
overlay_manager: "OverlayManager"
|
||||
picture_source_store: Optional["PictureSourceStore"] = None
|
||||
capture_template_store: Optional["TemplateStore"] = None
|
||||
pp_template_store: Optional["PostprocessingTemplateStore"] = None
|
||||
pattern_template_store: Optional["PatternTemplateStore"] = None
|
||||
device_store: Optional["DeviceStore"] = None
|
||||
fire_event: Callable[[dict], None] = lambda e: None
|
||||
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abstract base class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TargetProcessor(ABC):
|
||||
"""Abstract base class for target processors.
|
||||
|
||||
Lifecycle: register → start → (running loop) → stop → unregister
|
||||
"""
|
||||
|
||||
def __init__(self, target_id: str, picture_source_id: str, ctx: TargetContext):
|
||||
self._target_id = target_id
|
||||
self._picture_source_id = picture_source_id
|
||||
self._ctx = ctx
|
||||
self._is_running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._metrics = ProcessingMetrics()
|
||||
|
||||
# ----- Properties -----
|
||||
|
||||
@property
|
||||
def target_id(self) -> str:
|
||||
return self._target_id
|
||||
|
||||
@property
|
||||
def picture_source_id(self) -> str:
|
||||
return self._picture_source_id
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._is_running
|
||||
|
||||
@property
|
||||
def metrics(self) -> ProcessingMetrics:
|
||||
return self._metrics
|
||||
|
||||
# ----- Lifecycle (concrete helpers + abstract hook) -----
|
||||
|
||||
@abstractmethod
|
||||
async def start(self) -> None:
|
||||
"""Start the processing loop.
|
||||
|
||||
Implementations should acquire resources, reset metrics,
|
||||
create the asyncio task, and set _is_running = True.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def stop(self) -> None:
|
||||
"""Stop the processing loop and release resources.
|
||||
|
||||
Implementations should set _is_running = False, cancel the task,
|
||||
release the live stream, and close any connections.
|
||||
"""
|
||||
...
|
||||
|
||||
# ----- Settings -----
|
||||
|
||||
@abstractmethod
|
||||
def update_settings(self, settings) -> None:
|
||||
"""Update processing settings (type depends on subclass)."""
|
||||
...
|
||||
|
||||
def update_source(self, picture_source_id: str) -> None:
|
||||
"""Update the picture source ID."""
|
||||
self._picture_source_id = picture_source_id
|
||||
|
||||
# ----- State / Metrics reporting -----
|
||||
|
||||
@abstractmethod
|
||||
def get_state(self) -> dict:
|
||||
"""Return current processing state as a JSON-serializable dict."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_metrics(self) -> dict:
|
||||
"""Return processing metrics as a JSON-serializable dict."""
|
||||
...
|
||||
|
||||
# ----- Optional capabilities (default no-ops) -----
|
||||
|
||||
def supports_overlay(self) -> bool:
|
||||
"""Whether this target supports screen overlay visualization."""
|
||||
return False
|
||||
|
||||
async def start_overlay(self, target_name: Optional[str] = None) -> None:
|
||||
"""Start overlay visualization (if supported)."""
|
||||
raise NotImplementedError(f"{type(self).__name__} does not support overlays")
|
||||
|
||||
async def stop_overlay(self) -> None:
|
||||
"""Stop overlay visualization (if supported)."""
|
||||
raise NotImplementedError(f"{type(self).__name__} does not support overlays")
|
||||
|
||||
def is_overlay_active(self) -> bool:
|
||||
"""Check if overlay is currently active."""
|
||||
return False
|
||||
|
||||
def supports_websocket(self) -> bool:
|
||||
"""Whether this target supports WebSocket color streaming."""
|
||||
return False
|
||||
|
||||
def add_ws_client(self, ws) -> None:
|
||||
"""Add a WebSocket client for live color updates."""
|
||||
raise NotImplementedError(f"{type(self).__name__} does not support WebSockets")
|
||||
|
||||
def remove_ws_client(self, ws) -> None:
|
||||
"""Remove a WebSocket client."""
|
||||
raise NotImplementedError(f"{type(self).__name__} does not support WebSockets")
|
||||
|
||||
def get_latest_colors(self) -> Dict[str, Tuple[int, int, int]]:
|
||||
"""Get latest extracted colors (KC targets only)."""
|
||||
return {}
|
||||
@@ -0,0 +1,543 @@
|
||||
"""WLED/LED target processor — captures screen, maps to LEDs, sends via DDP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.capture.calibration import CalibrationConfig, PixelMapper
|
||||
from wled_controller.core.devices.led_client import LEDClient, create_led_client
|
||||
from wled_controller.core.processing.live_stream import LiveStream
|
||||
from wled_controller.core.processing.processing_settings import ProcessingSettings
|
||||
from wled_controller.core.capture.screen_capture import (
|
||||
extract_border_pixels,
|
||||
get_available_displays,
|
||||
)
|
||||
from wled_controller.core.processing.target_processor import (
|
||||
DeviceInfo,
|
||||
ProcessingMetrics,
|
||||
TargetContext,
|
||||
TargetProcessor,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wled_controller.core.capture_engines.base import ScreenCapture
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CPU-bound frame processing (runs in thread pool via asyncio.to_thread)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothing):
|
||||
"""All CPU-bound work for one WLED frame.
|
||||
|
||||
Returns (led_colors, timing_ms) where led_colors is numpy array (N, 3) uint8
|
||||
and timing_ms is a dict with per-stage timing in milliseconds.
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
border_pixels = extract_border_pixels(capture, border_width)
|
||||
t1 = time.perf_counter()
|
||||
led_colors = pixel_mapper.map_border_to_leds(border_pixels)
|
||||
t2 = time.perf_counter()
|
||||
|
||||
# Inline numpy smoothing — avoids list↔numpy round-trip
|
||||
if previous_colors is not None and smoothing > 0 and len(previous_colors) == len(led_colors):
|
||||
alpha = int(smoothing * 256)
|
||||
led_colors = (
|
||||
(256 - alpha) * led_colors.astype(np.uint16)
|
||||
+ alpha * previous_colors.astype(np.uint16)
|
||||
) >> 8
|
||||
led_colors = led_colors.astype(np.uint8)
|
||||
t3 = time.perf_counter()
|
||||
|
||||
timing_ms = {
|
||||
"extract": (t1 - t0) * 1000,
|
||||
"map_leds": (t2 - t1) * 1000,
|
||||
"smooth": (t3 - t2) * 1000,
|
||||
"total": (t3 - t0) * 1000,
|
||||
}
|
||||
return led_colors, timing_ms
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WledTargetProcessor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WledTargetProcessor(TargetProcessor):
|
||||
"""Processes screen capture frames and streams LED colors to a WLED/LED device."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target_id: str,
|
||||
device_id: str,
|
||||
settings: ProcessingSettings,
|
||||
picture_source_id: str,
|
||||
ctx: TargetContext,
|
||||
):
|
||||
super().__init__(target_id, picture_source_id, ctx)
|
||||
self._device_id = device_id
|
||||
self._settings = settings
|
||||
|
||||
# Runtime state (populated on start)
|
||||
self._led_client: Optional[LEDClient] = None
|
||||
self._pixel_mapper: Optional[PixelMapper] = None
|
||||
self._live_stream: Optional[LiveStream] = None
|
||||
self._previous_colors: Optional[np.ndarray] = None
|
||||
self._device_state_before: Optional[dict] = None
|
||||
self._overlay_active = False
|
||||
|
||||
# Resolved stream metadata
|
||||
self._resolved_display_index: Optional[int] = None
|
||||
self._resolved_target_fps: Optional[int] = None
|
||||
self._resolved_engine_type: Optional[str] = None
|
||||
self._resolved_engine_config: Optional[dict] = None
|
||||
|
||||
# ----- Properties -----
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
return self._device_id
|
||||
|
||||
@property
|
||||
def settings(self) -> ProcessingSettings:
|
||||
return self._settings
|
||||
|
||||
@property
|
||||
def led_client(self) -> Optional[LEDClient]:
|
||||
return self._led_client
|
||||
|
||||
# ----- Lifecycle -----
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._is_running:
|
||||
logger.debug(f"Processing already running for target {self._target_id}")
|
||||
return
|
||||
|
||||
device_info = self._ctx.get_device_info(self._device_id)
|
||||
if device_info is None:
|
||||
raise ValueError(f"Device {self._device_id} not registered")
|
||||
|
||||
# Resolve stream settings
|
||||
self._resolve_stream_settings()
|
||||
|
||||
# Connect to LED device
|
||||
try:
|
||||
self._led_client = create_led_client(
|
||||
device_info.device_type, device_info.device_url,
|
||||
use_ddp=True, led_count=device_info.led_count,
|
||||
baud_rate=device_info.baud_rate,
|
||||
)
|
||||
await self._led_client.connect()
|
||||
logger.info(
|
||||
f"Target {self._target_id} connected to {device_info.device_type} "
|
||||
f"device ({device_info.led_count} LEDs)"
|
||||
)
|
||||
|
||||
# Snapshot device state before streaming
|
||||
self._device_state_before = await self._led_client.snapshot_device_state()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
|
||||
raise RuntimeError(f"Failed to connect to LED device: {e}")
|
||||
|
||||
# Acquire live stream
|
||||
try:
|
||||
live_stream = await asyncio.to_thread(
|
||||
self._ctx.live_stream_manager.acquire, self._picture_source_id
|
||||
)
|
||||
self._live_stream = live_stream
|
||||
if live_stream.display_index is not None:
|
||||
self._resolved_display_index = live_stream.display_index
|
||||
self._resolved_target_fps = live_stream.target_fps
|
||||
logger.info(
|
||||
f"Acquired live stream for target {self._target_id} "
|
||||
f"(picture_source={self._picture_source_id})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize live stream for target {self._target_id}: {e}")
|
||||
if self._led_client:
|
||||
await self._led_client.close()
|
||||
raise RuntimeError(f"Failed to initialize live stream: {e}")
|
||||
|
||||
# Initialize pixel mapper from current device calibration
|
||||
calibration = device_info.calibration
|
||||
self._pixel_mapper = PixelMapper(
|
||||
calibration,
|
||||
interpolation_mode=self._settings.interpolation_mode,
|
||||
)
|
||||
|
||||
# Reset metrics
|
||||
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
|
||||
self._previous_colors = None
|
||||
|
||||
# Start processing task
|
||||
self._task = asyncio.create_task(self._processing_loop())
|
||||
self._is_running = True
|
||||
|
||||
logger.info(f"Started processing for target {self._target_id}")
|
||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True})
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._is_running:
|
||||
logger.warning(f"Processing not running for target {self._target_id}")
|
||||
return
|
||||
|
||||
self._is_running = False
|
||||
|
||||
# Cancel task
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
# Restore device state
|
||||
if self._led_client and self._device_state_before:
|
||||
await self._led_client.restore_device_state(self._device_state_before)
|
||||
self._device_state_before = None
|
||||
|
||||
# Close LED connection
|
||||
if self._led_client:
|
||||
await self._led_client.close()
|
||||
self._led_client = None
|
||||
|
||||
# Release live stream
|
||||
if self._live_stream:
|
||||
try:
|
||||
self._ctx.live_stream_manager.release(self._picture_source_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error releasing live stream: {e}")
|
||||
self._live_stream = None
|
||||
|
||||
logger.info(f"Stopped processing for target {self._target_id}")
|
||||
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
|
||||
|
||||
# ----- Settings -----
|
||||
|
||||
def update_settings(self, settings: ProcessingSettings) -> None:
|
||||
self._settings = settings
|
||||
# Recreate pixel mapper if interpolation mode changed
|
||||
if self._pixel_mapper:
|
||||
device_info = self._ctx.get_device_info(self._device_id)
|
||||
if device_info:
|
||||
self._pixel_mapper = PixelMapper(
|
||||
device_info.calibration,
|
||||
interpolation_mode=settings.interpolation_mode,
|
||||
)
|
||||
logger.info(f"Updated settings for target {self._target_id}")
|
||||
|
||||
def update_device(self, device_id: str) -> None:
|
||||
"""Update the device this target streams to."""
|
||||
self._device_id = device_id
|
||||
|
||||
def update_calibration(self, calibration: CalibrationConfig) -> None:
|
||||
"""Update the cached calibration + rebuild pixel mapper."""
|
||||
if self._pixel_mapper:
|
||||
self._pixel_mapper = PixelMapper(
|
||||
calibration,
|
||||
interpolation_mode=self._settings.interpolation_mode,
|
||||
)
|
||||
|
||||
# ----- State / Metrics -----
|
||||
|
||||
def get_state(self) -> dict:
|
||||
metrics = self._metrics
|
||||
device_info = self._ctx.get_device_info(self._device_id)
|
||||
|
||||
# Include device health info
|
||||
health_info = {}
|
||||
if device_info:
|
||||
# Get full health from the device state (delegate via manager callback)
|
||||
from wled_controller.core.devices.led_client import DeviceHealth
|
||||
# We access health through the manager's get_device_health_dict
|
||||
# For now, return empty — will be populated by manager wrapper
|
||||
pass
|
||||
|
||||
return {
|
||||
"target_id": self._target_id,
|
||||
"device_id": self._device_id,
|
||||
"processing": self._is_running,
|
||||
"fps_actual": metrics.fps_actual if self._is_running else None,
|
||||
"fps_potential": metrics.fps_potential if self._is_running else None,
|
||||
"fps_target": self._settings.fps,
|
||||
"frames_skipped": metrics.frames_skipped if self._is_running else None,
|
||||
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
|
||||
"fps_current": metrics.fps_current if self._is_running else None,
|
||||
"timing_extract_ms": round(metrics.timing_extract_ms, 1) if self._is_running else None,
|
||||
"timing_map_leds_ms": round(metrics.timing_map_leds_ms, 1) if self._is_running else None,
|
||||
"timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if self._is_running else None,
|
||||
"timing_send_ms": round(metrics.timing_send_ms, 1) if self._is_running else None,
|
||||
"timing_total_ms": round(metrics.timing_total_ms, 1) if self._is_running else None,
|
||||
"display_index": self._resolved_display_index if self._resolved_display_index is not None else self._settings.display_index,
|
||||
"overlay_active": self._overlay_active,
|
||||
"last_update": metrics.last_update,
|
||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||
}
|
||||
|
||||
def get_metrics(self) -> dict:
|
||||
metrics = self._metrics
|
||||
uptime_seconds = 0.0
|
||||
if metrics.start_time and self._is_running:
|
||||
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
|
||||
|
||||
return {
|
||||
"target_id": self._target_id,
|
||||
"device_id": self._device_id,
|
||||
"processing": self._is_running,
|
||||
"fps_actual": metrics.fps_actual if self._is_running else None,
|
||||
"fps_target": self._settings.fps,
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"frames_processed": metrics.frames_processed,
|
||||
"errors_count": metrics.errors_count,
|
||||
"last_error": metrics.last_error,
|
||||
"last_update": metrics.last_update,
|
||||
}
|
||||
|
||||
# ----- Overlay -----
|
||||
|
||||
def supports_overlay(self) -> bool:
|
||||
return True
|
||||
|
||||
async def start_overlay(self, target_name: Optional[str] = None) -> None:
|
||||
if self._overlay_active:
|
||||
raise RuntimeError(f"Overlay already active for {self._target_id}")
|
||||
|
||||
device_info = self._ctx.get_device_info(self._device_id)
|
||||
if device_info is None:
|
||||
raise ValueError(f"Device {self._device_id} not found")
|
||||
|
||||
display_index = self._resolved_display_index or self._settings.display_index
|
||||
displays = get_available_displays()
|
||||
if display_index >= len(displays):
|
||||
raise ValueError(f"Invalid display index {display_index}")
|
||||
|
||||
display_info = displays[display_index]
|
||||
|
||||
await asyncio.to_thread(
|
||||
self._ctx.overlay_manager.start_overlay,
|
||||
self._target_id, display_info, device_info.calibration, target_name,
|
||||
)
|
||||
|
||||
self._overlay_active = True
|
||||
logger.info(f"Started overlay for target {self._target_id}")
|
||||
|
||||
async def stop_overlay(self) -> None:
|
||||
if not self._overlay_active:
|
||||
logger.warning(f"Overlay not active for {self._target_id}")
|
||||
return
|
||||
|
||||
await asyncio.to_thread(
|
||||
self._ctx.overlay_manager.stop_overlay,
|
||||
self._target_id,
|
||||
)
|
||||
|
||||
self._overlay_active = False
|
||||
logger.info(f"Stopped overlay for target {self._target_id}")
|
||||
|
||||
def is_overlay_active(self) -> bool:
|
||||
return self._overlay_active
|
||||
|
||||
# ----- Private: stream settings resolution -----
|
||||
|
||||
def _resolve_stream_settings(self) -> None:
|
||||
"""Resolve picture source chain to populate resolved_* metadata fields."""
|
||||
if not self._picture_source_id or not self._ctx.picture_source_store:
|
||||
raise ValueError(f"Target {self._target_id} has no picture source assigned")
|
||||
|
||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
|
||||
|
||||
chain = self._ctx.picture_source_store.resolve_stream_chain(self._picture_source_id)
|
||||
raw_stream = chain["raw_stream"]
|
||||
|
||||
if isinstance(raw_stream, StaticImagePictureSource):
|
||||
self._resolved_display_index = -1
|
||||
self._resolved_target_fps = 1
|
||||
self._resolved_engine_type = None
|
||||
self._resolved_engine_config = None
|
||||
elif isinstance(raw_stream, ScreenCapturePictureSource):
|
||||
self._resolved_display_index = raw_stream.display_index
|
||||
self._resolved_target_fps = raw_stream.target_fps
|
||||
|
||||
if raw_stream.capture_template_id and self._ctx.capture_template_store:
|
||||
try:
|
||||
tpl = self._ctx.capture_template_store.get_template(raw_stream.capture_template_id)
|
||||
self._resolved_engine_type = tpl.engine_type
|
||||
self._resolved_engine_config = tpl.engine_config
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Capture template {raw_stream.capture_template_id} not found, "
|
||||
f"using MSS fallback"
|
||||
)
|
||||
self._resolved_engine_type = "mss"
|
||||
self._resolved_engine_config = {}
|
||||
|
||||
logger.info(
|
||||
f"Resolved stream metadata for target {self._target_id}: "
|
||||
f"display={self._resolved_display_index}, fps={self._resolved_target_fps}, "
|
||||
f"engine={self._resolved_engine_type}"
|
||||
)
|
||||
|
||||
# ----- Private: processing loop -----
|
||||
|
||||
async def _processing_loop(self) -> None:
|
||||
"""Main processing loop — capture → extract → map → smooth → send."""
|
||||
settings = self._settings
|
||||
device_info = self._ctx.get_device_info(self._device_id)
|
||||
|
||||
target_fps = settings.fps
|
||||
smoothing = settings.smoothing
|
||||
border_width = device_info.calibration.border_width if device_info else 10
|
||||
led_brightness = settings.brightness
|
||||
|
||||
logger.info(
|
||||
f"Processing loop started for target {self._target_id} "
|
||||
f"(display={self._resolved_display_index}, fps={target_fps})"
|
||||
)
|
||||
|
||||
frame_time = 1.0 / target_fps
|
||||
standby_interval = settings.standby_interval
|
||||
fps_samples = []
|
||||
timing_samples: collections.deque = collections.deque(maxlen=10)
|
||||
prev_frame_time_stamp = time.time()
|
||||
prev_capture = None
|
||||
last_send_time = 0.0
|
||||
send_timestamps: collections.deque = collections.deque()
|
||||
|
||||
try:
|
||||
while self._is_running:
|
||||
loop_start = time.time()
|
||||
|
||||
# Re-fetch device info for runtime changes (test mode, brightness)
|
||||
device_info = self._ctx.get_device_info(self._device_id)
|
||||
|
||||
# Skip capture/send while in calibration test mode
|
||||
if device_info and device_info.test_mode_active:
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
|
||||
try:
|
||||
capture = self._live_stream.get_latest_frame()
|
||||
|
||||
if capture is None:
|
||||
if self._metrics.frames_processed == 0:
|
||||
logger.info(f"Capture returned None for target {self._target_id} (no new frame yet)")
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
|
||||
# Skip processing + send if the frame hasn't changed
|
||||
if capture is prev_capture:
|
||||
if self._previous_colors is not None and (loop_start - last_send_time) >= standby_interval:
|
||||
if not self._is_running or self._led_client is None:
|
||||
break
|
||||
brightness_value = int(led_brightness * 255)
|
||||
if device_info and device_info.software_brightness < 255:
|
||||
brightness_value = brightness_value * device_info.software_brightness // 255
|
||||
if self._led_client.supports_fast_send:
|
||||
self._led_client.send_pixels_fast(self._previous_colors, brightness=brightness_value)
|
||||
else:
|
||||
await self._led_client.send_pixels(self._previous_colors, brightness=brightness_value)
|
||||
last_send_time = time.time()
|
||||
send_timestamps.append(last_send_time)
|
||||
self._metrics.frames_keepalive += 1
|
||||
self._metrics.frames_skipped += 1
|
||||
now_ts = time.time()
|
||||
while send_timestamps and send_timestamps[0] < now_ts - 1.0:
|
||||
send_timestamps.popleft()
|
||||
self._metrics.fps_current = len(send_timestamps)
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
prev_capture = capture
|
||||
|
||||
# CPU-bound work in thread pool
|
||||
led_colors, frame_timing = await asyncio.to_thread(
|
||||
_process_frame,
|
||||
capture, border_width,
|
||||
self._pixel_mapper, self._previous_colors, smoothing,
|
||||
)
|
||||
|
||||
# Send to LED device with brightness
|
||||
if not self._is_running or self._led_client is None:
|
||||
break
|
||||
brightness_value = int(led_brightness * 255)
|
||||
if device_info and device_info.software_brightness < 255:
|
||||
brightness_value = brightness_value * device_info.software_brightness // 255
|
||||
t_send_start = time.perf_counter()
|
||||
if self._led_client.supports_fast_send:
|
||||
self._led_client.send_pixels_fast(led_colors, brightness=brightness_value)
|
||||
else:
|
||||
await self._led_client.send_pixels(led_colors, brightness=brightness_value)
|
||||
send_ms = (time.perf_counter() - t_send_start) * 1000
|
||||
last_send_time = time.time()
|
||||
send_timestamps.append(last_send_time)
|
||||
|
||||
# Per-stage timing (rolling average over last 10 frames)
|
||||
frame_timing["send"] = send_ms
|
||||
timing_samples.append(frame_timing)
|
||||
n = len(timing_samples)
|
||||
self._metrics.timing_extract_ms = sum(s["extract"] for s in timing_samples) / n
|
||||
self._metrics.timing_map_leds_ms = sum(s["map_leds"] for s in timing_samples) / n
|
||||
self._metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n
|
||||
self._metrics.timing_send_ms = sum(s["send"] for s in timing_samples) / n
|
||||
self._metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + send_ms
|
||||
|
||||
# Update metrics
|
||||
self._metrics.frames_processed += 1
|
||||
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
|
||||
logger.info(
|
||||
f"Frame {self._metrics.frames_processed} for {self._target_id} "
|
||||
f"({len(led_colors)} LEDs, bri={brightness_value}) — "
|
||||
f"extract={frame_timing['extract']:.1f}ms "
|
||||
f"map={frame_timing['map_leds']:.1f}ms "
|
||||
f"smooth={frame_timing['smooth']:.1f}ms "
|
||||
f"send={send_ms:.1f}ms"
|
||||
)
|
||||
self._metrics.last_update = datetime.utcnow()
|
||||
self._previous_colors = led_colors
|
||||
|
||||
# Calculate actual FPS
|
||||
now = time.time()
|
||||
interval = now - prev_frame_time_stamp
|
||||
prev_frame_time_stamp = now
|
||||
fps_samples.append(1.0 / interval if interval > 0 else 0)
|
||||
if len(fps_samples) > 10:
|
||||
fps_samples.pop(0)
|
||||
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
|
||||
|
||||
# Potential FPS
|
||||
processing_time = now - loop_start
|
||||
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
|
||||
|
||||
# fps_current: count sends in last 1 second
|
||||
while send_timestamps and send_timestamps[0] < now - 1.0:
|
||||
send_timestamps.popleft()
|
||||
self._metrics.fps_current = len(send_timestamps)
|
||||
|
||||
except Exception as e:
|
||||
self._metrics.errors_count += 1
|
||||
self._metrics.last_error = str(e)
|
||||
logger.error(f"Processing error for target {self._target_id}: {e}", exc_info=True)
|
||||
|
||||
# Throttle to target FPS
|
||||
elapsed = time.time() - loop_start
|
||||
remaining = frame_time - elapsed
|
||||
if remaining > 0:
|
||||
await asyncio.sleep(remaining)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"Processing loop cancelled for target {self._target_id}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error in processing loop for target {self._target_id}: {e}")
|
||||
self._is_running = False
|
||||
raise
|
||||
finally:
|
||||
logger.info(f"Processing loop ended for target {self._target_id}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,8 @@ from wled_controller import __version__
|
||||
from wled_controller.api import router
|
||||
from wled_controller.api.dependencies import init_dependencies
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.core.processing.processing_settings import ProcessingSettings
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
@@ -71,7 +72,7 @@ def _migrate_devices_to_targets():
|
||||
continue
|
||||
|
||||
# Build ProcessingSettings from legacy data
|
||||
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
|
||||
from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL
|
||||
settings = ProcessingSettings(
|
||||
display_index=legacy_settings.get("display_index", 0),
|
||||
fps=legacy_settings.get("fps", 30),
|
||||
@@ -158,6 +159,7 @@ async def lifespan(app: FastAPI):
|
||||
device_type=device.device_type,
|
||||
baud_rate=device.baud_rate,
|
||||
software_brightness=device.software_brightness,
|
||||
auto_shutdown=device.auto_shutdown,
|
||||
)
|
||||
logger.info(f"Registered device: {device.name} ({device.id})")
|
||||
except Exception as e:
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.core.calibration import (
|
||||
from wled_controller.core.capture.calibration import (
|
||||
CalibrationConfig,
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
@@ -34,6 +34,7 @@ class Device:
|
||||
device_type: str = "wled",
|
||||
baud_rate: Optional[int] = None,
|
||||
software_brightness: int = 255,
|
||||
auto_shutdown: bool = False,
|
||||
calibration: Optional[CalibrationConfig] = None,
|
||||
created_at: Optional[datetime] = None,
|
||||
updated_at: Optional[datetime] = None,
|
||||
@@ -46,6 +47,7 @@ class Device:
|
||||
self.device_type = device_type
|
||||
self.baud_rate = baud_rate
|
||||
self.software_brightness = software_brightness
|
||||
self.auto_shutdown = auto_shutdown
|
||||
self.calibration = calibration or create_default_calibration(led_count)
|
||||
self.created_at = created_at or datetime.utcnow()
|
||||
self.updated_at = updated_at or datetime.utcnow()
|
||||
@@ -67,6 +69,8 @@ class Device:
|
||||
d["baud_rate"] = self.baud_rate
|
||||
if self.software_brightness != 255:
|
||||
d["software_brightness"] = self.software_brightness
|
||||
if self.auto_shutdown:
|
||||
d["auto_shutdown"] = True
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -92,6 +96,7 @@ class Device:
|
||||
device_type=data.get("device_type", "wled"),
|
||||
baud_rate=data.get("baud_rate"),
|
||||
software_brightness=data.get("software_brightness", 255),
|
||||
auto_shutdown=data.get("auto_shutdown", False),
|
||||
calibration=calibration,
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||
@@ -178,6 +183,7 @@ class DeviceStore:
|
||||
device_type: str = "wled",
|
||||
baud_rate: Optional[int] = None,
|
||||
calibration: Optional[CalibrationConfig] = None,
|
||||
auto_shutdown: bool = False,
|
||||
) -> Device:
|
||||
"""Create a new device."""
|
||||
device_id = f"device_{uuid.uuid4().hex[:8]}"
|
||||
@@ -190,6 +196,7 @@ class DeviceStore:
|
||||
device_type=device_type,
|
||||
baud_rate=baud_rate,
|
||||
calibration=calibration,
|
||||
auto_shutdown=auto_shutdown,
|
||||
)
|
||||
|
||||
self._devices[device_id] = device
|
||||
@@ -215,6 +222,7 @@ class DeviceStore:
|
||||
enabled: Optional[bool] = None,
|
||||
baud_rate: Optional[int] = None,
|
||||
calibration: Optional[CalibrationConfig] = None,
|
||||
auto_shutdown: Optional[bool] = None,
|
||||
) -> Device:
|
||||
"""Update device."""
|
||||
device = self._devices.get(device_id)
|
||||
@@ -232,6 +240,8 @@ class DeviceStore:
|
||||
device.enabled = enabled
|
||||
if baud_rate is not None:
|
||||
device.baud_rate = baud_rate
|
||||
if auto_shutdown is not None:
|
||||
device.auto_shutdown = auto_shutdown
|
||||
if calibration is not None:
|
||||
if calibration.get_total_leds() != device.led_count:
|
||||
raise ValueError(
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.core.processor_manager import ProcessingSettings
|
||||
from wled_controller.core.processing.processing_settings import ProcessingSettings
|
||||
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 (
|
||||
|
||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from wled_controller.core.processor_manager import ProcessingSettings
|
||||
from wled_controller.core.processing.processing_settings import ProcessingSettings
|
||||
from wled_controller.storage.picture_target import PictureTarget
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class WledPictureTarget(PictureTarget):
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "WledPictureTarget":
|
||||
"""Create from dictionary."""
|
||||
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
|
||||
from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL
|
||||
|
||||
settings_data = data.get("settings", {})
|
||||
settings = ProcessingSettings(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.calibration import (
|
||||
from wled_controller.core.capture.calibration import (
|
||||
CalibrationSegment,
|
||||
CalibrationConfig,
|
||||
PixelMapper,
|
||||
@@ -13,7 +13,7 @@ from wled_controller.core.calibration import (
|
||||
EDGE_ORDER,
|
||||
EDGE_REVERSE,
|
||||
)
|
||||
from wled_controller.core.screen_capture import BorderPixels
|
||||
from wled_controller.core.capture.screen_capture import BorderPixels
|
||||
|
||||
|
||||
def test_calibration_segment():
|
||||
|
||||
@@ -4,8 +4,8 @@ import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from wled_controller.storage.device_store import Device, DeviceStore
|
||||
from wled_controller.core.processor_manager import ProcessingSettings
|
||||
from wled_controller.core.calibration import create_default_calibration
|
||||
from wled_controller.core.processing.processing_settings import ProcessingSettings
|
||||
from wled_controller.core.capture.calibration import create_default_calibration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -5,11 +5,9 @@ import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from wled_controller.core.processor_manager import (
|
||||
ProcessorManager,
|
||||
ProcessingSettings,
|
||||
)
|
||||
from wled_controller.core.calibration import create_default_calibration
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.core.processing.processing_settings import ProcessingSettings
|
||||
from wled_controller.core.capture.calibration import create_default_calibration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -68,7 +66,7 @@ def test_add_device_duplicate(processor_manager):
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
@@ -95,24 +93,85 @@ def test_remove_device_not_found(processor_manager):
|
||||
processor_manager.remove_device("nonexistent")
|
||||
|
||||
|
||||
def test_update_settings(processor_manager):
|
||||
"""Test updating device settings."""
|
||||
def test_add_target(processor_manager):
|
||||
"""Test adding a WLED target."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
processor_manager.add_target(
|
||||
target_id="target_1",
|
||||
device_id="test_device",
|
||||
settings=ProcessingSettings(fps=60, display_index=1),
|
||||
)
|
||||
|
||||
state = processor_manager.get_target_state("target_1")
|
||||
assert state["target_id"] == "target_1"
|
||||
assert state["fps_target"] == 60
|
||||
|
||||
|
||||
def test_add_target_duplicate(processor_manager):
|
||||
"""Test adding duplicate target fails."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
processor_manager.add_target(
|
||||
target_id="target_1",
|
||||
device_id="test_device",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
processor_manager.add_target(
|
||||
target_id="target_1",
|
||||
device_id="test_device",
|
||||
)
|
||||
|
||||
|
||||
def test_remove_target(processor_manager):
|
||||
"""Test removing a target."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
processor_manager.add_target(
|
||||
target_id="target_1",
|
||||
device_id="test_device",
|
||||
)
|
||||
|
||||
processor_manager.remove_target("target_1")
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
processor_manager.get_target_state("target_1")
|
||||
|
||||
|
||||
def test_update_target_settings(processor_manager):
|
||||
"""Test updating target settings."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
processor_manager.add_target(
|
||||
target_id="target_1",
|
||||
device_id="test_device",
|
||||
)
|
||||
|
||||
new_settings = ProcessingSettings(
|
||||
display_index=1,
|
||||
fps=60,
|
||||
border_width=20,
|
||||
)
|
||||
|
||||
processor_manager.update_settings("test_device", new_settings)
|
||||
processor_manager.update_target_settings("target_1", new_settings)
|
||||
|
||||
# Verify settings updated
|
||||
state = processor_manager.get_state("test_device")
|
||||
state = processor_manager.get_target_state("target_1")
|
||||
assert state["fps_target"] == 60
|
||||
|
||||
|
||||
@@ -143,97 +202,80 @@ def test_update_calibration_led_count_mismatch(processor_manager):
|
||||
processor_manager.update_calibration("test_device", wrong_calibration)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_start_processing(processor_manager, wled_url, mock_wled_responses):
|
||||
"""Test starting processing."""
|
||||
respx.get(f"{wled_url}/json/info").mock(
|
||||
return_value=Response(200, json=mock_wled_responses["info"])
|
||||
)
|
||||
respx.post(f"{wled_url}/json/state").mock(
|
||||
return_value=Response(200, json={"success": True})
|
||||
)
|
||||
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url=wled_url,
|
||||
led_count=150,
|
||||
settings=ProcessingSettings(fps=5), # Low FPS for testing
|
||||
)
|
||||
|
||||
await processor_manager.start_processing("test_device")
|
||||
|
||||
assert processor_manager.is_processing("test_device") is True
|
||||
|
||||
# Let it process a few frames
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Stop processing
|
||||
await processor_manager.stop_processing("test_device")
|
||||
|
||||
assert processor_manager.is_processing("test_device") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_processing_already_running(processor_manager):
|
||||
"""Test starting processing when already running fails."""
|
||||
# This test would need mocked WLED responses
|
||||
# Skipping actual connection for simplicity
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_processing_not_running(processor_manager):
|
||||
"""Test stopping processing when not running."""
|
||||
def test_get_target_state(processor_manager):
|
||||
"""Test getting target state."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
# Should not raise error
|
||||
await processor_manager.stop_processing("test_device")
|
||||
|
||||
|
||||
def test_get_state(processor_manager):
|
||||
"""Test getting device state."""
|
||||
processor_manager.add_device(
|
||||
processor_manager.add_target(
|
||||
target_id="target_1",
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
settings=ProcessingSettings(fps=30, display_index=0),
|
||||
)
|
||||
|
||||
state = processor_manager.get_state("test_device")
|
||||
state = processor_manager.get_target_state("target_1")
|
||||
|
||||
assert state["device_id"] == "test_device"
|
||||
assert state["target_id"] == "target_1"
|
||||
assert state["processing"] is False
|
||||
assert state["fps_target"] == 30
|
||||
assert state["display_index"] == 0
|
||||
|
||||
|
||||
def test_get_state_not_found(processor_manager):
|
||||
"""Test getting state for non-existent device."""
|
||||
def test_get_target_state_not_found(processor_manager):
|
||||
"""Test getting state for non-existent target."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
processor_manager.get_state("nonexistent")
|
||||
processor_manager.get_target_state("nonexistent")
|
||||
|
||||
|
||||
def test_get_metrics(processor_manager):
|
||||
"""Test getting device metrics."""
|
||||
def test_get_target_metrics(processor_manager):
|
||||
"""Test getting target metrics."""
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
metrics = processor_manager.get_metrics("test_device")
|
||||
processor_manager.add_target(
|
||||
target_id="target_1",
|
||||
device_id="test_device",
|
||||
)
|
||||
|
||||
assert metrics["device_id"] == "test_device"
|
||||
metrics = processor_manager.get_target_metrics("target_1")
|
||||
|
||||
assert metrics["target_id"] == "target_1"
|
||||
assert metrics["processing"] is False
|
||||
assert metrics["frames_processed"] == 0
|
||||
assert metrics["errors_count"] == 0
|
||||
|
||||
|
||||
def test_is_kc_target(processor_manager):
|
||||
"""Test KC target type detection."""
|
||||
from wled_controller.storage.key_colors_picture_target import KeyColorsSettings
|
||||
|
||||
processor_manager.add_device(
|
||||
device_id="test_device",
|
||||
device_url="http://192.168.1.100",
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
processor_manager.add_target(
|
||||
target_id="wled_target",
|
||||
device_id="test_device",
|
||||
)
|
||||
|
||||
processor_manager.add_kc_target(
|
||||
target_id="kc_target",
|
||||
picture_source_id="src_1",
|
||||
settings=KeyColorsSettings(),
|
||||
)
|
||||
|
||||
assert processor_manager.is_kc_target("kc_target") is True
|
||||
assert processor_manager.is_kc_target("wled_target") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_all(processor_manager):
|
||||
"""Test stopping all processors."""
|
||||
@@ -248,7 +290,16 @@ async def test_stop_all(processor_manager):
|
||||
led_count=150,
|
||||
)
|
||||
|
||||
processor_manager.add_target(
|
||||
target_id="target_1",
|
||||
device_id="test_device1",
|
||||
)
|
||||
processor_manager.add_target(
|
||||
target_id="target_2",
|
||||
device_id="test_device2",
|
||||
)
|
||||
|
||||
await processor_manager.stop_all()
|
||||
|
||||
assert processor_manager.is_processing("test_device1") is False
|
||||
assert processor_manager.is_processing("test_device2") is False
|
||||
assert processor_manager.is_target_processing("target_1") is False
|
||||
assert processor_manager.is_target_processing("target_2") is False
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.screen_capture import (
|
||||
from wled_controller.core.capture.screen_capture import (
|
||||
get_available_displays,
|
||||
capture_display,
|
||||
extract_border_pixels,
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from wled_controller.core.wled_client import WLEDClient, WLEDInfo
|
||||
from wled_controller.core.devices.wled_client import WLEDClient, WLEDInfo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
Reference in New Issue
Block a user