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),
)

View File

@@ -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")

View File

@@ -64,6 +64,9 @@ class GpuInfo(BaseModel):
memory_used_mb: float | None = Field(default=None, description="GPU memory used in MB")
memory_total_mb: float | None = Field(default=None, description="GPU total memory in MB")
temperature_c: float | None = Field(default=None, description="GPU temperature in Celsius")
app_memory_mb: float | None = Field(
default=None, description="GPU memory used by this app in MB"
)
class PerformanceResponse(BaseModel):
@@ -74,6 +77,8 @@ class PerformanceResponse(BaseModel):
ram_used_mb: float = Field(description="RAM used in MB")
ram_total_mb: float = Field(description="RAM total in MB")
ram_percent: float = Field(description="RAM usage percent")
app_cpu_percent: float = Field(description="App process CPU usage percent")
app_ram_mb: float = Field(description="App process resident memory in MB")
gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)")
timestamp: datetime = Field(description="Measurement timestamp")

View File

@@ -87,6 +87,16 @@ class HomeAssistantManager:
runtime, _count = self._runtimes[source_id]
return runtime
async def call_service(
self, source_id: str, domain: str, service: str, service_data: dict, target: dict
) -> bool:
"""Call a HA service via the runtime for the given source. Returns success."""
entry = self._runtimes.get(source_id)
if entry is None:
return False
runtime, _count = entry
return await runtime.call_service(domain, service, service_data, target)
async def update_source(self, source_id: str) -> None:
"""Hot-update runtime config when the source is edited."""
entry = self._runtimes.get(source_id)

View File

@@ -54,6 +54,7 @@ class HARuntime:
# Async task management
self._task: Optional[asyncio.Task] = None
self._ws: Any = None # live websocket connection (set during _connection_loop)
self._connected = False
self._msg_id = 0
@@ -88,6 +89,34 @@ class HARuntime:
if not self._callbacks[entity_id]:
del self._callbacks[entity_id]
async def call_service(
self, domain: str, service: str, service_data: dict, target: dict
) -> bool:
"""Call a HA service (e.g. light.turn_on). Fire-and-forget.
Returns True if the message was sent, False if not connected.
"""
if not self._connected or self._ws is None:
return False
try:
msg_id = self._next_id()
await self._ws.send(
json.dumps(
{
"id": msg_id,
"type": "call_service",
"domain": domain,
"service": service,
"service_data": service_data,
"target": target,
}
)
)
return True
except Exception as e:
logger.debug(f"HA call_service failed ({domain}.{service}): {e}")
return False
async def start(self) -> None:
"""Start the WebSocket connection loop."""
if self._task is not None:
@@ -161,6 +190,7 @@ class HARuntime:
await asyncio.sleep(self._RECONNECT_DELAY)
continue
self._ws = ws
self._connected = True
logger.info(
f"HA connected: {self._source_id} (version {msg.get('ha_version', '?')})"
@@ -204,8 +234,10 @@ class HARuntime:
self._handle_state_changed(event.get("data", {}))
except asyncio.CancelledError:
self._ws = None
break
except Exception as e:
self._ws = None
self._connected = False
logger.warning(
f"HA connection lost ({self._source_id}): {e}. Reconnecting in {self._RECONNECT_DELAY}s..."

View File

@@ -0,0 +1,260 @@
"""Home Assistant light target processor — casts LED colors to HA lights.
Reads from a ColorStripStream, averages LED segments to single RGB values,
and calls light.turn_on / light.turn_off via the HA WebSocket connection.
Rate-limited to update_rate Hz (typically 1-5 Hz).
"""
import asyncio
import time
from typing import Dict, List, Optional, Tuple
import numpy as np
from wled_controller.core.processing.target_processor import TargetContext, TargetProcessor
from wled_controller.storage.ha_light_output_target import HALightMapping
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class HALightTargetProcessor(TargetProcessor):
"""Streams averaged LED colors to Home Assistant light entities."""
def __init__(
self,
target_id: str,
ha_source_id: str,
color_strip_source_id: str = "",
light_mappings: Optional[List[HALightMapping]] = None,
update_rate: float = 2.0,
transition: float = 0.5,
min_brightness_threshold: int = 0,
color_tolerance: int = 5,
ctx: Optional[TargetContext] = None,
):
super().__init__(target_id, ctx)
self._ha_source_id = ha_source_id
self._css_id = color_strip_source_id
self._light_mappings = light_mappings or []
self._update_rate = max(0.5, min(5.0, update_rate))
self._transition = transition
self._min_brightness_threshold = min_brightness_threshold
self._color_tolerance = color_tolerance
# Runtime state
self._css_stream = None
self._ha_runtime = None
self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
self._previous_on: Dict[str, bool] = {} # track on/off state per entity
self._start_time: Optional[float] = None
@property
def device_id(self) -> Optional[str]:
return None # HA light targets don't use device providers
async def start(self) -> None:
if self._is_running:
return
# Acquire CSS stream
if self._css_id and self._ctx.color_strip_stream_manager:
try:
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
self._css_id, self._target_id
)
except Exception as e:
logger.warning(f"HA light {self._target_id}: failed to acquire CSS stream: {e}")
# Acquire HA runtime
try:
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
ha_manager: Optional[HomeAssistantManager] = getattr(self._ctx, "ha_manager", None)
if ha_manager:
self._ha_runtime = await ha_manager.acquire(self._ha_source_id)
except Exception as e:
logger.warning(f"HA light {self._target_id}: failed to acquire HA runtime: {e}")
self._is_running = True
self._start_time = time.monotonic()
self._task = asyncio.create_task(self._processing_loop())
logger.info(f"HA light target started: {self._target_id}")
async def stop(self) -> None:
self._is_running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
# Release CSS stream
if self._css_stream and self._ctx.color_strip_stream_manager:
try:
self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id)
except Exception:
pass
self._css_stream = None
# Release HA runtime
if self._ha_runtime:
try:
ha_manager = getattr(self._ctx, "ha_manager", None)
if ha_manager:
await ha_manager.release(self._ha_source_id)
except Exception:
pass
self._ha_runtime = None
self._previous_colors.clear()
self._previous_on.clear()
logger.info(f"HA light target stopped: {self._target_id}")
def update_settings(self, settings) -> None:
if isinstance(settings, dict):
if "update_rate" in settings:
self._update_rate = max(0.5, min(5.0, float(settings["update_rate"])))
if "transition" in settings:
self._transition = float(settings["transition"])
if "min_brightness_threshold" in settings:
self._min_brightness_threshold = int(settings["min_brightness_threshold"])
if "color_tolerance" in settings:
self._color_tolerance = int(settings["color_tolerance"])
if "light_mappings" in settings:
self._light_mappings = settings["light_mappings"]
def update_css_source(self, color_strip_source_id: str) -> None:
"""Hot-swap the CSS stream."""
old_id = self._css_id
self._css_id = color_strip_source_id
if self._is_running and self._ctx.color_strip_stream_manager:
try:
new_stream = self._ctx.color_strip_stream_manager.acquire(
color_strip_source_id, self._target_id
)
old_stream = self._css_stream
self._css_stream = new_stream
if old_stream:
self._ctx.color_strip_stream_manager.release(old_id, self._target_id)
except Exception as e:
logger.warning(f"HA light {self._target_id}: CSS swap failed: {e}")
def get_state(self) -> dict:
return {
"target_id": self._target_id,
"ha_source_id": self._ha_source_id,
"css_id": self._css_id,
"is_running": self._is_running,
"ha_connected": self._ha_runtime.is_connected if self._ha_runtime else False,
"light_count": len(self._light_mappings),
"update_rate": self._update_rate,
}
def get_metrics(self) -> dict:
return {
"target_id": self._target_id,
"uptime": time.monotonic() - self._start_time if self._start_time else 0,
"update_rate": self._update_rate,
}
async def _processing_loop(self) -> None:
"""Main loop: read CSS colors, average per mapping, send to HA lights."""
interval = 1.0 / self._update_rate
while self._is_running:
try:
loop_start = time.monotonic()
if self._css_stream and self._ha_runtime and self._ha_runtime.is_connected:
colors = self._css_stream.get_latest_colors()
if colors is not None and len(colors) > 0:
await self._update_lights(colors)
# Sleep for remaining frame time
elapsed = time.monotonic() - loop_start
sleep_time = max(0.05, interval - elapsed)
await asyncio.sleep(sleep_time)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"HA light {self._target_id} loop error: {e}")
await asyncio.sleep(1.0)
async def _update_lights(self, colors: np.ndarray) -> None:
"""Average LED segments and call HA services for changed lights."""
led_count = len(colors)
for mapping in self._light_mappings:
if not mapping.entity_id:
continue
# Resolve LED range
start = max(0, mapping.led_start)
end = mapping.led_end if mapping.led_end >= 0 else led_count
end = min(end, led_count)
if start >= end:
continue
# Average the LED segment
segment = colors[start:end]
avg = segment.mean(axis=0).astype(int)
r, g, b = int(avg[0]), int(avg[1]), int(avg[2])
# Calculate brightness (0-255) from max channel
brightness = max(r, g, b)
# Apply brightness scale
if mapping.brightness_scale < 1.0:
brightness = int(brightness * mapping.brightness_scale)
# Check brightness threshold
should_be_on = (
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
)
entity_id = mapping.entity_id
prev_color = self._previous_colors.get(entity_id)
was_on = self._previous_on.get(entity_id, True)
if should_be_on:
# Check if color changed beyond tolerance
new_color = (r, g, b)
if prev_color is not None and was_on:
dr = abs(r - prev_color[0])
dg = abs(g - prev_color[1])
db = abs(b - prev_color[2])
if max(dr, dg, db) < self._color_tolerance:
continue # skip — color hasn't changed enough
# Call light.turn_on
service_data = {
"rgb_color": [r, g, b],
"brightness": min(255, int(brightness * mapping.brightness_scale)),
}
if self._transition > 0:
service_data["transition"] = self._transition
await self._ha_runtime.call_service(
domain="light",
service="turn_on",
service_data=service_data,
target={"entity_id": entity_id},
)
self._previous_colors[entity_id] = new_color
self._previous_on[entity_id] = True
elif was_on:
# Brightness dropped below threshold — turn off
await self._ha_runtime.call_service(
domain="light",
service="turn_off",
service_data={},
target={"entity_id": entity_id},
)
self._previous_on[entity_id] = False
self._previous_colors.pop(entity_id, None)

