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 ba1e311..512f9c1 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -34,6 +34,7 @@ class CompositeLayer(BaseModel): blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen") opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0") enabled: bool = Field(default=True, description="Whether this layer is active") + brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness") class MappedZone(BaseModel): 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 8d6635e..486ffff 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 @@ -68,7 +68,7 @@ class ColorStripStreamManager: keyed by ``{css_id}:{consumer_id}``. """ - def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None): + def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None): """ Args: color_strip_store: ColorStripStore for resolving source configs @@ -76,6 +76,7 @@ class ColorStripStreamManager: audio_capture_manager: AudioCaptureManager for audio-reactive sources audio_source_store: AudioSourceStore for resolving audio source chains sync_clock_manager: SyncClockManager for acquiring clock runtimes + value_stream_manager: ValueStreamManager for per-layer brightness sources """ self._color_strip_store = color_strip_store self._live_stream_manager = live_stream_manager @@ -83,6 +84,7 @@ class ColorStripStreamManager: self._audio_source_store = audio_source_store self._audio_template_store = audio_template_store self._sync_clock_manager = sync_clock_manager + self._value_stream_manager = value_stream_manager self._streams: Dict[str, _ColorStripEntry] = {} def _inject_clock(self, css_stream, source) -> Optional[str]: @@ -159,7 +161,7 @@ class ColorStripStreamManager: css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store, self._audio_template_store) elif source.source_type == "composite": from wled_controller.core.processing.composite_stream import CompositeColorStripStream - css_stream = CompositeColorStripStream(source, self) + css_stream = CompositeColorStripStream(source, self, self._value_stream_manager) elif source.source_type == "mapped": from wled_controller.core.processing.mapped_stream import MappedColorStripStream css_stream = MappedColorStripStream(source, self) diff --git a/server/src/wled_controller/core/processing/composite_stream.py b/server/src/wled_controller/core/processing/composite_stream.py index 17acbab..cd85fe4 100644 --- a/server/src/wled_controller/core/processing/composite_stream.py +++ b/server/src/wled_controller/core/processing/composite_stream.py @@ -29,12 +29,13 @@ class CompositeColorStripStream(ColorStripStream): sub-stream's latest colors and blending bottom-to-top. """ - def __init__(self, source, css_manager): + def __init__(self, source, css_manager, value_stream_manager=None): self._source_id: str = source.id self._layers: List[dict] = list(source.layers) self._led_count: int = source.led_count self._auto_size: bool = source.led_count == 0 self._css_manager = css_manager + self._value_stream_manager = value_stream_manager self._fps: int = 30 self._frame_time: float = 1.0 / 30 @@ -45,7 +46,9 @@ class CompositeColorStripStream(ColorStripStream): # layer_index -> (source_id, consumer_id, stream) self._sub_streams: Dict[int, tuple] = {} - self._sub_lock = threading.Lock() # guards _sub_streams access across threads + # layer_index -> (vs_id, value_stream) + self._brightness_streams: Dict[int, tuple] = {} + self._sub_lock = threading.Lock() # guards _sub_streams and _brightness_streams # Pre-allocated scratch (rebuilt when LED count changes) self._pool_n = 0 @@ -115,9 +118,9 @@ class CompositeColorStripStream(ColorStripStream): def update_source(self, source) -> None: """Hot-update: rebuild sub-streams if layer config changed.""" new_layers = list(source.layers) - old_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled")) + old_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"), l.get("brightness_source_id")) for l in self._layers] - new_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled")) + new_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"), l.get("brightness_source_id")) for l in new_layers] self._layers = new_layers @@ -152,6 +155,16 @@ class CompositeColorStripStream(ColorStripStream): logger.warning( f"Composite layer {i} (source {src_id}) failed to acquire: {e}" ) + # Acquire brightness value stream if configured + vs_id = layer.get("brightness_source_id") + if vs_id and self._value_stream_manager: + try: + vs = self._value_stream_manager.acquire(vs_id) + self._brightness_streams[i] = (vs_id, vs) + except Exception as e: + logger.warning( + f"Composite layer {i} brightness source {vs_id} failed: {e}" + ) def _release_sub_streams(self) -> None: for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()): @@ -160,6 +173,14 @@ class CompositeColorStripStream(ColorStripStream): except Exception as e: logger.warning(f"Composite layer release error ({src_id}): {e}") self._sub_streams.clear() + # Release brightness value streams + if self._value_stream_manager: + for _idx, (vs_id, _vs) in list(self._brightness_streams.items()): + try: + self._value_stream_manager.release(vs_id) + except Exception as e: + logger.warning(f"Composite brightness release error ({vs_id}): {e}") + self._brightness_streams.clear() # ── Scratch pool ──────────────────────────────────────────── @@ -299,6 +320,13 @@ class CompositeColorStripStream(ColorStripStream): if len(colors) != target_n: colors = self._resize_to_target(colors, target_n) + # Apply per-layer brightness from value source + if i in self._brightness_streams: + _vs_id, vs = self._brightness_streams[i] + bri = vs.get_value() + if bri < 1.0: + colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8) + opacity = layer.get("opacity", 1.0) blend_mode = layer.get("blend_mode", _BLEND_NORMAL) alpha = int(opacity * 256) diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index c57b5e6..56ee49c 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -122,6 +122,8 @@ class ProcessorManager: live_stream_manager=self._live_stream_manager, audio_template_store=audio_template_store, ) if value_source_store else None + # Wire value stream manager into CSS stream manager for composite layer brightness + self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager self._overlay_manager = OverlayManager() self._event_queues: List[asyncio.Queue] = [] self._metrics_history = MetricsHistory(self) 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 749abb1..1efd3bc 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -3,12 +3,12 @@ */ import { fetchWithAuth, escapeHtml } from '../core/api.js'; -import { _cachedSyncClocks, audioSourcesCache, streamsCache, colorStripSourcesCache } from '../core/state.js'; +import { _cachedSyncClocks, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache } from '../core/state.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { - getColorStripIcon, getPictureSourceIcon, getAudioSourceIcon, + getColorStripIcon, getPictureSourceIcon, getAudioSourceIcon, getValueSourceIcon, ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, @@ -36,6 +36,7 @@ class CSSEditorModal extends Modal { onForceClose() { if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; } + _compositeDestroyEntitySelects(); } snapshotValues() { @@ -170,7 +171,10 @@ export function onCSSTypeChange() { onAudioVizChange(); } if (type === 'gradient') _ensureGradientPresetIconSelect(); - if (type === 'notification') _ensureNotificationEffectIconSelect(); + if (type === 'notification') { + _ensureNotificationEffectIconSelect(); + _ensureNotificationFilterModeIconSelect(); + } // Animation section — shown for static/gradient only const animSection = document.getElementById('css-editor-animation-section'); @@ -319,6 +323,7 @@ let _audioPaletteIconSelect = null; let _audioVizIconSelect = null; let _gradientPresetIconSelect = null; let _notificationEffectIconSelect = null; +let _notificationFilterModeIconSelect = null; const _icon = (d) => `${d}`; @@ -405,6 +410,18 @@ function _ensureNotificationEffectIconSelect() { _notificationEffectIconSelect = new IconSelect({ target: sel, items, columns: 3 }); } +function _ensureNotificationFilterModeIconSelect() { + const sel = document.getElementById('css-editor-notification-filter-mode'); + if (!sel) return; + const items = [ + { value: 'off', icon: _icon(P.globe), label: t('color_strip.notification.filter_mode.off'), desc: t('color_strip.notification.filter_mode.off.desc') }, + { value: 'whitelist', icon: _icon(P.circleCheck), label: t('color_strip.notification.filter_mode.whitelist'), desc: t('color_strip.notification.filter_mode.whitelist.desc') }, + { value: 'blacklist', icon: _icon(P.eyeOff), label: t('color_strip.notification.filter_mode.blacklist'), desc: t('color_strip.notification.filter_mode.blacklist.desc') }, + ]; + if (_notificationFilterModeIconSelect) { _notificationFilterModeIconSelect.updateItems(items); return; } + _notificationFilterModeIconSelect = new IconSelect({ target: sel, items, columns: 3 }); +} + /* ── Effect type helpers ──────────────────────────────────────── */ // Palette color control points — mirrors _PALETTE_DEFS in effect_stream.py @@ -504,14 +521,57 @@ function _loadColorCycleState(css) { let _compositeLayers = []; let _compositeAvailableSources = []; // non-composite sources for layer dropdowns +let _compositeSourceEntitySelects = []; +let _compositeBrightnessEntitySelects = []; +let _compositeBlendIconSelects = []; + +function _compositeDestroyEntitySelects() { + _compositeSourceEntitySelects.forEach(es => es.destroy()); + _compositeSourceEntitySelects = []; + _compositeBrightnessEntitySelects.forEach(es => es.destroy()); + _compositeBrightnessEntitySelects = []; + _compositeBlendIconSelects.forEach(is => is.destroy()); + _compositeBlendIconSelects = []; +} + +function _getCompositeBlendItems() { + return [ + { value: 'normal', icon: _icon(P.square), label: t('color_strip.composite.blend_mode.normal'), desc: t('color_strip.composite.blend_mode.normal.desc') }, + { value: 'add', icon: _icon(P.sun), label: t('color_strip.composite.blend_mode.add'), desc: t('color_strip.composite.blend_mode.add.desc') }, + { value: 'multiply', icon: _icon(P.eye), label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') }, + { value: 'screen', icon: _icon(P.monitor), label: t('color_strip.composite.blend_mode.screen'), desc: t('color_strip.composite.blend_mode.screen.desc') }, + ]; +} + +function _getCompositeSourceItems() { + return _compositeAvailableSources.map(s => ({ + value: s.id, + label: s.name, + icon: getColorStripIcon(s.source_type), + })); +} + +function _getCompositeBrightnessItems() { + return (_cachedValueSources || []).map(v => ({ + value: v.id, + label: v.name, + icon: getValueSourceIcon(v.source_type), + })); +} function _compositeRenderList() { const list = document.getElementById('composite-layers-list'); if (!list) return; + _compositeDestroyEntitySelects(); + const vsList = _cachedValueSources || []; list.innerHTML = _compositeLayers.map((layer, i) => { const srcOptions = _compositeAvailableSources.map(s => `` ).join(''); + const vsOptions = `` + + vsList.map(v => + `` + ).join(''); const canRemove = _compositeLayers.length > 1; return `
@@ -540,6 +600,12 @@ function _compositeRenderList() { onclick="compositeRemoveLayer(${i})">✕` : ''}
+
+ + +
`; }).join(''); @@ -551,6 +617,33 @@ function _compositeRenderList() { el.closest('.composite-layer-row').querySelector('.composite-opacity-val').textContent = val.toFixed(2); }); }); + + // Attach IconSelect to each layer's blend mode dropdown + const blendItems = _getCompositeBlendItems(); + list.querySelectorAll('.composite-layer-blend').forEach(sel => { + const is = new IconSelect({ target: sel, items: blendItems, columns: 2 }); + _compositeBlendIconSelects.push(is); + }); + + // Attach EntitySelect to each layer's source dropdown + list.querySelectorAll('.composite-layer-source').forEach(sel => { + _compositeSourceEntitySelects.push(new EntitySelect({ + target: sel, + getItems: _getCompositeSourceItems, + placeholder: t('palette.search'), + })); + }); + + // Attach EntitySelect to each layer's brightness dropdown + list.querySelectorAll('.composite-layer-brightness').forEach(sel => { + _compositeBrightnessEntitySelects.push(new EntitySelect({ + target: sel, + getItems: _getCompositeBrightnessItems, + placeholder: t('palette.search'), + allowNone: true, + noneLabel: t('color_strip.composite.brightness.none'), + })); + }); } export function compositeAddLayer() { @@ -560,6 +653,7 @@ export function compositeAddLayer() { blend_mode: 'normal', opacity: 1.0, enabled: true, + brightness_source_id: null, }); _compositeRenderList(); } @@ -578,24 +672,30 @@ function _compositeLayersSyncFromDom() { const blends = list.querySelectorAll('.composite-layer-blend'); const opacities = list.querySelectorAll('.composite-layer-opacity'); const enableds = list.querySelectorAll('.composite-layer-enabled'); + const briSrcs = list.querySelectorAll('.composite-layer-brightness'); if (srcs.length === _compositeLayers.length) { for (let i = 0; i < srcs.length; i++) { _compositeLayers[i].source_id = srcs[i].value; _compositeLayers[i].blend_mode = blends[i].value; _compositeLayers[i].opacity = parseFloat(opacities[i].value); _compositeLayers[i].enabled = enableds[i].checked; + _compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null; } } } function _compositeGetLayers() { _compositeLayersSyncFromDom(); - return _compositeLayers.map(l => ({ - source_id: l.source_id, - blend_mode: l.blend_mode, - opacity: l.opacity, - enabled: l.enabled, - })); + return _compositeLayers.map(l => { + const layer = { + source_id: l.source_id, + blend_mode: l.blend_mode, + opacity: l.opacity, + enabled: l.enabled, + }; + if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id; + return layer; + }); } function _loadCompositeState(css) { @@ -606,8 +706,9 @@ function _loadCompositeState(css) { blend_mode: l.blend_mode || 'normal', opacity: l.opacity != null ? l.opacity : 1.0, enabled: l.enabled != null ? l.enabled : true, + brightness_source_id: l.brightness_source_id || null, })) - : [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true }]; + : [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null }]; _compositeRenderList(); } @@ -905,6 +1006,7 @@ function _loadNotificationState(css) { document.getElementById('css-editor-notification-duration-val').textContent = dur; document.getElementById('css-editor-notification-default-color').value = css.default_color || '#ffffff'; document.getElementById('css-editor-notification-filter-mode').value = css.app_filter_mode || 'off'; + if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off'); document.getElementById('css-editor-notification-filter-list').value = (css.app_filter_list || []).join('\n'); onNotificationFilterModeChange(); _attachNotificationProcessPicker(); @@ -924,6 +1026,7 @@ function _resetNotificationState() { document.getElementById('css-editor-notification-duration-val').textContent = '1500'; document.getElementById('css-editor-notification-default-color').value = '#ffffff'; document.getElementById('css-editor-notification-filter-mode').value = 'off'; + if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off'); document.getElementById('css-editor-notification-filter-list').value = ''; onNotificationFilterModeChange(); _attachNotificationProcessPicker(); @@ -1195,6 +1298,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) { const sources = await streamsCache.fetch(); // Fetch all color strip sources for composite layer dropdowns + await valueSourcesCache.fetch().catch(() => []); const allCssSources = await colorStripSourcesCache.fetch().catch(() => []); _compositeAvailableSources = allCssSources.filter(s => s.source_type !== 'composite' && (!cssId || s.id !== cssId) diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index a14dd7b..2006244 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -902,6 +902,9 @@ "color_strip.notification.filter_mode.off": "Off", "color_strip.notification.filter_mode.whitelist": "Whitelist", "color_strip.notification.filter_mode.blacklist": "Blacklist", + "color_strip.notification.filter_mode.off.desc": "Accept all notifications", + "color_strip.notification.filter_mode.whitelist.desc": "Only listed apps", + "color_strip.notification.filter_mode.blacklist.desc": "All except listed apps", "color_strip.notification.filter_list": "App List:", "color_strip.notification.filter_list.hint": "One app name per line. Use Browse to pick from running processes.", "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", @@ -945,10 +948,16 @@ "color_strip.composite.source": "Source", "color_strip.composite.blend_mode": "Blend", "color_strip.composite.blend_mode.normal": "Normal", + "color_strip.composite.blend_mode.normal.desc": "Standard alpha blending", "color_strip.composite.blend_mode.add": "Add", + "color_strip.composite.blend_mode.add.desc": "Brightens by adding colors", "color_strip.composite.blend_mode.multiply": "Multiply", + "color_strip.composite.blend_mode.multiply.desc": "Darkens by multiplying colors", "color_strip.composite.blend_mode.screen": "Screen", + "color_strip.composite.blend_mode.screen.desc": "Brightens, inverse of multiply", "color_strip.composite.opacity": "Opacity", + "color_strip.composite.brightness": "Brightness", + "color_strip.composite.brightness.none": "— None —", "color_strip.composite.enabled": "Enabled", "color_strip.composite.error.min_layers": "At least 1 layer is required", "color_strip.composite.error.no_source": "Each layer must have a source selected", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 90c1817..b157984 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -902,6 +902,9 @@ "color_strip.notification.filter_mode.off": "Выкл", "color_strip.notification.filter_mode.whitelist": "Белый список", "color_strip.notification.filter_mode.blacklist": "Чёрный список", + "color_strip.notification.filter_mode.off.desc": "Принимать все уведомления", + "color_strip.notification.filter_mode.whitelist.desc": "Только указанные приложения", + "color_strip.notification.filter_mode.blacklist.desc": "Все кроме указанных приложений", "color_strip.notification.filter_list": "Список приложений:", "color_strip.notification.filter_list.hint": "Одно имя приложения на строку. Используйте «Обзор» для выбора из запущенных процессов.", "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", @@ -945,10 +948,16 @@ "color_strip.composite.source": "Источник", "color_strip.composite.blend_mode": "Смешивание", "color_strip.composite.blend_mode.normal": "Обычное", + "color_strip.composite.blend_mode.normal.desc": "Стандартное альфа-смешивание", "color_strip.composite.blend_mode.add": "Сложение", + "color_strip.composite.blend_mode.add.desc": "Осветляет, складывая цвета", "color_strip.composite.blend_mode.multiply": "Умножение", + "color_strip.composite.blend_mode.multiply.desc": "Затемняет, умножая цвета", "color_strip.composite.blend_mode.screen": "Экран", + "color_strip.composite.blend_mode.screen.desc": "Осветляет, обратное умножение", "color_strip.composite.opacity": "Непрозрачность", + "color_strip.composite.brightness": "Яркость", + "color_strip.composite.brightness.none": "— Нет —", "color_strip.composite.enabled": "Включён", "color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой", "color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 09d257e..bb02f8c 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -902,6 +902,9 @@ "color_strip.notification.filter_mode.off": "关闭", "color_strip.notification.filter_mode.whitelist": "白名单", "color_strip.notification.filter_mode.blacklist": "黑名单", + "color_strip.notification.filter_mode.off.desc": "接受所有通知", + "color_strip.notification.filter_mode.whitelist.desc": "仅列出的应用", + "color_strip.notification.filter_mode.blacklist.desc": "排除列出的应用", "color_strip.notification.filter_list": "应用列表:", "color_strip.notification.filter_list.hint": "每行一个应用名称。使用「浏览」从运行中的进程中选择。", "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", @@ -945,10 +948,16 @@ "color_strip.composite.source": "源", "color_strip.composite.blend_mode": "混合", "color_strip.composite.blend_mode.normal": "正常", + "color_strip.composite.blend_mode.normal.desc": "标准 Alpha 混合", "color_strip.composite.blend_mode.add": "叠加", + "color_strip.composite.blend_mode.add.desc": "通过叠加颜色提亮", "color_strip.composite.blend_mode.multiply": "正片叠底", + "color_strip.composite.blend_mode.multiply.desc": "通过相乘颜色变暗", "color_strip.composite.blend_mode.screen": "滤色", + "color_strip.composite.blend_mode.screen.desc": "提亮,正片叠底的反转", "color_strip.composite.opacity": "不透明度", + "color_strip.composite.brightness": "亮度", + "color_strip.composite.brightness.none": "— 无 —", "color_strip.composite.enabled": "启用", "color_strip.composite.error.min_layers": "至少需要 1 个图层", "color_strip.composite.error.no_source": "每个图层必须选择一个源", diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 59a48ec..11b62d1 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -518,7 +518,7 @@ class CompositeColorStripSource(ColorStripSource): when led_count == 0. """ - # Each layer: {"source_id": str, "blend_mode": str, "opacity": float, "enabled": bool} + # Each layer: {"source_id": str, "blend_mode": str, "opacity": float, "enabled": bool, "brightness_source_id": str|None} layers: list = field(default_factory=list) led_count: int = 0 # 0 = use device LED count