Initial commit: WLED Screen Controller with FastAPI server and Home Assistant integration
Some checks failed
Validate / validate (push) Failing after 1m6s

This is a complete WLED ambient lighting controller that captures screen border pixels
and sends them to WLED devices for immersive ambient lighting effects.

## Server Features:
- FastAPI-based REST API with 17+ endpoints
- Real-time screen capture with multi-monitor support
- Advanced LED calibration system with visual GUI
- API key authentication with labeled tokens
- Per-device brightness control (0-100%)
- Configurable FPS (1-60), border width, and color correction
- Persistent device storage (JSON-based)
- Comprehensive Web UI with dark/light themes
- Docker support with docker-compose
- Windows monitor name detection via WMI (shows "LG ULTRAWIDE" etc.)

## Web UI Features:
- Device management (add, configure, remove WLED devices)
- Real-time status monitoring with FPS metrics
- Settings modal for device configuration
- Visual calibration GUI with edge testing
- Brightness slider per device
- Display selection with friendly monitor names
- Token-based authentication with login/logout
- Responsive button layout

## Calibration System:
- Support for any LED strip layout (clockwise/counterclockwise)
- 4 starting position options (corners)
- Per-edge LED count configuration
- Visual preview with starting position indicator
- Test buttons to light up individual edges
- Smart LED ordering based on start position and direction

## Home Assistant Integration:
- Custom HACS integration
- Switch entities for processing control
- Sensor entities for status and FPS
- Select entities for display selection
- Config flow for easy setup
- Auto-discovery of devices from server

## Technical Stack:
- Python 3.11+
- FastAPI + uvicorn
- mss (screen capture)
- httpx (async WLED client)
- Pydantic (validation)
- WMI (Windows monitor detection)
- Structlog (logging)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 16:38:27 +03:00
commit d471a40234
57 changed files with 9726 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
"""Core functionality for screen capture and WLED control."""
from .screen_capture import (
get_available_displays,
capture_display,
extract_border_pixels,
ScreenCapture,
BorderPixels,
)
__all__ = [
"get_available_displays",
"capture_display",
"extract_border_pixels",
"ScreenCapture",
"BorderPixels",
]

View File

