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