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:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')}">🔅 <${target.min_brightness_threshold} → off</span>` : ''}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
${isProcessing ? `
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user