feat: HA light output targets — cast LED colors to Home Assistant lights
Lint & Test / test (push) Has been cancelled

New output target type `ha_light` that sends averaged LED colors to HA
light entities via WebSocket service calls (light.turn_on/turn_off):

Backend:
- HARuntime.call_service(): fire-and-forget WS service calls
- HALightOutputTarget: data model with light mappings, update rate, transition
- HALightTargetProcessor: processing loop with delta detection, rate limiting
- ProcessorManager.add_ha_light_target(): registration
- API schemas/routes updated for ha_light target type

Frontend:
- HA Light Targets section in Targets tab tree nav
- Modal editor: HA source picker, CSS source picker, light entity mappings
- Target cards with start/stop/clone/edit actions
- i18n keys for all new UI strings
This commit is contained in:
2026-03-28 00:08:49 +03:00
parent fb98e6e2b8
commit cb9289f01f
25 changed files with 1679 additions and 164 deletions
@@ -22,10 +22,18 @@ class KeyColorsSettingsSchema(BaseModel):
"""Settings for key colors extraction."""
fps: int = Field(default=10, description="Extraction rate (1-60)", ge=1, le=60)
interpolation_mode: str = Field(default="average", description="Color mode (average, median, dominant)")
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
pattern_template_id: str = Field(default="", description="Pattern template ID for rectangle layout")
brightness: float = Field(default=1.0, description="Output brightness (0.0-1.0)", ge=0.0, le=1.0)
interpolation_mode: str = Field(
default="average", description="Color mode (average, median, dominant)"
)
smoothing: float = Field(
default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0
)
pattern_template_id: str = Field(
default="", description="Pattern template ID for rectangle layout"
)
brightness: float = Field(
default=1.0, description="Output brightness (0.0-1.0)", ge=0.0, le=1.0
)
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
@@ -46,24 +54,79 @@ class KeyColorsResponse(BaseModel):
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
class HALightMappingSchema(BaseModel):
"""Maps an LED range to one HA light entity."""
entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')")
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
brightness_scale: float = Field(
default=1.0, ge=0.0, le=1.0, description="Brightness multiplier"
)
class OutputTargetCreate(BaseModel):
"""Request to create an output target."""
name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="led", description="Target type (led, key_colors)")
target_type: str = Field(default="led", description="Target type (led, key_colors, ha_light)")
# LED target fields
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
min_brightness_threshold: int = Field(default=0, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
protocol: str = Field(default="ddp", pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)")
keepalive_interval: float = Field(
default=1.0,
description="Keepalive send interval when screen is static (0.5-5.0s)",
ge=0.5,
le=5.0,
)
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL,
description="Device health check interval (5-600s)",
ge=5,
le=600,
)
min_brightness_threshold: int = Field(
default=0,
ge=0,
le=254,
description="Min brightness threshold (0=disabled); below this → off",
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
)
protocol: str = Field(
default="ddp",
pattern="^(ddp|http)$",
description="Send protocol: ddp (UDP) or http (JSON API)",
)
# KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
picture_source_id: str = Field(
default="", description="Picture source ID (for key_colors targets)"
)
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
None, description="Key colors settings (for key_colors targets)"
)
# HA light target fields
ha_source_id: str = Field(
default="", description="Home Assistant source ID (for ha_light targets)"
)
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)"
)
update_rate: float = Field(
default=2.0, ge=0.5, le=5.0, description="Service call rate in Hz (for ha_light targets)"
)
transition: float = Field(
default=0.5, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
)
color_tolerance: int = Field(
default=5,
ge=0,
le=50,
description="Skip service call if RGB delta < this (for ha_light targets)",
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -75,16 +138,48 @@ class OutputTargetUpdate(BaseModel):
# LED target fields
device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
brightness_value_source_id: Optional[str] = Field(None, description="Brightness value source ID")
brightness_value_source_id: Optional[str] = Field(
None, description="Brightness value source ID"
)
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
min_brightness_threshold: Optional[int] = Field(None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
adaptive_fps: Optional[bool] = Field(None, description="Auto-reduce FPS when device is unresponsive")
protocol: Optional[str] = Field(None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)")
keepalive_interval: Optional[float] = Field(
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
)
state_check_interval: Optional[int] = Field(
None, description="Health check interval (5-600s)", ge=5, le=600
)
min_brightness_threshold: Optional[int] = Field(
None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off"
)
adaptive_fps: Optional[bool] = Field(
None, description="Auto-reduce FPS when device is unresponsive"
)
protocol: Optional[str] = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
)
# KC target fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
picture_source_id: Optional[str] = Field(
None, description="Picture source ID (for key_colors targets)"
)
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
None, description="Key colors settings (for key_colors targets)"
)
# HA light target fields
ha_source_id: Optional[str] = Field(
None, description="Home Assistant source ID (for ha_light targets)"
)
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (for ha_light targets)"
)
update_rate: Optional[float] = Field(
None, ge=0.5, le=5.0, description="Service call rate Hz (for ha_light targets)"
)
transition: Optional[float] = Field(
None, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
)
color_tolerance: Optional[int] = Field(
None, ge=0, le=50, description="RGB delta tolerance (for ha_light targets)"
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
@@ -101,13 +196,29 @@ class OutputTargetResponse(BaseModel):
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
fps: Optional[int] = Field(None, description="Target send FPS")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
min_brightness_threshold: int = Field(default=0, description="Min brightness threshold (0=disabled)")
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
)
min_brightness_threshold: int = Field(
default=0, description="Min brightness threshold (0=disabled)"
)
adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive"
)
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
# KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
None, description="Key colors settings"
)
# HA light target fields
ha_source_id: str = Field(default="", description="Home Assistant source ID (ha_light)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
None, description="LED-to-light mappings (ha_light)"
)
update_rate: Optional[float] = Field(None, description="Service call rate Hz (ha_light)")
ha_transition: Optional[float] = Field(None, description="HA transition seconds (ha_light)")
color_tolerance: Optional[int] = Field(None, description="RGB delta tolerance (ha_light)")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
@@ -129,23 +240,39 @@ class TargetProcessingState(BaseModel):
color_strip_source_id: str = Field(default="", description="Color strip source ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
fps_potential: Optional[float] = Field(
None, description="Potential FPS (processing speed without throttle)"
)
fps_target: Optional[int] = Field(None, description="Target FPS")
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
frames_keepalive: Optional[int] = Field(
None, description="Keepalive frames sent during standby"
)
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
timing_extract_ms: Optional[float] = Field(None, description="Border pixel extraction time (ms)")
timing_extract_ms: Optional[float] = Field(
None, description="Border pixel extraction time (ms)"
)
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
timing_total_ms: Optional[float] = Field(None, description="Total processing time per frame (ms)")
timing_total_ms: Optional[float] = Field(
None, description="Total processing time per frame (ms)"
)
timing_audio_read_ms: Optional[float] = Field(None, description="Audio device read time (ms)")
timing_audio_fft_ms: Optional[float] = Field(None, description="Audio FFT analysis time (ms)")
timing_audio_render_ms: Optional[float] = Field(None, description="Audio visualization render time (ms)")
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
timing_audio_render_ms: Optional[float] = Field(
None, description="Audio visualization render time (ms)"
)
timing_calc_colors_ms: Optional[float] = Field(
None, description="Color calculation time (ms, KC targets)"
)
timing_broadcast_ms: Optional[float] = Field(
None, description="WebSocket broadcast time (ms, KC targets)"
)
display_index: Optional[int] = Field(None, description="Current display index")
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
overlay_active: bool = Field(
default=False, description="Whether visualization overlay is active"
)
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
device_online: bool = Field(default=False, description="Whether device is reachable")
@@ -154,11 +281,17 @@ class TargetProcessingState(BaseModel):
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
device_led_type: Optional[str] = Field(
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
)
device_fps: Optional[int] = Field(
None, description="Device-reported FPS (WLED internal refresh rate)"
)
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
device_streaming_reachable: Optional[bool] = Field(None, description="Device reachable during streaming (HTTP probe)")
device_streaming_reachable: Optional[bool] = Field(
None, description="Device reachable during streaming (HTTP probe)"
)
fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction")
@@ -186,9 +319,15 @@ class BulkTargetRequest(BaseModel):
class BulkTargetResponse(BaseModel):
"""Response for bulk start/stop operations."""
started: List[str] = Field(default_factory=list, description="IDs that were successfully started")
stopped: List[str] = Field(default_factory=list, description="IDs that were successfully stopped")
errors: Dict[str, str] = Field(default_factory=dict, description="Map of target ID to error message for failures")
started: List[str] = Field(
default_factory=list, description="IDs that were successfully started"
)
stopped: List[str] = Field(
default_factory=list, description="IDs that were successfully stopped"
)
errors: Dict[str, str] = Field(
default_factory=dict, description="Map of target ID to error message for failures"
)
class KCTestRectangleResponse(BaseModel):
@@ -206,6 +345,8 @@ class KCTestResponse(BaseModel):
"""Response from testing a KC target."""
image: str = Field(description="Base64 data URI of the captured frame")
rectangles: List[KCTestRectangleResponse] = Field(description="Rectangles with extracted colors")
rectangles: List[KCTestRectangleResponse] = Field(
description="Rectangles with extracted colors"
)
interpolation_mode: str = Field(description="Color extraction mode used")
pattern_template_name: str = Field(description="Pattern template name")