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