|
|
|
|
@@ -88,10 +88,11 @@ class ProcessorState:
|
|
|
|
|
led_count: int
|
|
|
|
|
settings: ProcessingSettings
|
|
|
|
|
calibration: CalibrationConfig
|
|
|
|
|
capture_template_id: str = "tpl_mss_default" # NEW: template ID for capture engine
|
|
|
|
|
capture_template_id: str = ""
|
|
|
|
|
picture_stream_id: str = ""
|
|
|
|
|
wled_client: Optional[WLEDClient] = None
|
|
|
|
|
pixel_mapper: Optional[PixelMapper] = None
|
|
|
|
|
capture_engine: Optional[CaptureEngine] = None # NEW: initialized capture engine
|
|
|
|
|
capture_engine: Optional[CaptureEngine] = None
|
|
|
|
|
is_running: bool = False
|
|
|
|
|
task: Optional[asyncio.Task] = None
|
|
|
|
|
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
|
|
|
|
|
@@ -100,16 +101,34 @@ class ProcessorState:
|
|
|
|
|
test_mode_active: bool = False
|
|
|
|
|
test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
|
|
|
|
|
health_task: Optional[asyncio.Task] = 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
|
|
|
|
|
resolved_gamma: Optional[float] = None
|
|
|
|
|
resolved_saturation: Optional[float] = None
|
|
|
|
|
resolved_brightness: Optional[float] = None
|
|
|
|
|
resolved_smoothing: Optional[float] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProcessorManager:
|
|
|
|
|
"""Manages screen processing for multiple WLED devices."""
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
"""Initialize processor manager."""
|
|
|
|
|
def __init__(self, picture_stream_store=None, capture_template_store=None, pp_template_store=None):
|
|
|
|
|
"""Initialize processor manager.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
picture_stream_store: PictureStreamStore instance (for stream resolution)
|
|
|
|
|
capture_template_store: TemplateStore instance (for engine lookup)
|
|
|
|
|
pp_template_store: PostprocessingTemplateStore instance (for PP settings)
|
|
|
|
|
"""
|
|
|
|
|
self._processors: Dict[str, ProcessorState] = {}
|
|
|
|
|
self._health_monitoring_active = False
|
|
|
|
|
self._http_client: Optional[httpx.AsyncClient] = None
|
|
|
|
|
self._picture_stream_store = picture_stream_store
|
|
|
|
|
self._capture_template_store = capture_template_store
|
|
|
|
|
self._pp_template_store = pp_template_store
|
|
|
|
|
logger.info("Processor manager initialized")
|
|
|
|
|
|
|
|
|
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
|
|
|
|
@@ -125,7 +144,8 @@ class ProcessorManager:
|
|
|
|
|
led_count: int,
|
|
|
|
|
settings: Optional[ProcessingSettings] = None,
|
|
|
|
|
calibration: Optional[CalibrationConfig] = None,
|
|
|
|
|
capture_template_id: str = "tpl_mss_default",
|
|
|
|
|
capture_template_id: str = "",
|
|
|
|
|
picture_stream_id: str = "",
|
|
|
|
|
):
|
|
|
|
|
"""Add a device for processing.
|
|
|
|
|
|
|
|
|
|
@@ -135,7 +155,8 @@ class ProcessorManager:
|
|
|
|
|
led_count: Number of LEDs
|
|
|
|
|
settings: Processing settings (uses defaults if None)
|
|
|
|
|
calibration: Calibration config (creates default if None)
|
|
|
|
|
capture_template_id: Template ID for screen capture engine
|
|
|
|
|
capture_template_id: Legacy template ID for screen capture engine
|
|
|
|
|
picture_stream_id: Picture stream ID (preferred over capture_template_id)
|
|
|
|
|
"""
|
|
|
|
|
if device_id in self._processors:
|
|
|
|
|
raise ValueError(f"Device {device_id} already exists")
|
|
|
|
|
@@ -153,6 +174,7 @@ class ProcessorManager:
|
|
|
|
|
settings=settings,
|
|
|
|
|
calibration=calibration,
|
|
|
|
|
capture_template_id=capture_template_id,
|
|
|
|
|
picture_stream_id=picture_stream_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self._processors[device_id] = state
|
|
|
|
|
@@ -245,9 +267,81 @@ class ProcessorManager:
|
|
|
|
|
|
|
|
|
|
logger.info(f"Updated calibration for device {device_id}")
|
|
|
|
|
|
|
|
|
|
def _resolve_stream_settings(self, state: ProcessorState):
|
|
|
|
|
"""Resolve picture stream chain to populate resolved_* fields on state.
|
|
|
|
|
|
|
|
|
|
If device has a picture_stream_id and stores are available, resolves the
|
|
|
|
|
stream chain to get display_index, fps, engine type/config, and PP settings.
|
|
|
|
|
Otherwise falls back to legacy device settings.
|
|
|
|
|
"""
|
|
|
|
|
if state.picture_stream_id and self._picture_stream_store:
|
|
|
|
|
try:
|
|
|
|
|
chain = self._picture_stream_store.resolve_stream_chain(state.picture_stream_id)
|
|
|
|
|
raw_stream = chain["raw_stream"]
|
|
|
|
|
pp_template_ids = chain["postprocessing_template_ids"]
|
|
|
|
|
|
|
|
|
|
state.resolved_display_index = raw_stream.display_index
|
|
|
|
|
state.resolved_target_fps = raw_stream.target_fps
|
|
|
|
|
|
|
|
|
|
# Resolve capture engine from raw stream's capture template
|
|
|
|
|
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 = {}
|
|
|
|
|
|
|
|
|
|
# Resolve postprocessing: use first PP template in chain
|
|
|
|
|
if pp_template_ids and self._pp_template_store:
|
|
|
|
|
try:
|
|
|
|
|
pp = self._pp_template_store.get_template(pp_template_ids[0])
|
|
|
|
|
state.resolved_gamma = pp.gamma
|
|
|
|
|
state.resolved_saturation = pp.saturation
|
|
|
|
|
state.resolved_brightness = pp.brightness
|
|
|
|
|
state.resolved_smoothing = pp.smoothing
|
|
|
|
|
except ValueError:
|
|
|
|
|
logger.warning(f"PP template {pp_template_ids[0]} not found, using defaults")
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Resolved stream chain for {state.device_id}: "
|
|
|
|
|
f"display={state.resolved_display_index}, fps={state.resolved_target_fps}, "
|
|
|
|
|
f"engine={state.resolved_engine_type}, pp_templates={len(pp_template_ids)}"
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
logger.warning(f"Failed to resolve stream {state.picture_stream_id}: {e}, falling back to legacy settings")
|
|
|
|
|
|
|
|
|
|
# Fallback: use legacy device settings
|
|
|
|
|
state.resolved_display_index = state.settings.display_index
|
|
|
|
|
state.resolved_target_fps = state.settings.fps
|
|
|
|
|
state.resolved_gamma = state.settings.gamma
|
|
|
|
|
state.resolved_saturation = state.settings.saturation
|
|
|
|
|
state.resolved_brightness = state.settings.brightness
|
|
|
|
|
state.resolved_smoothing = state.settings.smoothing
|
|
|
|
|
|
|
|
|
|
# Resolve engine from legacy capture_template_id
|
|
|
|
|
if state.capture_template_id and self._capture_template_store:
|
|
|
|
|
try:
|
|
|
|
|
tpl = self._capture_template_store.get_template(state.capture_template_id)
|
|
|
|
|
state.resolved_engine_type = tpl.engine_type
|
|
|
|
|
state.resolved_engine_config = tpl.engine_config
|
|
|
|
|
except ValueError:
|
|
|
|
|
logger.warning(f"Capture template {state.capture_template_id} not found, using MSS fallback")
|
|
|
|
|
state.resolved_engine_type = "mss"
|
|
|
|
|
state.resolved_engine_config = {}
|
|
|
|
|
else:
|
|
|
|
|
state.resolved_engine_type = "mss"
|
|
|
|
|
state.resolved_engine_config = {}
|
|
|
|
|
|
|
|
|
|
async def start_processing(self, device_id: str):
|
|
|
|
|
"""Start screen processing for a device.
|
|
|
|
|
|
|
|
|
|
Resolves the picture stream chain (if assigned) to determine capture engine,
|
|
|
|
|
display, FPS, and postprocessing settings. Falls back to legacy device settings.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
device_id: Device identifier
|
|
|
|
|
|
|
|
|
|
@@ -263,9 +357,11 @@ class ProcessorManager:
|
|
|
|
|
if state.is_running:
|
|
|
|
|
raise RuntimeError(f"Processing already running for device {device_id}")
|
|
|
|
|
|
|
|
|
|
# Resolve stream settings
|
|
|
|
|
self._resolve_stream_settings(state)
|
|
|
|
|
|
|
|
|
|
# Connect to WLED device
|
|
|
|
|
try:
|
|
|
|
|
# Enable DDP for large LED counts (>500 LEDs)
|
|
|
|
|
use_ddp = state.led_count > 500
|
|
|
|
|
state.wled_client = WLEDClient(state.device_url, use_ddp=use_ddp)
|
|
|
|
|
await state.wled_client.connect()
|
|
|
|
|
@@ -276,17 +372,16 @@ class ProcessorManager:
|
|
|
|
|
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
|
|
|
|
|
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
|
|
|
|
|
|
|
|
|
# Initialize capture engine
|
|
|
|
|
# Phase 2: Use MSS engine for all devices (template integration in Phase 5)
|
|
|
|
|
# Initialize capture engine from resolved settings
|
|
|
|
|
try:
|
|
|
|
|
# For now, always use MSS engine (Phase 5 will load from template)
|
|
|
|
|
engine = EngineRegistry.create_engine("mss", {})
|
|
|
|
|
engine_type = state.resolved_engine_type or "mss"
|
|
|
|
|
engine_config = state.resolved_engine_config or {}
|
|
|
|
|
engine = EngineRegistry.create_engine(engine_type, engine_config)
|
|
|
|
|
engine.initialize()
|
|
|
|
|
state.capture_engine = engine
|
|
|
|
|
logger.debug(f"Initialized capture engine for device {device_id}: mss")
|
|
|
|
|
logger.info(f"Initialized capture engine for device {device_id}: {engine_type}")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to initialize capture engine for device {device_id}: {e}")
|
|
|
|
|
# Cleanup WLED client before raising
|
|
|
|
|
if state.wled_client:
|
|
|
|
|
await state.wled_client.disconnect()
|
|
|
|
|
raise RuntimeError(f"Failed to initialize capture engine: {e}")
|
|
|
|
|
@@ -352,18 +447,31 @@ class ProcessorManager:
|
|
|
|
|
async def _processing_loop(self, device_id: str):
|
|
|
|
|
"""Main processing loop for a device.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
device_id: Device identifier
|
|
|
|
|
Uses resolved_* fields from stream resolution for display, FPS,
|
|
|
|
|
and postprocessing. Falls back to device settings for LED projection
|
|
|
|
|
parameters (border_width, interpolation_mode) and WLED brightness.
|
|
|
|
|
"""
|
|
|
|
|
state = self._processors[device_id]
|
|
|
|
|
settings = state.settings
|
|
|
|
|
|
|
|
|
|
# Use resolved values (populated by _resolve_stream_settings)
|
|
|
|
|
display_index = state.resolved_display_index or settings.display_index
|
|
|
|
|
target_fps = state.resolved_target_fps or settings.fps
|
|
|
|
|
gamma = state.resolved_gamma if state.resolved_gamma is not None else settings.gamma
|
|
|
|
|
saturation = state.resolved_saturation if state.resolved_saturation is not None else settings.saturation
|
|
|
|
|
pp_brightness = state.resolved_brightness if state.resolved_brightness is not None else settings.brightness
|
|
|
|
|
smoothing = state.resolved_smoothing if state.resolved_smoothing is not None else settings.smoothing
|
|
|
|
|
|
|
|
|
|
# These always come from device settings (LED projection)
|
|
|
|
|
border_width = settings.border_width
|
|
|
|
|
wled_brightness = settings.brightness # WLED hardware brightness
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Processing loop started for {device_id} "
|
|
|
|
|
f"(display={settings.display_index}, fps={settings.fps})"
|
|
|
|
|
f"(display={display_index}, fps={target_fps})"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
frame_time = 1.0 / settings.fps
|
|
|
|
|
frame_time = 1.0 / target_fps
|
|
|
|
|
fps_samples = []
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
@@ -376,39 +484,38 @@ class ProcessorManager:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Run blocking operations in thread pool to avoid blocking event loop
|
|
|
|
|
# Capture screen using engine (blocking I/O)
|
|
|
|
|
# Capture screen using engine
|
|
|
|
|
capture = await asyncio.to_thread(
|
|
|
|
|
state.capture_engine.capture_display,
|
|
|
|
|
settings.display_index
|
|
|
|
|
display_index
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Extract border pixels (CPU-intensive)
|
|
|
|
|
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, settings.border_width)
|
|
|
|
|
# Extract border pixels
|
|
|
|
|
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, border_width)
|
|
|
|
|
|
|
|
|
|
# Map to LED colors (CPU-intensive)
|
|
|
|
|
# Map to LED colors
|
|
|
|
|
led_colors = await asyncio.to_thread(state.pixel_mapper.map_border_to_leds, border_pixels)
|
|
|
|
|
|
|
|
|
|
# Apply color correction (CPU-intensive)
|
|
|
|
|
# Apply color correction from postprocessing
|
|
|
|
|
led_colors = await asyncio.to_thread(
|
|
|
|
|
apply_color_correction,
|
|
|
|
|
led_colors,
|
|
|
|
|
gamma=settings.gamma,
|
|
|
|
|
saturation=settings.saturation,
|
|
|
|
|
brightness=settings.brightness,
|
|
|
|
|
gamma=gamma,
|
|
|
|
|
saturation=saturation,
|
|
|
|
|
brightness=pp_brightness,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Apply smoothing (CPU-intensive)
|
|
|
|
|
if state.previous_colors and settings.smoothing > 0:
|
|
|
|
|
# Apply smoothing from postprocessing
|
|
|
|
|
if state.previous_colors and smoothing > 0:
|
|
|
|
|
led_colors = await asyncio.to_thread(
|
|
|
|
|
smooth_colors,
|
|
|
|
|
led_colors,
|
|
|
|
|
state.previous_colors,
|
|
|
|
|
settings.smoothing,
|
|
|
|
|
smoothing,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Send to WLED with brightness
|
|
|
|
|
brightness_value = int(settings.brightness * 255)
|
|
|
|
|
# Send to WLED with device brightness
|
|
|
|
|
brightness_value = int(wled_brightness * 255)
|
|
|
|
|
await state.wled_client.send_pixels(led_colors, brightness=brightness_value)
|
|
|
|
|
|
|
|
|
|
# Update metrics
|
|
|
|
|
@@ -468,8 +575,8 @@ class ProcessorManager:
|
|
|
|
|
"device_id": device_id,
|
|
|
|
|
"processing": state.is_running,
|
|
|
|
|
"fps_actual": metrics.fps_actual if state.is_running else None,
|
|
|
|
|
"fps_target": state.settings.fps,
|
|
|
|
|
"display_index": state.settings.display_index,
|
|
|
|
|
"fps_target": state.resolved_target_fps or state.settings.fps,
|
|
|
|
|
"display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index,
|
|
|
|
|
"last_update": metrics.last_update,
|
|
|
|
|
"errors": [metrics.last_error] if metrics.last_error else [],
|
|
|
|
|
"wled_online": h.online,
|
|
|
|
|
|