refactor: key colors targets → CSS source type, HA target improvements
Lint & Test / test (push) Successful in 1m26s
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:
@@ -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.0–1.0)", ge=0.0, le=1.0)
|
||||
position: float = Field(
|
||||
description="Relative position along the strip (0.0–1.0)", ge=0.0, le=1.0
|
||||
)
|
||||
color: List[int] = Field(description="Primary RGB color [R, G, B] (0–255 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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}">✕</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' : ''}>▲</button>
|
||||
<button class="btn-micro" onclick="event.stopPropagation(); moveCalibrationLine(${i}, 1)" title="Move down" ${i === _state.lines.length - 1 ? 'disabled' : ''}>▼</button>
|
||||
<button class="btn-micro btn-danger" onclick="event.stopPropagation(); removeCalibrationLine(${i})" title="Remove">✕</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')}">✕</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})">✕</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')}">✓</button>
|
||||
<button type="button" class="btn btn-icon btn-sm btn-danger"
|
||||
onclick="deleteAndRefreshGradientPreset('${g.id}')"
|
||||
title="${t('common.delete')}">✕</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})">✕</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')}">×</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')}">✕</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')}">✕</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">✕</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">✕</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">✕</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()">✕</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"],
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user