@@ -0,0 +1,344 @@
"""Calibration system for mapping screen pixels to LED positions."""
from dataclasses import dataclass
from typing import List, Literal, Tuple
from wled_controller.core.screen_capture import (
BorderPixels,
get_edge_segments,
calculate_average_color,
calculate_median_color,
calculate_dominant_color,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@dataclass
class CalibrationSegment:
"""Configuration for one segment of the LED strip."""
edge: Literal["top", "right", "bottom", "left"]
led_start: int
led_count: int
reverse: bool = False
@dataclass
class CalibrationConfig:
"""Complete calibration configuration."""
layout: Literal["clockwise", "counterclockwise"]
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
segments: List[CalibrationSegment]
def validate(self) -> bool:
"""Validate calibration configuration.
Returns:
True if configuration is valid
Raises:
ValueError: If configuration is invalid
"""
if not self.segments:
raise ValueError("Calibration must have at least one segment")
# Check for duplicate edges
edges = [seg.edge for seg in self.segments]
if len(edges) != len(set(edges)):
raise ValueError("Duplicate edges in calibration segments")
# Validate LED indices don't overlap
led_ranges = []
for seg in self.segments:
led_range = range(seg.led_start, seg.led_start + seg.led_count)
led_ranges.append(led_range)
# Check for overlaps
for i, range1 in enumerate(led_ranges):
for j, range2 in enumerate(led_ranges):
if i != j:
overlap = set(range1) & set(range2)
if overlap:
raise ValueError(
f"LED indices overlap between segments {i} and {j}: {overlap}"
)
# Validate LED counts are positive
for seg in self.segments:
if seg.led_count <= 0:
raise ValueError(f"LED count must be positive, got {seg.led_count}")
if seg.led_start < 0:
raise ValueError(f"LED start must be non-negative, got {seg.led_start}")
return True
def get_total_leds(self) -> int:
"""Get total number of LEDs across all segments."""
return sum(seg.led_count for seg in self.segments)
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
"""Get segment configuration for a specific edge."""
for seg in self.segments:
if seg.edge == edge:
return seg
return None
class PixelMapper:
"""Maps screen border pixels to LED colors based on calibration."""
def __init__(
self,
calibration: CalibrationConfig,
interpolation_mode: Literal["average", "median", "dominant"] = "average",
):
"""Initialize pixel mapper.
Args:
calibration: Calibration configuration
interpolation_mode: Color calculation mode
"""
self.calibration = calibration
self.interpolation_mode = interpolation_mode
# Validate calibration
self.calibration.validate()
# Select color calculation function
if interpolation_mode == "average":
self._calc_color = calculate_average_color
elif interpolation_mode == "median":
self._calc_color = calculate_median_color
elif interpolation_mode == "dominant":
self._calc_color = calculate_dominant_color
else:
raise ValueError(f"Invalid interpolation mode: {interpolation_mode}")
logger.info(
f"Initialized pixel mapper with {self.calibration.get_total_leds()} LEDs "
f"using {interpolation_mode} interpolation"
)
def map_border_to_leds(
self,
border_pixels: BorderPixels
) -> List[Tuple[int, int, int]]:
"""Map screen border pixels to LED colors.
Args:
border_pixels: Extracted border pixels from screen
Returns:
List of (R, G, B) tuples for each LED
Raises:
ValueError: If border pixels don't match calibration
"""
total_leds = self.calibration.get_total_leds()
led_colors = [(0, 0, 0)] * total_leds
# Process each edge
for edge_name in ["top", "right", "bottom", "left"]:
segment = self.calibration.get_segment_for_edge(edge_name)
if not segment:
# This edge is not configured
continue
# Get pixels for this edge
if edge_name == "top":
edge_pixels = border_pixels.top
elif edge_name == "right":
edge_pixels = border_pixels.right
elif edge_name == "bottom":
edge_pixels = border_pixels.bottom
else: # left
edge_pixels = border_pixels.left
# Divide edge into segments matching LED count
try:
pixel_segments = get_edge_segments(
edge_pixels,
segment.led_count,
edge_name
)
except ValueError as e:
logger.error(f"Failed to segment {edge_name} edge: {e}")
raise
# Calculate LED indices for this segment
led_indices = list(range(segment.led_start, segment.led_start + segment.led_count))
# Reverse if needed
if segment.reverse:
led_indices = list(reversed(led_indices))
# Map pixel segments to LEDs
for led_idx, pixel_segment in zip(led_indices, pixel_segments):
color = self._calc_color(pixel_segment)
led_colors[led_idx] = color
logger.debug(f"Mapped border pixels to {total_leds} LED colors")
return led_colors
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
"""Generate test pattern to light up specific edge.
Useful for verifying calibration configuration.
Args:
edge: Edge to light up (top, right, bottom, left)
color: RGB color to use
Returns:
List of LED colors with only the specified edge lit
Raises:
ValueError: If edge is not in calibration
"""
segment = self.calibration.get_segment_for_edge(edge)
if not segment:
raise ValueError(f"Edge '{edge}' not found in calibration")
total_leds = self.calibration.get_total_leds()
led_colors = [(0, 0, 0)] * total_leds
# Light up the specified edge
led_indices = range(segment.led_start, segment.led_start + segment.led_count)
for led_idx in led_indices:
led_colors[led_idx] = color
logger.info(f"Generated test pattern for {edge} edge with color {color}")
return led_colors
def create_default_calibration(led_count: int) -> CalibrationConfig:
"""Create a default calibration for a rectangular screen.
Assumes LEDs are evenly distributed around the screen edges in clockwise order
starting from bottom-left.
Args:
led_count: Total number of LEDs
Returns:
Default calibration configuration
"""
if led_count < 4:
raise ValueError("Need at least 4 LEDs for default calibration")
# Distribute LEDs evenly across 4 edges
leds_per_edge = led_count // 4
remainder = led_count % 4
# Distribute remainder to longer edges (bottom and top)
bottom_count = leds_per_edge + (1 if remainder > 0 else 0)
right_count = leds_per_edge
top_count = leds_per_edge + (1 if remainder > 1 else 0)
left_count = leds_per_edge + (1 if remainder > 2 else 0)
segments = [
CalibrationSegment(
edge="bottom",
led_start=0,
led_count=bottom_count,
reverse=False,
),
CalibrationSegment(
edge="right",
led_start=bottom_count,
led_count=right_count,
reverse=False,
),
CalibrationSegment(
edge="top",
led_start=bottom_count + right_count,
led_count=top_count,
reverse=True,
),
CalibrationSegment(
edge="left",
led_start=bottom_count + right_count + top_count,
led_count=left_count,
reverse=True,
),
]
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
segments=segments,
)
logger.info(
f"Created default calibration for {led_count} LEDs: "
f"bottom={bottom_count}, right={right_count}, "
f"top={top_count}, left={left_count}"
)
return config
def calibration_from_dict(data: dict) -> CalibrationConfig:
"""Create calibration configuration from dictionary.
Args:
data: Dictionary with calibration data
Returns:
CalibrationConfig instance
Raises:
ValueError: If data is invalid
"""
try:
segments = [
CalibrationSegment(
edge=seg["edge"],
led_start=seg["led_start"],
led_count=seg["led_count"],
reverse=seg.get("reverse", False),
)
for seg in data["segments"]
]
config = CalibrationConfig(
layout=data["layout"],
start_position=data["start_position"],
segments=segments,
)
config.validate()
return config
except KeyError as e:
raise ValueError(f"Missing required calibration field: {e}")
except Exception as e:
raise ValueError(f"Invalid calibration data: {e}")
def calibration_to_dict(config: CalibrationConfig) -> dict:
"""Convert calibration configuration to dictionary.
Args:
config: Calibration configuration
Returns:
Dictionary representation
"""
return {
"layout": config.layout,
"start_position": config.start_position,
"segments": [
{
"edge": seg.edge,
"led_start": seg.led_start,
"led_count": seg.led_count,
"reverse": seg.reverse,
}
for seg in config.segments
],
}

