Add API Input color strip source type with REST and WebSocket push

New source_type "api_input" allows external clients to push raw LED
color arrays ([R,G,B] per LED) via REST POST or WebSocket. Includes
configurable fallback color and timeout for automatic revert when no
data is received. Stream auto-sizes LED count from the target device.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 17:07:47 +03:00
parent 1e4a7a067f
commit 67a15776b2
10 changed files with 512 additions and 10 deletions

View File

@@ -1,6 +1,9 @@
"""Color strip source routes: CRUD and calibration test."""
"""Color strip source routes: CRUD, calibration test, and API input push."""
from fastapi import APIRouter, Depends, HTTPException
import secrets
import numpy as np
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
@@ -10,6 +13,7 @@ from wled_controller.api.dependencies import (
get_processor_manager,
)
from wled_controller.api.schemas.color_strip_sources import (
ColorPushRequest,
ColorStripSourceCreate,
ColorStripSourceListResponse,
ColorStripSourceResponse,
@@ -26,12 +30,13 @@ 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 PictureColorStripSource
from wled_controller.storage.color_strip_source import ApiInputColorStripSource, PictureColorStripSource
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.utils import get_logger
from wled_controller.config import get_config
logger = get_logger(__name__)
@@ -85,6 +90,8 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
audio_source_id=getattr(source, "audio_source_id", None),
sensitivity=getattr(source, "sensitivity", None),
color_peak=getattr(source, "color_peak", None),
fallback_color=getattr(source, "fallback_color", None),
timeout=getattr(source, "timeout", None),
overlay_active=overlay_active,
created_at=source.created_at,
updated_at=source.updated_at,
@@ -168,6 +175,8 @@ async def create_color_strip_source(
audio_source_id=data.audio_source_id,
sensitivity=data.sensitivity,
color_peak=data.color_peak,
fallback_color=data.fallback_color,
timeout=data.timeout,
)
return _css_to_response(source)
@@ -243,6 +252,8 @@ async def update_color_strip_source(
audio_source_id=data.audio_source_id,
sensitivity=data.sensitivity,
color_peak=data.color_peak,
fallback_color=data.fallback_color,
timeout=data.timeout,
)
# Hot-reload running stream (no restart needed for in-place param changes)
@@ -441,3 +452,127 @@ async def get_css_overlay_status(
):
"""Check if overlay is active for a color strip source."""
return {"source_id": source_id, "active": manager.is_css_overlay_active(source_id)}
# ===== 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,
body: ColorPushRequest,
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Push raw LED colors to an api_input color strip source.
The colors are forwarded to all running stream instances for this source.
"""
try:
source = store.get_source(source_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
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),
}
@router.websocket("/api/v1/color-strip-sources/{source_id}/ws")
async def css_api_input_ws(
websocket: WebSocket,
source_id: str,
token: str = Query(""),
):
"""WebSocket for pushing raw LED colors to an api_input source.
Auth via ?token=<api_key>. Accepts JSON frames ({"colors": [[R,G,B], ...]})
or binary frames (raw RGBRGB... bytes, 3 bytes per LED).
"""
# Authenticate
authenticated = False
cfg = get_config()
if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
await websocket.close(code=4001, reason="Unauthorized")
return
# Validate source exists and is api_input type
manager = get_processor_manager()
try:
store = get_color_strip_store()
source = store.get_source(source_id)
except (ValueError, RuntimeError):
await websocket.close(code=4004, reason="Source not found")
return
if not isinstance(source, ApiInputColorStripSource):
await websocket.close(code=4003, reason="Source is not api_input type")
return
await websocket.accept()
logger.info(f"API input WebSocket connected for source {source_id}")
try:
while True:
message = await websocket.receive()
if message.get("type") == "websocket.disconnect":
break
if "text" in message:
# JSON frame: {"colors": [[R,G,B], ...]}
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:
await websocket.send_json({"error": str(e)})
continue
elif "bytes" in message:
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
raw_bytes = message["bytes"]
if len(raw_bytes) % 3 != 0:
await websocket.send_json({"error": "Binary data must be multiple of 3 bytes"})
continue
colors_array = np.frombuffer(raw_bytes, dtype=np.uint8).reshape(-1, 3)
else:
continue
# Push to all running streams
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)
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"API input WebSocket error for source {source_id}: {e}")
finally:
logger.info(f"API input WebSocket disconnected for source {source_id}")

View File

@@ -49,7 +49,7 @@ 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", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio"] = Field(default="picture", description="Source type")
source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
@@ -86,6 +86,9 @@ class ColorStripSourceCreate(BaseModel):
description: Optional[str] = Field(None, description="Optional description", max_length=500)
frame_interpolation: bool = Field(default=False, description="Blend between consecutive captured frames for smoother output")
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)
class ColorStripSourceUpdate(BaseModel):
@@ -128,6 +131,9 @@ class ColorStripSourceUpdate(BaseModel):
description: Optional[str] = Field(None, description="Optional description", max_length=500)
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
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)
class ColorStripSourceResponse(BaseModel):
@@ -172,6 +178,9 @@ class ColorStripSourceResponse(BaseModel):
description: Optional[str] = Field(None, description="Description")
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
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)")
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")
@@ -184,6 +193,12 @@ class ColorStripSourceListResponse(BaseModel):
count: int = Field(description="Number of sources")
class ColorPushRequest(BaseModel):
"""Request to push raw LED colors to an api_input source."""
colors: List[List[int]] = Field(description="LED color array [[R,G,B], ...] (0-255 each)")
class CSSCalibrationTestRequest(BaseModel):
"""Request to run a calibration test for a color strip source on a specific device."""