diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index ec29a97..3d9ae3f 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -26,7 +26,7 @@ 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 PictureColorStripSource, StaticColorStripSource 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 @@ -57,6 +57,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe interpolation_mode=getattr(source, "interpolation_mode", None), led_count=getattr(source, "led_count", 0), calibration=calibration, + color=getattr(source, "color", None), description=source.description, overlay_active=overlay_active, created_at=source.created_at, @@ -117,6 +118,7 @@ async def create_color_strip_source( interpolation_mode=data.interpolation_mode, led_count=data.led_count, calibration=calibration, + color=data.color, description=data.description, ) return _css_to_response(source) @@ -169,6 +171,7 @@ async def update_color_strip_source( interpolation_mode=data.interpolation_mode, led_count=data.led_count, calibration=calibration, + color=data.color, description=data.description, ) @@ -255,6 +258,11 @@ async def test_css_calibration( if body.edges: try: source = store.get_source(source_id) + if isinstance(source, StaticColorStripSource): + raise HTTPException( + status_code=400, + detail="Calibration test is not applicable for static color strip sources", + ) if isinstance(source, PictureColorStripSource) and source.calibration: calibration = source.calibration except ValueError as e: @@ -296,6 +304,8 @@ async def start_css_overlay( """Start screen overlay visualization for a color strip source.""" try: source = store.get_source(source_id) + if isinstance(source, StaticColorStripSource): + raise HTTPException(status_code=400, detail="Overlay is not supported for static color strip sources") if not isinstance(source, PictureColorStripSource): raise HTTPException(status_code=400, detail="Overlay only supported for picture color strip sources") if not source.calibration: diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 3165b92..f7c2950 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -12,7 +12,8 @@ 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"] = Field(default="picture", description="Source type") + source_type: Literal["picture", "static"] = Field(default="picture", description="Source type") + # picture-type fields picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") fps: int = Field(default=30, description="Target frames per second", ge=10, le=90) brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) @@ -20,8 +21,11 @@ class ColorStripSourceCreate(BaseModel): gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0) smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0) interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") - led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration)", ge=0) calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)") + # static-type fields + color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)") + # shared + led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -29,6 +33,7 @@ class ColorStripSourceUpdate(BaseModel): """Request to update a color strip source.""" name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) + # picture-type fields picture_source_id: Optional[str] = Field(None, description="Picture source ID") fps: Optional[int] = Field(None, description="Target FPS", ge=10, le=90) brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) @@ -36,8 +41,11 @@ class ColorStripSourceUpdate(BaseModel): gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0) smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0) interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)") - led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration)", ge=0) calibration: Optional[Calibration] = Field(None, description="LED calibration") + # static-type fields + color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)") + # shared + led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -47,6 +55,7 @@ class ColorStripSourceResponse(BaseModel): id: str = Field(description="Source ID") name: str = Field(description="Source name") source_type: str = Field(description="Source type") + # picture-type fields picture_source_id: Optional[str] = Field(None, description="Picture source ID") fps: Optional[int] = Field(None, description="Target FPS") brightness: Optional[float] = Field(None, description="Brightness multiplier") @@ -54,8 +63,11 @@ class ColorStripSourceResponse(BaseModel): gamma: Optional[float] = Field(None, description="Gamma correction") smoothing: Optional[float] = Field(None, description="Temporal smoothing") interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") - led_count: int = Field(0, description="Total LED count (0 = auto from calibration)") calibration: Optional[Calibration] = Field(None, description="LED calibration") + # static-type fields + color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B]") + # shared + led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)") description: Optional[str] = Field(None, description="Description") overlay_active: bool = Field(False, description="Whether the screen overlay is currently active") created_at: datetime = Field(description="Creation timestamp") diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index 4814ea7..4fc91a6 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -331,3 +331,72 @@ class PictureColorStripStream(ColorStripStream): remaining = frame_time - elapsed if remaining > 0: time.sleep(remaining) + + +class StaticColorStripStream(ColorStripStream): + """Color strip stream that returns a constant single-color array. + + No background thread needed — every call to get_latest_colors() returns + the same pre-built numpy array. Parameters can be hot-updated via + update_source(). + """ + + def __init__(self, source): + """ + Args: + source: StaticColorStripSource config + """ + self._update_from_source(source) + + def _update_from_source(self, source) -> None: + color = source.color if isinstance(source.color, list) and len(source.color) == 3 else [255, 255, 255] + self._source_color = color # stored separately so configure() can rebuild + self._auto_size = not source.led_count # True when led_count == 0 + led_count = source.led_count if source.led_count and source.led_count > 0 else 1 + self._led_count = led_count + self._rebuild_colors() + + def _rebuild_colors(self) -> None: + self._colors = np.tile( + np.array(self._source_color, dtype=np.uint8), + (self._led_count, 1), + ) + + def configure(self, device_led_count: int) -> None: + """Set LED count from the target device (called by WledTargetProcessor on start). + + Only takes effect when led_count was 0 (auto-size). Silently ignored + when an explicit led_count was configured on the source. + """ + if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: + self._led_count = device_led_count + self._rebuild_colors() + logger.debug(f"StaticColorStripStream auto-sized to {device_led_count} LEDs") + + @property + def target_fps(self) -> int: + return 30 # static output; any reasonable value is fine + + @property + def led_count(self) -> int: + return self._led_count + + def start(self) -> None: + logger.info(f"StaticColorStripStream started (leds={self._led_count})") + + def stop(self) -> None: + logger.info("StaticColorStripStream stopped") + + def get_latest_colors(self) -> Optional[np.ndarray]: + return self._colors + + def update_source(self, source) -> None: + from wled_controller.storage.color_strip_source import StaticColorStripSource + if isinstance(source, StaticColorStripSource): + prev_led_count = self._led_count if self._auto_size else None + self._update_from_source(source) + # If we were auto-sized, preserve the runtime LED count across updates + if prev_led_count and self._auto_size: + self._led_count = prev_led_count + self._rebuild_colors() + logger.info("StaticColorStripStream params updated in-place") diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 3367806..b084a36 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -15,6 +15,7 @@ from typing import Dict, Optional from wled_controller.core.processing.color_strip_stream import ( ColorStripStream, PictureColorStripStream, + StaticColorStripStream, ) from wled_controller.utils import get_logger @@ -78,10 +79,21 @@ class ColorStripStreamManager: ) return entry.stream - from wled_controller.storage.color_strip_source import PictureColorStripSource + from wled_controller.storage.color_strip_source import PictureColorStripSource, StaticColorStripSource source = self._color_strip_store.get_source(css_id) + if isinstance(source, StaticColorStripSource): + css_stream = StaticColorStripStream(source) + css_stream.start() + self._streams[css_id] = _ColorStripEntry( + stream=css_stream, + ref_count=1, + picture_source_id="", # no live stream to manage + ) + logger.info(f"Created static color strip stream for source {css_id}") + return css_stream + if not isinstance(source, PictureColorStripSource): raise ValueError( f"Unsupported color strip source type '{source.source_type}' for {css_id}" @@ -110,7 +122,7 @@ class ColorStripStreamManager: picture_source_id=source.picture_source_id, ) - logger.info(f"Created color strip stream for source {css_id}") + logger.info(f"Created picture color strip stream for source {css_id}") return css_stream def release(self, css_id: str) -> None: @@ -140,8 +152,9 @@ class ColorStripStreamManager: del self._streams[css_id] logger.info(f"Removed color strip stream for source {css_id}") - # Release the underlying live stream - self._live_stream_manager.release(picture_source_id) + # Release the underlying live stream (not needed for static sources) + if picture_source_id: + self._live_stream_manager.release(picture_source_id) def update_source(self, css_id: str, new_source) -> None: """Hot-update processing params on a running stream. diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index a6cc88a..f8ae9a6 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -114,6 +114,12 @@ class WledTargetProcessor(TargetProcessor): self._color_strip_stream = stream self._resolved_display_index = stream.display_index self._resolved_target_fps = stream.target_fps + + # For auto-sized static streams (led_count == 0), size to device LED count + from wled_controller.core.processing.color_strip_stream import StaticColorStripStream + if isinstance(stream, StaticColorStripStream) and device_info.led_count > 0: + stream.configure(device_info.led_count) + logger.info( f"Acquired color strip stream for target {self._target_id} " f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, " diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 8ac3c47..b7c7004 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -90,6 +90,7 @@ import { // Layer 5: color-strip sources import { showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip, + onCSSTypeChange, } from './features/color-strips.js'; // Layer 5: calibration @@ -274,6 +275,7 @@ Object.assign(window, { forceCSSEditorClose, saveCSSEditor, deleteColorStrip, + onCSSTypeChange, // calibration showCalibration, diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 2a14833..ba01979 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -13,8 +13,10 @@ class CSSEditorModal extends Modal { } snapshotValues() { + const type = document.getElementById('css-editor-type').value; return { name: document.getElementById('css-editor-name').value, + type, picture_source: document.getElementById('css-editor-picture-source').value, fps: document.getElementById('css-editor-fps').value, interpolation: document.getElementById('css-editor-interpolation').value, @@ -22,39 +24,81 @@ class CSSEditorModal extends Modal { brightness: document.getElementById('css-editor-brightness').value, saturation: document.getElementById('css-editor-saturation').value, gamma: document.getElementById('css-editor-gamma').value, - led_count: document.getElementById('css-editor-led-count').value, + color: document.getElementById('css-editor-color').value, + led_count: type === 'static' ? '0' : document.getElementById('css-editor-led-count').value, }; } } const cssEditorModal = new CSSEditorModal(); +/* ── Type-switch helper ───────────────────────────────────────── */ + +export function onCSSTypeChange() { + const type = document.getElementById('css-editor-type').value; + document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none'; + document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none'; + // LED count is only meaningful for picture sources; static uses device LED count automatically + document.getElementById('css-editor-led-count-group').style.display = type === 'static' ? 'none' : ''; +} + +/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */ +function rgbArrayToHex(rgb) { + if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff'; + return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join(''); +} + +/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */ +function hexToRgbArray(hex) { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); + return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255]; +} + /* ── Card ─────────────────────────────────────────────────────── */ export function createColorStripCard(source, pictureSourceMap) { - const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id]) - ? pictureSourceMap[source.picture_source_id].name - : source.picture_source_id || '—'; - const cal = source.calibration || {}; - const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0); - const ledCount = (source.led_count > 0) ? source.led_count : calLeds; + const isStatic = source.source_type === 'static'; + + let propsHtml; + if (isStatic) { + const hexColor = rgbArrayToHex(source.color); + propsHtml = ` + + ${hexColor.toUpperCase()} + + ${source.led_count ? `💡 ${source.led_count}` : ''} + `; + } else { + const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id]) + ? pictureSourceMap[source.picture_source_id].name + : source.picture_source_id || '—'; + const cal = source.calibration || {}; + const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0); + const ledCount = (source.led_count > 0) ? source.led_count : calLeds; + propsHtml = ` + ⚡ ${source.fps || 30} fps + ${ledCount ? `💡 ${ledCount}` : ''} + 📺 ${escapeHtml(srcName)} + `; + } + + const icon = isStatic ? '🎨' : '🎞️'; + const calibrationBtn = isStatic ? '' : ``; return `