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."""

View File

@@ -0,0 +1,169 @@
"""API Input color strip stream — receives raw LED colors from external clients.
External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
buffers the latest frame and serves it to targets. When no data has been
received within `timeout` seconds, LEDs revert to `fallback_color`.
Thread-safe: push_colors() can be called from any thread (REST handler,
WebSocket handler) while get_latest_colors() is called from the target
processor thread.
"""
import threading
import time
from typing import Optional
import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class ApiInputColorStripStream(ColorStripStream):
"""Color strip stream backed by externally-pushed LED color data.
Holds a thread-safe np.ndarray buffer. External clients push colors via
push_colors(). A background thread checks for timeout and reverts to
fallback_color when no data arrives within the configured timeout window.
"""
def __init__(self, source):
"""
Args:
source: ApiInputColorStripSource config
"""
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
# Parse config
fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
self._auto_size = not source.led_count
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
# Build initial fallback buffer
self._fallback_array = self._build_fallback(self._led_count)
self._colors = self._fallback_array.copy()
self._last_push_time: float = 0.0
self._timed_out = True # Start in timed-out state
def _build_fallback(self, led_count: int) -> np.ndarray:
"""Build a (led_count, 3) uint8 array filled with fallback_color."""
return np.tile(
np.array(self._fallback_color, dtype=np.uint8),
(led_count, 1),
)
def push_colors(self, colors: np.ndarray) -> None:
"""Push a new frame of LED colors.
Thread-safe. The array is truncated or zero-padded to match led_count.
Args:
colors: np.ndarray shape (N, 3) uint8
"""
with self._lock:
n = len(colors)
if n == self._led_count:
self._colors = colors.astype(np.uint8)
elif n > self._led_count:
self._colors = colors[:self._led_count].astype(np.uint8)
else:
# Zero-pad to led_count
padded = np.zeros((self._led_count, 3), dtype=np.uint8)
padded[:n] = colors[:n]
self._colors = padded
self._last_push_time = time.monotonic()
self._timed_out = False
def configure(self, device_led_count: int) -> None:
"""Set LED count from the target device (called on target start).
Only takes effect when led_count was 0 (auto-size).
"""
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
with self._lock:
self._led_count = device_led_count
self._fallback_array = self._build_fallback(device_led_count)
self._colors = self._fallback_array.copy()
self._timed_out = True
logger.debug(f"ApiInputColorStripStream auto-sized to {device_led_count} LEDs")
@property
def target_fps(self) -> int:
return self._fps
@property
def is_animated(self) -> bool:
return True # Always poll — external data can arrive at any time
@property
def led_count(self) -> int:
return self._led_count
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._timeout_loop,
name="css-api-input-timeout",
daemon=True,
)
self._thread.start()
logger.info(f"ApiInputColorStripStream started (leds={self._led_count})")
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning("ApiInputColorStripStream timeout thread did not terminate within 5s")
self._thread = None
logger.info("ApiInputColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._lock:
return self._colors
def update_source(self, source) -> None:
"""Hot-update fallback_color and timeout from updated source config."""
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
if isinstance(source, ApiInputColorStripSource):
fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
prev_led_count = self._led_count if self._auto_size else None
self._auto_size = not source.led_count
with self._lock:
self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out:
self._colors = self._fallback_array.copy()
# Preserve runtime LED count across updates if auto-sized
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
with self._lock:
self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out:
self._colors = self._fallback_array.copy()
logger.info("ApiInputColorStripStream params updated in-place")
def _timeout_loop(self) -> None:
"""Background thread that reverts to fallback on timeout."""
while self._running:
time.sleep(0.5)
if self._timeout <= 0:
continue
if self._timed_out:
continue
elapsed = time.monotonic() - self._last_push_time
if elapsed >= self._timeout:
with self._lock:
self._colors = self._fallback_array.copy()
self._timed_out = True
logger.debug("ApiInputColorStripStream timed out, reverted to fallback")

View File

@@ -19,6 +19,7 @@ from wled_controller.core.processing.color_strip_stream import (
StaticColorStripStream,
)
from wled_controller.core.processing.effect_stream import EffectColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -29,6 +30,7 @@ _SIMPLE_STREAM_MAP = {
"gradient": GradientColorStripStream,
"color_cycle": ColorCycleColorStripStream,
"effect": EffectColorStripStream,
"api_input": ApiInputColorStripStream,
}
@@ -271,6 +273,19 @@ class ColorStripStreamManager:
new_fps = 30 # default when no consumers
entry.stream.set_capture_fps(new_fps)
def get_streams_by_source_id(self, css_id: str) -> list:
"""Return all running stream instances for a given source ID.
Checks both the shared key (css_id) and per-consumer keys (css_id:xxx).
Used by the REST/WS push handlers to broadcast pushed colors to all
consumers of an api_input source.
"""
streams = []
for key, entry in self._streams.items():
if key == css_id or key.startswith(f"{css_id}:"):
streams.append(entry.stream)
return streams
def release_all(self) -> None:
"""Stop and remove all managed color strip streams. Called on shutdown."""
css_ids = list(self._streams.keys())

View File

@@ -48,6 +48,8 @@ class CSSEditorModal extends Modal {
audio_color: document.getElementById('css-editor-audio-color').value,
audio_color_peak: document.getElementById('css-editor-audio-color-peak').value,
audio_mirror: document.getElementById('css-editor-audio-mirror').checked,
api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').value,
api_input_timeout: document.getElementById('css-editor-api-input-timeout').value,
};
}
}
@@ -66,6 +68,7 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-composite-section').style.display = type === 'composite' ? '' : 'none';
document.getElementById('css-editor-mapped-section').style.display = type === 'mapped' ? '' : 'none';
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none';
if (type === 'effect') onEffectTypeChange();
if (type === 'audio') onAudioVizChange();
@@ -99,9 +102,9 @@ export function onCSSTypeChange() {
}
_syncAnimationSpeedState();
// LED count — not needed for composite/mapped/audio (uses device count)
// LED count — not needed for composite/mapped/audio/api_input (uses device count)
document.getElementById('css-editor-led-count-group').style.display =
(type === 'composite' || type === 'mapped' || type === 'audio') ? 'none' : '';
(type === 'composite' || type === 'mapped' || type === 'audio' || type === 'api_input') ? 'none' : '';
if (type === 'audio') {
_loadAudioSources();
@@ -575,6 +578,7 @@ export function createColorStripCard(source, pictureSourceMap) {
const isComposite = source.source_type === 'composite';
const isMapped = source.source_type === 'mapped';
const isAudio = source.source_type === 'audio';
const isApiInput = source.source_type === 'api_input';
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim
@@ -656,6 +660,15 @@ export function createColorStripCard(source, pictureSourceMap) {
${source.audio_source_id ? `<span class="stream-card-prop" title="${t('color_strip.audio.source')}">🔊</span>` : ''}
${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
`;
} else if (isApiInput) {
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
const timeoutVal = (source.timeout ?? 5.0).toFixed(1);
propsHtml = `
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${fbColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${fbColor.toUpperCase()}
</span>
<span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">⏱️ ${timeoutVal}s</span>
`;
} else {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name
@@ -669,8 +682,8 @@ export function createColorStripCard(source, pictureSourceMap) {
`;
}
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isMapped ? '📍' : isAudio ? '🎵' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio)
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isMapped ? '📍' : isAudio ? '🎵' : isApiInput ? '📡' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
: '';
@@ -759,6 +772,13 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
_loadCompositeState(css);
} else if (sourceType === 'mapped') {
_loadMappedState(css);
} else if (sourceType === 'api_input') {
document.getElementById('css-editor-api-input-fallback-color').value =
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
document.getElementById('css-editor-api-input-timeout').value = css.timeout ?? 5.0;
document.getElementById('css-editor-api-input-timeout-val').textContent =
parseFloat(css.timeout ?? 5.0).toFixed(1);
_showApiInputEndpoints(css.id);
} else {
sourceSelect.value = css.picture_source_id || '';
@@ -844,6 +864,10 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
_loadCompositeState(null);
_resetMappedState();
_resetAudioState();
document.getElementById('css-editor-api-input-fallback-color').value = '#000000';
document.getElementById('css-editor-api-input-timeout').value = 5.0;
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
_showApiInputEndpoints(null);
document.getElementById('css-editor-title').textContent = t('color_strip.add');
document.getElementById('css-editor-gradient-preset').value = '';
gradientInit([
@@ -969,6 +993,14 @@ export async function saveCSSEditor() {
}
payload = { name, zones };
if (!cssId) payload.source_type = 'mapped';
} else if (sourceType === 'api_input') {
const fbHex = document.getElementById('css-editor-api-input-fallback-color').value;
payload = {
name,
fallback_color: hexToRgbArray(fbHex),
timeout: parseFloat(document.getElementById('css-editor-api-input-timeout').value),
};
if (!cssId) payload.source_type = 'api_input';
} else {
payload = {
name,
@@ -1013,6 +1045,25 @@ export async function saveCSSEditor() {
}
}
/* ── API Input helpers ────────────────────────────────────────── */
function _showApiInputEndpoints(cssId) {
const el = document.getElementById('css-editor-api-input-endpoints');
const group = document.getElementById('css-editor-api-input-endpoints-group');
if (!el || !group) return;
if (!cssId) {
el.innerHTML = `<em data-i18n="color_strip.api_input.save_first">${t('color_strip.api_input.save_first')}</em>`;
return;
}
const base = `${window.location.origin}/api/v1`;
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsBase = `${wsProto}//${window.location.host}/api/v1`;
el.innerHTML = `
<div style="margin-bottom:4px"><strong>REST POST:</strong><br>${base}/color-strip-sources/${cssId}/colors</div>
<div><strong>WebSocket:</strong><br>${wsBase}/color-strip-sources/${cssId}/ws?token=&lt;api_key&gt;</div>
`;
}
/* ── Clone ────────────────────────────────────────────────────── */
export async function cloneColorStrip(cssId) {

View File

@@ -589,7 +589,7 @@
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
"color_strip.error.name_required": "Please enter a name",
"color_strip.type": "Type:",
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input.",
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.",
"color_strip.type.picture": "Picture Source",
"color_strip.type.static": "Static Color",
"color_strip.type.gradient": "Gradient",
@@ -657,6 +657,15 @@
"color_strip.type.mapped.hint": "Assign different color strip sources to different LED ranges (zones). Unlike composite which blends layers, mapped places sources side-by-side.",
"color_strip.type.audio": "Audio Reactive",
"color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.",
"color_strip.type.api_input": "API Input",
"color_strip.type.api_input.hint": "Receives raw LED color arrays from external clients via REST POST or WebSocket. Use this to integrate with custom software, home automation, or any system that can send HTTP requests.",
"color_strip.api_input.fallback_color": "Fallback Color:",
"color_strip.api_input.fallback_color.hint": "Color to display when no data has been received within the timeout period. LEDs will show this color on startup and after the connection is lost.",
"color_strip.api_input.timeout": "Timeout (seconds):",
"color_strip.api_input.timeout.hint": "How long to wait for new color data before reverting to the fallback color. Set to 0 to never time out.",
"color_strip.api_input.endpoints": "Push Endpoints:",
"color_strip.api_input.endpoints.hint": "Use these URLs to push LED color data from your external application. REST accepts JSON, WebSocket accepts both JSON and raw binary frames.",
"color_strip.api_input.save_first": "Save the source first to see the push endpoint URLs.",
"color_strip.composite.layers": "Layers:",
"color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.",
"color_strip.composite.add_layer": "+ Add Layer",

View File

@@ -589,7 +589,7 @@
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
"color_strip.error.name_required": "Введите название",
"color_strip.type": "Тип:",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени.",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.",
"color_strip.type.picture": "Источник изображения",
"color_strip.type.static": "Статический цвет",
"color_strip.type.gradient": "Градиент",
@@ -657,6 +657,15 @@
"color_strip.type.mapped.hint": "Назначает разные источники цветовой полосы на разные диапазоны LED (зоны). В отличие от композита, маппинг размещает источники рядом друг с другом.",
"color_strip.type.audio": "Аудиореактив",
"color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.",
"color_strip.type.api_input": "API-ввод",
"color_strip.type.api_input.hint": "Принимает массивы цветов LED от внешних клиентов через REST POST или WebSocket. Используйте для интеграции с собственным ПО, домашней автоматизацией или любой системой, способной отправлять HTTP-запросы.",
"color_strip.api_input.fallback_color": "Цвет по умолчанию:",
"color_strip.api_input.fallback_color.hint": "Цвет для отображения, когда данные не получены в течение периода ожидания. LED покажут этот цвет при запуске и после потери соединения.",
"color_strip.api_input.timeout": "Тайм-аут (секунды):",
"color_strip.api_input.timeout.hint": "Время ожидания новых данных о цветах перед возвратом к цвету по умолчанию. Установите 0, чтобы не использовать тайм-аут.",
"color_strip.api_input.endpoints": "Эндпоинты для отправки:",
"color_strip.api_input.endpoints.hint": "Используйте эти URL для отправки данных о цветах LED из вашего внешнего приложения. REST принимает JSON, WebSocket принимает как JSON, так и бинарные кадры.",
"color_strip.api_input.save_first": "Сначала сохраните источник, чтобы увидеть URL эндпоинтов.",
"color_strip.composite.layers": "Слои:",
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
"color_strip.composite.add_layer": "+ Добавить слой",

View File

@@ -10,6 +10,7 @@ Current types:
GradientColorStripSource — linear gradient across all LEDs from user-defined color stops
ColorCycleColorStripSource — smoothly cycles through a user-defined list of colors
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
"""
from dataclasses import dataclass, field
@@ -78,6 +79,8 @@ class ColorStripSource:
"audio_source_id": None,
"sensitivity": None,
"color_peak": None,
"fallback_color": None,
"timeout": None,
}
@staticmethod
@@ -200,6 +203,20 @@ class ColorStripSource:
mirror=bool(data.get("mirror", False)),
)
if source_type == "api_input":
raw_fallback = data.get("fallback_color")
fallback_color = (
raw_fallback if isinstance(raw_fallback, list) and len(raw_fallback) == 3
else [0, 0, 0]
)
return ApiInputColorStripSource(
id=sid, name=name, source_type="api_input",
created_at=created_at, updated_at=updated_at, description=description,
led_count=data.get("led_count") or 0,
fallback_color=fallback_color,
timeout=float(data.get("timeout") or 5.0),
)
# Default: "picture" type
return PictureColorStripSource(
id=sid, name=name, source_type=source_type,
@@ -433,3 +450,25 @@ class MappedColorStripSource(ColorStripSource):
d["zones"] = [dict(z) for z in self.zones]
d["led_count"] = self.led_count
return d
@dataclass
class ApiInputColorStripSource(ColorStripSource):
"""Color strip source that receives raw LED color arrays from external clients.
External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
buffers the latest frame and serves it to targets. When no data has been
received within `timeout` seconds, LEDs revert to `fallback_color`.
LED count auto-sizes from the connected device when led_count == 0.
"""
led_count: int = 0 # 0 = auto-size from device
fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B]
timeout: float = 5.0 # seconds before reverting to fallback
def to_dict(self) -> dict:
d = super().to_dict()
d["led_count"] = self.led_count
d["fallback_color"] = list(self.fallback_color)
d["timeout"] = self.timeout
return d

