feat: HA light target live color preview — per-entity swatches via WebSocket
Lint & Test / test (push) Successful in 1m24s

- Cache per-entity colors in HALightTargetProcessor._update_lights()
- Broadcast colors_update to WS clients at target's update_rate
- WS endpoint: /api/v1/output-targets/{target_id}/ha-light/ws
- Frontend: connect WS when target runs, update swatch colors live
- Card shows colored boxes per mapped entity with entity name labels
This commit is contained in:
2026-03-28 18:28:16 +03:00
parent 381ee75371
commit 40751fecb7
31 changed files with 6245 additions and 8351 deletions
@@ -3,7 +3,6 @@
Extracted from output_targets.py to keep files under 800 lines. Extracted from output_targets.py to keep files under 800 lines.
""" """
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
@@ -22,7 +21,10 @@ from wled_controller.api.schemas.output_targets import (
from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, PictureColorStripSource from wled_controller.storage.color_strip_source import (
AdvancedPictureColorStripSource,
PictureColorStripSource,
)
from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.wled_output_target import WledOutputTarget from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
@@ -35,7 +37,10 @@ router = APIRouter()
# ===== BULK PROCESSING CONTROL ENDPOINTS ===== # ===== BULK PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"])
@router.post(
"/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"]
)
async def bulk_start_processing( async def bulk_start_processing(
body: BulkTargetRequest, body: BulkTargetRequest,
_auth: AuthRequired, _auth: AuthRequired,
@@ -67,7 +72,9 @@ async def bulk_start_processing(
return BulkTargetResponse(started=started, errors=errors) return BulkTargetResponse(started=started, errors=errors)
@router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"]) @router.post(
"/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"]
)
async def bulk_stop_processing( async def bulk_stop_processing(
body: BulkTargetRequest, body: BulkTargetRequest,
_auth: AuthRequired, _auth: AuthRequired,
@@ -93,6 +100,7 @@ async def bulk_stop_processing(
# ===== PROCESSING CONTROL ENDPOINTS ===== # ===== PROCESSING CONTROL ENDPOINTS =====
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"]) @router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
async def start_processing( async def start_processing(
target_id: str, target_id: str,
@@ -146,7 +154,12 @@ async def stop_processing(
# ===== STATE & METRICS ENDPOINTS ===== # ===== STATE & METRICS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/state", response_model=TargetProcessingState, tags=["Processing"])
@router.get(
"/api/v1/output-targets/{target_id}/state",
response_model=TargetProcessingState,
tags=["Processing"],
)
async def get_target_state( async def get_target_state(
target_id: str, target_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -164,7 +177,11 @@ async def get_target_state(
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"]) @router.get(
"/api/v1/output-targets/{target_id}/metrics",
response_model=TargetMetricsResponse,
tags=["Metrics"],
)
async def get_target_metrics( async def get_target_metrics(
target_id: str, target_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -192,6 +209,7 @@ async def events_ws(
): ):
"""WebSocket for real-time state change events. Auth via ?token=<api_key>.""" """WebSocket for real-time state change events. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
return return
@@ -215,6 +233,7 @@ async def events_ws(
# ===== OVERLAY VISUALIZATION ===== # ===== OVERLAY VISUALIZATION =====
@router.post("/api/v1/output-targets/{target_id}/overlay/start", tags=["Visualization"]) @router.post("/api/v1/output-targets/{target_id}/overlay/start", tags=["Visualization"])
async def start_target_overlay( async def start_target_overlay(
target_id: str, target_id: str,
@@ -247,10 +266,16 @@ async def start_target_overlay(
if first_css_id: if first_css_id:
try: try:
css = color_strip_store.get_source(first_css_id) css = color_strip_store.get_source(first_css_id)
if isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource)) and css.calibration: if (
isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource))
and css.calibration
):
calibration = css.calibration calibration = css.calibration
# Resolve the display this CSS is capturing # Resolve the display this CSS is capturing
from wled_controller.api.routes.color_strip_sources import _resolve_display_index from wled_controller.api.routes.color_strip_sources import (
_resolve_display_index,
)
ps_id = getattr(css, "picture_source_id", "") or "" ps_id = getattr(css, "picture_source_id", "") or ""
display_index = _resolve_display_index(ps_id, picture_source_store) display_index = _resolve_display_index(ps_id, picture_source_store)
displays = get_available_displays() displays = get_available_displays()
@@ -258,9 +283,13 @@ async def start_target_overlay(
display_index = min(display_index, len(displays) - 1) display_index = min(display_index, len(displays) - 1)
display_info = displays[display_index] display_info = displays[display_index]
except Exception as e: except Exception as e:
logger.warning(f"Could not pre-load CSS calibration for overlay on {target_id}: {e}") logger.warning(
f"Could not pre-load CSS calibration for overlay on {target_id}: {e}"
)
await manager.start_overlay(target_id, target.name, calibration=calibration, display_info=display_info) await manager.start_overlay(
target_id, target.name, calibration=calibration, display_info=display_info
)
return {"status": "started", "target_id": target_id} return {"status": "started", "target_id": target_id}
except ValueError as e: except ValueError as e:
@@ -305,8 +334,55 @@ async def get_overlay_status(
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
# ===== HA LIGHT COLOR PREVIEW WEBSOCKET =====
@router.websocket("/api/v1/output-targets/{target_id}/ha-light/ws")
async def ha_light_colors_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for live HA light entity color preview.
Streams: {"type": "colors_update", "colors": {entity_id: {r,g,b,hex}, ...}}
at the target's update_rate.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
manager: ProcessorManager = get_processor_manager()
try:
proc = manager._processors.get(target_id)
if not proc or not proc.is_running:
await websocket.close(code=4003, reason="Target not running")
return
except Exception as e:
await websocket.close(code=4004, reason=str(e))
return
await websocket.accept()
try:
manager.add_ha_light_ws_client(target_id, websocket)
while True:
# Keep connection alive — wait for client disconnect
await websocket.receive_text()
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
manager.remove_ha_light_ws_client(target_id, websocket)
# ===== LED PREVIEW WEBSOCKET ===== # ===== LED PREVIEW WEBSOCKET =====
@router.websocket("/api/v1/output-targets/{target_id}/led-preview/ws") @router.websocket("/api/v1/output-targets/{target_id}/led-preview/ws")
async def led_preview_ws( async def led_preview_ws(
websocket: WebSocket, websocket: WebSocket,
@@ -315,6 +391,7 @@ async def led_preview_ws(
): ):
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>.""" """WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
return return
@@ -1,528 +0,0 @@
"""Output target routes: key colors endpoints, testing, and WebSocket streams.
Extracted from output_targets.py to keep files under 800 lines.
"""
import asyncio
import time
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_device_store,
get_output_target_store,
get_pattern_template_store,
get_picture_source_store,
get_pp_template_store,
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.output_targets import (
ExtractedColorResponse,
KCTestRectangleResponse,
KCTestResponse,
KeyColorsResponse,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ===== KEY COLORS ENDPOINTS =====
@router.get("/api/v1/output-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"])
async def get_target_colors(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get latest extracted colors for a key-colors target (polling)."""
try:
raw_colors = manager.get_kc_latest_colors(target_id)
colors = {}
for name, (r, g, b) in raw_colors.items():
colors[name] = ExtractedColorResponse(
r=r, g=g, b=b,
hex=f"#{r:02x}{g:02x}{b:02x}",
)
from datetime import datetime, timezone
return KeyColorsResponse(
target_id=target_id,
colors=colors,
timestamp=datetime.now(timezone.utc),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/api/v1/output-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"])
async def test_kc_target(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
source_store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
pattern_store: PatternTemplateStore = Depends(get_pattern_template_store),
processor_manager: ProcessorManager = Depends(get_processor_manager),
device_store: DeviceStore = Depends(get_device_store),
pp_template_store=Depends(get_pp_template_store),
):
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
stream = None
try:
# 1. Load and validate KC target
try:
target = target_store.get_target(target_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if not isinstance(target, KeyColorsOutputTarget):
raise HTTPException(status_code=400, detail="Target is not a key_colors target")
settings = target.settings
# 2. Resolve pattern template
if not settings.pattern_template_id:
raise HTTPException(status_code=400, detail="No pattern template configured")
try:
pattern_tmpl = pattern_store.get_template(settings.pattern_template_id)
except ValueError:
raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}")
rectangles = pattern_tmpl.rectangles
if not rectangles:
raise HTTPException(status_code=400, detail="Pattern template has no rectangles")
# 3. Resolve picture source and capture a frame
if not target.picture_source_id:
raise HTTPException(status_code=400, detail="No picture source configured")
try:
chain = source_store.resolve_stream_chain(target.picture_source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raw_stream = chain["raw_stream"]
from wled_controller.utils.image_codec import load_image_file
if isinstance(raw_stream, StaticImagePictureSource):
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
image = load_image_file(image_path)
elif isinstance(raw_stream, ScreenCapturePictureSource):
try:
capture_template = template_store.get_template(raw_stream.capture_template_id)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Capture template not found: {raw_stream.capture_template_id}",
)
display_index = raw_stream.display_index
if capture_template.engine_type not in EngineRegistry.get_available_engines():
raise HTTPException(
status_code=400,
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
)
locked_device_id = processor_manager.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
raise HTTPException(
status_code=409,
detail=f"Display {display_index} is currently being captured by device '{device_name}'. "
f"Please stop the device processing before testing.",
)
stream = EngineRegistry.create_stream(
capture_template.engine_type, display_index, capture_template.engine_config
)
stream.initialize()
screen_capture = stream.capture_frame()
if screen_capture is None:
raise RuntimeError("No frame captured")
if not isinstance(screen_capture.image, np.ndarray):
raise ValueError("Unexpected image format from engine")
image = screen_capture.image
else:
raise HTTPException(status_code=400, detail="Unsupported picture source type")
# 3b. Apply postprocessing filters (if the picture source has a filter chain)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store:
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store.get_template(pp_id)
except ValueError:
logger.warning(f"KC test: PP template {pp_id} not found, skipping")
continue
flat_filters = pp_template_store.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(image, image_pool)
if result is not None:
image = result
except ValueError:
logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping")
# 4. Extract colors from each rectangle
img_array = image
h, w = img_array.shape[:2]
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append(KCTestRectangleResponse(
name=rect.name,
x=rect.x,
y=rect.y,
width=rect.width,
height=rect.height,
color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"),
))
# 5. Encode frame as base64 JPEG
from wled_controller.utils.image_codec import encode_jpeg_data_uri
image_data_uri = encode_jpeg_data_uri(image, quality=90)
return KCTestResponse(
image=image_data_uri,
rectangles=result_rects,
interpolation_mode=settings.interpolation_mode,
pattern_template_name=pattern_tmpl.name,
)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
logger.error("Capture error during KC target test: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
except Exception as e:
logger.error("Failed to test KC target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
finally:
if stream:
try:
stream.cleanup()
except Exception as e:
logger.error(f"Error cleaning up test stream: {e}")
@router.websocket("/api/v1/output-targets/{target_id}/test/ws")
async def test_kc_target_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
fps: int = Query(3),
preview_width: int = Query(480),
):
"""WebSocket for real-time KC target test preview. Auth via ?token=<api_key>.
Streams JSON frames: {"type": "frame", "image": "data:image/jpeg;base64,...",
"rectangles": [...], "pattern_template_name": "...", "interpolation_mode": "..."}
"""
import json as _json
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Load stores
target_store_inst: OutputTargetStore = get_output_target_store()
source_store_inst: PictureSourceStore = get_picture_source_store()
get_template_store()
pattern_store_inst: PatternTemplateStore = get_pattern_template_store()
processor_manager_inst: ProcessorManager = get_processor_manager()
device_store_inst: DeviceStore = get_device_store()
pp_template_store_inst = get_pp_template_store()
# Validate target
try:
target = target_store_inst.get_target(target_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
if not isinstance(target, KeyColorsOutputTarget):
await websocket.close(code=4003, reason="Target is not a key_colors target")
return
settings = target.settings
if not settings.pattern_template_id:
await websocket.close(code=4003, reason="No pattern template configured")
return
try:
pattern_tmpl = pattern_store_inst.get_template(settings.pattern_template_id)
except ValueError:
await websocket.close(code=4003, reason=f"Pattern template not found: {settings.pattern_template_id}")
return
rectangles = pattern_tmpl.rectangles
if not rectangles:
await websocket.close(code=4003, reason="Pattern template has no rectangles")
return
if not target.picture_source_id:
await websocket.close(code=4003, reason="No picture source configured")
return
try:
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
except ValueError as e:
await websocket.close(code=4003, reason=str(e))
return
raw_stream = chain["raw_stream"]
# For screen capture sources, check display lock
if isinstance(raw_stream, ScreenCapturePictureSource):
display_index = raw_stream.display_index
locked_device_id = processor_manager_inst.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store_inst.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
await websocket.close(
code=4003,
reason=f"Display {display_index} is captured by '{device_name}'. Stop processing first.",
)
return
fps = max(1, min(30, fps))
preview_width = max(120, min(1920, preview_width))
frame_interval = 1.0 / fps
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
await websocket.accept()
logger.info(f"KC test WS connected for {target_id} (fps={fps})")
# Use the shared LiveStreamManager so we share the capture stream with
# running LED targets instead of creating a competing DXGI duplicator.
live_stream_mgr = processor_manager_inst._live_stream_manager
live_stream = None
try:
live_stream = await asyncio.to_thread(
live_stream_mgr.acquire, target.picture_source_id
)
logger.info(f"KC test WS acquired shared live stream for {target.picture_source_id}")
prev_frame_ref = None
while True:
loop_start = time.monotonic()
try:
capture = await asyncio.to_thread(live_stream.get_latest_frame)
if capture is None or capture.image is None:
await asyncio.sleep(frame_interval)
continue
# Skip if same frame object (no new capture yet)
if capture is prev_frame_ref:
await asyncio.sleep(frame_interval * 0.5)
continue
prev_frame_ref = capture
if not isinstance(capture.image, np.ndarray):
await asyncio.sleep(frame_interval)
continue
cur_image = capture.image
if cur_image is None:
await asyncio.sleep(frame_interval)
continue
# Apply postprocessing (if the source chain has PP templates)
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store_inst:
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store_inst.get_template(pp_id)
except ValueError as e:
logger.debug("PP template %s not found during KC test: %s", pp_id, e)
continue
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(cur_image, image_pool)
if result is not None:
cur_image = result
except ValueError as e:
logger.debug("Filter processing error during KC test: %s", e)
pass
# Extract colors
img_array = cur_image
h, w = img_array.shape[:2]
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append({
"name": rect.name,
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"color": {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"},
})
# Encode frame as JPEG
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
frame_to_encode = resize_down(cur_image, preview_width) if preview_width else cur_image
frame_uri = encode_jpeg_data_uri(frame_to_encode, quality=85)
await websocket.send_text(_json.dumps({
"type": "frame",
"image": frame_uri,
"rectangles": result_rects,
"pattern_template_name": pattern_tmpl.name,
"interpolation_mode": settings.interpolation_mode,
}))
except (WebSocketDisconnect, Exception) as inner_e:
if isinstance(inner_e, WebSocketDisconnect):
raise
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
elapsed = time.monotonic() - loop_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except WebSocketDisconnect:
logger.info(f"KC test WS disconnected for {target_id}")
except Exception as e:
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
finally:
if live_stream is not None:
try:
await asyncio.to_thread(
live_stream_mgr.release, target.picture_source_id
)
except Exception as e:
logger.debug("Live stream release during KC test cleanup: %s", e)
pass
logger.info(f"KC test WS closed for {target_id}")
@router.websocket("/api/v1/output-targets/{target_id}/ws")
async def target_colors_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
try:
manager.add_kc_ws_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
# Keep alive — wait for client messages (or disconnect)
await websocket.receive_text()
except WebSocketDisconnect:
logger.debug("KC live WebSocket disconnected for target %s", target_id)
pass
finally:
manager.remove_kc_ws_client(target_id, websocket)
@@ -15,7 +15,7 @@ from wled_controller.api.schemas.pattern_templates import (
PatternTemplateUpdate, PatternTemplateUpdate,
) )
from wled_controller.api.schemas.output_targets import KeyColorRectangleSchema from wled_controller.api.schemas.output_targets import KeyColorRectangleSchema
from wled_controller.storage.key_colors_output_target import KeyColorRectangle from wled_controller.storage.pattern_template import KeyColorRectangle
from wled_controller.storage.pattern_template_store import PatternTemplateStore from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -42,7 +42,11 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
) )
@router.get("/api/v1/pattern-templates", response_model=PatternTemplateListResponse, tags=["Pattern Templates"]) @router.get(
"/api/v1/pattern-templates",
response_model=PatternTemplateListResponse,
tags=["Pattern Templates"],
)
async def list_pattern_templates( async def list_pattern_templates(
_auth: AuthRequired, _auth: AuthRequired,
store: PatternTemplateStore = Depends(get_pattern_template_store), store: PatternTemplateStore = Depends(get_pattern_template_store),
@@ -57,7 +61,12 @@ async def list_pattern_templates(
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/pattern-templates", response_model=PatternTemplateResponse, tags=["Pattern Templates"], status_code=201) @router.post(
"/api/v1/pattern-templates",
response_model=PatternTemplateResponse,
tags=["Pattern Templates"],
status_code=201,
)
async def create_pattern_template( async def create_pattern_template(
data: PatternTemplateCreate, data: PatternTemplateCreate,
_auth: AuthRequired, _auth: AuthRequired,
@@ -87,7 +96,11 @@ async def create_pattern_template(
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/pattern-templates/{template_id}", response_model=PatternTemplateResponse, tags=["Pattern Templates"]) @router.get(
"/api/v1/pattern-templates/{template_id}",
response_model=PatternTemplateResponse,
tags=["Pattern Templates"],
)
async def get_pattern_template( async def get_pattern_template(
template_id: str, template_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -101,7 +114,11 @@ async def get_pattern_template(
raise HTTPException(status_code=404, detail=f"Pattern template {template_id} not found") raise HTTPException(status_code=404, detail=f"Pattern template {template_id} not found")
@router.put("/api/v1/pattern-templates/{template_id}", response_model=PatternTemplateResponse, tags=["Pattern Templates"]) @router.put(
"/api/v1/pattern-templates/{template_id}",
response_model=PatternTemplateResponse,
tags=["Pattern Templates"],
)
async def update_pattern_template( async def update_pattern_template(
template_id: str, template_id: str,
data: PatternTemplateUpdate, data: PatternTemplateUpdate,
@@ -135,7 +152,9 @@ async def update_pattern_template(
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/pattern-templates/{template_id}", status_code=204, tags=["Pattern Templates"]) @router.delete(
"/api/v1/pattern-templates/{template_id}", status_code=204, tags=["Pattern Templates"]
)
async def delete_pattern_template( async def delete_pattern_template(
template_id: str, template_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -150,7 +169,7 @@ async def delete_pattern_template(
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=f"Cannot delete pattern template: it is referenced by target(s): {names}. " detail=f"Cannot delete pattern template: it is referenced by target(s): {names}. "
"Please reassign those targets before deleting.", "Please reassign those targets before deleting.",
) )
store.delete_template(template_id) store.delete_template(template_id)
fire_entity_event("pattern_template", "deleted", template_id) fire_entity_event("pattern_template", "deleted", template_id)
@@ -18,42 +18,6 @@ class KeyColorRectangleSchema(BaseModel):
height: float = Field(default=1.0, description="Height (0.0-1.0)", gt=0.0, le=1.0) height: float = Field(default=1.0, description="Height (0.0-1.0)", gt=0.0, le=1.0)
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
)
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
class ExtractedColorResponse(BaseModel):
"""A single extracted color."""
r: int = Field(description="Red (0-255)")
g: int = Field(description="Green (0-255)")
b: int = Field(description="Blue (0-255)")
hex: str = Field(description="Hex color (#rrggbb)")
class KeyColorsResponse(BaseModel):
"""Extracted key colors for a target."""
target_id: str = Field(description="Target ID")
colors: Dict[str, ExtractedColorResponse] = Field(description="Rectangle name -> color")
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
class HALightMappingSchema(BaseModel): class HALightMappingSchema(BaseModel):
"""Maps an LED range to one HA light entity.""" """Maps an LED range to one HA light entity."""
@@ -69,7 +33,7 @@ class OutputTargetCreate(BaseModel):
"""Request to create an output target.""" """Request to create an output target."""
name: str = Field(description="Target name", min_length=1, max_length=100) name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="led", description="Target type (led, key_colors, ha_light)") target_type: str = Field(default="led", description="Target type (led, ha_light)")
# LED target fields # LED target fields
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") color_strip_source_id: str = Field(default="", description="Color strip source ID")
@@ -101,13 +65,6 @@ class OutputTargetCreate(BaseModel):
pattern="^(ddp|http)$", pattern="^(ddp|http)$",
description="Send protocol: ddp (UDP) or http (JSON API)", 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)"
)
# HA light target fields # HA light target fields
ha_source_id: str = Field( ha_source_id: str = Field(
default="", description="Home Assistant source ID (for ha_light targets)" default="", description="Home Assistant source ID (for ha_light targets)"
@@ -157,13 +114,6 @@ class OutputTargetUpdate(BaseModel):
protocol: Optional[str] = Field( protocol: Optional[str] = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)" 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)"
)
# HA light target fields # HA light target fields
ha_source_id: Optional[str] = Field( ha_source_id: Optional[str] = Field(
None, description="Home Assistant source ID (for ha_light targets)" None, description="Home Assistant source ID (for ha_light targets)"
@@ -206,11 +156,6 @@ class OutputTargetResponse(BaseModel):
default=False, description="Auto-reduce FPS when device is unresponsive" default=False, description="Auto-reduce FPS when device is unresponsive"
) )
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)") protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
# KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
None, description="Key colors settings"
)
# HA light target fields # HA light target fields
ha_source_id: str = Field(default="", description="Home Assistant source ID (ha_light)") ha_source_id: str = Field(default="", description="Home Assistant source ID (ha_light)")
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field( ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
@@ -263,12 +208,6 @@ class TargetProcessingState(BaseModel):
timing_audio_render_ms: Optional[float] = Field( timing_audio_render_ms: Optional[float] = Field(
None, description="Audio visualization render time (ms)" 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") display_index: Optional[int] = Field(None, description="Current display index")
overlay_active: bool = Field( overlay_active: bool = Field(
default=False, description="Whether visualization overlay is active" default=False, description="Whether visualization overlay is active"
@@ -328,25 +267,3 @@ class BulkTargetResponse(BaseModel):
errors: Dict[str, str] = Field( errors: Dict[str, str] = Field(
default_factory=dict, description="Map of target ID to error message for failures" default_factory=dict, description="Map of target ID to error message for failures"
) )
class KCTestRectangleResponse(BaseModel):
"""A rectangle with its extracted color from a KC test."""
name: str = Field(description="Rectangle name")
x: float = Field(description="Left edge (0.0-1.0)")
y: float = Field(description="Top edge (0.0-1.0)")
width: float = Field(description="Width (0.0-1.0)")
height: float = Field(description="Height (0.0-1.0)")
color: ExtractedColorResponse = Field(description="Extracted color for this rectangle")
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"
)
interpolation_mode: str = Field(description="Color extraction mode used")
pattern_template_name: str = Field(description="Pattern template name")
@@ -6,8 +6,9 @@ Rate-limited to update_rate Hz (typically 1-5 Hz).
""" """
import asyncio import asyncio
import json
import time import time
from typing import Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import numpy as np import numpy as np
@@ -50,6 +51,8 @@ class HALightTargetProcessor(TargetProcessor):
self._value_stream = None # brightness value source stream self._value_stream = None # brightness value source stream
self._previous_colors: Dict[str, Tuple[int, int, int]] = {} self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
self._previous_on: Dict[str, bool] = {} # track on/off state per entity self._previous_on: Dict[str, bool] = {} # track on/off state per entity
self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {}
self._ws_clients: List[Any] = []
self._start_time: Optional[float] = None self._start_time: Optional[float] = None
@property @property
@@ -130,6 +133,8 @@ class HALightTargetProcessor(TargetProcessor):
self._previous_colors.clear() self._previous_colors.clear()
self._previous_on.clear() self._previous_on.clear()
self._latest_entity_colors.clear()
self._ws_clients.clear()
logger.info(f"HA light target stopped: {self._target_id}") logger.info(f"HA light target stopped: {self._target_id}")
def update_settings(self, settings) -> None: def update_settings(self, settings) -> None:
@@ -162,8 +167,24 @@ class HALightTargetProcessor(TargetProcessor):
except Exception as e: except Exception as e:
logger.warning(f"HA light {self._target_id}: CSS swap failed: {e}") logger.warning(f"HA light {self._target_id}: CSS swap failed: {e}")
# ── WebSocket clients ──
def add_ws_client(self, ws: Any) -> None:
self._ws_clients.append(ws)
def remove_ws_client(self, ws: Any) -> None:
if ws in self._ws_clients:
self._ws_clients.remove(ws)
def supports_websocket(self) -> bool:
return True
def get_state(self) -> dict: def get_state(self) -> dict:
uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0 uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0
entity_colors = {
eid: {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"}
for eid, (r, g, b) in self._latest_entity_colors.items()
}
return { return {
"target_id": self._target_id, "target_id": self._target_id,
"processing": self._is_running, "processing": self._is_running,
@@ -176,6 +197,7 @@ class HALightTargetProcessor(TargetProcessor):
"fps_actual": self._update_rate if self._is_running else None, "fps_actual": self._update_rate if self._is_running else None,
"fps_target": self._update_rate, "fps_target": self._update_rate,
"uptime_seconds": uptime, "uptime_seconds": uptime,
"entity_colors": entity_colors,
} }
def get_metrics(self) -> dict: def get_metrics(self) -> dict:
@@ -244,6 +266,9 @@ class HALightTargetProcessor(TargetProcessor):
avg = segment.mean(axis=0).astype(int) avg = segment.mean(axis=0).astype(int)
r, g, b = int(avg[0]), int(avg[1]), int(avg[2]) r, g, b = int(avg[0]), int(avg[1]), int(avg[2])
# Cache for WS preview (always, even if HA call is skipped)
self._latest_entity_colors[mapping.entity_id] = (r, g, b)
# Calculate brightness (0-255) from max channel # Calculate brightness (0-255) from max channel
brightness = max(r, g, b) brightness = max(r, g, b)
@@ -298,3 +323,23 @@ class HALightTargetProcessor(TargetProcessor):
) )
self._previous_on[entity_id] = False self._previous_on[entity_id] = False
self._previous_colors.pop(entity_id, None) self._previous_colors.pop(entity_id, None)
# Broadcast colors to WS clients
if self._ws_clients and self._latest_entity_colors:
await self._broadcast_entity_colors()
async def _broadcast_entity_colors(self) -> None:
"""Send current entity colors to all connected WS clients."""
colors_payload = {
eid: {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"}
for eid, (r, g, b) in self._latest_entity_colors.items()
}
message = json.dumps({"type": "colors_update", "colors": colors_payload})
dead: List[Any] = []
for ws in self._ws_clients:
try:
await ws.send_text(message)
except Exception:
dead.append(ws)
for ws in dead:
self._ws_clients.remove(ws)
@@ -1,489 +0,0 @@
"""Key Colors target processor — extracts dominant colors from screen regions."""
from __future__ import annotations
import asyncio
import collections
import json
import time
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple
import cv2
import numpy as np
from wled_controller.core.processing.live_stream import LiveStream
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.core.processing.target_processor import (
ProcessingMetrics,
TargetContext,
TargetProcessor,
)
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
KC_WORK_SIZE = (160, 90) # (width, height) — small enough for fast color calc
# ---------------------------------------------------------------------------
# CPU-bound frame processing (runs in thread pool via asyncio.to_thread)
# ---------------------------------------------------------------------------
def _process_kc_frame(capture, rect_names, rect_bounds, calc_fn, prev_colors_arr, smoothing, brightness):
"""All CPU-bound work for one KC frame.
Returns (colors, colors_arr, timing_ms) where:
- colors is a dict {name: (r, g, b)}
- colors_arr is a (N, 3) float64 array for smoothing continuity
- timing_ms is a dict with per-stage timing in milliseconds.
"""
t0 = time.perf_counter()
# Downsample to working resolution — 144x fewer pixels at 1080p
small = cv2.resize(capture.image, KC_WORK_SIZE, interpolation=cv2.INTER_AREA)
# Extract colors for each rectangle from the small image
n = len(rect_names)
colors_arr = np.empty((n, 3), dtype=np.float64)
for i, (y1, y2, x1, x2) in enumerate(rect_bounds):
colors_arr[i] = calc_fn(small[y1:y2, x1:x2])
t1 = time.perf_counter()
# Vectorized smoothing on (N, 3) array
if prev_colors_arr is not None and smoothing > 0:
colors_arr = colors_arr * (1 - smoothing) + prev_colors_arr * smoothing
# Apply brightness scaling
if brightness < 1.0:
output_arr = colors_arr * brightness
else:
output_arr = colors_arr
colors_u8 = np.clip(output_arr, 0, 255).astype(np.uint8)
t2 = time.perf_counter()
# Build output dict
colors = {rect_names[i]: tuple(int(c) for c in colors_u8[i]) for i in range(n)}
timing_ms = {
"calc_colors": (t1 - t0) * 1000,
"smooth": (t2 - t1) * 1000,
"total": (t2 - t0) * 1000,
}
return colors, colors_arr, timing_ms
# ---------------------------------------------------------------------------
# KCTargetProcessor
# ---------------------------------------------------------------------------
class KCTargetProcessor(TargetProcessor):
"""Extracts key colors from screen capture regions and broadcasts via WebSocket."""
def __init__(
self,
target_id: str,
picture_source_id: str,
settings, # KeyColorsSettings
ctx: TargetContext,
):
super().__init__(target_id, ctx, picture_source_id)
self._settings = settings
self._brightness_vs_id = settings.brightness_value_source_id if settings else ""
# Runtime state
self._live_stream: Optional[LiveStream] = None
self._value_stream = None # active brightness value stream
self._previous_colors: Optional[Dict[str, Tuple[int, int, int]]] = None
self._latest_colors: Optional[Dict[str, Tuple[int, int, int]]] = None
self._ws_clients: List = []
self._resolved_target_fps: Optional[int] = None
self._resolved_rectangles = None
# ----- Properties -----
@property
def settings(self):
return self._settings
# ----- Lifecycle -----
async def start(self) -> None:
if self._is_running:
logger.debug(f"KC target {self._target_id} is already running")
return
if not self._picture_source_id:
raise ValueError(f"KC target {self._target_id} has no picture source assigned")
if not self._settings.pattern_template_id:
raise ValueError(f"KC target {self._target_id} has no pattern template assigned")
# Resolve pattern template to get rectangles
try:
pattern_template = self._ctx.pattern_template_store.get_template(
self._settings.pattern_template_id
)
except (ValueError, AttributeError):
raise ValueError(
f"Pattern template {self._settings.pattern_template_id} not found"
)
if not pattern_template.rectangles:
raise ValueError(
f"Pattern template {self._settings.pattern_template_id} has no rectangles"
)
self._resolved_rectangles = pattern_template.rectangles
# Acquire live stream
try:
live_stream = await asyncio.to_thread(
self._ctx.live_stream_manager.acquire, self._picture_source_id
)
self._live_stream = live_stream
self._resolved_target_fps = live_stream.target_fps
logger.info(
f"Acquired live stream for KC target {self._target_id} "
f"(picture_source={self._picture_source_id})"
)
except Exception as e:
logger.error(f"Failed to initialize live stream for KC target {self._target_id}: {e}")
raise RuntimeError(f"Failed to initialize live stream: {e}")
# Acquire value stream for brightness modulation (if configured)
if self._brightness_vs_id and self._ctx.value_stream_manager:
try:
self._value_stream = self._ctx.value_stream_manager.acquire(
self._brightness_vs_id
)
except Exception as e:
logger.warning(f"Failed to acquire value stream {self._brightness_vs_id}: {e}")
self._value_stream = None
# Reset metrics
self._metrics = ProcessingMetrics(start_time=datetime.now(timezone.utc))
self._previous_colors = None
self._latest_colors = None
# Start processing task
self._is_running = True
self._task = asyncio.create_task(self._processing_loop())
logger.info(f"Started KC processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True})
async def stop(self) -> None:
if not self._is_running:
logger.warning(f"KC processing not running for target {self._target_id}")
return
self._is_running = False
# Cancel task
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
logger.debug("KC target processor task cancelled")
pass
self._task = None
# Release live stream
if self._live_stream:
try:
self._ctx.live_stream_manager.release(self._picture_source_id)
except Exception as e:
logger.warning(f"Error releasing live stream for KC target: {e}")
self._live_stream = None
# Release value stream
if self._value_stream is not None and self._ctx.value_stream_manager:
try:
self._ctx.value_stream_manager.release(self._brightness_vs_id)
except Exception as e:
logger.warning(f"Error releasing value stream: {e}")
self._value_stream = None
logger.info(f"Stopped KC processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
# ----- Settings -----
def update_settings(self, settings) -> None:
self._settings = settings
# Keep _brightness_vs_id in sync (hot-swap handled separately)
self._brightness_vs_id = settings.brightness_value_source_id if settings else ""
logger.info(f"Updated KC target settings: {self._target_id}")
def update_brightness_value_source(self, vs_id: str) -> None:
"""Hot-swap the brightness value source for a running KC target."""
old_vs_id = self._brightness_vs_id
self._brightness_vs_id = vs_id
vs_mgr = self._ctx.value_stream_manager
if not self._is_running or vs_mgr is None:
return
# Release old stream
if self._value_stream is not None and old_vs_id:
try:
vs_mgr.release(old_vs_id)
except Exception as e:
logger.warning(f"Error releasing old value stream {old_vs_id}: {e}")
self._value_stream = None
# Acquire new stream
if vs_id:
try:
self._value_stream = vs_mgr.acquire(vs_id)
except Exception as e:
logger.warning(f"Failed to acquire value stream {vs_id}: {e}")
self._value_stream = None
logger.info(f"Hot-swapped brightness VS for KC target {self._target_id}: {old_vs_id} -> {vs_id}")
# ----- State / Metrics -----
def get_state(self) -> dict:
metrics = self._metrics
return {
"target_id": self._target_id,
"processing": self._is_running,
"fps_actual": round(metrics.fps_actual, 1) if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None,
"fps_target": self._settings.fps,
"frames_skipped": metrics.frames_skipped if self._is_running else None,
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
"fps_current": metrics.fps_current if self._is_running else None,
"timing_calc_colors_ms": round(metrics.timing_calc_colors_ms, 1) if self._is_running else None,
"timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if self._is_running else None,
"timing_broadcast_ms": round(metrics.timing_broadcast_ms, 1) if self._is_running else None,
"timing_total_ms": round(metrics.timing_total_ms, 1) if self._is_running else None,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
"brightness_value_source_id": self._brightness_vs_id,
}
def get_metrics(self) -> dict:
metrics = self._metrics
uptime = 0.0
if metrics.start_time and self._is_running:
uptime = (datetime.now(timezone.utc) - metrics.start_time).total_seconds()
return {
"target_id": self._target_id,
"processing": self._is_running,
"fps_actual": round(metrics.fps_actual, 1),
"fps_target": self._settings.fps,
"uptime_seconds": round(uptime, 1),
"frames_processed": metrics.frames_processed,
"errors_count": metrics.errors_count,
"last_error": metrics.last_error,
"last_update": metrics.last_update.isoformat() if metrics.last_update else None,
}
# ----- WebSocket -----
def supports_websocket(self) -> bool:
return True
def add_ws_client(self, ws) -> None:
self._ws_clients.append(ws)
def remove_ws_client(self, ws) -> None:
if ws in self._ws_clients:
self._ws_clients.remove(ws)
def get_latest_colors(self) -> Dict[str, Tuple[int, int, int]]:
return self._latest_colors or {}
# ----- Private: processing loop -----
async def _processing_loop(self) -> None:
"""Main processing loop for key-colors extraction."""
target_fps = self._settings.fps
# Lookup table for interpolation mode → function (used per-frame from live settings)
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
frame_time = 1.0 / target_fps
fps_samples: collections.deque = collections.deque(maxlen=10)
timing_samples: collections.deque = collections.deque(maxlen=10)
prev_frame_time_stamp = time.perf_counter()
prev_capture = None
last_broadcast_time = 0.0
send_timestamps: collections.deque = collections.deque()
rectangles = self._resolved_rectangles
# Pre-compute pixel bounds at working resolution (160x90)
kc_w, kc_h = KC_WORK_SIZE
rect_names = [r.name for r in rectangles]
rect_bounds = []
for rect in rectangles:
px_x = max(0, int(rect.x * kc_w))
px_y = max(0, int(rect.y * kc_h))
px_w = max(1, int(rect.width * kc_w))
px_h = max(1, int(rect.height * kc_h))
px_x = min(px_x, kc_w - 1)
px_y = min(px_y, kc_h - 1)
px_w = min(px_w, kc_w - px_x)
px_h = min(px_h, kc_h - px_y)
rect_bounds.append((px_y, px_y + px_h, px_x, px_x + px_w))
prev_colors_arr = None
logger.info(
f"KC processing loop started for target {self._target_id} "
f"(fps={target_fps}, rects={len(rectangles)})"
)
try:
with high_resolution_timer():
while self._is_running:
loop_start = time.perf_counter()
try:
capture = self._live_stream.get_latest_frame()
if capture is None:
await asyncio.sleep(frame_time)
continue
# Skip processing if the frame hasn't changed
if capture is prev_capture:
# Keepalive: re-broadcast last colors
if self._latest_colors and (loop_start - last_broadcast_time) >= 1.0:
await self._broadcast_colors(self._latest_colors)
last_broadcast_time = time.perf_counter()
send_timestamps.append(last_broadcast_time)
self._metrics.frames_keepalive += 1
self._metrics.frames_skipped += 1
now_ts = time.perf_counter()
while send_timestamps and send_timestamps[0] < now_ts - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
await asyncio.sleep(frame_time)
continue
prev_capture = capture
# Read settings fresh each frame so hot updates (brightness,
# smoothing, interpolation_mode) take effect without restart.
s = self._settings
calc_fn = calc_fns.get(s.interpolation_mode, calculate_average_color)
# Effective brightness: static setting * value stream
eff_brightness = s.brightness
vs = self._value_stream
if vs is not None:
eff_brightness *= vs.get_value()
# CPU-bound work in thread pool
colors, colors_arr, frame_timing = await asyncio.to_thread(
_process_kc_frame,
capture, rect_names, rect_bounds, calc_fn,
prev_colors_arr, s.smoothing, eff_brightness,
)
prev_colors_arr = colors_arr
self._latest_colors = dict(colors)
# Broadcast to WebSocket clients
t_broadcast_start = time.perf_counter()
await self._broadcast_colors(colors)
broadcast_ms = (time.perf_counter() - t_broadcast_start) * 1000
last_broadcast_time = time.perf_counter()
send_timestamps.append(last_broadcast_time)
# Per-stage timing (rolling average over last 10 frames)
frame_timing["broadcast"] = broadcast_ms
timing_samples.append(frame_timing)
n = len(timing_samples)
self._metrics.timing_calc_colors_ms = sum(s["calc_colors"] for s in timing_samples) / n
self._metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n
self._metrics.timing_broadcast_ms = sum(s["broadcast"] for s in timing_samples) / n
self._metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + broadcast_ms
# Update metrics
self._metrics.frames_processed += 1
self._metrics.last_update = datetime.now(timezone.utc)
# Calculate actual FPS
now = time.perf_counter()
interval = now - prev_frame_time_stamp
prev_frame_time_stamp = now
fps_samples.append(1.0 / interval if interval > 0 else 0)
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
# Potential FPS
processing_time = now - loop_start
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
# fps_current
while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
except Exception as e:
self._metrics.errors_count += 1
self._metrics.last_error = str(e)
logger.error(f"KC processing error for {self._target_id}: {e}", exc_info=True)
# Throttle to target FPS
elapsed = time.perf_counter() - loop_start
remaining = frame_time - elapsed
if remaining > 0:
await asyncio.sleep(remaining)
except asyncio.CancelledError:
logger.info(f"KC processing loop cancelled for target {self._target_id}")
raise
except Exception as e:
logger.error(f"Fatal error in KC processing loop for target {self._target_id}: {e}")
self._is_running = False
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False, "crashed": True})
raise
finally:
logger.info(f"KC processing loop ended for target {self._target_id}")
async def _broadcast_colors(self, colors: Dict[str, Tuple[int, int, int]]) -> None:
"""Broadcast extracted colors to WebSocket clients (concurrent sends)."""
if not self._ws_clients:
return
message = json.dumps({
"type": "colors_update",
"target_id": self._target_id,
"colors": {
name: {"r": c[0], "g": c[1], "b": c[2]}
for name, c in colors.items()
},
"timestamp": datetime.now(timezone.utc).isoformat(),
})
async def _send_safe(ws):
try:
await ws.send_text(message)
return True
except Exception as e:
logger.debug("KC WS send failed: %s", e)
return False
clients = list(self._ws_clients)
results = await asyncio.gather(*[_send_safe(ws) for ws in clients])
for ws, ok in zip(clients, results):
if not ok and ws in self._ws_clients:
self._ws_clients.remove(ws)
@@ -498,10 +498,6 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
del self._processors[target_id] del self._processors[target_id]
logger.info(f"Unregistered target {target_id}") logger.info(f"Unregistered target {target_id}")
# Backward-compat alias
def remove_kc_target(self, target_id: str) -> None:
self.remove_target(target_id)
# ===== UNIFIED TARGET OPERATIONS ===== # ===== UNIFIED TARGET OPERATIONS =====
def update_target_settings(self, target_id: str, settings): def update_target_settings(self, target_id: str, settings):
@@ -774,10 +770,15 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
# ===== WEBSOCKET (delegates to processor) ===== # ===== WEBSOCKET (delegates to processor) =====
def add_kc_ws_client(self, target_id: str, ws) -> None: def add_ha_light_ws_client(self, target_id: str, ws) -> None:
proc = self._get_processor(target_id) proc = self._get_processor(target_id)
proc.add_ws_client(ws) proc.add_ws_client(ws)
def remove_ha_light_ws_client(self, target_id: str, ws) -> None:
proc = self._processors.get(target_id)
if proc:
proc.remove_ws_client(ws)
def add_led_preview_client(self, target_id: str, ws) -> None: def add_led_preview_client(self, target_id: str, ws) -> None:
proc = self._get_processor(target_id) proc = self._get_processor(target_id)
proc.add_led_preview_client(ws) proc.add_led_preview_client(ws)
@@ -1,7 +1,7 @@
"""Abstract base class for target processors. """Abstract base class for target processors.
A TargetProcessor encapsulates the processing loop and state for a single A TargetProcessor encapsulates the processing loop and state for a single
output target. Concrete subclasses (WledTargetProcessor, KCTargetProcessor) output target. Concrete subclasses (WledTargetProcessor, HALightTargetProcessor)
implement the target-specific capture→process→output pipeline. implement the target-specific capture→process→output pipeline.
ProcessorManager creates and owns TargetProcessor instances, delegating ProcessorManager creates and owns TargetProcessor instances, delegating
-1
View File
@@ -233,7 +233,6 @@ async def lifespan(app: FastAPI):
logger.info(f"Registered {len(devices)} devices for health monitoring") logger.info(f"Registered {len(devices)} devices for health monitoring")
# Migrate KC targets → key_colors CSS sources
# Register output targets in processor manager # Register output targets in processor manager
targets = output_target_store.get_all_targets() targets = output_target_store.get_all_targets()
registered_targets = 0 registered_targets = 0
@@ -1553,6 +1553,35 @@
height: 16px; height: 16px;
} }
/* HA Light color swatches */
.ha-light-swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.ha-light-swatch {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 4px;
background: var(--bg-color);
border: 1px solid var(--border-color);
font-size: 0.75rem;
}
.ha-light-swatch .swatch-color {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid rgba(255,255,255,0.2);
transition: background 0.3s;
}
.ha-light-swatch .swatch-label {
opacity: 0.8;
}
.btn-remove-mapping:hover { .btn-remove-mapping:hover {
opacity: 1; opacity: 1;
} }
+1 -1
View File
@@ -35,7 +35,7 @@ import {
closeTutorial, tutorialNext, tutorialPrev, closeTutorial, tutorialNext, tutorialPrev,
} from './features/tutorials.ts'; } from './features/tutorials.ts';
// Layer 4: devices, dashboard, streams, kc-targets, pattern-templates, automations // Layer 4: devices, dashboard, streams, pattern-templates, automations
import { import {
showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal, showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal,
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness, saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
@@ -6,7 +6,7 @@ import { fetchWithAuth, escapeHtml } from './api.ts';
import { t } from './i18n.ts'; import { t } from './i18n.ts';
import { navigateToCard } from './navigation.ts'; import { navigateToCard } from './navigation.ts';
import { import {
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE, ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK,
} from './icons.ts'; } from './icons.ts';
@@ -46,17 +46,10 @@ function _buildItems(results: any[], states: any = {}) {
_mapEntities(targets, tgt => { _mapEntities(targets, tgt => {
const running = !!states[tgt.id]?.processing; const running = !!states[tgt.id]?.processing;
if (tgt.target_type === 'key_colors') { items.push({
items.push({ name: tgt.name, detail: tgt.target_type, group: 'targets', icon: ICON_TARGET,
name: tgt.name, detail: 'key_colors', group: 'kc_targets', icon: getTargetTypeIcon('key_colors'), nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
nav: ['targets', 'kc-targets', 'kc-targets', 'data-kc-target-id', tgt.id], running, });
});
} else {
items.push({
name: tgt.name, detail: tgt.target_type, group: 'targets', icon: ICON_TARGET,
nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
});
}
// Action item: toggle start/stop // Action item: toggle start/stop
const actionItem: any = { const actionItem: any = {
name: tgt.name, group: 'actions', name: tgt.name, group: 'actions',
@@ -219,7 +212,7 @@ async function _fetchAllEntities() {
const _groupOrder = [ const _groupOrder = [
'actions', 'actions',
'devices', 'targets', 'kc_targets', 'css', 'cspt', 'automations', 'devices', 'targets', 'css', 'cspt', 'automations',
'streams', 'capture_templates', 'pp_templates', 'pattern_templates', 'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
'audio', 'value', 'scenes', 'sync_clocks', 'audio', 'value', 'scenes', 'sync_clocks',
]; ];
@@ -105,7 +105,7 @@ const SUBTYPE_ICONS = {
adaptive_time: P.clock, adaptive_scene: P.cloudSun, daylight: P.sun, adaptive_time: P.clock, adaptive_scene: P.cloudSun, daylight: P.sun,
}, },
audio_source: { mono: P.mic, multichannel: P.volume2 }, audio_source: { mono: P.mic, multichannel: P.volume2 },
output_target: { led: P.lightbulb, wled: P.lightbulb, key_colors: P.palette }, output_target: { led: P.lightbulb, wled: P.lightbulb, ha_light: P.lightbulb },
}; };
function svgEl(tag: string, attrs: Record<string, string | number> = {}): SVGElement { function svgEl(tag: string, attrs: Record<string, string | number> = {}): SVGElement {
@@ -413,7 +413,7 @@ function _createOverlay(node: GraphNode, nodeWidth: number, callbacks: NodeCallb
} }
// Test button for applicable kinds // Test button for applicable kinds
if (TEST_KINDS.has(node.kind) || (node.kind === 'output_target' && node.subtype === 'key_colors')) { if (TEST_KINDS.has(node.kind)) {
btns.push({ svgPath: P.flaskConical, action: 'test', cls: '' }); btns.push({ svgPath: P.flaskConical, action: 'test', cls: '' });
} }
@@ -14,7 +14,7 @@ import * as P from './icon-paths.ts';
const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`; const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Type-resolution maps (private) ────────────────────────── // ── Type-resolution maps (private) ──────────────────────────
const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb), key_colors: _svg(P.palette) }; const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb), ha_light: _svg(P.lightbulb) };
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) }; const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) };
const _colorStripTypeIcons = { const _colorStripTypeIcons = {
picture_advanced: _svg(P.monitor), picture_advanced: _svg(P.monitor),
@@ -24,18 +24,6 @@ export function setAuthRequired(v: boolean) { authRequired = v; }
export let refreshInterval: ReturnType<typeof setInterval> | null = null; export let refreshInterval: ReturnType<typeof setInterval> | null = null;
export function setRefreshInterval(v: ReturnType<typeof setInterval> | null) { refreshInterval = v; } export function setRefreshInterval(v: ReturnType<typeof setInterval> | null) { refreshInterval = v; }
export let kcTestAutoRefresh: ReturnType<typeof setInterval> | null = null;
export function setKcTestAutoRefresh(v: ReturnType<typeof setInterval> | null) { kcTestAutoRefresh = v; }
export let kcTestTargetId: string | null = null;
export function setKcTestTargetId(v: string | null) { kcTestTargetId = v; }
export let kcTestWs: WebSocket | null = null;
export function setKcTestWs(v: WebSocket | null) { kcTestWs = v; }
export let kcTestFps = 3;
export function setKcTestFps(v: number) { kcTestFps = v; }
export let _cachedDisplays: Display[] | null = null; export let _cachedDisplays: Display[] | null = null;
export let _displayPickerCallback: ((index: number, display?: Display | null) => void) | null = null; export let _displayPickerCallback: ((index: number, display?: Display | null) => void) | null = null;
@@ -127,13 +115,6 @@ export function set_lastValidatedImageSource(v: string) { _lastValidatedImageSou
export let _targetEditorDevices: Device[] = []; export let _targetEditorDevices: Device[] = [];
export function set_targetEditorDevices(v: Device[]) { _targetEditorDevices = v; } export function set_targetEditorDevices(v: Device[]) { _targetEditorDevices = v; }
// KC editor state
export let _kcNameManuallyEdited = false;
export function set_kcNameManuallyEdited(v: boolean) { _kcNameManuallyEdited = v; }
// KC WebSockets
export const kcWebSockets: Record<string, WebSocket> = {};
// LED Preview WebSockets // LED Preview WebSockets
export const ledPreviewWebSockets: Record<string, WebSocket> = {}; export const ledPreviewWebSockets: Record<string, WebSocket> = {};
@@ -2,7 +2,7 @@
* UI utilities modal helpers, lightbox, toast, confirm. * UI utilities modal helpers, lightbox, toast, confirm.
*/ */
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, kcTestWs, setKcTestWs, confirmResolve, setConfirmResolve } from './state.ts'; import { confirmResolve, setConfirmResolve } from './state.ts';
import { API_BASE, getHeaders } from './api.ts'; import { API_BASE, getHeaders } from './api.ts';
import { t } from './i18n.ts'; import { t } from './i18n.ts';
@@ -101,8 +101,6 @@ export function openLightbox(imageSrc: string, statsHtml?: string) {
export function closeLightbox(event?: Event) { export function closeLightbox(event?: Event) {
if (event && event.target && (event.target as HTMLElement).closest('.lightbox-content')) return; if (event && event.target && (event.target as HTMLElement).closest('.lightbox-content')) return;
// Stop KC test WS if running
stopKCTestAutoRefresh();
const lightbox = document.getElementById('image-lightbox')!; const lightbox = document.getElementById('image-lightbox')!;
lightbox.classList.remove('active'); lightbox.classList.remove('active');
const img = document.getElementById('lightbox-image') as HTMLImageElement; const img = document.getElementById('lightbox-image') as HTMLImageElement;
@@ -115,18 +113,6 @@ export function closeLightbox(event?: Event) {
unlockBody(); unlockBody();
} }
export function stopKCTestAutoRefresh() {
if (kcTestAutoRefresh) {
clearInterval(kcTestAutoRefresh);
setKcTestAutoRefresh(null);
}
if (kcTestWs) {
try { kcTestWs.close(); } catch (_) {}
setKcTestWs(null);
}
setKcTestTargetId(null);
}
export function showToast(message: string, type = 'info') { export function showToast(message: string, type = 'info') {
const toast = document.getElementById('toast')!; const toast = document.getElementById('toast')!;
toast.textContent = message; toast.textContent = message;
@@ -1528,7 +1528,7 @@ function _onTestNode(node: any) {
value_source: () => _w.testValueSource?.(node.id), value_source: () => _w.testValueSource?.(node.id),
color_strip_source: () => _w.testColorStrip?.(node.id), color_strip_source: () => _w.testColorStrip?.(node.id),
cspt: () => _w.testCSPT?.(node.id), cspt: () => _w.testCSPT?.(node.id),
output_target: () => _w.testKCTarget?.(node.id), output_target: undefined,
}; };
fnMap[node.kind]?.(); fnMap[node.kind]?.();
} }
@@ -488,6 +488,9 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
<div class="metric-value" data-tm="ha-status">${state.ha_connected ? ICON_OK : ICON_WARNING}</div> <div class="metric-value" data-tm="ha-status">${state.ha_connected ? ICON_OK : ICON_WARNING}</div>
</div> </div>
</div> </div>
<div class="ha-light-swatches" data-ha-swatches="${target.id}">
${_renderEntitySwatches(state.entity_colors || {}, target.ha_light_mappings || [])}
</div>
` : ''} ` : ''}
</div>`, </div>`,
actions: ` actions: `
@@ -560,6 +563,78 @@ export function initHALightTargetDelegation(container: HTMLElement): void {
}); });
} }
// ── Entity color swatches ──
function _renderEntitySwatches(entityColors: Record<string, any>, mappings: any[]): string {
if (!mappings.length) return '';
return mappings.map(m => {
const c = entityColors[m.entity_id];
const bg = c ? c.hex : '#333';
const label = m.entity_id.replace('light.', '');
return `<div class="ha-light-swatch" data-entity="${escapeHtml(m.entity_id)}">
<span class="swatch-color" style="background:${bg}"></span>
<span class="swatch-label">${escapeHtml(label)}</span>
</div>`;
}).join('');
}
// ── WebSocket color preview ──
const _haLightWS: Record<string, WebSocket> = {};
export function connectHALightWS(targetId: string): void {
if (_haLightWS[targetId]) return;
const loc = window.location;
const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
const apiKey = (window as any).apiKey || localStorage.getItem('wled_api_key') || '';
const url = `${wsProto}//${loc.host}/api/v1/output-targets/${targetId}/ha-light/ws?token=${encodeURIComponent(apiKey)}`;
const ws = new WebSocket(url);
_haLightWS[targetId] = ws;
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data.type === 'colors_update') {
_updateSwatchColors(targetId, data.colors);
}
} catch {}
};
ws.onclose = () => {
delete _haLightWS[targetId];
};
ws.onerror = () => {
delete _haLightWS[targetId];
};
}
export function disconnectHALightWS(targetId: string): void {
const ws = _haLightWS[targetId];
if (ws) {
ws.close();
delete _haLightWS[targetId];
}
}
export function disconnectAllHALightWS(): void {
for (const id of Object.keys(_haLightWS)) {
disconnectHALightWS(id);
}
}
function _updateSwatchColors(targetId: string, colors: Record<string, any>): void {
const container = document.querySelector(`[data-ha-swatches="${targetId}"]`);
if (!container) return;
for (const [entityId, c] of Object.entries(colors)) {
const swatch = container.querySelector(`[data-entity="${entityId}"] .swatch-color`) as HTMLElement | null;
if (swatch) {
swatch.style.background = (c as any).hex;
}
}
}
// ── Expose to global scope ── // ── Expose to global scope ──
window.showHALightEditor = showHALightEditor; window.showHALightEditor = showHALightEditor;
@@ -1,841 +0,0 @@
/**
* Key Colors targets cards, test lightbox, editor, WebSocket live colors.
*/
import {
kcTestAutoRefresh, setKcTestAutoRefresh,
kcTestTargetId, setKcTestTargetId,
kcTestWs, setKcTestWs,
kcTestFps, setKcTestFps,
_kcNameManuallyEdited, set_kcNameManuallyEdited,
kcWebSockets,
PATTERN_RECT_BORDERS,
_cachedValueSources, valueSourcesCache, streamsCache,
outputTargetsCache, patternTemplatesCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { lockBody, showToast, showConfirm, formatUptime, formatCompact, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import {
getValueSourceIcon, getPictureSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_START, ICON_STOP,
ICON_LINK_SOURCE, ICON_PATTERN_TEMPLATE, ICON_FPS, ICON_PALETTE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import type { OutputTarget } from '../types.ts';
let _kcTagsInput: any = null;
class KCEditorModal extends Modal {
constructor() {
super('kc-editor-modal');
}
snapshotValues() {
return {
name: (document.getElementById('kc-editor-name') as HTMLInputElement).value,
source: (document.getElementById('kc-editor-source') as HTMLSelectElement).value,
fps: (document.getElementById('kc-editor-fps') as HTMLInputElement).value,
interpolation: (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value,
smoothing: (document.getElementById('kc-editor-smoothing') as HTMLInputElement).value,
patternTemplateId: (document.getElementById('kc-editor-pattern-template') as HTMLSelectElement).value,
brightness_vs: (document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement).value,
tags: JSON.stringify(_kcTagsInput ? _kcTagsInput.getValue() : []),
};
}
}
const kcEditorModal = new KCEditorModal();
/* ── Visual selectors ─────────────────────────────────────────── */
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _kcColorModeIconSelect: any = null;
let _kcSourceEntitySelect: any = null;
let _kcPatternEntitySelect: any = null;
let _kcBrightnessEntitySelect: any = null;
// Inline SVG previews for color modes
const _COLOR_MODE_SVG = {
average: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="52" height="16" rx="3" opacity="0.3" fill="currentColor"/><path d="M30 8v8" stroke-width="1.5"/><path d="M20 10v4" stroke-width="1.5"/><path d="M40 10v4" stroke-width="1.5"/></svg>',
median: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="14" width="8" height="6" rx="1" fill="currentColor" opacity="0.3"/><rect x="18" y="8" width="8" height="12" rx="1" fill="currentColor" opacity="0.5"/><rect x="30" y="4" width="8" height="16" rx="1" fill="currentColor" opacity="0.7"/><rect x="42" y="10" width="8" height="10" rx="1" fill="currentColor" opacity="0.4"/></svg>',
dominant: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="20" cy="12" r="4" opacity="0.2" fill="currentColor"/><circle cx="30" cy="12" r="8" fill="currentColor" opacity="0.6"/><circle cx="42" cy="12" r="3" opacity="0.15" fill="currentColor"/></svg>',
};
function _ensureColorModeIconSelect() {
const sel = document.getElementById('kc-editor-interpolation');
if (!sel) return;
const items = [
{ value: 'average', icon: _COLOR_MODE_SVG.average, label: t('kc.interpolation.average'), desc: t('kc.interpolation.average.desc') },
{ value: 'median', icon: _COLOR_MODE_SVG.median, label: t('kc.interpolation.median'), desc: t('kc.interpolation.median.desc') },
{ value: 'dominant', icon: _COLOR_MODE_SVG.dominant, label: t('kc.interpolation.dominant'), desc: t('kc.interpolation.dominant.desc') },
];
if (_kcColorModeIconSelect) { _kcColorModeIconSelect.updateItems(items); return; }
_kcColorModeIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
}
function _ensureSourceEntitySelect(sources: any) {
const sel = document.getElementById('kc-editor-source');
if (!sel) return;
if (_kcSourceEntitySelect) _kcSourceEntitySelect.destroy();
if (sources.length > 0) {
_kcSourceEntitySelect = new EntitySelect({
target: sel,
getItems: () => sources.map((s: any) => ({
value: s.id,
label: s.name,
icon: getPictureSourceIcon(s.stream_type),
desc: s.stream_type,
})),
placeholder: t('palette.search'),
} as any);
}
}
function _ensurePatternEntitySelect(patTemplates: any) {
const sel = document.getElementById('kc-editor-pattern-template');
if (!sel) return;
if (_kcPatternEntitySelect) _kcPatternEntitySelect.destroy();
if (patTemplates.length > 0) {
_kcPatternEntitySelect = new EntitySelect({
target: sel,
getItems: () => patTemplates.map((pt: any) => {
const rectCount = (pt.rectangles || []).length;
return {
value: pt.id,
label: pt.name,
icon: _icon(P.fileText),
desc: `${rectCount} rect${rectCount !== 1 ? 's' : ''}`,
};
}),
placeholder: t('palette.search'),
} as any);
}
}
function _ensureBrightnessEntitySelect() {
const sel = document.getElementById('kc-editor-brightness-vs');
if (!sel) return;
if (_kcBrightnessEntitySelect) _kcBrightnessEntitySelect.destroy();
if (_cachedValueSources.length > 0) {
_kcBrightnessEntitySelect = new EntitySelect({
target: sel,
getItems: () => {
const items = [{ value: '', label: t('kc.brightness_vs.none'), icon: _icon(P.sunDim), desc: '' }];
return items.concat(_cachedValueSources.map((vs: any) => ({
value: vs.id,
label: vs.name,
icon: getValueSourceIcon(vs.source_type),
desc: vs.source_type,
})));
},
placeholder: t('palette.search'),
} as any);
}
}
export function patchKCTargetMetrics(target: any) {
const card = document.querySelector(`[data-kc-target-id="${CSS.escape(target.id)}"]`);
if (!card) return;
const state = target.state || {};
const metrics = target.metrics || {};
const fpsActual = card.querySelector('[data-tm="fps-actual"]');
if (fpsActual) fpsActual.textContent = state.fps_actual?.toFixed(1) || '0.0';
const fpsCurrent = card.querySelector('[data-tm="fps-current"]');
if (fpsCurrent) fpsCurrent.textContent = state.fps_current ?? '-';
const fpsTarget = card.querySelector('[data-tm="fps-target"]');
if (fpsTarget) fpsTarget.textContent = state.fps_target || 0;
const frames = card.querySelector('[data-tm="frames"]') as HTMLElement;
if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); }
const keepalive = card.querySelector('[data-tm="keepalive"]') as HTMLElement;
if (keepalive) { keepalive.textContent = formatCompact(state.frames_keepalive ?? 0); keepalive.title = String(state.frames_keepalive ?? 0); }
const errors = card.querySelector('[data-tm="errors"]') as HTMLElement;
if (errors) { errors.textContent = formatCompact(metrics.errors_count || 0); errors.title = String(metrics.errors_count || 0); }
const uptime = card.querySelector('[data-tm="uptime"]');
if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds);
const timing = card.querySelector('[data-tm="timing"]');
if (timing && state.timing_total_ms != null) {
timing.innerHTML = `
<div class="timing-header">
<div class="metric-label">${t('device.metrics.timing')}</div>
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
</div>
<div class="timing-bar">
<span class="timing-seg timing-extract" style="flex:${state.timing_calc_colors_ms}" title="calc ${state.timing_calc_colors_ms}ms"></span>
<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>
<span class="timing-seg timing-send" style="flex:${state.timing_broadcast_ms}" title="broadcast ${state.timing_broadcast_ms}ms"></span>
</div>
<div class="timing-legend">
<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>calc ${state.timing_calc_colors_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>broadcast ${state.timing_broadcast_ms}ms</span>
</div>`;
}
}
export function createKCTargetCard(target: OutputTarget & { state?: any; metrics?: any; latestColors?: any }, sourceMap: Record<string, any>, patternTemplateMap: Record<string, any>, valueSourceMap: Record<string, any>) {
const state = target.state || {};
const kcSettings = target.key_colors_settings ?? {} as Partial<import('../types.ts').KeyColorsSettings>;
const isProcessing = state.processing || false;
const brightness = kcSettings.brightness ?? 1.0;
const brightnessInt = Math.round(brightness * 255);
const source = sourceMap[target.picture_source_id!];
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
const patTmpl = patternTemplateMap[kcSettings.pattern_template_id!];
const patternName = patTmpl ? patTmpl.name : 'No pattern';
const rectCount = patTmpl ? (patTmpl.rectangles || []).length : 0;
const bvsId = kcSettings.brightness_value_source_id || '';
const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;
// Render initial color swatches from pre-fetched REST data
let swatchesHtml = '';
const latestColors = target.latestColors && target.latestColors.colors;
if (isProcessing && latestColors && Object.keys(latestColors).length > 0) {
swatchesHtml = Object.entries(latestColors).map(([name, color]: [string, any]) => `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`).join('');
} else if (isProcessing) {
swatchesHtml = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
}
return wrapCard({
dataAttr: 'data-kc-target-id',
id: target.id,
removeOnclick: `deleteKCTarget('${target.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(target.name)}">
<span class="card-title-text">${escapeHtml(target.name)}</span>
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop${source ? ' stream-card-link' : ''}" title="${t('kc.source')}"${source ? ` onclick="event.stopPropagation(); navigateToCard('streams','${source.stream_type === 'static_image' ? 'static_image' : source.stream_type === 'processed' ? 'processed' : 'raw'}','${source.stream_type === 'static_image' ? 'static-streams' : source.stream_type === 'processed' ? 'proc-streams' : 'raw-streams'}','data-stream-id','${target.picture_source_id}')"` : ''}>${ICON_LINK_SOURCE} ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop${patTmpl ? ' stream-card-link' : ''}" title="${t('kc.pattern_template')}"${patTmpl ? ` onclick="event.stopPropagation(); navigateToCard('targets','kc-patterns','kc-patterns','data-pattern-template-id','${kcSettings.pattern_template_id}')"` : ''}>${ICON_PATTERN_TEMPLATE} ${escapeHtml(patternName)}</span>
<span class="stream-card-prop"> ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop" title="${t('kc.fps')}">${ICON_FPS} ${kcSettings.fps ?? 10}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
</div>
${renderTagChips(target.tags)}
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
<input type="range" class="brightness-slider" min="0" max="255"
value="${brightnessInt}" data-kc-brightness="${target.id}"
oninput="updateKCBrightnessLabel('${target.id}', this.value)"
onchange="saveKCBrightness('${target.id}', this.value)"
title="${Math.round(brightness * 100)}%">
</div>
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
${swatchesHtml}
</div>
${isProcessing ? `
<div class="card-content">
<div class="metrics-grid">
<div class="metric">
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
<div class="metric-value" data-tm="fps-actual">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.current_fps')}</div>
<div class="metric-value" data-tm="fps-current">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value" data-tm="fps-target">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value" data-tm="frames">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value" data-tm="keepalive">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value" data-tm="errors">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.uptime')}</div>
<div class="metric-value" data-tm="uptime">---</div>
</div>
</div>
${state.timing_total_ms != null ? `
<div class="timing-breakdown" data-tm="timing"></div>
` : ''}
</div>
` : ''}`,
actions: `
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('targets.button.stop')}">
${ICON_STOP}
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('targets.button.start')}">
${ICON_START}
</button>
`}
<button class="btn btn-icon btn-secondary" onclick="testKCTarget('${target.id}')" title="${t('kc.test')}">
${ICON_TEST}
</button>
<button class="btn btn-icon btn-secondary" onclick="cloneKCTarget('${target.id}')" title="${t('common.clone')}">
${ICON_CLONE}
</button>
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
${ICON_EDIT}
</button>`,
});
}
// ===== KEY COLORS TEST =====
function _openKCTestWs(targetId: any, fps: any, previewWidth = 480) {
// Close any existing WS
if (kcTestWs) {
try { kcTestWs.close(); } catch (_) {}
setKcTestWs(null);
}
const key = localStorage.getItem('wled_api_key');
if (!key) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/output-targets/${targetId}/test/ws?token=${encodeURIComponent(key)}&fps=${fps}&preview_width=${previewWidth}`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'frame') {
// Hide spinner on first frame
const spinner = document.querySelector('.lightbox-spinner') as HTMLElement;
if (spinner) spinner.style.display = 'none';
displayKCTestResults(data);
}
} catch (e) {
console.error('KC test WS parse error:', e);
}
};
ws.onclose = (ev) => {
setKcTestWs(null);
// Only show error if closed unexpectedly (not a normal close)
if (ev.code !== 1000 && ev.code !== 1001 && kcTestTargetId) {
const reason = ev.reason || t('kc.test.ws_closed');
showToast(t('kc.test.error') + ': ' + reason, 'error');
// Close lightbox on fatal errors (auth, bad target, etc.)
if (ev.code === 4001 || ev.code === 4003 || ev.code === 4004) {
if (typeof window.closeLightbox === 'function') window.closeLightbox();
}
}
};
ws.onerror = () => {
// onclose will fire after onerror; no need to handle here
};
setKcTestWs(ws);
setKcTestTargetId(targetId);
}
export async function testKCTarget(targetId: any) {
setKcTestTargetId(targetId);
// Show lightbox immediately with a spinner
const lightbox = document.getElementById('image-lightbox')!;
const lbImg = document.getElementById('lightbox-image') as HTMLImageElement;
const statsEl = document.getElementById('lightbox-stats') as HTMLElement;
lbImg.style.display = 'none';
lbImg.src = '';
statsEl.style.display = 'none';
// Insert spinner if not already present
let spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement;
if (!spinner) {
spinner = document.createElement('div');
spinner.className = 'lightbox-spinner loading-spinner';
lightbox.querySelector('.lightbox-content')!.prepend(spinner);
}
spinner.style.display = '';
// Hide controls — KC test streams automatically
const refreshBtn = document.getElementById('lightbox-auto-refresh') as HTMLElement;
if (refreshBtn) refreshBtn.style.display = 'none';
const fpsSelect = document.getElementById('lightbox-fps-select') as HTMLElement;
if (fpsSelect) fpsSelect.style.display = 'none';
lightbox.classList.add('active');
lockBody();
// Use same FPS from CSS test settings and dynamic preview resolution
const fps = parseInt(localStorage.getItem('css_test_fps')!) || 15;
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
_openKCTestWs(targetId, fps, previewWidth);
}
export function stopKCTestAutoRefresh() {
if (kcTestAutoRefresh) {
clearInterval(kcTestAutoRefresh);
setKcTestAutoRefresh(null);
}
if (kcTestWs) {
try { kcTestWs.close(1000, 'lightbox closed'); } catch (_) {}
setKcTestWs(null);
}
setKcTestTargetId(null);
}
export function displayKCTestResults(result: any) {
const srcImg = new window.Image();
srcImg.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = srcImg.width;
canvas.height = srcImg.height;
const ctx = canvas.getContext('2d')!;
// Draw captured frame
ctx.drawImage(srcImg, 0, 0);
const w = srcImg.width;
const h = srcImg.height;
// Draw each rectangle with extracted color overlay
result.rectangles.forEach((rect: any, i: number) => {
const px = rect.x * w;
const py = rect.y * h;
const pw = rect.width * w;
const ph = rect.height * h;
const color = rect.color;
const borderColor = PATTERN_RECT_BORDERS[i % PATTERN_RECT_BORDERS.length];
// Semi-transparent fill with the extracted color
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`;
ctx.fillRect(px, py, pw, ph);
// Border using pattern colors for distinction
ctx.strokeStyle = borderColor;
ctx.lineWidth = 3;
ctx.strokeRect(px, py, pw, ph);
// Color swatch in top-left corner of rect
const swatchSize = Math.max(16, Math.min(32, pw * 0.15));
ctx.fillStyle = color.hex;
ctx.fillRect(px + 4, py + 4, swatchSize, swatchSize);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(px + 4, py + 4, swatchSize, swatchSize);
// Name label with shadow for readability
const fontSize = Math.max(12, Math.min(18, pw * 0.06));
ctx.font = `bold ${fontSize}px sans-serif`;
const labelX = px + swatchSize + 10;
const labelY = py + 4 + swatchSize / 2 + fontSize / 3;
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 4;
ctx.fillStyle = '#fff';
ctx.fillText(rect.name, labelX, labelY);
// Hex label below name
ctx.font = `${fontSize - 2}px monospace`;
ctx.fillText(color.hex, labelX, labelY + fontSize + 2);
ctx.shadowBlur = 0;
});
const dataUrl = canvas.toDataURL('image/jpeg', 0.92);
// Build stats HTML
let statsHtml = `<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">`;
statsHtml += `<span style="opacity:0.7;margin-right:8px;">${escapeHtml(result.pattern_template_name)} \u2022 ${escapeHtml(result.interpolation_mode)}</span>`;
result.rectangles.forEach((rect: any) => {
const c = rect.color;
statsHtml += `<div style="display:flex;align-items:center;gap:4px;">`;
statsHtml += `<div style="width:14px;height:14px;border-radius:3px;border:1px solid rgba(255,255,255,0.4);background:${c.hex};"></div>`;
statsHtml += `<span style="font-size:0.85em;">${escapeHtml(rect.name)} <code>${c.hex}</code></span>`;
statsHtml += `</div>`;
});
statsHtml += `</div>`;
// Hide spinner, show result in the already-open lightbox
const spinner = document.querySelector('.lightbox-spinner') as HTMLElement;
if (spinner) spinner.style.display = 'none';
const lbImg = document.getElementById('lightbox-image') as HTMLImageElement;
const statsEl = document.getElementById('lightbox-stats') as HTMLElement;
lbImg.src = dataUrl;
lbImg.style.display = '';
statsEl.innerHTML = statsHtml;
statsEl.style.display = '';
};
srcImg.src = result.image;
}
// ===== KEY COLORS EDITOR =====
function _autoGenerateKCName() {
if (_kcNameManuallyEdited) return;
if ((document.getElementById('kc-editor-id') as HTMLInputElement).value) return;
const sourceSelect = document.getElementById('kc-editor-source') as HTMLSelectElement;
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
if (!sourceName) return;
const mode = (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value || 'average';
const modeName = t(`kc.interpolation.${mode}`);
const patSelect = document.getElementById('kc-editor-pattern-template') as HTMLSelectElement;
const patName = patSelect.selectedOptions[0]?.dataset?.name || '';
(document.getElementById('kc-editor-name') as HTMLInputElement).value = `${sourceName} \u00b7 ${patName} (${modeName})`;
}
function _populateKCBrightnessVsDropdown(selectedId = '') {
const sel = document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement;
// Keep the first "None" option, remove the rest
while (sel.options.length > 1) sel.remove(1);
_cachedValueSources.forEach((vs: any) => {
const opt = document.createElement('option');
opt.value = vs.id;
opt.textContent = vs.name;
sel.appendChild(opt);
});
sel.value = selectedId || '';
_ensureBrightnessEntitySelect();
}
export async function showKCEditor(targetId: any = null, cloneData: any = null) {
try {
// Load sources, pattern templates, and value sources in parallel
const [sources, patTemplates, valueSources] = await Promise.all([
streamsCache.fetch().catch((): any[] => []),
patternTemplatesCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch(),
]);
// Populate source select
const sourceSelect = document.getElementById('kc-editor-source') as HTMLSelectElement;
sourceSelect.innerHTML = '';
sources.forEach((s: any) => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
opt.textContent = s.name;
sourceSelect.appendChild(opt);
});
// Populate pattern template select
const patSelect = document.getElementById('kc-editor-pattern-template') as HTMLSelectElement;
patSelect.innerHTML = '';
patTemplates.forEach((pt: any) => {
const opt = document.createElement('option');
opt.value = pt.id;
opt.dataset.name = pt.name;
const rectCount = (pt.rectangles || []).length;
opt.textContent = `${pt.name} (${rectCount} rect${rectCount !== 1 ? 's' : ''})`;
patSelect.appendChild(opt);
});
// Set up visual selectors
_ensureColorModeIconSelect();
_ensureSourceEntitySelect(sources);
_ensurePatternEntitySelect(patTemplates);
let _editorTags: any[] = [];
if (targetId) {
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
_editorTags = target.tags || [];
const kcSettings = target.key_colors_settings || {};
(document.getElementById('kc-editor-id') as HTMLInputElement).value = target.id;
(document.getElementById('kc-editor-name') as HTMLInputElement).value = target.name;
sourceSelect.value = target.picture_source_id || '';
(document.getElementById('kc-editor-fps') as HTMLInputElement).value = kcSettings.fps ?? 10;
(document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = kcSettings.fps ?? 10;
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = kcSettings.interpolation_mode ?? 'average';
if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue(kcSettings.interpolation_mode ?? 'average');
(document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = kcSettings.smoothing ?? 0.3;
(document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = kcSettings.smoothing ?? 0.3;
patSelect.value = kcSettings.pattern_template_id || '';
_populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || '');
(document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.edit')}`;
} else if (cloneData) {
_editorTags = cloneData.tags || [];
const kcSettings = cloneData.key_colors_settings || {};
(document.getElementById('kc-editor-id') as HTMLInputElement).value = '';
(document.getElementById('kc-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
sourceSelect.value = cloneData.picture_source_id || '';
(document.getElementById('kc-editor-fps') as HTMLInputElement).value = kcSettings.fps ?? 10;
(document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = kcSettings.fps ?? 10;
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = kcSettings.interpolation_mode ?? 'average';
if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue(kcSettings.interpolation_mode ?? 'average');
(document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = kcSettings.smoothing ?? 0.3;
(document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = kcSettings.smoothing ?? 0.3;
patSelect.value = kcSettings.pattern_template_id || '';
_populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || '');
(document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.add')}`;
} else {
(document.getElementById('kc-editor-id') as HTMLInputElement).value = '';
(document.getElementById('kc-editor-name') as HTMLInputElement).value = '';
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
(document.getElementById('kc-editor-fps') as HTMLInputElement).value = '10' as any;
(document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = '10';
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = 'average';
if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue('average');
(document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = '0.3' as any;
(document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = '0.3';
if (patTemplates.length > 0) patSelect.value = patTemplates[0].id;
_populateKCBrightnessVsDropdown('');
(document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.add')}`;
}
// Auto-name
set_kcNameManuallyEdited(!!(targetId || cloneData));
(document.getElementById('kc-editor-name') as HTMLInputElement).oninput = () => { set_kcNameManuallyEdited(true); };
sourceSelect.onchange = () => _autoGenerateKCName();
(document.getElementById('kc-editor-interpolation') as HTMLSelectElement).onchange = () => _autoGenerateKCName();
patSelect.onchange = () => _autoGenerateKCName();
if (!targetId && !cloneData) _autoGenerateKCName();
// Tags
if (_kcTagsInput) _kcTagsInput.destroy();
_kcTagsInput = new TagInput(document.getElementById('kc-tags-container'), {
placeholder: t('tags.placeholder'),
});
_kcTagsInput.setValue(_editorTags);
kcEditorModal.snapshot();
kcEditorModal.open();
(document.getElementById('kc-editor-error') as HTMLElement).style.display = 'none';
setTimeout(() => desktopFocus(document.getElementById('kc-editor-name')), 100);
} catch (error) {
console.error('Failed to open KC editor:', error);
showToast(t('kc_target.error.editor_open_failed'), 'error');
}
}
export function isKCEditorDirty() {
return kcEditorModal.isDirty();
}
export async function closeKCEditorModal() {
await kcEditorModal.close();
set_kcNameManuallyEdited(false);
}
export function forceCloseKCEditorModal() {
if (_kcTagsInput) { _kcTagsInput.destroy(); _kcTagsInput = null; }
kcEditorModal.forceClose();
set_kcNameManuallyEdited(false);
}
export async function saveKCEditor() {
const targetId = (document.getElementById('kc-editor-id') as HTMLInputElement).value;
const name = (document.getElementById('kc-editor-name') as HTMLInputElement).value.trim();
const sourceId = (document.getElementById('kc-editor-source') as HTMLSelectElement).value;
const fps = parseInt((document.getElementById('kc-editor-fps') as HTMLInputElement).value) || 10;
const interpolation = (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value;
const smoothing = parseFloat((document.getElementById('kc-editor-smoothing') as HTMLInputElement).value);
const patternTemplateId = (document.getElementById('kc-editor-pattern-template') as HTMLSelectElement).value;
const brightnessVsId = (document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement).value;
if (!name) {
kcEditorModal.showError(t('kc.error.required'));
return;
}
if (!patternTemplateId) {
kcEditorModal.showError(t('kc.error.no_pattern'));
return;
}
const payload: any = {
name,
picture_source_id: sourceId,
tags: _kcTagsInput ? _kcTagsInput.getValue() : [],
key_colors_settings: {
fps,
interpolation_mode: interpolation,
smoothing,
pattern_template_id: patternTemplateId,
brightness_value_source_id: brightnessVsId,
},
};
try {
let response;
if (targetId) {
response = await fetchWithAuth(`/output-targets/${targetId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'key_colors';
response = await fetchWithAuth('/output-targets', {
method: 'POST',
body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(targetId ? t('kc.updated') : t('kc.created'), 'success');
outputTargetsCache.invalidate();
kcEditorModal.forceClose();
// Use window.* to avoid circular import with targets.js
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} catch (error: any) {
if (error.isAuth) return;
console.error('Error saving KC target:', error);
kcEditorModal.showError(error.message);
}
}
export async function cloneKCTarget(targetId: any) {
try {
const targets = await outputTargetsCache.fetch();
const target = targets.find((t: any) => t.id === targetId);
if (!target) throw new Error('Target not found');
showKCEditor(null, target);
} catch (error: any) {
if (error.isAuth) return;
showToast(t('kc_target.error.clone_failed'), 'error');
}
}
export async function deleteKCTarget(targetId: any) {
const confirmed = await showConfirm(t('kc.delete.confirm'));
if (!confirmed) return;
try {
disconnectKCWebSocket(targetId);
const response = await fetchWithAuth(`/output-targets/${targetId}`, {
method: 'DELETE',
});
if (response.ok) {
showToast(t('kc.deleted'), 'success');
outputTargetsCache.invalidate();
// Use window.* to avoid circular import with targets.js
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else {
const error = await response.json();
showToast(error.detail || t('kc_target.error.delete_failed'), 'error');
}
} catch (error: any) {
if (error.isAuth) return;
showToast(t('kc_target.error.delete_failed'), 'error');
}
}
// ===== KC BRIGHTNESS =====
export function updateKCBrightnessLabel(targetId: any, value: any) {
const slider = document.querySelector(`[data-kc-brightness="${CSS.escape(targetId)}"]`) as HTMLElement;
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
}
export async function saveKCBrightness(targetId: any, value: any) {
const brightness = parseInt(value) / 255;
try {
await fetch(`${API_BASE}/output-targets/${targetId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ key_colors_settings: { brightness } }),
});
} catch (err) {
console.error('Failed to save KC brightness:', err);
showToast(t('kc.error.brightness') || 'Failed to save brightness', 'error');
}
}
// ===== KEY COLORS WEBSOCKET =====
export function connectKCWebSocket(targetId: any) {
// Disconnect existing connection if any
disconnectKCWebSocket(targetId);
const key = localStorage.getItem('wled_api_key');
if (!key) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/output-targets/${targetId}/ws?token=${encodeURIComponent(key)}`;
try {
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
updateKCColorSwatches(targetId, data.colors || {});
} catch (e) {
console.error('Failed to parse KC WebSocket message:', e);
}
};
ws.onclose = () => {
delete kcWebSockets[targetId];
};
ws.onerror = (error) => {
console.error(`KC WebSocket error for ${targetId}:`, error);
};
kcWebSockets[targetId] = ws;
} catch (error) {
console.error(`Failed to connect KC WebSocket for ${targetId}:`, error);
}
}
export function disconnectKCWebSocket(targetId: any) {
const ws = kcWebSockets[targetId];
if (ws) {
ws.close();
delete kcWebSockets[targetId];
}
}
export function disconnectAllKCWebSockets() {
Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId));
}
export function updateKCColorSwatches(targetId: any, colors: any) {
const container = document.getElementById(`kc-swatches-${targetId}`);
if (!container) return;
const entries = Object.entries(colors);
if (entries.length === 0) {
container.innerHTML = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
return;
}
container.innerHTML = entries.map(([name, color]: [string, any]) => {
const hex = color.hex || `#${(color.r || 0).toString(16).padStart(2, '0')}${(color.g || 0).toString(16).padStart(2, '0')}${(color.b || 0).toString(16).padStart(2, '0')}`;
return `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${hex}" title="${hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`;
}).join('');
}
@@ -18,7 +18,7 @@ import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing,
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts'; import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts';
import { _splitOpenrgbZone } from './device-discovery.ts'; import { _splitOpenrgbZone } from './device-discovery.ts';
import { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics } from './ha-light-targets.ts'; import { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics, connectHALightWS, disconnectHALightWS } from './ha-light-targets.ts';
import { import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon, getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
@@ -616,21 +616,12 @@ export async function loadTargetsTab() {
const devicesWithState = devices.map(d => ({ ...d, state: allDeviceStates[d.id] || {} })); const devicesWithState = devices.map(d => ({ ...d, state: allDeviceStates[d.id] || {} }));
// Enrich targets with state/metrics; fetch colors only for running KC targets // Enrich targets with state/metrics
const targetsWithState = await Promise.all( const targetsWithState = targets.map((target) => {
targets.map(async (target) => { const state = allTargetStates[target.id] || {};
const state = allTargetStates[target.id] || {}; const metrics = allTargetMetrics[target.id] || {};
const metrics = allTargetMetrics[target.id] || {}; return { ...target, state, metrics };
let latestColors = null; });
if (target.target_type === 'key_colors' && state.processing) {
try {
const colorsResp = await fetch(`${API_BASE}/output-targets/${target.id}/colors`, { headers: getHeaders() });
if (colorsResp.ok) latestColors = await colorsResp.json();
} catch {}
}
return { ...target, state, metrics, latestColors };
})
);
// Build device map for target name resolution // Build device map for target name resolution
const deviceMap = {}; const deviceMap = {};
@@ -769,6 +760,15 @@ export async function loadTargetsTab() {
if (!processingLedIds.has(id)) disconnectLedPreviewWS(id); if (!processingLedIds.has(id)) disconnectLedPreviewWS(id);
}); });
// Manage HA light color preview WebSockets
haLightTargets.forEach(target => {
if (target.state && target.state.processing) {
connectHALightWS(target.id);
} else {
disconnectHALightWS(target.id);
}
});
// FPS charts: only destroy charts for replaced/removed cards (or all on first render) // FPS charts: only destroy charts for replaced/removed cards (or all on first render)
if (changedTargetIds) { if (changedTargetIds) {
// Incremental: destroy only charts whose cards were replaced or removed // Incremental: destroy only charts whose cards were replaced or removed
+1 -15
View File
@@ -157,19 +157,6 @@ interface Window {
closeTestAudioTemplateModal: (...args: any[]) => any; closeTestAudioTemplateModal: (...args: any[]) => any;
startAudioTemplateTest: (...args: any[]) => any; startAudioTemplateTest: (...args: any[]) => any;
// ─── KC Targets ───
createKCTargetCard: (...args: any[]) => any;
testKCTarget: (...args: any[]) => any;
showKCEditor: (...args: any[]) => any;
closeKCEditorModal: (...args: any[]) => any;
forceCloseKCEditorModal: (...args: any[]) => any;
saveKCEditor: (...args: any[]) => any;
deleteKCTarget: (...args: any[]) => any;
disconnectAllKCWebSockets: (...args: any[]) => any;
updateKCBrightnessLabel: (...args: any[]) => any;
saveKCBrightness: (...args: any[]) => any;
cloneKCTarget: (...args: any[]) => any;
// ─── Pattern Templates ─── // ─── Pattern Templates ───
createPatternTemplateCard: (...args: any[]) => any; createPatternTemplateCard: (...args: any[]) => any;
showPatternTemplateEditor: (...args: any[]) => any; showPatternTemplateEditor: (...args: any[]) => any;
@@ -226,8 +213,7 @@ interface Window {
startTargetProcessing: (...args: any[]) => any; startTargetProcessing: (...args: any[]) => any;
stopTargetProcessing: (...args: any[]) => any; stopTargetProcessing: (...args: any[]) => any;
stopAllLedTargets: (...args: any[]) => any; stopAllLedTargets: (...args: any[]) => any;
stopAllKCTargets: (...args: any[]) => any; startTargetOverlay: (...args: any[]) => any;
startTargetOverlay: (...args: any[]) => any;
stopTargetOverlay: (...args: any[]) => any; stopTargetOverlay: (...args: any[]) => any;
deleteTarget: (...args: any[]) => any; deleteTarget: (...args: any[]) => any;
cloneTarget: (...args: any[]) => any; cloneTarget: (...args: any[]) => any;
+14 -13
View File
@@ -45,16 +45,7 @@ export interface Device {
// ── Output Target ───────────────────────────────────────────── // ── Output Target ─────────────────────────────────────────────
export type TargetType = 'led' | 'key_colors'; export type TargetType = 'led' | 'ha_light';
export interface KeyColorsSettings {
fps: number;
interpolation_mode: string;
smoothing: number;
pattern_template_id: string;
brightness: number;
brightness_value_source_id: string;
}
export interface OutputTarget { export interface OutputTarget {
id: string; id: string;
@@ -76,9 +67,19 @@ export interface OutputTarget {
adaptive_fps?: boolean; adaptive_fps?: boolean;
protocol?: string; protocol?: string;
// Key Colors target fields // HA light target fields
picture_source_id?: string; ha_source_id?: string;
key_colors_settings?: KeyColorsSettings; ha_light_mappings?: HALightMapping[];
update_rate?: number;
ha_transition?: number;
color_tolerance?: number;
}
export interface HALightMapping {
entity_id: string;
led_start: number;
led_end: number;
brightness_scale: number;
} }
// ── Color Strip Source ──────────────────────────────────────── // ── Color Strip Source ────────────────────────────────────────
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,137 +0,0 @@
"""Key colors output target — extracts key colors from image rectangles."""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.utils import resolve_ref
@dataclass
class KeyColorRectangle:
"""A named rectangle in relative coordinates (0.0 to 1.0)."""
name: str
x: float
y: float
width: float
height: float
def to_dict(self) -> dict:
return {
"name": self.name,
"x": self.x,
"y": self.y,
"width": self.width,
"height": self.height,
}
@classmethod
def from_dict(cls, data: dict) -> "KeyColorRectangle":
return cls(
name=data["name"],
x=float(data.get("x", 0.0)),
y=float(data.get("y", 0.0)),
width=float(data.get("width", 1.0)),
height=float(data.get("height", 1.0)),
)
@dataclass
class KeyColorsSettings:
"""Settings for key colors extraction."""
fps: int = 10
interpolation_mode: str = "average"
smoothing: float = 0.3
pattern_template_id: str = ""
brightness: float = 1.0
brightness_value_source_id: str = ""
def to_dict(self) -> dict:
return {
"fps": self.fps,
"interpolation_mode": self.interpolation_mode,
"smoothing": self.smoothing,
"pattern_template_id": self.pattern_template_id,
"brightness": self.brightness,
"brightness_value_source_id": self.brightness_value_source_id,
}
@classmethod
def from_dict(cls, data: dict) -> "KeyColorsSettings":
return cls(
fps=data.get("fps", 10),
interpolation_mode=data.get("interpolation_mode", "average"),
smoothing=data.get("smoothing", 0.3),
pattern_template_id=data.get("pattern_template_id", ""),
brightness=data.get("brightness", 1.0),
brightness_value_source_id=data.get("brightness_value_source_id", ""),
)
@dataclass
class KeyColorsOutputTarget(OutputTarget):
"""Key colors extractor target — extracts key colors from image rectangles."""
picture_source_id: str = ""
settings: KeyColorsSettings = field(default_factory=KeyColorsSettings)
def register_with_manager(self, manager) -> None:
"""Register this KC target with the processor manager."""
manager.add_kc_target(
target_id=self.id,
picture_source_id=self.picture_source_id,
settings=self.settings,
)
def sync_with_manager(self, manager, *, settings_changed: bool,
source_changed: bool = False,
css_changed: bool = False,
device_changed: bool = False,
brightness_vs_changed: bool = False) -> None:
"""Push changed fields to the processor manager."""
if settings_changed:
manager.update_target_settings(self.id, self.settings)
if source_changed:
manager.update_target_source(self.id, self.picture_source_id)
if brightness_vs_changed:
manager.update_target_brightness_vs(self.id, self.settings.brightness_value_source_id)
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
settings=None, key_colors_settings=None, description=None,
tags=None,
**_kwargs) -> None:
"""Apply mutable field updates for KC targets."""
super().update_fields(name=name, description=description, tags=tags)
if picture_source_id is not None:
self.picture_source_id = resolve_ref(picture_source_id, self.picture_source_id)
if key_colors_settings is not None:
self.settings = key_colors_settings
@property
def has_picture_source(self) -> bool:
return True
def to_dict(self) -> dict:
d = super().to_dict()
d["picture_source_id"] = self.picture_source_id
d["settings"] = self.settings.to_dict()
return d
@classmethod
def from_dict(cls, data: dict) -> "KeyColorsOutputTarget":
settings_data = data.get("settings", {})
settings = KeyColorsSettings.from_dict(settings_data)
return cls(
id=data["id"],
name=data["name"],
target_type="key_colors",
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
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())),
)
@@ -11,7 +11,7 @@ class OutputTarget:
id: str id: str
name: str name: str
target_type: str # "wled", "key_colors", ... target_type: str # "led", "ha_light"
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
@@ -32,9 +32,6 @@ class OutputTarget:
*, *,
name=None, name=None,
device_id=None, device_id=None,
picture_source_id=None,
settings=None,
key_colors_settings=None,
description=None, description=None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
**_kwargs, **_kwargs,
@@ -47,11 +44,6 @@ class OutputTarget:
if tags is not None: if tags is not None:
self.tags = tags self.tags = tags
@property
def has_picture_source(self) -> bool:
"""Whether this target type uses a picture source."""
return False
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert to dictionary.""" """Convert to dictionary."""
return { return {
@@ -4,7 +4,35 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from wled_controller.storage.key_colors_output_target import KeyColorRectangle
@dataclass
class KeyColorRectangle:
"""A named rectangle in relative coordinates (0.0 to 1.0)."""
name: str
x: float
y: float
width: float
height: float
def to_dict(self) -> dict:
return {
"name": self.name,
"x": self.x,
"y": self.y,
"width": self.width,
"height": self.height,
}
@classmethod
def from_dict(cls, data: dict) -> "KeyColorRectangle":
return cls(
name=data["name"],
x=float(data.get("x", 0.0)),
y=float(data.get("y", 0.0)),
width=float(data.get("width", 1.0)),
height=float(data.get("height", 1.0)),
)
@dataclass @dataclass
@@ -39,12 +67,16 @@ class PatternTemplate:
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
rectangles=rectangles, rectangles=rectangles,
created_at=datetime.fromisoformat(data["created_at"]) created_at=(
if isinstance(data.get("created_at"), str) datetime.fromisoformat(data["created_at"])
else data.get("created_at", datetime.now(timezone.utc)), if isinstance(data.get("created_at"), str)
updated_at=datetime.fromisoformat(data["updated_at"]) else data.get("created_at", datetime.now(timezone.utc))
if isinstance(data.get("updated_at"), str) ),
else data.get("updated_at", datetime.now(timezone.utc)), updated_at=(
datetime.fromisoformat(data["updated_at"])
if isinstance(data.get("updated_at"), str)
else data.get("updated_at", datetime.now(timezone.utc))
),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
) )
@@ -6,8 +6,7 @@ from typing import List, Optional
from wled_controller.storage.base_sqlite_store import BaseSqliteStore from wled_controller.storage.base_sqlite_store import BaseSqliteStore
from wled_controller.storage.database import Database from wled_controller.storage.database import Database
from wled_controller.storage.key_colors_output_target import KeyColorRectangle from wled_controller.storage.pattern_template import KeyColorRectangle, PatternTemplate
from wled_controller.storage.pattern_template import PatternTemplate
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -113,10 +112,5 @@ class PatternTemplateStore(BaseSqliteStore[PatternTemplate]):
return template return template
def get_targets_referencing(self, template_id: str, output_target_store) -> List[str]: def get_targets_referencing(self, template_id: str, output_target_store) -> List[str]:
"""Return names of KC targets that reference this template.""" """Return names of targets that reference this template (legacy, always empty)."""
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget return []
return [
target.name for target in output_target_store.get_all_targets()
if isinstance(target, KeyColorsOutputTarget) and target.settings.pattern_template_id == template_id
]
@@ -1,92 +0,0 @@
<!-- Key Colors Editor Modal -->
<div id="kc-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="kc-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="kc-editor-title"><svg class="icon" viewBox="0 0 24 24"><path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></svg> <span data-i18n="kc.add">Add Key Colors Target</span></h2>
<button class="modal-close-btn" onclick="closeKCEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="kc-editor-form">
<input type="hidden" id="kc-editor-id">
<div class="form-group">
<label for="kc-editor-name" data-i18n="kc.name">Target Name:</label>
<input type="text" id="kc-editor-name" data-i18n-placeholder="kc.name.placeholder" placeholder="My Key Colors Target" required>
<div id="kc-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-source" data-i18n="kc.source">Picture Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.source.hint">Which picture source to extract colors from</small>
<select id="kc-editor-source"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-pattern-template" data-i18n="kc.pattern_template">Pattern Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.pattern_template.hint">Select the rectangle pattern to use for color extraction</small>
<select id="kc-editor-pattern-template"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-brightness-vs" data-i18n="kc.brightness_vs">Brightness Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.brightness_vs.hint">Optional value source that dynamically controls brightness each frame (multiplied with the manual brightness slider)</small>
<select id="kc-editor-brightness-vs">
<option value="" data-i18n="kc.brightness_vs.none">None (manual brightness only)</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-fps" data-i18n="kc.fps">Extraction FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.fps.hint">How many times per second to extract colors (1-60)</small>
<div class="slider-row">
<input type="range" id="kc-editor-fps" min="1" max="60" value="10" oninput="document.getElementById('kc-editor-fps-value').textContent = this.value">
<span id="kc-editor-fps-value" class="slider-value">10</span>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-interpolation" data-i18n="kc.interpolation">Color Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.interpolation.hint">How to compute the key color from pixels in each rectangle</small>
<select id="kc-editor-interpolation">
<option value="average">Average</option>
<option value="median">Median</option>
<option value="dominant">Dominant</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-smoothing">
<span data-i18n="kc.smoothing">Smoothing:</span>
<span id="kc-editor-smoothing-value">0.3</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.smoothing.hint">Temporal blending between extractions (0=none, 1=full)</small>
<input type="range" id="kc-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('kc-editor-smoothing-value').textContent = this.value">
</div>
<div id="kc-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeKCEditorModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveKCEditor()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
"""Tests for OutputTargetStore — CRUD for LED and key_colors targets.""" """Tests for OutputTargetStore — CRUD for LED and HA light targets."""
import pytest import pytest
@@ -34,18 +34,6 @@ class TestOutputTargetModel:
assert isinstance(target, WledOutputTarget) assert isinstance(target, WledOutputTarget)
assert target.device_id == "dev_1" assert target.device_id == "dev_1"
def test_key_colors_type_rejected(self):
"""key_colors target type removed — from_dict raises ValueError."""
data = {
"id": "pt_2",
"name": "KC Target",
"target_type": "key_colors",
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
with pytest.raises(ValueError, match="Unknown target type"):
OutputTarget.from_dict(data)
def test_unknown_type_raises(self): def test_unknown_type_raises(self):
data = { data = {
"id": "pt_3", "id": "pt_3",
@@ -78,11 +66,6 @@ class TestOutputTargetStoreCRUD:
assert t.name == "LED 1" assert t.name == "LED 1"
assert store.count() == 1 assert store.count() == 1
def test_create_key_colors_target_rejected(self, store):
"""key_colors target type is no longer supported (migrated to CSS source)."""
with pytest.raises(ValueError, match="Invalid target type"):
store.create_target(name="KC 1", target_type="key_colors")
def test_create_invalid_type(self, store): def test_create_invalid_type(self, store):
with pytest.raises(ValueError, match="Invalid target type"): with pytest.raises(ValueError, match="Invalid target type"):
store.create_target(name="Bad", target_type="invalid") store.create_target(name="Bad", target_type="invalid")