feat: add chase and gradient flash notification effects with priority queue
Some checks failed
Lint & Test / test (push) Failing after 28s

New notification effects:
- Chase: light bounces across strip with Gaussian glow tail
- Gradient flash: bright center fades to edges with exponential decay

Queue priority: notifications with color_override get high priority and
interrupt the current effect.

Also fixes transient preview for notification sources — adds WebSocket
"fire" command so inline preview works without a saved source, plus
auto-fires on preview open so the effect is visible immediately.
This commit is contained in:
2026-03-24 00:00:49 +03:00
parent 9b80076b5b
commit 2c3f08344c
6 changed files with 120 additions and 9 deletions

View File

@@ -507,7 +507,7 @@ async def os_notification_history(_auth: AuthRequired):
# ── Transient Preview WebSocket ────────────────────────────────────────
_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight"}
_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight", "notification"}
@router.websocket("/api/v1/color-strip-sources/preview/ws")
@@ -648,6 +648,17 @@ async def preview_color_strip_ws(
if msg is not None:
try:
new_config = _json.loads(msg)
# Handle "fire" command for notification streams
if new_config.get("action") == "fire":
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
if isinstance(stream, NotificationColorStripStream):
stream.fire(
app_name=new_config.get("app", ""),
color_override=new_config.get("color"),
)
continue
new_type = new_config.get("source_type")
if new_type not in _PREVIEW_ALLOWED_TYPES:
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))

View File

