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:
@@ -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}")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user