Add min brightness threshold to LED targets

New per-target property: when effective output brightness
(max pixel value × device/source brightness) falls below
the threshold, LEDs turn off completely. Useful for cutting
dim flicker in audio-reactive and ambient setups.

Threshold slider (0–254) in target editor, badge on card,
hot-swap to running processors, persisted in storage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 15:03:53 +03:00
parent c2deef214e
commit a164abe774
11 changed files with 75 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
<span class="stream-card-prop stream-card-prop-full${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('targets','led','led-css','data-css-id','${cssId}')"` : ''}>🎞️ ${cssSummary}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
${target.min_brightness_threshold > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">🔅 &lt;${target.min_brightness_threshold} → off</span>` : ''}
</div>
<div class="card-content">
${isProcessing ? `

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,6 +44,18 @@
</select>
</div>
<div class="form-group" id="target-editor-brightness-threshold-group">
<div class="label-row">
<label for="target-editor-brightness-threshold">
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
<span id="target-editor-brightness-threshold-value">0</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.min_brightness_threshold.hint">Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)</small>
<input type="range" id="target-editor-brightness-threshold" min="0" max="254" value="0" oninput="document.getElementById('target-editor-brightness-threshold-value').textContent = this.value">
</div>
<div class="form-group" id="target-editor-fps-group">
<div class="label-row">
<label for="target-editor-fps">