diff --git a/BRAINSTORM.md b/BRAINSTORM.md
new file mode 100644
index 0000000..e66a5c3
--- /dev/null
+++ b/BRAINSTORM.md
@@ -0,0 +1,118 @@
+# Feature Brainstorm — LED Grab
+
+## New Automation Conditions (Profiles)
+
+Right now profiles only trigger on **app detection**. High-value additions:
+
+- **Time-of-day / Schedule** — "warm tones after sunset, off at midnight." Schedule-based value sources pattern already exists
+- **Display state** — detect monitor on/off/sleep, auto-stop targets when display is off
+- **System idle** — dim or switch to ambient effect after N minutes of no input
+- **Sunrise/sunset** — fetch local solar times, drive circadian color temperature shifts
+- **Webhook/MQTT trigger** — let external systems activate profiles without HA integration
+
+## New Output Targets
+
+Currently: WLED, Adalight, AmbileD, DDP. Potential:
+
+- **MQTT publish** — generic IoT output, any MQTT subscriber becomes a target
+- **Art-Net / sACN (E1.31)** — stage/theatrical lighting protocols, DMX controllers
+- **OpenRGB** — control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
+- **HTTP webhook** — POST color data to arbitrary endpoints
+- **Recording target** — save color streams to file for playback later
+
+## New Color Strip Sources
+
+- **Spotify / media player** — album art color extraction or tempo-synced effects
+- **Weather** — pull conditions from API, map to palettes (blue=rain, orange=sun, white=snow)
+- **Camera / webcam** — border-sampling from camera feed for video calls or room-reactive lighting
+- **Script source** — user-written JS/Python snippets producing color arrays per frame
+- **Notification reactive** — flash/pulse on OS notifications (optional app filter)
+
+## Processing Pipeline Extensions
+
+- **Palette quantization** — force output to match a user-defined palette
+- **Zone grouping** — merge adjacent LEDs into logical groups sharing one averaged color
+- **Color temperature filter** — warm/cool shift separate from hue shift (circadian/mood)
+- **Noise gate** — suppress small color changes below threshold, preventing shimmer on static content
+
+## Multi-Instance & Sync
+
+- **Multi-room sync** — multiple instances with shared clock for synchronized effects
+- **Multi-display unification** — treat 2-3 monitors as single virtual display for seamless ambilight
+- **Leader/follower mode** — one target's output drives others with optional delay (cascade)
+
+## UX & Dashboard
+
+- **PWA / mobile layout** — mobile-first layout + "Add to Home Screen" manifest
+- **Scene presets** — bundled source + filters + brightness as one-click presets ("Movie night", "Gaming")
+- **Live preview on dashboard** — miniature screen with LED colors rendered around its border
+- **Undo/redo for calibration** — reduce frustration in the fiddly calibration editor
+- **Drag-and-drop filter ordering** — reorder postprocessing filter chains visually
+
+## API & Integration
+
+- **WebSocket event bus** — broadcast all state changes over a single WS channel
+- **OBS integration** — detect active scene, switch profiles; or use OBS virtual camera as source
+- **Plugin system** — formalize extension points into documented plugin API with hot-reload
+
+## Creative / Fun
+
+- **Effect sequencer** — timeline-based choreography of effects, colors, and transitions
+- **Music BPM sync** — lock effect speed to detected BPM (beat detection already exists)
+- **Color extraction from image** — upload photo, extract palette, use as gradient/cycle source
+- **Transition effects** — crossfade, wipe, or dissolve between sources/profiles instead of instant cut
+
+---
+
+## Deep Dive: Notification Reactive Source
+
+**Type:** New `ColorStripSource` (`source_type: "notification"`) — normally outputs transparent RGBA, flashes on notification events. Designed to be used as a layer in a **composite source** so it overlays on top of a persistent base (gradient, effect, screen capture, etc.).
+
+### Trigger modes (both active simultaneously)
+
+1. **OS listener (Windows)** — `pywinrt` + `Windows.UI.Notifications.Management.UserNotificationListener`. Runs in background thread, pushes events to source via queue. Windows-only for now; macOS (`pyobjc` + `NSUserNotificationCenter`) and Linux (`dbus` + `org.freedesktop.Notifications`) deferred to future.
+2. **Webhook** — `POST /api/v1/notifications/{source_id}/fire` with optional body `{ "app": "MyApp", "color": "#FF0000" }`. Always available, cross-platform by nature.
+
+### Source config
+
+```yaml
+os_listener: true # enable Windows notification listener
+app_filter:
+ mode: whitelist|blacklist # which apps to react to
+ apps: [Discord, Slack, Telegram]
+app_colors: # user-configured app → color mapping
+ Discord: "#5865F2"
+ Slack: "#4A154B"
+ Telegram: "#26A5E4"
+default_color: "#FFFFFF" # fallback when app has no mapping
+effect: flash|pulse|sweep # visual effect type
+duration_ms: 1500 # effect duration
+```
+
+### Effect rendering
+
+Source outputs RGBA color array per frame:
+- **Idle**: all pixels `(0,0,0,0)` — composite passes through base layer
+- **Flash**: instant full-color, linear fade to transparent over `duration_ms`
+- **Pulse**: sine fade in/out over `duration_ms`
+- **Sweep**: color travels across the strip like a wave
+
+Each notification starts its own mini-timeline from trigger timestamp (not sync clock).
+
+### Overlap handling
+
+New notification while previous effect is active → restart timer with new color. No queuing.
+
+### App color resolution
+
+1. Webhook body `color` field (explicit override) → highest priority
+2. `app_colors` mapping by app name
+3. `default_color` fallback
+
+---
+
+## Top Picks (impact vs effort)
+
+1. **Time-of-day + idle profile conditions** — builds on existing profile/condition architecture
+2. **MQTT output target** — opens the door to an enormous IoT ecosystem
+3. **Scene presets** — purely frontend, bundles existing features into one-click UX
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 643297f..8fa5f2b 100644
--- a/server/src/wled_controller/api/routes/color_strip_sources.py
+++ b/server/src/wled_controller/api/routes/color_strip_sources.py
@@ -19,6 +19,7 @@ from wled_controller.api.schemas.color_strip_sources import (
ColorStripSourceResponse,
ColorStripSourceUpdate,
CSSCalibrationTestRequest,
+ NotifyRequest,
)
from wled_controller.api.schemas.devices import (
Calibration as CalibrationSchema,
@@ -30,7 +31,7 @@ from wled_controller.core.capture.calibration import (
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
-from wled_controller.storage.color_strip_source import ApiInputColorStripSource, PictureColorStripSource
+from wled_controller.storage.color_strip_source import ApiInputColorStripSource, NotificationColorStripSource, PictureColorStripSource
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
@@ -91,6 +92,13 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
color_peak=getattr(source, "color_peak", None),
fallback_color=getattr(source, "fallback_color", None),
timeout=getattr(source, "timeout", None),
+ notification_effect=getattr(source, "notification_effect", None),
+ duration_ms=getattr(source, "duration_ms", None),
+ default_color=getattr(source, "default_color", None),
+ app_colors=getattr(source, "app_colors", None),
+ app_filter_mode=getattr(source, "app_filter_mode", None),
+ app_filter_list=getattr(source, "app_filter_list", None),
+ os_listener=getattr(source, "os_listener", None),
overlay_active=overlay_active,
created_at=source.created_at,
updated_at=source.updated_at,
@@ -175,6 +183,13 @@ async def create_color_strip_source(
fallback_color=data.fallback_color,
timeout=data.timeout,
clock_id=data.clock_id,
+ notification_effect=data.notification_effect,
+ duration_ms=data.duration_ms,
+ default_color=data.default_color,
+ app_colors=data.app_colors,
+ app_filter_mode=data.app_filter_mode,
+ app_filter_list=data.app_filter_list,
+ os_listener=data.os_listener,
)
return _css_to_response(source)
@@ -251,6 +266,13 @@ async def update_color_strip_source(
fallback_color=data.fallback_color,
timeout=data.timeout,
clock_id=data.clock_id,
+ notification_effect=data.notification_effect,
+ duration_ms=data.duration_ms,
+ default_color=data.default_color,
+ app_colors=data.app_colors,
+ app_filter_mode=data.app_filter_mode,
+ app_filter_list=data.app_filter_list,
+ os_listener=data.os_listener,
)
# Hot-reload running stream (no restart needed for in-place param changes)
@@ -489,6 +511,45 @@ async def push_colors(
}
+@router.post("/api/v1/color-strip-sources/{source_id}/notify", tags=["Color Strip Sources"])
+async def notify_source(
+ source_id: str,
+ _auth: AuthRequired,
+ body: NotifyRequest = None,
+ store: ColorStripStore = Depends(get_color_strip_store),
+ manager: ProcessorManager = Depends(get_processor_manager),
+):
+ """Trigger a notification on a notification color strip source.
+
+ Fires a one-shot visual effect (flash, pulse, sweep) on all running
+ stream instances for this source. Optionally specify an app name for
+ color lookup or a hex color override.
+ """
+ try:
+ source = store.get_source(source_id)
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+
+ if not isinstance(source, NotificationColorStripSource):
+ raise HTTPException(status_code=400, detail="Source is not a notification type")
+
+ app_name = body.app if body else None
+ color_override = body.color if body else None
+
+ streams = manager._color_strip_stream_manager.get_streams_by_source_id(source_id)
+ accepted = 0
+ for stream in streams:
+ if hasattr(stream, "fire"):
+ if stream.fire(app_name=app_name, color_override=color_override):
+ accepted += 1
+
+ return {
+ "status": "ok",
+ "streams_notified": accepted,
+ "filtered": len(streams) - accepted,
+ }
+
+
@router.websocket("/api/v1/color-strip-sources/{source_id}/ws")
async def css_api_input_ws(
websocket: WebSocket,
diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py
index e660aa4..70f47f5 100644
--- a/server/src/wled_controller/api/schemas/color_strip_sources.py
+++ b/server/src/wled_controller/api/schemas/color_strip_sources.py
@@ -1,7 +1,7 @@
"""Color strip source schemas (CRUD)."""
from datetime import datetime
-from typing import List, Literal, Optional
+from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field
@@ -49,7 +49,7 @@ class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
- source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input"] = Field(default="picture", description="Source type")
+ source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
@@ -87,6 +87,14 @@ class ColorStripSourceCreate(BaseModel):
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)")
timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0)
+ # notification-type fields
+ notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
+ duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
+ default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB) for notifications")
+ app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color (#RRGGBB)")
+ app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
+ app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
+ os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
@@ -132,6 +140,14 @@ class ColorStripSourceUpdate(BaseModel):
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0)
+ # notification-type fields
+ notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
+ duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
+ default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)")
+ app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color")
+ app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
+ app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
+ os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
@@ -179,6 +195,14 @@ class ColorStripSourceResponse(BaseModel):
# api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)")
+ # notification-type fields
+ notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
+ duration_ms: Optional[int] = Field(None, description="Effect duration in milliseconds")
+ default_color: Optional[str] = Field(None, description="Default hex color (#RRGGBB)")
+ app_colors: Optional[Dict[str, str]] = Field(None, description="Map of app name to hex color")
+ app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
+ app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
+ os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
# sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
@@ -199,6 +223,13 @@ class ColorPushRequest(BaseModel):
colors: List[List[int]] = Field(description="LED color array [[R,G,B], ...] (0-255 each)")
+class NotifyRequest(BaseModel):
+ """Request to trigger a notification on a notification color strip source."""
+
+ app: Optional[str] = Field(None, description="App name for color lookup")
+ color: Optional[str] = Field(None, description="Hex color override (#RRGGBB)")
+
+
class CSSCalibrationTestRequest(BaseModel):
"""Request to run a calibration test for a color strip source on a specific device."""
diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py
index 95127b2..6b6ba1b 100644
--- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py
+++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py
@@ -20,6 +20,7 @@ from wled_controller.core.processing.color_strip_stream import (
)
from wled_controller.core.processing.effect_stream import EffectColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
+from wled_controller.core.processing.notification_stream import NotificationColorStripStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -31,6 +32,7 @@ _SIMPLE_STREAM_MAP = {
"color_cycle": ColorCycleColorStripStream,
"effect": EffectColorStripStream,
"api_input": ApiInputColorStripStream,
+ "notification": NotificationColorStripStream,
}
diff --git a/server/src/wled_controller/core/processing/notification_stream.py b/server/src/wled_controller/core/processing/notification_stream.py
new file mode 100644
index 0000000..dcc2879
--- /dev/null
+++ b/server/src/wled_controller/core/processing/notification_stream.py
@@ -0,0 +1,290 @@
+"""Notification color strip stream — fires one-shot visual alerts.
+
+Renders brief animated LED effects (flash, pulse, sweep) triggered via the
+fire() method. When idle, outputs black. Thread-safe: fire() can be called
+from any thread (REST handler) while get_latest_colors() is called from the
+target processor thread.
+
+Uses a background render loop at 30 FPS with double-buffered output.
+"""
+
+import collections
+import math
+import threading
+import time
+from typing import Optional
+
+import numpy as np
+
+from wled_controller.core.processing.color_strip_stream import ColorStripStream
+from wled_controller.utils import get_logger
+
+logger = get_logger(__name__)
+
+
+def _hex_to_rgb(hex_str: str) -> tuple:
+ """Convert a '#RRGGBB' hex string to an (R, G, B) tuple.
+
+ Returns (255, 255, 255) for invalid input.
+ """
+ try:
+ h = hex_str.lstrip("#")
+ if len(h) != 6:
+ return (255, 255, 255)
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
+ except (ValueError, TypeError):
+ return (255, 255, 255)
+
+
+class NotificationColorStripStream(ColorStripStream):
+ """Color strip stream that fires one-shot visual alerts.
+
+ Supports three notification effects:
+ - flash: linear fade from full brightness to zero
+ - pulse: smooth bell curve (sin)
+ - sweep: fill LEDs left-to-right, then fade out
+
+ Uses collections.deque for thread-safe event passing and threading.Lock
+ for the output color buffer.
+ """
+
+ def __init__(self, source):
+ self._colors_lock = threading.Lock()
+ self._running = False
+ self._thread: Optional[threading.Thread] = None
+ self._fps = 30
+
+ # Event queue: deque of (color_rgb_tuple, start_time)
+ self._event_queue: collections.deque = collections.deque(maxlen=16)
+
+ # Active effect state
+ self._active_effect: Optional[dict] = None # {"color": (r,g,b), "start": float}
+
+ self._update_from_source(source)
+
+ def _update_from_source(self, source) -> None:
+ """Parse config from source dataclass."""
+ self._notification_effect = getattr(source, "notification_effect", "flash")
+ self._duration_ms = max(100, int(getattr(source, "duration_ms", 1500)))
+ self._default_color = getattr(source, "default_color", "#FFFFFF")
+ self._app_colors = dict(getattr(source, "app_colors", {}))
+ self._app_filter_mode = getattr(source, "app_filter_mode", "off")
+ self._app_filter_list = list(getattr(source, "app_filter_list", []))
+ self._auto_size = not source.led_count
+ self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
+ with self._colors_lock:
+ self._colors: Optional[np.ndarray] = np.zeros((self._led_count, 3), dtype=np.uint8)
+
+ def fire(self, app_name: str = None, color_override: str = None) -> bool:
+ """Trigger a notification effect. Thread-safe.
+
+ Args:
+ app_name: Optional app name for color lookup and filtering.
+ color_override: Optional hex color override (#RRGGBB).
+
+ Returns:
+ True if the notification was accepted, False if filtered out.
+ """
+ # Check app filter
+ if app_name and self._app_filter_mode != "off":
+ in_list = app_name in self._app_filter_list
+ if self._app_filter_mode == "whitelist" and not in_list:
+ return False
+ if self._app_filter_mode == "blacklist" and in_list:
+ return False
+
+ # Resolve color: override > app_colors[app_name] > default_color
+ if color_override:
+ color = _hex_to_rgb(color_override)
+ elif app_name and app_name in self._app_colors:
+ color = _hex_to_rgb(self._app_colors[app_name])
+ else:
+ color = _hex_to_rgb(self._default_color)
+
+ # Push event to queue (thread-safe deque.append)
+ self._event_queue.append({"color": color, "start": time.monotonic()})
+ return True
+
+ def configure(self, device_led_count: int) -> None:
+ """Set LED count from the target device (called on target start)."""
+ if self._auto_size and device_led_count > 0:
+ new_count = max(self._led_count, device_led_count)
+ if new_count != self._led_count:
+ self._led_count = new_count
+ with self._colors_lock:
+ self._colors = np.zeros((new_count, 3), dtype=np.uint8)
+ logger.debug(f"NotificationColorStripStream auto-sized to {new_count} LEDs")
+
+ @property
+ def target_fps(self) -> int:
+ return self._fps
+
+ @property
+ def is_animated(self) -> bool:
+ return True
+
+ @property
+ def led_count(self) -> int:
+ return self._led_count
+
+ def start(self) -> None:
+ if self._running:
+ return
+ self._running = True
+ self._thread = threading.Thread(
+ target=self._render_loop,
+ name="css-notification",
+ daemon=True,
+ )
+ self._thread.start()
+ logger.info(f"NotificationColorStripStream started (leds={self._led_count})")
+
+ def stop(self) -> None:
+ self._running = False
+ if self._thread:
+ self._thread.join(timeout=5.0)
+ if self._thread.is_alive():
+ logger.warning("NotificationColorStripStream render thread did not terminate within 5s")
+ self._thread = None
+ logger.info("NotificationColorStripStream stopped")
+
+ def get_latest_colors(self) -> Optional[np.ndarray]:
+ with self._colors_lock:
+ return self._colors
+
+ def update_source(self, source) -> None:
+ """Hot-update config from updated source."""
+ from wled_controller.storage.color_strip_source import NotificationColorStripSource
+ if isinstance(source, NotificationColorStripSource):
+ prev_led_count = self._led_count if self._auto_size else None
+ self._update_from_source(source)
+ if prev_led_count and self._auto_size:
+ self._led_count = prev_led_count
+ with self._colors_lock:
+ self._colors = np.zeros((self._led_count, 3), dtype=np.uint8)
+ logger.info("NotificationColorStripStream params updated in-place")
+
+ def set_clock(self, clock) -> None:
+ """Set or clear the sync clock runtime (not used, but required for interface)."""
+ pass # Notification stream does not use sync clocks
+
+ # ── Render loop ──────────────────────────────────────────────────
+
+ def _render_loop(self) -> None:
+ """Background thread rendering at 30 FPS."""
+ _pool_n = 0
+ _buf_a = _buf_b = None
+ _use_a = True
+
+ try:
+ while self._running:
+ wall_start = time.perf_counter()
+ frame_time = 1.0 / self._fps
+
+ try:
+ # Check for new events
+ while self._event_queue:
+ try:
+ event = self._event_queue.popleft()
+ self._active_effect = event
+ except IndexError:
+ break
+
+ n = self._led_count
+
+ # Reallocate buffers if LED count changed
+ if n != _pool_n:
+ _pool_n = n
+ _buf_a = np.zeros((n, 3), dtype=np.uint8)
+ _buf_b = np.zeros((n, 3), dtype=np.uint8)
+
+ buf = _buf_a if _use_a else _buf_b
+ _use_a = not _use_a
+
+ if self._active_effect is not None:
+ color = self._active_effect["color"]
+ start_time = self._active_effect["start"]
+ elapsed_ms = (time.monotonic() - start_time) * 1000.0
+ duration_ms = self._duration_ms
+ progress = min(elapsed_ms / duration_ms, 1.0)
+
+ if progress >= 1.0:
+ # Effect complete, output black
+ self._active_effect = None
+ buf[:] = 0
+ else:
+ self._render_effect(buf, n, color, progress)
+ else:
+ # Idle: output black
+ buf[:] = 0
+
+ with self._colors_lock:
+ self._colors = buf
+
+ except Exception as e:
+ logger.error(f"NotificationColorStripStream render error: {e}")
+
+ elapsed = time.perf_counter() - wall_start
+ time.sleep(max(frame_time - elapsed, 0.001))
+
+ except Exception as e:
+ logger.error(f"Fatal NotificationColorStripStream loop error: {e}", exc_info=True)
+ finally:
+ self._running = False
+
+ def _render_effect(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
+ """Dispatch to the appropriate effect renderer."""
+ effect = self._notification_effect
+ if effect == "pulse":
+ self._render_pulse(buf, n, color, progress)
+ elif effect == "sweep":
+ self._render_sweep(buf, n, color, progress)
+ else:
+ # Default: flash
+ self._render_flash(buf, n, color, progress)
+
+ def _render_flash(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
+ """Flash effect: linear fade from full brightness to zero."""
+ brightness = max(0.0, 1.0 - progress)
+ r = int(color[0] * brightness)
+ g = int(color[1] * brightness)
+ b = int(color[2] * brightness)
+ buf[:, 0] = r
+ buf[:, 1] = g
+ buf[:, 2] = b
+
+ def _render_pulse(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
+ """Pulse effect: smooth bell curve (sin)."""
+ brightness = math.sin(progress * math.pi)
+ r = int(color[0] * brightness)
+ g = int(color[1] * brightness)
+ b = int(color[2] * brightness)
+ buf[:, 0] = r
+ buf[:, 1] = g
+ buf[:, 2] = b
+
+ def _render_sweep(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
+ """Sweep effect: fill LEDs left-to-right, then fade all.
+
+ First half: progressively fill LEDs from left to right.
+ Second half: fade all filled LEDs from full to zero.
+ """
+ if progress < 0.5:
+ # Fill phase: progress 0→0.5 maps to fill_pos 0→n
+ fill_progress = progress * 2.0
+ fill_pos = int(fill_progress * n)
+ buf[:] = 0
+ if fill_pos > 0:
+ buf[:fill_pos, 0] = color[0]
+ buf[:fill_pos, 1] = color[1]
+ buf[:fill_pos, 2] = color[2]
+ else:
+ # Fade phase: progress 0.5→1.0 maps to brightness 1→0
+ fade_progress = (progress - 0.5) * 2.0
+ brightness = max(0.0, 1.0 - fade_progress)
+ r = int(color[0] * brightness)
+ g = int(color[1] * brightness)
+ b = int(color[2] * brightness)
+ buf[:, 0] = r
+ buf[:, 1] = g
+ buf[:, 2] = b
diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js
index 67aaa90..a01c765 100644
--- a/server/src/wled_controller/static/js/app.js
+++ b/server/src/wled_controller/static/js/app.js
@@ -116,6 +116,8 @@ import {
applyGradientPreset,
cloneColorStrip,
copyEndpointUrl,
+ onNotificationFilterModeChange,
+ notificationAddAppColor, notificationRemoveAppColor,
} from './features/color-strips.js';
// Layer 5: audio sources
@@ -379,6 +381,8 @@ Object.assign(window, {
applyGradientPreset,
cloneColorStrip,
copyEndpointUrl,
+ onNotificationFilterModeChange,
+ notificationAddAppColor, notificationRemoveAppColor,
// audio sources
showAudioSourceModal,
diff --git a/server/src/wled_controller/static/js/core/icon-paths.js b/server/src/wled_controller/static/js/core/icon-paths.js
index 4f9c879..8879511 100644
--- a/server/src/wled_controller/static/js/core/icon-paths.js
+++ b/server/src/wled_controller/static/js/core/icon-paths.js
@@ -37,6 +37,7 @@ export const eyeOff = '