View File

@@ -1,6 +1,7 @@
"""Server-side ring buffer for system and per-target metrics."""
import asyncio
import os
from collections import deque
from datetime import datetime, timezone
from typing import Dict, Optional
@@ -8,7 +9,11 @@ from typing import Dict, Optional
import psutil
from wled_controller.utils import get_logger
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
from wled_controller.utils.gpu import (
nvml_available as _nvml_available,
nvml as _nvml,
nvml_handle as _nvml_handle,
)
logger = get_logger(__name__)
@@ -16,20 +21,28 @@ MAX_SAMPLES = 120 # ~2 minutes at 1-second interval
SAMPLE_INTERVAL = 1.0 # seconds
_process = psutil.Process(os.getpid())
_process.cpu_percent(interval=None) # prime process-level counter
def _collect_system_snapshot() -> dict:
"""Collect CPU/RAM/GPU metrics (blocking — run in thread pool).
Returns a dict suitable for direct JSON serialization.
"""
mem = psutil.virtual_memory()
proc_mem = _process.memory_info()
snapshot = {
"t": datetime.now(timezone.utc).isoformat(),
"cpu": psutil.cpu_percent(interval=None),
"ram_pct": mem.percent,
"ram_used": round(mem.used / 1024 / 1024, 1),
"ram_total": round(mem.total / 1024 / 1024, 1),
"app_cpu": _process.cpu_percent(interval=None),
"app_ram": round(proc_mem.rss / 1024 / 1024, 1),
"gpu_util": None,
"gpu_temp": None,
"app_gpu_mem": None,
}
try:
@@ -38,6 +51,14 @@ def _collect_system_snapshot() -> dict:
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
snapshot["gpu_util"] = float(util.gpu)
snapshot["gpu_temp"] = float(temp)
try:
pid = os.getpid()
for proc_info in _nvml.nvmlDeviceGetComputeRunningProcesses(_nvml_handle):
if proc_info.pid == pid and proc_info.usedGpuMemory:
snapshot["app_gpu_mem"] = round(proc_info.usedGpuMemory / 1024 / 1024, 1)
break
except Exception:
pass
except Exception as e:
logger.debug("GPU metrics collection failed: %s", e)
pass
@@ -104,14 +125,16 @@ class MetricsHistory:
if target_id not in self._targets:
self._targets[target_id] = deque(maxlen=MAX_SAMPLES)
if state.get("processing"):
self._targets[target_id].append({
"t": now,
"fps": state.get("fps_actual"),
"fps_current": state.get("fps_current"),
"fps_target": state.get("fps_target"),
"timing": state.get("timing_total_ms"),
"errors": state.get("errors_count", 0),
})
self._targets[target_id].append(
{
"t": now,
"fps": state.get("fps_actual"),
"fps_current": state.get("fps_current"),
"fps_target": state.get("fps_target"),
"timing": state.get("timing_total_ms"),
"errors": state.get("errors_count", 0),
}
)
# Prune deques for targets no longer registered
for tid in list(self._targets.keys()):

View File

