refactor(color-strip): rename static -> single + frontend follow-through

The "static" source kind always rendered a SINGLE color and the name
confused new code paths. Rename the module + kind to "single". Storage
keeps backward-compatible serialisation. Frontend color-strip cards /
gradient / index / test modules and the affected tests follow the new
name.
This commit is contained in:
2026-05-23 00:49:00 +03:00
parent 737fd72b73
commit 826e680f37
10 changed files with 74 additions and 67 deletions
@@ -9,12 +9,12 @@ from .base import ColorStripStream, _SimpleNoise1D, _gradient_noise
from .gradient import GradientColorStripStream
from .helpers import _compute_gradient_colors
from .picture import PictureColorStripStream
from .static import StaticColorStripStream
from .single import SingleColorStripStream
__all__ = [
"ColorStripStream",
"PictureColorStripStream",
"StaticColorStripStream",
"SingleColorStripStream",
"GradientColorStripStream",
"_compute_gradient_colors",
"_SimpleNoise1D",
@@ -1,4 +1,4 @@
"""Static color strip stream — solid color with optional animation."""
"""Single color strip stream — solid color with optional animation."""
import colorsys
import math
@@ -18,7 +18,7 @@ from .base import ColorStripStream
logger = get_logger(__name__)
class StaticColorStripStream(ColorStripStream):
class SingleColorStripStream(ColorStripStream):
"""Color strip stream that returns a constant single-color array.
When animation is enabled a 30 fps background thread updates _colors with
@@ -28,7 +28,7 @@ class StaticColorStripStream(ColorStripStream):
def __init__(self, source):
"""
Args:
source: StaticColorStripSource config
source: SingleColorStripSource config
"""
self._colors_lock = threading.Lock()
self._running = False
@@ -64,7 +64,7 @@ class StaticColorStripStream(ColorStripStream):
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")
logger.debug(f"SingleColorStripStream auto-sized to {device_led_count} LEDs")
@property
def target_fps(self) -> int:
@@ -98,36 +98,36 @@ class StaticColorStripStream(ColorStripStream):
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
name="css-static-animate",
name="css-single-animate",
daemon=True,
)
self._thread.start()
logger.info(f"StaticColorStripStream started (leds={self._led_count})")
logger.info(f"SingleColorStripStream 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("StaticColorStripStream animate thread did not terminate within 5s")
logger.warning("SingleColorStripStream animate thread did not terminate within 5s")
self._thread = None
logger.info("StaticColorStripStream stopped")
logger.info("SingleColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
from ledgrab.storage.color_strip_source import StaticColorStripSource
from ledgrab.storage.color_strip_source import SingleColorStripSource
if isinstance(source, StaticColorStripSource):
if isinstance(source, SingleColorStripSource):
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")
logger.info("SingleColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
@@ -266,7 +266,7 @@ class StaticColorStripStream(ColorStripStream):
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"StaticColorStripStream animation error: {e}")
logger.error(f"SingleColorStripStream animation error: {e}")
if (anim and anim.get("enabled")) or self._is_color_bound():
sleep_target = frame_time
@@ -274,6 +274,6 @@ class StaticColorStripStream(ColorStripStream):
sleep_target = 0.25
limiter.wait(sleep_target)
except Exception as e:
logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True)
logger.error(f"Fatal SingleColorStripStream loop error: {e}", exc_info=True)
finally:
self._running = False
@@ -9,7 +9,7 @@ from ledgrab.core.processing.color_strip import ( # noqa: F401
ColorStripStream,
GradientColorStripStream,
PictureColorStripStream,
StaticColorStripStream,
SingleColorStripStream,
_compute_gradient_colors,
_gradient_noise,
_SimpleNoise1D,
@@ -18,7 +18,7 @@ from ledgrab.core.processing.color_strip import ( # noqa: F401
__all__ = [
"ColorStripStream",
"PictureColorStripStream",
"StaticColorStripStream",
"SingleColorStripStream",
"GradientColorStripStream",
"_compute_gradient_colors",
"_SimpleNoise1D",
@@ -38,7 +38,7 @@ registerIconEntityType('color_strip_source', makeSimpleIconAdapter<ColorStripSou
typeLabelKey: 'device.icon.entity.color_strip_source',
typeLabelFallback: 'Color strip',
cardSelectors: (id) => [`[data-css-id="${CSS.escape(id)}"]`],
bodyExtras: (rec) => ({ source_type: (rec as any)?.source_type ?? 'static' }),
bodyExtras: (rec) => ({ source_type: (rec as any)?.source_type ?? 'single_color' }),
}));
/* ── Types ────────────────────────────────────────────────────── */
@@ -88,7 +88,7 @@ function _gradientEntityStripHTML(stops: Array<{ position: number; color: number
/* ── Non-picture types set ────────────────────────────────────── */
const NON_PICTURE_TYPES = new Set([
'static', 'gradient', 'effect', 'composite', 'mapped',
'single_color', 'gradient', 'effect', 'composite', 'mapped',
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'math_wave',
]);
@@ -96,8 +96,8 @@ const NON_PICTURE_TYPES = new Set([
/* ── Per-type card property renderers ─────────────────────────── */
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
static: (source, { clockBadge, animBadge }) => {
const colorBadge = _bindableColorBadge(source.color, [255, 255, 255], t('color_strip.static_color'));
single_color: (source, { clockBadge, animBadge }) => {
const colorBadge = _bindableColorBadge(source.color, [255, 255, 255], t('color_strip.single_color'));
return `
${colorBadge}
${animBadge}
@@ -312,7 +312,7 @@ function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Rec
/* ── Main card builder ────────────────────────────────────────── */
const STRIP_BADGE: Record<string, string> = {
static: 'STRIP · COLOR',
single_color: 'STRIP · COLOR',
gradient: 'STRIP · GRD',
effect: 'STRIP · FX',
composite: 'STRIP · COMP',
@@ -336,7 +336,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
? `<span class="stream-card-prop stream-card-link" title="${t('color_strip.clock')}" onclick="event.stopPropagation(); navigateToCard('streams','sync','sync-clocks','data-id','${source.clock_id}')">${ICON_CLOCK} ${escapeHtml(clockObj.name)}</span>`
: source.clock_id ? `<span class="stream-card-prop">${ICON_CLOCK} ${source.clock_id}</span>` : '';
const isAnimatable = source.source_type === 'static' || source.source_type === 'gradient';
const isAnimatable = source.source_type === 'single_color' || source.source_type === 'gradient';
const anim = isAnimatable && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>`
@@ -194,6 +194,8 @@ export async function closeGradientEditor() {
export async function saveGradientEntity() {
const id = (document.getElementById('gradient-editor-id') as HTMLInputElement).value;
if (gradientEditorModal.closeIfPristine(id)) return;
const name = (document.getElementById('gradient-editor-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('gradient-editor-description') as HTMLInputElement).value.trim() || null;
const tags = _gradientTagsInput ? _gradientTagsInput.getValue() : [];
@@ -127,7 +127,7 @@ class CSSEditorModal extends Modal {
if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; }
if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; }
if (_weatherTempInfluenceWidget) { _weatherTempInfluenceWidget.destroy(); _weatherTempInfluenceWidget = null; }
if (_staticColorWidget) { _staticColorWidget.destroy(); _staticColorWidget = null; }
if (_singleColorWidget) { _singleColorWidget.destroy(); _singleColorWidget = null; }
if (_effectColorWidget) { _effectColorWidget.destroy(); _effectColorWidget = null; }
if (_apiInputFallbackColorWidget) { _apiInputFallbackColorWidget.destroy(); _apiInputFallbackColorWidget = null; }
if (_candlelightColorWidget) { _candlelightColorWidget.destroy(); _candlelightColorWidget = null; }
@@ -150,7 +150,7 @@ class CSSEditorModal extends Modal {
picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
smoothing: _smoothingWidget ? JSON.stringify(_smoothingWidget.getValue()) : '0.3',
color: _staticColorWidget ? JSON.stringify(_staticColorWidget.getValue()) : '[]',
color: _singleColorWidget ? JSON.stringify(_singleColorWidget.getValue()) : '[]',
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value,
@@ -223,7 +223,7 @@ let _candlelightWindWidget: BindableScalarWidget | null = null;
let _weatherSpeedWidget: BindableScalarWidget | null = null;
let _weatherTempInfluenceWidget: BindableScalarWidget | null = null;
let _staticColorWidget: BindableColorWidget | null = null;
let _singleColorWidget: BindableColorWidget | null = null;
let _effectColorWidget: BindableColorWidget | null = null;
let _apiInputFallbackColorWidget: BindableColorWidget | null = null;
let _candlelightColorWidget: BindableColorWidget | null = null;
@@ -303,7 +303,7 @@ async function configureKCRegions(sourceId: string): Promise<void> {
// ══════════════════════════════════════════════════════════════════
const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient',
'picture', 'picture_advanced', 'single_color', 'gradient',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'game_event', 'math_wave',
@@ -341,7 +341,7 @@ function _ensureCSSTypeIconSelect() {
const CSS_SECTION_MAP: Record<string, string> = {
'picture': 'css-editor-picture-section',
'picture_advanced': 'css-editor-picture-section',
'static': 'css-editor-static-section',
'single_color': 'css-editor-single-color-section',
'gradient': 'css-editor-gradient-section',
'effect': 'css-editor-effect-section',
'composite': 'css-editor-composite-section',
@@ -399,7 +399,7 @@ export function onCSSTypeChange() {
const animSection = document.getElementById('css-editor-animation-section') as HTMLElement;
const animTypeSelect = document.getElementById('css-editor-animation-type') as HTMLSelectElement;
if (type === 'static' || type === 'gradient') {
if (type === 'single_color' || type === 'gradient') {
animSection.style.display = '';
const opts = type === 'gradient'
? ['none','breathing','gradient_shift','wave','noise_perturb','hue_rotate','strobe','sparkle','pulse','candle','rainbow_fade']
@@ -417,7 +417,7 @@ export function onCSSTypeChange() {
(document.getElementById('css-editor-led-count-group') as HTMLElement).style.display =
hasLedCount.includes(type) ? '' : 'none';
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
const clockTypes = ['single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
if (clockTypes.includes(type)) _populateClockDropdown();
@@ -726,16 +726,16 @@ function _ensureWeatherTempInfluenceWidget(): BindableScalarWidget {
return _weatherTempInfluenceWidget;
}
function _ensureStaticColorWidget(): BindableColorWidget {
if (!_staticColorWidget) {
_staticColorWidget = new BindableColorWidget({
function _ensureSingleColorWidget(): BindableColorWidget {
if (!_singleColorWidget) {
_singleColorWidget = new BindableColorWidget({
container: document.getElementById('css-editor-color-container')!,
default: [255, 255, 255],
valueSources: () => _cachedValueSources,
idPrefix: 'css-editor-color',
});
}
return _staticColorWidget;
return _singleColorWidget;
}
function _ensureEffectColorWidget(): BindableColorWidget {
@@ -988,17 +988,17 @@ function _autoGenerateCSSName() {
// ══════════════════════════════════════════════════════════════════
const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...args: any[]) => any; getPayload: (name: any) => any }> = {
static: {
single_color: {
load(css) {
_ensureStaticColorWidget().setValue(css.color);
_ensureSingleColorWidget().setValue(css.color);
_loadAnimationState(css.animation);
},
reset() {
_ensureStaticColorWidget().setValue([255, 255, 255]);
_ensureSingleColorWidget().setValue([255, 255, 255]);
_loadAnimationState(null);
},
getPayload(name) {
return { name, color: _ensureStaticColorWidget().getValue(), animation: _getAnimationPayload() };
return { name, color: _ensureSingleColorWidget().getValue(), animation: _getAnimationPayload() };
},
},
gradient: {
@@ -1417,7 +1417,7 @@ export function getCSSEditorPreviewPayload(sourceType: string): any {
const payload = handler.getPayload('__preview__');
if (!payload) return null;
payload.source_type = sourceType;
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
const clockTypes = ['single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
if (clockTypes.includes(sourceType)) {
const clockEl = document.getElementById('css-editor-clock') as HTMLInputElement | null;
if (clockEl && clockEl.value) payload.clock_id = clockEl.value;
@@ -1559,6 +1559,8 @@ export function isCSSEditorDirty() { return cssEditorModal.isDirty(); }
export async function saveCSSEditor() {
const cssId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
if (cssEditorModal.closeIfPristine(cssId)) return;
const name = (document.getElementById('css-editor-name') as HTMLInputElement).value.trim();
const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value;
@@ -1571,7 +1573,7 @@ export async function saveCSSEditor() {
payload.source_type = knownType ? sourceType : 'picture';
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
const clockTypes = ['single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
if (clockTypes.includes(sourceType)) {
payload.clock_id = (document.getElementById('css-editor-clock') as HTMLInputElement).value || null;
}
@@ -30,7 +30,7 @@ import { openAuthedWs } from '../../core/ws-auth.ts';
/* ── Preview config builder ───────────────────────────────────── */
const _PREVIEW_TYPES = new Set([
'static', 'gradient', 'effect', 'daylight', 'candlelight', 'notification', 'audio', 'math_wave',
'single_color', 'gradient', 'effect', 'daylight', 'candlelight', 'notification', 'audio', 'math_wave',
'weather', 'game_event', 'api_input', 'mapped', 'composite', 'processed',
]);
@@ -38,8 +38,8 @@ function _collectPreviewConfig() {
const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value;
if (!_PREVIEW_TYPES.has(sourceType)) return null;
let config: any;
if (sourceType === 'static') {
config = { source_type: 'static', color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), animation: _getAnimationPayload() };
if (sourceType === 'single_color') {
config = { source_type: 'single_color', color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value), animation: _getAnimationPayload() };
} else if (sourceType === 'gradient') {
const stops = getGradientStops();
if (stops.length < 2) return null;
@@ -7,7 +7,7 @@ calibration, color correction, smoothing, and FPS.
Current types:
PictureColorStripSource — derives LED colors from a single PictureSource (simple 4-edge calibration)
AdvancedPictureColorStripSource — line-based calibration across multiple PictureSources
StaticColorStripSource — constant solid color fills all LEDs
SingleColorStripSource — constant solid color fills all LEDs
GradientColorStripSource — linear gradient across all LEDs from user-defined color stops
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
@@ -366,8 +366,8 @@ class AdvancedPictureColorStripSource(ColorStripSource):
@dataclass
class StaticColorStripSource(ColorStripSource):
"""Color strip source that fills all LEDs with a single static color.
class SingleColorStripSource(ColorStripSource):
"""Color strip source that fills all LEDs with a single solid 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
@@ -384,11 +384,11 @@ class StaticColorStripSource(ColorStripSource):
return d
@classmethod
def from_dict(cls, data: dict) -> "StaticColorStripSource":
def from_dict(cls, data: dict) -> "SingleColorStripSource":
common = _parse_css_common(data)
return cls(
**common,
source_type="static",
source_type="single_color",
color=BindableColor.from_raw(data.get("color"), default=[255, 255, 255]),
animation=data.get("animation"),
)
@@ -412,7 +412,7 @@ class StaticColorStripSource(ColorStripSource):
return cls(
id=id,
name=name,
source_type="static",
source_type="single_color",
created_at=created_at,
updated_at=updated_at,
description=description,
@@ -1823,7 +1823,10 @@ class MathWaveColorStripSource(ColorStripSource):
_SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
"picture": PictureColorStripSource,
"picture_advanced": AdvancedPictureColorStripSource,
"static": StaticColorStripSource,
"single_color": SingleColorStripSource,
# Legacy alias: pre-rename rows used "static". Kept so old DBs deserialize;
# ColorStripStore migrates the on-disk source_type to "single_color" on startup.
"static": SingleColorStripSource,
"gradient": GradientColorStripSource,
"effect": EffectColorStripSource,
"audio": AudioColorStripSource,
+15 -15
View File
@@ -7,23 +7,23 @@ Tests creating, listing, updating, cloning, and deleting color strip sources.
class TestColorStripSourceLifecycle:
"""A user manages color strip sources for LED effects."""
def test_static_and_gradient_crud(self, client):
# 1. Create a static color strip source
def test_single_color_and_gradient_crud(self, client):
# 1. Create a single-color strip source
resp = client.post(
"/api/v1/color-strip-sources",
json={
"name": "Red Static",
"source_type": "static",
"name": "Red Single",
"source_type": "single_color",
"color": [255, 0, 0],
"led_count": 60,
"tags": ["e2e", "static"],
"tags": ["e2e", "single_color"],
},
)
assert resp.status_code == 201, f"Create static failed: {resp.text}"
assert resp.status_code == 201, f"Create single_color failed: {resp.text}"
static = resp.json()
static_id = static["id"]
assert static["name"] == "Red Static"
assert static["source_type"] == "static"
assert static["name"] == "Red Single"
assert static["source_type"] == "single_color"
assert static["color"] == [255, 0, 0]
# 2. Create a gradient color strip source
@@ -58,7 +58,7 @@ class TestColorStripSourceLifecycle:
# 4. Update the static source -- change color
resp = client.put(
f"/api/v1/color-strip-sources/{static_id}",
json={"source_type": "static", "color": [0, 255, 0]},
json={"source_type": "single_color", "color": [0, 255, 0]},
)
assert resp.status_code == 200
assert resp.json()["color"] == [0, 255, 0]
@@ -72,8 +72,8 @@ class TestColorStripSourceLifecycle:
resp = client.post(
"/api/v1/color-strip-sources",
json={
"name": "Cloned Static",
"source_type": "static",
"name": "Cloned Single",
"source_type": "single_color",
"color": [0, 255, 0],
"led_count": 60,
},
@@ -81,7 +81,7 @@ class TestColorStripSourceLifecycle:
assert resp.status_code == 201
clone_id = resp.json()["id"]
assert clone_id != static_id
assert resp.json()["name"] == "Cloned Static"
assert resp.json()["name"] == "Cloned Single"
# 7. Delete all three
for sid in [static_id, gradient_id, clone_id]:
@@ -99,7 +99,7 @@ class TestColorStripSourceLifecycle:
"/api/v1/color-strip-sources",
json={
"name": "Original Name",
"source_type": "static",
"source_type": "single_color",
"color": [100, 100, 100],
"led_count": 10,
},
@@ -108,7 +108,7 @@ class TestColorStripSourceLifecycle:
resp = client.put(
f"/api/v1/color-strip-sources/{source_id}",
json={"source_type": "static", "name": "New Name"},
json={"source_type": "single_color", "name": "New Name"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "New Name"
@@ -125,7 +125,7 @@ class TestColorStripSourceLifecycle:
"""Cannot create two sources with the same name."""
payload = {
"name": "Unique Name",
"source_type": "static",
"source_type": "single_color",
"color": [0, 0, 0],
"led_count": 10,
}
+2 -2
View File
@@ -22,8 +22,8 @@ def store(tmp_db):
def _create_static(store: ColorStripStore, name: str) -> str:
"""Create a static color strip source, return its ID."""
source = store.create_source(name=name, source_type="static", colors=[[255, 0, 0]])
"""Create a single-color strip source, return its ID."""
source = store.create_source(name=name, source_type="single_color", colors=[[255, 0, 0]])
return source.id