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,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
],
}