@@ -106,7 +106,9 @@ class NotificationColorStripStream(ColorStripStream):
color = _hex_to_rgb(self._default_color)
# Push event to queue (thread-safe deque.append)
self._event_queue.append({"color": color, "start": time.monotonic()})
# Priority: 0 = normal, 1 = high (high interrupts current effect)
priority = 1 if color_override else 0
self._event_queue.append({"color": color, "start": time.monotonic(), "priority": priority})
return True
def configure(self, device_led_count: int) -> None:
@@ -190,11 +192,12 @@ class NotificationColorStripStream(ColorStripStream):
frame_time = self._frame_time
try:
# Check for new events
# Check for new events — high priority interrupts current
while self._event_queue:
try:
event = self._event_queue.popleft()
self._active_effect = event
if self._active_effect is None or event.get("priority", 0) >= self._active_effect.get("priority", 0):
self._active_effect = event
except IndexError:
break
@@ -247,6 +250,10 @@ class NotificationColorStripStream(ColorStripStream):
self._render_pulse(buf, n, color, progress)
elif effect == "sweep":
self._render_sweep(buf, n, color, progress)
elif effect == "chase":
self._render_chase(buf, n, color, progress)
elif effect == "gradient_flash":
self._render_gradient_flash(buf, n, color, progress)
else:
# Default: flash
self._render_flash(buf, n, color, progress)
@@ -296,3 +303,62 @@ class NotificationColorStripStream(ColorStripStream):
buf[:, 0] = r
buf[:, 1] = g
buf[:, 2] = b
def _render_chase(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
"""Chase effect: light travels across strip and bounces back.
First half: light moves left-to-right with a glowing tail.
Second half: light moves right-to-left back to start.
Overall brightness fades as progress approaches 1.0.
"""
buf[:] = 0
if n <= 0:
return
# Position: bounce (0→n→0)
if progress < 0.5:
pos = progress * 2.0 * (n - 1)
else:
pos = (1.0 - (progress - 0.5) * 2.0) * (n - 1)
# Overall fade
fade = max(0.0, 1.0 - progress * 0.6)
# Glow radius: ~5% of strip, minimum 2 LEDs
radius = max(2.0, n * 0.05)
for i in range(n):
dist = abs(i - pos)
if dist < radius * 3:
glow = math.exp(-0.5 * (dist / radius) ** 2) * fade
buf[i, 0] = min(255, int(color[0] * glow))
buf[i, 1] = min(255, int(color[1] * glow))
buf[i, 2] = min(255, int(color[2] * glow))
def _render_gradient_flash(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
"""Gradient flash: bright center fades to edges, then all fades out.
Creates a gradient from the notification color at center to darker
edges, with overall brightness fading over the duration.
"""
if n <= 0:
return
# Overall brightness envelope: quick attack, exponential decay
if progress < 0.1:
brightness = progress / 0.1
else:
brightness = math.exp(-3.0 * (progress - 0.1))
# Center-to-edge gradient
center = n / 2.0
max_dist = center if center > 0 else 1.0
for i in range(n):
dist = abs(i - center) / max_dist
# Smooth falloff from center
edge_factor = 1.0 - dist * 0.6
b_final = brightness * edge_factor
buf[i, 0] = min(255, int(color[0] * b_final))
buf[i, 1] = min(255, int(color[1] * b_final))
buf[i, 2] = min(255, int(color[2] * b_final))

View File

@@ -31,12 +31,14 @@ export function ensureNotificationEffectIconSelect() {
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'flash', icon: _icon(P.zap), label: t('color_strip.notification.effect.flash'), desc: t('color_strip.notification.effect.flash.desc') },
{ value: 'pulse', icon: _icon(P.activity), label: t('color_strip.notification.effect.pulse'), desc: t('color_strip.notification.effect.pulse.desc') },
{ value: 'sweep', icon: _icon(P.fastForward), label: t('color_strip.notification.effect.sweep'), desc: t('color_strip.notification.effect.sweep.desc') },
{ value: 'flash', icon: _icon(P.zap), label: t('color_strip.notification.effect.flash'), desc: t('color_strip.notification.effect.flash.desc') },
{ value: 'pulse', icon: _icon(P.activity), label: t('color_strip.notification.effect.pulse'), desc: t('color_strip.notification.effect.pulse.desc') },
{ value: 'sweep', icon: _icon(P.fastForward), label: t('color_strip.notification.effect.sweep'), desc: t('color_strip.notification.effect.sweep.desc') },
{ value: 'chase', icon: _icon(P.rocket), label: t('color_strip.notification.effect.chase'), desc: t('color_strip.notification.effect.chase.desc') },
{ value: 'gradient_flash', icon: _icon(P.rainbow), label: t('color_strip.notification.effect.gradient_flash'), desc: t('color_strip.notification.effect.gradient_flash.desc') },
];
if (_notificationEffectIconSelect) { _notificationEffectIconSelect.updateItems(items); return; }
_notificationEffectIconSelect = new IconSelect({ target: sel, items, columns: 3 });
_notificationEffectIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
export function ensureNotificationFilterModeIconSelect() {

View File

@@ -15,11 +15,12 @@ import {
import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
import { notificationGetAppColorsDict } from './color-strips-notification.ts';
/* ── Preview config builder ───────────────────────────────────── */
const _PREVIEW_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight',
'static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'notification',
]);
function _collectPreviewConfig() {
@@ -44,6 +45,17 @@ function _collectPreviewConfig() {
config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value), longitude: parseFloat((document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value) };
} else if (sourceType === 'candlelight') {
config = { source_type: 'candlelight', color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value), num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value), wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value), candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value };
} else if (sourceType === 'notification') {
const filterList = (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value.split('\n').map(s => s.trim()).filter(Boolean);
config = {
source_type: 'notification',
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
duration_ms: parseInt((document.getElementById('css-editor-notification-duration') as HTMLInputElement).value) || 1500,
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList,
app_colors: notificationGetAppColorsDict(),
};
}
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
if (clockEl && clockEl.value) config.clock_id = clockEl.value;
@@ -245,6 +257,14 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
_cssTestWs.onopen = () => {
if (gen !== _cssTestGeneration) return;
_cssTestWs!.send(JSON.stringify(_cssTestTransientConfig));
// Auto-fire notification after stream starts so user sees the effect immediately
if (_cssTestTransientConfig.source_type === 'notification') {
setTimeout(() => {
if (gen === _cssTestGeneration && _cssTestWs && _cssTestWs.readyState === WebSocket.OPEN) {
_cssTestWs.send(JSON.stringify({ action: 'fire', color: _cssTestTransientConfig.default_color }));
}
}, 300);
}
};
}
@@ -826,6 +846,12 @@ function _cssTestRenderStripAxis(canvasId: string, ledCount: number) {
}
export function fireCssTestNotification() {
// Transient preview: fire via WebSocket command
if (_cssTestTransientConfig && _cssTestWs && _cssTestWs.readyState === WebSocket.OPEN) {
_cssTestWs.send(JSON.stringify({ action: 'fire', color: _cssTestTransientConfig.default_color }));
return;
}
// Saved source: fire via REST endpoint
for (const id of _cssTestNotificationIds) {
testNotification(id);
}

View File

@@ -1067,6 +1067,10 @@
"color_strip.notification.effect.pulse.desc": "Smooth bell-curve glow",
"color_strip.notification.effect.sweep": "Sweep",
"color_strip.notification.effect.sweep.desc": "Fills left-to-right then fades",
"color_strip.notification.effect.chase": "Chase",
"color_strip.notification.effect.chase.desc": "Light travels across strip and bounces back",
"color_strip.notification.effect.gradient_flash": "Gradient Flash",
"color_strip.notification.effect.gradient_flash.desc": "Bright center fades to edges, then all fades out",
"color_strip.notification.duration": "Duration (ms):",
"color_strip.notification.duration.hint": "How long the notification effect plays, in milliseconds.",
"color_strip.notification.default_color": "Default Color:",

View File

@@ -443,6 +443,8 @@
<option value="flash" data-i18n="color_strip.notification.effect.flash">Flash</option>
<option value="pulse" data-i18n="color_strip.notification.effect.pulse">Pulse</option>
<option value="sweep" data-i18n="color_strip.notification.effect.sweep">Sweep</option>
<option value="chase" data-i18n="color_strip.notification.effect.chase">Chase</option>
<option value="gradient_flash" data-i18n="color_strip.notification.effect.gradient_flash">Gradient Flash</option>
</select>
</div>