diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index aefebc1..bf48de4 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -100,6 +100,7 @@ def _target_to_response(target) -> PictureTargetResponse: fps=target.fps, keepalive_interval=target.keepalive_interval, state_check_interval=target.state_check_interval, + min_brightness_threshold=target.min_brightness_threshold, description=target.description, auto_start=target.auto_start, created_at=target.created_at, @@ -159,6 +160,7 @@ async def create_target( fps=data.fps, keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, + min_brightness_threshold=data.min_brightness_threshold, picture_source_id=data.picture_source_id, key_colors_settings=kc_settings, description=data.description, @@ -276,6 +278,7 @@ async def update_target( fps=data.fps, keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, + min_brightness_threshold=data.min_brightness_threshold, key_colors_settings=kc_settings, description=data.description, auto_start=data.auto_start, @@ -295,6 +298,7 @@ async def update_target( settings_changed=(data.fps is not None or data.keepalive_interval is not None or data.state_check_interval is not None or + data.min_brightness_threshold is not None or data.key_colors_settings is not None), css_changed=data.color_strip_source_id is not None, device_changed=data.device_id is not None, diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index 758013b..c6b820c 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -58,6 +58,7 @@ class PictureTargetCreate(BaseModel): fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)") keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0) state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600) + min_brightness_threshold: int = Field(default=0, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off") # KC target fields picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") @@ -76,6 +77,7 @@ class PictureTargetUpdate(BaseModel): fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)") keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0) state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600) + min_brightness_threshold: Optional[int] = Field(None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off") # KC target fields picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") @@ -96,6 +98,7 @@ class PictureTargetResponse(BaseModel): fps: Optional[int] = Field(None, description="Target send FPS") keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)") state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)") + min_brightness_threshold: int = Field(default=0, description="Min brightness threshold (0=disabled)") # KC target fields picture_source_id: str = Field(default="", description="Picture source ID (key_colors)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings") diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index ff523f1..be0ce8e 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -317,6 +317,7 @@ class ProcessorManager: keepalive_interval: float = 1.0, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, brightness_value_source_id: str = "", + min_brightness_threshold: int = 0, ): """Register a WLED target processor.""" if target_id in self._processors: @@ -332,6 +333,7 @@ class ProcessorManager: keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, brightness_value_source_id=brightness_value_source_id, + min_brightness_threshold=min_brightness_threshold, ctx=self._build_context(), ) self._processors[target_id] = proc 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 70d3db8..88881d6 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -36,6 +36,7 @@ class WledTargetProcessor(TargetProcessor): keepalive_interval: float = 1.0, state_check_interval: int = 30, brightness_value_source_id: str = "", + min_brightness_threshold: int = 0, ctx: TargetContext = None, ): super().__init__(target_id, ctx) @@ -45,6 +46,7 @@ class WledTargetProcessor(TargetProcessor): self._state_check_interval = state_check_interval self._css_id = color_strip_source_id self._brightness_vs_id = brightness_value_source_id + self._min_brightness_threshold = min_brightness_threshold # Runtime state (populated on start) self._led_client: Optional[LEDClient] = None @@ -210,6 +212,8 @@ class WledTargetProcessor(TargetProcessor): self._keepalive_interval = settings["keepalive_interval"] if "state_check_interval" in settings: self._state_check_interval = settings["state_check_interval"] + if "min_brightness_threshold" in settings: + self._min_brightness_threshold = settings["min_brightness_threshold"] logger.info(f"Updated settings for target {self._target_id}") def update_device(self, device_id: str) -> None: @@ -581,6 +585,15 @@ class WledTargetProcessor(TargetProcessor): cur_brightness = _effective_brightness(device_info) + # Min brightness threshold: combine brightness source + # with max pixel value to get effective output brightness. + # If below cutoff → snap to 0 (LEDs off). + _thresh = self._min_brightness_threshold + if _thresh > 0 and cur_brightness > 0: + max_pixel = int(np.max(frame)) + if max_pixel * cur_brightness // 255 < _thresh: + cur_brightness = 0 + # Zero-brightness suppression: if output is black and # the last sent frame was also black, skip sending. if cur_brightness <= 1 and _prev_brightness <= 1 and has_any_frame: diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index b0085f4..58cdfd1 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -140,6 +140,7 @@ class TargetEditorModal extends Modal { device: document.getElementById('target-editor-device').value, css_source: document.getElementById('target-editor-css-source').value, brightness_vs: document.getElementById('target-editor-brightness-vs').value, + brightness_threshold: document.getElementById('target-editor-brightness-threshold').value, fps: document.getElementById('target-editor-fps').value, keepalive_interval: document.getElementById('target-editor-keepalive-interval').value, }; @@ -198,6 +199,11 @@ function _updateKeepaliveVisibility() { keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none'; } +function _updateBrightnessThresholdVisibility() { + // Always visible — threshold considers both brightness source and pixel content + document.getElementById('target-editor-brightness-threshold-group').style.display = ''; +} + function _populateCssDropdown(selectedId = '') { const select = document.getElementById('target-editor-css-source'); select.innerHTML = _editorCssSources.map(s => @@ -262,6 +268,10 @@ export async function showTargetEditor(targetId = null, cloneData = null) { document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0; document.getElementById('target-editor-title').textContent = t('targets.edit'); + const thresh = target.min_brightness_threshold ?? 0; + document.getElementById('target-editor-brightness-threshold').value = thresh; + document.getElementById('target-editor-brightness-threshold-value').textContent = thresh; + _populateCssDropdown(target.color_strip_source_id || ''); _populateBrightnessVsDropdown(target.brightness_value_source_id || ''); } else if (cloneData) { @@ -276,6 +286,10 @@ export async function showTargetEditor(targetId = null, cloneData = null) { document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0; document.getElementById('target-editor-title').textContent = t('targets.add'); + const cloneThresh = cloneData.min_brightness_threshold ?? 0; + document.getElementById('target-editor-brightness-threshold').value = cloneThresh; + document.getElementById('target-editor-brightness-threshold-value').textContent = cloneThresh; + _populateCssDropdown(cloneData.color_strip_source_id || ''); _populateBrightnessVsDropdown(cloneData.brightness_value_source_id || ''); } else { @@ -288,6 +302,9 @@ export async function showTargetEditor(targetId = null, cloneData = null) { document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0'; document.getElementById('target-editor-title').textContent = t('targets.add'); + document.getElementById('target-editor-brightness-threshold').value = 0; + document.getElementById('target-editor-brightness-threshold-value').textContent = '0'; + _populateCssDropdown(''); _populateBrightnessVsDropdown(''); } @@ -298,12 +315,14 @@ export async function showTargetEditor(targetId = null, cloneData = null) { window._targetAutoName = _autoGenerateTargetName; deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; document.getElementById('target-editor-css-source').onchange = () => { _autoGenerateTargetName(); }; + document.getElementById('target-editor-brightness-vs').onchange = () => { _updateBrightnessThresholdVisibility(); }; if (!targetId && !cloneData) _autoGenerateTargetName(); - // Show/hide standby interval based on selected device capabilities + // Show/hide conditional fields _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); + _updateBrightnessThresholdVisibility(); targetEditorModal.snapshot(); targetEditorModal.open(); @@ -343,12 +362,14 @@ export async function saveTargetEditor() { const colorStripSourceId = document.getElementById('target-editor-css-source').value; const brightnessVsId = document.getElementById('target-editor-brightness-vs').value; + const minBrightnessThreshold = parseInt(document.getElementById('target-editor-brightness-threshold').value) || 0; const payload = { name, device_id: deviceId, color_strip_source_id: colorStripSourceId, brightness_value_source_id: brightnessVsId, + min_brightness_threshold: minBrightnessThreshold, fps, keepalive_interval: standbyInterval, }; @@ -808,6 +829,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo ${ICON_FPS} ${target.fps || 30} 🎞️ ${cssSummary} ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''} + ${target.min_brightness_threshold > 0 ? `🔅 <${target.min_brightness_threshold} → off` : ''}
${isProcessing ? ` diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 0fab620..055565e 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -896,6 +896,8 @@ "targets.brightness_vs": "Brightness Source:", "targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)", "targets.brightness_vs.none": "None (device brightness)", + "targets.min_brightness_threshold": "Min Brightness Threshold:", + "targets.min_brightness_threshold.hint": "Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)", "search.open": "Search (Ctrl+K)", "search.placeholder": "Search entities... (Ctrl+K)", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 2484b29..d414323 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -896,6 +896,8 @@ "targets.brightness_vs": "Источник яркости:", "targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)", "targets.brightness_vs.none": "Нет (яркость устройства)", + "targets.min_brightness_threshold": "Мин. порог яркости:", + "targets.min_brightness_threshold.hint": "Если итоговая яркость (яркость пикселей × яркость устройства/источника) ниже этого значения, светодиоды полностью выключаются (0 = отключено)", "search.open": "Поиск (Ctrl+K)", "search.placeholder": "Поиск... (Ctrl+K)", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 86de631..145be44 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -896,6 +896,8 @@ "targets.brightness_vs": "亮度源:", "targets.brightness_vs.hint": "可选的值源,每帧动态控制亮度(覆盖设备亮度)", "targets.brightness_vs.none": "无(设备亮度)", + "targets.min_brightness_threshold": "最低亮度阈值:", + "targets.min_brightness_threshold.hint": "当有效输出亮度(像素亮度 × 设备/源亮度)低于此值时,LED完全关闭(0 = 禁用)", "search.open": "搜索 (Ctrl+K)", "search.placeholder": "搜索实体... (Ctrl+K)", diff --git a/server/src/wled_controller/storage/picture_target_store.py b/server/src/wled_controller/storage/picture_target_store.py index 1708e3c..740c1f4 100644 --- a/server/src/wled_controller/storage/picture_target_store.py +++ b/server/src/wled_controller/storage/picture_target_store.py @@ -106,6 +106,7 @@ class PictureTargetStore: fps: int = 30, keepalive_interval: float = 1.0, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, + min_brightness_threshold: int = 0, key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, picture_source_id: str = "", @@ -138,6 +139,7 @@ class PictureTargetStore: fps=fps, keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, + min_brightness_threshold=min_brightness_threshold, description=description, auto_start=auto_start, created_at=now, @@ -174,6 +176,7 @@ class PictureTargetStore: fps: Optional[int] = None, keepalive_interval: Optional[float] = None, state_check_interval: Optional[int] = None, + min_brightness_threshold: Optional[int] = None, key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, auto_start: Optional[bool] = None, @@ -202,6 +205,7 @@ class PictureTargetStore: fps=fps, keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, + min_brightness_threshold=min_brightness_threshold, key_colors_settings=key_colors_settings, description=description, auto_start=auto_start, diff --git a/server/src/wled_controller/storage/wled_picture_target.py b/server/src/wled_controller/storage/wled_picture_target.py index 40d30f0..f8deacd 100644 --- a/server/src/wled_controller/storage/wled_picture_target.py +++ b/server/src/wled_controller/storage/wled_picture_target.py @@ -19,6 +19,7 @@ class WledPictureTarget(PictureTarget): fps: int = 30 # target send FPS (1-90) keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL + min_brightness_threshold: int = 0 # brightness below this → 0 (disabled when 0) def register_with_manager(self, manager) -> None: """Register this WLED target with the processor manager.""" @@ -31,6 +32,7 @@ class WledPictureTarget(PictureTarget): keepalive_interval=self.keepalive_interval, state_check_interval=self.state_check_interval, brightness_value_source_id=self.brightness_value_source_id, + min_brightness_threshold=self.min_brightness_threshold, ) def sync_with_manager(self, manager, *, settings_changed: bool, @@ -43,6 +45,7 @@ class WledPictureTarget(PictureTarget): "fps": self.fps, "keepalive_interval": self.keepalive_interval, "state_check_interval": self.state_check_interval, + "min_brightness_threshold": self.min_brightness_threshold, }) if css_changed: manager.update_target_css(self.id, self.color_strip_source_id) @@ -54,6 +57,7 @@ class WledPictureTarget(PictureTarget): def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None, brightness_value_source_id=None, fps=None, keepalive_interval=None, state_check_interval=None, + min_brightness_threshold=None, description=None, auto_start=None, **_kwargs) -> None: """Apply mutable field updates for WLED targets.""" super().update_fields(name=name, description=description, auto_start=auto_start) @@ -69,6 +73,8 @@ class WledPictureTarget(PictureTarget): self.keepalive_interval = keepalive_interval if state_check_interval is not None: self.state_check_interval = state_check_interval + if min_brightness_threshold is not None: + self.min_brightness_threshold = min_brightness_threshold @property def has_picture_source(self) -> bool: @@ -83,6 +89,7 @@ class WledPictureTarget(PictureTarget): d["fps"] = self.fps d["keepalive_interval"] = self.keepalive_interval d["state_check_interval"] = self.state_check_interval + d["min_brightness_threshold"] = self.min_brightness_threshold return d @classmethod @@ -108,6 +115,7 @@ class WledPictureTarget(PictureTarget): fps=data.get("fps", 30), keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)), state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), + min_brightness_threshold=data.get("min_brightness_threshold", 0), description=data.get("description"), auto_start=data.get("auto_start", False), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), diff --git a/server/src/wled_controller/templates/modals/target-editor.html b/server/src/wled_controller/templates/modals/target-editor.html index 7262115..795b190 100644 --- a/server/src/wled_controller/templates/modals/target-editor.html +++ b/server/src/wled_controller/templates/modals/target-editor.html @@ -44,6 +44,18 @@
+
+
+ + +
+ + +
+