@@ -3,7 +3,7 @@
import asyncio
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
import httpx
@@ -31,7 +31,9 @@ from wled_controller.core.processing.auto_restart import (
from wled_controller.storage import DeviceStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
)
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
@@ -72,6 +74,7 @@ class ProcessorDependencies:
gradient_store: Optional[GradientStore] = None
weather_manager: Optional[WeatherManager] = None
asset_store: Optional[AssetStore] = None
ha_manager: Optional[Any] = None # HomeAssistantManager
@dataclass
@@ -134,7 +137,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
self._value_source_store = deps.value_source_store
self._cspt_store = deps.cspt_store
self._live_stream_manager = LiveStreamManager(
deps.picture_source_store, deps.capture_template_store, deps.pp_template_store,
deps.picture_source_store,
deps.capture_template_store,
deps.pp_template_store,
asset_store=deps.asset_store,
)
self._audio_capture_manager = AudioCaptureManager()
@@ -151,15 +156,20 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
weather_manager=deps.weather_manager,
asset_store=deps.asset_store,
)
self._value_stream_manager = ValueStreamManager(
value_source_store=deps.value_source_store,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=deps.audio_source_store,
live_stream_manager=self._live_stream_manager,
audio_template_store=deps.audio_template_store,
) if deps.value_source_store else None
self._value_stream_manager = (
ValueStreamManager(
value_source_store=deps.value_source_store,
audio_capture_manager=self._audio_capture_manager,
audio_source_store=deps.audio_source_store,
live_stream_manager=self._live_stream_manager,
audio_template_store=deps.audio_template_store,
)
if deps.value_source_store
else None
)
# Wire value stream manager into CSS stream manager for composite layer brightness
self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager
self._ha_manager = deps.ha_manager
self._overlay_manager = OverlayManager()
self._event_queues: List[asyncio.Queue] = []
self._metrics_history = MetricsHistory(self)
@@ -199,15 +209,24 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
cspt_store=self._cspt_store,
fire_event=self.fire_event,
get_device_info=self._get_device_info,
ha_manager=self._ha_manager,
)
# Default values for device-specific fields read from persistent storage
_DEVICE_FIELD_DEFAULTS = {
"send_latency_ms": 0, "rgbw": False, "dmx_protocol": "artnet",
"dmx_start_universe": 0, "dmx_start_channel": 1, "espnow_peer_mac": "",
"espnow_channel": 1, "hue_username": "", "hue_client_key": "",
"hue_entertainment_group_id": "", "spi_speed_hz": 800000,
"spi_led_type": "WS2812B", "chroma_device_type": "chromalink",
"send_latency_ms": 0,
"rgbw": False,
"dmx_protocol": "artnet",
"dmx_start_universe": 0,
"dmx_start_channel": 1,
"espnow_peer_mac": "",
"espnow_channel": 1,
"hue_username": "",
"hue_client_key": "",
"hue_entertainment_group_id": "",
"spi_speed_hz": 800000,
"spi_led_type": "WS2812B",
"chroma_device_type": "chromalink",
"gamesense_device_type": "keyboard",
}
@@ -228,11 +247,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
pass
return DeviceInfo(
device_id=ds.device_id, device_url=ds.device_url,
led_count=ds.led_count, device_type=ds.device_type,
baud_rate=ds.baud_rate, software_brightness=ds.software_brightness,
test_mode_active=ds.test_mode_active, zone_mode=ds.zone_mode,
auto_shutdown=ds.auto_shutdown, **extras,
device_id=ds.device_id,
device_url=ds.device_url,
led_count=ds.led_count,
device_type=ds.device_type,
baud_rate=ds.baud_rate,
software_brightness=ds.software_brightness,
test_mode_active=ds.test_mode_active,
zone_mode=ds.zone_mode,
auto_shutdown=ds.auto_shutdown,
**extras,
)
# ===== EVENT SYSTEM (state change notifications) =====
@@ -314,7 +338,13 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
del self._devices[device_id]
logger.info(f"Unregistered device {device_id}")
def update_device_info(self, device_id: str, device_url: Optional[str] = None, led_count: Optional[int] = None, baud_rate: Optional[int] = None):
def update_device_info(
self,
device_id: str,
device_url: Optional[str] = None,
led_count: Optional[int] = None,
baud_rate: Optional[int] = None,
):
"""Update device connection info."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
@@ -440,6 +470,37 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
self._processors[target_id] = proc
logger.info(f"Registered KC target: {target_id}")
def add_ha_light_target(
self,
target_id: str,
ha_source_id: str,
color_strip_source_id: str = "",
light_mappings=None,
update_rate: float = 2.0,
transition: float = 0.5,
min_brightness_threshold: int = 0,
color_tolerance: int = 5,
) -> None:
"""Register a Home Assistant light target processor."""
if target_id in self._processors:
raise ValueError(f"HA light target {target_id} already registered")
from wled_controller.core.processing.ha_light_target_processor import HALightTargetProcessor
proc = HALightTargetProcessor(
target_id=target_id,
ha_source_id=ha_source_id,
color_strip_source_id=color_strip_source_id,
light_mappings=light_mappings or [],
update_rate=update_rate,
transition=transition,
min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance,
ctx=self._build_context(),
)
self._processors[target_id] = proc
logger.info(f"Registered HA light target: {target_id}")
def remove_target(self, target_id: str):
"""Unregister a target (any type)."""
if target_id not in self._processors:
@@ -499,7 +560,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
await self.start_processing(target_id)
logger.info(
"Hot-switch complete for target %s -> device %s",
target_id, device_id,
target_id,
device_id,
)
def update_target_brightness_vs(self, target_id: str, vs_id: str):
@@ -520,11 +582,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
# Enforce one-target-per-device for device-aware targets
if proc.device_id is not None:
for other_id, other in self._processors.items():
if (
other_id != target_id
and other.device_id == proc.device_id
and other.is_running
):
if other_id != target_id and other.device_id == proc.device_id and other.is_running:
# Stale state guard: if the task is actually finished,
# clean up and allow starting instead of blocking.
task = getattr(other, "_task", None)
@@ -543,7 +601,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
dev = self._device_store.get_device(proc.device_id)
dev_name = dev.name
except ValueError as e:
logger.debug("Device %s not found for name lookup: %s", proc.device_id, e)
logger.debug(
"Device %s not found for name lookup: %s", proc.device_id, e
)
pass
raise RuntimeError(
f"Device '{dev_name}' is already being processed by target {tgt_name}"
@@ -573,9 +633,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
# Attach done callback to detect crashes
if proc._task is not None:
proc._task.add_done_callback(
lambda task, tid=target_id: self._on_task_done(tid, task)
)
proc._task.add_done_callback(lambda task, tid=target_id: self._on_task_done(tid, task))
async def stop_processing(self, target_id: str):
"""Stop processing for a target (any type).
@@ -617,18 +675,20 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
# Merge device health for device-aware targets
if proc.device_id is not None and proc.device_id in self._devices:
h = self._devices[proc.device_id].health
state.update({
"device_online": h.online,
"device_latency_ms": h.latency_ms,
"device_name": h.device_name,
"device_version": h.device_version,
"device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type,
"device_fps": h.device_fps,
"device_last_checked": h.last_checked,
"device_error": h.error,
})
state.update(
{
"device_online": h.online,
"device_latency_ms": h.latency_ms,
"device_name": h.device_name,
"device_version": h.device_version,
"device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type,
"device_fps": h.device_fps,
"device_last_checked": h.last_checked,
"device_error": h.error,
}
)
return state
@@ -676,7 +736,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
"left": [255, 255, 0],
}
async def start_overlay(self, target_id: str, target_name: str = None, calibration=None, display_info=None) -> None:
async def start_overlay(
self, target_id: str, target_name: str = None, calibration=None, display_info=None
) -> None:
proc = self._get_processor(target_id)
if not proc.supports_overlay():
raise ValueError(f"Target {target_id} does not support overlays")
@@ -707,10 +769,15 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
# ===== CSS OVERLAY (direct, no target processor required) =====
async def start_css_overlay(self, css_id: str, display_info, calibration, css_name: str = None) -> None:
async def start_css_overlay(
self, css_id: str, display_info, calibration, css_name: str = None
) -> None:
await asyncio.to_thread(
self._overlay_manager.start_overlay,
css_id, display_info, calibration, css_name,
css_id,
display_info,
calibration,
css_name,
)
async def stop_css_overlay(self, css_id: str) -> None:

View File

