feat: HA light output targets — cast LED colors to Home Assistant lights
Some checks failed
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

View File

@@ -25,6 +25,11 @@ from wled_controller.storage.key_colors_output_target import (
KeyColorsSettings,
KeyColorsOutputTarget,
)
from wled_controller.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
)
from wled_controller.api.schemas.output_targets import HALightMappingSchema
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
@@ -76,7 +81,6 @@ def _target_to_response(target) -> OutputTargetResponse:
protocol=target.protocol,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
@@ -89,7 +93,31 @@ def _target_to_response(target) -> OutputTargetResponse:
key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
elif isinstance(target, HALightOutputTarget):
return OutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
ha_source_id=target.ha_source_id,
color_strip_source_id=target.color_strip_source_id,
ha_light_mappings=[
HALightMappingSchema(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale,
)
for m in target.light_mappings
],
update_rate=target.update_rate,
ha_transition=target.transition,
color_tolerance=target.color_tolerance,
min_brightness_threshold=target.min_brightness_threshold,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
@@ -100,7 +128,6 @@ def _target_to_response(target) -> OutputTargetResponse:
target_type=target.target_type,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
@@ -108,7 +135,10 @@ def _target_to_response(target) -> OutputTargetResponse:
# ===== CRUD ENDPOINTS =====
@router.post("/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201)
@router.post(
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
)
async def create_target(
data: OutputTargetCreate,
_auth: AuthRequired,
@@ -125,7 +155,22 @@ async def create_target(
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
kc_settings = (
_kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
)
ha_mappings = (
[
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale,
)
for m in data.ha_light_mappings
]
if data.ha_light_mappings
else None
)
# Create in store
target = target_store.create_target(
@@ -144,6 +189,11 @@ async def create_target(
key_colors_settings=kc_settings,
description=data.description,
tags=data.tags,
ha_source_id=data.ha_source_id,
ha_light_mappings=ha_mappings,
update_rate=data.update_rate,
transition=data.transition,
color_tolerance=data.color_tolerance,
)
# Register in processor manager
@@ -196,7 +246,9 @@ async def batch_target_metrics(
return {"metrics": manager.get_all_target_metrics()}
@router.get("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"])
@router.get(
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
)
async def get_target(
target_id: str,
_auth: AuthRequired,
@@ -210,7 +262,9 @@ async def get_target(
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"])
@router.put(
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
)
async def update_target(
target_id: str,
data: OutputTargetUpdate,
@@ -246,7 +300,9 @@ async def update_target(
smoothing=incoming.get("smoothing", ex.smoothing),
pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id),
brightness=incoming.get("brightness", ex.brightness),
brightness_value_source_id=incoming.get("brightness_value_source_id", ex.brightness_value_source_id),
brightness_value_source_id=incoming.get(
"brightness_value_source_id", ex.brightness_value_source_id
),
)
kc_settings = _kc_schema_to_settings(merged)
else:
@@ -282,14 +338,18 @@ async def update_target(
await asyncio.to_thread(
target.sync_with_manager,
manager,
settings_changed=(data.fps is not None or
data.keepalive_interval is not None or
data.state_check_interval is not None or
data.min_brightness_threshold is not None or
data.adaptive_fps is not None or
data.key_colors_settings is not None),
settings_changed=(
data.fps is not None
or data.keepalive_interval is not None
or data.state_check_interval is not None
or data.min_brightness_threshold is not None
or data.adaptive_fps is not None
or data.key_colors_settings is not None
),
css_changed=data.color_strip_source_id is not None,
brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed),
brightness_vs_changed=(
data.brightness_value_source_id is not None or kc_brightness_vs_changed
),
)
except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e)

View File

@@ -10,6 +10,8 @@ import sys
from datetime import datetime, timezone
from typing import Optional
import os
import psutil
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -52,8 +54,10 @@ from wled_controller.api.routes.system_settings import load_external_url # noqa
logger = get_logger(__name__)
# Prime psutil CPU counter (first call always returns 0.0)
# Prime psutil CPU counters (first call always returns 0.0)
psutil.cpu_percent(interval=None)
_process = psutil.Process(os.getpid())
_process.cpu_percent(interval=None) # prime process-level counter
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from wled_controller.utils.gpu import ( # noqa: E402
@@ -264,18 +268,36 @@ def get_system_performance(_: AuthRequired):
"""
mem = psutil.virtual_memory()
# App-level metrics
proc_mem = _process.memory_info()
app_cpu = _process.cpu_percent(interval=None)
app_ram_mb = round(proc_mem.rss / 1024 / 1024, 1)
gpu = None
if _nvml_available:
try:
util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle)
mem_info = _nvml.nvmlDeviceGetMemoryInfo(_nvml_handle)
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
# App GPU memory: sum memory used by this process on the GPU
app_gpu_mem: float | None = None
try:
pid = os.getpid()
for proc_info in _nvml.nvmlDeviceGetComputeRunningProcesses(_nvml_handle):
if proc_info.pid == pid and proc_info.usedGpuMemory:
app_gpu_mem = round(proc_info.usedGpuMemory / 1024 / 1024, 1)
break
except Exception:
pass # not all drivers support per-process queries
gpu = GpuInfo(
name=_nvml.nvmlDeviceGetName(_nvml_handle),
utilization=float(util.gpu),
memory_used_mb=round(mem_info.used / 1024 / 1024, 1),
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
temperature_c=float(temp),
app_memory_mb=app_gpu_mem,
)
except Exception as e:
logger.debug("NVML query failed: %s", e)
@@ -286,6 +308,8 @@ def get_system_performance(_: AuthRequired):
ram_used_mb=round(mem.used / 1024 / 1024, 1),
ram_total_mb=round(mem.total / 1024 / 1024, 1),
ram_percent=mem.percent,
app_cpu_percent=app_cpu,
app_ram_mb=app_ram_mb,
gpu=gpu,
timestamp=datetime.now(timezone.utc),
)