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, KeyColorsSettings,
KeyColorsOutputTarget, 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.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from wled_controller.storage.base_store import EntityNotFoundError
@@ -76,7 +81,6 @@ def _target_to_response(target) -> OutputTargetResponse:
protocol=target.protocol, protocol=target.protocol,
description=target.description, description=target.description,
tags=target.tags, tags=target.tags,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_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), key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description, description=target.description,
tags=target.tags, 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, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
) )
@@ -100,7 +128,6 @@ def _target_to_response(target) -> OutputTargetResponse:
target_type=target.target_type, target_type=target.target_type,
description=target.description, description=target.description,
tags=target.tags, tags=target.tags,
created_at=target.created_at, created_at=target.created_at,
updated_at=target.updated_at, updated_at=target.updated_at,
) )
@@ -108,7 +135,10 @@ def _target_to_response(target) -> OutputTargetResponse:
# ===== CRUD ENDPOINTS ===== # ===== 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( async def create_target(
data: OutputTargetCreate, data: OutputTargetCreate,
_auth: AuthRequired, _auth: AuthRequired,
@@ -125,7 +155,22 @@ async def create_target(
except ValueError: except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found") 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 # Create in store
target = target_store.create_target( target = target_store.create_target(
@@ -144,6 +189,11 @@ async def create_target(
key_colors_settings=kc_settings, key_colors_settings=kc_settings,
description=data.description, description=data.description,
tags=data.tags, 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 # Register in processor manager
@@ -196,7 +246,9 @@ async def batch_target_metrics(
return {"metrics": manager.get_all_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( async def get_target(
target_id: str, target_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -210,7 +262,9 @@ async def get_target(
raise HTTPException(status_code=404, detail=str(e)) 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( async def update_target(
target_id: str, target_id: str,
data: OutputTargetUpdate, data: OutputTargetUpdate,
@@ -246,7 +300,9 @@ async def update_target(
smoothing=incoming.get("smoothing", ex.smoothing), smoothing=incoming.get("smoothing", ex.smoothing),
pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id), pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id),
brightness=incoming.get("brightness", ex.brightness), 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) kc_settings = _kc_schema_to_settings(merged)
else: else:
@@ -282,14 +338,18 @@ async def update_target(
await asyncio.to_thread( await asyncio.to_thread(
target.sync_with_manager, target.sync_with_manager,
manager, manager,
settings_changed=(data.fps is not None or settings_changed=(
data.keepalive_interval is not None or data.fps is not None
data.state_check_interval is not None or or data.keepalive_interval is not None
data.min_brightness_threshold is not None or or data.state_check_interval is not None
data.adaptive_fps is not None or or data.min_brightness_threshold is not None
data.key_colors_settings 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, 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: except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, 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 datetime import datetime, timezone
from typing import Optional from typing import Optional
import os
import psutil import psutil
from fastapi import APIRouter, Depends, HTTPException, Query 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__) 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) 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) # GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from wled_controller.utils.gpu import ( # noqa: E402 from wled_controller.utils.gpu import ( # noqa: E402
@@ -264,18 +268,36 @@ def get_system_performance(_: AuthRequired):
""" """
mem = psutil.virtual_memory() 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 gpu = None
if _nvml_available: if _nvml_available:
try: try:
util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle) util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle)
mem_info = _nvml.nvmlDeviceGetMemoryInfo(_nvml_handle) mem_info = _nvml.nvmlDeviceGetMemoryInfo(_nvml_handle)
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU) 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( gpu = GpuInfo(
name=_nvml.nvmlDeviceGetName(_nvml_handle), name=_nvml.nvmlDeviceGetName(_nvml_handle),
utilization=float(util.gpu), utilization=float(util.gpu),
memory_used_mb=round(mem_info.used / 1024 / 1024, 1), memory_used_mb=round(mem_info.used / 1024 / 1024, 1),
memory_total_mb=round(mem_info.total / 1024 / 1024, 1), memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
temperature_c=float(temp), temperature_c=float(temp),
app_memory_mb=app_gpu_mem,
) )
except Exception as e: except Exception as e:
logger.debug("NVML query failed: %s", 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_used_mb=round(mem.used / 1024 / 1024, 1),
ram_total_mb=round(mem.total / 1024 / 1024, 1), ram_total_mb=round(mem.total / 1024 / 1024, 1),
ram_percent=mem.percent, ram_percent=mem.percent,
app_cpu_percent=app_cpu,
app_ram_mb=app_ram_mb,
gpu=gpu, gpu=gpu,
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )

View File

@@ -22,10 +22,18 @@ class KeyColorsSettingsSchema(BaseModel):
"""Settings for key colors extraction.""" """Settings for key colors extraction."""
fps: int = Field(default=10, description="Extraction rate (1-60)", ge=1, le=60) 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)") interpolation_mode: str = Field(
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0) default="average", description="Color mode (average, median, dominant)"
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) 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") 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") 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): class OutputTargetCreate(BaseModel):
"""Request to create an output target.""" """Request to create an output target."""
name: str = Field(description="Target name", min_length=1, max_length=100) 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 # LED target fields
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source 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") 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)") 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) keepalive_interval: float = Field(
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600) default=1.0,
min_brightness_threshold: int = Field(default=0, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off") description="Keepalive send interval when screen is static (0.5-5.0s)",
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive") ge=0.5,
protocol: str = Field(default="ddp", pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)") 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 # KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)") picture_source_id: str = Field(
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") 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) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -75,16 +138,48 @@ class OutputTargetUpdate(BaseModel):
# LED target fields # LED target fields
device_id: Optional[str] = Field(None, description="LED device ID") device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source 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)") 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) keepalive_interval: Optional[float] = Field(
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600) None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
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") state_check_interval: Optional[int] = Field(
protocol: Optional[str] = Field(None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)") 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 # KC target fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)") picture_source_id: Optional[str] = Field(
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") 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) description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
@@ -101,13 +196,29 @@ class OutputTargetResponse(BaseModel):
brightness_value_source_id: str = Field(default="", description="Brightness value source ID") brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
fps: Optional[int] = Field(None, description="Target send FPS") fps: Optional[int] = Field(None, description="Target send FPS")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)") 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)") state_check_interval: int = Field(
min_brightness_threshold: int = Field(default=0, description="Min brightness threshold (0=disabled)") default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive") )
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)") protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
# KC target fields # KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)") 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") description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") 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") color_strip_source_id: str = Field(default="", description="Color strip source ID")
processing: bool = Field(description="Whether processing is active") processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") 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") fps_target: Optional[int] = Field(None, description="Target FPS")
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)") 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") 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_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_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_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_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_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_audio_render_ms: Optional[float] = Field(
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)") None, description="Audio visualization render time (ms)"
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)") )
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") 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") last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors") errors: List[str] = Field(default_factory=list, description="Recent errors")
device_online: bool = Field(default=False, description="Whether device is reachable") 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_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device") 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_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_led_type: Optional[str] = Field(
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)") 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_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error") 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") fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction")
@@ -186,9 +319,15 @@ class BulkTargetRequest(BaseModel):
class BulkTargetResponse(BaseModel): class BulkTargetResponse(BaseModel):
"""Response for bulk start/stop operations.""" """Response for bulk start/stop operations."""
started: List[str] = Field(default_factory=list, description="IDs that were successfully started") started: List[str] = Field(
stopped: List[str] = Field(default_factory=list, description="IDs that were successfully stopped") default_factory=list, description="IDs that were successfully started"
errors: Dict[str, str] = Field(default_factory=dict, description="Map of target ID to error message for failures") )
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): class KCTestRectangleResponse(BaseModel):
@@ -206,6 +345,8 @@ class KCTestResponse(BaseModel):
"""Response from testing a KC target.""" """Response from testing a KC target."""
image: str = Field(description="Base64 data URI of the captured frame") 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") interpolation_mode: str = Field(description="Color extraction mode used")
pattern_template_name: str = Field(description="Pattern template name") 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_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") 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") 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): class PerformanceResponse(BaseModel):
@@ -74,6 +77,8 @@ class PerformanceResponse(BaseModel):
ram_used_mb: float = Field(description="RAM used in MB") ram_used_mb: float = Field(description="RAM used in MB")
ram_total_mb: float = Field(description="RAM total in MB") ram_total_mb: float = Field(description="RAM total in MB")
ram_percent: float = Field(description="RAM usage percent") 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)") gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)")
timestamp: datetime = Field(description="Measurement timestamp") timestamp: datetime = Field(description="Measurement timestamp")

