CSS: add StaticColorStripSource type with auto-sized LED count

Introduces a new 'static' source type that fills all device LEDs with a
single constant RGB color — no screen capture or processing required.

- StaticColorStripSource storage model (color + led_count=0 auto-size)
- StaticColorStripStream: no background thread, configure() sizes to device
  LED count at processor start; hot-updates preserve runtime size
- ColorStripStreamManager dispatches static sources (no LiveStream needed)
- WledTargetProcessor calls stream.configure(device_led_count) on start
- API schemas/routes: source_type Literal["picture","static"]; color field;
  overlay/calibration-test endpoints return 400 for static
- Frontend: type selector modal, color picker, type-aware card rendering
  (🎨 icon + color swatch), LED count field hidden for static type
- Locale keys: color_strip.type, color_strip.static_color (en + ru)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 17:49:48 +03:00
parent 0a23cb7043
commit 2a8e2daefc
12 changed files with 430 additions and 155 deletions

View File

@@ -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:

View File

@@ -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")

View File

@@ -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")

View File

@@ -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,7 +152,8 @@ class ColorStripStreamManager:
del self._streams[css_id]
logger.info(f"Removed color strip stream for source {css_id}")
# Release the underlying live stream
# 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:

View File

@@ -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}, "

View File

@@ -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,

View File

