feat: HA light target live color preview — per-entity swatches via WebSocket
Lint & Test / test (push) Successful in 1m24s
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:
@@ -3,7 +3,6 @@
|
||||
Extracted from output_targets.py to keep files under 800 lines.
|
||||
"""
|
||||
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
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.capture.screen_capture import get_available_displays
|
||||
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.wled_output_target import WledOutputTarget
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
@@ -35,7 +37,10 @@ router = APIRouter()
|
||||
|
||||
# ===== 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(
|
||||
body: BulkTargetRequest,
|
||||
_auth: AuthRequired,
|
||||
@@ -67,7 +72,9 @@ async def bulk_start_processing(
|
||||
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(
|
||||
body: BulkTargetRequest,
|
||||
_auth: AuthRequired,
|
||||
@@ -93,6 +100,7 @@ async def bulk_stop_processing(
|
||||
|
||||
# ===== PROCESSING CONTROL ENDPOINTS =====
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
|
||||
async def start_processing(
|
||||
target_id: str,
|
||||
@@ -146,7 +154,12 @@ async def stop_processing(
|
||||
|
||||
# ===== 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(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
@@ -164,7 +177,11 @@ async def get_target_state(
|
||||
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(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
@@ -192,6 +209,7 @@ async def events_ws(
|
||||
):
|
||||
"""WebSocket for real-time state change events. 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
|
||||
@@ -215,6 +233,7 @@ async def events_ws(
|
||||
|
||||
# ===== OVERLAY VISUALIZATION =====
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/overlay/start", tags=["Visualization"])
|
||||
async def start_target_overlay(
|
||||
target_id: str,
|
||||
@@ -247,10 +266,16 @@ async def start_target_overlay(
|
||||
if first_css_id:
|
||||
try:
|
||||
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
|
||||
# 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 ""
|
||||
display_index = _resolve_display_index(ps_id, picture_source_store)
|
||||
displays = get_available_displays()
|
||||
@@ -258,9 +283,13 @@ async def start_target_overlay(
|
||||
display_index = min(display_index, len(displays) - 1)
|
||||
display_info = displays[display_index]
|
||||
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}
|
||||
|
||||
except ValueError as e:
|
||||
@@ -305,8 +334,55 @@ async def get_overlay_status(
|
||||
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 =====
|
||||
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/led-preview/ws")
|
||||
async def led_preview_ws(
|
||||
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>."""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
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,
|
||||
)
|
||||
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.output_target_store import OutputTargetStore
|
||||
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(
|
||||
_auth: AuthRequired,
|
||||
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")
|
||||
|
||||
|
||||
@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(
|
||||
data: PatternTemplateCreate,
|
||||
_auth: AuthRequired,
|
||||
@@ -87,7 +96,11 @@ async def create_pattern_template(
|
||||
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(
|
||||
template_id: str,
|
||||
_auth: AuthRequired,
|
||||
@@ -101,7 +114,11 @@ async def get_pattern_template(
|
||||
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(
|
||||
template_id: str,
|
||||
data: PatternTemplateUpdate,
|
||||
@@ -135,7 +152,9 @@ async def update_pattern_template(
|
||||
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(
|
||||
template_id: str,
|
||||
_auth: AuthRequired,
|
||||
@@ -150,7 +169,7 @@ async def delete_pattern_template(
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""Maps an LED range to one HA light entity."""
|
||||
|
||||
@@ -69,7 +33,7 @@ class OutputTargetCreate(BaseModel):
|
||||
"""Request to create an output target."""
|
||||
|
||||
name: str = Field(description="Target name", min_length=1, max_length=100)
|
||||
target_type: str = Field(default="led", description="Target type (led, key_colors, ha_light)")
|
||||
target_type: str = Field(default="led", description="Target type (led, ha_light)")
|
||||
# LED target fields
|
||||
device_id: str = Field(default="", description="LED device ID")
|
||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||
@@ -101,13 +65,6 @@ class OutputTargetCreate(BaseModel):
|
||||
pattern="^(ddp|http)$",
|
||||
description="Send protocol: ddp (UDP) or http (JSON API)",
|
||||
)
|
||||
# KC target fields
|
||||
picture_source_id: str = Field(
|
||||
default="", description="Picture source ID (for key_colors targets)"
|
||||
)
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
|
||||
None, description="Key colors settings (for key_colors targets)"
|
||||
)
|
||||
# HA light target fields
|
||||
ha_source_id: str = Field(
|
||||
default="", description="Home Assistant source ID (for ha_light targets)"
|
||||
@@ -157,13 +114,6 @@ class OutputTargetUpdate(BaseModel):
|
||||
protocol: Optional[str] = Field(
|
||||
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
|
||||
)
|
||||
# KC target fields
|
||||
picture_source_id: Optional[str] = Field(
|
||||
None, description="Picture source ID (for key_colors targets)"
|
||||
)
|
||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
|
||||
None, description="Key colors settings (for key_colors targets)"
|
||||
)
|
||||
# HA light target fields
|
||||
ha_source_id: Optional[str] = Field(
|
||||
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"
|
||||
)
|
||||
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_source_id: str = Field(default="", description="Home Assistant source ID (ha_light)")
|
||||
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||
@@ -263,12 +208,6 @@ class TargetProcessingState(BaseModel):
|
||||
timing_audio_render_ms: Optional[float] = Field(
|
||||
None, description="Audio visualization render time (ms)"
|
||||
)
|
||||
timing_calc_colors_ms: Optional[float] = Field(
|
||||
None, description="Color calculation time (ms, KC targets)"
|
||||
)
|
||||
timing_broadcast_ms: Optional[float] = Field(
|
||||
None, description="WebSocket broadcast time (ms, KC targets)"
|
||||
)
|
||||
display_index: Optional[int] = Field(None, description="Current display index")
|
||||
overlay_active: bool = Field(
|
||||
default=False, description="Whether visualization overlay is active"
|
||||
@@ -328,25 +267,3 @@ class BulkTargetResponse(BaseModel):
|
||||
errors: Dict[str, str] = Field(
|
||||
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 json
|
||||
import time
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -50,6 +51,8 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
self._value_stream = None # brightness value source stream
|
||||
self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
|
||||
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
|
||||
|
||||
@property
|
||||
@@ -130,6 +133,8 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
|
||||
self._previous_colors.clear()
|
||||
self._previous_on.clear()
|
||||
self._latest_entity_colors.clear()
|
||||
self._ws_clients.clear()
|
||||
logger.info(f"HA light target stopped: {self._target_id}")
|
||||
|
||||
def update_settings(self, settings) -> None:
|
||||
@@ -162,8 +167,24 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
except Exception as 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:
|
||||
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 {
|
||||
"target_id": self._target_id,
|
||||
"processing": self._is_running,
|
||||
@@ -176,6 +197,7 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
"fps_actual": self._update_rate if self._is_running else None,
|
||||
"fps_target": self._update_rate,
|
||||
"uptime_seconds": uptime,
|
||||
"entity_colors": entity_colors,
|
||||
}
|
||||
|
||||
def get_metrics(self) -> dict:
|
||||
@@ -244,6 +266,9 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
avg = segment.mean(axis=0).astype(int)
|
||||
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
|
||||
brightness = max(r, g, b)
|
||||
|
||||
@@ -298,3 +323,23 @@ class HALightTargetProcessor(TargetProcessor):
|
||||
)
|
||||
self._previous_on[entity_id] = False
|
||||
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]
|
||||
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 =====
|
||||
|
||||
def update_target_settings(self, target_id: str, settings):
|
||||
@@ -774,10 +770,15 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
|
||||
# ===== 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.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:
|
||||
proc = self._get_processor(target_id)
|
||||
proc.add_led_preview_client(ws)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Abstract base class for target processors.
|
||||
|
||||
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.
|
||||
|
||||
ProcessorManager creates and owns TargetProcessor instances, delegating
|
||||
|
||||
@@ -233,7 +233,6 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
logger.info(f"Registered {len(devices)} devices for health monitoring")
|
||||
|
||||
# Migrate KC targets → key_colors CSS sources
|
||||
# Register output targets in processor manager
|
||||
targets = output_target_store.get_all_targets()
|
||||
registered_targets = 0
|
||||
|
||||
@@ -1553,6 +1553,35 @@
|
||||
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 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
closeTutorial, tutorialNext, tutorialPrev,
|
||||
} from './features/tutorials.ts';
|
||||
|
||||
// Layer 4: devices, dashboard, streams, kc-targets, pattern-templates, automations
|
||||
// Layer 4: devices, dashboard, streams, pattern-templates, automations
|
||||
import {
|
||||
showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal,
|
||||
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fetchWithAuth, escapeHtml } from './api.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { navigateToCard } from './navigation.ts';
|
||||
import {
|
||||
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
|
||||
getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
|
||||
ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK,
|
||||
} from './icons.ts';
|
||||
@@ -46,17 +46,10 @@ function _buildItems(results: any[], states: any = {}) {
|
||||
|
||||
_mapEntities(targets, tgt => {
|
||||
const running = !!states[tgt.id]?.processing;
|
||||
if (tgt.target_type === 'key_colors') {
|
||||
items.push({
|
||||
name: tgt.name, detail: 'key_colors', group: 'kc_targets', icon: getTargetTypeIcon('key_colors'),
|
||||
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,
|
||||
});
|
||||
}
|
||||
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
|
||||
const actionItem: any = {
|
||||
name: tgt.name, group: 'actions',
|
||||
@@ -219,7 +212,7 @@ async function _fetchAllEntities() {
|
||||
|
||||
const _groupOrder = [
|
||||
'actions',
|
||||
'devices', 'targets', 'kc_targets', 'css', 'cspt', 'automations',
|
||||
'devices', 'targets', 'css', 'cspt', 'automations',
|
||||
'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
|
||||
'audio', 'value', 'scenes', 'sync_clocks',
|
||||
];
|
||||
|
||||
@@ -105,7 +105,7 @@ const SUBTYPE_ICONS = {
|
||||
adaptive_time: P.clock, adaptive_scene: P.cloudSun, daylight: P.sun,
|
||||
},
|
||||
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 {
|
||||
@@ -413,7 +413,7 @@ function _createOverlay(node: GraphNode, nodeWidth: number, callbacks: NodeCallb
|
||||
}
|
||||
|
||||
// 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: '' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
// ── 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 _colorStripTypeIcons = {
|
||||
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 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 _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 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
|
||||
export const ledPreviewWebSockets: Record<string, WebSocket> = {};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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 { t } from './i18n.ts';
|
||||
|
||||
@@ -101,8 +101,6 @@ export function openLightbox(imageSrc: string, statsHtml?: string) {
|
||||
|
||||
export function closeLightbox(event?: Event) {
|
||||
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')!;
|
||||
lightbox.classList.remove('active');
|
||||
const img = document.getElementById('lightbox-image') as HTMLImageElement;
|
||||
@@ -115,18 +113,6 @@ export function closeLightbox(event?: Event) {
|
||||
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') {
|
||||
const toast = document.getElementById('toast')!;
|
||||
toast.textContent = message;
|
||||
|
||||
@@ -1528,7 +1528,7 @@ function _onTestNode(node: any) {
|
||||
value_source: () => _w.testValueSource?.(node.id),
|
||||
color_strip_source: () => _w.testColorStrip?.(node.id),
|
||||
cspt: () => _w.testCSPT?.(node.id),
|
||||
output_target: () => _w.testKCTarget?.(node.id),
|
||||
output_target: undefined,
|
||||
};
|
||||
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>
|
||||
</div>
|
||||
<div class="ha-light-swatches" data-ha-swatches="${target.id}">
|
||||
${_renderEntitySwatches(state.entity_colors || {}, target.ha_light_mappings || [])}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>`,
|
||||
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 ──
|
||||
|
||||
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 { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.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 {
|
||||
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
|
||||
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] || {} }));
|
||||
|
||||
// Enrich targets with state/metrics; fetch colors only for running KC targets
|
||||
const targetsWithState = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
const state = allTargetStates[target.id] || {};
|
||||
const metrics = allTargetMetrics[target.id] || {};
|
||||
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 };
|
||||
})
|
||||
);
|
||||
// Enrich targets with state/metrics
|
||||
const targetsWithState = targets.map((target) => {
|
||||
const state = allTargetStates[target.id] || {};
|
||||
const metrics = allTargetMetrics[target.id] || {};
|
||||
return { ...target, state, metrics };
|
||||
});
|
||||
|
||||
// Build device map for target name resolution
|
||||
const deviceMap = {};
|
||||
@@ -769,6 +760,15 @@ export async function loadTargetsTab() {
|
||||
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)
|
||||
if (changedTargetIds) {
|
||||
// Incremental: destroy only charts whose cards were replaced or removed
|
||||
|
||||
+1
-15
@@ -157,19 +157,6 @@ interface Window {
|
||||
closeTestAudioTemplateModal: (...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 ───
|
||||
createPatternTemplateCard: (...args: any[]) => any;
|
||||
showPatternTemplateEditor: (...args: any[]) => any;
|
||||
@@ -226,8 +213,7 @@ interface Window {
|
||||
startTargetProcessing: (...args: any[]) => any;
|
||||
stopTargetProcessing: (...args: any[]) => any;
|
||||
stopAllLedTargets: (...args: any[]) => any;
|
||||
stopAllKCTargets: (...args: any[]) => any;
|
||||
startTargetOverlay: (...args: any[]) => any;
|
||||
startTargetOverlay: (...args: any[]) => any;
|
||||
stopTargetOverlay: (...args: any[]) => any;
|
||||
deleteTarget: (...args: any[]) => any;
|
||||
cloneTarget: (...args: any[]) => any;
|
||||
|
||||
@@ -45,16 +45,7 @@ export interface Device {
|
||||
|
||||
// ── Output Target ─────────────────────────────────────────────
|
||||
|
||||
export type TargetType = 'led' | 'key_colors';
|
||||
|
||||
export interface KeyColorsSettings {
|
||||
fps: number;
|
||||
interpolation_mode: string;
|
||||
smoothing: number;
|
||||
pattern_template_id: string;
|
||||
brightness: number;
|
||||
brightness_value_source_id: string;
|
||||
}
|
||||
export type TargetType = 'led' | 'ha_light';
|
||||
|
||||
export interface OutputTarget {
|
||||
id: string;
|
||||
@@ -76,9 +67,19 @@ export interface OutputTarget {
|
||||
adaptive_fps?: boolean;
|
||||
protocol?: string;
|
||||
|
||||
// Key Colors target fields
|
||||
picture_source_id?: string;
|
||||
key_colors_settings?: KeyColorsSettings;
|
||||
// HA light target fields
|
||||
ha_source_id?: string;
|
||||
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 ────────────────────────────────────────
|
||||
|
||||
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
|
||||
name: str
|
||||
target_type: str # "wled", "key_colors", ...
|
||||
target_type: str # "led", "ha_light"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
@@ -32,9 +32,6 @@ class OutputTarget:
|
||||
*,
|
||||
name=None,
|
||||
device_id=None,
|
||||
picture_source_id=None,
|
||||
settings=None,
|
||||
key_colors_settings=None,
|
||||
description=None,
|
||||
tags: Optional[List[str]] = None,
|
||||
**_kwargs,
|
||||
@@ -47,11 +44,6 @@ class OutputTarget:
|
||||
if tags is not None:
|
||||
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:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,35 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
@@ -39,12 +67,16 @@ class PatternTemplate:
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
rectangles=rectangles,
|
||||
created_at=datetime.fromisoformat(data["created_at"])
|
||||
if isinstance(data.get("created_at"), str)
|
||||
else data.get("created_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)),
|
||||
created_at=(
|
||||
datetime.fromisoformat(data["created_at"])
|
||||
if isinstance(data.get("created_at"), str)
|
||||
else data.get("created_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"),
|
||||
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.database import Database
|
||||
from wled_controller.storage.key_colors_output_target import KeyColorRectangle
|
||||
from wled_controller.storage.pattern_template import PatternTemplate
|
||||
from wled_controller.storage.pattern_template import KeyColorRectangle, PatternTemplate
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -113,10 +112,5 @@ class PatternTemplateStore(BaseSqliteStore[PatternTemplate]):
|
||||
return template
|
||||
|
||||
def get_targets_referencing(self, template_id: str, output_target_store) -> List[str]:
|
||||
"""Return names of KC targets that reference this template."""
|
||||
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget
|
||||
|
||||
return [
|
||||
target.name for target in output_target_store.get_all_targets()
|
||||
if isinstance(target, KeyColorsOutputTarget) and target.settings.pattern_template_id == template_id
|
||||
]
|
||||
"""Return names of targets that reference this template (legacy, always empty)."""
|
||||
return []
|
||||
|
||||
@@ -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">✕</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">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveKCEditor()" title="Save" data-i18n-aria-label="aria.save">✓</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
|
||||
|
||||
@@ -34,18 +34,6 @@ class TestOutputTargetModel:
|
||||
assert isinstance(target, WledOutputTarget)
|
||||
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):
|
||||
data = {
|
||||
"id": "pt_3",
|
||||
@@ -78,11 +66,6 @@ class TestOutputTargetStoreCRUD:
|
||||
assert t.name == "LED 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):
|
||||
with pytest.raises(ValueError, match="Invalid target type"):
|
||||
store.create_target(name="Bad", target_type="invalid")
|
||||
|
||||
Reference in New Issue
Block a user