feat: add chase and gradient flash notification effects with priority queue
Some checks failed
Lint & Test / test (push) Failing after 28s
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:
@@ -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)}"}))
|
||||
|
||||
@@ -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,10 +192,11 @@ 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()
|
||||
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))
|
||||
|
||||
@@ -34,9 +34,11 @@ export function ensureNotificationEffectIconSelect() {
|
||||
{ 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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user