@@ -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 isStatic = source.source_type === 'static';
let propsHtml;
if (isStatic) {
const hexColor = rgbArrayToHex(source.color);
propsHtml = `
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} 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 = `
<span class="stream-card-prop" title="${t('color_strip.fps')}">⚡ ${source.fps || 30} fps</span>
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">📺 ${escapeHtml(srcName)}</span>
`;
}
const icon = isStatic ? '🎨' : '🎞️';
const calibrationBtn = isStatic ? '' : `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`;
return `
<div class="card" data-css-id="${source.id}">
<button class="card-remove-btn" onclick="deleteColorStrip('${source.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
🎞️ ${escapeHtml(source.name)}
${icon} ${escapeHtml(source.name)}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('color_strip.fps')}">⚡ ${source.fps || 30} fps</span>
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">📺 ${escapeHtml(srcName)}</span>
${propsHtml}
</div>
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">✏️</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>
${calibrationBtn}
</div>
</div>
`;
@@ -85,6 +129,14 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-id').value = css.id;
document.getElementById('css-editor-name').value = css.name;
const sourceType = css.source_type || 'picture';
document.getElementById('css-editor-type').value = sourceType;
onCSSTypeChange();
if (sourceType === 'static') {
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
} else {
sourceSelect.value = css.picture_source_id || '';
const fps = css.fps ?? 30;
@@ -108,13 +160,15 @@ export async function showCSSEditor(cssId = null) {
const gamma = css.gamma ?? 1.0;
document.getElementById('css-editor-gamma').value = gamma;
document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2);
}
document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
document.getElementById('css-editor-title').textContent = t('color_strip.edit');
} else {
document.getElementById('css-editor-id').value = '';
document.getElementById('css-editor-name').value = '';
document.getElementById('css-editor-type').value = 'picture';
onCSSTypeChange();
document.getElementById('css-editor-fps').value = 30;
document.getElementById('css-editor-fps-value').textContent = '30';
document.getElementById('css-editor-interpolation').value = 'average';
@@ -126,6 +180,7 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-saturation-value').textContent = '1.00';
document.getElementById('css-editor-gamma').value = 1.0;
document.getElementById('css-editor-gamma-value').textContent = '1.00';
document.getElementById('css-editor-color').value = '#ffffff';
document.getElementById('css-editor-led-count').value = 0;
document.getElementById('css-editor-title').textContent = t('color_strip.add');
}
@@ -150,13 +205,24 @@ export function isCSSEditorDirty() { return cssEditorModal.isDirty(); }
export async function saveCSSEditor() {
const cssId = document.getElementById('css-editor-id').value;
const name = document.getElementById('css-editor-name').value.trim();
const sourceType = document.getElementById('css-editor-type').value;
if (!name) {
cssEditorModal.showError(t('color_strip.error.name_required'));
return;
}
const payload = {
let payload;
if (sourceType === 'static') {
payload = {
name,
color: hexToRgbArray(document.getElementById('css-editor-color').value),
};
if (!cssId) {
payload.source_type = 'static';
}
} else {
payload = {
name,
picture_source_id: document.getElementById('css-editor-picture-source').value,
fps: parseInt(document.getElementById('css-editor-fps').value) || 30,
@@ -167,6 +233,10 @@ export async function saveCSSEditor() {
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
if (!cssId) {
payload.source_type = 'picture';
}
}
try {
let response;
@@ -176,7 +246,6 @@ export async function saveCSSEditor() {
body: JSON.stringify(payload),
});
} else {
payload.source_type = 'picture';
response = await fetchWithAuth('/color-strip-sources', {
method: 'POST',
body: JSON.stringify(payload),

View File

@@ -563,11 +563,17 @@
"color_strip.test_device.hint": "Select a device to send test pixels to when clicking edge toggles",
"color_strip.leds": "LED count",
"color_strip.led_count": "LED Count:",
"color_strip.led_count.hint": "Total number of LEDs on the physical strip. Set to 0 to use the sum from calibration. Useful when the strip has LEDs behind the TV that are not mapped to screen edges — those LEDs will be sent black.",
"color_strip.led_count.hint": "Total number of LEDs on the physical strip. For screen sources: 0 = auto from calibration (extra LEDs not mapped to edges will be black). For static color: set to match your device LED count.",
"color_strip.created": "Color strip source created",
"color_strip.updated": "Color strip source updated",
"color_strip.deleted": "Color strip source deleted",
"color_strip.delete.confirm": "Are you sure you want to delete this color strip source?",
"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.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.",
"color_strip.type.picture": "Picture Source",
"color_strip.type.static": "Static Color",
"color_strip.static_color": "Color:",
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip."
}

View File

@@ -563,11 +563,17 @@
"color_strip.test_device.hint": "Выберите устройство для отправки тестовых пикселей при нажатии на рамку",
"color_strip.leds": "Количество светодиодов",
"color_strip.led_count": "Количество LED:",
"color_strip.led_count.hint": "Общее число светодиодов на физической полосе. 0 = взять из калибровки. Укажите явно, если на полосе есть светодиоды за телевизором, не привязанные к краям экрана — им будет отправлен чёрный цвет.",
"color_strip.led_count.hint": "Общее число светодиодов на физической полосе. Для источников экрана: 0 = автоматически из калибровки (светодиоды за ТВ будут чёрными). Для статического цвета: укажите точное количество светодиодов устройства.",
"color_strip.created": "Источник цветовой полосы создан",
"color_strip.updated": "Источник цветовой полосы обновлён",
"color_strip.deleted": "Источник цветовой полосы удалён",
"color_strip.delete.confirm": "Удалить этот источник цветовой полосы?",
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
"color_strip.error.name_required": "Введите название"
"color_strip.error.name_required": "Введите название",
"color_strip.type": "Тип:",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом.",
"color_strip.type.picture": "Источник изображения",
"color_strip.type.static": "Статический цвет",
"color_strip.static_color": "Цвет:",
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы."
}

View File

@@ -6,9 +6,9 @@ calibration, color correction, smoothing, and FPS.
Current types:
PictureColorStripSource — derives LED colors from a PictureSource (screen capture)
StaticColorStripSource — constant solid color fills all LEDs
Future types (not yet implemented):
StaticColorStripSource — constant solid colors
GradientColorStripSource — animated gradient
"""
@@ -53,6 +53,7 @@ class ColorStripSource:
"interpolation_mode": None,
"calibration": None,
"led_count": None,
"color": None,
}
@staticmethod
@@ -85,7 +86,20 @@ class ColorStripSource:
else CalibrationConfig(layout="clockwise", start_position="bottom_left")
)
# Only "picture" type for now; extend with elif branches for future types
if source_type == "static":
raw_color = data.get("color")
color = (
raw_color if isinstance(raw_color, list) and len(raw_color) == 3
else [255, 255, 255]
)
return StaticColorStripSource(
id=sid, name=name, source_type="static",
created_at=created_at, updated_at=updated_at, description=description,
color=color,
led_count=data.get("led_count") or 0,
)
# Default: "picture" type
return PictureColorStripSource(
id=sid, name=name, source_type=source_type,
created_at=created_at, updated_at=updated_at, description=description,
@@ -133,3 +147,22 @@ class PictureColorStripSource(ColorStripSource):
d["calibration"] = calibration_to_dict(self.calibration)
d["led_count"] = self.led_count
return d
@dataclass
class StaticColorStripSource(ColorStripSource):
"""Color strip source that fills all LEDs with a single static color.
No capture or processing — the entire LED strip is set to one constant
RGB color. Useful for solid-color accents or as a placeholder while
a PictureColorStripSource is being configured.
"""
color: list = field(default_factory=lambda: [255, 255, 255]) # [R, G, B]
led_count: int = 0 # 0 = use device LED count
def to_dict(self) -> dict:
d = super().to_dict()
d["color"] = list(self.color)
d["led_count"] = self.led_count
return d

View File

@@ -10,6 +10,7 @@ from wled_controller.core.capture.calibration import CalibrationConfig, calibrat
from wled_controller.storage.color_strip_source import (
ColorStripSource,
PictureColorStripSource,
StaticColorStripSource,
)
from wled_controller.utils import get_logger
@@ -99,6 +100,7 @@ class ColorStripStore:
interpolation_mode: str = "average",
calibration=None,
led_count: int = 0,
color: Optional[list] = None,
description: Optional[str] = None,
) -> ColorStripSource:
"""Create a new color strip source.
@@ -113,12 +115,24 @@ class ColorStripStore:
if source.name == name:
raise ValueError(f"Color strip source with name '{name}' already exists")
if calibration is None:
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
source_id = f"css_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
if source_type == "static":
rgb = color if isinstance(color, list) and len(color) == 3 else [255, 255, 255]
source = StaticColorStripSource(
id=source_id,
name=name,
source_type="static",
created_at=now,
updated_at=now,
description=description,
color=rgb,
led_count=led_count,
)
else:
if calibration is None:
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
source = PictureColorStripSource(
id=source_id,
name=name,
@@ -156,6 +170,7 @@ class ColorStripStore:
interpolation_mode: Optional[str] = None,
calibration=None,
led_count: Optional[int] = None,
color: Optional[list] = None,
description: Optional[str] = None,
) -> ColorStripSource:
"""Update an existing color strip source.
@@ -196,6 +211,12 @@ class ColorStripStore:
source.calibration = calibration
if led_count is not None:
source.led_count = led_count
elif isinstance(source, StaticColorStripSource):
if color is not None:
if isinstance(color, list) and len(color) == 3:
source.color = color
if led_count is not None:
source.led_count = led_count
source.updated_at = datetime.utcnow()
self._save()

View File

@@ -14,6 +14,20 @@
<input type="text" id="css-editor-name" data-i18n-placeholder="color_strip.name.placeholder" placeholder="Wall Strip" required>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-type" data-i18n="color_strip.type">Type:</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.type.hint">Picture Source derives colors from a screen capture. Static Color fills all LEDs with one constant color.</small>
<select id="css-editor-type" onchange="onCSSTypeChange()">
<option value="picture" data-i18n="color_strip.type.picture">Picture Source</option>
<option value="static" data-i18n="color_strip.type.static">Static Color</option>
</select>
</div>
<!-- Picture-source-specific fields -->
<div id="css-editor-picture-section">
<div class="form-group">
<div class="label-row">
<label for="css-editor-picture-source" data-i18n="color_strip.picture_source">Picture Source:</label>
@@ -103,8 +117,22 @@
</div>
</div>
</details>
</div>
<!-- Static-color-specific fields -->
<div id="css-editor-static-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-color" data-i18n="color_strip.static_color">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.static_color.hint">The solid color that will be sent to all LEDs on the strip.</small>
<input type="color" id="css-editor-color" value="#ffffff">
</div>
</div>
<!-- LED count — picture type only (auto-sized from device for static) -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">
<label for="css-editor-led-count" data-i18n="color_strip.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>