From fc779eef3926e8d47c28d5c95d3c98969a6639d8 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 18 Feb 2026 12:03:29 +0300 Subject: [PATCH] Refactor core/ into logical sub-packages and split filter files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/wled_controller/api/dependencies.py | 2 +- .../src/wled_controller/api/routes/devices.py | 32 +- .../api/routes/picture_sources.py | 2 +- .../api/routes/picture_targets.py | 72 +- .../api/routes/postprocessing.py | 2 +- .../src/wled_controller/api/routes/system.py | 2 +- .../wled_controller/api/routes/templates.py | 2 +- .../wled_controller/api/schemas/devices.py | 3 + .../api/schemas/picture_targets.py | 2 +- server/src/wled_controller/core/__init__.py | 2 +- .../wled_controller/core/capture/__init__.py | 25 + .../core/{ => capture}/calibration.py | 2 +- .../core/{ => capture}/pixel_processor.py | 0 .../core/{ => capture}/screen_capture.py | 0 .../core/{ => capture}/screen_overlay.py | 2 +- .../wled_controller/core/devices/__init__.py | 25 + .../core/{ => devices}/adalight_client.py | 4 +- .../core/{ => devices}/adalight_provider.py | 39 +- .../core/{ => devices}/ddp_client.py | 0 .../core/{ => devices}/led_client.py | 14 +- .../core/{ => devices}/wled_client.py | 4 +- .../core/{ => devices}/wled_provider.py | 10 +- .../wled_controller/core/filters/__init__.py | 11 +- .../wled_controller/core/filters/auto_crop.py | 100 + .../core/filters/brightness.py | 43 + .../wled_controller/core/filters/builtin.py | 459 ----- .../core/filters/color_correction.py | 126 ++ .../core/filters/downscaler.py | 50 + .../src/wled_controller/core/filters/flip.py | 55 + .../src/wled_controller/core/filters/gamma.py | 44 + .../wled_controller/core/filters/pixelate.py | 48 + .../core/filters/saturation.py | 52 + .../core/processing/__init__.py | 23 + .../core/processing/kc_target_processor.py | 419 +++++ .../core/{ => processing}/live_stream.py | 0 .../{ => processing}/live_stream_manager.py | 2 +- .../core/processing/processing_settings.py | 20 + .../core/processing/processor_manager.py | 701 +++++++ .../core/processing/target_processor.py | 204 +++ .../core/processing/wled_target_processor.py | 543 ++++++ .../wled_controller/core/processor_manager.py | 1626 ----------------- server/src/wled_controller/main.py | 6 +- .../wled_controller/storage/device_store.py | 12 +- .../storage/picture_target_store.py | 2 +- .../storage/wled_picture_target.py | 4 +- server/tests/test_calibration.py | 4 +- server/tests/test_device_store.py | 4 +- server/tests/test_processor_manager.py | 199 +- server/tests/test_screen_capture.py | 2 +- server/tests/test_wled_client.py | 2 +- 50 files changed, 2740 insertions(+), 2267 deletions(-) create mode 100644 server/src/wled_controller/core/capture/__init__.py rename server/src/wled_controller/core/{ => capture}/calibration.py (99%) rename server/src/wled_controller/core/{ => capture}/pixel_processor.py (100%) rename server/src/wled_controller/core/{ => capture}/screen_capture.py (100%) rename server/src/wled_controller/core/{ => capture}/screen_overlay.py (99%) create mode 100644 server/src/wled_controller/core/devices/__init__.py rename server/src/wled_controller/core/{ => devices}/adalight_client.py (98%) rename server/src/wled_controller/core/{ => devices}/adalight_provider.py (70%) rename server/src/wled_controller/core/{ => devices}/ddp_client.py (100%) rename server/src/wled_controller/core/{ => devices}/led_client.py (95%) rename server/src/wled_controller/core/{ => devices}/wled_client.py (99%) rename server/src/wled_controller/core/{ => devices}/wled_provider.py (95%) create mode 100644 server/src/wled_controller/core/filters/auto_crop.py create mode 100644 server/src/wled_controller/core/filters/brightness.py delete mode 100644 server/src/wled_controller/core/filters/builtin.py create mode 100644 server/src/wled_controller/core/filters/color_correction.py create mode 100644 server/src/wled_controller/core/filters/downscaler.py create mode 100644 server/src/wled_controller/core/filters/flip.py create mode 100644 server/src/wled_controller/core/filters/gamma.py create mode 100644 server/src/wled_controller/core/filters/pixelate.py create mode 100644 server/src/wled_controller/core/filters/saturation.py create mode 100644 server/src/wled_controller/core/processing/__init__.py create mode 100644 server/src/wled_controller/core/processing/kc_target_processor.py rename server/src/wled_controller/core/{ => processing}/live_stream.py (100%) rename server/src/wled_controller/core/{ => processing}/live_stream_manager.py (99%) create mode 100644 server/src/wled_controller/core/processing/processing_settings.py create mode 100644 server/src/wled_controller/core/processing/processor_manager.py create mode 100644 server/src/wled_controller/core/processing/target_processor.py create mode 100644 server/src/wled_controller/core/processing/wled_target_processor.py delete mode 100644 server/src/wled_controller/core/processor_manager.py diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 3519c48..e0ad4ae 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -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 diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 96f22cc..1e13d93 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -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}") diff --git a/server/src/wled_controller/api/routes/picture_sources.py b/server/src/wled_controller/api/routes/picture_sources.py index ecf64b8..e00050b 100644 --- a/server/src/wled_controller/api/routes/picture_sources.py +++ b/server/src/wled_controller/api/routes/picture_sources.py @@ -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 diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index 2f0a955..a8b5091 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -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: diff --git a/server/src/wled_controller/api/routes/postprocessing.py b/server/src/wled_controller/api/routes/postprocessing.py index e5c79a5..88a7fdf 100644 --- a/server/src/wled_controller/api/routes/postprocessing.py +++ b/server/src/wled_controller/api/routes/postprocessing.py @@ -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 diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index 01a8a3e..1e462a2 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -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__) diff --git a/server/src/wled_controller/api/routes/templates.py b/server/src/wled_controller/api/routes/templates.py index 3bc3eba..f29aea6 100644 --- a/server/src/wled_controller/api/routes/templates.py +++ b/server/src/wled_controller/api/routes/templates.py @@ -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 diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index d136465..de2ae88 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -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") diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index d31da46..842e90b 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -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): diff --git a/server/src/wled_controller/core/__init__.py b/server/src/wled_controller/core/__init__.py index 6172e72..a635c32 100644 --- a/server/src/wled_controller/core/__init__.py +++ b/server/src/wled_controller/core/__init__.py @@ -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, diff --git a/server/src/wled_controller/core/capture/__init__.py b/server/src/wled_controller/core/capture/__init__.py new file mode 100644 index 0000000..8642cbf --- /dev/null +++ b/server/src/wled_controller/core/capture/__init__.py @@ -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", +] diff --git a/server/src/wled_controller/core/calibration.py b/server/src/wled_controller/core/capture/calibration.py similarity index 99% rename from server/src/wled_controller/core/calibration.py rename to server/src/wled_controller/core/capture/calibration.py index aec2cf4..df05a25 100644 --- a/server/src/wled_controller/core/calibration.py +++ b/server/src/wled_controller/core/capture/calibration.py @@ -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, diff --git a/server/src/wled_controller/core/pixel_processor.py b/server/src/wled_controller/core/capture/pixel_processor.py similarity index 100% rename from server/src/wled_controller/core/pixel_processor.py rename to server/src/wled_controller/core/capture/pixel_processor.py diff --git a/server/src/wled_controller/core/screen_capture.py b/server/src/wled_controller/core/capture/screen_capture.py similarity index 100% rename from server/src/wled_controller/core/screen_capture.py rename to server/src/wled_controller/core/capture/screen_capture.py diff --git a/server/src/wled_controller/core/screen_overlay.py b/server/src/wled_controller/core/capture/screen_overlay.py similarity index 99% rename from server/src/wled_controller/core/screen_overlay.py rename to server/src/wled_controller/core/capture/screen_overlay.py index a961bc6..2457ac3 100644 --- a/server/src/wled_controller/core/screen_overlay.py +++ b/server/src/wled_controller/core/capture/screen_overlay.py @@ -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__) diff --git a/server/src/wled_controller/core/devices/__init__.py b/server/src/wled_controller/core/devices/__init__.py new file mode 100644 index 0000000..333abd8 --- /dev/null +++ b/server/src/wled_controller/core/devices/__init__.py @@ -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", +] diff --git a/server/src/wled_controller/core/adalight_client.py b/server/src/wled_controller/core/devices/adalight_client.py similarity index 98% rename from server/src/wled_controller/core/adalight_client.py rename to server/src/wled_controller/core/devices/adalight_client.py index c6e5049..b5d7646 100644 --- a/server/src/wled_controller/core/adalight_client.py +++ b/server/src/wled_controller/core/devices/adalight_client.py @@ -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: diff --git a/server/src/wled_controller/core/adalight_provider.py b/server/src/wled_controller/core/devices/adalight_provider.py similarity index 70% rename from server/src/wled_controller/core/adalight_provider.py rename to server/src/wled_controller/core/devices/adalight_provider.py index 05b72f8..85e269f 100644 --- a/server/src/wled_controller/core/adalight_provider.py +++ b/server/src/wled_controller/core/devices/adalight_provider.py @@ -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() diff --git a/server/src/wled_controller/core/ddp_client.py b/server/src/wled_controller/core/devices/ddp_client.py similarity index 100% rename from server/src/wled_controller/core/ddp_client.py rename to server/src/wled_controller/core/devices/ddp_client.py diff --git a/server/src/wled_controller/core/led_client.py b/server/src/wled_controller/core/devices/led_client.py similarity index 95% rename from server/src/wled_controller/core/led_client.py rename to server/src/wled_controller/core/devices/led_client.py index 154f3ef..ecbe5c6 100644 --- a/server/src/wled_controller/core/led_client.py +++ b/server/src/wled_controller/core/devices/led_client.py @@ -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()) diff --git a/server/src/wled_controller/core/wled_client.py b/server/src/wled_controller/core/devices/wled_client.py similarity index 99% rename from server/src/wled_controller/core/wled_client.py rename to server/src/wled_controller/core/devices/wled_client.py index 761725b..6a3a84e 100644 --- a/server/src/wled_controller/core/wled_client.py +++ b/server/src/wled_controller/core/devices/wled_client.py @@ -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__) diff --git a/server/src/wled_controller/core/wled_provider.py b/server/src/wled_controller/core/devices/wled_provider.py similarity index 95% rename from server/src/wled_controller/core/wled_provider.py rename to server/src/wled_controller/core/devices/wled_provider.py index 9b42338..a403834 100644 --- a/server/src/wled_controller/core/wled_provider.py +++ b/server/src/wled_controller/core/devices/wled_provider.py @@ -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( diff --git a/server/src/wled_controller/core/filters/__init__.py b/server/src/wled_controller/core/filters/__init__.py index 590f5a3..1d4ccb9 100644 --- a/server/src/wled_controller/core/filters/__init__.py +++ b/server/src/wled_controller/core/filters/__init__.py @@ -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", diff --git a/server/src/wled_controller/core/filters/auto_crop.py b/server/src/wled_controller/core/filters/auto_crop.py new file mode 100644 index 0000000..c1a652d --- /dev/null +++ b/server/src/wled_controller/core/filters/auto_crop.py @@ -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 diff --git a/server/src/wled_controller/core/filters/brightness.py b/server/src/wled_controller/core/filters/brightness.py new file mode 100644 index 0000000..f35adf0 --- /dev/null +++ b/server/src/wled_controller/core/filters/brightness.py @@ -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 diff --git a/server/src/wled_controller/core/filters/builtin.py b/server/src/wled_controller/core/filters/builtin.py deleted file mode 100644 index 42ea755..0000000 --- a/server/src/wled_controller/core/filters/builtin.py +++ /dev/null @@ -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 diff --git a/server/src/wled_controller/core/filters/color_correction.py b/server/src/wled_controller/core/filters/color_correction.py new file mode 100644 index 0000000..bde9fde --- /dev/null +++ b/server/src/wled_controller/core/filters/color_correction.py @@ -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 diff --git a/server/src/wled_controller/core/filters/downscaler.py b/server/src/wled_controller/core/filters/downscaler.py new file mode 100644 index 0000000..b82cded --- /dev/null +++ b/server/src/wled_controller/core/filters/downscaler.py @@ -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 diff --git a/server/src/wled_controller/core/filters/flip.py b/server/src/wled_controller/core/filters/flip.py new file mode 100644 index 0000000..ba6d031 --- /dev/null +++ b/server/src/wled_controller/core/filters/flip.py @@ -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 diff --git a/server/src/wled_controller/core/filters/gamma.py b/server/src/wled_controller/core/filters/gamma.py new file mode 100644 index 0000000..e1ff7f2 --- /dev/null +++ b/server/src/wled_controller/core/filters/gamma.py @@ -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 diff --git a/server/src/wled_controller/core/filters/pixelate.py b/server/src/wled_controller/core/filters/pixelate.py new file mode 100644 index 0000000..3859530 --- /dev/null +++ b/server/src/wled_controller/core/filters/pixelate.py @@ -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 diff --git a/server/src/wled_controller/core/filters/saturation.py b/server/src/wled_controller/core/filters/saturation.py new file mode 100644 index 0000000..aa6593b --- /dev/null +++ b/server/src/wled_controller/core/filters/saturation.py @@ -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 diff --git a/server/src/wled_controller/core/processing/__init__.py b/server/src/wled_controller/core/processing/__init__.py new file mode 100644 index 0000000..8408ca2 --- /dev/null +++ b/server/src/wled_controller/core/processing/__init__.py @@ -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", +] diff --git a/server/src/wled_controller/core/processing/kc_target_processor.py b/server/src/wled_controller/core/processing/kc_target_processor.py new file mode 100644 index 0000000..eb3b5f1 --- /dev/null +++ b/server/src/wled_controller/core/processing/kc_target_processor.py @@ -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) diff --git a/server/src/wled_controller/core/live_stream.py b/server/src/wled_controller/core/processing/live_stream.py similarity index 100% rename from server/src/wled_controller/core/live_stream.py rename to server/src/wled_controller/core/processing/live_stream.py diff --git a/server/src/wled_controller/core/live_stream_manager.py b/server/src/wled_controller/core/processing/live_stream_manager.py similarity index 99% rename from server/src/wled_controller/core/live_stream_manager.py rename to server/src/wled_controller/core/processing/live_stream_manager.py index 7c2f176..79eac39 100644 --- a/server/src/wled_controller/core/live_stream_manager.py +++ b/server/src/wled_controller/core/processing/live_stream_manager.py @@ -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, diff --git a/server/src/wled_controller/core/processing/processing_settings.py b/server/src/wled_controller/core/processing/processing_settings.py new file mode 100644 index 0000000..08a39a4 --- /dev/null +++ b/server/src/wled_controller/core/processing/processing_settings.py @@ -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 diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py new file mode 100644 index 0000000..161ca39 --- /dev/null +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -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 diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py new file mode 100644 index 0000000..de47a2d --- /dev/null +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -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 {} diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py new file mode 100644 index 0000000..ec928b3 --- /dev/null +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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}") diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py deleted file mode 100644 index d51336b..0000000 --- a/server/src/wled_controller/core/processor_manager.py +++ /dev/null @@ -1,1626 +0,0 @@ -"""Processing manager for coordinating screen capture and WLED updates.""" - -import asyncio -import collections -import json -import time -from dataclasses import dataclass, field -from datetime import datetime -from typing import Dict, List, Optional, Tuple - -import cv2 -import httpx -import numpy as np - -from wled_controller.core.calibration import ( - CalibrationConfig, - PixelMapper, - create_default_calibration, -) -from wled_controller.core.capture_engines.base import ScreenCapture -from wled_controller.core.live_stream import LiveStream -from wled_controller.core.live_stream_manager import LiveStreamManager -from wled_controller.core.screen_capture import ( - calculate_average_color, - calculate_dominant_color, - calculate_median_color, - extract_border_pixels, -) -from wled_controller.core.led_client import ( - DeviceHealth, - LEDClient, - check_device_health, - create_led_client, -) -from wled_controller.core.screen_overlay import OverlayManager -from wled_controller.core.screen_capture import get_available_displays -from wled_controller.utils import get_logger - -logger = get_logger(__name__) - -DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks - - -def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothing): - """All CPU-bound work for one WLED frame (runs in thread pool). - - 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 - - -KC_WORK_SIZE = (160, 90) # (width, height) — small enough for fast color calc - - -def _process_kc_frame(capture, rect_names, rect_bounds, calc_fn, prev_colors_arr, smoothing): - """All CPU-bound work for one KC frame (runs in thread pool). - - 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 - -@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 - - -@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 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 - # 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) - - -@dataclass -class TargetState: - """State of a running picture target processor.""" - - target_id: str - device_id: str - device_url: str - led_count: int - settings: ProcessingSettings - calibration: CalibrationConfig - picture_source_id: str = "" - led_client: Optional[LEDClient] = None - pixel_mapper: Optional[PixelMapper] = None - is_running: bool = False - task: Optional[asyncio.Task] = None - metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics) - previous_colors: Optional[list] = None - # Resolved stream values (populated at start_processing time) - resolved_display_index: Optional[int] = None - resolved_target_fps: Optional[int] = None - resolved_engine_type: Optional[str] = None - resolved_engine_config: Optional[dict] = None - # LiveStream: runtime frame source (shared via LiveStreamManager) - live_stream: Optional[LiveStream] = None - # Device state snapshot taken before streaming starts (to restore on stop) - device_state_before: Optional[dict] = None - # Overlay visualization state - overlay_active: bool = False - - -@dataclass -class KeyColorsTargetState: - """State of a running key-colors extractor processor.""" - - target_id: str - picture_source_id: str - settings: "KeyColorsSettings" # forward ref, resolved at runtime - is_running: bool = False - task: Optional[asyncio.Task] = None - metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics) - live_stream: Optional[LiveStream] = None - previous_colors: Optional[Dict[str, Tuple[int, int, int]]] = None - latest_colors: Optional[Dict[str, Tuple[int, int, int]]] = None - ws_clients: list = field(default_factory=list) # List[WebSocket] - resolved_target_fps: Optional[int] = None - - -class ProcessorManager: - """Manages screen processing for multiple WLED devices. - - Devices are registered for health monitoring and calibration. - Targets are registered for processing (streaming sources to devices). - """ - - 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._targets: Dict[str, TargetState] = {} - self._kc_targets: Dict[str, KeyColorsTargetState] = {} - 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") - - # ===== 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, - ): - """Register a device for health monitoring. - - Args: - device_id: Unique device identifier - device_url: Device URL - led_count: Number of LEDs - calibration: Calibration config (creates default if None) - device_type: LED device type (e.g. "wled") - baud_rate: Serial baud rate (for adalight devices) - software_brightness: Software brightness 0-255 (for devices without hardware brightness) - """ - 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, - ) - - self._devices[device_id] = state - - # Start health monitoring for this device - 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. - - Raises: - ValueError: If device not found - RuntimeError: If any target is using this device - """ - if device_id not in self._devices: - raise ValueError(f"Device {device_id} not found") - - # Check if any target is using this device - for target in self._targets.values(): - if target.device_id == device_id: - raise RuntimeError( - f"Cannot remove device {device_id}: target {target.target_id} is using it" - ) - - # Stop health check task - 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 updates cached calibration in any active target for 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 - - # Update any active target's cached calibration + pixel mapper - for ts in self._targets.values(): - if ts.device_id == device_id: - ts.calibration = calibration - if ts.pixel_mapper: - ts.pixel_mapper = PixelMapper( - calibration, - interpolation_mode=ts.settings.interpolation_mode, - ) - - logger.info(f"Updated calibration for device {device_id}") - - async def send_black_frame(self, device_id: str) -> None: - """Send an all-black frame to an Adalight device to blank its LEDs. - - Uses the existing client from a running target if available, - otherwise opens a temporary serial connection. - """ - if device_id not in self._devices: - raise ValueError(f"Device {device_id} not found") - - ds = self._devices[device_id] - black = np.zeros((ds.led_count, 3), dtype=np.uint8) - - # Try to use existing client from a running target - for ts in self._targets.values(): - if ts.device_id == device_id and ts.is_running and ts.led_client: - await ts.led_client.send_pixels(black, brightness=255) - return - - # No running target — open a temporary connection - client = create_led_client( - ds.device_type, ds.device_url, - led_count=ds.led_count, baud_rate=ds.baud_rate, - ) - try: - await client.connect() - await client.send_pixels(black, brightness=255) - finally: - await client.close() - - 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()), - } - - # ===== TARGET MANAGEMENT (processing) ===== - - def add_target( - self, - target_id: str, - device_id: str, - settings: Optional[ProcessingSettings] = None, - picture_source_id: str = "", - ): - """Register a target for processing. - - Args: - target_id: Unique target identifier - device_id: WLED device to stream to - settings: Processing settings - picture_source_id: Picture source ID - """ - if target_id in self._targets: - raise ValueError(f"Target {target_id} already registered") - - if device_id not in self._devices: - raise ValueError(f"Device {device_id} not registered") - - ds = self._devices[device_id] - - state = TargetState( - target_id=target_id, - device_id=device_id, - device_url=ds.device_url, - led_count=ds.led_count, - settings=settings or ProcessingSettings(), - calibration=ds.calibration, - picture_source_id=picture_source_id, - ) - - self._targets[target_id] = state - logger.info(f"Registered target {target_id} for device {device_id}") - - def remove_target(self, target_id: str): - """Unregister a target. - - Raises: - ValueError: If target not found - RuntimeError: If target is currently processing - """ - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - - if self._targets[target_id].is_running: - raise RuntimeError(f"Cannot remove target {target_id} while processing") - - del self._targets[target_id] - logger.info(f"Unregistered target {target_id}") - - def update_target_settings(self, target_id: str, settings: ProcessingSettings): - """Update processing settings for a target.""" - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - - ts = self._targets[target_id] - ts.settings = settings - - # Recreate pixel mapper if interpolation mode changed - if ts.pixel_mapper: - ts.pixel_mapper = PixelMapper( - ts.calibration, - interpolation_mode=settings.interpolation_mode, - ) - - logger.info(f"Updated settings for target {target_id}") - - def update_target_source(self, target_id: str, picture_source_id: str): - """Update the picture source for a target.""" - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - self._targets[target_id].picture_source_id = picture_source_id - - def update_target_device(self, target_id: str, device_id: str): - """Update the device for a target (re-cache device info).""" - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - if device_id not in self._devices: - raise ValueError(f"Device {device_id} not registered") - - ds = self._devices[device_id] - ts = self._targets[target_id] - ts.device_id = device_id - ts.device_url = ds.device_url - ts.led_count = ds.led_count - ts.calibration = ds.calibration - - def _resolve_stream_settings(self, state: TargetState): - """Resolve picture source chain to populate resolved_* metadata fields.""" - if not state.picture_source_id or not self._picture_source_store: - raise ValueError(f"Target {state.target_id} has no picture source assigned") - - from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource - - chain = self._picture_source_store.resolve_stream_chain(state.picture_source_id) - raw_stream = chain["raw_stream"] - pp_template_ids = chain["postprocessing_template_ids"] - - if isinstance(raw_stream, StaticImagePictureSource): - state.resolved_display_index = -1 - state.resolved_target_fps = 1 - state.resolved_engine_type = None - state.resolved_engine_config = None - elif isinstance(raw_stream, ScreenCapturePictureSource): - state.resolved_display_index = raw_stream.display_index - state.resolved_target_fps = raw_stream.target_fps - - if raw_stream.capture_template_id and self._capture_template_store: - try: - tpl = self._capture_template_store.get_template(raw_stream.capture_template_id) - state.resolved_engine_type = tpl.engine_type - state.resolved_engine_config = tpl.engine_config - except ValueError: - logger.warning(f"Capture template {raw_stream.capture_template_id} not found, using MSS fallback") - state.resolved_engine_type = "mss" - state.resolved_engine_config = {} - - logger.info( - f"Resolved stream metadata for target {state.target_id}: " - f"display={state.resolved_display_index}, fps={state.resolved_target_fps}, " - f"engine={state.resolved_engine_type}, pp_templates={len(pp_template_ids)}" - ) - - async def start_processing(self, target_id: str): - """Start screen processing for a target. - - Args: - target_id: Target identifier - - Raises: - ValueError: If target not found or no picture source assigned - RuntimeError: If processing already running or device conflict - """ - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - - state = self._targets[target_id] - - if state.is_running: - logger.debug(f"Processing already running for target {target_id}") - return - - # Enforce one-target-per-device constraint - for other_id, other in self._targets.items(): - if other_id != target_id and other.device_id == state.device_id and other.is_running: - raise RuntimeError( - f"Device {state.device_id} is already being processed by target {other_id}" - ) - - # Refresh cached device info - if state.device_id in self._devices: - ds = self._devices[state.device_id] - state.device_url = ds.device_url - state.led_count = ds.led_count - state.calibration = ds.calibration - - # Resolve stream settings - self._resolve_stream_settings(state) - - # Determine device type and baud rate from device state - device_type = "wled" - baud_rate = None - if state.device_id in self._devices: - device_type = self._devices[state.device_id].device_type - baud_rate = self._devices[state.device_id].baud_rate - - # Connect to LED device via factory - try: - state.led_client = create_led_client(device_type, state.device_url, use_ddp=True, led_count=state.led_count, baud_rate=baud_rate) - await state.led_client.connect() - logger.info(f"Target {target_id} connected to {device_type} device ({state.led_count} LEDs)") - - # Snapshot device state before streaming (type-specific, e.g. WLED saves on/lor/AudioReactive) - state.device_state_before = await state.led_client.snapshot_device_state() - except Exception as e: - logger.error(f"Failed to connect to LED device for target {target_id}: {e}") - raise RuntimeError(f"Failed to connect to LED device: {e}") - - # Acquire live stream via LiveStreamManager (shared across targets) - try: - live_stream = await asyncio.to_thread( - self._live_stream_manager.acquire, state.picture_source_id - ) - state.live_stream = live_stream - if live_stream.display_index is not None: - state.resolved_display_index = live_stream.display_index - state.resolved_target_fps = live_stream.target_fps - logger.info( - f"Acquired live stream for target {target_id} " - f"(picture_source={state.picture_source_id})" - ) - except Exception as e: - logger.error(f"Failed to initialize live stream for target {target_id}: {e}") - if state.led_client: - await state.led_client.close() - raise RuntimeError(f"Failed to initialize live stream: {e}") - - # Initialize pixel mapper - state.pixel_mapper = PixelMapper( - state.calibration, - interpolation_mode=state.settings.interpolation_mode, - ) - - # Reset metrics - state.metrics = ProcessingMetrics(start_time=datetime.utcnow()) - state.previous_colors = None - - # Start processing task - state.task = asyncio.create_task(self._processing_loop(target_id)) - state.is_running = True - - logger.info(f"Started processing for target {target_id}") - self._fire_event({"type": "state_change", "target_id": target_id, "processing": True}) - - async def stop_processing(self, target_id: str): - """Stop screen processing for a target.""" - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - - state = self._targets[target_id] - - if not state.is_running: - logger.warning(f"Processing not running for target {target_id}") - return - - # Stop processing - state.is_running = False - - # Cancel task - if state.task: - state.task.cancel() - try: - await state.task - except asyncio.CancelledError: - pass - state.task = None - - # Restore device state (type-specific, e.g. WLED restores on/lor/AudioReactive) - if state.led_client and state.device_state_before: - await state.led_client.restore_device_state(state.device_state_before) - state.device_state_before = None - - # Close LED connection - if state.led_client: - await state.led_client.close() - state.led_client = None - - # Release live stream - if state.live_stream: - try: - self._live_stream_manager.release(state.picture_source_id) - except Exception as e: - logger.warning(f"Error releasing live stream: {e}") - state.live_stream = None - - logger.info(f"Stopped processing for target {target_id}") - self._fire_event({"type": "state_change", "target_id": target_id, "processing": False}) - - async def _processing_loop(self, target_id: str): - """Main processing loop for a target.""" - state = self._targets[target_id] - settings = state.settings - - target_fps = settings.fps - smoothing = settings.smoothing - border_width = state.calibration.border_width - led_brightness = settings.brightness - - logger.info( - f"Processing loop started for target {target_id} " - f"(display={state.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) # per-stage timing - prev_frame_time_stamp = time.time() - prev_capture = None # Track previous ScreenCapture for change detection - last_send_time = 0.0 # Timestamp of last DDP send (for keepalive) - send_timestamps: collections.deque = collections.deque() # for fps_current - - # Check if the device has test mode active — skip capture while in test mode - device_state = self._devices.get(state.device_id) - - try: - while state.is_running: - loop_start = time.time() - - # Skip capture/send while in calibration test mode - if device_state and device_state.test_mode_active: - await asyncio.sleep(frame_time) - continue - - try: - # get_latest_frame() is a fast lock read (ProcessedLiveStream - # pre-computes in a background thread). Safe on asyncio thread. - capture = state.live_stream.get_latest_frame() - - if capture is None: - if state.metrics.frames_processed == 0: - logger.info(f"Capture returned None for target {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: - # Keepalive: resend last colors to prevent device exiting live mode - if state.previous_colors is not None and (loop_start - last_send_time) >= standby_interval: - if not state.is_running or state.led_client is None: - break - brightness_value = int(led_brightness * 255) - if device_state and device_state.software_brightness < 255: - brightness_value = brightness_value * device_state.software_brightness // 255 - if state.led_client.supports_fast_send: - state.led_client.send_pixels_fast(state.previous_colors, brightness=brightness_value) - else: - await state.led_client.send_pixels(state.previous_colors, brightness=brightness_value) - last_send_time = time.time() - send_timestamps.append(last_send_time) - state.metrics.frames_keepalive += 1 - state.metrics.frames_skipped += 1 - # Update fps_current: count sends in last 1 second - now_ts = time.time() - while send_timestamps and send_timestamps[0] < now_ts - 1.0: - send_timestamps.popleft() - state.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, - state.pixel_mapper, state.previous_colors, smoothing, - ) - - # Send to LED device with brightness - if not state.is_running or state.led_client is None: - break - brightness_value = int(led_brightness * 255) - if device_state and device_state.software_brightness < 255: - brightness_value = brightness_value * device_state.software_brightness // 255 - t_send_start = time.perf_counter() - if state.led_client.supports_fast_send: - state.led_client.send_pixels_fast(led_colors, brightness=brightness_value) - else: - await state.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) - state.metrics.timing_extract_ms = sum(s["extract"] for s in timing_samples) / n - state.metrics.timing_map_leds_ms = sum(s["map_leds"] for s in timing_samples) / n - state.metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n - state.metrics.timing_send_ms = sum(s["send"] for s in timing_samples) / n - state.metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + send_ms - - # Update metrics - state.metrics.frames_processed += 1 - if state.metrics.frames_processed <= 3 or state.metrics.frames_processed % 100 == 0: - logger.info( - f"Frame {state.metrics.frames_processed} for {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" - ) - state.metrics.last_update = datetime.utcnow() - state.previous_colors = led_colors - - # Calculate actual FPS from frame-to-frame interval - 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) - state.metrics.fps_actual = sum(fps_samples) / len(fps_samples) - - # Potential FPS = how fast the pipeline could run without throttle - processing_time = now - loop_start - state.metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0 - - # Update fps_current: count sends in last 1 second - while send_timestamps and send_timestamps[0] < now - 1.0: - send_timestamps.popleft() - state.metrics.fps_current = len(send_timestamps) - - except Exception as e: - state.metrics.errors_count += 1 - state.metrics.last_error = str(e) - logger.error(f"Processing error for target {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 {target_id}") - raise - except Exception as e: - logger.error(f"Fatal error in processing loop for target {target_id}: {e}") - state.is_running = False - raise - finally: - logger.info(f"Processing loop ended for target {target_id}") - - def get_target_state(self, target_id: str) -> dict: - """Get current processing state for a target.""" - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - - state = self._targets[target_id] - metrics = state.metrics - - # Include device health info - health_info = {} - if state.device_id in self._devices: - h = self._devices[state.device_id].health - health_info = { - "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 { - "target_id": target_id, - "device_id": state.device_id, - "processing": state.is_running, - "fps_actual": metrics.fps_actual if state.is_running else None, - "fps_potential": metrics.fps_potential if state.is_running else None, - "fps_target": state.settings.fps, - "frames_skipped": metrics.frames_skipped if state.is_running else None, - "frames_keepalive": metrics.frames_keepalive if state.is_running else None, - "fps_current": metrics.fps_current if state.is_running else None, - "timing_extract_ms": round(metrics.timing_extract_ms, 1) if state.is_running else None, - "timing_map_leds_ms": round(metrics.timing_map_leds_ms, 1) if state.is_running else None, - "timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if state.is_running else None, - "timing_send_ms": round(metrics.timing_send_ms, 1) if state.is_running else None, - "timing_total_ms": round(metrics.timing_total_ms, 1) if state.is_running else None, - "display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index, - "overlay_active": state.overlay_active, - "last_update": metrics.last_update, - "errors": [metrics.last_error] if metrics.last_error else [], - **health_info, - } - - def get_target_metrics(self, target_id: str) -> dict: - """Get detailed metrics for a target.""" - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - - state = self._targets[target_id] - metrics = state.metrics - - uptime_seconds = 0.0 - if metrics.start_time and state.is_running: - uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds() - - return { - "target_id": target_id, - "device_id": state.device_id, - "processing": state.is_running, - "fps_actual": metrics.fps_actual if state.is_running else None, - "fps_target": state.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, - } - - def is_target_processing(self, target_id: str) -> bool: - """Check if target is currently processing.""" - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - return self._targets[target_id].is_running - - def is_device_processing(self, device_id: str) -> bool: - """Check if any target is processing for a device.""" - for ts in self._targets.values(): - if ts.device_id == device_id and ts.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 ts in self._targets.values(): - if ts.device_id == device_id and ts.is_running: - return ts.target_id - return None - - # ===== OVERLAY VISUALIZATION ===== - - async def start_overlay(self, target_id: str, target_name: str = None) -> None: - """Start screen overlay visualization for a target.""" - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - - state = self._targets[target_id] - - if state.overlay_active: - raise RuntimeError(f"Overlay already active for {target_id}") - - # Get device for calibration - if state.device_id not in self._devices: - raise ValueError(f"Device {state.device_id} not found") - - device_state = self._devices[state.device_id] - calibration = device_state.calibration - - # Get display info - display_index = state.resolved_display_index or state.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] - - # Start overlay in background thread - await asyncio.to_thread( - self._overlay_manager.start_overlay, - target_id, display_info, calibration, target_name - ) - - state.overlay_active = True - logger.info(f"Started overlay for target {target_id}") - - async def stop_overlay(self, target_id: str) -> None: - """Stop screen overlay visualization for a target.""" - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - - state = self._targets[target_id] - - if not state.overlay_active: - logger.warning(f"Overlay not active for {target_id}") - return - - await asyncio.to_thread( - self._overlay_manager.stop_overlay, - target_id - ) - - state.overlay_active = False - logger.info(f"Stopped overlay for target {target_id}") - - def is_overlay_active(self, target_id: str) -> bool: - """Check if overlay is active for a target.""" - if target_id not in self._targets: - raise ValueError(f"Target {target_id} not found") - return self._targets[target_id].overlay_active - - # ===== 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: - # Check if a target is running for this device (use its LED client) - active_client = None - for ts in self._targets.values(): - if ts.device_id == device_id and ts.is_running and ts.led_client: - active_client = ts.led_client - break - - 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 = None - for ts in self._targets.values(): - if ts.device_id == device_id and ts.is_running and ts.led_client: - active_client = ts.led_client - break - - if active_client: - await active_client.send_pixels(pixels) - else: - async with 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}") - - # ===== DISPLAY LOCK INFO ===== - - def is_display_locked(self, display_index: int) -> bool: - """Check if a display is currently being captured by any target.""" - for state in self._targets.values(): - if state.is_running and state.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 state in self._targets.values(): - if state.is_running and state.settings.display_index == display_index: - return state.device_id - return None - - # ===== LIFECYCLE ===== - - async def stop_all(self): - """Stop processing and health monitoring for all targets and devices.""" - # Stop health monitoring - await self.stop_health_monitoring() - - # Stop all WLED targets - target_ids = list(self._targets.keys()) - for target_id in target_ids: - if self._targets[target_id].is_running: - try: - await self.stop_processing(target_id) - except Exception as e: - logger.error(f"Error stopping target {target_id}: {e}") - - # Stop all key-colors targets - kc_ids = list(self._kc_targets.keys()) - for target_id in kc_ids: - if self._kc_targets[target_id].is_running: - try: - await self.stop_kc_processing(target_id) - except Exception as e: - logger.error(f"Error stopping KC target {target_id}: {e}") - - # Safety net: release any remaining managed live streams - 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): - """Start health check task for a single device.""" - 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): - """Stop health check task for a single device.""" - 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( - t.is_running for t in self._targets.values() - if t.device_id == device_id - ) - - async def _health_check_loop(self, device_id: str): - """Background loop that periodically checks a WLED device via GET /json/info.""" - state = self._devices.get(device_id) - if not state: - return - - check_interval = DEFAULT_STATE_CHECK_INTERVAL - - try: - while self._health_monitoring_active: - # Skip health check while actively streaming — the device is - # clearly online and the HTTP request causes LED stutters - if not self._device_is_processing(device_id): - await self._check_device_health(device_id) - else: - # Mark as online since we're successfully sending frames - 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 via the LED client abstraction. - - Also auto-syncs LED count if the device reports a different value. - """ - 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 when device reports a different value - 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: - # Update persistent storage (creates new default calibration) - device = self._device_store.update_device(device_id, led_count=reported) - # Sync in-memory state - state.led_count = reported - state.calibration = device.calibration - # Update any active targets using this device - for ts in self._targets.values(): - if ts.device_id == device_id: - ts.led_count = reported - ts.calibration = device.calibration - if ts.pixel_mapper: - ts.pixel_mapper = PixelMapper( - device.calibration, - interpolation_mode=ts.settings.interpolation_mode, - ) - except Exception as e: - logger.error(f"Failed to sync LED count for {device_id}: {e}") - - # ===== KEY COLORS TARGET MANAGEMENT ===== - - def add_kc_target(self, target_id: str, picture_source_id: str, settings) -> None: - """Register a key-colors target for processing.""" - if target_id in self._kc_targets: - raise ValueError(f"KC target {target_id} already registered") - self._kc_targets[target_id] = KeyColorsTargetState( - target_id=target_id, - picture_source_id=picture_source_id, - settings=settings, - ) - logger.info(f"Registered KC target: {target_id}") - - def remove_kc_target(self, target_id: str) -> None: - """Unregister a key-colors target.""" - if target_id not in self._kc_targets: - raise ValueError(f"KC target {target_id} not found") - state = self._kc_targets[target_id] - if state.is_running: - raise ValueError(f"Cannot remove KC target {target_id}: still running") - del self._kc_targets[target_id] - logger.info(f"Removed KC target: {target_id}") - - def update_kc_target_settings(self, target_id: str, settings) -> None: - """Update settings for a key-colors target.""" - if target_id not in self._kc_targets: - raise ValueError(f"KC target {target_id} not found") - self._kc_targets[target_id].settings = settings - logger.info(f"Updated KC target settings: {target_id}") - - def update_kc_target_source(self, target_id: str, picture_source_id: str) -> None: - """Update picture source for a key-colors target.""" - if target_id not in self._kc_targets: - raise ValueError(f"KC target {target_id} not found") - self._kc_targets[target_id].picture_source_id = picture_source_id - logger.info(f"Updated KC target source: {target_id}") - - async def start_kc_processing(self, target_id: str) -> None: - """Start key-colors extraction for a target.""" - if target_id not in self._kc_targets: - raise ValueError(f"KC target {target_id} not found") - - state = self._kc_targets[target_id] - if state.is_running: - logger.debug(f"KC target {target_id} is already running") - return - - if not state.picture_source_id: - raise ValueError(f"KC target {target_id} has no picture source assigned") - - if not state.settings.pattern_template_id: - raise ValueError(f"KC target {target_id} has no pattern template assigned") - - # Resolve pattern template to get rectangles - try: - pattern_template = self._pattern_template_store.get_template(state.settings.pattern_template_id) - except (ValueError, AttributeError): - raise ValueError(f"Pattern template {state.settings.pattern_template_id} not found") - - if not pattern_template.rectangles: - raise ValueError(f"Pattern template {state.settings.pattern_template_id} has no rectangles") - - state._resolved_rectangles = pattern_template.rectangles - - # Acquire live stream - try: - live_stream = await asyncio.to_thread( - self._live_stream_manager.acquire, state.picture_source_id - ) - state.live_stream = live_stream - state.resolved_target_fps = live_stream.target_fps - logger.info( - f"Acquired live stream for KC target {target_id} " - f"(picture_source={state.picture_source_id})" - ) - except Exception as e: - logger.error(f"Failed to initialize live stream for KC target {target_id}: {e}") - raise RuntimeError(f"Failed to initialize live stream: {e}") - - # Reset metrics - state.metrics = ProcessingMetrics(start_time=datetime.utcnow()) - state.previous_colors = None - state.latest_colors = None - - # Start processing task - state.task = asyncio.create_task(self._kc_processing_loop(target_id)) - state.is_running = True - - logger.info(f"Started KC processing for target {target_id}") - self._fire_event({"type": "state_change", "target_id": target_id, "processing": True}) - - async def stop_kc_processing(self, target_id: str) -> None: - """Stop key-colors extraction for a target.""" - if target_id not in self._kc_targets: - raise ValueError(f"KC target {target_id} not found") - - state = self._kc_targets[target_id] - if not state.is_running: - logger.warning(f"KC processing not running for target {target_id}") - return - - state.is_running = False - - # Cancel task - if state.task: - state.task.cancel() - try: - await state.task - except asyncio.CancelledError: - pass - state.task = None - - # Release live stream - if state.live_stream: - try: - self._live_stream_manager.release(state.picture_source_id) - except Exception as e: - logger.warning(f"Error releasing live stream for KC target: {e}") - state.live_stream = None - - logger.info(f"Stopped KC processing for target {target_id}") - self._fire_event({"type": "state_change", "target_id": target_id, "processing": False}) - - async def _kc_processing_loop(self, target_id: str) -> None: - """Main processing loop for a key-colors target.""" - state = self._kc_targets[target_id] - settings = state.settings - - target_fps = 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 # Track previous ScreenCapture for change detection - last_broadcast_time = 0.0 # Timestamp of last WS broadcast (for keepalive) - send_timestamps: collections.deque = collections.deque() # for fps_current - - rectangles = state._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 # (N, 3) float64 for vectorized smoothing - - logger.info( - f"KC processing loop started for target {target_id} " - f"(fps={target_fps}, rects={len(rectangles)})" - ) - - try: - while state.is_running: - loop_start = time.time() - - try: - # get_latest_frame() is a fast lock read — safe on asyncio thread - capture = state.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 so WS clients stay in sync - if state.latest_colors and (loop_start - last_broadcast_time) >= 1.0: - await self._broadcast_kc_colors(target_id, state.latest_colors) - last_broadcast_time = time.time() - send_timestamps.append(last_broadcast_time) - state.metrics.frames_keepalive += 1 - state.metrics.frames_skipped += 1 - # Update fps_current even on skip - now_ts = time.time() - while send_timestamps and send_timestamps[0] < now_ts - 1.0: - send_timestamps.popleft() - state.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 - state.latest_colors = dict(colors) - - # Broadcast to WebSocket clients - t_broadcast_start = time.perf_counter() - await self._broadcast_kc_colors(target_id, 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) - state.metrics.timing_calc_colors_ms = sum(s["calc_colors"] for s in timing_samples) / n - state.metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n - state.metrics.timing_broadcast_ms = sum(s["broadcast"] for s in timing_samples) / n - state.metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + broadcast_ms - - # Update metrics - state.metrics.frames_processed += 1 - state.metrics.last_update = datetime.utcnow() - - # Calculate actual FPS from frame-to-frame interval - 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) - state.metrics.fps_actual = sum(fps_samples) / len(fps_samples) - - # Potential FPS = how fast the pipeline could run without throttle - processing_time = now - loop_start - state.metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0 - - # Update fps_current: count sends in last 1 second - while send_timestamps and send_timestamps[0] < now - 1.0: - send_timestamps.popleft() - state.metrics.fps_current = len(send_timestamps) - - except Exception as e: - state.metrics.errors_count += 1 - state.metrics.last_error = str(e) - logger.error(f"KC processing error for {target_id}: {e}", exc_info=True) - - # 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 {target_id}") - raise - except Exception as e: - logger.error(f"Fatal error in KC processing loop for target {target_id}: {e}") - state.is_running = False - raise - finally: - logger.info(f"KC processing loop ended for target {target_id}") - - async def _broadcast_kc_colors(self, target_id: str, colors: Dict[str, Tuple[int, int, int]]) -> None: - """Broadcast extracted colors to WebSocket clients.""" - state = self._kc_targets.get(target_id) - if not state or not state.ws_clients: - return - - message = json.dumps({ - "type": "colors_update", - "target_id": target_id, - "colors": { - name: {"r": c[0], "g": c[1], "b": c[2]} - for name, c in colors.items() - }, - "timestamp": datetime.utcnow().isoformat(), - }) - - disconnected = [] - for ws in state.ws_clients: - try: - await ws.send_text(message) - except Exception: - disconnected.append(ws) - - for ws in disconnected: - state.ws_clients.remove(ws) - - def add_kc_ws_client(self, target_id: str, ws) -> None: - """Add a WebSocket client for KC color updates.""" - if target_id not in self._kc_targets: - raise ValueError(f"KC target {target_id} not found") - self._kc_targets[target_id].ws_clients.append(ws) - - def remove_kc_ws_client(self, target_id: str, ws) -> None: - """Remove a WebSocket client.""" - state = self._kc_targets.get(target_id) - if state and ws in state.ws_clients: - state.ws_clients.remove(ws) - - def get_kc_target_state(self, target_id: str) -> dict: - """Get current state for a KC target.""" - if target_id not in self._kc_targets: - raise ValueError(f"KC target {target_id} not found") - - state = self._kc_targets[target_id] - metrics = state.metrics - return { - "target_id": target_id, - "processing": state.is_running, - "fps_actual": round(metrics.fps_actual, 1) if state.is_running else None, - "fps_potential": metrics.fps_potential if state.is_running else None, - "fps_target": state.settings.fps, - "frames_skipped": metrics.frames_skipped if state.is_running else None, - "frames_keepalive": metrics.frames_keepalive if state.is_running else None, - "fps_current": metrics.fps_current if state.is_running else None, - "timing_calc_colors_ms": round(metrics.timing_calc_colors_ms, 1) if state.is_running else None, - "timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if state.is_running else None, - "timing_broadcast_ms": round(metrics.timing_broadcast_ms, 1) if state.is_running else None, - "timing_total_ms": round(metrics.timing_total_ms, 1) if state.is_running else None, - "last_update": metrics.last_update, - "errors": [metrics.last_error] if metrics.last_error else [], - } - - def get_kc_target_metrics(self, target_id: str) -> dict: - """Get metrics for a KC target.""" - if target_id not in self._kc_targets: - raise ValueError(f"KC target {target_id} not found") - - state = self._kc_targets[target_id] - uptime = 0.0 - if state.metrics.start_time and state.is_running: - uptime = (datetime.utcnow() - state.metrics.start_time).total_seconds() - - return { - "target_id": target_id, - "processing": state.is_running, - "fps_actual": round(state.metrics.fps_actual, 1), - "fps_target": state.settings.fps, - "uptime_seconds": round(uptime, 1), - "frames_processed": state.metrics.frames_processed, - "errors_count": state.metrics.errors_count, - "last_error": state.metrics.last_error, - "last_update": state.metrics.last_update.isoformat() if state.metrics.last_update else None, - } - - def get_kc_latest_colors(self, target_id: str) -> Dict[str, Tuple[int, int, int]]: - """Get latest extracted colors for a KC target.""" - if target_id not in self._kc_targets: - raise ValueError(f"KC target {target_id} not found") - return self._kc_targets[target_id].latest_colors or {} - - def is_kc_target(self, target_id: str) -> bool: - """Check if a target ID belongs to a KC target.""" - return target_id in self._kc_targets diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index ae1432a..fde4722 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -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: diff --git a/server/src/wled_controller/storage/device_store.py b/server/src/wled_controller/storage/device_store.py index 7fc3a7a..1530707 100644 --- a/server/src/wled_controller/storage/device_store.py +++ b/server/src/wled_controller/storage/device_store.py @@ -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( diff --git a/server/src/wled_controller/storage/picture_target_store.py b/server/src/wled_controller/storage/picture_target_store.py index 3aa5c84..10d8271 100644 --- a/server/src/wled_controller/storage/picture_target_store.py +++ b/server/src/wled_controller/storage/picture_target_store.py @@ -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 ( diff --git a/server/src/wled_controller/storage/wled_picture_target.py b/server/src/wled_controller/storage/wled_picture_target.py index 2826061..2c9dcbb 100644 --- a/server/src/wled_controller/storage/wled_picture_target.py +++ b/server/src/wled_controller/storage/wled_picture_target.py @@ -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( diff --git a/server/tests/test_calibration.py b/server/tests/test_calibration.py index 33ca04a..a1f5c07 100644 --- a/server/tests/test_calibration.py +++ b/server/tests/test_calibration.py @@ -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(): diff --git a/server/tests/test_device_store.py b/server/tests/test_device_store.py index 0534b69..4a29688 100644 --- a/server/tests/test_device_store.py +++ b/server/tests/test_device_store.py @@ -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 diff --git a/server/tests/test_processor_manager.py b/server/tests/test_processor_manager.py index d2f4f8d..f064b07 100644 --- a/server/tests/test_processor_manager.py +++ b/server/tests/test_processor_manager.py @@ -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 diff --git a/server/tests/test_screen_capture.py b/server/tests/test_screen_capture.py index a76d16b..85c8eaa 100644 --- a/server/tests/test_screen_capture.py +++ b/server/tests/test_screen_capture.py @@ -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, diff --git a/server/tests/test_wled_client.py b/server/tests/test_wled_client.py index 62fa958..8350f7a 100644 --- a/server/tests/test_wled_client.py +++ b/server/tests/test_wled_client.py @@ -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