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 ────────────────────────────────────────
|
# ── 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")
|
@router.websocket("/api/v1/color-strip-sources/preview/ws")
|
||||||
@@ -648,6 +648,17 @@ async def preview_color_strip_ws(
|
|||||||
if msg is not None:
|
if msg is not None:
|
||||||
try:
|
try:
|
||||||
new_config = _json.loads(msg)
|
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")
|
new_type = new_config.get("source_type")
|
||||||
if new_type not in _PREVIEW_ALLOWED_TYPES:
|
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)}"}))
|
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)
|
color = _hex_to_rgb(self._default_color)
|
||||||
|
|
||||||
# Push event to queue (thread-safe deque.append)
|
# 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
|
return True
|
||||||
|
|
||||||
def configure(self, device_led_count: int) -> None:
|
def configure(self, device_led_count: int) -> None:
|
||||||
@@ -190,10 +192,11 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
frame_time = self._frame_time
|
frame_time = self._frame_time
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check for new events
|
# Check for new events — high priority interrupts current
|
||||||
while self._event_queue:
|
while self._event_queue:
|
||||||
try:
|
try:
|
||||||
event = self._event_queue.popleft()
|
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
|
self._active_effect = event
|
||||||
except IndexError:
|
except IndexError:
|
||||||
break
|
break
|
||||||
@@ -247,6 +250,10 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
self._render_pulse(buf, n, color, progress)
|
self._render_pulse(buf, n, color, progress)
|
||||||
elif effect == "sweep":
|
elif effect == "sweep":
|
||||||
self._render_sweep(buf, n, color, progress)
|
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:
|
else:
|
||||||
# Default: flash
|
# Default: flash
|
||||||
self._render_flash(buf, n, color, progress)
|
self._render_flash(buf, n, color, progress)
|
||||||
@@ -296,3 +303,62 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
buf[:, 0] = r
|
buf[:, 0] = r
|
||||||
buf[:, 1] = g
|
buf[:, 1] = g
|
||||||
buf[:, 2] = b
|
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: '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: '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: '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; }
|
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() {
|
export function ensureNotificationFilterModeIconSelect() {
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import {
|
|||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
|
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
|
||||||
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
|
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
|
||||||
|
import { notificationGetAppColorsDict } from './color-strips-notification.ts';
|
||||||
|
|
||||||
/* ── Preview config builder ───────────────────────────────────── */
|
/* ── Preview config builder ───────────────────────────────────── */
|
||||||
|
|
||||||
const _PREVIEW_TYPES = new Set([
|
const _PREVIEW_TYPES = new Set([
|
||||||
'static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight',
|
'static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'notification',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function _collectPreviewConfig() {
|
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) };
|
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') {
|
} 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 };
|
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;
|
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
|
||||||
if (clockEl && clockEl.value) config.clock_id = clockEl.value;
|
if (clockEl && clockEl.value) config.clock_id = clockEl.value;
|
||||||
@@ -245,6 +257,14 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
|
|||||||
_cssTestWs.onopen = () => {
|
_cssTestWs.onopen = () => {
|
||||||
if (gen !== _cssTestGeneration) return;
|
if (gen !== _cssTestGeneration) return;
|
||||||
_cssTestWs!.send(JSON.stringify(_cssTestTransientConfig));
|
_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() {
|
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) {
|
for (const id of _cssTestNotificationIds) {
|
||||||
testNotification(id);
|
testNotification(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1067,6 +1067,10 @@
|
|||||||
"color_strip.notification.effect.pulse.desc": "Smooth bell-curve glow",
|
"color_strip.notification.effect.pulse.desc": "Smooth bell-curve glow",
|
||||||
"color_strip.notification.effect.sweep": "Sweep",
|
"color_strip.notification.effect.sweep": "Sweep",
|
||||||
"color_strip.notification.effect.sweep.desc": "Fills left-to-right then fades",
|
"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": "Duration (ms):",
|
||||||
"color_strip.notification.duration.hint": "How long the notification effect plays, in milliseconds.",
|
"color_strip.notification.duration.hint": "How long the notification effect plays, in milliseconds.",
|
||||||
"color_strip.notification.default_color": "Default Color:",
|
"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="flash" data-i18n="color_strip.notification.effect.flash">Flash</option>
|
||||||
<option value="pulse" data-i18n="color_strip.notification.effect.pulse">Pulse</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="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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user