feat: HA light output targets — cast LED colors to Home Assistant lights
Some checks failed
Lint & Test / test (push) Has been cancelled
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:
@@ -25,6 +25,11 @@ from wled_controller.storage.key_colors_output_target import (
|
||||
KeyColorsSettings,
|
||||
KeyColorsOutputTarget,
|
||||
)
|
||||
from wled_controller.storage.ha_light_output_target import (
|
||||
HALightMapping,
|
||||
HALightOutputTarget,
|
||||
)
|
||||
from wled_controller.api.schemas.output_targets import HALightMappingSchema
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
@@ -76,7 +81,6 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
protocol=target.protocol,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
@@ -89,7 +93,31 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
key_colors_settings=_kc_settings_to_schema(target.settings),
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
elif isinstance(target, HALightOutputTarget):
|
||||
return OutputTargetResponse(
|
||||
id=target.id,
|
||||
name=target.name,
|
||||
target_type=target.target_type,
|
||||
ha_source_id=target.ha_source_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
ha_light_mappings=[
|
||||
HALightMappingSchema(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=m.brightness_scale,
|
||||
)
|
||||
for m in target.light_mappings
|
||||
],
|
||||
update_rate=target.update_rate,
|
||||
ha_transition=target.transition,
|
||||
color_tolerance=target.color_tolerance,
|
||||
min_brightness_threshold=target.min_brightness_threshold,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
@@ -100,7 +128,6 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
target_type=target.target_type,
|
||||
description=target.description,
|
||||
tags=target.tags,
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
)
|
||||
@@ -108,7 +135,10 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
|
||||
# ===== CRUD ENDPOINTS =====
|
||||
|
||||
@router.post("/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201)
|
||||
|
||||
@router.post(
|
||||
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
|
||||
)
|
||||
async def create_target(
|
||||
data: OutputTargetCreate,
|
||||
_auth: AuthRequired,
|
||||
@@ -125,7 +155,22 @@ async def create_target(
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||
|
||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
||||
kc_settings = (
|
||||
_kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
||||
)
|
||||
ha_mappings = (
|
||||
[
|
||||
HALightMapping(
|
||||
entity_id=m.entity_id,
|
||||
led_start=m.led_start,
|
||||
led_end=m.led_end,
|
||||
brightness_scale=m.brightness_scale,
|
||||
)
|
||||
for m in data.ha_light_mappings
|
||||
]
|
||||
if data.ha_light_mappings
|
||||
else None
|
||||
)
|
||||
|
||||
# Create in store
|
||||
target = target_store.create_target(
|
||||
@@ -144,6 +189,11 @@ async def create_target(
|
||||
key_colors_settings=kc_settings,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
ha_source_id=data.ha_source_id,
|
||||
ha_light_mappings=ha_mappings,
|
||||
update_rate=data.update_rate,
|
||||
transition=data.transition,
|
||||
color_tolerance=data.color_tolerance,
|
||||
)
|
||||
|
||||
# Register in processor manager
|
||||
@@ -196,7 +246,9 @@ async def batch_target_metrics(
|
||||
return {"metrics": manager.get_all_target_metrics()}
|
||||
|
||||
|
||||
@router.get("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"])
|
||||
@router.get(
|
||||
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
|
||||
)
|
||||
async def get_target(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
@@ -210,7 +262,9 @@ async def get_target(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"])
|
||||
@router.put(
|
||||
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
|
||||
)
|
||||
async def update_target(
|
||||
target_id: str,
|
||||
data: OutputTargetUpdate,
|
||||
@@ -246,7 +300,9 @@ async def update_target(
|
||||
smoothing=incoming.get("smoothing", ex.smoothing),
|
||||
pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id),
|
||||
brightness=incoming.get("brightness", ex.brightness),
|
||||
brightness_value_source_id=incoming.get("brightness_value_source_id", ex.brightness_value_source_id),
|
||||
brightness_value_source_id=incoming.get(
|
||||
"brightness_value_source_id", ex.brightness_value_source_id
|
||||
),
|
||||
)
|
||||
kc_settings = _kc_schema_to_settings(merged)
|
||||
else:
|
||||
@@ -282,14 +338,18 @@ async def update_target(
|
||||
await asyncio.to_thread(
|
||||
target.sync_with_manager,
|
||||
manager,
|
||||
settings_changed=(data.fps is not None or
|
||||
data.keepalive_interval is not None or
|
||||
data.state_check_interval is not None or
|
||||
data.min_brightness_threshold is not None or
|
||||
data.adaptive_fps is not None or
|
||||
data.key_colors_settings is not None),
|
||||
settings_changed=(
|
||||
data.fps is not None
|
||||
or data.keepalive_interval is not None
|
||||
or data.state_check_interval is not None
|
||||
or data.min_brightness_threshold is not None
|
||||
or data.adaptive_fps is not None
|
||||
or data.key_colors_settings is not None
|
||||
),
|
||||
css_changed=data.color_strip_source_id is not None,
|
||||
brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed),
|
||||
brightness_vs_changed=(
|
||||
data.brightness_value_source_id is not None or kc_brightness_vs_changed
|
||||
),
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
|
||||
|
||||
@@ -10,6 +10,8 @@ import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import os
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
@@ -52,8 +54,10 @@ from wled_controller.api.routes.system_settings import load_external_url # noqa
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Prime psutil CPU counter (first call always returns 0.0)
|
||||
# Prime psutil CPU counters (first call always returns 0.0)
|
||||
psutil.cpu_percent(interval=None)
|
||||
_process = psutil.Process(os.getpid())
|
||||
_process.cpu_percent(interval=None) # prime process-level counter
|
||||
|
||||
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
|
||||
from wled_controller.utils.gpu import ( # noqa: E402
|
||||
@@ -264,18 +268,36 @@ def get_system_performance(_: AuthRequired):
|
||||
"""
|
||||
mem = psutil.virtual_memory()
|
||||
|
||||
# App-level metrics
|
||||
proc_mem = _process.memory_info()
|
||||
app_cpu = _process.cpu_percent(interval=None)
|
||||
app_ram_mb = round(proc_mem.rss / 1024 / 1024, 1)
|
||||
|
||||
gpu = None
|
||||
if _nvml_available:
|
||||
try:
|
||||
util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle)
|
||||
mem_info = _nvml.nvmlDeviceGetMemoryInfo(_nvml_handle)
|
||||
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
|
||||
|
||||
# App GPU memory: sum memory used by this process on the GPU
|
||||
app_gpu_mem: float | None = None
|
||||
try:
|
||||
pid = os.getpid()
|
||||
for proc_info in _nvml.nvmlDeviceGetComputeRunningProcesses(_nvml_handle):
|
||||
if proc_info.pid == pid and proc_info.usedGpuMemory:
|
||||
app_gpu_mem = round(proc_info.usedGpuMemory / 1024 / 1024, 1)
|
||||
break
|
||||
except Exception:
|
||||
pass # not all drivers support per-process queries
|
||||
|
||||
gpu = GpuInfo(
|
||||
name=_nvml.nvmlDeviceGetName(_nvml_handle),
|
||||
utilization=float(util.gpu),
|
||||
memory_used_mb=round(mem_info.used / 1024 / 1024, 1),
|
||||
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
|
||||
temperature_c=float(temp),
|
||||
app_memory_mb=app_gpu_mem,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("NVML query failed: %s", e)
|
||||
@@ -286,6 +308,8 @@ def get_system_performance(_: AuthRequired):
|
||||
ram_used_mb=round(mem.used / 1024 / 1024, 1),
|
||||
ram_total_mb=round(mem.total / 1024 / 1024, 1),
|
||||
ram_percent=mem.percent,
|
||||
app_cpu_percent=app_cpu,
|
||||
app_ram_mb=app_ram_mb,
|
||||
gpu=gpu,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
@@ -22,10 +22,18 @@ class KeyColorsSettingsSchema(BaseModel):
|
||||
"""Settings for key colors extraction."""
|
||||
|
||||
fps: int = Field(default=10, description="Extraction rate (1-60)", ge=1, le=60)
|
||||
interpolation_mode: str = Field(default="average", description="Color mode (average, median, dominant)")
|
||||
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
pattern_template_id: str = Field(default="", description="Pattern template ID for rectangle layout")
|
||||
brightness: float = Field(default=1.0, description="Output brightness (0.0-1.0)", ge=0.0, le=1.0)
|
||||
interpolation_mode: str = Field(
|
||||
default="average", description="Color mode (average, median, dominant)"
|
||||
)
|
||||
smoothing: float = Field(
|
||||
default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0
|
||||
)
|
||||
pattern_template_id: str = Field(
|
||||
default="", description="Pattern template ID for rectangle layout"
|
||||
)
|
||||
brightness: float = Field(
|
||||
default=1.0, description="Output brightness (0.0-1.0)", ge=0.0, le=1.0
|
||||
)
|
||||
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
||||
|
||||
|
||||
@@ -46,24 +54,79 @@ class KeyColorsResponse(BaseModel):
|
||||
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
|
||||
|
||||
|
||||
class HALightMappingSchema(BaseModel):
|
||||
"""Maps an LED range to one HA light entity."""
|
||||
|
||||
entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')")
|
||||
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
|
||||
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
|
||||
brightness_scale: float = Field(
|
||||
default=1.0, ge=0.0, le=1.0, description="Brightness multiplier"
|
||||
)
|
||||
|
||||
|
||||
class OutputTargetCreate(BaseModel):
|
||||
"""Request to create an output target."""
|
||||
|
||||
name: str = Field(description="Target name", min_length=1, max_length=100)
|
||||
target_type: str = Field(default="led", description="Target type (led, key_colors)")
|
||||
target_type: str = Field(default="led", description="Target type (led, key_colors, ha_light)")
|
||||
# LED target fields
|
||||
device_id: str = Field(default="", description="LED device ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
||||
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
|
||||
keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
|
||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
|
||||
min_brightness_threshold: int = Field(default=0, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
|
||||
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
|
||||
protocol: str = Field(default="ddp", pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)")
|
||||
keepalive_interval: float = Field(
|
||||
default=1.0,
|
||||
description="Keepalive send interval when screen is static (0.5-5.0s)",
|
||||
ge=0.5,
|
||||
le=5.0,
|
||||
)
|
||||
state_check_interval: int = Field(
|
||||
default=DEFAULT_STATE_CHECK_INTERVAL,
|
||||
description="Device health check interval (5-600s)",
|
||||
ge=5,
|
||||
le=600,
|
||||
)
|
||||
min_brightness_threshold: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
le=254,
|
||||
description="Min brightness threshold (0=disabled); below this → off",
|
||||
)
|
||||
adaptive_fps: bool = Field(
|
||||
default=False, description="Auto-reduce FPS when device is unresponsive"
|
||||
)
|
||||
protocol: str = Field(
|
||||
default="ddp",
|
||||
pattern="^(ddp|http)$",
|
||||
description="Send protocol: ddp (UDP) or http (JSON API)",
|
||||
)
|
||||
# KC target fields
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
||||
picture_source_id: str = Field(
|
||||
default="", description="Picture source ID (for key_colors targets)"
|
||||
)
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
|
||||
None, description="Key colors settings (for key_colors targets)"
|
||||
)
|
||||
# HA light target fields
|
||||
ha_source_id: str = Field(
|
||||
default="", description="Home Assistant source ID (for ha_light targets)"
|
||||
)
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
None, description="LED-to-light mappings (for ha_light targets)"
|
||||
)
|
||||
update_rate: float = Field(
|
||||
default=2.0, ge=0.5, le=5.0, description="Service call rate in Hz (for ha_light targets)"
|
||||
)
|
||||
transition: float = Field(
|
||||
default=0.5, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
|
||||
)
|
||||
color_tolerance: int = Field(
|
||||
default=5,
|
||||
ge=0,
|
||||
le=50,
|
||||
description="Skip service call if RGB delta < this (for ha_light targets)",
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
@@ -75,16 +138,48 @@ class OutputTargetUpdate(BaseModel):
|
||||
# LED target fields
|
||||
device_id: Optional[str] = Field(None, description="LED device ID")
|
||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||
brightness_value_source_id: Optional[str] = Field(None, description="Brightness value source ID")
|
||||
brightness_value_source_id: Optional[str] = Field(
|
||||
None, description="Brightness value source ID"
|
||||
)
|
||||
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
|
||||
keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
|
||||
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
|
||||
min_brightness_threshold: Optional[int] = Field(None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
|
||||
adaptive_fps: Optional[bool] = Field(None, description="Auto-reduce FPS when device is unresponsive")
|
||||
protocol: Optional[str] = Field(None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)")
|
||||
keepalive_interval: Optional[float] = Field(
|
||||
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
|
||||
)
|
||||
state_check_interval: Optional[int] = Field(
|
||||
None, description="Health check interval (5-600s)", ge=5, le=600
|
||||
)
|
||||
min_brightness_threshold: Optional[int] = Field(
|
||||
None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off"
|
||||
)
|
||||
adaptive_fps: Optional[bool] = Field(
|
||||
None, description="Auto-reduce FPS when device is unresponsive"
|
||||
)
|
||||
protocol: Optional[str] = Field(
|
||||
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
|
||||
)
|
||||
# KC target fields
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
||||
picture_source_id: Optional[str] = Field(
|
||||
None, description="Picture source ID (for key_colors targets)"
|
||||
)
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
|
||||
None, description="Key colors settings (for key_colors targets)"
|
||||
)
|
||||
# HA light target fields
|
||||
ha_source_id: Optional[str] = Field(
|
||||
None, description="Home Assistant source ID (for ha_light targets)"
|
||||
)
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
None, description="LED-to-light mappings (for ha_light targets)"
|
||||
)
|
||||
update_rate: Optional[float] = Field(
|
||||
None, ge=0.5, le=5.0, description="Service call rate Hz (for ha_light targets)"
|
||||
)
|
||||
transition: Optional[float] = Field(
|
||||
None, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
|
||||
)
|
||||
color_tolerance: Optional[int] = Field(
|
||||
None, ge=0, le=50, description="RGB delta tolerance (for ha_light targets)"
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
@@ -101,13 +196,29 @@ class OutputTargetResponse(BaseModel):
|
||||
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
||||
fps: Optional[int] = Field(None, description="Target send FPS")
|
||||
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
|
||||
min_brightness_threshold: int = Field(default=0, description="Min brightness threshold (0=disabled)")
|
||||
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
|
||||
state_check_interval: int = Field(
|
||||
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
|
||||
)
|
||||
min_brightness_threshold: int = Field(
|
||||
default=0, description="Min brightness threshold (0=disabled)"
|
||||
)
|
||||
adaptive_fps: bool = Field(
|
||||
default=False, description="Auto-reduce FPS when device is unresponsive"
|
||||
)
|
||||
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
|
||||
# KC target fields
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
|
||||
None, description="Key colors settings"
|
||||
)
|
||||
# HA light target fields
|
||||
ha_source_id: str = Field(default="", description="Home Assistant source ID (ha_light)")
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
None, description="LED-to-light mappings (ha_light)"
|
||||
)
|
||||
update_rate: Optional[float] = Field(None, description="Service call rate Hz (ha_light)")
|
||||
ha_transition: Optional[float] = Field(None, description="HA transition seconds (ha_light)")
|
||||
color_tolerance: Optional[int] = Field(None, description="RGB delta tolerance (ha_light)")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
@@ -129,23 +240,39 @@ class TargetProcessingState(BaseModel):
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
processing: bool = Field(description="Whether processing is active")
|
||||
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
|
||||
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
|
||||
fps_potential: Optional[float] = Field(
|
||||
None, description="Potential FPS (processing speed without throttle)"
|
||||
)
|
||||
fps_target: Optional[int] = Field(None, description="Target FPS")
|
||||
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
|
||||
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
|
||||
frames_keepalive: Optional[int] = Field(
|
||||
None, description="Keepalive frames sent during standby"
|
||||
)
|
||||
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
|
||||
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
|
||||
timing_extract_ms: Optional[float] = Field(None, description="Border pixel extraction time (ms)")
|
||||
timing_extract_ms: Optional[float] = Field(
|
||||
None, description="Border pixel extraction time (ms)"
|
||||
)
|
||||
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
|
||||
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
|
||||
timing_total_ms: Optional[float] = Field(None, description="Total processing time per frame (ms)")
|
||||
timing_total_ms: Optional[float] = Field(
|
||||
None, description="Total processing time per frame (ms)"
|
||||
)
|
||||
timing_audio_read_ms: Optional[float] = Field(None, description="Audio device read time (ms)")
|
||||
timing_audio_fft_ms: Optional[float] = Field(None, description="Audio FFT analysis time (ms)")
|
||||
timing_audio_render_ms: Optional[float] = Field(None, description="Audio visualization render time (ms)")
|
||||
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
|
||||
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
|
||||
timing_audio_render_ms: Optional[float] = Field(
|
||||
None, description="Audio visualization render time (ms)"
|
||||
)
|
||||
timing_calc_colors_ms: Optional[float] = Field(
|
||||
None, description="Color calculation time (ms, KC targets)"
|
||||
)
|
||||
timing_broadcast_ms: Optional[float] = Field(
|
||||
None, description="WebSocket broadcast time (ms, KC targets)"
|
||||
)
|
||||
display_index: Optional[int] = Field(None, description="Current display index")
|
||||
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
|
||||
overlay_active: bool = Field(
|
||||
default=False, description="Whether visualization overlay is active"
|
||||
)
|
||||
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
||||
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
||||
device_online: bool = Field(default=False, description="Whether device is reachable")
|
||||
@@ -154,11 +281,17 @@ class TargetProcessingState(BaseModel):
|
||||
device_version: Optional[str] = Field(None, description="Firmware version")
|
||||
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
|
||||
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
|
||||
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
|
||||
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
|
||||
device_led_type: Optional[str] = Field(
|
||||
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
|
||||
)
|
||||
device_fps: Optional[int] = Field(
|
||||
None, description="Device-reported FPS (WLED internal refresh rate)"
|
||||
)
|
||||
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
||||
device_error: Optional[str] = Field(None, description="Last health check error")
|
||||
device_streaming_reachable: Optional[bool] = Field(None, description="Device reachable during streaming (HTTP probe)")
|
||||
device_streaming_reachable: Optional[bool] = Field(
|
||||
None, description="Device reachable during streaming (HTTP probe)"
|
||||
)
|
||||
fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction")
|
||||
|
||||
|
||||
@@ -186,9 +319,15 @@ class BulkTargetRequest(BaseModel):
|
||||
class BulkTargetResponse(BaseModel):
|
||||
"""Response for bulk start/stop operations."""
|
||||
|
||||
started: List[str] = Field(default_factory=list, description="IDs that were successfully started")
|
||||
stopped: List[str] = Field(default_factory=list, description="IDs that were successfully stopped")
|
||||
errors: Dict[str, str] = Field(default_factory=dict, description="Map of target ID to error message for failures")
|
||||
started: List[str] = Field(
|
||||
default_factory=list, description="IDs that were successfully started"
|
||||
)
|
||||
stopped: List[str] = Field(
|
||||
default_factory=list, description="IDs that were successfully stopped"
|
||||
)
|
||||
errors: Dict[str, str] = Field(
|
||||
default_factory=dict, description="Map of target ID to error message for failures"
|
||||
)
|
||||
|
||||
|
||||
class KCTestRectangleResponse(BaseModel):
|
||||
@@ -206,6 +345,8 @@ class KCTestResponse(BaseModel):
|
||||
"""Response from testing a KC target."""
|
||||
|
||||
image: str = Field(description="Base64 data URI of the captured frame")
|
||||
rectangles: List[KCTestRectangleResponse] = Field(description="Rectangles with extracted colors")
|
||||
rectangles: List[KCTestRectangleResponse] = Field(
|
||||
description="Rectangles with extracted colors"
|
||||
)
|
||||
interpolation_mode: str = Field(description="Color extraction mode used")
|
||||
pattern_template_name: str = Field(description="Pattern template name")
|
||||
|
||||
@@ -64,6 +64,9 @@ class GpuInfo(BaseModel):
|
||||
memory_used_mb: float | None = Field(default=None, description="GPU memory used in MB")
|
||||
memory_total_mb: float | None = Field(default=None, description="GPU total memory in MB")
|
||||
temperature_c: float | None = Field(default=None, description="GPU temperature in Celsius")
|
||||
app_memory_mb: float | None = Field(
|
||||
default=None, description="GPU memory used by this app in MB"
|
||||
)
|
||||
|
||||
|
||||
class PerformanceResponse(BaseModel):
|
||||
@@ -74,6 +77,8 @@ class PerformanceResponse(BaseModel):
|
||||
ram_used_mb: float = Field(description="RAM used in MB")
|
||||
ram_total_mb: float = Field(description="RAM total in MB")
|
||||
ram_percent: float = Field(description="RAM usage percent")
|
||||
app_cpu_percent: float = Field(description="App process CPU usage percent")
|
||||
app_ram_mb: float = Field(description="App process resident memory in MB")
|
||||
gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)")
|
||||
timestamp: datetime = Field(description="Measurement timestamp")
|
||||
|
||||
|
||||
@@ -87,6 +87,16 @@ class HomeAssistantManager:
|
||||
runtime, _count = self._runtimes[source_id]
|
||||
return runtime
|
||||
|
||||
async def call_service(
|
||||
self, source_id: str, domain: str, service: str, service_data: dict, target: dict
|
||||
) -> bool:
|
||||
"""Call a HA service via the runtime for the given source. Returns success."""
|
||||
entry = self._runtimes.get(source_id)
|
||||
if entry is None:
|
||||
return False
|
||||
runtime, _count = entry
|
||||
return await runtime.call_service(domain, service, service_data, target)
|
||||
|
||||
async def update_source(self, source_id: str) -> None:
|
||||
"""Hot-update runtime config when the source is edited."""
|
||||
entry = self._runtimes.get(source_id)
|
||||
|
||||
@@ -54,6 +54,7 @@ class HARuntime:
|
||||
|
||||
# Async task management
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._ws: Any = None # live websocket connection (set during _connection_loop)
|
||||
self._connected = False
|
||||
self._msg_id = 0
|
||||
|
||||
@@ -88,6 +89,34 @@ class HARuntime:
|
||||
if not self._callbacks[entity_id]:
|
||||
del self._callbacks[entity_id]
|
||||
|
||||
async def call_service(
|
||||
self, domain: str, service: str, service_data: dict, target: dict
|
||||
) -> bool:
|
||||
"""Call a HA service (e.g. light.turn_on). Fire-and-forget.
|
||||
|
||||
Returns True if the message was sent, False if not connected.
|
||||
"""
|
||||
if not self._connected or self._ws is None:
|
||||
return False
|
||||
try:
|
||||
msg_id = self._next_id()
|
||||
await self._ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "call_service",
|
||||
"domain": domain,
|
||||
"service": service,
|
||||
"service_data": service_data,
|
||||
"target": target,
|
||||
}
|
||||
)
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"HA call_service failed ({domain}.{service}): {e}")
|
||||
return False
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the WebSocket connection loop."""
|
||||
if self._task is not None:
|
||||
@@ -161,6 +190,7 @@ class HARuntime:
|
||||
await asyncio.sleep(self._RECONNECT_DELAY)
|
||||
continue
|
||||
|
||||
self._ws = ws
|
||||
self._connected = True
|
||||
logger.info(
|
||||
f"HA connected: {self._source_id} (version {msg.get('ha_version', '?')})"
|
||||
@@ -204,8 +234,10 @@ class HARuntime:
|
||||
self._handle_state_changed(event.get("data", {}))
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self._ws = None
|
||||
break
|
||||
except Exception as e:
|
||||
self._ws = None
|
||||
self._connected = False
|
||||
logger.warning(
|
||||
f"HA connection lost ({self._source_id}): {e}. Reconnecting in {self._RECONNECT_DELAY}s..."
|
||||
|
||||
@@ -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)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Server-side ring buffer for system and per-target metrics."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional
|
||||
@@ -8,7 +9,11 @@ from typing import Dict, Optional
|
||||
import psutil
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
|
||||
from wled_controller.utils.gpu import (
|
||||
nvml_available as _nvml_available,
|
||||
nvml as _nvml,
|
||||
nvml_handle as _nvml_handle,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -16,20 +21,28 @@ MAX_SAMPLES = 120 # ~2 minutes at 1-second interval
|
||||
SAMPLE_INTERVAL = 1.0 # seconds
|
||||
|
||||
|
||||
_process = psutil.Process(os.getpid())
|
||||
_process.cpu_percent(interval=None) # prime process-level counter
|
||||
|
||||
|
||||
def _collect_system_snapshot() -> dict:
|
||||
"""Collect CPU/RAM/GPU metrics (blocking — run in thread pool).
|
||||
|
||||
Returns a dict suitable for direct JSON serialization.
|
||||
"""
|
||||
mem = psutil.virtual_memory()
|
||||
proc_mem = _process.memory_info()
|
||||
snapshot = {
|
||||
"t": datetime.now(timezone.utc).isoformat(),
|
||||
"cpu": psutil.cpu_percent(interval=None),
|
||||
"ram_pct": mem.percent,
|
||||
"ram_used": round(mem.used / 1024 / 1024, 1),
|
||||
"ram_total": round(mem.total / 1024 / 1024, 1),
|
||||
"app_cpu": _process.cpu_percent(interval=None),
|
||||
"app_ram": round(proc_mem.rss / 1024 / 1024, 1),
|
||||
"gpu_util": None,
|
||||
"gpu_temp": None,
|
||||
"app_gpu_mem": None,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -38,6 +51,14 @@ def _collect_system_snapshot() -> dict:
|
||||
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
|
||||
snapshot["gpu_util"] = float(util.gpu)
|
||||
snapshot["gpu_temp"] = float(temp)
|
||||
try:
|
||||
pid = os.getpid()
|
||||
for proc_info in _nvml.nvmlDeviceGetComputeRunningProcesses(_nvml_handle):
|
||||
if proc_info.pid == pid and proc_info.usedGpuMemory:
|
||||
snapshot["app_gpu_mem"] = round(proc_info.usedGpuMemory / 1024 / 1024, 1)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("GPU metrics collection failed: %s", e)
|
||||
pass
|
||||
@@ -104,14 +125,16 @@ class MetricsHistory:
|
||||
if target_id not in self._targets:
|
||||
self._targets[target_id] = deque(maxlen=MAX_SAMPLES)
|
||||
if state.get("processing"):
|
||||
self._targets[target_id].append({
|
||||
"t": now,
|
||||
"fps": state.get("fps_actual"),
|
||||
"fps_current": state.get("fps_current"),
|
||||
"fps_target": state.get("fps_target"),
|
||||
"timing": state.get("timing_total_ms"),
|
||||
"errors": state.get("errors_count", 0),
|
||||
})
|
||||
self._targets[target_id].append(
|
||||
{
|
||||
"t": now,
|
||||
"fps": state.get("fps_actual"),
|
||||
"fps_current": state.get("fps_current"),
|
||||
"fps_target": state.get("fps_target"),
|
||||
"timing": state.get("timing_total_ms"),
|
||||
"errors": state.get("errors_count", 0),
|
||||
}
|
||||
)
|
||||
|
||||
# Prune deques for targets no longer registered
|
||||
for tid in list(self._targets.keys()):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -31,7 +31,9 @@ from wled_controller.core.processing.auto_restart import (
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.audio_template_store import AudioTemplateStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import (
|
||||
ColorStripProcessingTemplateStore,
|
||||
)
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.gradient_store import GradientStore
|
||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
@@ -72,6 +74,7 @@ class ProcessorDependencies:
|
||||
gradient_store: Optional[GradientStore] = None
|
||||
weather_manager: Optional[WeatherManager] = None
|
||||
asset_store: Optional[AssetStore] = None
|
||||
ha_manager: Optional[Any] = None # HomeAssistantManager
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -134,7 +137,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
self._value_source_store = deps.value_source_store
|
||||
self._cspt_store = deps.cspt_store
|
||||
self._live_stream_manager = LiveStreamManager(
|
||||
deps.picture_source_store, deps.capture_template_store, deps.pp_template_store,
|
||||
deps.picture_source_store,
|
||||
deps.capture_template_store,
|
||||
deps.pp_template_store,
|
||||
asset_store=deps.asset_store,
|
||||
)
|
||||
self._audio_capture_manager = AudioCaptureManager()
|
||||
@@ -151,15 +156,20 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
weather_manager=deps.weather_manager,
|
||||
asset_store=deps.asset_store,
|
||||
)
|
||||
self._value_stream_manager = ValueStreamManager(
|
||||
value_source_store=deps.value_source_store,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=deps.audio_source_store,
|
||||
live_stream_manager=self._live_stream_manager,
|
||||
audio_template_store=deps.audio_template_store,
|
||||
) if deps.value_source_store else None
|
||||
self._value_stream_manager = (
|
||||
ValueStreamManager(
|
||||
value_source_store=deps.value_source_store,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=deps.audio_source_store,
|
||||
live_stream_manager=self._live_stream_manager,
|
||||
audio_template_store=deps.audio_template_store,
|
||||
)
|
||||
if deps.value_source_store
|
||||
else None
|
||||
)
|
||||
# Wire value stream manager into CSS stream manager for composite layer brightness
|
||||
self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager
|
||||
self._ha_manager = deps.ha_manager
|
||||
self._overlay_manager = OverlayManager()
|
||||
self._event_queues: List[asyncio.Queue] = []
|
||||
self._metrics_history = MetricsHistory(self)
|
||||
@@ -199,15 +209,24 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
cspt_store=self._cspt_store,
|
||||
fire_event=self.fire_event,
|
||||
get_device_info=self._get_device_info,
|
||||
ha_manager=self._ha_manager,
|
||||
)
|
||||
|
||||
# Default values for device-specific fields read from persistent storage
|
||||
_DEVICE_FIELD_DEFAULTS = {
|
||||
"send_latency_ms": 0, "rgbw": False, "dmx_protocol": "artnet",
|
||||
"dmx_start_universe": 0, "dmx_start_channel": 1, "espnow_peer_mac": "",
|
||||
"espnow_channel": 1, "hue_username": "", "hue_client_key": "",
|
||||
"hue_entertainment_group_id": "", "spi_speed_hz": 800000,
|
||||
"spi_led_type": "WS2812B", "chroma_device_type": "chromalink",
|
||||
"send_latency_ms": 0,
|
||||
"rgbw": False,
|
||||
"dmx_protocol": "artnet",
|
||||
"dmx_start_universe": 0,
|
||||
"dmx_start_channel": 1,
|
||||
"espnow_peer_mac": "",
|
||||
"espnow_channel": 1,
|
||||
"hue_username": "",
|
||||
"hue_client_key": "",
|
||||
"hue_entertainment_group_id": "",
|
||||
"spi_speed_hz": 800000,
|
||||
"spi_led_type": "WS2812B",
|
||||
"chroma_device_type": "chromalink",
|
||||
"gamesense_device_type": "keyboard",
|
||||
}
|
||||
|
||||
@@ -228,11 +247,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
pass
|
||||
|
||||
return DeviceInfo(
|
||||
device_id=ds.device_id, device_url=ds.device_url,
|
||||
led_count=ds.led_count, device_type=ds.device_type,
|
||||
baud_rate=ds.baud_rate, software_brightness=ds.software_brightness,
|
||||
test_mode_active=ds.test_mode_active, zone_mode=ds.zone_mode,
|
||||
auto_shutdown=ds.auto_shutdown, **extras,
|
||||
device_id=ds.device_id,
|
||||
device_url=ds.device_url,
|
||||
led_count=ds.led_count,
|
||||
device_type=ds.device_type,
|
||||
baud_rate=ds.baud_rate,
|
||||
software_brightness=ds.software_brightness,
|
||||
test_mode_active=ds.test_mode_active,
|
||||
zone_mode=ds.zone_mode,
|
||||
auto_shutdown=ds.auto_shutdown,
|
||||
**extras,
|
||||
)
|
||||
|
||||
# ===== EVENT SYSTEM (state change notifications) =====
|
||||
@@ -314,7 +338,13 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
del self._devices[device_id]
|
||||
logger.info(f"Unregistered device {device_id}")
|
||||
|
||||
def update_device_info(self, device_id: str, device_url: Optional[str] = None, led_count: Optional[int] = None, baud_rate: Optional[int] = None):
|
||||
def update_device_info(
|
||||
self,
|
||||
device_id: str,
|
||||
device_url: Optional[str] = None,
|
||||
led_count: Optional[int] = None,
|
||||
baud_rate: Optional[int] = None,
|
||||
):
|
||||
"""Update device connection info."""
|
||||
if device_id not in self._devices:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
@@ -440,6 +470,37 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
self._processors[target_id] = proc
|
||||
logger.info(f"Registered KC target: {target_id}")
|
||||
|
||||
def add_ha_light_target(
|
||||
self,
|
||||
target_id: str,
|
||||
ha_source_id: str,
|
||||
color_strip_source_id: str = "",
|
||||
light_mappings=None,
|
||||
update_rate: float = 2.0,
|
||||
transition: float = 0.5,
|
||||
min_brightness_threshold: int = 0,
|
||||
color_tolerance: int = 5,
|
||||
) -> None:
|
||||
"""Register a Home Assistant light target processor."""
|
||||
if target_id in self._processors:
|
||||
raise ValueError(f"HA light target {target_id} already registered")
|
||||
|
||||
from wled_controller.core.processing.ha_light_target_processor import HALightTargetProcessor
|
||||
|
||||
proc = HALightTargetProcessor(
|
||||
target_id=target_id,
|
||||
ha_source_id=ha_source_id,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
light_mappings=light_mappings or [],
|
||||
update_rate=update_rate,
|
||||
transition=transition,
|
||||
min_brightness_threshold=min_brightness_threshold,
|
||||
color_tolerance=color_tolerance,
|
||||
ctx=self._build_context(),
|
||||
)
|
||||
self._processors[target_id] = proc
|
||||
logger.info(f"Registered HA light target: {target_id}")
|
||||
|
||||
def remove_target(self, target_id: str):
|
||||
"""Unregister a target (any type)."""
|
||||
if target_id not in self._processors:
|
||||
@@ -499,7 +560,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
await self.start_processing(target_id)
|
||||
logger.info(
|
||||
"Hot-switch complete for target %s -> device %s",
|
||||
target_id, device_id,
|
||||
target_id,
|
||||
device_id,
|
||||
)
|
||||
|
||||
def update_target_brightness_vs(self, target_id: str, vs_id: str):
|
||||
@@ -520,11 +582,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
# Enforce one-target-per-device for device-aware targets
|
||||
if proc.device_id is not None:
|
||||
for other_id, other in self._processors.items():
|
||||
if (
|
||||
other_id != target_id
|
||||
and other.device_id == proc.device_id
|
||||
and other.is_running
|
||||
):
|
||||
if other_id != target_id and other.device_id == proc.device_id and other.is_running:
|
||||
# Stale state guard: if the task is actually finished,
|
||||
# clean up and allow starting instead of blocking.
|
||||
task = getattr(other, "_task", None)
|
||||
@@ -543,7 +601,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
dev = self._device_store.get_device(proc.device_id)
|
||||
dev_name = dev.name
|
||||
except ValueError as e:
|
||||
logger.debug("Device %s not found for name lookup: %s", proc.device_id, e)
|
||||
logger.debug(
|
||||
"Device %s not found for name lookup: %s", proc.device_id, e
|
||||
)
|
||||
pass
|
||||
raise RuntimeError(
|
||||
f"Device '{dev_name}' is already being processed by target {tgt_name}"
|
||||
@@ -573,9 +633,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
|
||||
# Attach done callback to detect crashes
|
||||
if proc._task is not None:
|
||||
proc._task.add_done_callback(
|
||||
lambda task, tid=target_id: self._on_task_done(tid, task)
|
||||
)
|
||||
proc._task.add_done_callback(lambda task, tid=target_id: self._on_task_done(tid, task))
|
||||
|
||||
async def stop_processing(self, target_id: str):
|
||||
"""Stop processing for a target (any type).
|
||||
@@ -617,18 +675,20 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
# Merge device health for device-aware targets
|
||||
if proc.device_id is not None and proc.device_id in self._devices:
|
||||
h = self._devices[proc.device_id].health
|
||||
state.update({
|
||||
"device_online": h.online,
|
||||
"device_latency_ms": h.latency_ms,
|
||||
"device_name": h.device_name,
|
||||
"device_version": h.device_version,
|
||||
"device_led_count": h.device_led_count,
|
||||
"device_rgbw": h.device_rgbw,
|
||||
"device_led_type": h.device_led_type,
|
||||
"device_fps": h.device_fps,
|
||||
"device_last_checked": h.last_checked,
|
||||
"device_error": h.error,
|
||||
})
|
||||
state.update(
|
||||
{
|
||||
"device_online": h.online,
|
||||
"device_latency_ms": h.latency_ms,
|
||||
"device_name": h.device_name,
|
||||
"device_version": h.device_version,
|
||||
"device_led_count": h.device_led_count,
|
||||
"device_rgbw": h.device_rgbw,
|
||||
"device_led_type": h.device_led_type,
|
||||
"device_fps": h.device_fps,
|
||||
"device_last_checked": h.last_checked,
|
||||
"device_error": h.error,
|
||||
}
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
@@ -676,7 +736,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
"left": [255, 255, 0],
|
||||
}
|
||||
|
||||
async def start_overlay(self, target_id: str, target_name: str = None, calibration=None, display_info=None) -> None:
|
||||
async def start_overlay(
|
||||
self, target_id: str, target_name: str = None, calibration=None, display_info=None
|
||||
) -> None:
|
||||
proc = self._get_processor(target_id)
|
||||
if not proc.supports_overlay():
|
||||
raise ValueError(f"Target {target_id} does not support overlays")
|
||||
@@ -707,10 +769,15 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
|
||||
# ===== CSS OVERLAY (direct, no target processor required) =====
|
||||
|
||||
async def start_css_overlay(self, css_id: str, display_info, calibration, css_name: str = None) -> None:
|
||||
async def start_css_overlay(
|
||||
self, css_id: str, display_info, calibration, css_name: str = None
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._overlay_manager.start_overlay,
|
||||
css_id, display_info, calibration, css_name,
|
||||
css_id,
|
||||
display_info,
|
||||
calibration,
|
||||
css_name,
|
||||
)
|
||||
|
||||
async def stop_css_overlay(self, css_id: str) -> None:
|
||||
|
||||
@@ -14,7 +14,7 @@ import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Callable, Dict, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
||||
@@ -26,13 +26,16 @@ if TYPE_CHECKING:
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import (
|
||||
ColorStripProcessingTemplateStore,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessingMetrics:
|
||||
"""Metrics for processing performance."""
|
||||
@@ -43,7 +46,9 @@ class ProcessingMetrics:
|
||||
errors_count: int = 0
|
||||
last_error: Optional[str] = None
|
||||
last_update: Optional[datetime] = None
|
||||
last_update_mono: float = 0.0 # monotonic timestamp for hot-path; lazily converted to last_update on read
|
||||
last_update_mono: float = (
|
||||
0.0 # monotonic timestamp for hot-path; lazily converted to last_update on read
|
||||
)
|
||||
start_time: Optional[datetime] = None
|
||||
fps_actual: float = 0.0
|
||||
fps_potential: float = 0.0
|
||||
@@ -117,12 +122,14 @@ class TargetContext:
|
||||
cspt_store: Optional["ColorStripProcessingTemplateStore"] = None
|
||||
fire_event: Callable[[dict], None] = lambda e: None
|
||||
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
|
||||
ha_manager: Optional[Any] = None # HomeAssistantManager (avoid circular import)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abstract base class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TargetProcessor(ABC):
|
||||
"""Abstract base class for target processors.
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ processor_manager = ProcessorManager(
|
||||
gradient_store=gradient_store,
|
||||
weather_manager=weather_manager,
|
||||
asset_store=asset_store,
|
||||
ha_manager=ha_manager,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -432,3 +432,38 @@
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.perf-mode-toggle {
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.perf-mode-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.perf-mode-btn:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.perf-mode-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.perf-mode-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
import { startEventsWS, stopEventsWS } from './core/events-ws.ts';
|
||||
import { startEntityEventListeners } from './core/entity-events.ts';
|
||||
import {
|
||||
startPerfPolling, stopPerfPolling,
|
||||
startPerfPolling, stopPerfPolling, setPerfMode,
|
||||
} from './features/perf-charts.ts';
|
||||
import {
|
||||
loadPictureSources, switchStreamTab,
|
||||
@@ -290,6 +290,7 @@ Object.assign(window, {
|
||||
stopUptimeTimer,
|
||||
startPerfPolling,
|
||||
stopPerfPolling,
|
||||
setPerfMode,
|
||||
|
||||
// streams / capture templates / PP templates
|
||||
loadPictureSources,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval,
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
|
||||
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts';
|
||||
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.ts';
|
||||
import { startAutoRefresh, updateTabBadge } from './tabs.ts';
|
||||
import {
|
||||
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
|
||||
@@ -511,7 +511,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${pollSelect}<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
|
||||
if (isFirstLoad) {
|
||||
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section">
|
||||
${_sectionHeader('perf', t('dashboard.section.performance'), '')}
|
||||
${_sectionHeader('perf', t('dashboard.section.performance'), '', renderPerfModeToggle())}
|
||||
${_sectionContent('perf', renderPerfSection())}
|
||||
</div>
|
||||
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
|
||||
|
||||
@@ -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()">×</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;
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Performance charts — real-time CPU, RAM, GPU usage with Chart.js.
|
||||
* Supports system-wide and app-level (process) metrics with a toggle.
|
||||
* History is seeded from the server-side ring buffer on init.
|
||||
*/
|
||||
|
||||
@@ -14,11 +15,16 @@ import { createColorPicker, registerColorPicker } from '../core/color-picker.ts'
|
||||
|
||||
const MAX_SAMPLES = 120;
|
||||
const CHART_KEYS = ['cpu', 'ram', 'gpu'];
|
||||
const PERF_MODE_KEY = 'perfMetricsMode';
|
||||
|
||||
type PerfMode = 'system' | 'app' | 'both';
|
||||
|
||||
let _pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let _charts: Record<string, any> = {}; // { cpu: Chart, ram: Chart, gpu: Chart }
|
||||
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [] };
|
||||
let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [] };
|
||||
let _hasGpu: boolean | null = null; // null = unknown, true/false after first fetch
|
||||
let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both';
|
||||
|
||||
function _getColor(key: string): string {
|
||||
return localStorage.getItem(`perfChartColor_${key}`)
|
||||
@@ -26,6 +32,12 @@ function _getColor(key: string): string {
|
||||
|| '#4CAF50';
|
||||
}
|
||||
|
||||
function _getAppColor(key: string): string {
|
||||
const base = _getColor(key);
|
||||
// Use a lighter/shifted version for the app line
|
||||
return base + '99'; // 60% opacity hex suffix
|
||||
}
|
||||
|
||||
function _onChartColorChange(key: string, hex: string | null): void {
|
||||
if (hex) {
|
||||
localStorage.setItem(`perfChartColor_${key}`, hex);
|
||||
@@ -41,10 +53,43 @@ function _onChartColorChange(key: string, hex: string | null): void {
|
||||
if (chart) {
|
||||
chart.data.datasets[0].borderColor = hex;
|
||||
chart.data.datasets[0].backgroundColor = hex + '26';
|
||||
chart.data.datasets[1].borderColor = hex + '99';
|
||||
chart.data.datasets[1].backgroundColor = hex + '14';
|
||||
chart.update();
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the 3-way toggle HTML for perf section header. */
|
||||
export function renderPerfModeToggle(): string {
|
||||
return `<span class="perf-mode-toggle" onclick="event.stopPropagation()">
|
||||
<button class="perf-mode-btn${_mode === 'system' ? ' active' : ''}" data-perf-mode="system" onclick="setPerfMode('system')" title="${t('dashboard.perf.mode.system')}">${t('dashboard.perf.mode.system')}</button>
|
||||
<button class="perf-mode-btn${_mode === 'app' ? ' active' : ''}" data-perf-mode="app" onclick="setPerfMode('app')" title="${t('dashboard.perf.mode.app')}">${t('dashboard.perf.mode.app')}</button>
|
||||
<button class="perf-mode-btn${_mode === 'both' ? ' active' : ''}" data-perf-mode="both" onclick="setPerfMode('both')" title="${t('dashboard.perf.mode.both')}">${t('dashboard.perf.mode.both')}</button>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
/** Change the perf metrics display mode. */
|
||||
export function setPerfMode(mode: PerfMode): void {
|
||||
_mode = mode;
|
||||
localStorage.setItem(PERF_MODE_KEY, mode);
|
||||
|
||||
// Update toggle button active states
|
||||
document.querySelectorAll('.perf-mode-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', (btn as HTMLElement).dataset.perfMode === mode);
|
||||
});
|
||||
|
||||
// Update dataset visibility on all charts
|
||||
for (const key of CHART_KEYS) {
|
||||
const chart = _charts[key];
|
||||
if (!chart) continue;
|
||||
const showSystem = mode === 'system' || mode === 'both';
|
||||
const showApp = mode === 'app' || mode === 'both';
|
||||
chart.data.datasets[0].hidden = !showSystem;
|
||||
chart.data.datasets[1].hidden = !showApp;
|
||||
chart.update('none');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the static HTML for the perf section (canvas placeholders). */
|
||||
export function renderPerfSection(): string {
|
||||
// Register callbacks before rendering
|
||||
@@ -81,19 +126,37 @@ function _createChart(canvasId: string, key: string): any {
|
||||
const ctx = document.getElementById(canvasId) as HTMLCanvasElement | null;
|
||||
if (!ctx) return null;
|
||||
const color = _getColor(key);
|
||||
const showSystem = _mode === 'system' || _mode === 'both';
|
||||
const showApp = _mode === 'app' || _mode === 'both';
|
||||
return new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: Array(MAX_SAMPLES).fill(''),
|
||||
datasets: [{
|
||||
data: [],
|
||||
borderColor: color,
|
||||
backgroundColor: color + '26',
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
}],
|
||||
datasets: [
|
||||
{
|
||||
// System-wide dataset
|
||||
data: [],
|
||||
borderColor: color,
|
||||
backgroundColor: color + '26',
|
||||
borderWidth: 1.5,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
hidden: !showSystem,
|
||||
},
|
||||
{
|
||||
// App-level dataset (dashed line)
|
||||
data: [],
|
||||
borderColor: color + '99',
|
||||
backgroundColor: color + '14',
|
||||
borderWidth: 1.5,
|
||||
borderDash: [4, 3],
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
hidden: !showApp,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
@@ -115,9 +178,12 @@ async function _seedFromServer(): Promise<void> {
|
||||
const data = await fetchMetricsHistory();
|
||||
if (!data) return;
|
||||
const samples = data.system || [];
|
||||
_history.cpu = samples.map(s => s.cpu).filter(v => v != null);
|
||||
_history.ram = samples.map(s => s.ram_pct).filter(v => v != null);
|
||||
_history.gpu = samples.map(s => s.gpu_util).filter(v => v != null);
|
||||
_history.cpu = samples.map((s: any) => s.cpu).filter((v: any) => v != null);
|
||||
_history.ram = samples.map((s: any) => s.ram_pct).filter((v: any) => v != null);
|
||||
_history.gpu = samples.map((s: any) => s.gpu_util).filter((v: any) => v != null);
|
||||
_appHistory.cpu = samples.map((s: any) => s.app_cpu).filter((v: any) => v != null);
|
||||
_appHistory.ram = samples.map((s: any) => s.app_ram).filter((v: any) => v != null);
|
||||
_appHistory.gpu = samples.map((s: any) => s.app_gpu_mem).filter((v: any) => v != null);
|
||||
|
||||
// Detect GPU availability from history
|
||||
if (_history.gpu.length > 0) {
|
||||
@@ -125,11 +191,20 @@ async function _seedFromServer(): Promise<void> {
|
||||
}
|
||||
|
||||
for (const key of CHART_KEYS) {
|
||||
if (_charts[key] && _history[key].length > 0) {
|
||||
_charts[key].data.datasets[0].data = [..._history[key]];
|
||||
_charts[key].data.labels = _history[key].map(() => '');
|
||||
_charts[key].update();
|
||||
const chart = _charts[key];
|
||||
if (!chart) continue;
|
||||
// System dataset
|
||||
if (_history[key].length > 0) {
|
||||
chart.data.datasets[0].data = [..._history[key]];
|
||||
}
|
||||
// App dataset
|
||||
if (_appHistory[key].length > 0) {
|
||||
chart.data.datasets[1].data = [..._appHistory[key]];
|
||||
}
|
||||
// Align labels to the longer dataset
|
||||
const maxLen = Math.max(chart.data.datasets[0].data.length, chart.data.datasets[1].data.length);
|
||||
chart.data.labels = Array(maxLen).fill('');
|
||||
chart.update();
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — charts will fill from polling
|
||||
@@ -151,50 +226,99 @@ function _destroyCharts(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function _pushSample(key: string, value: number): void {
|
||||
_history[key].push(value);
|
||||
function _pushSample(key: string, sysValue: number, appValue: number | null): void {
|
||||
// System history
|
||||
_history[key].push(sysValue);
|
||||
if (_history[key].length > MAX_SAMPLES) _history[key].shift();
|
||||
|
||||
// App history
|
||||
if (appValue != null) {
|
||||
_appHistory[key].push(appValue);
|
||||
if (_appHistory[key].length > MAX_SAMPLES) _appHistory[key].shift();
|
||||
}
|
||||
|
||||
const chart = _charts[key];
|
||||
if (!chart) return;
|
||||
const ds = chart.data.datasets[0].data;
|
||||
ds.length = 0;
|
||||
ds.push(..._history[key]);
|
||||
// Ensure labels array matches length (reuse existing array)
|
||||
while (chart.data.labels.length < ds.length) chart.data.labels.push('');
|
||||
chart.data.labels.length = ds.length;
|
||||
|
||||
// Update system dataset
|
||||
const sysDs = chart.data.datasets[0].data;
|
||||
sysDs.length = 0;
|
||||
sysDs.push(..._history[key]);
|
||||
|
||||
// Update app dataset
|
||||
const appDs = chart.data.datasets[1].data;
|
||||
appDs.length = 0;
|
||||
appDs.push(..._appHistory[key]);
|
||||
|
||||
// Ensure labels array matches the longer dataset
|
||||
const maxLen = Math.max(sysDs.length, appDs.length);
|
||||
while (chart.data.labels.length < maxLen) chart.data.labels.push('');
|
||||
chart.data.labels.length = maxLen;
|
||||
chart.update('none');
|
||||
}
|
||||
|
||||
/** Format the value display based on mode. */
|
||||
function _formatValue(sysVal: string, appVal: string | null): string {
|
||||
if (_mode === 'system') return sysVal;
|
||||
if (_mode === 'app') return appVal ?? '-';
|
||||
// 'both': show both
|
||||
if (appVal != null) return `${sysVal} / ${appVal}`;
|
||||
return sysVal;
|
||||
}
|
||||
|
||||
async function _fetchPerformance(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() });
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
// CPU
|
||||
_pushSample('cpu', data.cpu_percent);
|
||||
// CPU — app_cpu_percent is in the same scale as cpu_percent (per-core %)
|
||||
_pushSample('cpu', data.cpu_percent, data.app_cpu_percent);
|
||||
const cpuEl = document.getElementById('perf-cpu-value');
|
||||
if (cpuEl) cpuEl.textContent = `${data.cpu_percent.toFixed(0)}%`;
|
||||
if (cpuEl) {
|
||||
cpuEl.textContent = _formatValue(
|
||||
`${data.cpu_percent.toFixed(0)}%`,
|
||||
`${data.app_cpu_percent.toFixed(0)}%`
|
||||
);
|
||||
}
|
||||
if (data.cpu_name) {
|
||||
const nameEl = document.getElementById('perf-cpu-name');
|
||||
if (nameEl && !nameEl.textContent) nameEl.textContent = data.cpu_name;
|
||||
}
|
||||
|
||||
// RAM
|
||||
_pushSample('ram', data.ram_percent);
|
||||
// RAM — convert app_ram_mb to percent of total for consistent chart scale
|
||||
const appRamPct = data.ram_total_mb > 0
|
||||
? (data.app_ram_mb / data.ram_total_mb) * 100
|
||||
: 0;
|
||||
_pushSample('ram', data.ram_percent, appRamPct);
|
||||
const ramEl = document.getElementById('perf-ram-value');
|
||||
if (ramEl) {
|
||||
const usedGb = (data.ram_used_mb / 1024).toFixed(1);
|
||||
const totalGb = (data.ram_total_mb / 1024).toFixed(1);
|
||||
ramEl.textContent = `${usedGb}/${totalGb} GB`;
|
||||
const appMb = data.app_ram_mb.toFixed(0);
|
||||
ramEl.textContent = _formatValue(
|
||||
`${usedGb}/${totalGb} GB`,
|
||||
`${appMb} MB`
|
||||
);
|
||||
}
|
||||
|
||||
// GPU
|
||||
if (data.gpu) {
|
||||
_hasGpu = true;
|
||||
_pushSample('gpu', data.gpu.utilization);
|
||||
// GPU utilization is system-wide only (no per-process util from NVML)
|
||||
// For app, show memory percentage if available
|
||||
const appGpuPct = (data.gpu.app_memory_mb != null && data.gpu.memory_total_mb)
|
||||
? (data.gpu.app_memory_mb / data.gpu.memory_total_mb) * 100
|
||||
: null;
|
||||
_pushSample('gpu', data.gpu.utilization, appGpuPct);
|
||||
const gpuEl = document.getElementById('perf-gpu-value');
|
||||
if (gpuEl) gpuEl.textContent = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`;
|
||||
if (gpuEl) {
|
||||
const sysText = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`;
|
||||
const appText = data.gpu.app_memory_mb != null
|
||||
? `${data.gpu.app_memory_mb.toFixed(0)} MB VRAM`
|
||||
: null;
|
||||
gpuEl.textContent = _formatValue(sysText, appText);
|
||||
}
|
||||
if (data.gpu.name) {
|
||||
const nameEl = document.getElementById('perf-gpu-name');
|
||||
if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
_cachedValueSources, valueSourcesCache,
|
||||
streamsCache, audioSourcesCache, syncClocksCache,
|
||||
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
|
||||
_cachedHASources, haSourcesCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
@@ -19,6 +20,7 @@ import { Modal } from '../core/modal.ts';
|
||||
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts';
|
||||
import { _splitOpenrgbZone } from './device-discovery.ts';
|
||||
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts';
|
||||
import { createHALightTargetCard, initHALightTargetDelegation } from './ha-light-targets.ts';
|
||||
import {
|
||||
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
|
||||
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
|
||||
@@ -104,6 +106,7 @@ const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.de
|
||||
] });
|
||||
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
|
||||
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
|
||||
const csHALightTargets = new CardSection('ha-light-targets', { titleKey: 'ha_light.section.title', gridClass: 'devices-grid', addCardOnclick: "showHALightEditor()", keyAttr: 'data-ha-target-id', emptyKey: 'section.empty.ha_light_targets', bulkActions: _targetBulkActions });
|
||||
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates', bulkActions: [
|
||||
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeletePatternTemplates },
|
||||
] });
|
||||
@@ -605,6 +608,7 @@ export async function loadTargetsTab() {
|
||||
valueSourcesCache.fetch().catch((): any[] => []),
|
||||
audioSourcesCache.fetch().catch((): any[] => []),
|
||||
syncClocksCache.fetch().catch((): any[] => []),
|
||||
haSourcesCache.fetch().catch((): any[] => []),
|
||||
]);
|
||||
|
||||
const colorStripSourceMap = {};
|
||||
@@ -658,6 +662,7 @@ export async function loadTargetsTab() {
|
||||
const ledDevices = devicesWithState;
|
||||
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
|
||||
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
|
||||
const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light');
|
||||
|
||||
// Update tab badge with running target count
|
||||
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
|
||||
@@ -680,10 +685,16 @@ export async function loadTargetsTab() {
|
||||
{ key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length },
|
||||
{ key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'ha_light_group', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'ha_light.section.title',
|
||||
children: [
|
||||
{ key: 'ha-light-targets', titleKey: 'ha_light.section.targets', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: haLightTargets.length },
|
||||
]
|
||||
}
|
||||
];
|
||||
// Determine which tree leaf is active — migrate old values
|
||||
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns'];
|
||||
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns', 'ha-light-targets'];
|
||||
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab
|
||||
: activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices';
|
||||
|
||||
@@ -694,6 +705,9 @@ export async function loadTargetsTab() {
|
||||
const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })));
|
||||
const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })));
|
||||
const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })));
|
||||
const haSourceMap: Record<string, any> = {};
|
||||
_cachedHASources.forEach(s => { haSourceMap[s.id] = s; });
|
||||
const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap) })));
|
||||
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
|
||||
|
||||
// Track which target cards were replaced/added (need chart re-init)
|
||||
@@ -706,11 +720,13 @@ export async function loadTargetsTab() {
|
||||
'led-targets': ledTargets.length,
|
||||
'kc-targets': kcTargets.length,
|
||||
'kc-patterns': patternTemplates.length,
|
||||
'ha-light-targets': haLightTargets.length,
|
||||
});
|
||||
csDevices.reconcile(deviceItems);
|
||||
const ledResult = csLedTargets.reconcile(ledTargetItems);
|
||||
const kcResult = csKCTargets.reconcile(kcTargetItems);
|
||||
csPatternTemplates.reconcile(patternItems);
|
||||
csHALightTargets.reconcile(haLightTargetItems);
|
||||
changedTargetIds = new Set<string>([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[]),
|
||||
...(kcResult.added as unknown as string[]), ...(kcResult.replaced as unknown as string[]), ...(kcResult.removed as unknown as string[])]);
|
||||
|
||||
@@ -727,9 +743,11 @@ export async function loadTargetsTab() {
|
||||
{ key: 'led-targets', html: csLedTargets.render(ledTargetItems) },
|
||||
{ key: 'kc-targets', html: csKCTargets.render(kcTargetItems) },
|
||||
{ key: 'kc-patterns', html: csPatternTemplates.render(patternItems) },
|
||||
{ key: 'ha-light-targets', html: csHALightTargets.render(haLightTargetItems) },
|
||||
].map(p => `<div class="target-sub-tab-panel stream-tab-panel${p.key === activeLeaf ? ' active' : ''}" id="target-sub-tab-${p.key}">${p.html}</div>`).join('');
|
||||
container.innerHTML = panels;
|
||||
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates]);
|
||||
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates, csHALightTargets]);
|
||||
initHALightTargetDelegation(container);
|
||||
|
||||
// Render tree sidebar with expand/collapse buttons
|
||||
_targetsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||
|
||||
@@ -759,6 +759,9 @@
|
||||
"dashboard.perf.gpu": "GPU",
|
||||
"dashboard.perf.unavailable": "unavailable",
|
||||
"dashboard.perf.color": "Chart color",
|
||||
"dashboard.perf.mode.system": "System",
|
||||
"dashboard.perf.mode.app": "App",
|
||||
"dashboard.perf.mode.both": "Both",
|
||||
"dashboard.poll_interval": "Refresh interval",
|
||||
"automations.title": "Automations",
|
||||
"automations.empty": "No automations configured. Create one to automate scene activation.",
|
||||
@@ -1815,6 +1818,31 @@
|
||||
"ha_source.deleted": "Home Assistant source deleted",
|
||||
"ha_source.delete.confirm": "Delete this Home Assistant connection?",
|
||||
"section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.",
|
||||
"ha_light.section.title": "HA Lights",
|
||||
"ha_light.section.targets": "HA Light Targets",
|
||||
"ha_light.add": "Add HA Light Target",
|
||||
"ha_light.edit": "Edit HA Light Target",
|
||||
"ha_light.name": "Name:",
|
||||
"ha_light.name.placeholder": "Living Room Lights",
|
||||
"ha_light.ha_source": "HA Connection:",
|
||||
"ha_light.css_source": "Color Strip Source:",
|
||||
"ha_light.update_rate": "Update Rate:",
|
||||
"ha_light.update_rate.hint": "How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.",
|
||||
"ha_light.transition": "Transition:",
|
||||
"ha_light.transition.hint": "Smooth fade duration between colors (HA transition parameter).",
|
||||
"ha_light.mappings": "Light Mappings:",
|
||||
"ha_light.mappings.hint": "Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.",
|
||||
"ha_light.mappings.add": "Add Mapping",
|
||||
"ha_light.mapping.entity_id": "Entity ID:",
|
||||
"ha_light.mapping.led_start": "LED Start:",
|
||||
"ha_light.mapping.led_end": "LED End (-1=last):",
|
||||
"ha_light.mapping.brightness": "Brightness Scale:",
|
||||
"ha_light.description": "Description (optional):",
|
||||
"ha_light.error.name_required": "Name is required",
|
||||
"ha_light.error.ha_source_required": "HA connection is required",
|
||||
"ha_light.created": "HA light target created",
|
||||
"ha_light.updated": "HA light target updated",
|
||||
"section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.",
|
||||
"automations.condition.home_assistant": "Home Assistant",
|
||||
"automations.condition.home_assistant.desc": "HA entity state",
|
||||
"automations.condition.home_assistant.ha_source": "HA Source:",
|
||||
|
||||
@@ -759,6 +759,9 @@
|
||||
"dashboard.perf.gpu": "ГП",
|
||||
"dashboard.perf.unavailable": "недоступно",
|
||||
"dashboard.perf.color": "Цвет графика",
|
||||
"dashboard.perf.mode.system": "Система",
|
||||
"dashboard.perf.mode.app": "Приложение",
|
||||
"dashboard.perf.mode.both": "Оба",
|
||||
"dashboard.poll_interval": "Интервал обновления",
|
||||
"automations.title": "Автоматизации",
|
||||
"automations.empty": "Автоматизации не настроены. Создайте автоматизацию для автоматической активации сцен.",
|
||||
|
||||
@@ -759,6 +759,9 @@
|
||||
"dashboard.perf.gpu": "GPU",
|
||||
"dashboard.perf.unavailable": "不可用",
|
||||
"dashboard.perf.color": "图表颜色",
|
||||
"dashboard.perf.mode.system": "系统",
|
||||
"dashboard.perf.mode.app": "应用",
|
||||
"dashboard.perf.mode.both": "全部",
|
||||
"dashboard.poll_interval": "刷新间隔",
|
||||
"automations.title": "自动化",
|
||||
"automations.empty": "尚未配置自动化。创建一个以自动激活场景。",
|
||||
|
||||
160
server/src/wled_controller/storage/ha_light_output_target.py
Normal file
160
server/src/wled_controller/storage/ha_light_output_target.py
Normal 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())
|
||||
),
|
||||
)
|
||||
@@ -21,14 +21,24 @@ class OutputTarget:
|
||||
"""Register this target with the processor manager. Subclasses override."""
|
||||
pass
|
||||
|
||||
def sync_with_manager(self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool) -> None:
|
||||
def sync_with_manager(
|
||||
self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool
|
||||
) -> None:
|
||||
"""Push changed fields to a running processor. Subclasses override."""
|
||||
pass
|
||||
|
||||
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
|
||||
settings=None, key_colors_settings=None, description=None,
|
||||
tags: Optional[List[str]] = None,
|
||||
**_kwargs) -> None:
|
||||
def update_fields(
|
||||
self,
|
||||
*,
|
||||
name=None,
|
||||
device_id=None,
|
||||
picture_source_id=None,
|
||||
settings=None,
|
||||
key_colors_settings=None,
|
||||
description=None,
|
||||
tags: Optional[List[str]] = None,
|
||||
**_kwargs,
|
||||
) -> None:
|
||||
"""Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones."""
|
||||
if name is not None:
|
||||
self.name = name
|
||||
@@ -60,8 +70,14 @@ class OutputTarget:
|
||||
target_type = data.get("target_type", "led")
|
||||
if target_type == "led":
|
||||
from wled_controller.storage.wled_output_target import WledOutputTarget
|
||||
|
||||
return WledOutputTarget.from_dict(data)
|
||||
if target_type == "key_colors":
|
||||
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget
|
||||
|
||||
return KeyColorsOutputTarget.from_dict(data)
|
||||
if target_type == "ha_light":
|
||||
from wled_controller.storage.ha_light_output_target import HALightOutputTarget
|
||||
|
||||
return HALightOutputTarget.from_dict(data)
|
||||
raise ValueError(f"Unknown target type: {target_type}")
|
||||
|
||||
@@ -12,6 +12,10 @@ from wled_controller.storage.key_colors_output_target import (
|
||||
KeyColorsSettings,
|
||||
KeyColorsOutputTarget,
|
||||
)
|
||||
from wled_controller.storage.ha_light_output_target import (
|
||||
HALightMapping,
|
||||
HALightOutputTarget,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -50,13 +54,18 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
||||
description: Optional[str] = None,
|
||||
picture_source_id: str = "",
|
||||
tags: Optional[List[str]] = None,
|
||||
ha_source_id: str = "",
|
||||
ha_light_mappings: Optional[List[HALightMapping]] = None,
|
||||
update_rate: float = 2.0,
|
||||
transition: float = 0.5,
|
||||
color_tolerance: int = 5,
|
||||
) -> OutputTarget:
|
||||
"""Create a new output target.
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails
|
||||
"""
|
||||
if target_type not in ("led", "key_colors"):
|
||||
if target_type not in ("led", "key_colors", "ha_light"):
|
||||
raise ValueError(f"Invalid target type: {target_type}")
|
||||
|
||||
# Check for duplicate name
|
||||
@@ -96,6 +105,22 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
elif target_type == "ha_light":
|
||||
target = HALightOutputTarget(
|
||||
id=target_id,
|
||||
name=name,
|
||||
target_type="ha_light",
|
||||
ha_source_id=ha_source_id,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
light_mappings=ha_light_mappings or [],
|
||||
update_rate=update_rate,
|
||||
transition=transition,
|
||||
min_brightness_threshold=min_brightness_threshold,
|
||||
color_tolerance=color_tolerance,
|
||||
description=description,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown target type: {target_type}")
|
||||
|
||||
@@ -164,24 +189,28 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
|
||||
def get_targets_for_device(self, device_id: str) -> List[OutputTarget]:
|
||||
"""Get all targets that reference a specific device."""
|
||||
return [
|
||||
t for t in self._items.values()
|
||||
t
|
||||
for t in self._items.values()
|
||||
if isinstance(t, WledOutputTarget) and t.device_id == device_id
|
||||
]
|
||||
|
||||
def get_targets_referencing_source(self, source_id: str) -> List[str]:
|
||||
"""Return names of KC targets that reference a picture source."""
|
||||
return [
|
||||
target.name for target in self._items.values()
|
||||
target.name
|
||||
for target in self._items.values()
|
||||
if isinstance(target, KeyColorsOutputTarget) and target.picture_source_id == source_id
|
||||
]
|
||||
|
||||
def get_targets_referencing_css(self, css_id: str) -> List[str]:
|
||||
"""Return names of LED targets that reference a color strip source."""
|
||||
return [
|
||||
target.name for target in self._items.values()
|
||||
if isinstance(target, WledOutputTarget)
|
||||
and target.color_strip_source_id == css_id
|
||||
]
|
||||
"""Return names of targets that reference a color strip source."""
|
||||
result = []
|
||||
for target in self._items.values():
|
||||
if isinstance(target, WledOutputTarget) and target.color_strip_source_id == css_id:
|
||||
result.append(target.name)
|
||||
elif isinstance(target, HALightOutputTarget) and target.color_strip_source_id == css_id:
|
||||
result.append(target.name)
|
||||
return result
|
||||
|
||||
def count(self) -> int:
|
||||
"""Get number of targets."""
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
{% include 'modals/sync-clock-editor.html' %}
|
||||
{% include 'modals/weather-source-editor.html' %}
|
||||
{% include 'modals/ha-source-editor.html' %}
|
||||
{% include 'modals/ha-light-editor.html' %}
|
||||
{% include 'modals/asset-upload.html' %}
|
||||
{% include 'modals/asset-editor.html' %}
|
||||
{% include 'modals/settings.html' %}
|
||||
|
||||
@@ -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">✕</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">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveHALightEditor()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user