View File

@@ -0,0 +1,166 @@
"""Pixel processing utilities for color correction and manipulation."""
from typing import List, Tuple
import numpy as np
from wled_controller.utils import get_logger
logger = get_logger(__name__)
def apply_color_correction(
colors: List[Tuple[int, int, int]],
gamma: float = 2.2,
saturation: float = 1.0,
brightness: float = 1.0,
) -> List[Tuple[int, int, int]]:
"""Apply color correction to LED colors.
Args:
colors: List of (R, G, B) tuples
gamma: Gamma correction factor (default 2.2)
saturation: Saturation multiplier (0.0-2.0)
brightness: Brightness multiplier (0.0-1.0)
Returns:
Corrected list of (R, G, B) tuples
"""
if not colors:
return colors
# Convert to numpy array for efficient processing
colors_array = np.array(colors, dtype=np.float32) / 255.0
# Apply brightness
if brightness != 1.0:
colors_array *= brightness
# Apply saturation
if saturation != 1.0:
# Convert RGB to HSV-like saturation adjustment
# Calculate luminance (grayscale)
luminance = np.dot(colors_array, [0.299, 0.587, 0.114])
luminance = luminance[:, np.newaxis] # Reshape for broadcasting
# Blend between grayscale and color based on saturation
colors_array = luminance + (colors_array - luminance) * saturation
# Apply gamma correction
if gamma != 1.0:
colors_array = np.power(colors_array, 1.0 / gamma)
# Clamp to valid range and convert back to integers
colors_array = np.clip(colors_array * 255.0, 0, 255).astype(np.uint8)
# Convert back to list of tuples
corrected_colors = [tuple(color) for color in colors_array]
return corrected_colors
def smooth_colors(
current_colors: List[Tuple[int, int, int]],
previous_colors: List[Tuple[int, int, int]],
smoothing_factor: float = 0.5,
) -> List[Tuple[int, int, int]]:
"""Smooth color transitions between frames.
Args:
current_colors: Current frame colors
previous_colors: Previous frame colors
smoothing_factor: Smoothing amount (0.0-1.0, where 0=no smoothing, 1=full smoothing)
Returns:
Smoothed colors
"""
if not current_colors or not previous_colors:
return current_colors
if len(current_colors) != len(previous_colors):
logger.warning(
f"Color count mismatch: current={len(current_colors)}, "
f"previous={len(previous_colors)}. Skipping smoothing."
)
return current_colors
if smoothing_factor <= 0:
return current_colors
if smoothing_factor >= 1:
return previous_colors
# Convert to numpy arrays
current = np.array(current_colors, dtype=np.float32)
previous = np.array(previous_colors, dtype=np.float32)
# Blend between current and previous
smoothed = current * (1 - smoothing_factor) + previous * smoothing_factor
# Convert back to integers
smoothed = np.clip(smoothed, 0, 255).astype(np.uint8)
return [tuple(color) for color in smoothed]
def adjust_brightness_global(
colors: List[Tuple[int, int, int]],
target_brightness: int,
) -> List[Tuple[int, int, int]]:
"""Adjust colors to achieve target global brightness.
Args:
colors: List of (R, G, B) tuples
target_brightness: Target brightness (0-255)
Returns:
Adjusted colors
"""
if not colors or target_brightness == 255:
return colors
# Calculate scaling factor
scale = target_brightness / 255.0
# Scale all colors
scaled = [
(
int(r * scale),
int(g * scale),
int(b * scale),
)
for r, g, b in colors
]
return scaled
def limit_brightness(
colors: List[Tuple[int, int, int]],
max_brightness: int = 255,
) -> List[Tuple[int, int, int]]:
"""Limit maximum brightness of any color channel.
Args:
colors: List of (R, G, B) tuples
max_brightness: Maximum allowed brightness (0-255)
Returns:
Limited colors
"""
if not colors or max_brightness == 255:
return colors
limited = []
for r, g, b in colors:
# Find max channel value
max_val = max(r, g, b)
if max_val > max_brightness:
# Scale down proportionally
scale = max_brightness / max_val
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
limited.append((r, g, b))
return limited

View File

