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