View File

@@ -87,6 +87,16 @@ class HomeAssistantManager:
runtime, _count = self._runtimes[source_id] runtime, _count = self._runtimes[source_id]
return runtime 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: async def update_source(self, source_id: str) -> None:
"""Hot-update runtime config when the source is edited.""" """Hot-update runtime config when the source is edited."""
entry = self._runtimes.get(source_id) entry = self._runtimes.get(source_id)

View File

@@ -54,6 +54,7 @@ class HARuntime:
# Async task management # Async task management
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
self._ws: Any = None # live websocket connection (set during _connection_loop)
self._connected = False self._connected = False
self._msg_id = 0 self._msg_id = 0
@@ -88,6 +89,34 @@ class HARuntime:
if not self._callbacks[entity_id]: if not self._callbacks[entity_id]:
del 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: async def start(self) -> None:
"""Start the WebSocket connection loop.""" """Start the WebSocket connection loop."""
if self._task is not None: if self._task is not None:
@@ -161,6 +190,7 @@ class HARuntime:
await asyncio.sleep(self._RECONNECT_DELAY) await asyncio.sleep(self._RECONNECT_DELAY)
continue continue
self._ws = ws
self._connected = True self._connected = True
logger.info( logger.info(
f"HA connected: {self._source_id} (version {msg.get('ha_version', '?')})" f"HA connected: {self._source_id} (version {msg.get('ha_version', '?')})"
@@ -204,8 +234,10 @@ class HARuntime:
self._handle_state_changed(event.get("data", {})) self._handle_state_changed(event.get("data", {}))
except asyncio.CancelledError: except asyncio.CancelledError:
self._ws = None
break break
except Exception as e: except Exception as e:
self._ws = None
self._connected = False self._connected = False
logger.warning( logger.warning(
f"HA connection lost ({self._source_id}): {e}. Reconnecting in {self._RECONNECT_DELAY}s..." 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.""" """Server-side ring buffer for system and per-target metrics."""
import asyncio import asyncio
import os
from collections import deque from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Optional from typing import Dict, Optional
@@ -8,7 +9,11 @@ from typing import Dict, Optional
import psutil import psutil
from wled_controller.utils import get_logger 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__) logger = get_logger(__name__)
@@ -16,20 +21,28 @@ MAX_SAMPLES = 120 # ~2 minutes at 1-second interval
SAMPLE_INTERVAL = 1.0 # seconds SAMPLE_INTERVAL = 1.0 # seconds
_process = psutil.Process(os.getpid())
_process.cpu_percent(interval=None) # prime process-level counter
def _collect_system_snapshot() -> dict: def _collect_system_snapshot() -> dict:
"""Collect CPU/RAM/GPU metrics (blocking — run in thread pool). """Collect CPU/RAM/GPU metrics (blocking — run in thread pool).
Returns a dict suitable for direct JSON serialization. Returns a dict suitable for direct JSON serialization.
""" """
mem = psutil.virtual_memory() mem = psutil.virtual_memory()
proc_mem = _process.memory_info()
snapshot = { snapshot = {
"t": datetime.now(timezone.utc).isoformat(), "t": datetime.now(timezone.utc).isoformat(),
"cpu": psutil.cpu_percent(interval=None), "cpu": psutil.cpu_percent(interval=None),
"ram_pct": mem.percent, "ram_pct": mem.percent,
"ram_used": round(mem.used / 1024 / 1024, 1), "ram_used": round(mem.used / 1024 / 1024, 1),
"ram_total": round(mem.total / 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_util": None,
"gpu_temp": None, "gpu_temp": None,
"app_gpu_mem": None,
} }
try: try:
@@ -38,6 +51,14 @@ def _collect_system_snapshot() -> dict:
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU) temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
snapshot["gpu_util"] = float(util.gpu) snapshot["gpu_util"] = float(util.gpu)
snapshot["gpu_temp"] = float(temp) 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: except Exception as e:
logger.debug("GPU metrics collection failed: %s", e) logger.debug("GPU metrics collection failed: %s", e)
pass pass
@@ -104,14 +125,16 @@ class MetricsHistory:
if target_id not in self._targets: if target_id not in self._targets:
self._targets[target_id] = deque(maxlen=MAX_SAMPLES) self._targets[target_id] = deque(maxlen=MAX_SAMPLES)
if state.get("processing"): if state.get("processing"):
self._targets[target_id].append({ self._targets[target_id].append(
{
"t": now, "t": now,
"fps": state.get("fps_actual"), "fps": state.get("fps_actual"),
"fps_current": state.get("fps_current"), "fps_current": state.get("fps_current"),
"fps_target": state.get("fps_target"), "fps_target": state.get("fps_target"),
"timing": state.get("timing_total_ms"), "timing": state.get("timing_total_ms"),
"errors": state.get("errors_count", 0), "errors": state.get("errors_count", 0),
}) }
)
# Prune deques for targets no longer registered # Prune deques for targets no longer registered
for tid in list(self._targets.keys()): for tid in list(self._targets.keys()):

View File

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

View File

@@ -14,7 +14,7 @@ import asyncio
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime 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: if TYPE_CHECKING:
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager 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.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore 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 # Shared dataclasses
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass @dataclass
class ProcessingMetrics: class ProcessingMetrics:
"""Metrics for processing performance.""" """Metrics for processing performance."""
@@ -43,7 +46,9 @@ class ProcessingMetrics:
errors_count: int = 0 errors_count: int = 0
last_error: Optional[str] = None last_error: Optional[str] = None
last_update: Optional[datetime] = 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 start_time: Optional[datetime] = None
fps_actual: float = 0.0 fps_actual: float = 0.0
fps_potential: float = 0.0 fps_potential: float = 0.0
@@ -117,12 +122,14 @@ class TargetContext:
cspt_store: Optional["ColorStripProcessingTemplateStore"] = None cspt_store: Optional["ColorStripProcessingTemplateStore"] = None
fire_event: Callable[[dict], None] = lambda e: None fire_event: Callable[[dict], None] = lambda e: None
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
ha_manager: Optional[Any] = None # HomeAssistantManager (avoid circular import)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Abstract base class # Abstract base class
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TargetProcessor(ABC): class TargetProcessor(ABC):
"""Abstract base class for target processors. """Abstract base class for target processors.

View File

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

View File

@@ -432,3 +432,38 @@
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.8rem; 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 { startEventsWS, stopEventsWS } from './core/events-ws.ts';
import { startEntityEventListeners } from './core/entity-events.ts'; import { startEntityEventListeners } from './core/entity-events.ts';
import { import {
startPerfPolling, stopPerfPolling, startPerfPolling, stopPerfPolling, setPerfMode,
} from './features/perf-charts.ts'; } from './features/perf-charts.ts';
import { import {
loadPictureSources, switchStreamTab, loadPictureSources, switchStreamTab,
@@ -290,6 +290,7 @@ Object.assign(window, {
stopUptimeTimer, stopUptimeTimer,
startPerfPolling, startPerfPolling,
stopPerfPolling, stopPerfPolling,
setPerfMode,
// streams / capture templates / PP templates // streams / capture templates / PP templates
loadPictureSources, 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 { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.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 { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { import {
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK, 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>`; 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) { if (isFirstLoad) {
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section"> 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())} ${_sectionContent('perf', renderPerfSection())}
</div> </div>
<div class="dashboard-dynamic">${dynamicHtml}</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. * 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. * 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 MAX_SAMPLES = 120;
const CHART_KEYS = ['cpu', 'ram', 'gpu']; const CHART_KEYS = ['cpu', 'ram', 'gpu'];
const PERF_MODE_KEY = 'perfMetricsMode';
type PerfMode = 'system' | 'app' | 'both';
let _pollTimer: ReturnType<typeof setInterval> | null = null; let _pollTimer: ReturnType<typeof setInterval> | null = null;
let _charts: Record<string, any> = {}; // { cpu: Chart, ram: Chart, gpu: Chart } let _charts: Record<string, any> = {}; // { cpu: Chart, ram: Chart, gpu: Chart }
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [] }; 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 _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 { function _getColor(key: string): string {
return localStorage.getItem(`perfChartColor_${key}`) return localStorage.getItem(`perfChartColor_${key}`)
@@ -26,6 +32,12 @@ function _getColor(key: string): string {
|| '#4CAF50'; || '#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 { function _onChartColorChange(key: string, hex: string | null): void {
if (hex) { if (hex) {
localStorage.setItem(`perfChartColor_${key}`, hex); localStorage.setItem(`perfChartColor_${key}`, hex);
@@ -41,10 +53,43 @@ function _onChartColorChange(key: string, hex: string | null): void {
if (chart) { if (chart) {
chart.data.datasets[0].borderColor = hex; chart.data.datasets[0].borderColor = hex;
chart.data.datasets[0].backgroundColor = hex + '26'; chart.data.datasets[0].backgroundColor = hex + '26';
chart.data.datasets[1].borderColor = hex + '99';
chart.data.datasets[1].backgroundColor = hex + '14';
chart.update(); 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). */ /** Returns the static HTML for the perf section (canvas placeholders). */
export function renderPerfSection(): string { export function renderPerfSection(): string {
// Register callbacks before rendering // Register callbacks before rendering
@@ -81,11 +126,15 @@ function _createChart(canvasId: string, key: string): any {
const ctx = document.getElementById(canvasId) as HTMLCanvasElement | null; const ctx = document.getElementById(canvasId) as HTMLCanvasElement | null;
if (!ctx) return null; if (!ctx) return null;
const color = _getColor(key); const color = _getColor(key);
const showSystem = _mode === 'system' || _mode === 'both';
const showApp = _mode === 'app' || _mode === 'both';
return new Chart(ctx, { return new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: Array(MAX_SAMPLES).fill(''), labels: Array(MAX_SAMPLES).fill(''),
datasets: [{ datasets: [
{
// System-wide dataset
data: [], data: [],
borderColor: color, borderColor: color,
backgroundColor: color + '26', backgroundColor: color + '26',
@@ -93,7 +142,21 @@ function _createChart(canvasId: string, key: string): any {
tension: 0.3, tension: 0.3,
fill: true, fill: true,
pointRadius: 0, 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: { options: {
responsive: true, responsive: true,
@@ -115,9 +178,12 @@ async function _seedFromServer(): Promise<void> {
const data = await fetchMetricsHistory(); const data = await fetchMetricsHistory();
if (!data) return; if (!data) return;
const samples = data.system || []; const samples = data.system || [];
_history.cpu = samples.map(s => s.cpu).filter(v => v != null); _history.cpu = samples.map((s: any) => s.cpu).filter((v: any) => v != null);
_history.ram = samples.map(s => s.ram_pct).filter(v => v != null); _history.ram = samples.map((s: any) => s.ram_pct).filter((v: any) => v != null);
_history.gpu = samples.map(s => s.gpu_util).filter(v => 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 // Detect GPU availability from history
if (_history.gpu.length > 0) { if (_history.gpu.length > 0) {
@@ -125,11 +191,20 @@ async function _seedFromServer(): Promise<void> {
} }
for (const key of CHART_KEYS) { for (const key of CHART_KEYS) {
if (_charts[key] && _history[key].length > 0) { const chart = _charts[key];
_charts[key].data.datasets[0].data = [..._history[key]]; if (!chart) continue;
_charts[key].data.labels = _history[key].map(() => ''); // System dataset
_charts[key].update(); 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 { } catch {
// Silently ignore — charts will fill from polling // Silently ignore — charts will fill from polling
@@ -151,50 +226,99 @@ function _destroyCharts(): void {
} }
} }
function _pushSample(key: string, value: number): void { function _pushSample(key: string, sysValue: number, appValue: number | null): void {
_history[key].push(value); // System history
_history[key].push(sysValue);
if (_history[key].length > MAX_SAMPLES) _history[key].shift(); 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]; const chart = _charts[key];
if (!chart) return; if (!chart) return;
const ds = chart.data.datasets[0].data;
ds.length = 0; // Update system dataset
ds.push(..._history[key]); const sysDs = chart.data.datasets[0].data;
// Ensure labels array matches length (reuse existing array) sysDs.length = 0;
while (chart.data.labels.length < ds.length) chart.data.labels.push(''); sysDs.push(..._history[key]);
chart.data.labels.length = ds.length;
// 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'); 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> { async function _fetchPerformance(): Promise<void> {
try { try {
const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() }); const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() });
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await resp.json();
// CPU // CPU — app_cpu_percent is in the same scale as cpu_percent (per-core %)
_pushSample('cpu', data.cpu_percent); _pushSample('cpu', data.cpu_percent, data.app_cpu_percent);
const cpuEl = document.getElementById('perf-cpu-value'); 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) { if (data.cpu_name) {
const nameEl = document.getElementById('perf-cpu-name'); const nameEl = document.getElementById('perf-cpu-name');
if (nameEl && !nameEl.textContent) nameEl.textContent = data.cpu_name; if (nameEl && !nameEl.textContent) nameEl.textContent = data.cpu_name;
} }
// RAM // RAM — convert app_ram_mb to percent of total for consistent chart scale
_pushSample('ram', data.ram_percent); 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'); const ramEl = document.getElementById('perf-ram-value');
if (ramEl) { if (ramEl) {
const usedGb = (data.ram_used_mb / 1024).toFixed(1); const usedGb = (data.ram_used_mb / 1024).toFixed(1);
const totalGb = (data.ram_total_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 // GPU
if (data.gpu) { if (data.gpu) {
_hasGpu = true; _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'); 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) { if (data.gpu.name) {
const nameEl = document.getElementById('perf-gpu-name'); const nameEl = document.getElementById('perf-gpu-name');
if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name; if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name;

View File

@@ -11,6 +11,7 @@ import {
_cachedValueSources, valueSourcesCache, _cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache, streamsCache, audioSourcesCache, syncClocksCache,
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache, colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
_cachedHASources, haSourcesCache,
} from '../core/state.ts'; } from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.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 { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts';
import { _splitOpenrgbZone } from './device-discovery.ts'; import { _splitOpenrgbZone } from './device-discovery.ts';
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts'; import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts';
import { createHALightTargetCard, initHALightTargetDelegation } from './ha-light-targets.ts';
import { import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon, getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, 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 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 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: [ 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 }, { 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[] => []), valueSourcesCache.fetch().catch((): any[] => []),
audioSourcesCache.fetch().catch((): any[] => []), audioSourcesCache.fetch().catch((): any[] => []),
syncClocksCache.fetch().catch((): any[] => []), syncClocksCache.fetch().catch((): any[] => []),
haSourcesCache.fetch().catch((): any[] => []),
]); ]);
const colorStripSourceMap = {}; const colorStripSourceMap = {};
@@ -658,6 +662,7 @@ export async function loadTargetsTab() {
const ledDevices = devicesWithState; const ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled'); const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors'); 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 // Update tab badge with running target count
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length; 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-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: '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 // 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 const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab
: activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices'; : 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 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 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 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) }))); const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
// Track which target cards were replaced/added (need chart re-init) // Track which target cards were replaced/added (need chart re-init)
@@ -706,11 +720,13 @@ export async function loadTargetsTab() {
'led-targets': ledTargets.length, 'led-targets': ledTargets.length,
'kc-targets': kcTargets.length, 'kc-targets': kcTargets.length,
'kc-patterns': patternTemplates.length, 'kc-patterns': patternTemplates.length,
'ha-light-targets': haLightTargets.length,
}); });
csDevices.reconcile(deviceItems); csDevices.reconcile(deviceItems);
const ledResult = csLedTargets.reconcile(ledTargetItems); const ledResult = csLedTargets.reconcile(ledTargetItems);
const kcResult = csKCTargets.reconcile(kcTargetItems); const kcResult = csKCTargets.reconcile(kcTargetItems);
csPatternTemplates.reconcile(patternItems); 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[]), 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[])]); ...(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: 'led-targets', html: csLedTargets.render(ledTargetItems) },
{ key: 'kc-targets', html: csKCTargets.render(kcTargetItems) }, { key: 'kc-targets', html: csKCTargets.render(kcTargetItems) },
{ key: 'kc-patterns', html: csPatternTemplates.render(patternItems) }, { 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(''); ].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; 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 // 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>`); _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.gpu": "GPU",
"dashboard.perf.unavailable": "unavailable", "dashboard.perf.unavailable": "unavailable",
"dashboard.perf.color": "Chart color", "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", "dashboard.poll_interval": "Refresh interval",
"automations.title": "Automations", "automations.title": "Automations",
"automations.empty": "No automations configured. Create one to automate scene activation.", "automations.empty": "No automations configured. Create one to automate scene activation.",
@@ -1815,6 +1818,31 @@
"ha_source.deleted": "Home Assistant source deleted", "ha_source.deleted": "Home Assistant source deleted",
"ha_source.delete.confirm": "Delete this Home Assistant connection?", "ha_source.delete.confirm": "Delete this Home Assistant connection?",
"section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.", "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": "Home Assistant",
"automations.condition.home_assistant.desc": "HA entity state", "automations.condition.home_assistant.desc": "HA entity state",
"automations.condition.home_assistant.ha_source": "HA Source:", "automations.condition.home_assistant.ha_source": "HA Source:",

View File

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

View File

@@ -759,6 +759,9 @@
"dashboard.perf.gpu": "GPU", "dashboard.perf.gpu": "GPU",
"dashboard.perf.unavailable": "不可用", "dashboard.perf.unavailable": "不可用",
"dashboard.perf.color": "图表颜色", "dashboard.perf.color": "图表颜色",
"dashboard.perf.mode.system": "系统",
"dashboard.perf.mode.app": "应用",
"dashboard.perf.mode.both": "全部",
"dashboard.poll_interval": "刷新间隔", "dashboard.poll_interval": "刷新间隔",
"automations.title": "自动化", "automations.title": "自动化",
"automations.empty": "尚未配置自动化。创建一个以自动激活场景。", "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.""" """Register this target with the processor manager. Subclasses override."""
pass 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.""" """Push changed fields to a running processor. Subclasses override."""
pass pass
def update_fields(self, *, name=None, device_id=None, picture_source_id=None, def update_fields(
settings=None, key_colors_settings=None, description=None, self,
*,
name=None,
device_id=None,
picture_source_id=None,
settings=None,
key_colors_settings=None,
description=None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
**_kwargs) -> None: **_kwargs,
) -> None:
"""Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones.""" """Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones."""
if name is not None: if name is not None:
self.name = name self.name = name
@@ -60,8 +70,14 @@ class OutputTarget:
target_type = data.get("target_type", "led") target_type = data.get("target_type", "led")
if target_type == "led": if target_type == "led":
from wled_controller.storage.wled_output_target import WledOutputTarget from wled_controller.storage.wled_output_target import WledOutputTarget
return WledOutputTarget.from_dict(data) return WledOutputTarget.from_dict(data)
if target_type == "key_colors": if target_type == "key_colors":
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget
return KeyColorsOutputTarget.from_dict(data) 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}") raise ValueError(f"Unknown target type: {target_type}")

View File

@@ -12,6 +12,10 @@ from wled_controller.storage.key_colors_output_target import (
KeyColorsSettings, KeyColorsSettings,
KeyColorsOutputTarget, KeyColorsOutputTarget,
) )
from wled_controller.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
)
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -50,13 +54,18 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
description: Optional[str] = None, description: Optional[str] = None,
picture_source_id: str = "", picture_source_id: str = "",
tags: Optional[List[str]] = None, 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: ) -> OutputTarget:
"""Create a new output target. """Create a new output target.
Raises: Raises:
ValueError: If validation fails 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}") raise ValueError(f"Invalid target type: {target_type}")
# Check for duplicate name # Check for duplicate name
@@ -96,6 +105,22 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
created_at=now, created_at=now,
updated_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: else:
raise ValueError(f"Unknown target type: {target_type}") 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]: def get_targets_for_device(self, device_id: str) -> List[OutputTarget]:
"""Get all targets that reference a specific device.""" """Get all targets that reference a specific device."""
return [ 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 if isinstance(t, WledOutputTarget) and t.device_id == device_id
] ]
def get_targets_referencing_source(self, source_id: str) -> List[str]: def get_targets_referencing_source(self, source_id: str) -> List[str]:
"""Return names of KC targets that reference a picture source.""" """Return names of KC targets that reference a picture source."""
return [ 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 if isinstance(target, KeyColorsOutputTarget) and target.picture_source_id == source_id
] ]
def get_targets_referencing_css(self, css_id: str) -> List[str]: def get_targets_referencing_css(self, css_id: str) -> List[str]:
"""Return names of LED targets that reference a color strip source.""" """Return names of targets that reference a color strip source."""
return [ result = []
target.name for target in self._items.values() for target in self._items.values():
if isinstance(target, WledOutputTarget) if isinstance(target, WledOutputTarget) and target.color_strip_source_id == css_id:
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: def count(self) -> int:
"""Get number of targets.""" """Get number of targets."""

View File

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