refactor: key colors targets → CSS source type, HA target improvements
Lint & Test / test (push) Successful in 1m26s

Key Colors refactor:
- New `key_colors` CSS source type with inline rectangles
- KeyColorsColorStripStream: extracts N colors from screen regions
- CSS editor: EntitySelect for picture source, IconSelect for color mode
- Configure Regions button on card opens pattern canvas editor
- Live WS preview at 5 FPS with rectangle overlay + color swatches
- Removed KC target type, pattern template entity, and related API routes
- Removed KC/pattern template sections from Targets tab

HA light target improvements:
- Update rate, transition, mappings, brightness VS now editable via PUT
- Card crosslinks for HA source, CSS source, brightness VS
- HA connection status icon, text metrics (Hz, uptime)
- Brightness value source selector in editor
This commit is contained in:
2026-03-28 15:28:22 +03:00
parent 89d1b13854
commit 3e6760f726
46 changed files with 2707 additions and 789 deletions
@@ -9,10 +9,8 @@ from .routes.devices import router as devices_router
from .routes.templates import router as templates_router
from .routes.postprocessing import router as postprocessing_router
from .routes.picture_sources import router as picture_sources_router
from .routes.pattern_templates import router as pattern_templates_router
from .routes.output_targets import router as output_targets_router
from .routes.output_targets_control import router as output_targets_control_router
from .routes.output_targets_keycolors import router as output_targets_keycolors_router
from .routes.color_strip_sources import router as color_strip_sources_router
from .routes.audio import router as audio_router
from .routes.audio_sources import router as audio_sources_router
@@ -36,7 +34,6 @@ router.include_router(system_settings_router)
router.include_router(devices_router)
router.include_router(templates_router)
router.include_router(postprocessing_router)
router.include_router(pattern_templates_router)
router.include_router(picture_sources_router)
router.include_router(color_strip_sources_router)
router.include_router(audio_router)
@@ -45,7 +42,6 @@ router.include_router(audio_templates_router)
router.include_router(value_sources_router)
router.include_router(output_targets_router)
router.include_router(output_targets_control_router)
router.include_router(output_targets_keycolors_router)
router.include_router(automations_router)
router.include_router(scene_presets_router)
router.include_router(webhooks_router)
@@ -11,7 +11,6 @@ from wled_controller.storage.database import Database
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore
@@ -64,10 +63,6 @@ def get_pp_template_store() -> PostprocessingTemplateStore:
return _get("pp_template_store", "Postprocessing template store")
def get_pattern_template_store() -> PatternTemplateStore:
return _get("pattern_template_store", "Pattern template store")
def get_picture_source_store() -> PictureSourceStore:
return _get("picture_source_store", "Picture source store")
@@ -188,7 +183,6 @@ def init_dependencies(
processor_manager: ProcessorManager,
database: Database | None = None,
pp_template_store: PostprocessingTemplateStore | None = None,
pattern_template_store: PatternTemplateStore | None = None,
picture_source_store: PictureSourceStore | None = None,
output_target_store: OutputTargetStore | None = None,
color_strip_store: ColorStripStore | None = None,
@@ -218,7 +212,6 @@ def init_dependencies(
"template_store": template_store,
"processor_manager": processor_manager,
"pp_template_store": pp_template_store,
"pattern_template_store": pattern_template_store,
"picture_source_store": picture_source_store,
"output_target_store": output_target_store,
"color_strip_store": color_strip_store,
@@ -12,9 +12,12 @@ from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_device_store,
get_picture_source_store,
get_output_target_store,
get_pp_template_store,
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.color_strip_sources import (
ColorPushRequest,
@@ -34,13 +37,25 @@ from wled_controller.core.capture.calibration import (
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource
from wled_controller.storage.color_strip_source import (
AdvancedPictureColorStripSource,
ApiInputColorStripSource,
CompositeColorStripSource,
NotificationColorStripSource,
PictureColorStripSource,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.picture_source import (
ProcessedPictureSource,
ScreenCapturePictureSource,
)
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
@@ -90,14 +105,18 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
)
def _resolve_display_index(picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0) -> int:
def _resolve_display_index(
picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0
) -> int:
"""Resolve display index from a picture source, following processed source chains."""
if not picture_source_id or depth > 5:
return 0
try:
ps = picture_source_store.get_stream(picture_source_id)
except Exception as e:
logger.debug("Failed to resolve display index for picture source %s: %s", picture_source_id, e)
logger.debug(
"Failed to resolve display index for picture source %s: %s", picture_source_id, e
)
return 0
if isinstance(ps, ScreenCapturePictureSource):
return ps.display_index
@@ -108,7 +127,12 @@ def _resolve_display_index(picture_source_id: str, picture_source_store: Picture
# ===== CRUD ENDPOINTS =====
@router.get("/api/v1/color-strip-sources", response_model=ColorStripSourceListResponse, tags=["Color Strip Sources"])
@router.get(
"/api/v1/color-strip-sources",
response_model=ColorStripSourceListResponse,
tags=["Color Strip Sources"],
)
async def list_color_strip_sources(
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
@@ -126,7 +150,9 @@ def _extract_css_kwargs(data) -> dict:
Converts nested Pydantic models (calibration, stops, layers, zones,
animation) to plain dicts/lists that the store expects.
"""
kwargs = data.model_dump(exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"})
kwargs = data.model_dump(
exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"}
)
# Remove fields that don't map to store kwargs
kwargs.pop("source_type", None)
@@ -135,13 +161,20 @@ def _extract_css_kwargs(data) -> dict:
else:
kwargs["calibration"] = None
kwargs["stops"] = [s.model_dump() for s in data.stops] if data.stops is not None else None
kwargs["layers"] = [layer.model_dump() for layer in data.layers] if data.layers is not None else None
kwargs["layers"] = (
[layer.model_dump() for layer in data.layers] if data.layers is not None else None
)
kwargs["zones"] = [z.model_dump() for z in data.zones] if data.zones is not None else None
kwargs["animation"] = data.animation.model_dump() if data.animation else None
return kwargs
@router.post("/api/v1/color-strip-sources", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"], status_code=201)
@router.post(
"/api/v1/color-strip-sources",
response_model=ColorStripSourceResponse,
tags=["Color Strip Sources"],
status_code=201,
)
async def create_color_strip_source(
data: ColorStripSourceCreate,
_auth: AuthRequired,
@@ -157,7 +190,6 @@ async def create_color_strip_source(
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 Exception as e:
@@ -165,7 +197,11 @@ async def create_color_strip_source(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"])
@router.get(
"/api/v1/color-strip-sources/{source_id}",
response_model=ColorStripSourceResponse,
tags=["Color Strip Sources"],
)
async def get_color_strip_source(
source_id: str,
_auth: AuthRequired,
@@ -180,7 +216,11 @@ async def get_color_strip_source(
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"])
@router.put(
"/api/v1/color-strip-sources/{source_id}",
response_model=ColorStripSourceResponse,
tags=["Color Strip Sources"],
)
async def update_color_strip_source(
source_id: str,
data: ColorStripSourceUpdate,
@@ -209,7 +249,9 @@ async def update_color_strip_source(
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"])
@router.delete(
"/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"]
)
async def delete_color_strip_source(
source_id: str,
_auth: AuthRequired,
@@ -224,7 +266,7 @@ async def delete_color_strip_source(
raise HTTPException(
status_code=409,
detail=f"Color strip source is referenced by target(s): {names}. "
"Delete or reassign the target(s) first.",
"Delete or reassign the target(s) first.",
)
composite_names = store.get_composites_referencing(source_id)
if composite_names:
@@ -232,7 +274,7 @@ async def delete_color_strip_source(
raise HTTPException(
status_code=409,
detail=f"Color strip source is used as a layer in composite source(s): {names}. "
"Remove it from the composite(s) first.",
"Remove it from the composite(s) first.",
)
mapped_names = store.get_mapped_referencing(source_id)
if mapped_names:
@@ -240,7 +282,7 @@ async def delete_color_strip_source(
raise HTTPException(
status_code=409,
detail=f"Color strip source is used as a zone in mapped source(s): {names}. "
"Remove it from the mapped source(s) first.",
"Remove it from the mapped source(s) first.",
)
processed_names = store.get_processed_referencing(source_id)
if processed_names:
@@ -248,7 +290,7 @@ async def delete_color_strip_source(
raise HTTPException(
status_code=409,
detail=f"Color strip source is used as input in processed source(s): {names}. "
"Delete or reassign the processed source(s) first.",
"Delete or reassign the processed source(s) first.",
)
store.delete_source(source_id)
fire_entity_event("color_strip_source", "deleted", source_id)
@@ -261,8 +303,360 @@ async def delete_color_strip_source(
raise HTTPException(status_code=500, detail="Internal server error")
# ===== KEY COLORS TEST =====
@router.post(
"/api/v1/color-strip-sources/{source_id}/key-colors/test",
tags=["Color Strip Sources"],
)
async def test_key_colors_source(
source_id: str,
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
source_store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_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 source: capture a frame, extract colors from each rectangle."""
from wled_controller.storage.color_strip_source import KeyColorsColorStripSource
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.storage.picture_source import (
ScreenCapturePictureSource,
StaticImagePictureSource,
)
from wled_controller.utils.image_codec import encode_jpeg_data_uri
stream = None
try:
source = store.get_source(source_id)
if not isinstance(source, KeyColorsColorStripSource):
raise HTTPException(status_code=400, detail="Source is not a key_colors type")
if not source.rectangles:
raise HTTPException(status_code=400, detail="No screen regions configured")
if not source.picture_source_id:
raise HTTPException(status_code=400, detail="No picture source configured")
# Resolve picture source and capture a frame
chain = source_store.resolve_stream_chain(source.picture_source_id)
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")
image = load_image_file(image_path)
elif isinstance(raw_stream, ScreenCapturePictureSource):
capture_template = template_store.get_template(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}' not available"
)
locked = processor_manager.get_display_lock_info(display_index)
if locked:
try:
device_name = device_store.get_device(locked).name
except Exception:
device_name = locked
raise HTTPException(
status_code=409,
detail=f"Display {display_index} is captured by '{device_name}'. Stop it first.",
)
stream = EngineRegistry.create_stream(
capture_template.engine_type, display_index, capture_template.engine_config
)
stream.initialize()
sc = stream.capture_frame()
if sc is None:
raise RuntimeError("No frame captured")
image = sc.image
else:
raise HTTPException(status_code=400, detail="Unsupported picture source type")
# Apply postprocessing filters
pp_ids = chain.get("postprocessing_template_ids", [])
if pp_ids and pp_template_store:
pool = ImagePool()
for pp_id in pp_ids:
try:
pp = pp_template_store.get_template(pp_id)
for fi in pp_template_store.resolve_filter_instances(pp.filters):
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(image, pool)
if result is not None:
image = result
except Exception:
pass
# Extract colors from each rectangle
h, w = image.shape[:2]
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color)
result_rects = []
for rect in source.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, px_y = min(px_x, w - 1), min(px_y, h - 1)
px_w, px_h = min(px_w, w - px_x), min(px_h, h - px_y)
sub_img = image[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": int(r),
"g": int(g),
"b": int(b),
"hex": f"#{int(r):02x}{int(g):02x}{int(b):02x}",
},
}
)
image_data_uri = encode_jpeg_data_uri(image, quality=90)
return {
"image": image_data_uri,
"rectangles": result_rects,
"interpolation_mode": source.interpolation_mode,
}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Key colors test failed: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
finally:
if stream:
try:
stream.stop()
except Exception:
pass
@router.websocket("/api/v1/color-strip-sources/{source_id}/key-colors/test/ws")
async def test_key_colors_ws(
websocket: WebSocket,
source_id: str,
token: str = Query(""),
fps: int = Query(3),
preview_width: int = Query(480),
):
"""WebSocket for real-time key_colors test preview with frame + rectangle overlay."""
import json as ws_json
import time as ws_time
from wled_controller.api.auth import verify_ws_token
from wled_controller.storage.color_strip_source import KeyColorsColorStripSource
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.storage.picture_source import ScreenCapturePictureSource
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
store = get_color_strip_store()
source_store = get_picture_source_store()
manager = get_processor_manager()
device_store = get_device_store()
pp_store = get_pp_template_store()
try:
source = store.get_source(source_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
if not isinstance(source, KeyColorsColorStripSource):
await websocket.close(code=4003, reason="Not a key_colors source")
return
if not source.rectangles:
await websocket.close(code=4003, reason="No regions configured")
return
if not source.picture_source_id:
await websocket.close(code=4003, reason="No picture source configured")
return
try:
chain = source_store.resolve_stream_chain(source.picture_source_id)
except ValueError as e:
await websocket.close(code=4003, reason=str(e))
return
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, ScreenCapturePictureSource):
locked = manager.get_display_lock_info(raw_stream.display_index)
if locked:
try:
name = device_store.get_device(locked).name
except Exception:
name = locked
await websocket.close(code=4003, reason=f"Display captured by '{name}'")
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(source.interpolation_mode, calculate_average_color)
await websocket.accept()
logger.info(f"KC CSS test WS connected for {source_id} (fps={fps})")
live_stream_mgr = manager._live_stream_manager
live_stream = None
try:
live_stream = await asyncio.to_thread(live_stream_mgr.acquire, source.picture_source_id)
prev_frame_ref = None
while True:
loop_start = ws_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
if capture is prev_frame_ref:
await asyncio.sleep(frame_interval * 0.5)
continue
prev_frame_ref = capture
cur_image = capture.image
if not isinstance(cur_image, np.ndarray):
await asyncio.sleep(frame_interval)
continue
# Apply postprocessing
pp_ids = chain.get("postprocessing_template_ids", [])
if pp_ids and pp_store:
pool = ImagePool()
for pp_id in pp_ids:
try:
pp = pp_store.get_template(pp_id)
for fi in pp_store.resolve_filter_instances(pp.filters):
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(cur_image, pool)
if result is not None:
cur_image = result
except Exception:
pass
# Re-read source for hot-update support
try:
source = store.get_source(source_id)
calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color)
except Exception:
pass
h, w = cur_image.shape[:2]
result_rects = []
for rect in source.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, px_y = min(px_x, w - 1), min(px_y, h - 1)
px_w, px_h = min(px_w, w - px_x), min(px_h, h - px_y)
sub = cur_image[px_y : px_y + px_h, px_x : px_x + px_w]
r, g, b = calc_fn(sub)
result_rects.append(
{
"name": rect.name,
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"color": {
"r": int(r),
"g": int(g),
"b": int(b),
"hex": f"#{int(r):02x}{int(g):02x}{int(b):02x}",
},
}
)
frame_to_encode = resize_down(cur_image, preview_width)
frame_uri = encode_jpeg_data_uri(frame_to_encode, quality=85)
await websocket.send_text(
ws_json.dumps(
{
"type": "frame",
"image": frame_uri,
"rectangles": result_rects,
"interpolation_mode": source.interpolation_mode,
}
)
)
except (WebSocketDisconnect, Exception) as inner_e:
if isinstance(inner_e, WebSocketDisconnect):
raise
logger.warning(f"KC CSS test WS frame error: {inner_e}")
elapsed = ws_time.monotonic() - loop_start
if frame_interval - elapsed > 0:
await asyncio.sleep(frame_interval - elapsed)
except WebSocketDisconnect:
logger.info(f"KC CSS test WS disconnected for {source_id}")
except Exception as e:
logger.error(f"KC CSS test WS error: {e}", exc_info=True)
finally:
if live_stream is not None:
try:
await asyncio.to_thread(live_stream_mgr.release, source.picture_source_id)
except Exception:
pass
# ===== CALIBRATION TEST =====
@router.put(
"/api/v1/color-strip-sources/{source_id}/calibration/test",
response_model=CalibrationTestModeResponse,
@@ -291,7 +685,7 @@ async def test_css_calibration(
if edge_name not in valid_edges:
raise HTTPException(
status_code=400,
detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(sorted(valid_edges))}"
detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(sorted(valid_edges))}",
)
if len(color) != 3 or not all(0 <= c <= 255 for c in color):
raise HTTPException(
@@ -304,7 +698,9 @@ async def test_css_calibration(
if body.edges:
try:
source = store.get_source(source_id)
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
if not isinstance(
source, (PictureColorStripSource, AdvancedPictureColorStripSource)
):
raise HTTPException(
status_code=400,
detail="Calibration test is only available for picture color strip sources",
@@ -339,6 +735,7 @@ async def test_css_calibration(
# ===== OVERLAY VISUALIZATION =====
@router.post("/api/v1/color-strip-sources/{source_id}/overlay/start", tags=["Color Strip Sources"])
async def start_css_overlay(
source_id: str,
@@ -351,9 +748,13 @@ async def start_css_overlay(
try:
source = store.get_source(source_id)
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
raise HTTPException(status_code=400, detail="Overlay is only supported for picture color strip sources")
raise HTTPException(
status_code=400, detail="Overlay is only supported for picture color strip sources"
)
if not source.calibration:
raise HTTPException(status_code=400, detail="Color strip source has no calibration configured")
raise HTTPException(
status_code=400, detail="Color strip source has no calibration configured"
)
ps_id = getattr(source, "picture_source_id", "") or ""
display_index = _resolve_display_index(ps_id, picture_source_store)
@@ -404,6 +805,7 @@ async def get_css_overlay_status(
# ===== API INPUT: COLOR PUSH =====
@router.post("/api/v1/color-strip-sources/{source_id}/colors", tags=["Color Strip Sources"])
async def push_colors(
source_id: str,
@@ -442,7 +844,9 @@ async def push_colors(
# Legacy flat colors path
colors_array = np.array(body.colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets")
raise HTTPException(
status_code=400, detail="Colors must be an array of [R,G,B] triplets"
)
for stream in streams:
if hasattr(stream, "push_colors"):
stream.push_colors(colors_array)
@@ -495,7 +899,10 @@ async def notify_source(
@router.get("/api/v1/color-strip-sources/os-notifications/history", tags=["Color Strip Sources"])
async def os_notification_history(_auth: AuthRequired):
"""Return recent OS notification capture history (newest first)."""
from wled_controller.core.processing.os_notification_listener import get_os_notification_listener
from wled_controller.core.processing.os_notification_listener import (
get_os_notification_listener,
)
listener = get_os_notification_listener()
if listener is None:
return {"available": False, "history": []}
@@ -507,7 +914,15 @@ async def os_notification_history(_auth: AuthRequired):
# ── Transient Preview WebSocket ────────────────────────────────────────
_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight", "notification"}
_PREVIEW_ALLOWED_TYPES = {
"static",
"gradient",
"color_cycle",
"effect",
"daylight",
"candlelight",
"notification",
}
@router.websocket("/api/v1/color-strip-sources/preview/ws")
@@ -529,6 +944,7 @@ async def preview_color_strip_ws(
changed the old stream is replaced; otherwise ``update_source()`` is used.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -557,6 +973,7 @@ async def preview_color_strip_ws(
def _build_source(config: dict):
"""Build a ColorStripSource from a raw config dict, injecting synthetic id/name."""
from wled_controller.storage.color_strip_source import ColorStripSource
config.setdefault("id", "__preview__")
config.setdefault("name", "__preview__")
return ColorStripSource.from_dict(config)
@@ -564,6 +981,7 @@ async def preview_color_strip_ws(
def _create_stream(source):
"""Instantiate and start the appropriate stream class for *source*."""
from wled_controller.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls:
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
@@ -572,6 +990,7 @@ async def preview_color_strip_ws(
if hasattr(s, "set_gradient_store"):
try:
from wled_controller.api.dependencies import get_gradient_store
s.set_gradient_store(get_gradient_store())
except Exception:
pass
@@ -626,7 +1045,14 @@ async def preview_color_strip_ws(
config = _json.loads(initial_text)
source_type = config.get("source_type")
if source_type not in _PREVIEW_ALLOWED_TYPES:
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
await websocket.send_text(
_json.dumps(
{
"type": "error",
"detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}",
}
)
)
await websocket.close(code=4003, reason="Invalid source_type")
return
source = _build_source(config)
@@ -639,7 +1065,9 @@ async def preview_color_strip_ws(
return
await _send_meta(current_source_type)
logger.info(f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}")
logger.info(
f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}"
)
# Frame loop ─────────────────────────────────────────────────────────
@@ -659,7 +1087,10 @@ async def preview_color_strip_ws(
# Handle "fire" command for notification streams
if new_config.get("action") == "fire":
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
from wled_controller.core.processing.notification_stream import (
NotificationColorStripStream,
)
if isinstance(stream, NotificationColorStripStream):
stream.fire(
app_name=new_config.get("app", ""),
@@ -669,7 +1100,14 @@ async def preview_color_strip_ws(
new_type = new_config.get("source_type")
if new_type not in _PREVIEW_ALLOWED_TYPES:
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
await websocket.send_text(
_json.dumps(
{
"type": "error",
"detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}",
}
)
)
continue
new_source = _build_source(new_config)
if new_type != current_source_type:
@@ -692,7 +1130,7 @@ async def preview_color_strip_ws(
await websocket.send_bytes(colors.tobytes())
else:
# Stream hasn't produced a frame yet — send black
await websocket.send_bytes(b'\x00' * led_count * 3)
await websocket.send_bytes(b"\x00" * led_count * 3)
except WebSocketDisconnect:
pass
@@ -716,6 +1154,7 @@ async def css_api_input_ws(
or binary frames (raw RGBRGB... bytes, 3 bytes per LED).
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -746,6 +1185,7 @@ async def css_api_input_ws(
if "text" in message:
# JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
import json
try:
data = json.loads(message["text"])
except (json.JSONDecodeError, ValueError) as e:
@@ -756,6 +1196,7 @@ async def css_api_input_ws(
# Segment-based path — validate and push
try:
from wled_controller.api.schemas.color_strip_sources import SegmentPayload
seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
except Exception as e:
await websocket.send_json({"error": f"Invalid segment: {e}"})
@@ -777,7 +1218,9 @@ async def css_api_input_ws(
await websocket.send_json({"error": str(e)})
continue
else:
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
await websocket.send_json(
{"error": "JSON frame must contain 'colors' or 'segments'"}
)
continue
elif "bytes" in message:
@@ -807,6 +1250,7 @@ async def css_api_input_ws(
# ── Test / Preview WebSocket ──────────────────────────────────────────
@router.websocket("/api/v1/color-strip-sources/{source_id}/test/ws")
async def test_color_strip_ws(
websocket: WebSocket,
@@ -821,6 +1265,7 @@ async def test_color_strip_ws(
Subsequent messages are binary RGB frames (``led_count * 3`` bytes).
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -868,6 +1313,7 @@ async def test_color_strip_ws(
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
is_api_input = isinstance(stream, ApiInputColorStripStream)
_last_push_gen = 0 # track api_input push generation to skip unchanged frames
@@ -900,13 +1346,19 @@ async def test_color_strip_ws(
enabled_layers = [layer for layer in source.layers if layer.get("enabled", True)]
layer_infos = [] # [{name, id, is_notification, has_brightness, ...}, ...]
for layer in enabled_layers:
info = {"id": layer["source_id"], "name": layer.get("source_id", "?"),
"is_notification": False, "has_brightness": bool(layer.get("brightness_source_id"))}
info = {
"id": layer["source_id"],
"name": layer.get("source_id", "?"),
"is_notification": False,
"has_brightness": bool(layer.get("brightness_source_id")),
}
try:
layer_src = store.get_source(layer["source_id"])
info["name"] = layer_src.name
info["is_notification"] = isinstance(layer_src, NotificationColorStripSource)
if isinstance(layer_src, (PictureColorStripSource, AdvancedPictureColorStripSource)):
if isinstance(
layer_src, (PictureColorStripSource, AdvancedPictureColorStripSource)
):
info["is_picture"] = True
if hasattr(layer_src, "calibration") and layer_src.calibration:
info["calibration_led_count"] = layer_src.calibration.get_total_leds()
@@ -927,7 +1379,7 @@ async def test_color_strip_ws(
# For picture sources, grab the live stream for frame preview
_frame_live = None
if is_picture and hasattr(stream, 'live_stream'):
if is_picture and hasattr(stream, "live_stream"):
_frame_live = stream.live_stream
_last_aux_time = 0.0
_AUX_INTERVAL = 0.08 # send JPEG preview / brightness updates ~12 FPS
@@ -943,15 +1395,17 @@ async def test_color_strip_ws(
led_count = composite_colors.shape[0]
rgb_size = led_count * 3
# Wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layer0_rgb...] ... [composite_rgb]
header = bytes([0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF])
header = bytes(
[0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF]
)
parts = [header]
for lc in layer_colors:
if lc is not None and lc.shape[0] == led_count:
parts.append(lc.tobytes())
else:
parts.append(b'\x00' * rgb_size)
parts.append(b"\x00" * rgb_size)
parts.append(composite_colors.tobytes())
await websocket.send_bytes(b''.join(parts))
await websocket.send_bytes(b"".join(parts))
elif composite_colors is not None:
await websocket.send_bytes(composite_colors.tobytes())
else:
@@ -978,9 +1432,12 @@ async def test_color_strip_ws(
try:
bri_values = stream.get_layer_brightness()
if any(v is not None for v in bri_values):
bri_msg = {"type": "brightness", "values": [
round(v * 100) if v is not None else None for v in bri_values
]}
bri_msg = {
"type": "brightness",
"values": [
round(v * 100) if v is not None else None for v in bri_values
],
}
await websocket.send_text(_json.dumps(bri_msg))
except Exception:
pass
@@ -992,6 +1449,7 @@ async def test_color_strip_ws(
if frame is not None and frame.image is not None:
from wled_controller.utils.image_codec import encode_jpeg
import cv2 as _cv2
img = frame.image
# Ensure 3-channel RGB (some engines may produce BGRA)
if img.ndim == 3 and img.shape[2] == 4:
@@ -1000,19 +1458,25 @@ async def test_color_strip_ws(
# Send frame dimensions once so client can compute border overlay
if not _frame_dims_sent:
_frame_dims_sent = True
await websocket.send_text(_json.dumps({
"type": "frame_dims",
"width": w,
"height": h,
}))
await websocket.send_text(
_json.dumps(
{
"type": "frame_dims",
"width": w,
"height": h,
}
)
)
# Downscale for bandwidth
scale = min(960 / w, 540 / h, 1.0)
if scale < 1.0:
new_w = max(1, int(w * scale))
new_h = max(1, int(h * scale))
img = _cv2.resize(img, (new_w, new_h), interpolation=_cv2.INTER_AREA)
img = _cv2.resize(
img, (new_w, new_h), interpolation=_cv2.INTER_AREA
)
# Wire format: [0xFD] [jpeg_bytes]
await websocket.send_bytes(b'\xfd' + encode_jpeg(img, quality=70))
await websocket.send_bytes(b"\xfd" + encode_jpeg(img, quality=70))
except Exception as e:
logger.warning(f"JPEG frame preview error: {e}")
@@ -12,7 +12,6 @@ from wled_controller.api.dependencies import (
get_processor_manager,
)
from wled_controller.api.schemas.output_targets import (
KeyColorsSettingsSchema,
OutputTargetCreate,
OutputTargetListResponse,
OutputTargetResponse,
@@ -21,10 +20,6 @@ from wled_controller.api.schemas.output_targets import (
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.key_colors_output_target import (
KeyColorsSettings,
KeyColorsOutputTarget,
)
from wled_controller.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
@@ -39,30 +34,6 @@ logger = get_logger(__name__)
router = APIRouter()
def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema:
"""Convert core KeyColorsSettings to schema."""
return KeyColorsSettingsSchema(
fps=settings.fps,
interpolation_mode=settings.interpolation_mode,
smoothing=settings.smoothing,
pattern_template_id=settings.pattern_template_id,
brightness=settings.brightness,
brightness_value_source_id=settings.brightness_value_source_id,
)
def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings:
"""Convert schema KeyColorsSettings to core."""
return KeyColorsSettings(
fps=schema.fps,
interpolation_mode=schema.interpolation_mode,
smoothing=schema.smoothing,
pattern_template_id=schema.pattern_template_id,
brightness=schema.brightness,
brightness_value_source_id=schema.brightness_value_source_id,
)
def _target_to_response(target) -> OutputTargetResponse:
"""Convert an OutputTarget to OutputTargetResponse."""
if isinstance(target, WledOutputTarget):
@@ -84,18 +55,6 @@ def _target_to_response(target) -> OutputTargetResponse:
created_at=target.created_at,
updated_at=target.updated_at,
)
elif isinstance(target, KeyColorsOutputTarget):
return OutputTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
picture_source_id=target.picture_source_id,
key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
)
elif isinstance(target, HALightOutputTarget):
return OutputTargetResponse(
id=target.id,
@@ -155,9 +114,6 @@ async def create_target(
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
kc_settings = (
_kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
)
ha_mappings = (
[
HALightMapping(
@@ -185,8 +141,6 @@ async def create_target(
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
picture_source_id=data.picture_source_id,
key_colors_settings=kc_settings,
description=data.description,
tags=data.tags,
ha_source_id=data.ha_source_id,
@@ -282,31 +236,18 @@ async def update_target(
except ValueError:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
# Build KC settings with partial-update support: only apply fields that were
# explicitly provided in the request body, merging with the existing settings.
kc_settings = None
if data.key_colors_settings is not None:
incoming = data.key_colors_settings.model_dump(exclude_unset=True)
try:
existing_target = target_store.get_target(target_id)
except ValueError:
existing_target = None
if isinstance(existing_target, KeyColorsOutputTarget):
ex = existing_target.settings
merged = KeyColorsSettingsSchema(
fps=incoming.get("fps", ex.fps),
interpolation_mode=incoming.get("interpolation_mode", ex.interpolation_mode),
smoothing=incoming.get("smoothing", ex.smoothing),
pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id),
brightness=incoming.get("brightness", ex.brightness),
brightness_value_source_id=incoming.get(
"brightness_value_source_id", ex.brightness_value_source_id
),
# Build HA light mappings if provided
ha_mappings = None
if data.ha_light_mappings is not None:
ha_mappings = [
HALightMapping(
entity_id=m.entity_id,
led_start=m.led_start,
led_end=m.led_end,
brightness_scale=m.brightness_scale,
)
kc_settings = _kc_schema_to_settings(merged)
else:
kc_settings = _kc_schema_to_settings(data.key_colors_settings)
for m in data.ha_light_mappings
]
# Update in store
target = target_store.update_target(
@@ -321,18 +262,15 @@ async def update_target(
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
key_colors_settings=kc_settings,
description=data.description,
tags=data.tags,
ha_source_id=data.ha_source_id,
ha_light_mappings=ha_mappings,
update_rate=data.update_rate,
transition=data.transition,
color_tolerance=data.color_tolerance,
)
# Detect KC brightness VS change (inside key_colors_settings)
kc_brightness_vs_changed = False
if data.key_colors_settings is not None:
kc_incoming = data.key_colors_settings.model_dump(exclude_unset=True)
if "brightness_value_source_id" in kc_incoming:
kc_brightness_vs_changed = True
# Sync processor manager (run in thread — css release/acquire can block)
try:
await asyncio.to_thread(
@@ -344,12 +282,13 @@ async def update_target(
or data.state_check_interval is not None
or data.min_brightness_threshold is not None
or data.adaptive_fps is not None
or data.key_colors_settings is not None
or data.update_rate is not None
or data.transition is not None
or data.color_tolerance is not None
or data.ha_light_mappings is not None
),
css_changed=data.color_strip_source_id is not None,
brightness_vs_changed=(
data.brightness_value_source_id is not None or kc_brightness_vs_changed
),
brightness_vs_changed=data.brightness_value_source_id is not None,
)
except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
@@ -26,7 +26,6 @@ from wled_controller.api.dependencies import (
get_ha_manager,
get_ha_store,
get_output_target_store,
get_pattern_template_store,
get_picture_source_store,
get_pp_template_store,
get_processor_manager,
@@ -155,7 +154,6 @@ async def list_all_tags(_: AuthRequired):
get_template_store,
get_audio_template_store,
get_pp_template_store,
get_pattern_template_store,
get_asset_store,
]
for getter in store_getters:
@@ -61,12 +61,6 @@ from .postprocessing import (
PostprocessingTemplateUpdate,
PPTemplateTestRequest,
)
from .pattern_templates import (
PatternTemplateCreate,
PatternTemplateListResponse,
PatternTemplateResponse,
PatternTemplateUpdate,
)
from .picture_sources import (
ImageValidateRequest,
ImageValidateResponse,
@@ -11,8 +11,12 @@ from wled_controller.api.schemas.devices import Calibration
class AppSoundOverride(BaseModel):
"""Per-application sound override for notification sources."""
sound_asset_id: Optional[str] = Field(None, description="Asset ID for the sound (None = mute this app)")
volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Volume override (None = use global)")
sound_asset_id: Optional[str] = Field(
None, description="Asset ID for the sound (None = mute this app)"
)
volume: Optional[float] = Field(
None, ge=0.0, le=1.0, description="Volume override (None = use global)"
)
class AnimationConfig(BaseModel):
@@ -26,7 +30,9 @@ class AnimationConfig(BaseModel):
class ColorStop(BaseModel):
"""A single color stop in a gradient."""
position: float = Field(description="Relative position along the strip (0.01.0)", ge=0.0, le=1.0)
position: float = Field(
description="Relative position along the strip (0.01.0)", ge=0.0, le=1.0
)
color: List[int] = Field(description="Primary RGB color [R, G, B] (0255 each)")
color_right: Optional[List[int]] = Field(
None,
@@ -38,13 +44,21 @@ class CompositeLayer(BaseModel):
"""A single layer in a composite color strip source."""
source_id: str = Field(description="ID of the layer's color strip source")
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen|override")
blend_mode: str = Field(
default="normal", description="Blend mode: normal|add|multiply|screen|override"
)
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
enabled: bool = Field(default=True, description="Whether this layer is active")
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID")
brightness_source_id: Optional[str] = Field(
None, description="Optional value source ID for dynamic brightness"
)
processing_template_id: Optional[str] = Field(
None, description="Optional color strip processing template ID"
)
start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)")
end: int = Field(default=0, ge=0, description="Last LED index exclusive for range (0 = full strip)")
end: int = Field(
default=0, ge=0, description="Last LED index exclusive for range (0 = full strip)"
)
reverse: bool = Field(default=False, description="Reverse layer output within its range")
@@ -61,74 +75,179 @@ class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed", "weather"] = Field(default="picture", description="Source type")
source_type: Literal[
"picture",
"picture_advanced",
"static",
"gradient",
"color_cycle",
"effect",
"composite",
"mapped",
"audio",
"api_input",
"notification",
"daylight",
"candlelight",
"processed",
"weather",
"key_colors",
] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
smoothing: float = Field(
default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0
)
interpolation_mode: str = Field(
default="average", description="LED color interpolation mode (average, median, dominant)"
)
calibration: Optional[Calibration] = Field(
None, description="LED calibration (position and count per edge)"
)
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
color: Optional[List[int]] = Field(
None, description="Static RGB color [R, G, B] (0-255 each, for static type)"
)
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
colors: Optional[List[List[int]]] = Field(
None, description="List of [R,G,B] colors to cycle (color_cycle type)"
)
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora|rain|comet|bouncing_ball|fireworks|sparkle_rain|lava_lamp|wave_interference")
palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice) or 'custom'")
effect_type: Optional[str] = Field(
None,
description="Effect algorithm: fire|meteor|plasma|noise|aurora|rain|comet|bouncing_ball|fireworks|sparkle_rain|lava_lamp|wave_interference",
)
palette: Optional[str] = Field(
None,
description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice) or 'custom'",
)
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor/comet)")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops [[pos,R,G,B],...]")
custom_palette: Optional[List[List[float]]] = Field(
None, description="Custom palette stops [[pos,R,G,B],...]"
)
# gradient entity reference (effect, gradient, audio types)
gradient_id: Optional[str] = Field(None, description="Gradient entity ID (overrides palette/inline stops)")
gradient_id: Optional[str] = Field(
None, description="Gradient entity ID (overrides palette/inline stops)"
)
# gradient-type easing
easing: Optional[str] = Field(None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic")
easing: Optional[str] = Field(
None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic"
)
# composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# mapped-type fields
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
# audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
visualization_mode: Optional[str] = Field(
None, description="Audio visualization: spectrum|beat_pulse|vu_meter"
)
audio_source_id: Optional[str] = Field(
None, description="Mono audio source ID (for audio type)"
)
sensitivity: Optional[float] = Field(
None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0
)
color_peak: Optional[List[int]] = Field(
None, description="Peak/high RGB color for VU meter [R,G,B]"
)
# shared
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
led_count: int = Field(
default=0, description="Total LED count (0 = auto from calibration / device)", ge=0
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
animation: Optional[AnimationConfig] = Field(
None, description="Procedural animation config (static/gradient only)"
)
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)")
timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0)
interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)")
fallback_color: Optional[List[int]] = Field(
None, description="Fallback RGB color [R,G,B] when no data received (api_input type)"
)
timeout: Optional[float] = Field(
None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0
)
interpolation: Optional[str] = Field(
None, description="LED count interpolation mode: none|linear|nearest (api_input type)"
)
# notification-type fields
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB) for notifications")
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color (#RRGGBB)")
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
notification_effect: Optional[str] = Field(
None, description="Notification effect: flash|pulse|sweep"
)
duration_ms: Optional[int] = Field(
None, ge=100, le=10000, description="Effect duration in milliseconds"
)
default_color: Optional[str] = Field(
None, description="Default hex color (#RRGGBB) for notifications"
)
app_colors: Optional[Dict[str, str]] = Field(
None, description="Map of app name to hex color (#RRGGBB)"
)
app_filter_mode: Optional[str] = Field(
None, description="App filter mode: off|whitelist|blacklist"
)
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(None, description="Per-app sound overrides")
sound_volume: Optional[float] = Field(
None, ge=0.0, le=1.0, description="Global notification sound volume"
)
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(
None, description="Per-app sound overrides"
)
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0)
speed: Optional[float] = Field(
None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0
)
use_real_time: Optional[bool] = Field(
None, description="Use wall-clock time for daylight cycle"
)
latitude: Optional[float] = Field(
None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0
)
longitude: Optional[float] = Field(
None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0
)
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
wind_strength: Optional[float] = Field(None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0)
candle_type: Optional[str] = Field(None, description="Candle type preset: default|taper|votive|bonfire")
num_candles: Optional[int] = Field(
None, description="Number of independent candle sources (1-20)", ge=1, le=20
)
wind_strength: Optional[float] = Field(
None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0
)
candle_type: Optional[str] = Field(
None, description="Candle type preset: default|taper|votive|bonfire"
)
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
input_source_id: Optional[str] = Field(
None, description="Input color strip source ID (for processed type)"
)
processing_template_id: Optional[str] = Field(
None, description="Color strip processing template ID (for processed type)"
)
# weather-type fields
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID (for weather type)")
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0)
weather_source_id: Optional[str] = Field(
None, description="Weather source entity ID (for weather type)"
)
temperature_influence: Optional[float] = Field(
None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0
)
# key_colors-type fields
rectangles: Optional[List[dict]] = Field(
None, description="Named screen regions [{name,x,y,width,height}] for key_colors type"
)
brightness: Optional[float] = Field(
None, description="Static brightness (0.0-1.0)", ge=0.0, le=1.0
)
brightness_value_source_id: Optional[str] = Field(
None, description="Dynamic brightness value source ID"
)
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID for synchronized animation"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -138,71 +257,147 @@ class ColorStripSourceUpdate(BaseModel):
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
# picture-type fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)")
smoothing: Optional[float] = Field(
None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0
)
interpolation_mode: Optional[str] = Field(
None, description="Interpolation mode (average, median, dominant)"
)
calibration: Optional[Calibration] = Field(None, description="LED calibration")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
color: Optional[List[int]] = Field(
None, description="Static RGB color [R, G, B] (0-255 each, for static type)"
)
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
colors: Optional[List[List[int]]] = Field(
None, description="List of [R,G,B] colors to cycle (color_cycle type)"
)
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops [[pos,R,G,B],...]")
custom_palette: Optional[List[List[float]]] = Field(
None, description="Custom palette stops [[pos,R,G,B],...]"
)
# gradient entity reference (effect, gradient, audio types)
gradient_id: Optional[str] = Field(None, description="Gradient entity ID (overrides palette/inline stops)")
gradient_id: Optional[str] = Field(
None, description="Gradient entity ID (overrides palette/inline stops)"
)
# gradient-type easing
easing: Optional[str] = Field(None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic")
easing: Optional[str] = Field(
None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic"
)
# composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# mapped-type fields
zones: Optional[List[MappedZone]] = Field(None, description="Zones for mapped type")
# audio-type fields
visualization_mode: Optional[str] = Field(None, description="Audio visualization: spectrum|beat_pulse|vu_meter")
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID (for audio type)")
sensitivity: Optional[float] = Field(None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0)
color_peak: Optional[List[int]] = Field(None, description="Peak/high RGB color for VU meter [R,G,B]")
visualization_mode: Optional[str] = Field(
None, description="Audio visualization: spectrum|beat_pulse|vu_meter"
)
audio_source_id: Optional[str] = Field(
None, description="Mono audio source ID (for audio type)"
)
sensitivity: Optional[float] = Field(
None, description="Audio sensitivity/gain 0.1-5.0", ge=0.1, le=5.0
)
color_peak: Optional[List[int]] = Field(
None, description="Peak/high RGB color for VU meter [R,G,B]"
)
# shared
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
led_count: Optional[int] = Field(
None, description="Total LED count (0 = auto from calibration / device)", ge=0
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
animation: Optional[AnimationConfig] = Field(
None, description="Procedural animation config (static/gradient only)"
)
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0)
interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)")
fallback_color: Optional[List[int]] = Field(
None, description="Fallback RGB color [R,G,B] (api_input type)"
)
timeout: Optional[float] = Field(
None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0
)
interpolation: Optional[str] = Field(
None, description="LED count interpolation mode: none|linear|nearest (api_input type)"
)
# notification-type fields
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
notification_effect: Optional[str] = Field(
None, description="Notification effect: flash|pulse|sweep"
)
duration_ms: Optional[int] = Field(
None, ge=100, le=10000, description="Effect duration in milliseconds"
)
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)")
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color")
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_mode: Optional[str] = Field(
None, description="App filter mode: off|whitelist|blacklist"
)
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(None, description="Per-app sound overrides")
sound_volume: Optional[float] = Field(
None, ge=0.0, le=1.0, description="Global notification sound volume"
)
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(
None, description="Per-app sound overrides"
)
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0)
speed: Optional[float] = Field(
None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0
)
use_real_time: Optional[bool] = Field(
None, description="Use wall-clock time for daylight cycle"
)
latitude: Optional[float] = Field(
None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0
)
longitude: Optional[float] = Field(
None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0
)
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
wind_strength: Optional[float] = Field(None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0)
candle_type: Optional[str] = Field(None, description="Candle type preset: default|taper|votive|bonfire")
num_candles: Optional[int] = Field(
None, description="Number of independent candle sources (1-20)", ge=1, le=20
)
wind_strength: Optional[float] = Field(
None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0
)
candle_type: Optional[str] = Field(
None, description="Candle type preset: default|taper|votive|bonfire"
)
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
input_source_id: Optional[str] = Field(
None, description="Input color strip source ID (for processed type)"
)
processing_template_id: Optional[str] = Field(
None, description="Color strip processing template ID (for processed type)"
)
# weather-type fields
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID (for weather type)")
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0)
weather_source_id: Optional[str] = Field(
None, description="Weather source entity ID (for weather type)"
)
temperature_influence: Optional[float] = Field(
None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0
)
# key_colors-type fields
rectangles: Optional[List[dict]] = Field(
None, description="Named screen regions for key_colors type"
)
brightness: Optional[float] = Field(
None, description="Static brightness (0.0-1.0)", ge=0.0, le=1.0
)
brightness_value_source_id: Optional[str] = Field(
None, description="Dynamic brightness value source ID"
)
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID for synchronized animation"
)
tags: Optional[List[str]] = None
@@ -222,7 +417,9 @@ class ColorStripSourceResponse(BaseModel):
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
colors: Optional[List[List[int]]] = Field(
None, description="List of [R,G,B] colors to cycle (color_cycle type)"
)
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm")
palette: Optional[str] = Field(None, description="Named palette")
@@ -245,17 +442,27 @@ class ColorStripSourceResponse(BaseModel):
# shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description")
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
animation: Optional[AnimationConfig] = Field(
None, description="Procedural animation config (static/gradient only)"
)
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
fallback_color: Optional[List[int]] = Field(
None, description="Fallback RGB color [R,G,B] (api_input type)"
)
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)")
interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)")
interpolation: Optional[str] = Field(
None, description="LED count interpolation mode: none|linear|nearest (api_input type)"
)
# notification-type fields
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
notification_effect: Optional[str] = Field(
None, description="Notification effect: flash|pulse|sweep"
)
duration_ms: Optional[int] = Field(None, description="Effect duration in milliseconds")
default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)")
app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color")
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_mode: Optional[str] = Field(
None, description="App filter mode: off|whitelist|blacklist"
)
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
@@ -263,7 +470,9 @@ class ColorStripSourceResponse(BaseModel):
app_sounds: Optional[Dict[str, dict]] = Field(None, description="Per-app sound overrides")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
use_real_time: Optional[bool] = Field(
None, description="Use wall-clock time for daylight cycle"
)
latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
longitude: Optional[float] = Field(None, description="Longitude for daylight timing")
# candlelight-type fields
@@ -272,14 +481,30 @@ class ColorStripSourceResponse(BaseModel):
candle_type: Optional[str] = Field(None, description="Candle type preset")
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID")
processing_template_id: Optional[str] = Field(
None, description="Color strip processing template ID"
)
# weather-type fields
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID")
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength")
temperature_influence: Optional[float] = Field(
None, description="Temperature color shift strength"
)
# key_colors-type fields
rectangles: Optional[List[dict]] = Field(
None, description="Named screen regions for key_colors type"
)
brightness: Optional[float] = Field(None, description="Static brightness (0.0-1.0)")
brightness_value_source_id: Optional[str] = Field(
None, description="Dynamic brightness value source ID"
)
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID for synchronized animation"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
overlay_active: bool = Field(
False, description="Whether the screen overlay is currently active"
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -298,7 +523,9 @@ class SegmentPayload(BaseModel):
length: int = Field(ge=1, description="Number of LEDs in segment")
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field(None, description="Colors for per_pixel/gradient [[R,G,B],...]")
colors: Optional[List[List[int]]] = Field(
None, description="Colors for per_pixel/gradient [[R,G,B],...]"
)
@model_validator(mode="after")
def _validate_mode_fields(self) -> "SegmentPayload":
@@ -329,8 +556,12 @@ class ColorPushRequest(BaseModel):
At least one must be provided.
"""
colors: Optional[List[List[int]]] = Field(None, description="LED color array [[R,G,B], ...] (0-255 each)")
segments: Optional[List[SegmentPayload]] = Field(None, description="Segment-based color updates")
colors: Optional[List[List[int]]] = Field(
None, description="LED color array [[R,G,B], ...] (0-255 each)"
)
segments: Optional[List[SegmentPayload]] = Field(
None, description="Segment-based color updates"
)
@model_validator(mode="after")
def _require_colors_or_segments(self) -> "ColorPushRequest":
@@ -69,7 +69,20 @@ class ColorStripStreamManager:
keyed by ``{css_id}:{consumer_id}``.
"""
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None, gradient_store=None, weather_manager=None, asset_store=None):
def __init__(
self,
color_strip_store,
live_stream_manager,
audio_capture_manager=None,
audio_source_store=None,
audio_template_store=None,
sync_clock_manager=None,
value_stream_manager=None,
cspt_store=None,
gradient_store=None,
weather_manager=None,
asset_store=None,
):
"""
Args:
color_strip_store: ColorStripStore for resolving source configs
@@ -166,17 +179,30 @@ class ColorStripStreamManager:
if not source.sharable:
if source.source_type == "audio":
from wled_controller.core.processing.audio_stream import AudioColorStripStream
css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store, self._audio_template_store)
css_stream = AudioColorStripStream(
source,
self._audio_capture_manager,
self._audio_source_store,
self._audio_template_store,
)
elif source.source_type == "composite":
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
css_stream = CompositeColorStripStream(source, self, self._value_stream_manager, self._cspt_store)
from wled_controller.core.processing.composite_stream import (
CompositeColorStripStream,
)
css_stream = CompositeColorStripStream(
source, self, self._value_stream_manager, self._cspt_store
)
elif source.source_type == "mapped":
from wled_controller.core.processing.mapped_stream import MappedColorStripStream
css_stream = MappedColorStripStream(source, self)
elif source.source_type == "processed":
css_stream = ProcessedColorStripStream(source, self, self._cspt_store)
elif source.source_type == "weather":
from wled_controller.core.processing.weather_stream import WeatherColorStripStream
css_stream = WeatherColorStripStream(source, self._weather_manager)
else:
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
@@ -196,7 +222,9 @@ class ColorStripStreamManager:
css_stream.start()
key = f"{css_id}:{consumer_id}" if consumer_id else css_id
self._streams[key] = _ColorStripEntry(
stream=css_stream, ref_count=1, picture_source_ids=[],
stream=css_stream,
ref_count=1,
picture_source_ids=[],
clock_id=acquired_clock_id,
)
logger.info(f"Created {source.source_type} stream {key}")
@@ -209,8 +237,44 @@ class ColorStripStreamManager:
logger.info(f"Reusing stream {css_id} (ref_count={entry.ref_count})")
return entry.stream
# Key Colors: sharable, needs a single LiveStream
from wled_controller.storage.color_strip_source import KeyColorsColorStripSource
if isinstance(source, KeyColorsColorStripSource):
ps_id = source.picture_source_id
if not ps_id:
raise ValueError(f"Key colors source {css_id} has no picture_source_id assigned")
try:
live_stream = self._live_stream_manager.acquire(ps_id)
except Exception as e:
raise ValueError(
f"Failed to acquire live stream for key_colors {css_id}: {e}"
) from e
try:
from wled_controller.core.processing.kc_color_strip_stream import (
KeyColorsColorStripStream,
)
css_stream = KeyColorsColorStripStream(live_stream, source)
css_stream.start()
except Exception as e:
self._live_stream_manager.release(ps_id)
raise RuntimeError(f"Failed to start key_colors stream {css_id}: {e}") from e
self._streams[css_id] = _ColorStripEntry(
stream=css_stream,
ref_count=1,
picture_source_ids=[ps_id],
)
logger.info(f"Created key_colors stream {css_id} ({len(source.rectangles)} rects)")
return css_stream
# Create new picture stream — needs LiveStream(s) from the capture pipeline
from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource
from wled_controller.storage.color_strip_source import (
PictureColorStripSource,
AdvancedPictureColorStripSource,
)
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
raise ValueError(
f"Unsupported sharable source type '{source.source_type}' for {css_id}"
@@ -222,9 +286,7 @@ class ColorStripStreamManager:
# Simple mode: use the CSS source's single picture_source_id
ps_id = getattr(source, "picture_source_id", "")
if not ps_id:
raise ValueError(
f"Color strip source {css_id} has no picture_source_id assigned"
)
raise ValueError(f"Color strip source {css_id} has no picture_source_id assigned")
required_ps_ids = [ps_id]
# Acquire all required live streams (with rollback on failure)
@@ -235,9 +297,7 @@ class ColorStripStreamManager:
except Exception as e:
for ps_id in acquired:
self._live_stream_manager.release(ps_id)
raise ValueError(
f"Failed to acquire live streams for source {css_id}: {e}"
) from e
raise ValueError(f"Failed to acquire live streams for source {css_id}: {e}") from e
# Create stream (single LiveStream for simple, dict for advanced)
try:
@@ -314,10 +374,7 @@ class ColorStripStreamManager:
new_source: Updated ColorStripSource config
"""
# Find all entries: shared key OR per-consumer keys (css_id:xxx)
matching_keys = [
k for k in self._streams
if k == css_id or k.startswith(f"{css_id}:")
]
matching_keys = [k for k in self._streams if k == css_id or k.startswith(f"{css_id}:")]
if not matching_keys:
return # Stream not running; config will be used on next acquire
@@ -347,7 +404,11 @@ class ColorStripStreamManager:
self._release_clock(source_id, entry.stream, clock_id=old_clock_id)
# Track picture source changes for future reference counting
from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource
from wled_controller.storage.color_strip_source import (
PictureColorStripSource,
AdvancedPictureColorStripSource,
)
if isinstance(new_source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
new_ps_ids = new_source.calibration.get_required_picture_source_ids()
if not new_ps_ids:
@@ -424,7 +485,4 @@ class ColorStripStreamManager:
def get_active_stream_ids(self) -> list:
"""Get list of active stream IDs with ref counts (for diagnostics)."""
return [
{"id": sid, "ref_count": entry.ref_count}
for sid, entry in self._streams.items()
]
return [{"id": sid, "ref_count": entry.ref_count} for sid, entry in self._streams.items()]
@@ -144,21 +144,33 @@ class HALightTargetProcessor(TargetProcessor):
logger.warning(f"HA light {self._target_id}: CSS swap failed: {e}")
def get_state(self) -> dict:
uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0
return {
"target_id": self._target_id,
"processing": self._is_running,
"ha_source_id": self._ha_source_id,
"css_id": self._css_id,
"is_running": self._is_running,
"ha_connected": self._ha_runtime.is_connected if self._ha_runtime else False,
"light_count": len(self._light_mappings),
"update_rate": self._update_rate,
"fps_actual": self._update_rate if self._is_running else None,
"fps_target": self._update_rate,
"uptime_seconds": uptime,
}
def get_metrics(self) -> dict:
uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0
return {
"target_id": self._target_id,
"uptime": time.monotonic() - self._start_time if self._start_time else 0,
"update_rate": self._update_rate,
"processing": self._is_running,
"fps_actual": self._update_rate if self._is_running else None,
"fps_target": self._update_rate,
"uptime_seconds": uptime,
"frames_processed": 0,
"errors_count": 0,
"last_error": None,
"last_update": None,
}
async def _processing_loop(self) -> None:
@@ -0,0 +1,195 @@
"""Key Colors color strip stream — extracts dominant colors from screen rectangles.
Produces an np.ndarray(N, 3) where N = number of rectangles.
Each "LED" is the average/median/dominant color of one screen region.
"""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING, List, Optional
import cv2
import numpy as np
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer
if TYPE_CHECKING:
from wled_controller.core.processing.live_stream import LiveStream
from wled_controller.storage.color_strip_source import KeyColorsColorStripSource
logger = get_logger(__name__)
KC_WORK_SIZE = (160, 90) # (width, height) — small enough for fast color calc
_CALC_FNS = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
class KeyColorsColorStripStream:
"""Streams N colors extracted from screen rectangles.
Implements the same interface as ColorStripStream so it can be used
by any target processor via ColorStripStreamManager.
"""
def __init__(
self,
live_stream: LiveStream,
source: KeyColorsColorStripSource,
) -> None:
self._live_stream = live_stream
self._source = source
# Pre-compute rectangle pixel bounds at KC_WORK_SIZE
kc_w, kc_h = KC_WORK_SIZE
self._rect_names: List[str] = []
self._rect_bounds: List[tuple] = []
for rect in source.rectangles:
self._rect_names.append(rect.name)
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)
self._rect_bounds.append((px_y, px_y + px_h, px_x, px_x + px_w))
n = len(source.rectangles)
self._led_count = n
self._latest_colors: Optional[np.ndarray] = None
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
# ── Public interface (matches ColorStripStream) ──
@property
def led_count(self) -> int:
return self._led_count
@property
def target_fps(self) -> int:
return self._live_stream.target_fps if self._live_stream else 10
@property
def is_animated(self) -> bool:
return True
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._latest_colors
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._processing_loop, daemon=True, name=f"kc-css-{self._source.id[:8]}"
)
self._thread.start()
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
self._latest_colors = None
def update_source(self, source: KeyColorsColorStripSource) -> None:
"""Hot-update source config (rectangles, interpolation, smoothing, brightness)."""
self._source = source
# Recompute rectangle bounds
kc_w, kc_h = KC_WORK_SIZE
self._rect_names = []
self._rect_bounds = []
for rect in source.rectangles:
self._rect_names.append(rect.name)
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)
self._rect_bounds.append((px_y, px_y + px_h, px_x, px_x + px_w))
self._led_count = len(source.rectangles)
# ── Private: processing loop ──
def _processing_loop(self) -> None:
"""Background thread: capture → extract → smooth → cache."""
prev_capture = None
prev_colors_arr: Optional[np.ndarray] = None
frame_time = 1.0 / max(1, self.target_fps)
logger.info(f"KC CSS stream started: {self._source.id} ({len(self._rect_names)} rects)")
with high_resolution_timer():
while self._running:
try:
capture = self._live_stream.get_latest_frame()
if capture is None or capture is prev_capture:
time.sleep(frame_time)
continue
prev_capture = capture
# Read source config (hot-update safe)
src = self._source
calc_fn = _CALC_FNS.get(src.interpolation_mode, calculate_average_color)
# Downsample
small = cv2.resize(capture.image, KC_WORK_SIZE, interpolation=cv2.INTER_AREA)
# Extract colors per rectangle
n = len(self._rect_names)
if n == 0:
time.sleep(frame_time)
continue
colors_arr = np.empty((n, 3), dtype=np.float64)
for i, (y1, y2, x1, x2) in enumerate(self._rect_bounds):
colors_arr[i] = calc_fn(small[y1:y2, x1:x2])
# Temporal smoothing
smoothing = src.smoothing
if (
prev_colors_arr is not None
and smoothing > 0
and prev_colors_arr.shape == colors_arr.shape
):
colors_arr = colors_arr * (1 - smoothing) + prev_colors_arr * smoothing
prev_colors_arr = colors_arr
# Apply brightness
brightness = src.brightness
if brightness < 1.0:
output = colors_arr * brightness
else:
output = colors_arr
result = np.clip(output, 0, 255).astype(np.uint8)
with self._colors_lock:
self._latest_colors = result
time.sleep(frame_time)
except Exception as e:
logger.error(f"KC CSS stream error: {e}")
time.sleep(0.5)
logger.info(f"KC CSS stream stopped: {self._source.id}")
@@ -21,7 +21,6 @@ from wled_controller.core.processing.target_processor import (
TargetProcessor,
)
from wled_controller.core.processing.wled_target_processor import WledTargetProcessor
from wled_controller.core.processing.kc_target_processor import KCTargetProcessor
from wled_controller.core.processing.auto_restart import (
AutoRestartMixin,
RestartState as _RestartState,
@@ -36,7 +35,6 @@ from wled_controller.storage.color_strip_processing_template_store import (
)
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.template_store import TemplateStore
@@ -63,7 +61,6 @@ class ProcessorDependencies:
picture_source_store: Optional[PictureSourceStore] = None
capture_template_store: Optional[TemplateStore] = None
pp_template_store: Optional[PostprocessingTemplateStore] = None
pattern_template_store: Optional[PatternTemplateStore] = None
device_store: Optional[DeviceStore] = None
color_strip_store: Optional[ColorStripStore] = None
audio_source_store: Optional[AudioSourceStore] = None
@@ -129,7 +126,6 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
self._picture_source_store = deps.picture_source_store
self._capture_template_store = deps.capture_template_store
self._pp_template_store = deps.pp_template_store
self._pattern_template_store = deps.pattern_template_store
self._device_store = deps.device_store
self._color_strip_store = deps.color_strip_store
self._audio_source_store = deps.audio_source_store
@@ -202,7 +198,6 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
picture_source_store=self._picture_source_store,
capture_template_store=self._capture_template_store,
pp_template_store=self._pp_template_store,
pattern_template_store=self._pattern_template_store,
device_store=self._device_store,
color_strip_stream_manager=self._color_strip_stream_manager,
value_stream_manager=self._value_stream_manager,
@@ -456,20 +451,6 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
self._processors[target_id] = proc
logger.info(f"Registered target {target_id} for device {device_id}")
def add_kc_target(self, target_id: str, picture_source_id: str, settings) -> None:
"""Register a key-colors target processor."""
if target_id in self._processors:
raise ValueError(f"KC target {target_id} already registered")
proc = KCTargetProcessor(
target_id=target_id,
picture_source_id=picture_source_id,
settings=settings,
ctx=self._build_context(),
)
self._processors[target_id] = proc
logger.info(f"Registered KC target: {target_id}")
def add_ha_light_target(
self,
target_id: str,
@@ -795,15 +776,6 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
proc = self._get_processor(target_id)
proc.add_ws_client(ws)
def remove_kc_ws_client(self, target_id: str, ws) -> None:
proc = self._processors.get(target_id)
if proc:
proc.remove_ws_client(ws)
def get_kc_latest_colors(self, target_id: str) -> Dict[str, Tuple[int, int, int]]:
proc = self._get_processor(target_id)
return proc.get_latest_colors()
def add_led_preview_client(self, target_id: str, ws) -> None:
proc = self._get_processor(target_id)
proc.add_led_preview_client(ws)
@@ -25,7 +25,6 @@ if TYPE_CHECKING:
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
)
@@ -115,7 +114,6 @@ class TargetContext:
picture_source_store: Optional["PictureSourceStore"] = None
capture_template_store: Optional["TemplateStore"] = None
pp_template_store: Optional["PostprocessingTemplateStore"] = None
pattern_template_store: Optional["PatternTemplateStore"] = None
device_store: Optional["DeviceStore"] = None
color_strip_stream_manager: Optional["ColorStripStreamManager"] = None
value_stream_manager: Optional["ValueStreamManager"] = None
+1 -4
View File
@@ -23,7 +23,6 @@ from wled_controller.core.processing.processor_manager import (
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore
@@ -77,7 +76,6 @@ template_store = TemplateStore(db)
pp_template_store = PostprocessingTemplateStore(db)
picture_source_store = PictureSourceStore(db)
output_target_store = OutputTargetStore(db)
pattern_template_store = PatternTemplateStore(db)
color_strip_store = ColorStripStore(db)
audio_source_store = AudioSourceStore(db)
audio_template_store = AudioTemplateStore(db)
@@ -105,7 +103,6 @@ processor_manager = ProcessorManager(
picture_source_store=picture_source_store,
capture_template_store=template_store,
pp_template_store=pp_template_store,
pattern_template_store=pattern_template_store,
device_store=device_store,
color_strip_store=color_strip_store,
audio_source_store=audio_source_store,
@@ -195,7 +192,6 @@ async def lifespan(app: FastAPI):
processor_manager,
database=db,
pp_template_store=pp_template_store,
pattern_template_store=pattern_template_store,
picture_source_store=picture_source_store,
output_target_store=output_target_store,
color_strip_store=color_strip_store,
@@ -237,6 +233,7 @@ 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
@@ -134,6 +134,11 @@
line-height: 1;
}
.btn-micro .icon {
width: 12px;
height: 12px;
}
.btn-micro:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--border-color);
@@ -83,8 +83,18 @@
border: none;
color: var(--danger-color, #dc3545);
cursor: pointer;
font-size: 1rem;
padding: 2px 6px;
opacity: 0.7;
transition: opacity 0.15s;
}
.btn-remove-condition:hover {
opacity: 1;
}
.btn-remove-condition .icon {
width: 16px;
height: 16px;
}
.condition-fields {
@@ -453,7 +453,6 @@ body.cs-drag-active .card-drag-handle {
background: none;
border: none;
color: var(--text-muted);
font-size: 1rem;
width: 28px;
height: 28px;
display: flex;
@@ -464,6 +463,11 @@ body.cs-drag-active .card-drag-handle {
transition: color 0.2s, background 0.2s;
}
.card-remove-btn .icon {
width: 16px;
height: 16px;
}
.card-remove-btn:hover {
color: var(--danger-color);
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
+198 -39
View File
@@ -956,11 +956,15 @@
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 0 4px;
line-height: 1;
}
.btn-icon-inline .icon {
width: 16px;
height: 16px;
}
.btn-danger-text {
color: var(--danger-color, #f44336);
}
@@ -1474,13 +1478,17 @@
}
.gradient-stop-remove-btn {
font-size: 0.75rem;
padding: 0;
width: 26px;
height: 26px;
flex: 0 0 26px;
}
.gradient-stop-remove-btn .icon {
width: 14px;
height: 14px;
}
.gradient-stop-bidir-btn.active {
background: var(--primary-color);
color: var(--primary-contrast);
@@ -1492,6 +1500,89 @@
flex: 1;
}
/* ── HA Light Mapping rows ────────────────────────────────── */
#ha-light-mappings-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
.ha-light-mapping-row {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
background: var(--bg-secondary, var(--bg-color));
}
.ha-mapping-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.ha-mapping-header .ha-mapping-label {
font-weight: 600;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
}
.ha-mapping-header .ha-mapping-label .icon {
width: 16px;
height: 16px;
opacity: 0.7;
}
.btn-remove-mapping {
background: none;
border: none;
color: var(--danger-color, #dc3545);
cursor: pointer;
padding: 2px 6px;
opacity: 0.7;
transition: opacity 0.15s;
}
.btn-remove-mapping .icon {
width: 16px;
height: 16px;
}
.btn-remove-mapping:hover {
opacity: 1;
}
.ha-mapping-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.ha-mapping-field label {
display: block;
font-size: 0.85rem;
margin-bottom: 3px;
color: var(--text-muted);
}
.ha-mapping-range-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.ha-mapping-range-row label {
display: block;
font-size: 0.85rem;
margin-bottom: 3px;
color: var(--text-muted);
}
/* ── Custom gradient presets list ───────────────────────────── */
.custom-presets-list {
@@ -1547,7 +1638,6 @@
}
.color-cycle-remove-btn {
font-size: 0.6rem;
padding: 0;
width: 36px;
height: 14px;
@@ -1555,6 +1645,11 @@
line-height: 1;
}
.color-cycle-remove-btn .icon {
width: 12px;
height: 12px;
}
.color-cycle-add-btn {
width: 36px;
height: 28px;
@@ -1566,31 +1661,79 @@
/* ── Notification per-app overrides (unified color + sound) ──── */
.notif-override-row {
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 4px 4px;
align-items: center;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
#notification-app-overrides-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
.notif-override-row .notif-override-name,
.notif-override-row .notif-override-sound {
.notif-override-row {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
background: var(--bg-secondary, var(--bg-color));
}
.notif-override-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.notif-override-label {
font-weight: 600;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
}
.notif-override-label .icon {
width: 16px;
height: 16px;
opacity: 0.7;
}
.btn-remove-override {
background: none;
border: none;
color: var(--danger-color, #dc3545);
cursor: pointer;
padding: 2px 6px;
opacity: 0.7;
transition: opacity 0.15s;
}
.btn-remove-override:hover {
opacity: 1;
}
.btn-remove-override .icon {
width: 16px;
height: 16px;
}
.notif-override-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.notif-override-app-row {
display: flex;
align-items: center;
gap: 6px;
}
.notif-override-app-row .notif-override-name {
flex: 1;
min-width: 0;
}
/* Sound select spans the first column, volume spans browse+color columns */
.notif-override-row .notif-override-sound {
grid-column: 1;
}
.notif-override-row .notif-override-volume {
grid-column: 2 / 4;
width: 100%;
}
.notif-override-row .notif-override-color {
.notif-override-app-row .notif-override-color {
width: 26px;
height: 26px;
border: 1px solid var(--border-color);
@@ -1598,6 +1741,24 @@
padding: 1px;
cursor: pointer;
background: transparent;
flex-shrink: 0;
}
.notif-override-sound-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
align-items: center;
}
.notif-override-sound-row .notif-override-sound,
.notif-override-sound-row .entity-select-trigger {
min-width: 0;
width: 100%;
}
.notif-override-sound-row .notif-override-volume {
width: 100%;
}
@@ -1671,7 +1832,7 @@
#composite-layers-list {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
margin-bottom: 8px;
}
@@ -1679,10 +1840,10 @@
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 8px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-bg);
border-radius: 6px;
background: var(--bg-secondary, var(--bg-color));
}
.composite-layer-header {
@@ -1802,22 +1963,20 @@
.composite-layer-remove-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.85rem;
width: 26px;
height: 26px;
flex: 0 0 26px;
display: flex;
align-items: center;
justify-content: center;
color: var(--danger-color, #dc3545);
cursor: pointer;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
padding: 2px 6px;
opacity: 0.7;
transition: opacity 0.15s;
}
.composite-layer-remove-btn .icon {
width: 16px;
height: 16px;
}
.composite-layer-remove-btn:hover {
color: var(--danger-color);
background: color-mix(in srgb, var(--danger-color) 10%, transparent);
opacity: 1;
}
.composite-layer-range-toggle-label {
@@ -348,7 +348,6 @@
background: none;
border: none;
color: var(--text-muted);
font-size: 0.9rem;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
@@ -356,6 +355,11 @@
transition: color 0.2s, background 0.2s;
}
.pattern-rect-row .pattern-rect-remove-btn .icon {
width: 14px;
height: 14px;
}
.pattern-rect-row .pattern-rect-remove-btn:hover {
color: var(--danger-color);
background: rgba(244, 67, 54, 0.1);
+3 -30
View File
@@ -73,20 +73,11 @@ import {
renderCSPTModalFilterList,
} from './features/streams.ts';
import {
createKCTargetCard, testKCTarget,
showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor,
deleteKCTarget, disconnectAllKCWebSockets,
updateKCBrightnessLabel, saveKCBrightness,
cloneKCTarget,
} from './features/kc-targets.ts';
import {
createPatternTemplateCard,
showPatternTemplateEditor, closePatternTemplateModal, forceClosePatternTemplateModal,
savePatternTemplate, deletePatternTemplate,
savePatternTemplate,
renderPatternRectList, selectPatternRect, updatePatternRect,
addPatternRect, deleteSelectedPatternRect, removePatternRect,
capturePatternBackground,
clonePatternTemplate,
} from './features/pattern-templates.ts';
import {
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
@@ -109,7 +100,7 @@ import {
loadTargetsTab, switchTargetSubTab,
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
startTargetProcessing, stopTargetProcessing,
stopAllLedTargets, stopAllKCTargets,
stopAllLedTargets,
startTargetOverlay, stopTargetOverlay, deleteTarget,
cloneTarget, toggleLedPreview,
disconnectAllLedPreviewWS,
@@ -357,26 +348,11 @@ Object.assign(window, {
closeTestAudioTemplateModal,
startAudioTemplateTest,
// kc-targets
createKCTargetCard,
testKCTarget,
showKCEditor,
closeKCEditorModal,
forceCloseKCEditorModal,
saveKCEditor,
deleteKCTarget,
disconnectAllKCWebSockets,
updateKCBrightnessLabel,
saveKCBrightness,
cloneKCTarget,
// pattern-templates
createPatternTemplateCard,
// pattern-templates (canvas editor — used by key_colors CSS source)
showPatternTemplateEditor,
closePatternTemplateModal,
forceClosePatternTemplateModal,
savePatternTemplate,
deletePatternTemplate,
renderPatternRectList,
selectPatternRect,
updatePatternRect,
@@ -384,7 +360,6 @@ Object.assign(window, {
deleteSelectedPatternRect,
removePatternRect,
capturePatternBackground,
clonePatternTemplate,
// automations
loadAutomations,
@@ -427,7 +402,6 @@ Object.assign(window, {
startTargetProcessing,
stopTargetProcessing,
stopAllLedTargets,
stopAllKCTargets,
startTargetOverlay,
stopTargetOverlay,
deleteTarget,
@@ -642,7 +616,6 @@ window.addEventListener('beforeunload', () => {
}
stopConnectionMonitor();
stopEventsWS();
disconnectAllKCWebSockets();
disconnectAllLedPreviewWS();
});
@@ -20,6 +20,7 @@
*/
import { createColorPicker, registerColorPicker } from './color-picker.ts';
import { ICON_TRASH } from './icons.ts';
const STORAGE_KEY = 'cardColors';
const DEFAULT_SWATCH = '#808080';
@@ -115,7 +116,7 @@ export function wrapCard({
<div class="${type}${classes ? ' ' + classes : ''}" ${dataAttr}="${id}"${colorStyle ? ` style="${colorStyle}" data-has-color="1"` : ''}>
<div class="card-top-actions">
${topButtons}
${removeOnclick ? `<button class="card-remove-btn" onclick="${removeOnclick}" title="${removeTitle}">&#x2715;</button>` : ''}
${removeOnclick ? `<button class="card-remove-btn" onclick="${removeOnclick}" title="${removeTitle}">${ICON_TRASH}</button>` : ''}
</div>
${content}
<div class="${actionsClass}">
@@ -28,6 +28,7 @@ const _colorStripTypeIcons = {
candlelight: _svg(P.flame),
weather: _svg(P.cloudSun),
processed: _svg(P.sparkles),
key_colors: _svg(P.palette),
};
const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
@@ -11,7 +11,7 @@ import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { getPictureSourceIcon } from '../core/icons.ts';
import { getPictureSourceIcon, ICON_TRASH } from '../core/icons.ts';
import type { Calibration, CalibrationLine, PictureSource } from '../types.ts';
/* ── Types ──────────────────────────────────────────────────── */
@@ -455,7 +455,7 @@ function _renderLineList(): void {
<span class="advcal-line-actions">
<button class="btn-micro" onclick="event.stopPropagation(); moveCalibrationLine(${i}, -1)" title="Move up" ${i === 0 ? 'disabled' : ''}>&#x25B2;</button>
<button class="btn-micro" onclick="event.stopPropagation(); moveCalibrationLine(${i}, 1)" title="Move down" ${i === _state.lines.length - 1 ? 'disabled' : ''}>&#x25BC;</button>
<button class="btn-micro btn-danger" onclick="event.stopPropagation(); removeCalibrationLine(${i})" title="Remove">&#x2715;</button>
<button class="btn-micro btn-danger" onclick="event.stopPropagation(); removeCalibrationLine(${i})" title="Remove">${ICON_TRASH}</button>
</span>
`;
container.appendChild(div);
@@ -8,7 +8,7 @@ import { _cachedValueSources, _cachedCSPTemplates } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import {
getColorStripIcon, getValueSourceIcon,
ICON_SPARKLES,
ICON_SPARKLES, ICON_TRASH,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
@@ -124,7 +124,7 @@ export function compositeRenderList() {
</label>
${canRemove
? `<button type="button" class="composite-layer-remove-btn"
onclick="compositeRemoveLayer(${i})" title="${t('common.delete')}">&#x2715;</button>`
onclick="compositeRemoveLayer(${i})" title="${t('common.delete')}">${ICON_TRASH}</button>`
: ''}
</div>
<div class="composite-layer-body-wrapper">
@@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import {
ICON_SEARCH, ICON_CLONE, getAssetTypeIcon,
ICON_SEARCH, ICON_CLONE, ICON_TRASH, getAssetTypeIcon,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
@@ -112,17 +112,26 @@ function _overridesRenderList() {
const volPct = entry.volume ?? 100;
return `
<div class="notif-override-row">
<input type="text" class="notif-override-name" data-idx="${i}" value="${escapeHtml(entry.app)}"
placeholder="${t('color_strip.notification.app_overrides.app_placeholder') || 'App name'}">
<button type="button" class="btn btn-icon btn-secondary notif-override-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-override-color" data-idx="${i}" value="${entry.color}">
<button type="button" class="btn btn-icon btn-secondary"
onclick="notificationRemoveAppOverride(${i})">&#x2715;</button>
<select class="notif-override-sound" data-idx="${i}">${soundOpts}</select>
<input type="range" class="notif-override-volume" data-idx="${i}" min="0" max="100" step="5" value="${volPct}"
title="${volPct}%"
oninput="this.title = this.value + '%'">
<div class="notif-override-header">
<span class="notif-override-label">${_icon(P.bellRing)} #${i + 1}</span>
<button type="button" class="btn-remove-override"
onclick="notificationRemoveAppOverride(${i})" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="notif-override-fields">
<div class="notif-override-app-row">
<input type="text" class="notif-override-name" data-idx="${i}" value="${escapeHtml(entry.app)}"
placeholder="${t('color_strip.notification.app_overrides.app_placeholder') || 'App name'}">
<button type="button" class="btn btn-icon btn-secondary notif-override-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-override-color" data-idx="${i}" value="${entry.color}">
</div>
<div class="notif-override-sound-row">
<select class="notif-override-sound" data-idx="${i}">${soundOpts}</select>
<input type="range" class="notif-override-volume" data-idx="${i}" min="0" max="100" step="5" value="${volPct}"
title="${volPct}%"
oninput="this.title = this.value + '%'">
</div>
</div>
</div>`;
}).join('');
@@ -6,7 +6,7 @@
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { colorStripSourcesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { showToast, openLightbox, closeLightbox } from '../core/ui.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import {
getColorStripIcon,
@@ -143,15 +143,145 @@ function _populateCssTestSourceSelector(preselectId: any) {
export function testColorStrip(sourceId: string) {
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
// Detect api_input type
// Detect source type
const sources = (colorStripSourcesCache.data || []) as any[];
const src = sources.find(s => s.id === sourceId);
// Key Colors sources use a frame + rectangle overlay test (not the strip WS renderer)
if (src?.source_type === 'key_colors') {
_testKeyColorsSource(sourceId);
return;
}
_cssTestIsApiInput = src?.source_type === 'api_input';
// Populate input source selector with current source preselected
_populateCssTestSourceSelector(sourceId);
_openTestModal(sourceId);
}
let _kcTestWs: WebSocket | null = null;
const _kcTestCanvas = document.createElement('canvas');
const BORDER_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96e6a1', '#dda0dd', '#f9ca24', '#ff9ff3', '#54a0ff'];
function _testKeyColorsSource(sourceId: string) {
// Show lightbox with spinner
const lightbox = document.getElementById('image-lightbox')!;
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
const img = document.getElementById('lightbox-image') as HTMLImageElement;
img.src = '';
if (spinner) spinner.style.display = '';
document.getElementById('lightbox-stats')!.style.display = 'none';
lightbox.classList.add('active');
// Close any previous WS
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
// Build WS URL
const loc = window.location;
const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
const apiKey = (window as any).apiKey || localStorage.getItem('wled_api_key') || '';
const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?token=${encodeURIComponent(apiKey)}&fps=5&preview_width=960`;
const ws = new WebSocket(wsUrl);
_kcTestWs = ws;
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data.type === 'frame') {
_renderKCTestFrame(data);
}
} catch {}
};
ws.onerror = () => {
showToast('Key Colors test connection failed', 'error');
closeLightbox();
};
ws.onclose = () => {
_kcTestWs = null;
};
// Stop WS when lightbox closes
const origClose = (window as any).closeLightbox;
lightbox.onclick = (e) => {
if ((e.target as HTMLElement).closest('.lightbox-content')) return;
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
closeLightbox();
};
}
function _renderKCTestFrame(data: any) {
const rects = data.rectangles || [];
const mode = data.interpolation_mode || 'average';
// Draw frame + rectangles onto offscreen canvas
const tmpImg = new Image();
tmpImg.onload = () => {
_kcTestCanvas.width = tmpImg.naturalWidth;
_kcTestCanvas.height = tmpImg.naturalHeight;
const ctx = _kcTestCanvas.getContext('2d')!;
ctx.drawImage(tmpImg, 0, 0);
rects.forEach((r: any, i: number) => {
const x = r.x * _kcTestCanvas.width;
const y = r.y * _kcTestCanvas.height;
const w = r.width * _kcTestCanvas.width;
const h = r.height * _kcTestCanvas.height;
const borderColor = BORDER_COLORS[i % BORDER_COLORS.length];
ctx.fillStyle = r.color.hex + '33';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = borderColor;
ctx.lineWidth = 3;
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px sans-serif';
ctx.shadowColor = '#000';
ctx.shadowBlur = 3;
ctx.fillText(r.name, x + 4, y + 18);
ctx.shadowBlur = 0;
ctx.fillStyle = r.color.hex;
ctx.fillRect(x + w - 24, y + 2, 22, 22);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(x + w - 24, y + 2, 22, 22);
});
// Update lightbox image directly (use data URL for full-size display)
const lbImg = document.getElementById('lightbox-image') as HTMLImageElement;
if (lbImg) {
lbImg.src = _kcTestCanvas.toDataURL('image/jpeg', 0.9);
lbImg.style.display = '';
lbImg.style.maxWidth = '100%';
lbImg.style.width = '100%';
}
// Hide spinner after first frame
const spinner = document.querySelector('#image-lightbox .lightbox-spinner') as HTMLElement | null;
if (spinner) spinner.style.display = 'none';
// Update swatches
const statsEl = document.getElementById('lightbox-stats')!;
const swatches = rects.map((r: any) =>
`<div style="display:inline-flex;align-items:center;gap:6px;margin:4px 8px;">
<span style="display:inline-block;width:20px;height:20px;background:${r.color.hex};border:1px solid #888;border-radius:3px;"></span>
<span>${escapeHtml(r.name)}</span>
<small style="opacity:0.6;">${r.color.hex}</small>
</div>`
).join('');
statsEl.innerHTML = `
<div style="display:flex;flex-wrap:wrap;justify-content:center;">${swatches}</div>
<div style="margin-top:4px;opacity:0.6;text-align:center;">Mode: ${mode} | ${rects.length} region${rects.length !== 1 ? 's' : ''}</div>
`;
statsEl.style.display = '';
};
tmpImg.src = data.image;
}
export async function testCSPT(templateId: string) {
_cssTestCSPTMode = true;
_cssTestCSPTId = templateId;
@@ -13,7 +13,7 @@ import {
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST,
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER,
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_TRASH, ICON_PATTERN_TEMPLATE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
@@ -58,6 +58,8 @@ class CSSEditorModal extends Modal {
onForceClose() {
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; }
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
compositeDestroyEntitySelects();
}
@@ -110,6 +112,7 @@ class CSSEditorModal extends Modal {
candlelight_speed: (document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value,
processed_input: (document.getElementById('css-editor-processed-input') as HTMLInputElement).value,
processed_template: (document.getElementById('css-editor-processed-template') as HTMLInputElement).value,
kc_rects: JSON.stringify(_kcEditorRects),
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
};
}
@@ -125,13 +128,79 @@ let _cssAudioSourceEntitySelect: any = null;
let _cssClockEntitySelect: any = null;
let _processedInputEntitySelect: any = null;
let _processedTemplateEntitySelect: any = null;
let _kcPictureSourceEntitySelect: any = null;
let _kcInterpolationIconSelect: any = null;
// ── Key Colors rectangle editor state ──
let _kcEditorRects: Array<{ name: string; x: number; y: number; width: number; height: number }> = [];
function _renderKCRectSummary(): void {
const el = document.getElementById('css-editor-kc-rect-summary');
if (!el) return;
if (_kcEditorRects.length === 0) {
el.textContent = t('color_strip.key_colors.no_rects');
} else {
const names = _kcEditorRects.map(r => r.name).join(', ');
el.textContent = `${_kcEditorRects.length} region${_kcEditorRects.length !== 1 ? 's' : ''}: ${names}`;
}
}
function _openKCRegionEditor(): void {
// Open the pattern template canvas editor in inline mode
const { showPatternTemplateEditor } = window as any;
if (!showPatternTemplateEditor) return;
showPatternTemplateEditor(null, null, {
rects: _kcEditorRects.map(r => ({ ...r })),
onSave: (rects: any[]) => {
_kcEditorRects = rects;
_renderKCRectSummary();
},
});
}
(window as any)._openKCRegionEditor = _openKCRegionEditor;
async function configureKCRegions(sourceId: string): Promise<void> {
// Fetch source to get current rectangles
try {
const resp = await fetchWithAuth(`/color-strip-sources/${sourceId}`);
if (!resp.ok) throw new Error('Failed to load source');
const source = await resp.json();
const rects = source.rectangles || [];
const { showPatternTemplateEditor } = window as any;
if (!showPatternTemplateEditor) return;
showPatternTemplateEditor(null, null, {
rects: rects.map((r: any) => ({ ...r })),
onSave: async (newRects: any[]) => {
// Save rectangles back to the CSS source
try {
const putResp = await fetchWithAuth(`/color-strip-sources/${sourceId}`, {
method: 'PUT',
body: JSON.stringify({ rectangles: newRects }),
});
if (!putResp.ok) throw new Error('Failed to save');
showToast(t('color_strip.updated'), 'success');
colorStripSourcesCache.invalidate();
if (window.loadPictureSources) await window.loadPictureSources();
} catch (e: any) {
showToast(e.message, 'error');
}
},
});
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
(window as any).configureKCRegions = configureKCRegions;
/* ── Icon-grid type selector ──────────────────────────────────── */
const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
];
function _buildCSSTypeItems() {
@@ -178,6 +247,7 @@ const CSS_SECTION_MAP: Record<string, string> = {
'candlelight': 'css-editor-candlelight-section',
'weather': 'css-editor-weather-section',
'processed': 'css-editor-processed-section',
'key_colors': 'css-editor-key-colors-section',
};
const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))];
@@ -554,7 +624,7 @@ function _renderCustomPresetList() {
title="${t('color_strip.gradient.preset.apply')}">&#x2713;</button>
<button type="button" class="btn btn-icon btn-sm btn-danger"
onclick="deleteAndRefreshGradientPreset('${g.id}')"
title="${t('common.delete')}">&#x2715;</button>
title="${t('common.delete')}">${ICON_TRASH}</button>
</div>`;
}).join('');
}
@@ -710,7 +780,7 @@ function _colorCycleRenderList() {
<input type="color" value="${hex}">
${canRemove
? `<button type="button" class="btn btn-secondary color-cycle-remove-btn"
onclick="colorCycleRemoveColor(${i})">&#x2715;</button>`
onclick="colorCycleRemoveColor(${i})">${ICON_TRASH}</button>`
: `<div style="height:14px"></div>`}
</div>
`).join('') + `<div class="color-cycle-item"><button type="button" class="btn btn-secondary color-cycle-add-btn" onclick="colorCycleAddColor()">+</button></div>`;
@@ -778,7 +848,7 @@ function _mappedRenderList() {
<div class="segment-row-header">
<span class="segment-index-label">#${i + 1}</span>
<button type="button" class="btn-icon-inline btn-danger-text"
onclick="mappedRemoveZone(${i})" title="${t('common.delete')}">&times;</button>
onclick="mappedRemoveZone(${i})" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="segment-row-fields">
<select class="mapped-zone-source" data-idx="${i}">${srcOptions}</select>
@@ -969,7 +1039,7 @@ type CardPropsRenderer = (source: ColorStripSource, opts: {
const NON_PICTURE_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed',
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
]);
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
@@ -1115,6 +1185,20 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
${clockBadge}
`;
},
key_colors: (source, { pictureSourceMap }) => {
const rectCount = (source.rectangles || []).length;
const mode = source.interpolation_mode || 'average';
const ps = pictureSourceMap && source.picture_source_id ? pictureSourceMap[source.picture_source_id] : null;
const psName = ps?.name || '—';
const psLink = ps
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','raw','raw-streams','data-stream-id','${source.picture_source_id}')`
: '';
return `
<span class="stream-card-prop${psLink}" title="${t('color_strip.key_colors.picture_source')}">${ICON_LINK_SOURCE} ${escapeHtml(psName)}</span>
<span class="stream-card-prop">${ICON_PALETTE} ${rectCount} region${rectCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop">${mode}</span>
`;
},
processed: (source) => {
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
const inputName = inputSrc?.name || source.input_source_id || '—';
@@ -1195,6 +1279,10 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
const notifHistoryBtn = isNotification
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
: '';
const isKeyColors = source.source_type === 'key_colors';
const regionsBtn = isKeyColors
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); configureKCRegions('${source.id}')" title="${t('color_strip.key_colors.configure_regions')}">${ICON_PATTERN_TEMPLATE}</button>`
: '';
const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
return wrapCard({
@@ -1215,7 +1303,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
${calibrationBtn}${overlayBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
${calibrationBtn}${overlayBtn}${regionsBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
});
}
@@ -1658,6 +1746,97 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
};
},
},
key_colors: {
async load(css) {
// Populate and wire picture source EntitySelect
const sourceSelect = document.getElementById('css-editor-kc-picture-source') as HTMLSelectElement;
const sources = await streamsCache.fetch().catch((): any[] => []);
sourceSelect.innerHTML = sources.map((s: any) =>
`<option value="${s.id}" ${s.id === (css.picture_source_id || '') ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_kcPictureSourceEntitySelect) _kcPictureSourceEntitySelect.destroy();
_kcPictureSourceEntitySelect = new EntitySelect({
target: sourceSelect,
getItems: () => sources.map((s: any) => ({ value: s.id, label: s.name, icon: getPictureSourceIcon(s.stream_type), desc: s.stream_type })),
placeholder: t('palette.search'),
});
// Wire interpolation mode IconSelect
const interpSelect = document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement;
interpSelect.value = css.interpolation_mode || 'average';
if (_kcInterpolationIconSelect) _kcInterpolationIconSelect.destroy();
_kcInterpolationIconSelect = new IconSelect({
target: interpSelect,
items: [
{ value: 'average', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.average'), desc: t('color_strip.key_colors.mode.average.desc') },
{ value: 'median', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.median'), desc: t('color_strip.key_colors.mode.median.desc') },
{ value: 'dominant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.dominant'), desc: t('color_strip.key_colors.mode.dominant.desc') },
],
columns: 1,
});
const smoothing = css.smoothing ?? 0.3;
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = smoothing;
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = css.brightness ?? 1.0;
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = parseFloat(css.brightness ?? 1.0).toFixed(2);
// Load rectangles
_kcEditorRects = (css.rectangles || []).map((r: any) => ({ ...r }));
_renderKCRectSummary();
},
async reset() {
const sourceSelect = document.getElementById('css-editor-kc-picture-source') as HTMLSelectElement;
const sources = await streamsCache.fetch().catch((): any[] => []);
sourceSelect.innerHTML = sources.map((s: any) =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
if (_kcPictureSourceEntitySelect) _kcPictureSourceEntitySelect.destroy();
_kcPictureSourceEntitySelect = new EntitySelect({
target: sourceSelect,
getItems: () => sources.map((s: any) => ({ value: s.id, label: s.name, icon: getPictureSourceIcon(s.stream_type), desc: s.stream_type })),
placeholder: t('palette.search'),
});
const interpSelect = document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement;
interpSelect.value = 'average';
if (_kcInterpolationIconSelect) _kcInterpolationIconSelect.destroy();
_kcInterpolationIconSelect = new IconSelect({
target: interpSelect,
items: [
{ value: 'average', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.average'), desc: t('color_strip.key_colors.mode.average.desc') },
{ value: 'median', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.median'), desc: t('color_strip.key_colors.mode.median.desc') },
{ value: 'dominant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.dominant'), desc: t('color_strip.key_colors.mode.dominant.desc') },
],
columns: 1,
});
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = 0.3 as any;
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = '0.30';
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = '1.00';
_kcEditorRects = [{ name: 'Region 1', x: 0.0, y: 0.0, width: 1.0, height: 1.0 }];
_renderKCRectSummary();
},
getPayload(name) {
const psId = (document.getElementById('css-editor-kc-picture-source') as HTMLSelectElement).value;
if (!psId) {
cssEditorModal.showError(t('color_strip.key_colors.error.no_source'));
return null;
}
if (_kcEditorRects.length === 0) {
cssEditorModal.showError(t('color_strip.key_colors.error.no_rects'));
return null;
}
return {
name,
picture_source_id: psId,
rectangles: _kcEditorRects.map(r => ({ name: r.name, x: r.x, y: r.y, width: r.width, height: r.height })),
interpolation_mode: (document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement).value,
smoothing: parseFloat((document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value),
brightness: parseFloat((document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value),
};
},
},
};
/* ── Editor open/close ────────────────────────────────────────── */
@@ -6,6 +6,7 @@
*/
import { t } from '../core/i18n.ts';
import { ICON_TRASH } from '../core/icons.ts';
/* ── Types ─────────────────────────────────────────────────────── */
@@ -304,7 +305,7 @@ function _gradientRenderStopList(): void {
style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color">
<span class="gradient-stop-spacer"></span>
<button type="button" class="btn btn-sm btn-danger gradient-stop-remove-btn"
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}></button>
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>${ICON_TRASH}</button>
`;
// Select row on mousedown — CSS-only update so child click events are not interrupted
@@ -2,17 +2,16 @@
* HA Light Targets editor, cards, CRUD for Home Assistant light output targets.
*/
import { _cachedHASources, haSourcesCache, colorStripSourcesCache, outputTargetsCache } from '../core/state.ts';
import { _cachedHASources, _cachedValueSources, haSourcesCache, colorStripSourcesCache, outputTargetsCache, valueSourcesCache } from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH } from '../core/icons.ts';
import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { getColorStripIcon } from '../core/icons.ts';
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -22,6 +21,7 @@ const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _haLightTagsInput: TagInput | null = null;
let _haSourceEntitySelect: EntitySelect | null = null;
let _cssSourceEntitySelect: EntitySelect | null = null;
let _brightnessVsEntitySelect: EntitySelect | null = null;
let _mappingEntitySelects: EntitySelect[] = [];
let _editorCssSources: any[] = [];
let _cachedHAEntities: any[] = []; // fetched from selected HA source
@@ -33,6 +33,7 @@ class HALightEditorModal extends Modal {
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; }
_destroyMappingEntitySelects();
}
@@ -207,6 +208,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
const [haSources, cssSources] = await Promise.all([
haSourcesCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch().catch(() => {}),
]);
_editorCssSources = cssSources;
@@ -307,6 +309,23 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
placeholder: t('palette.search'),
});
// Brightness value source
const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement;
bvsSelect.innerHTML = `<option value="">${t('targets.brightness_vs.none')}</option>` +
_cachedValueSources.map((vs: any) =>
`<option value="${vs.id}" ${vs.id === (editData?.brightness_value_source_id || '') ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
).join('');
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
_brightnessVsEntitySelect = new EntitySelect({
target: bvsSelect,
getItems: () => _cachedValueSources.map((vs: any) => ({
value: vs.id, label: vs.name, icon: getValueSourceIcon(vs.source_type), desc: vs.source_type,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('targets.brightness_vs.none'),
});
// Tags
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
_haLightTagsInput = new TagInput(document.getElementById('ha-light-tags-container'), { placeholder: t('tags.placeholder') });
@@ -343,10 +362,13 @@ export async function saveHALightEditor(): Promise<void> {
// Collect mappings
const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id);
const brightnessVsId = (document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement).value;
const payload: any = {
name,
ha_source_id: haSourceId,
color_strip_source_id: cssSourceId,
brightness_value_source_id: brightnessVsId,
ha_light_mappings: mappings,
update_rate: updateRate,
transition,
@@ -407,13 +429,28 @@ export async function cloneHALightTarget(targetId: string): Promise<void> {
// ── Card rendering ──
export function createHALightTargetCard(target: any, haSourceMap: Record<string, any> = {}, cssSourceMap: Record<string, any> = {}): string {
export function createHALightTargetCard(target: any, haSourceMap: Record<string, any> = {}, cssSourceMap: Record<string, any> = {}, valueSourceMap: Record<string, any> = {}): string {
const haSource = haSourceMap[target.ha_source_id];
const cssSource = cssSourceMap[target.color_strip_source_id];
const haName = haSource ? escapeHtml(haSource.name) : target.ha_source_id || '—';
const cssName = cssSource ? escapeHtml(cssSource.name) : target.color_strip_source_id || '—';
const cssId = target.color_strip_source_id;
const cssName = cssSource ? escapeHtml(cssSource.name) : cssId || '—';
const mappingCount = target.ha_light_mappings?.length || 0;
const isRunning = target.state?.processing;
const state = target.state || {};
const metrics = target.metrics || {};
// Crosslinks
const haLink = haSource
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','home_assistant','ha-sources','data-id','${target.ha_source_id}')`
: '';
const cssLink = cssSource
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')`
: '';
// Brightness value source
const bvsId = target.brightness_value_source_id || '';
const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null;
return wrapCard({
type: 'card',
@@ -426,13 +463,32 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
<span class="card-title-text">${ICON_HA} ${escapeHtml(target.name)}</span>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${ICON_HA} ${haName}</span>
${cssName !== '—' ? `<span class="stream-card-prop">${_icon(P.palette)} ${cssName}</span>` : ''}
<span class="stream-card-prop${haLink}" title="HA Connection">${ICON_HA} ${haName}</span>
${cssName !== '—' ? `<span class="stream-card-prop${cssLink}" title="${t('targets.color_strip_source')}">${cssSource ? getColorStripIcon(cssSource.source_type) : _icon(P.palette)} ${cssName}</span>` : ''}
<span class="stream-card-prop">${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop">${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz</span>
${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 || [])}
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''}`,
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''}
<div class="card-content">
${isRunning ? `
<div class="metrics-grid target-metrics-expanded">
<div class="metric">
<div class="metric-label">${t('targets.fps')}</div>
<div class="metric-value" data-tm="fps">${(state.fps_actual ?? target.update_rate ?? 2).toFixed(1)} Hz</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.uptime')}</div>
<div class="metric-value" data-tm="uptime">${metrics.uptime_seconds ? formatUptime(metrics.uptime_seconds) : '---'}</div>
</div>
<div class="metric">
<div class="metric-label">HA</div>
<div class="metric-value" data-tm="ha-status">${state.ha_connected ? ICON_OK : ICON_WARNING}</div>
</div>
</div>
` : ''}
</div>`,
actions: `
<button class="btn btn-icon ${isRunning ? 'btn-danger' : 'btn-primary'}" data-action="${isRunning ? 'stop' : 'start'}" title="${isRunning ? t('targets.stop') : t('targets.start')}">
${isRunning ? ICON_STOP : ICON_START}
@@ -442,6 +498,24 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
});
}
// ── Metrics patching ──
export function patchHALightTargetMetrics(target: any): void {
const card = document.querySelector(`[data-ha-target-id="${target.id}"]`);
if (!card) return;
const state = target.state || {};
const metrics = target.metrics || {};
const fpsEl = card.querySelector('[data-tm="fps"]') as HTMLElement | null;
if (fpsEl) fpsEl.textContent = `${(state.fps_actual ?? 0).toFixed(1)} Hz`;
const uptimeEl = card.querySelector('[data-tm="uptime"]') as HTMLElement | null;
if (uptimeEl) uptimeEl.textContent = metrics.uptime_seconds ? formatUptime(metrics.uptime_seconds) : '---';
const haEl = card.querySelector('[data-tm="ha-status"]') as HTMLElement | null;
if (haEl) haEl.innerHTML = state.ha_connected ? ICON_OK : ICON_WARNING;
}
// ── Event delegation ──
const _haLightActions: Record<string, (id: string) => void> = {
@@ -20,7 +20,7 @@ import { patternTemplatesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.ts';
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TRASH } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { EntitySelect } from '../core/entity-palette.ts';
@@ -29,6 +29,9 @@ import type { PatternTemplate } from '../types.ts';
let _patternBgEntitySelect: EntitySelect | null = null;
let _patternTagsInput: TagInput | null = null;
// When set, save returns rectangles to this callback instead of saving to API
let _inlineCallback: ((rects: any[]) => void) | null = null;
// ── Auto-name ──
let _ptNameManuallyEdited = false;
@@ -98,7 +101,8 @@ export function createPatternTemplateCard(pt: PatternTemplate) {
});
}
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null): Promise<void> {
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null, opts?: { rects?: any[]; onSave?: (rects: any[]) => void }): Promise<void> {
_inlineCallback = opts?.onSave || null;
try {
// Load sources for background capture
const sources = await streamsCache.fetch().catch((): any[] => []);
@@ -150,6 +154,13 @@ export async function showPatternTemplateEditor(templateId: string | null = null
(document.getElementById('pattern-template-modal-title') as HTMLElement).innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r })));
_editorTags = cloneData.tags || [];
} else if (_inlineCallback && opts?.rects) {
// Inline mode: editing rectangles for a CSS source (no name/description)
(document.getElementById('pattern-template-id') as HTMLInputElement).value = '';
(document.getElementById('pattern-template-name') as HTMLInputElement).value = 'Regions';
(document.getElementById('pattern-template-description') as HTMLInputElement).value = '';
(document.getElementById('pattern-template-modal-title') as HTMLElement).innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('color_strip.key_colors.configure_regions')}`;
setPatternEditorRects((opts.rects || []).map((r: any) => ({ ...r })));
} else {
(document.getElementById('pattern-template-id') as HTMLInputElement).value = '';
(document.getElementById('pattern-template-name') as HTMLInputElement).value = '';
@@ -158,15 +169,25 @@ export async function showPatternTemplateEditor(templateId: string | null = null
setPatternEditorRects([]);
}
// Hide name/description/tags fields in inline mode
const nameGroup = document.getElementById('pattern-name-group');
const descGroup = document.getElementById('pattern-desc-group');
const tagsContainer = document.getElementById('pattern-tags-container');
if (nameGroup) nameGroup.style.display = _inlineCallback ? 'none' : '';
if (descGroup) descGroup.style.display = _inlineCallback ? 'none' : '';
if (tagsContainer) tagsContainer.style.display = _inlineCallback ? 'none' : '';
// Tags
if (_patternTagsInput) { _patternTagsInput.destroy(); _patternTagsInput = null; }
_patternTagsInput = new TagInput(document.getElementById('pattern-tags-container'), { placeholder: t('tags.placeholder') });
_patternTagsInput.setValue(_editorTags);
if (!_inlineCallback) {
_patternTagsInput = new TagInput(document.getElementById('pattern-tags-container'), { placeholder: t('tags.placeholder') });
_patternTagsInput.setValue(_editorTags);
}
// Auto-name wiring
_ptNameManuallyEdited = !!(templateId || cloneData);
_ptNameManuallyEdited = !!(templateId || cloneData || _inlineCallback);
(document.getElementById('pattern-template-name') as HTMLElement).oninput = () => { _ptNameManuallyEdited = true; };
if (!templateId && !cloneData) _autoGeneratePatternName();
if (!templateId && !cloneData && !_inlineCallback) _autoGeneratePatternName();
patternModal.snapshot();
@@ -197,6 +218,18 @@ export function forceClosePatternTemplateModal(): void {
}
export async function savePatternTemplate(): Promise<void> {
// Inline mode: return rectangles to callback, don't save to API
if (_inlineCallback) {
const rects = patternEditorRects.map(r => ({
name: r.name, x: r.x, y: r.y, width: r.width, height: r.height,
}));
const cb = _inlineCallback;
_inlineCallback = null;
patternModal.forceClose();
cb(rects);
return;
}
const templateId = (document.getElementById('pattern-template-id') as HTMLInputElement).value;
const name = (document.getElementById('pattern-template-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('pattern-template-description') as HTMLInputElement).value.trim();
@@ -296,7 +329,7 @@ export function renderPatternRectList(): void {
<input type="number" value="${rect.y.toFixed(2)}" min="0" max="1" step="0.01" onchange="updatePatternRect(${i}, 'y', parseFloat(this.value)||0)" onclick="event.stopPropagation()">
<input type="number" value="${rect.width.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'width', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
<input type="number" value="${rect.height.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'height', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
<button type="button" class="pattern-rect-remove-btn" onclick="event.stopPropagation(); removePatternRect(${i})" title="${t('pattern.rect.remove')}">&#x2715;</button>
<button type="button" class="pattern-rect-remove-btn" onclick="event.stopPropagation(); removePatternRect(${i})" title="${t('pattern.rect.remove')}">${ICON_TRASH}</button>
</div>
`).join('');
}
@@ -90,7 +90,7 @@ export function createSceneCard(preset: ScenePreset) {
const colorStyle = cardColorStyle(preset.id);
return `<div class="card" data-scene-id="${preset.id}"${colorStyle ? ` style="${colorStyle}"` : ''}>
<div class="card-top-actions">
<button class="card-remove-btn" data-action="delete-scene" data-id="${preset.id}" title="${t('scenes.delete')}">&#x2715;</button>
<button class="card-remove-btn" data-action="delete-scene" data-id="${preset.id}" title="${t('scenes.delete')}">${ICON_TRASH}</button>
</div>
<div class="card-header">
<div class="card-title" title="${escapeHtml(preset.name)}"><span class="card-title-text">${escapeHtml(preset.name)}</span></div>
@@ -219,7 +219,7 @@ export async function editScenePreset(presetId: string): Promise<void> {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = tid;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
@@ -314,7 +314,7 @@ function _addTargetToList(targetId: string, targetName: string): void {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = targetId;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
list.appendChild(item);
_refreshTargetSelect();
}
@@ -433,7 +433,7 @@ export async function cloneScenePreset(presetId: string): Promise<void> {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = tid;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
@@ -6,11 +6,10 @@ import {
apiKey,
_targetEditorDevices, set_targetEditorDevices,
_deviceBrightnessCache,
kcWebSockets,
ledPreviewWebSockets,
_cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache,
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
colorStripSourcesCache, devicesCache, outputTargetsCache,
_cachedHASources, haSourcesCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts';
@@ -19,8 +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 { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts';
import { createHALightTargetCard, initHALightTargetDelegation } from './ha-light-targets.ts';
import { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics } from './ha-light-targets.ts';
import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
@@ -83,16 +81,6 @@ async function _bulkDeleteDevices(ids: any) {
await loadTargetsTab();
}
async function _bulkDeletePatternTemplates(ids: any) {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/pattern-templates/${id}`, { method: 'DELETE' })
));
const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('targets.deleted'), 'success');
patternTemplatesCache.invalidate();
await loadTargetsTab();
}
const _targetBulkActions = [
{ key: 'start', labelKey: 'bulk.start', icon: ICON_START, handler: _bulkStartTargets },
@@ -105,11 +93,7 @@ const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.de
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteDevices },
] });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csHALightTargets = new CardSection('ha-light-targets', { titleKey: 'ha_light.section.title', gridClass: 'devices-grid', addCardOnclick: "showHALightEditor()", keyAttr: 'data-ha-target-id', emptyKey: 'section.empty.ha_light_targets', bulkActions: _targetBulkActions });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates', bulkActions: [
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeletePatternTemplates },
] });
// Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
@@ -582,8 +566,6 @@ export function switchTargetSubTab(tabKey: any) {
const _targetSectionMap = {
'led-devices': [csDevices],
'led-targets': [csLedTargets],
'kc-targets': [csKCTargets],
'kc-patterns': [csPatternTemplates],
};
let _loadTargetsLock = false;
@@ -599,11 +581,10 @@ export async function loadTargetsTab() {
try {
// Fetch all entities via DataCache
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
const [devices, targets, cssArr, psArr, valueSrcArr, asSrcArr] = await Promise.all([
devicesCache.fetch().catch((): any[] => []),
outputTargetsCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
patternTemplatesCache.fetch().catch((): any[] => []),
streamsCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch().catch((): any[] => []),
audioSourcesCache.fetch().catch((): any[] => []),
@@ -617,9 +598,6 @@ export async function loadTargetsTab() {
let pictureSourceMap = {};
psArr.forEach(s => { pictureSourceMap[s.id] = s; });
let patternTemplateMap = {};
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
let valueSourceMap = {};
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
@@ -661,7 +639,6 @@ export async function loadTargetsTab() {
// Group by type
const ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light');
// Update tab badge with running target count
@@ -679,13 +656,6 @@ export async function loadTargetsTab() {
{ key: 'led-targets', titleKey: 'targets.section.targets', icon: getTargetTypeIcon('led'), count: ledTargets.length },
]
},
{
key: 'kc_group', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors',
children: [
{ key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length },
{ key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length },
]
},
{
key: 'ha_light_group', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'ha_light.section.title',
children: [
@@ -694,21 +664,15 @@ export async function loadTargetsTab() {
}
];
// Determine which tree leaf is active — migrate old values
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns', 'ha-light-targets'];
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab
: activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices';
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
const validLeaves = ['led-devices', 'led-targets', 'ha-light-targets'];
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab : 'led-devices';
// Build items arrays for each section (apply saved drag order)
const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })));
const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })));
const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })));
const haSourceMap: Record<string, any> = {};
_cachedHASources.forEach(s => { haSourceMap[s.id] = s; });
const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap) })));
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap, valueSourceMap) })));
// Track which target cards were replaced/added (need chart re-init)
let changedTargetIds: Set<string> | null = null;
@@ -718,17 +682,12 @@ export async function loadTargetsTab() {
_targetsTree.updateCounts({
'led-devices': ledDevices.length,
'led-targets': ledTargets.length,
'kc-targets': kcTargets.length,
'kc-patterns': patternTemplates.length,
'ha-light-targets': haLightTargets.length,
});
csDevices.reconcile(deviceItems);
const ledResult = csLedTargets.reconcile(ledTargetItems);
const kcResult = csKCTargets.reconcile(kcTargetItems);
csPatternTemplates.reconcile(patternItems);
csHALightTargets.reconcile(haLightTargetItems);
changedTargetIds = new Set<string>([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[]),
...(kcResult.added as unknown as string[]), ...(kcResult.replaced as unknown as string[]), ...(kcResult.removed as unknown as string[])]);
changedTargetIds = new Set<string>([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[])]);
// Restore LED preview state on replaced cards (panel hidden by default in HTML)
for (const id of Array.from(ledResult.replaced) as any[]) {
@@ -741,12 +700,10 @@ export async function loadTargetsTab() {
const panels = [
{ key: 'led-devices', html: csDevices.render(deviceItems) },
{ key: 'led-targets', html: csLedTargets.render(ledTargetItems) },
{ key: 'kc-targets', html: csKCTargets.render(kcTargetItems) },
{ key: 'kc-patterns', html: csPatternTemplates.render(patternItems) },
{ key: 'ha-light-targets', html: csHALightTargets.render(haLightTargetItems) },
].map(p => `<div class="target-sub-tab-panel stream-tab-panel${p.key === activeLeaf ? ' active' : ''}" id="target-sub-tab-${p.key}">${p.html}</div>`).join('');
container.innerHTML = panels;
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates, csHALightTargets]);
CardSection.bindAll([csDevices, csLedTargets, csHALightTargets]);
initHALightTargetDelegation(container);
// Render tree sidebar with expand/collapse buttons
@@ -757,18 +714,15 @@ export async function loadTargetsTab() {
// Show/hide stop-all buttons based on running state
const ledRunning = ledTargets.some(t => t.state && t.state.processing);
const kcRunning = kcTargets.some(t => t.state && t.state.processing);
const ledStopBtn = container.querySelector('[data-stop-all="led"]') as HTMLElement | null;
const kcStopBtn = container.querySelector('[data-stop-all="kc"]') as HTMLElement | null;
if (ledStopBtn) { ledStopBtn.style.display = ledRunning ? '' : 'none'; if (!ledStopBtn.title) { ledStopBtn.title = t('targets.stop_all.button'); ledStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }
if (kcStopBtn) { kcStopBtn.style.display = kcRunning ? '' : 'none'; if (!kcStopBtn.title) { kcStopBtn.title = t('targets.stop_all.button'); kcStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }
// Patch volatile metrics in-place (avoids full card replacement on polls)
for (const tgt of ledTargets) {
if (tgt.state && tgt.state.processing) _patchTargetMetrics(tgt);
}
for (const tgt of kcTargets) {
if (tgt.state && tgt.state.processing) patchKCTargetMetrics(tgt);
for (const tgt of haLightTargets) {
if (tgt.state && tgt.state.processing) patchHALightTargetMetrics(tgt);
}
// Attach event listeners and fetch brightness for device cards
@@ -806,20 +760,6 @@ export async function loadTargetsTab() {
}
}
// Manage KC WebSockets: connect for processing, disconnect for stopped
const processingKCIds = new Set();
kcTargets.forEach(target => {
if (target.state && target.state.processing) {
processingKCIds.add(target.id);
if (!kcWebSockets[target.id]) {
connectKCWebSocket(target.id);
}
}
});
Object.keys(kcWebSockets).forEach(id => {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
// Auto-disconnect LED preview WebSockets for targets that stopped
const processingLedIds = new Set();
ledTargets.forEach(target => {
@@ -847,7 +787,7 @@ export async function loadTargetsTab() {
}
// Push FPS samples and create/update charts for running targets
const allTargets = [...ledTargets, ...kcTargets];
const allTargets = [...ledTargets];
const runningIds = new Set();
const runningTargetIds = allTargets.filter(t => t.state?.processing).map(t => t.id);
@@ -1168,12 +1108,6 @@ export async function stopAllLedTargets() {
await _stopAllByType('led');
}
export async function stopAllKCTargets() {
const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return;
await _stopAllByType('key_colors');
}
async function _stopAllByType(targetType: any) {
try {
const [allTargets, statesResp] = await Promise.all([
@@ -19,7 +19,7 @@ import {
getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST,
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH,
} from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
@@ -897,7 +897,7 @@ export function addSchedulePoint(time: string = '', value: number = 1.0) {
<input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}"
oninput="this.nextElementSibling.textContent = this.value">
<span class="schedule-value-display">${value}</span>
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">&#x2715;</button>
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">${ICON_TRASH}</button>
`;
list.appendChild(row);
_wireScheduleTimePicker(row);
@@ -87,7 +87,7 @@ export type CSSSourceType =
| 'picture' | 'picture_advanced' | 'static' | 'gradient'
| 'color_cycle' | 'effect' | 'composite' | 'mapped'
| 'audio' | 'api_input' | 'notification' | 'daylight'
| 'candlelight' | 'processed';
| 'candlelight' | 'processed' | 'weather' | 'key_colors';
export interface ColorStop {
position: number;
@@ -228,6 +228,11 @@ export interface ColorStripSource {
// Weather
weather_source_id?: string;
temperature_influence?: number;
// Key Colors
rectangles?: KeyColorRectangle[];
brightness?: number;
brightness_value_source_id?: string;
}
// ── Pattern Template ──────────────────────────────────────────
@@ -1167,6 +1167,23 @@
"color_strip.type.candlelight": "Candlelight",
"color_strip.type.candlelight.desc": "Realistic flickering candle simulation",
"color_strip.type.candlelight.hint": "Simulates realistic candle flickering across all LEDs with warm tones and organic flicker patterns.",
"color_strip.type.key_colors": "Key Colors",
"color_strip.type.key_colors.desc": "Extract colors from screen regions",
"color_strip.key_colors.picture_source": "Picture Source:",
"color_strip.key_colors.interpolation": "Color Mode:",
"color_strip.key_colors.smoothing": "Smoothing:",
"color_strip.key_colors.brightness": "Brightness:",
"color_strip.key_colors.rectangles": "Screen Regions:",
"color_strip.key_colors.no_rects": "No regions defined. Click Configure Regions to add.",
"color_strip.key_colors.configure_regions": "Configure Regions",
"color_strip.key_colors.mode.average": "Average",
"color_strip.key_colors.mode.average.desc": "Mean color of all pixels in the region",
"color_strip.key_colors.mode.median": "Median",
"color_strip.key_colors.mode.median.desc": "Median color (less affected by outliers)",
"color_strip.key_colors.mode.dominant": "Dominant",
"color_strip.key_colors.mode.dominant.desc": "Most frequent color (K-means clustering)",
"color_strip.key_colors.error.no_source": "Picture source is required",
"color_strip.key_colors.error.no_rects": "At least one screen region is required",
"color_strip.type.weather": "Weather",
"color_strip.type.weather.desc": "Weather-reactive ambient colors",
"color_strip.type.weather.hint": "Maps real-time weather conditions to ambient LED colors. Requires a Weather Source entity.",
@@ -1,8 +1,7 @@
"""Storage layer for device and configuration persistence."""
from .device_store import DeviceStore
from .pattern_template_store import PatternTemplateStore
from .picture_source_store import PictureSourceStore
from .postprocessing_template_store import PostprocessingTemplateStore
__all__ = ["DeviceStore", "PatternTemplateStore", "PictureSourceStore", "PostprocessingTemplateStore"]
__all__ = ["DeviceStore", "PictureSourceStore", "PostprocessingTemplateStore"]
File diff suppressed because it is too large Load Diff
@@ -44,7 +44,6 @@ _ENTITY_TABLES = [
"postprocessing_templates",
"picture_sources",
"output_targets",
"pattern_templates",
"color_strip_sources",
"audio_sources",
"audio_templates",
@@ -72,10 +72,6 @@ class OutputTarget:
from wled_controller.storage.wled_output_target import WledOutputTarget
return WledOutputTarget.from_dict(data)
if target_type == "key_colors":
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget
return KeyColorsOutputTarget.from_dict(data)
if target_type == "ha_light":
from wled_controller.storage.ha_light_output_target import HALightOutputTarget
@@ -8,10 +8,6 @@ from wled_controller.storage.base_sqlite_store import BaseSqliteStore
from wled_controller.storage.database import Database
from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.key_colors_output_target import (
KeyColorsSettings,
KeyColorsOutputTarget,
)
from wled_controller.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
@@ -50,9 +46,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold: int = 0,
adaptive_fps: bool = False,
protocol: str = "ddp",
key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None,
picture_source_id: str = "",
tags: Optional[List[str]] = None,
ha_source_id: str = "",
ha_light_mappings: Optional[List[HALightMapping]] = None,
@@ -65,7 +59,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
Raises:
ValueError: If validation fails
"""
if target_type not in ("led", "key_colors", "ha_light"):
if target_type not in ("led", "ha_light"):
raise ValueError(f"Invalid target type: {target_type}")
# Check for duplicate name
@@ -94,17 +88,6 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
created_at=now,
updated_at=now,
)
elif target_type == "key_colors":
target = KeyColorsOutputTarget(
id=target_id,
name=name,
target_type="key_colors",
picture_source_id=picture_source_id,
settings=key_colors_settings or KeyColorsSettings(),
description=description,
created_at=now,
updated_at=now,
)
elif target_type == "ha_light":
target = HALightOutputTarget(
id=target_id,
@@ -144,9 +127,13 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold: Optional[int] = None,
adaptive_fps: Optional[bool] = None,
protocol: Optional[str] = None,
key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
ha_source_id: Optional[str] = None,
ha_light_mappings: Optional[List[HALightMapping]] = None,
update_rate: Optional[float] = None,
transition: Optional[float] = None,
color_tolerance: Optional[int] = None,
) -> OutputTarget:
"""Update an output target.
@@ -175,9 +162,13 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps,
protocol=protocol,
key_colors_settings=key_colors_settings,
description=description,
tags=tags,
ha_source_id=ha_source_id,
light_mappings=ha_light_mappings,
update_rate=update_rate,
transition=transition,
color_tolerance=color_tolerance,
)
target.updated_at = datetime.now(timezone.utc)
@@ -194,14 +185,6 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
if isinstance(t, WledOutputTarget) and t.device_id == device_id
]
def get_targets_referencing_source(self, source_id: str) -> List[str]:
"""Return names of KC targets that reference a picture source."""
return [
target.name
for target in self._items.values()
if isinstance(target, KeyColorsOutputTarget) and target.picture_source_id == source_id
]
def get_targets_referencing_css(self, css_id: str) -> List[str]:
"""Return names of targets that reference a color strip source."""
result = []
@@ -191,7 +191,6 @@
{% include 'modals/gradient-editor.html' %}
{% include 'modals/test-css-source.html' %}
{% include 'modals/notification-history.html' %}
{% include 'modals/kc-editor.html' %}
{% include 'modals/pattern-template.html' %}
{% include 'modals/api-key.html' %}
{% include 'modals/confirm.html' %}
@@ -37,6 +37,7 @@
<option value="candlelight" data-i18n="color_strip.type.candlelight">Candlelight</option>
<option value="weather" data-i18n="color_strip.type.weather">Weather</option>
<option value="processed" data-i18n="color_strip.type.processed">Processed</option>
<option value="key_colors" data-i18n="color_strip.type.key_colors">Key Colors</option>
</select>
</div>
@@ -676,6 +677,40 @@
</div>
</div>
<!-- Key Colors section -->
<div id="css-editor-key-colors-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-kc-picture-source" data-i18n="color_strip.key_colors.picture_source">Picture Source:</label>
</div>
<select id="css-editor-kc-picture-source"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-kc-interpolation" data-i18n="color_strip.key_colors.interpolation">Color Mode:</label>
</div>
<select id="css-editor-kc-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="css-editor-kc-smoothing"><span data-i18n="color_strip.key_colors.smoothing">Smoothing:</span> <span id="css-editor-kc-smoothing-val">0.30</span></label>
</div>
<input type="range" id="css-editor-kc-smoothing" min="0" max="1" step="0.05" value="0.3"
oninput="document.getElementById('css-editor-kc-smoothing-val').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-kc-brightness"><span data-i18n="color_strip.key_colors.brightness">Brightness:</span> <span id="css-editor-kc-brightness-val">1.00</span></label>
</div>
<input type="range" id="css-editor-kc-brightness" min="0" max="1" step="0.05" value="1.0"
oninput="document.getElementById('css-editor-kc-brightness-val').textContent = parseFloat(this.value).toFixed(2)">
</div>
</div>
<!-- Shared LED count field -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">
@@ -64,14 +64,27 @@
oninput="document.getElementById('ha-light-editor-transition-display').textContent = parseFloat(this.value).toFixed(1)">
</div>
<!-- Brightness Value Source -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-brightness-vs" data-i18n="targets.brightness_vs">Brightness Source:</label>
</div>
<select id="ha-light-editor-brightness-vs">
<option value="">None</option>
</select>
</div>
<!-- Light Mappings -->
<div class="form-group">
<div class="label-row">
<label data-i18n="ha_light.mappings">Light Mappings:</label>
<button type="button" class="btn btn-sm btn-secondary" onclick="addHALightMapping()" data-i18n-title="ha_light.mappings.add">+</button>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" data-i18n="ha_light.mappings.hint">Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.</small>
<small class="input-hint" style="display:none" data-i18n="ha_light.mappings.hint">Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.</small>
<div id="ha-light-mappings-list"></div>
<button type="button" class="btn btn-sm btn-secondary" onclick="addHALightMapping()" style="margin-top: 4px;">
+ <span data-i18n="ha_light.mappings.add">Add Mapping</span>
</button>
</div>
<!-- Description -->
@@ -9,7 +9,7 @@
<form id="pattern-template-form">
<input type="hidden" id="pattern-template-id">
<div class="form-group">
<div id="pattern-name-group" class="form-group">
<div class="label-row">
<label for="pattern-template-name" data-i18n="pattern.name">Template Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
@@ -19,7 +19,7 @@
<div id="pattern-tags-container"></div>
</div>
<div class="form-group">
<div id="pattern-desc-group" class="form-group">
<div class="label-row">
<label for="pattern-template-description" data-i18n="pattern.description_label">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
@@ -19,15 +19,74 @@ logger = get_logger(__name__)
# Lock + handle for cancelling previous sound
_play_lock = threading.Lock()
_current_process: subprocess.Popen | None = None
# Hold reference to SND_MEMORY buffer to prevent GC during async playback
_win_sound_buf: bytes | None = None
def _scale_wav_volume(file_path: Path, volume: float) -> bytes | None:
"""Read a WAV file and return a volume-scaled WAV as bytes.
Uses stdlib wave + struct to scale PCM samples in memory.
Returns None on error or if the format is unsupported.
"""
import io
import struct
import wave
try:
with wave.open(str(file_path), "rb") as wf:
n_channels = wf.getnchannels()
sample_width = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
raw = wf.readframes(n_frames)
except Exception as e:
logger.debug(f"Failed to read WAV for volume scaling: {e}")
return None
if sample_width not in (1, 2):
return None # Only 8-bit and 16-bit PCM supported
# Scale samples
if sample_width == 2:
fmt = f"<{len(raw) // 2}h"
samples = struct.unpack(fmt, raw)
scaled = struct.pack(fmt, *(max(-32768, min(32767, int(s * volume))) for s in samples))
else:
# 8-bit WAV is unsigned, center at 128
samples = struct.unpack(f"{len(raw)}B", raw)
scaled = struct.pack(
f"{len(raw)}B",
*(max(0, min(255, int((s - 128) * volume + 128))) for s in samples),
)
# Write scaled WAV to memory buffer
buf = io.BytesIO()
with wave.open(buf, "wb") as out:
out.setnchannels(n_channels)
out.setsampwidth(sample_width)
out.setframerate(framerate)
out.writeframes(scaled)
return buf.getvalue()
def _play_windows(file_path: Path, volume: float) -> None:
"""Play a WAV file on Windows using winsound."""
"""Play a WAV file on Windows using winsound with volume scaling."""
import winsound
# winsound doesn't support volume control natively,
# but SND_ASYNC plays non-blocking within this thread
global _win_sound_buf
try:
if volume < 1.0:
wav_data = _scale_wav_volume(file_path, volume)
if wav_data:
# Keep a global reference so GC doesn't free the buffer
# while async playback is still using it
_win_sound_buf = wav_data
winsound.PlaySound(wav_data, winsound.SND_MEMORY | winsound.SND_ASYNC)
return
# Full volume or fallback: play file directly
_win_sound_buf = None
winsound.PlaySound(str(file_path), winsound.SND_FILENAME | winsound.SND_ASYNC)
except Exception as e:
logger.error(f"winsound playback failed: {e}")
@@ -110,6 +169,7 @@ def stop_current_sound() -> None:
if sys.platform == "win32":
try:
import winsound
winsound.PlaySound(None, winsound.SND_PURGE)
except Exception as e:
logger.debug("Failed to stop winsound playback: %s", e)
@@ -5,9 +5,6 @@ import pytest
from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.key_colors_output_target import (
KeyColorsOutputTarget,
)
@pytest.fixture
@@ -37,18 +34,17 @@ class TestOutputTargetModel:
assert isinstance(target, WledOutputTarget)
assert target.device_id == "dev_1"
def test_key_colors_from_dict(self):
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",
"picture_source_id": "ps_1",
"settings": {},
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
target = OutputTarget.from_dict(data)
assert isinstance(target, KeyColorsOutputTarget)
with pytest.raises(ValueError, match="Unknown target type"):
OutputTarget.from_dict(data)
def test_unknown_type_raises(self):
data = {
@@ -82,14 +78,10 @@ class TestOutputTargetStoreCRUD:
assert t.name == "LED 1"
assert store.count() == 1
def test_create_key_colors_target(self, store):
t = store.create_target(
name="KC 1",
target_type="key_colors",
picture_source_id="ps_1",
)
assert isinstance(t, KeyColorsOutputTarget)
assert t.picture_source_id == "ps_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"):
@@ -194,11 +186,13 @@ class TestOutputTargetQueries:
class TestOutputTargetPersistence:
def test_persist_and_reload(self, tmp_path):
from wled_controller.storage.database import Database
db_path = str(tmp_path / "ot_persist.db")
db = Database(db_path)
s1 = OutputTargetStore(db)
t = s1.create_target(
"Persist", "led",
"Persist",
"led",
device_id="dev_1",
fps=60,
tags=["tv"],
+4 -10
View File
@@ -2,7 +2,10 @@
import pytest
from wled_controller.core.processing.processor_manager import ProcessorDependencies, ProcessorManager
from wled_controller.core.processing.processor_manager import (
ProcessorDependencies,
ProcessorManager,
)
@pytest.fixture
@@ -230,8 +233,6 @@ def test_get_target_metrics(processor_manager):
def test_target_type_detection(processor_manager):
"""Test target type detection via processor instances."""
from wled_controller.storage.key_colors_output_target import KeyColorsSettings
from wled_controller.core.processing.kc_target_processor import KCTargetProcessor
from wled_controller.core.processing.wled_target_processor import WledTargetProcessor
processor_manager.add_device(
@@ -245,13 +246,6 @@ def test_target_type_detection(processor_manager):
device_id="test_device",
)
processor_manager.add_kc_target(
target_id="kc_target",
picture_source_id="src_1",
settings=KeyColorsSettings(),
)
assert isinstance(processor_manager._processors["kc_target"], KCTargetProcessor)
assert isinstance(processor_manager._processors["wled_target"], WledTargetProcessor)