View File

@@ -8,6 +8,7 @@ from typing import Dict, List, Optional
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
from wled_controller.storage.color_strip_source import (
ApiInputColorStripSource,
AudioColorStripSource,
ColorCycleColorStripSource,
ColorStripSource,
@@ -125,6 +126,8 @@ class ColorStripStore:
audio_source_id: str = "",
sensitivity: float = 1.0,
color_peak: Optional[list] = None,
fallback_color: Optional[list] = None,
timeout: Optional[float] = None,
) -> ColorStripSource:
"""Create a new color strip source.
@@ -245,6 +248,19 @@ class ColorStripStore:
zones=zones if isinstance(zones, list) else [],
led_count=led_count,
)
elif source_type == "api_input":
fb = fallback_color if isinstance(fallback_color, list) and len(fallback_color) == 3 else [0, 0, 0]
source = ApiInputColorStripSource(
id=source_id,
name=name,
source_type="api_input",
created_at=now,
updated_at=now,
description=description,
led_count=led_count,
fallback_color=fb,
timeout=float(timeout) if timeout is not None else 5.0,
)
else:
if calibration is None:
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
@@ -305,6 +321,8 @@ class ColorStripStore:
audio_source_id: Optional[str] = None,
sensitivity: Optional[float] = None,
color_peak: Optional[list] = None,
fallback_color: Optional[list] = None,
timeout: Optional[float] = None,
) -> ColorStripSource:
"""Update an existing color strip source.
@@ -414,6 +432,13 @@ class ColorStripStore:
source.zones = zones
if led_count is not None:
source.led_count = led_count
elif isinstance(source, ApiInputColorStripSource):
if fallback_color is not None and isinstance(fallback_color, list) and len(fallback_color) == 3:
source.fallback_color = fallback_color
if timeout is not None:
source.timeout = float(timeout)
if led_count is not None:
source.led_count = led_count
source.updated_at = datetime.utcnow()
self._save()