@@ -0,0 +1,452 @@
"""Processing manager for coordinating screen capture and WLED updates."""
import asyncio
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Optional
from wled_controller.core.calibration import (
CalibrationConfig,
PixelMapper,
create_default_calibration,
)
from wled_controller.core.pixel_processor import apply_color_correction, smooth_colors
from wled_controller.core.screen_capture import capture_display, extract_border_pixels
from wled_controller.core.wled_client import WLEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@dataclass
class ProcessingSettings:
"""Settings for screen processing."""
display_index: int = 0
fps: int = 30
border_width: int = 10
brightness: float = 1.0
gamma: float = 2.2
saturation: float = 1.0
smoothing: float = 0.3
interpolation_mode: str = "average"
@dataclass
class ProcessingMetrics:
"""Metrics for processing performance."""
frames_processed: 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
@dataclass
class ProcessorState:
"""State of a running processor."""
device_id: str
device_url: str
led_count: int
settings: ProcessingSettings
calibration: CalibrationConfig
wled_client: Optional[WLEDClient] = 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
class ProcessorManager:
"""Manages screen processing for multiple WLED devices."""
def __init__(self):
"""Initialize processor manager."""
self._processors: Dict[str, ProcessorState] = {}
logger.info("Processor manager initialized")
def add_device(
self,
device_id: str,
device_url: str,
led_count: int,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
):
"""Add a device for processing.
Args:
device_id: Unique device identifier
device_url: WLED device URL
led_count: Number of LEDs
settings: Processing settings (uses defaults if None)
calibration: Calibration config (creates default if None)
"""
if device_id in self._processors:
raise ValueError(f"Device {device_id} already exists")
if settings is None:
settings = ProcessingSettings()
if calibration is None:
calibration = create_default_calibration(led_count)
state = ProcessorState(
device_id=device_id,
device_url=device_url,
led_count=led_count,
settings=settings,
calibration=calibration,
)
self._processors[device_id] = state
logger.info(f"Added device {device_id} with {led_count} LEDs")
def remove_device(self, device_id: str):
"""Remove a device.
Args:
device_id: Device identifier
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
# Stop processing if running
if self._processors[device_id].is_running:
raise RuntimeError(f"Cannot remove device {device_id} while processing")
del self._processors[device_id]
logger.info(f"Removed device {device_id}")
def update_settings(self, device_id: str, settings: ProcessingSettings):
"""Update processing settings for a device.
Args:
device_id: Device identifier
settings: New settings
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
self._processors[device_id].settings = settings
# Recreate pixel mapper if interpolation mode changed
state = self._processors[device_id]
if state.pixel_mapper:
state.pixel_mapper = PixelMapper(
state.calibration,
interpolation_mode=settings.interpolation_mode,
)
logger.info(f"Updated settings for device {device_id}")
def update_calibration(self, device_id: str, calibration: CalibrationConfig):
"""Update calibration for a device.
Args:
device_id: Device identifier
calibration: New calibration config
Raises:
ValueError: If device not found or calibration invalid
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
# Validate calibration
calibration.validate()
# Check LED count matches
state = self._processors[device_id]
if calibration.get_total_leds() != state.led_count:
raise ValueError(
f"Calibration LED count ({calibration.get_total_leds()}) "
f"does not match device LED count ({state.led_count})"
)
state.calibration = calibration
# Recreate pixel mapper if running
if state.pixel_mapper:
state.pixel_mapper = PixelMapper(
calibration,
interpolation_mode=state.settings.interpolation_mode,
)
logger.info(f"Updated calibration for device {device_id}")
async def start_processing(self, device_id: str):
"""Start screen processing for a device.
Args:
device_id: Device identifier
Raises:
ValueError: If device not found
RuntimeError: If processing already running
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
state = self._processors[device_id]
if state.is_running:
raise RuntimeError(f"Processing already running for device {device_id}")
# Connect to WLED device
try:
state.wled_client = WLEDClient(state.device_url)
await state.wled_client.connect()
except Exception as e:
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
raise RuntimeError(f"Failed to connect to WLED device: {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(device_id))
state.is_running = True
logger.info(f"Started processing for device {device_id}")
async def stop_processing(self, device_id: str):
"""Stop screen processing for a device.
Args:
device_id: Device identifier
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
state = self._processors[device_id]
if not state.is_running:
logger.warning(f"Processing not running for device {device_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
# Close WLED connection
if state.wled_client:
await state.wled_client.close()
state.wled_client = None
logger.info(f"Stopped processing for device {device_id}")
async def _processing_loop(self, device_id: str):
"""Main processing loop for a device.
Args:
device_id: Device identifier
"""
state = self._processors[device_id]
settings = state.settings
logger.info(
f"Processing loop started for {device_id} "
f"(display={settings.display_index}, fps={settings.fps})"
)
frame_time = 1.0 / settings.fps
fps_samples = []
try:
while state.is_running:
loop_start = time.time()
try:
# Capture screen
capture = capture_display(settings.display_index)
# Extract border pixels
border_pixels = extract_border_pixels(capture, settings.border_width)
# Map to LED colors
led_colors = state.pixel_mapper.map_border_to_leds(border_pixels)
# Apply color correction
led_colors = apply_color_correction(
led_colors,
gamma=settings.gamma,
saturation=settings.saturation,
brightness=settings.brightness,
)
# Apply smoothing
if state.previous_colors and settings.smoothing > 0:
led_colors = smooth_colors(
led_colors,
state.previous_colors,
settings.smoothing,
)
# Send to WLED with brightness
brightness_value = int(settings.brightness * 255)
await state.wled_client.send_pixels(led_colors, brightness=brightness_value)
# Update metrics
state.metrics.frames_processed += 1
state.metrics.last_update = datetime.utcnow()
state.previous_colors = led_colors
# Calculate actual FPS
loop_time = time.time() - loop_start
fps_samples.append(1.0 / loop_time if loop_time > 0 else 0)
if len(fps_samples) > 10:
fps_samples.pop(0)
state.metrics.fps_actual = sum(fps_samples) / len(fps_samples)
except Exception as e:
state.metrics.errors_count += 1
state.metrics.last_error = str(e)
logger.error(f"Processing error for device {device_id}: {e}")
# FPS control
elapsed = time.time() - loop_start
sleep_time = max(0, frame_time - elapsed)
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except asyncio.CancelledError:
logger.info(f"Processing loop cancelled for device {device_id}")
raise
except Exception as e:
logger.error(f"Fatal error in processing loop for {device_id}: {e}")
state.is_running = False
raise
finally:
logger.info(f"Processing loop ended for device {device_id}")
def get_state(self, device_id: str) -> dict:
"""Get current processing state for a device.
Args:
device_id: Device identifier
Returns:
State dictionary
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
state = self._processors[device_id]
metrics = state.metrics
return {
"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,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
}
def get_metrics(self, device_id: str) -> dict:
"""Get detailed metrics for a device.
Args:
device_id: Device identifier
Returns:
Metrics dictionary
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
state = self._processors[device_id]
metrics = state.metrics
# Calculate uptime
uptime_seconds = 0.0
if metrics.start_time and state.is_running:
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
return {
"device_id": 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_processing(self, device_id: str) -> bool:
"""Check if device is currently processing.
Args:
device_id: Device identifier
Returns:
True if processing
Raises:
ValueError: If device not found
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
return self._processors[device_id].is_running
def get_all_devices(self) -> list[str]:
"""Get list of all device IDs.
Returns:
List of device IDs
"""
return list(self._processors.keys())
async def stop_all(self):
"""Stop processing for all devices."""
device_ids = list(self._processors.keys())
for device_id in device_ids:
if self._processors[device_id].is_running:
try:
await self.stop_processing(device_id)
except Exception as e:
logger.error(f"Error stopping device {device_id}: {e}")
logger.info("Stopped all processors")

View File

@@ -0,0 +1,329 @@
"""Screen capture functionality using mss library."""
from dataclasses import dataclass
from typing import Dict, List
import mss
import numpy as np
from PIL import Image
from wled_controller.utils import get_logger, get_monitor_names
logger = get_logger(__name__)
@dataclass
class DisplayInfo:
"""Information about a display/monitor."""
index: int
name: str
width: int
height: int
x: int
y: int
is_primary: bool
@dataclass
class ScreenCapture:
"""Captured screen image data."""
image: np.ndarray
width: int
height: int
display_index: int
@dataclass
class BorderPixels:
"""Border pixels extracted from screen edges."""
top: np.ndarray
right: np.ndarray
bottom: np.ndarray
left: np.ndarray
def get_available_displays() -> List[DisplayInfo]:
"""Get list of available displays/monitors.
Returns:
List of DisplayInfo objects for each available monitor
Raises:
RuntimeError: If unable to detect displays
"""
try:
# Get friendly monitor names (Windows only, falls back to generic names)
monitor_names = get_monitor_names()
with mss.mss() as sct:
displays = []
# Skip the first monitor (combined virtual screen on multi-monitor setups)
for idx, monitor in enumerate(sct.monitors[1:], start=0):
# Use friendly name from WMI if available, otherwise generic name
friendly_name = monitor_names.get(idx, f"Display {idx}")
display_info = DisplayInfo(
index=idx,
name=friendly_name,
width=monitor["width"],
height=monitor["height"],
x=monitor["left"],
y=monitor["top"],
is_primary=(idx == 0),
)
displays.append(display_info)
logger.info(f"Detected {len(displays)} display(s)")
return displays
except Exception as e:
logger.error(f"Failed to detect displays: {e}")
raise RuntimeError(f"Failed to detect displays: {e}")
def capture_display(display_index: int = 0) -> ScreenCapture:
"""Capture the specified display.
Args:
display_index: Index of the display to capture (0-based)
Returns:
ScreenCapture object containing the captured image
Raises:
ValueError: If display_index is invalid
RuntimeError: If screen capture fails
"""
try:
with mss.mss() as sct:
# mss monitors[0] is the combined screen, monitors[1+] are individual displays
monitor_index = display_index + 1
if monitor_index >= len(sct.monitors):
raise ValueError(
f"Invalid display index {display_index}. "
f"Available displays: 0-{len(sct.monitors) - 2}"
)
monitor = sct.monitors[monitor_index]
# Capture screenshot
screenshot = sct.grab(monitor)
# Convert to numpy array (RGB)
img = Image.frombytes("RGB", screenshot.size, screenshot.rgb)
img_array = np.array(img)
logger.debug(
f"Captured display {display_index}: {monitor['width']}x{monitor['height']}"
)
return ScreenCapture(
image=img_array,
width=monitor["width"],
height=monitor["height"],
display_index=display_index,
)
except ValueError:
raise
except Exception as e:
logger.error(f"Failed to capture display {display_index}: {e}")
raise RuntimeError(f"Screen capture failed: {e}")
def extract_border_pixels(
screen_capture: ScreenCapture,
border_width: int = 10
) -> BorderPixels:
"""Extract border pixels from screen capture.
Args:
screen_capture: Captured screen image
border_width: Width of the border in pixels to extract
Returns:
BorderPixels object containing pixels from each edge
Raises:
ValueError: If border_width is invalid
"""
if border_width < 1:
raise ValueError("border_width must be at least 1")
if border_width > min(screen_capture.width, screen_capture.height) // 4:
raise ValueError(
f"border_width {border_width} is too large for screen size "
f"{screen_capture.width}x{screen_capture.height}"
)
img = screen_capture.image
height, width = img.shape[:2]
# Extract border regions
# Top edge: top border_width rows, full width
top = img[:border_width, :, :]
# Bottom edge: bottom border_width rows, full width
bottom = img[-border_width:, :, :]
# Right edge: right border_width columns, full height
right = img[:, -border_width:, :]
# Left edge: left border_width columns, full height
left = img[:, :border_width, :]
logger.debug(
f"Extracted borders: top={top.shape}, right={right.shape}, "
f"bottom={bottom.shape}, left={left.shape}"
)
return BorderPixels(
top=top,
right=right,
bottom=bottom,
left=left,
)
def get_edge_segments(
edge_pixels: np.ndarray,
segment_count: int,
edge_name: str
) -> List[np.ndarray]:
"""Divide edge pixels into segments.
Args:
edge_pixels: Pixel array for one edge
segment_count: Number of segments to divide into
edge_name: Name of the edge (for orientation)
Returns:
List of pixel arrays, one per segment
Raises:
ValueError: If segment_count is invalid
"""
if segment_count < 1:
raise ValueError("segment_count must be at least 1")
# Determine the dimension to divide
# For top/bottom edges: divide along width (axis 1)
# For left/right edges: divide along height (axis 0)
if edge_name in ["top", "bottom"]:
divide_axis = 1 # Width
edge_length = edge_pixels.shape[1]
else: # left, right
divide_axis = 0 # Height
edge_length = edge_pixels.shape[0]
if segment_count > edge_length:
raise ValueError(
f"segment_count {segment_count} is larger than edge length {edge_length}"
)
# Calculate segment size
segment_size = edge_length // segment_count
segments = []
for i in range(segment_count):
start = i * segment_size
end = start + segment_size if i < segment_count - 1 else edge_length
if divide_axis == 1:
segment = edge_pixels[:, start:end, :]
else:
segment = edge_pixels[start:end, :, :]
segments.append(segment)
return segments
def calculate_average_color(pixels: np.ndarray) -> tuple[int, int, int]:
"""Calculate average color of a pixel region.
Args:
pixels: Pixel array (height, width, 3)
Returns:
Tuple of (R, G, B) average values
"""
if pixels.size == 0:
return (0, 0, 0)
# Calculate mean across height and width dimensions
mean_color = np.mean(pixels, axis=(0, 1))
# Convert to integers and clamp to valid range
r = int(np.clip(mean_color[0], 0, 255))
g = int(np.clip(mean_color[1], 0, 255))
b = int(np.clip(mean_color[2], 0, 255))
return (r, g, b)
def calculate_median_color(pixels: np.ndarray) -> tuple[int, int, int]:
"""Calculate median color of a pixel region.
Args:
pixels: Pixel array (height, width, 3)
Returns:
Tuple of (R, G, B) median values
"""
if pixels.size == 0:
return (0, 0, 0)
# Calculate median across height and width dimensions
median_color = np.median(pixels, axis=(0, 1))
# Convert to integers and clamp to valid range
r = int(np.clip(median_color[0], 0, 255))
g = int(np.clip(median_color[1], 0, 255))
b = int(np.clip(median_color[2], 0, 255))
return (r, g, b)
def calculate_dominant_color(pixels: np.ndarray) -> tuple[int, int, int]:
"""Calculate dominant color of a pixel region using simple clustering.
Args:
pixels: Pixel array (height, width, 3)
Returns:
Tuple of (R, G, B) dominant color values
"""
if pixels.size == 0:
return (0, 0, 0)
# Reshape to (n_pixels, 3)
pixels_reshaped = pixels.reshape(-1, 3)
# For performance, sample pixels if there are too many
max_samples = 1000
if len(pixels_reshaped) > max_samples:
indices = np.random.choice(len(pixels_reshaped), max_samples, replace=False)
pixels_reshaped = pixels_reshaped[indices]
# Simple dominant color: quantize colors and find most common
# Reduce color space to 32 levels per channel for binning
quantized = (pixels_reshaped // 8) * 8
# Find unique colors and their counts
unique_colors, counts = np.unique(quantized, axis=0, return_counts=True)
# Get the most common color
dominant_idx = np.argmax(counts)
dominant_color = unique_colors[dominant_idx]
r = int(np.clip(dominant_color[0], 0, 255))
g = int(np.clip(dominant_color[1], 0, 255))
b = int(np.clip(dominant_color[2], 0, 255))
return (r, g, b)

View File

@@ -0,0 +1,368 @@
"""WLED HTTP client for controlling LED devices."""
import asyncio
from dataclasses import dataclass
from typing import List, Tuple, Optional, Dict, Any
import httpx
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@dataclass
class WLEDInfo:
"""WLED device information."""
name: str
version: str
led_count: int
brand: str
product: str
mac: str
ip: str
class WLEDClient:
"""HTTP client for WLED devices."""
def __init__(
self,
url: str,
timeout: int = 5,
retry_attempts: int = 3,
retry_delay: int = 1,
):
"""Initialize WLED client.
Args:
url: WLED device URL (e.g., http://192.168.1.100)
timeout: Request timeout in seconds
retry_attempts: Number of retry attempts on failure
retry_delay: Delay between retries in seconds
"""
self.url = url.rstrip("/")
self.timeout = timeout
self.retry_attempts = retry_attempts
self.retry_delay = retry_delay
self._client: Optional[httpx.AsyncClient] = None
self._connected = False
async def __aenter__(self):
"""Async context manager entry."""
await self.connect()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def connect(self) -> bool:
"""Establish connection to WLED device.
Returns:
True if connection successful
Raises:
RuntimeError: If connection fails
"""
try:
self._client = httpx.AsyncClient(timeout=self.timeout)
# Test connection by getting device info
info = await self.get_info()
self._connected = True
logger.info(
f"Connected to WLED device: {info.name} ({info.version}) "
f"with {info.led_count} LEDs"
)
return True
except Exception as e:
logger.error(f"Failed to connect to WLED device at {self.url}: {e}")
self._connected = False
if self._client:
await self._client.aclose()
self._client = None
raise RuntimeError(f"Failed to connect to WLED device: {e}")
async def close(self):
"""Close the connection to WLED device."""
if self._client:
await self._client.aclose()
self._client = None
self._connected = False
logger.debug(f"Closed connection to {self.url}")
@property
def is_connected(self) -> bool:
"""Check if connected to WLED device."""
return self._connected and self._client is not None
async def _request(
self,
method: str,
endpoint: str,
json_data: Optional[Dict[str, Any]] = None,
retry: bool = True,
) -> Dict[str, Any]:
"""Make HTTP request to WLED device with retry logic.
Args:
method: HTTP method (GET, POST, etc.)
endpoint: API endpoint
json_data: JSON data for request body
retry: Whether to retry on failure
Returns:
Response JSON data
Raises:
RuntimeError: If request fails after retries
"""
if not self._client:
raise RuntimeError("Client not connected. Call connect() first.")
url = f"{self.url}{endpoint}"
attempts = self.retry_attempts if retry else 1
for attempt in range(attempts):
try:
if method == "GET":
response = await self._client.get(url)
elif method == "POST":
response = await self._client.post(url, json=json_data)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error {e.response.status_code} on attempt {attempt + 1}: {e}")
if attempt < attempts - 1:
await asyncio.sleep(self.retry_delay)
else:
raise RuntimeError(f"HTTP request failed: {e}")
except httpx.RequestError as e:
logger.error(f"Request error on attempt {attempt + 1}: {e}")
if attempt < attempts - 1:
await asyncio.sleep(self.retry_delay)
else:
self._connected = False
raise RuntimeError(f"Request to WLED device failed: {e}")
except Exception as e:
logger.error(f"Unexpected error on attempt {attempt + 1}: {e}")
if attempt < attempts - 1:
await asyncio.sleep(self.retry_delay)
else:
raise RuntimeError(f"WLED request failed: {e}")
raise RuntimeError("Request failed after all retry attempts")
async def get_info(self) -> WLEDInfo:
"""Get WLED device information.
Returns:
WLEDInfo object with device details
Raises:
RuntimeError: If request fails
"""
try:
data = await self._request("GET", "/json/info")
return WLEDInfo(
name=data.get("name", "Unknown"),
version=data.get("ver", "Unknown"),
led_count=data.get("leds", {}).get("count", 0),
brand=data.get("brand", "WLED"),
product=data.get("product", "FOSS"),
mac=data.get("mac", ""),
ip=data.get("ip", ""),
)
except Exception as e:
logger.error(f"Failed to get device info: {e}")
raise
async def get_state(self) -> Dict[str, Any]:
"""Get current WLED device state.
Returns:
State dictionary
Raises:
RuntimeError: If request fails
"""
try:
return await self._request("GET", "/json/state")
except Exception as e:
logger.error(f"Failed to get device state: {e}")
raise
async def send_pixels(
self,
pixels: List[Tuple[int, int, int]],
brightness: int = 255,
segment_id: int = 0,
) -> bool:
"""Send pixel colors to WLED device.
Args:
pixels: List of (R, G, B) tuples for each LED
brightness: Global brightness (0-255)
segment_id: Segment ID to update
Returns:
True if successful
Raises:
ValueError: If pixel values are invalid
RuntimeError: If request fails
"""
# Validate inputs
if not pixels:
raise ValueError("Pixels list cannot be empty")
if not 0 <= brightness <= 255:
raise ValueError(f"Brightness must be 0-255, got {brightness}")
# Validate pixel values
for i, (r, g, b) in enumerate(pixels):
if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
raise ValueError(f"Invalid RGB values at index {i}: ({r}, {g}, {b})")
# Build WLED JSON state
payload = {
"on": True,
"bri": brightness,
"seg": [
{
"id": segment_id,
"i": pixels, # Individual LED colors
}
],
}
try:
await self._request("POST", "/json/state", json_data=payload)
logger.debug(f"Sent {len(pixels)} pixel colors to WLED device")
return True
except Exception as e:
logger.error(f"Failed to send pixels: {e}")
raise
async def set_power(self, on: bool) -> bool:
"""Turn WLED device on or off.
Args:
on: True to turn on, False to turn off
Returns:
True if successful
Raises:
RuntimeError: If request fails
"""
payload = {"on": on}
try:
await self._request("POST", "/json/state", json_data=payload)
logger.info(f"Set WLED power: {'ON' if on else 'OFF'}")
return True
except Exception as e:
logger.error(f"Failed to set power: {e}")
raise
async def set_brightness(self, brightness: int) -> bool:
"""Set global brightness.
Args:
brightness: Brightness value (0-255)
Returns:
True if successful
Raises:
ValueError: If brightness is out of range
RuntimeError: If request fails
"""
if not 0 <= brightness <= 255:
raise ValueError(f"Brightness must be 0-255, got {brightness}")
payload = {"bri": brightness}
try:
await self._request("POST", "/json/state", json_data=payload)
logger.debug(f"Set brightness to {brightness}")
return True
except Exception as e:
logger.error(f"Failed to set brightness: {e}")
raise
async def test_connection(self) -> bool:
"""Test connection to WLED device.
Returns:
True if device is reachable
Raises:
RuntimeError: If connection test fails
"""
try:
await self.get_info()
return True
except Exception as e:
logger.error(f"Connection test failed: {e}")
raise
async def send_test_pattern(self, led_count: int, duration: float = 2.0):
"""Send a test pattern to verify LED configuration.
Cycles through red, green, blue on all LEDs.
Args:
led_count: Number of LEDs
duration: Duration for each color in seconds
Raises:
RuntimeError: If test pattern fails
"""
logger.info(f"Sending test pattern to {led_count} LEDs")
try:
# Red
pixels = [(255, 0, 0)] * led_count
await self.send_pixels(pixels)
await asyncio.sleep(duration)
# Green
pixels = [(0, 255, 0)] * led_count
await self.send_pixels(pixels)
await asyncio.sleep(duration)
# Blue
pixels = [(0, 0, 255)] * led_count
await self.send_pixels(pixels)
await asyncio.sleep(duration)
# Off
pixels = [(0, 0, 0)] * led_count
await self.send_pixels(pixels)
logger.info("Test pattern complete")
except Exception as e:
logger.error(f"Test pattern failed: {e}")
raise