@@ -14,7 +14,7 @@ import asyncio
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Callable, Dict, Optional, Tuple
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple
if TYPE_CHECKING:
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
@@ -26,13 +26,16 @@ if TYPE_CHECKING:
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
)
# ---------------------------------------------------------------------------
# Shared dataclasses
# ---------------------------------------------------------------------------
@dataclass
class ProcessingMetrics:
"""Metrics for processing performance."""
@@ -43,7 +46,9 @@ class ProcessingMetrics:
errors_count: int = 0
last_error: Optional[str] = None
last_update: Optional[datetime] = None
last_update_mono: float = 0.0 # monotonic timestamp for hot-path; lazily converted to last_update on read
last_update_mono: float = (
0.0 # monotonic timestamp for hot-path; lazily converted to last_update on read
)
start_time: Optional[datetime] = None
fps_actual: float = 0.0
fps_potential: float = 0.0
@@ -117,12 +122,14 @@ class TargetContext:
cspt_store: Optional["ColorStripProcessingTemplateStore"] = None
fire_event: Callable[[dict], None] = lambda e: None
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
ha_manager: Optional[Any] = None # HomeAssistantManager (avoid circular import)
# ---------------------------------------------------------------------------
# Abstract base class
# ---------------------------------------------------------------------------
class TargetProcessor(ABC):
"""Abstract base class for target processors.

View File

@@ -116,6 +116,7 @@ processor_manager = ProcessorManager(
gradient_store=gradient_store,
weather_manager=weather_manager,
asset_store=asset_store,
ha_manager=ha_manager,
)
)

View File

@@ -432,3 +432,38 @@
color: var(--text-secondary);
font-size: 0.8rem;
}
.perf-mode-toggle {
display: inline-flex;
gap: 0;
margin-left: auto;
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.perf-mode-btn {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 2px 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.perf-mode-btn:not(:last-child) {
border-right: 1px solid var(--border-color);
}
.perf-mode-btn:hover {
background: var(--hover-bg);
}
.perf-mode-btn.active {
background: var(--primary-color);
color: #fff;
}

View File

@@ -51,7 +51,7 @@ import {
import { startEventsWS, stopEventsWS } from './core/events-ws.ts';
import { startEntityEventListeners } from './core/entity-events.ts';
import {
startPerfPolling, stopPerfPolling,
startPerfPolling, stopPerfPolling, setPerfMode,
} from './features/perf-charts.ts';
import {
loadPictureSources, switchStreamTab,
@@ -290,6 +290,7 @@ Object.assign(window, {
stopUptimeTimer,
startPerfPolling,
stopPerfPolling,
setPerfMode,
// streams / capture templates / PP templates
loadPictureSources,

View File

@@ -6,7 +6,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval,
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts';
import { startAutoRefresh, updateTabBadge } from './tabs.ts';
import {
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
@@ -511,7 +511,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${pollSelect}<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
if (isFirstLoad) {
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section">
${_sectionHeader('perf', t('dashboard.section.performance'), '')}
${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())}
${_sectionContent('perf', renderPerfSection())}
</div>
<div class="dashboard-dynamic">${dynamicHtml}</div>`;

View File

@@ -0,0 +1,375 @@
/**
* HA Light Targets — editor, cards, CRUD for Home Assistant light output targets.
*/
import { _cachedHASources, haSourcesCache, colorStripSourcesCache, outputTargetsCache } from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { getColorStripIcon } from '../core/icons.ts';
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Modal ──
let _haLightTagsInput: TagInput | null = null;
let _haSourceEntitySelect: EntitySelect | null = null;
let _cssSourceEntitySelect: EntitySelect | null = null;
let _editorCssSources: any[] = [];
class HALightEditorModal extends Modal {
constructor() { super('ha-light-editor-modal'); }
onForceClose() {
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
}
snapshotValues() {
return {
name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value,
css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
update_rate: (document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value,
transition: (document.getElementById('ha-light-editor-transition') as HTMLInputElement).value,
mappings: _getMappingsJSON(),
tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []),
};
}
}
const haLightEditorModal = new HALightEditorModal();
function _getMappingsJSON(): string {
const rows = document.querySelectorAll('#ha-light-mappings-list .ha-light-mapping-row');
const mappings: any[] = [];
rows.forEach(row => {
mappings.push({
entity_id: (row.querySelector('.ha-mapping-entity') as HTMLInputElement).value.trim(),
led_start: parseInt((row.querySelector('.ha-mapping-led-start') as HTMLInputElement).value) || 0,
led_end: parseInt((row.querySelector('.ha-mapping-led-end') as HTMLInputElement).value) || -1,
brightness_scale: parseFloat((row.querySelector('.ha-mapping-brightness') as HTMLInputElement).value) || 1.0,
});
});
return JSON.stringify(mappings);
}
// ── Mapping rows ──
export function addHALightMapping(data: any = null): void {
const list = document.getElementById('ha-light-mappings-list');
if (!list) return;
const row = document.createElement('div');
row.className = 'ha-light-mapping-row condition-fields';
row.innerHTML = `
<div class="condition-field">
<label>${t('ha_light.mapping.entity_id')}</label>
<input type="text" class="ha-mapping-entity" value="${escapeHtml(data?.entity_id || '')}" placeholder="light.living_room">
</div>
<div class="condition-field" style="display:flex; gap:0.5rem;">
<div style="flex:1">
<label>${t('ha_light.mapping.led_start')}</label>
<input type="number" class="ha-mapping-led-start" value="${data?.led_start ?? 0}" min="0" step="1">
</div>
<div style="flex:1">
<label>${t('ha_light.mapping.led_end')}</label>
<input type="number" class="ha-mapping-led-end" value="${data?.led_end ?? -1}" min="-1" step="1">
</div>
<div style="flex:1">
<label>${t('ha_light.mapping.brightness')}</label>
<input type="number" class="ha-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
</div>
</div>
<button type="button" class="btn btn-sm btn-danger ha-mapping-remove" onclick="this.closest('.ha-light-mapping-row').remove()">&times;</button>
`;
list.appendChild(row);
}
// ── Show / Close ──
export async function showHALightEditor(targetId: string | null = null, cloneData: any = null): Promise<void> {
// Load data for dropdowns
const [haSources, cssSources] = await Promise.all([
haSourcesCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
]);
_editorCssSources = cssSources;
const isEdit = !!targetId;
const isClone = !!cloneData;
const titleKey = isEdit ? 'ha_light.edit' : 'ha_light.add';
document.getElementById('ha-light-editor-title')!.innerHTML = `${ICON_HA} ${t(titleKey)}`;
(document.getElementById('ha-light-editor-id') as HTMLInputElement).value = '';
(document.getElementById('ha-light-editor-error') as HTMLElement).style.display = 'none';
// Populate HA source dropdown
const haSelect = document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement;
haSelect.innerHTML = haSources.map((s: any) =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
// Populate CSS source dropdown
const cssSelect = document.getElementById('ha-light-editor-css-source') as HTMLSelectElement;
cssSelect.innerHTML = `<option value="">—</option>` + cssSources.map((s: any) =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
// Clear mappings
document.getElementById('ha-light-mappings-list')!.innerHTML = '';
let editData: any = null;
if (isEdit) {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
if (!resp.ok) throw new Error('Failed to load target');
editData = await resp.json();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
return;
}
} else if (isClone) {
editData = cloneData;
}
if (editData) {
if (isEdit) (document.getElementById('ha-light-editor-id') as HTMLInputElement).value = editData.id;
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || '';
haSelect.value = editData.ha_source_id || '';
cssSelect.value = editData.color_strip_source_id || '';
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = String(editData.update_rate ?? 2.0);
document.getElementById('ha-light-editor-update-rate-display')!.textContent = (editData.update_rate ?? 2.0).toFixed(1);
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = String(editData.ha_transition ?? 0.5);
document.getElementById('ha-light-editor-transition-display')!.textContent = (editData.ha_transition ?? 0.5).toFixed(1);
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
// Load mappings
const mappings = editData.ha_light_mappings || [];
mappings.forEach((m: any) => addHALightMapping(m));
} else {
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = '2.0';
document.getElementById('ha-light-editor-update-rate-display')!.textContent = '2.0';
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = '0.5';
document.getElementById('ha-light-editor-transition-display')!.textContent = '0.5';
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
// Add one empty mapping by default
addHALightMapping();
}
// EntitySelects
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
_haSourceEntitySelect = new EntitySelect({
target: haSelect,
getItems: () => haSources.map((s: any) => ({
value: s.id, label: s.name, icon: ICON_HA,
desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'),
})),
placeholder: t('palette.search'),
});
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
_cssSourceEntitySelect = new EntitySelect({
target: cssSelect,
getItems: () => _editorCssSources.map((s: any) => ({
value: s.id, label: s.name, icon: getColorStripIcon(s.source_type), desc: s.source_type,
})),
placeholder: t('palette.search'),
});
// Tags
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
_haLightTagsInput = new TagInput(document.getElementById('ha-light-tags-container'), { placeholder: t('tags.placeholder') });
_haLightTagsInput.setValue(editData?.tags || []);
haLightEditorModal.open();
haLightEditorModal.snapshot();
}
export async function closeHALightEditor(): Promise<void> {
await haLightEditorModal.close();
}
// ── Save ──
export async function saveHALightEditor(): Promise<void> {
const targetId = (document.getElementById('ha-light-editor-id') as HTMLInputElement).value;
const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim();
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
const updateRate = parseFloat((document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value) || 2.0;
const transition = parseFloat((document.getElementById('ha-light-editor-transition') as HTMLInputElement).value) || 0.5;
const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null;
if (!name) {
haLightEditorModal.showError(t('ha_light.error.name_required'));
return;
}
if (!haSourceId) {
haLightEditorModal.showError(t('ha_light.error.ha_source_required'));
return;
}
// Collect mappings
const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id);
const payload: any = {
name,
ha_source_id: haSourceId,
color_strip_source_id: cssSourceId,
ha_light_mappings: mappings,
update_rate: updateRate,
transition,
description,
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
};
try {
let response;
if (targetId) {
response = await fetchWithAuth(`/output-targets/${targetId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'ha_light';
response = await fetchWithAuth('/output-targets', {
method: 'POST',
body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${response.status}`);
}
showToast(targetId ? t('ha_light.updated') : t('ha_light.created'), 'success');
outputTargetsCache.invalidate();
haLightEditorModal.forceClose();
// Reload targets tab
if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (e: any) {
if (e.isAuth) return;
haLightEditorModal.showError(e.message);
}
}
// ── Edit / Clone / Delete ──
export async function editHALightTarget(targetId: string): Promise<void> {
await showHALightEditor(targetId);
}
export async function cloneHALightTarget(targetId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
if (!resp.ok) throw new Error('Failed to load target');
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showHALightEditor(null, data);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Card rendering ──
export function createHALightTargetCard(target: any, haSourceMap: Record<string, any> = {}, cssSourceMap: Record<string, any> = {}): string {
const haSource = haSourceMap[target.ha_source_id];
const cssSource = cssSourceMap[target.color_strip_source_id];
const haName = haSource ? escapeHtml(haSource.name) : target.ha_source_id || '—';
const cssName = cssSource ? escapeHtml(cssSource.name) : target.color_strip_source_id || '—';
const mappingCount = target.ha_light_mappings?.length || 0;
const isRunning = target.state?.processing;
return wrapCard({
type: 'card',
dataAttr: 'data-ha-target-id',
id: target.id,
removeOnclick: `deleteTarget('${target.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<span class="card-title-text">${ICON_HA} ${escapeHtml(target.name)}</span>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${ICON_HA} ${haName}</span>
${cssName !== '—' ? `<span class="stream-card-prop">${_icon(P.palette)} ${cssName}</span>` : ''}
<span class="stream-card-prop">${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop">${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz</span>
</div>
${renderTagChips(target.tags || [])}
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon ${isRunning ? 'btn-danger' : 'btn-primary'}" data-action="${isRunning ? 'stop' : 'start'}" title="${isRunning ? t('targets.stop') : t('targets.start')}">
${isRunning ? ICON_STOP : ICON_START}
</button>
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Event delegation ──
const _haLightActions: Record<string, (id: string) => void> = {
start: (id) => _startStop(id, 'start'),
stop: (id) => _startStop(id, 'stop'),
clone: cloneHALightTarget,
edit: editHALightTarget,
};
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
outputTargetsCache.invalidate();
if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export function initHALightTargetDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const section = btn.closest<HTMLElement>('[data-card-section="ha-light-targets"]');
if (!section) return;
const card = btn.closest<HTMLElement>('[data-ha-target-id]');
if (!card) return;
const action = btn.dataset.action;
const id = card.getAttribute('data-ha-target-id');
if (!action || !id) return;
const handler = _haLightActions[action];
if (handler) {
e.stopPropagation();
handler(id);
}
});
}
// ── Expose to global scope ──
window.showHALightEditor = showHALightEditor;
window.closeHALightEditor = closeHALightEditor;
window.saveHALightEditor = saveHALightEditor;
window.editHALightTarget = editHALightTarget;
window.cloneHALightTarget = cloneHALightTarget;
window.addHALightMapping = addHALightMapping;

View File

@@ -1,5 +1,6 @@
/**
* Performance charts — real-time CPU, RAM, GPU usage with Chart.js.
* Supports system-wide and app-level (process) metrics with a toggle.
* History is seeded from the server-side ring buffer on init.
*/
@@ -14,11 +15,16 @@ import { createColorPicker, registerColorPicker } from '../core/color-picker.ts'
const MAX_SAMPLES = 120;
const CHART_KEYS = ['cpu', 'ram', 'gpu'];
const PERF_MODE_KEY = 'perfMetricsMode';
type PerfMode = 'system' | 'app' | 'both';
let _pollTimer: ReturnType<typeof setInterval> | null = null;
let _charts: Record<string, any> = {}; // { cpu: Chart, ram: Chart, gpu: Chart }
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [] };
let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [] };
let _hasGpu: boolean | null = null; // null = unknown, true/false after first fetch
let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both';
function _getColor(key: string): string {
return localStorage.getItem(`perfChartColor_${key}`)
@@ -26,6 +32,12 @@ function _getColor(key: string): string {
|| '#4CAF50';
}
function _getAppColor(key: string): string {
const base = _getColor(key);
// Use a lighter/shifted version for the app line
return base + '99'; // 60% opacity hex suffix
}
function _onChartColorChange(key: string, hex: string | null): void {
if (hex) {
localStorage.setItem(`perfChartColor_${key}`, hex);
@@ -41,10 +53,43 @@ function _onChartColorChange(key: string, hex: string | null): void {
if (chart) {
chart.data.datasets[0].borderColor = hex;
chart.data.datasets[0].backgroundColor = hex + '26';
chart.data.datasets[1].borderColor = hex + '99';
chart.data.datasets[1].backgroundColor = hex + '14';
chart.update();
}
}
/** Build the 3-way toggle HTML for perf section header. */
export function renderPerfModeToggle(): string {
return `<span class="perf-mode-toggle" onclick="event.stopPropagation()">
<button class="perf-mode-btn${_mode === 'system' ? ' active' : ''}" data-perf-mode="system" onclick="setPerfMode('system')" title="${t('dashboard.perf.mode.system')}">${t('dashboard.perf.mode.system')}</button>
<button class="perf-mode-btn${_mode === 'app' ? ' active' : ''}" data-perf-mode="app" onclick="setPerfMode('app')" title="${t('dashboard.perf.mode.app')}">${t('dashboard.perf.mode.app')}</button>
<button class="perf-mode-btn${_mode === 'both' ? ' active' : ''}" data-perf-mode="both" onclick="setPerfMode('both')" title="${t('dashboard.perf.mode.both')}">${t('dashboard.perf.mode.both')}</button>
</span>`;
}
/** Change the perf metrics display mode. */
export function setPerfMode(mode: PerfMode): void {
_mode = mode;
localStorage.setItem(PERF_MODE_KEY, mode);
// Update toggle button active states
document.querySelectorAll('.perf-mode-btn').forEach(btn => {
btn.classList.toggle('active', (btn as HTMLElement).dataset.perfMode === mode);
});
// Update dataset visibility on all charts
for (const key of CHART_KEYS) {
const chart = _charts[key];
if (!chart) continue;
const showSystem = mode === 'system' || mode === 'both';
const showApp = mode === 'app' || mode === 'both';
chart.data.datasets[0].hidden = !showSystem;
chart.data.datasets[1].hidden = !showApp;
chart.update('none');
}
}
/** Returns the static HTML for the perf section (canvas placeholders). */
export function renderPerfSection(): string {
// Register callbacks before rendering
@@ -81,19 +126,37 @@ function _createChart(canvasId: string, key: string): any {
const ctx = document.getElementById(canvasId) as HTMLCanvasElement | null;
if (!ctx) return null;
const color = _getColor(key);
const showSystem = _mode === 'system' || _mode === 'both';
const showApp = _mode === 'app' || _mode === 'both';
return new Chart(ctx, {
type: 'line',
data: {
labels: Array(MAX_SAMPLES).fill(''),
datasets: [{
data: [],
borderColor: color,
backgroundColor: color + '26',
borderWidth: 1.5,
tension: 0.3,
fill: true,
pointRadius: 0,
}],
datasets: [
{
// System-wide dataset
data: [],
borderColor: color,
backgroundColor: color + '26',
borderWidth: 1.5,
tension: 0.3,
fill: true,
pointRadius: 0,
hidden: !showSystem,
},
{
// App-level dataset (dashed line)
data: [],
borderColor: color + '99',
backgroundColor: color + '14',
borderWidth: 1.5,
borderDash: [4, 3],
tension: 0.3,
fill: true,
pointRadius: 0,
hidden: !showApp,
},
],
},
options: {
responsive: true,
@@ -115,9 +178,12 @@ async function _seedFromServer(): Promise<void> {
const data = await fetchMetricsHistory();
if (!data) return;
const samples = data.system || [];
_history.cpu = samples.map(s => s.cpu).filter(v => v != null);
_history.ram = samples.map(s => s.ram_pct).filter(v => v != null);
_history.gpu = samples.map(s => s.gpu_util).filter(v => v != null);
_history.cpu = samples.map((s: any) => s.cpu).filter((v: any) => v != null);
_history.ram = samples.map((s: any) => s.ram_pct).filter((v: any) => v != null);
_history.gpu = samples.map((s: any) => s.gpu_util).filter((v: any) => v != null);
_appHistory.cpu = samples.map((s: any) => s.app_cpu).filter((v: any) => v != null);
_appHistory.ram = samples.map((s: any) => s.app_ram).filter((v: any) => v != null);
_appHistory.gpu = samples.map((s: any) => s.app_gpu_mem).filter((v: any) => v != null);
// Detect GPU availability from history
if (_history.gpu.length > 0) {
@@ -125,11 +191,20 @@ async function _seedFromServer(): Promise<void> {
}
for (const key of CHART_KEYS) {
if (_charts[key] && _history[key].length > 0) {
_charts[key].data.datasets[0].data = [..._history[key]];
_charts[key].data.labels = _history[key].map(() => '');
_charts[key].update();
const chart = _charts[key];
if (!chart) continue;
// System dataset
if (_history[key].length > 0) {
chart.data.datasets[0].data = [..._history[key]];
}
// App dataset
if (_appHistory[key].length > 0) {
chart.data.datasets[1].data = [..._appHistory[key]];
}
// Align labels to the longer dataset
const maxLen = Math.max(chart.data.datasets[0].data.length, chart.data.datasets[1].data.length);
chart.data.labels = Array(maxLen).fill('');
chart.update();
}
} catch {
// Silently ignore — charts will fill from polling
@@ -151,50 +226,99 @@ function _destroyCharts(): void {
}
}
function _pushSample(key: string, value: number): void {
_history[key].push(value);
function _pushSample(key: string, sysValue: number, appValue: number | null): void {
// System history
_history[key].push(sysValue);
if (_history[key].length > MAX_SAMPLES) _history[key].shift();
// App history
if (appValue != null) {
_appHistory[key].push(appValue);
if (_appHistory[key].length > MAX_SAMPLES) _appHistory[key].shift();
}
const chart = _charts[key];
if (!chart) return;
const ds = chart.data.datasets[0].data;
ds.length = 0;
ds.push(..._history[key]);
// Ensure labels array matches length (reuse existing array)
while (chart.data.labels.length < ds.length) chart.data.labels.push('');
chart.data.labels.length = ds.length;
// Update system dataset
const sysDs = chart.data.datasets[0].data;
sysDs.length = 0;
sysDs.push(..._history[key]);
// Update app dataset
const appDs = chart.data.datasets[1].data;
appDs.length = 0;
appDs.push(..._appHistory[key]);
// Ensure labels array matches the longer dataset
const maxLen = Math.max(sysDs.length, appDs.length);
while (chart.data.labels.length < maxLen) chart.data.labels.push('');
chart.data.labels.length = maxLen;
chart.update('none');
}
/** Format the value display based on mode. */
function _formatValue(sysVal: string, appVal: string | null): string {
if (_mode === 'system') return sysVal;
if (_mode === 'app') return appVal ?? '-';
// 'both': show both
if (appVal != null) return `${sysVal} / ${appVal}`;
return sysVal;
}
async function _fetchPerformance(): Promise<void> {
try {
const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() });
if (!resp.ok) return;
const data = await resp.json();
// CPU
_pushSample('cpu', data.cpu_percent);
// CPU — app_cpu_percent is in the same scale as cpu_percent (per-core %)
_pushSample('cpu', data.cpu_percent, data.app_cpu_percent);
const cpuEl = document.getElementById('perf-cpu-value');
if (cpuEl) cpuEl.textContent = `${data.cpu_percent.toFixed(0)}%`;
if (cpuEl) {
cpuEl.textContent = _formatValue(
`${data.cpu_percent.toFixed(0)}%`,
`${data.app_cpu_percent.toFixed(0)}%`
);
}
if (data.cpu_name) {
const nameEl = document.getElementById('perf-cpu-name');
if (nameEl && !nameEl.textContent) nameEl.textContent = data.cpu_name;
}
// RAM
_pushSample('ram', data.ram_percent);
// RAM — convert app_ram_mb to percent of total for consistent chart scale
const appRamPct = data.ram_total_mb > 0
? (data.app_ram_mb / data.ram_total_mb) * 100
: 0;
_pushSample('ram', data.ram_percent, appRamPct);
const ramEl = document.getElementById('perf-ram-value');
if (ramEl) {
const usedGb = (data.ram_used_mb / 1024).toFixed(1);
const totalGb = (data.ram_total_mb / 1024).toFixed(1);
ramEl.textContent = `${usedGb}/${totalGb} GB`;
const appMb = data.app_ram_mb.toFixed(0);
ramEl.textContent = _formatValue(
`${usedGb}/${totalGb} GB`,
`${appMb} MB`
);
}
// GPU
if (data.gpu) {
_hasGpu = true;
_pushSample('gpu', data.gpu.utilization);
// GPU utilization is system-wide only (no per-process util from NVML)
// For app, show memory percentage if available
const appGpuPct = (data.gpu.app_memory_mb != null && data.gpu.memory_total_mb)
? (data.gpu.app_memory_mb / data.gpu.memory_total_mb) * 100
: null;
_pushSample('gpu', data.gpu.utilization, appGpuPct);
const gpuEl = document.getElementById('perf-gpu-value');
if (gpuEl) gpuEl.textContent = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`;
if (gpuEl) {
const sysText = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`;
const appText = data.gpu.app_memory_mb != null
? `${data.gpu.app_memory_mb.toFixed(0)} MB VRAM`
: null;
gpuEl.textContent = _formatValue(sysText, appText);
}
if (data.gpu.name) {
const nameEl = document.getElementById('perf-gpu-name');
if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name;

View File

@@ -11,6 +11,7 @@ import {
_cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache,
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
_cachedHASources, haSourcesCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
@@ -19,6 +20,7 @@ import { Modal } from '../core/modal.ts';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts';
import { _splitOpenrgbZone } from './device-discovery.ts';
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts';
import { createHALightTargetCard, initHALightTargetDelegation } from './ha-light-targets.ts';
import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
@@ -104,6 +106,7 @@ const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.de
] });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csHALightTargets = new CardSection('ha-light-targets', { titleKey: 'ha_light.section.title', gridClass: 'devices-grid', addCardOnclick: "showHALightEditor()", keyAttr: 'data-ha-target-id', emptyKey: 'section.empty.ha_light_targets', bulkActions: _targetBulkActions });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates', bulkActions: [
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeletePatternTemplates },
] });
@@ -605,6 +608,7 @@ export async function loadTargetsTab() {
valueSourcesCache.fetch().catch((): any[] => []),
audioSourcesCache.fetch().catch((): any[] => []),
syncClocksCache.fetch().catch((): any[] => []),
haSourcesCache.fetch().catch((): any[] => []),
]);
const colorStripSourceMap = {};
@@ -658,6 +662,7 @@ export async function loadTargetsTab() {
const ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light');
// Update tab badge with running target count
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
@@ -680,10 +685,16 @@ export async function loadTargetsTab() {
{ key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length },
{ key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length },
]
},
{
key: 'ha_light_group', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'ha_light.section.title',
children: [
{ key: 'ha-light-targets', titleKey: 'ha_light.section.targets', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: haLightTargets.length },
]
}
];
// Determine which tree leaf is active — migrate old values
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns'];
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns', 'ha-light-targets'];
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab
: activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices';
@@ -694,6 +705,9 @@ export async function loadTargetsTab() {
const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })));
const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })));
const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })));
const haSourceMap: Record<string, any> = {};
_cachedHASources.forEach(s => { haSourceMap[s.id] = s; });
const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap) })));
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
// Track which target cards were replaced/added (need chart re-init)
@@ -706,11 +720,13 @@ export async function loadTargetsTab() {
'led-targets': ledTargets.length,
'kc-targets': kcTargets.length,
'kc-patterns': patternTemplates.length,
'ha-light-targets': haLightTargets.length,
});
csDevices.reconcile(deviceItems);
const ledResult = csLedTargets.reconcile(ledTargetItems);
const kcResult = csKCTargets.reconcile(kcTargetItems);
csPatternTemplates.reconcile(patternItems);
csHALightTargets.reconcile(haLightTargetItems);
changedTargetIds = new Set<string>([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[]),
...(kcResult.added as unknown as string[]), ...(kcResult.replaced as unknown as string[]), ...(kcResult.removed as unknown as string[])]);
@@ -727,9 +743,11 @@ export async function loadTargetsTab() {
{ key: 'led-targets', html: csLedTargets.render(ledTargetItems) },
{ key: 'kc-targets', html: csKCTargets.render(kcTargetItems) },
{ key: 'kc-patterns', html: csPatternTemplates.render(patternItems) },
{ key: 'ha-light-targets', html: csHALightTargets.render(haLightTargetItems) },
].map(p => `<div class="target-sub-tab-panel stream-tab-panel${p.key === activeLeaf ? ' active' : ''}" id="target-sub-tab-${p.key}">${p.html}</div>`).join('');
container.innerHTML = panels;
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates]);
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates, csHALightTargets]);
initHALightTargetDelegation(container);
// Render tree sidebar with expand/collapse buttons
_targetsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);

View File

@@ -759,6 +759,9 @@
"dashboard.perf.gpu": "GPU",
"dashboard.perf.unavailable": "unavailable",
"dashboard.perf.color": "Chart color",
"dashboard.perf.mode.system": "System",
"dashboard.perf.mode.app": "App",
"dashboard.perf.mode.both": "Both",
"dashboard.poll_interval": "Refresh interval",
"automations.title": "Automations",
"automations.empty": "No automations configured. Create one to automate scene activation.",
@@ -1815,6 +1818,31 @@
"ha_source.deleted": "Home Assistant source deleted",
"ha_source.delete.confirm": "Delete this Home Assistant connection?",
"section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.",
"ha_light.section.title": "HA Lights",
"ha_light.section.targets": "HA Light Targets",
"ha_light.add": "Add HA Light Target",
"ha_light.edit": "Edit HA Light Target",
"ha_light.name": "Name:",
"ha_light.name.placeholder": "Living Room Lights",
"ha_light.ha_source": "HA Connection:",
"ha_light.css_source": "Color Strip Source:",
"ha_light.update_rate": "Update Rate:",
"ha_light.update_rate.hint": "How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.",
"ha_light.transition": "Transition:",
"ha_light.transition.hint": "Smooth fade duration between colors (HA transition parameter).",
"ha_light.mappings": "Light Mappings:",
"ha_light.mappings.hint": "Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.",
"ha_light.mappings.add": "Add Mapping",
"ha_light.mapping.entity_id": "Entity ID:",
"ha_light.mapping.led_start": "LED Start:",
"ha_light.mapping.led_end": "LED End (-1=last):",
"ha_light.mapping.brightness": "Brightness Scale:",
"ha_light.description": "Description (optional):",
"ha_light.error.name_required": "Name is required",
"ha_light.error.ha_source_required": "HA connection is required",
"ha_light.created": "HA light target created",
"ha_light.updated": "HA light target updated",
"section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.",
"automations.condition.home_assistant": "Home Assistant",
"automations.condition.home_assistant.desc": "HA entity state",
"automations.condition.home_assistant.ha_source": "HA Source:",

View File

@@ -759,6 +759,9 @@
"dashboard.perf.gpu": "ГП",
"dashboard.perf.unavailable": "недоступно",
"dashboard.perf.color": "Цвет графика",
"dashboard.perf.mode.system": "Система",
"dashboard.perf.mode.app": "Приложение",
"dashboard.perf.mode.both": "Оба",
"dashboard.poll_interval": "Интервал обновления",
"automations.title": "Автоматизации",
"automations.empty": "Автоматизации не настроены. Создайте автоматизацию для автоматической активации сцен.",

View File

@@ -759,6 +759,9 @@
"dashboard.perf.gpu": "GPU",
"dashboard.perf.unavailable": "不可用",
"dashboard.perf.color": "图表颜色",
"dashboard.perf.mode.system": "系统",
"dashboard.perf.mode.app": "应用",
"dashboard.perf.mode.both": "全部",
"dashboard.poll_interval": "刷新间隔",
"automations.title": "自动化",
"automations.empty": "尚未配置自动化。创建一个以自动激活场景。",

View File

@@ -0,0 +1,160 @@
"""Home Assistant light output target — casts LED colors to HA lights."""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List, Optional
from wled_controller.storage.output_target import OutputTarget
def _resolve_ref(new_val: str, old_val: str) -> str:
"""Resolve entity reference: empty string clears, non-empty replaces."""
return "" if new_val == "" else (new_val or old_val)
@dataclass
class HALightMapping:
"""Maps an LED range to a single HA light entity."""
entity_id: str = "" # e.g. "light.living_room"
led_start: int = 0 # start LED index (0-based)
led_end: int = -1 # end LED index (-1 = last)
brightness_scale: float = 1.0 # 0.0-1.0 multiplier on brightness
def to_dict(self) -> dict:
return {
"entity_id": self.entity_id,
"led_start": self.led_start,
"led_end": self.led_end,
"brightness_scale": self.brightness_scale,
}
@classmethod
def from_dict(cls, data: dict) -> "HALightMapping":
return cls(
entity_id=data.get("entity_id", ""),
led_start=data.get("led_start", 0),
led_end=data.get("led_end", -1),
brightness_scale=data.get("brightness_scale", 1.0),
)
@dataclass
class HALightOutputTarget(OutputTarget):
"""Output target that casts LED colors to Home Assistant lights via service calls."""
ha_source_id: str = "" # references HomeAssistantSource
color_strip_source_id: str = "" # CSS providing the colors
light_mappings: List[HALightMapping] = field(default_factory=list)
update_rate: float = 2.0 # Hz (calls per second, 0.5-5.0)
transition: float = 0.5 # HA transition seconds (smooth fade between colors)
min_brightness_threshold: int = 0 # below this brightness → turn off light
color_tolerance: int = 5 # skip service call if RGB delta < this
def register_with_manager(self, manager) -> None:
"""Register this HA light target with the processor manager."""
if self.ha_source_id and self.light_mappings:
manager.add_ha_light_target(
target_id=self.id,
ha_source_id=self.ha_source_id,
color_strip_source_id=self.color_strip_source_id,
light_mappings=self.light_mappings,
update_rate=self.update_rate,
transition=self.transition,
min_brightness_threshold=self.min_brightness_threshold,
color_tolerance=self.color_tolerance,
)
def sync_with_manager(
self,
manager,
*,
settings_changed: bool = False,
source_changed: bool = False,
device_changed: bool = False,
css_changed: bool = False,
**_kwargs,
) -> None:
"""Push changed fields to the processor manager."""
if settings_changed:
manager.update_target_settings(
self.id,
{
"update_rate": self.update_rate,
"transition": self.transition,
"min_brightness_threshold": self.min_brightness_threshold,
"color_tolerance": self.color_tolerance,
"light_mappings": self.light_mappings,
},
)
if css_changed:
manager.update_target_css(self.id, self.color_strip_source_id)
def update_fields(
self,
*,
name=None,
ha_source_id=None,
color_strip_source_id=None,
light_mappings=None,
update_rate=None,
transition=None,
min_brightness_threshold=None,
color_tolerance=None,
description=None,
tags: Optional[List[str]] = None,
**_kwargs,
) -> None:
"""Apply mutable field updates."""
super().update_fields(name=name, description=description, tags=tags)
if ha_source_id is not None:
self.ha_source_id = _resolve_ref(ha_source_id, self.ha_source_id)
if color_strip_source_id is not None:
self.color_strip_source_id = _resolve_ref(
color_strip_source_id, self.color_strip_source_id
)
if light_mappings is not None:
self.light_mappings = light_mappings
if update_rate is not None:
self.update_rate = max(0.5, min(5.0, float(update_rate)))
if transition is not None:
self.transition = max(0.0, min(10.0, float(transition)))
if min_brightness_threshold is not None:
self.min_brightness_threshold = int(min_brightness_threshold)
if color_tolerance is not None:
self.color_tolerance = int(color_tolerance)
def to_dict(self) -> dict:
d = super().to_dict()
d["ha_source_id"] = self.ha_source_id
d["color_strip_source_id"] = self.color_strip_source_id
d["light_mappings"] = [m.to_dict() for m in self.light_mappings]
d["update_rate"] = self.update_rate
d["transition"] = self.transition
d["min_brightness_threshold"] = self.min_brightness_threshold
d["color_tolerance"] = self.color_tolerance
return d
@classmethod
def from_dict(cls, data: dict) -> "HALightOutputTarget":
mappings = [HALightMapping.from_dict(m) for m in data.get("light_mappings", [])]
return cls(
id=data["id"],
name=data["name"],
target_type="ha_light",
ha_source_id=data.get("ha_source_id", ""),
color_strip_source_id=data.get("color_strip_source_id", ""),
light_mappings=mappings,
update_rate=data.get("update_rate", 2.0),
transition=data.get("transition", 0.5),
min_brightness_threshold=data.get("min_brightness_threshold", 0),
color_tolerance=data.get("color_tolerance", 5),
description=data.get("description"),
tags=data.get("tags", []),
created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat())
),
updated_at=datetime.fromisoformat(
data.get("updated_at", datetime.now(timezone.utc).isoformat())
),
)

View File

@@ -21,14 +21,24 @@ class OutputTarget:
"""Register this target with the processor manager. Subclasses override."""
pass
def sync_with_manager(self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool) -> None:
def sync_with_manager(
self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool
) -> None:
"""Push changed fields to a running processor. Subclasses override."""
pass
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
settings=None, key_colors_settings=None, description=None,
tags: Optional[List[str]] = None,
**_kwargs) -> None:
def update_fields(
self,
*,
name=None,
device_id=None,
picture_source_id=None,
settings=None,
key_colors_settings=None,
description=None,
tags: Optional[List[str]] = None,
**_kwargs,
) -> None:
"""Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones."""
if name is not None:
self.name = name
@@ -60,8 +70,14 @@ class OutputTarget:
target_type = data.get("target_type", "led")
if target_type == "led":
from wled_controller.storage.wled_output_target import WledOutputTarget
return WledOutputTarget.from_dict(data)
if target_type == "key_colors":
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget
return KeyColorsOutputTarget.from_dict(data)
if target_type == "ha_light":
from wled_controller.storage.ha_light_output_target import HALightOutputTarget
return HALightOutputTarget.from_dict(data)
raise ValueError(f"Unknown target type: {target_type}")

View File

@@ -12,6 +12,10 @@ 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.utils import get_logger
logger = get_logger(__name__)
@@ -50,13 +54,18 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
description: Optional[str] = None,
picture_source_id: str = "",
tags: Optional[List[str]] = None,
ha_source_id: str = "",
ha_light_mappings: Optional[List[HALightMapping]] = None,
update_rate: float = 2.0,
transition: float = 0.5,
color_tolerance: int = 5,
) -> OutputTarget:
"""Create a new output target.
Raises:
ValueError: If validation fails
"""
if target_type not in ("led", "key_colors"):
if target_type not in ("led", "key_colors", "ha_light"):
raise ValueError(f"Invalid target type: {target_type}")
# Check for duplicate name
@@ -96,6 +105,22 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
created_at=now,
updated_at=now,
)
elif target_type == "ha_light":
target = HALightOutputTarget(
id=target_id,
name=name,
target_type="ha_light",
ha_source_id=ha_source_id,
color_strip_source_id=color_strip_source_id,
light_mappings=ha_light_mappings or [],
update_rate=update_rate,
transition=transition,
min_brightness_threshold=min_brightness_threshold,
color_tolerance=color_tolerance,
description=description,
created_at=now,
updated_at=now,
)
else:
raise ValueError(f"Unknown target type: {target_type}")
@@ -164,24 +189,28 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
def get_targets_for_device(self, device_id: str) -> List[OutputTarget]:
"""Get all targets that reference a specific device."""
return [
t for t in self._items.values()
t
for t in self._items.values()
if isinstance(t, WledOutputTarget) and t.device_id == device_id
]
def get_targets_referencing_source(self, source_id: str) -> List[str]:
"""Return names of KC targets that reference a picture source."""
return [
target.name for target in self._items.values()
target.name
for target in self._items.values()
if isinstance(target, KeyColorsOutputTarget) and target.picture_source_id == source_id
]
def get_targets_referencing_css(self, css_id: str) -> List[str]:
"""Return names of LED targets that reference a color strip source."""
return [
target.name for target in self._items.values()
if isinstance(target, WledOutputTarget)
and target.color_strip_source_id == css_id
]
"""Return names of targets that reference a color strip source."""
result = []
for target in self._items.values():
if isinstance(target, WledOutputTarget) and target.color_strip_source_id == css_id:
result.append(target.name)
elif isinstance(target, HALightOutputTarget) and target.color_strip_source_id == css_id:
result.append(target.name)
return result
def count(self) -> int:
"""Get number of targets."""

View File

@@ -214,6 +214,7 @@
{% include 'modals/sync-clock-editor.html' %}
{% include 'modals/weather-source-editor.html' %}
{% include 'modals/ha-source-editor.html' %}
{% include 'modals/ha-light-editor.html' %}
{% include 'modals/asset-upload.html' %}
{% include 'modals/asset-editor.html' %}
{% include 'modals/settings.html' %}

View File

@@ -0,0 +1,92 @@
<!-- HA Light Target Editor Modal -->
<div id="ha-light-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="ha-light-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="ha-light-editor-title" data-i18n="ha_light.add">Add HA Light Target</h2>
<button class="modal-close-btn" onclick="closeHALightEditor()" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="ha-light-editor-form" onsubmit="return false;">
<input type="hidden" id="ha-light-editor-id">
<div id="ha-light-editor-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-name" data-i18n="ha_light.name">Name:</label>
</div>
<input type="text" id="ha-light-editor-name" data-i18n-placeholder="ha_light.name.placeholder" placeholder="Living Room Lights" required>
<div id="ha-light-tags-container"></div>
</div>
<!-- HA Source -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-ha-source" data-i18n="ha_light.ha_source">HA Connection:</label>
</div>
<select id="ha-light-editor-ha-source"></select>
</div>
<!-- CSS Source -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-css-source" data-i18n="ha_light.css_source">Color Strip Source:</label>
</div>
<select id="ha-light-editor-css-source"></select>
</div>
<!-- Update Rate -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-update-rate">
<span data-i18n="ha_light.update_rate">Update Rate:</span>
<span id="ha-light-editor-update-rate-display">2.0</span> Hz
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.update_rate.hint">How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.</small>
<input type="range" id="ha-light-editor-update-rate" min="0.5" max="5.0" step="0.5" value="2.0"
oninput="document.getElementById('ha-light-editor-update-rate-display').textContent = parseFloat(this.value).toFixed(1)">
</div>
<!-- Transition -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-transition">
<span data-i18n="ha_light.transition">Transition:</span>
<span id="ha-light-editor-transition-display">0.5</span>s
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.transition.hint">Smooth fade duration between colors (HA transition parameter). Higher values give smoother but slower transitions.</small>
<input type="range" id="ha-light-editor-transition" min="0" max="5.0" step="0.1" value="0.5"
oninput="document.getElementById('ha-light-editor-transition-display').textContent = parseFloat(this.value).toFixed(1)">
</div>
<!-- Light Mappings -->
<div class="form-group">
<div class="label-row">
<label data-i18n="ha_light.mappings">Light Mappings:</label>
<button type="button" class="btn btn-sm btn-secondary" onclick="addHALightMapping()" data-i18n-title="ha_light.mappings.add">+</button>
</div>
<small class="input-hint" data-i18n="ha_light.mappings.hint">Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.</small>
<div id="ha-light-mappings-list"></div>
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-description" data-i18n="ha_light.description">Description (optional):</label>
</div>
<input type="text" id="ha-light-editor-description" placeholder="">
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeHALightEditor()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveHALightEditor()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>