From 2c3f08344c9c63fa6d2317ecd196698b871a7516 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 00:00:49 +0300 Subject: [PATCH] feat: add chase and gradient flash notification effects with priority queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../api/routes/color_strip_sources.py | 13 +++- .../core/processing/notification_stream.py | 72 ++++++++++++++++++- .../js/features/color-strips-notification.ts | 10 +-- .../static/js/features/color-strips-test.ts | 28 +++++++- .../wled_controller/static/locales/en.json | 4 ++ .../templates/modals/css-editor.html | 2 + 6 files changed, 120 insertions(+), 9 deletions(-) diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index db50afc..68e2797 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -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)}"})) diff --git a/server/src/wled_controller/core/processing/notification_stream.py b/server/src/wled_controller/core/processing/notification_stream.py index 0806ba2..a09e546 100644 --- a/server/src/wled_controller/core/processing/notification_stream.py +++ b/server/src/wled_controller/core/processing/notification_stream.py @@ -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)) diff --git a/server/src/wled_controller/static/js/features/color-strips-notification.ts b/server/src/wled_controller/static/js/features/color-strips-notification.ts index a46889c..3348254 100644 --- a/server/src/wled_controller/static/js/features/color-strips-notification.ts +++ b/server/src/wled_controller/static/js/features/color-strips-notification.ts @@ -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() { diff --git a/server/src/wled_controller/static/js/features/color-strips-test.ts b/server/src/wled_controller/static/js/features/color-strips-test.ts index 4319bf3..5d71fa0 100644 --- a/server/src/wled_controller/static/js/features/color-strips-test.ts +++ b/server/src/wled_controller/static/js/features/color-strips-test.ts @@ -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); } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index fab4d8d..a6996ad 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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:", diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index feaf78a..32a8cc2 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -443,6 +443,8 @@ + +