Rework API input CSS: segments, remove led_count, HAOS light, test preview

API Input CSS rework:
- Remove led_count field from ApiInputColorStripSource (always auto-sizes)
- Add segment-based payload: solid, per_pixel, gradient modes
- Segments applied in order (last wins on overlap), auto-grow buffer
- Backward compatible: legacy {"colors": [...]} still works
- Pydantic validation: mode-specific field requirements

Test preview:
- Enable test preview button on api_input cards
- Hide LED/FPS controls for api_input (sender controls those)
- Show input source selector for all CSS tests (preselected)
- FPS sparkline chart using shared createFpsSparkline (same as target cards)
- Server only sends frames when push_generation changes (no idle frames)

HAOS integration:
- New light.py: ApiInputLight entity per api_input source (RGB + brightness)
- turn_on pushes solid segment, turn_off pushes fallback color
- Register wled_screen_controller.set_leds service for arbitrary segments
- New services.yaml with field definitions
- Coordinator: push_colors() and push_segments() methods
- Platform.LIGHT added to platforms list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 14:47:42 +03:00
parent 823cb90d2d
commit 8a6ffca446
25 changed files with 1085 additions and 326 deletions

View File

@@ -410,7 +410,8 @@ async def push_colors(
):
"""Push raw LED colors to an api_input color strip source.
The colors are forwarded to all running stream instances for this source.
Accepts either 'colors' (flat [[R,G,B], ...] array) or 'segments' (segment-based).
The payload is forwarded to all running stream instances for this source.
"""
try:
source = store.get_source(source_id)
@@ -420,20 +421,32 @@ async def push_colors(
if not isinstance(source, ApiInputColorStripSource):
raise HTTPException(status_code=400, detail="Source is not an api_input type")
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")
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams:
if hasattr(stream, "push_colors"):
stream.push_colors(colors_array)
return {
"status": "ok",
"streams_updated": len(streams),
"leds_received": len(body.colors),
}
if body.segments is not None:
# Segment-based path
seg_dicts = [s.model_dump() for s in body.segments]
for stream in streams:
if hasattr(stream, "push_segments"):
stream.push_segments(seg_dicts)
return {
"status": "ok",
"streams_updated": len(streams),
"segments_applied": len(body.segments),
}
else:
# 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")
for stream in streams:
if hasattr(stream, "push_colors"):
stream.push_colors(colors_array)
return {
"status": "ok",
"streams_updated": len(streams),
"leds_received": len(body.colors),
}
@router.post("/api/v1/color-strip-sources/{source_id}/notify", tags=["Color Strip Sources"])
@@ -708,19 +721,42 @@ async def css_api_input_ws(
break
if "text" in message:
# JSON frame: {"colors": [[R,G,B], ...]}
# JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
import json
try:
data = json.loads(message["text"])
raw_colors = data.get("colors", [])
colors_array = np.array(raw_colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
continue
except (json.JSONDecodeError, ValueError, TypeError) as e:
except (json.JSONDecodeError, ValueError) as e:
await websocket.send_json({"error": str(e)})
continue
if "segments" in data:
# 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}"})
continue
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams:
if hasattr(stream, "push_segments"):
stream.push_segments(seg_dicts)
continue
elif "colors" in data:
try:
raw_colors = data["colors"]
colors_array = np.array(raw_colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
continue
except (ValueError, TypeError) as e:
await websocket.send_json({"error": str(e)})
continue
else:
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
continue
elif "bytes" in message:
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
raw_bytes = message["bytes"]
@@ -732,7 +768,7 @@ async def css_api_input_ws(
else:
continue
# Push to all running streams
# Push to all running streams (colors_array path only reaches here)
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams:
if hasattr(stream, "push_colors"):
@@ -799,6 +835,10 @@ async def test_color_strip_ws(
try:
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
# Send metadata as first message
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
is_composite = isinstance(source, CompositeColorStripSource)
@@ -875,9 +915,18 @@ async def test_color_strip_ws(
elif composite_colors is not None:
await websocket.send_bytes(composite_colors.tobytes())
else:
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
# For api_input: only send when new data was pushed
if is_api_input:
gen = stream.push_generation
if gen != _last_push_gen:
_last_push_gen = gen
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
else:
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
# Periodically send auxiliary data (frame preview, brightness)
now = _time.monotonic()