View File

@@ -29,6 +29,7 @@
<option value="composite" data-i18n="color_strip.type.composite">Composite</option>
<option value="mapped" data-i18n="color_strip.type.mapped">Mapped</option>
<option value="audio" data-i18n="color_strip.type.audio">Audio Reactive</option>
<option value="api_input" data-i18n="color_strip.type.api_input">API Input</option>
</select>
</div>
@@ -431,6 +432,40 @@
</div>
</div>
<!-- API Input fields -->
<div id="css-editor-api-input-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-api-input-fallback-color" data-i18n="color_strip.api_input.fallback_color">Fallback Color:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.api_input.fallback_color.hint">Color to display when no data has been received within the timeout period.</small>
<input type="color" id="css-editor-api-input-fallback-color" value="#000000">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-api-input-timeout">
<span data-i18n="color_strip.api_input.timeout">Timeout (seconds):</span>
<span id="css-editor-api-input-timeout-val">5.0</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.api_input.timeout.hint">How long to wait for new color data before reverting to the fallback color.</small>
<input type="range" id="css-editor-api-input-timeout" min="0" max="60" step="0.5" value="5.0"
oninput="document.getElementById('css-editor-api-input-timeout-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group" id="css-editor-api-input-endpoints-group">
<div class="label-row">
<label data-i18n="color_strip.api_input.endpoints">Push Endpoints:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.api_input.endpoints.hint">Use these URLs to push LED color data from your external application.</small>
<div id="css-editor-api-input-endpoints" class="template-config" style="font-family:monospace; font-size:0.85em; word-break:break-all;"></div>
</div>
</div>
<!-- Shared LED count field -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">