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:
@@ -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()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from wled_controller.api.schemas.devices import Calibration
|
||||
|
||||
@@ -237,10 +237,52 @@ class ColorStripSourceListResponse(BaseModel):
|
||||
count: int = Field(description="Number of sources")
|
||||
|
||||
|
||||
class ColorPushRequest(BaseModel):
|
||||
"""Request to push raw LED colors to an api_input source."""
|
||||
class SegmentPayload(BaseModel):
|
||||
"""A single segment for segment-based LED color updates."""
|
||||
|
||||
colors: List[List[int]] = Field(description="LED color array [[R,G,B], ...] (0-255 each)")
|
||||
start: int = Field(ge=0, description="Starting LED index")
|
||||
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],...]")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_mode_fields(self) -> "SegmentPayload":
|
||||
if self.mode == "solid":
|
||||
if self.color is None or len(self.color) != 3:
|
||||
raise ValueError("solid mode requires 'color' as a list of 3 ints [R,G,B]")
|
||||
if not all(0 <= c <= 255 for c in self.color):
|
||||
raise ValueError("solid color values must be 0-255")
|
||||
elif self.mode == "per_pixel":
|
||||
if not self.colors:
|
||||
raise ValueError("per_pixel mode requires non-empty 'colors' list")
|
||||
for c in self.colors:
|
||||
if len(c) != 3:
|
||||
raise ValueError("each color in per_pixel must be [R,G,B]")
|
||||
elif self.mode == "gradient":
|
||||
if not self.colors or len(self.colors) < 2:
|
||||
raise ValueError("gradient mode requires 'colors' with at least 2 stops")
|
||||
for c in self.colors:
|
||||
if len(c) != 3:
|
||||
raise ValueError("each color stop in gradient must be [R,G,B]")
|
||||
return self
|
||||
|
||||
|
||||
class ColorPushRequest(BaseModel):
|
||||
"""Request to push raw LED colors to an api_input source.
|
||||
|
||||
Accepts either 'colors' (legacy flat array) or 'segments' (new segment-based).
|
||||
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")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_colors_or_segments(self) -> "ColorPushRequest":
|
||||
if self.colors is None and self.segments is None:
|
||||
raise ValueError("Either 'colors' or 'segments' must be provided")
|
||||
return self
|
||||
|
||||
|
||||
class NotifyRequest(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user