Initial commit: WLED Screen Controller with FastAPI server and Home Assistant integration
Some checks failed
Validate / validate (push) Failing after 1m6s
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:
17
server/src/wled_controller/core/__init__.py
Normal file
17
server/src/wled_controller/core/__init__.py
Normal 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",
|
||||
]
|
||||
344
server/src/wled_controller/core/calibration.py
Normal file
344
server/src/wled_controller/core/calibration.py
Normal 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
|
||||
],
|
||||
}
|
||||
166
server/src/wled_controller/core/pixel_processor.py
Normal file
166
server/src/wled_controller/core/pixel_processor.py
Normal 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
|
||||
452
server/src/wled_controller/core/processor_manager.py
Normal file
452
server/src/wled_controller/core/processor_manager.py
Normal 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")
|
||||
329
server/src/wled_controller/core/screen_capture.py
Normal file
329
server/src/wled_controller/core/screen_capture.py
Normal 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)
|
||||
368
server/src/wled_controller/core/wled_client.py
Normal file
368
server/src/wled_controller/core/wled_client.py
Normal 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
|
||||
Reference in New Issue
Block a user