Add notification reactive color strip source with webhook trigger

New source_type "notification" fires one-shot visual effects (flash, pulse, sweep)
triggered via POST webhook. Designed as a composite layer for overlay on persistent
sources. Includes app color mapping, whitelist/blacklist filtering, and auto-sizing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 21:10:32 +03:00
parent 80b48e3618
commit de04872fdc
16 changed files with 932 additions and 6 deletions

118
BRAINSTORM.md Normal file
View File

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

View File

@@ -19,6 +19,7 @@ from wled_controller.api.schemas.color_strip_sources import (
ColorStripSourceResponse, ColorStripSourceResponse,
ColorStripSourceUpdate, ColorStripSourceUpdate,
CSSCalibrationTestRequest, CSSCalibrationTestRequest,
NotifyRequest,
) )
from wled_controller.api.schemas.devices import ( from wled_controller.api.schemas.devices import (
Calibration as CalibrationSchema, 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.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager 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.color_strip_store import ColorStripStore
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore 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), color_peak=getattr(source, "color_peak", None),
fallback_color=getattr(source, "fallback_color", None), fallback_color=getattr(source, "fallback_color", None),
timeout=getattr(source, "timeout", 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, overlay_active=overlay_active,
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_at, updated_at=source.updated_at,
@@ -175,6 +183,13 @@ async def create_color_strip_source(
fallback_color=data.fallback_color, fallback_color=data.fallback_color,
timeout=data.timeout, timeout=data.timeout,
clock_id=data.clock_id, 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) return _css_to_response(source)
@@ -251,6 +266,13 @@ async def update_color_strip_source(
fallback_color=data.fallback_color, fallback_color=data.fallback_color,
timeout=data.timeout, timeout=data.timeout,
clock_id=data.clock_id, 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) # 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") @router.websocket("/api/v1/color-strip-sources/{source_id}/ws")
async def css_api_input_ws( async def css_api_input_ws(
websocket: WebSocket, websocket: WebSocket,

View File

@@ -1,7 +1,7 @@
"""Color strip source schemas (CRUD).""" """Color strip source schemas (CRUD)."""
from datetime import datetime from datetime import datetime
from typing import List, Literal, Optional from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -49,7 +49,7 @@ class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source.""" """Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100) 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-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") 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) 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 # 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)") 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) 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 # sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") 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 # api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") 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) 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 # sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") 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 # api_input-type fields
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") 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)") 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 # sync clock
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") 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") 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)") 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): class CSSCalibrationTestRequest(BaseModel):
"""Request to run a calibration test for a color strip source on a specific device.""" """Request to run a calibration test for a color strip source on a specific device."""

View File

@@ -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.effect_stream import EffectColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream 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 from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -31,6 +32,7 @@ _SIMPLE_STREAM_MAP = {
"color_cycle": ColorCycleColorStripStream, "color_cycle": ColorCycleColorStripStream,
"effect": EffectColorStripStream, "effect": EffectColorStripStream,
"api_input": ApiInputColorStripStream, "api_input": ApiInputColorStripStream,
"notification": NotificationColorStripStream,
} }

View File

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

View File

@@ -116,6 +116,8 @@ import {
applyGradientPreset, applyGradientPreset,
cloneColorStrip, cloneColorStrip,
copyEndpointUrl, copyEndpointUrl,
onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor,
} from './features/color-strips.js'; } from './features/color-strips.js';
// Layer 5: audio sources // Layer 5: audio sources
@@ -379,6 +381,8 @@ Object.assign(window, {
applyGradientPreset, applyGradientPreset,
cloneColorStrip, cloneColorStrip,
copyEndpointUrl, copyEndpointUrl,
onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor,
// audio sources // audio sources
showAudioSourceModal, showAudioSourceModal,

View File

@@ -37,6 +37,7 @@ export const eyeOff = '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6
export const star = '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>'; export const star = '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>';
export const hash = '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>'; export const hash = '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>';
export const camera = '<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/>'; export const camera = '<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/>';
export const bellRing = '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/>';
export const wrench = '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>'; export const wrench = '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>';
export const music = '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>'; export const music = '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>';
export const search = '<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>'; export const search = '<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>';

View File

@@ -22,6 +22,7 @@ const _colorStripTypeIcons = {
mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin), mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),
audio: _svg(P.music), audio_visualization: _svg(P.music), audio: _svg(P.music), audio_visualization: _svg(P.music),
api_input: _svg(P.send), api_input: _svg(P.send),
notification: _svg(P.bellRing),
}; };
const _valueSourceTypeIcons = { const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music), static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
@@ -146,3 +147,4 @@ export const ICON_DOWNLOAD = _svg(P.download);
export const ICON_UNDO = _svg(P.undo2); export const ICON_UNDO = _svg(P.undo2);
export const ICON_SCENE = _svg(P.sparkles); export const ICON_SCENE = _svg(P.sparkles);
export const ICON_CAPTURE = _svg(P.camera); export const ICON_CAPTURE = _svg(P.camera);
export const ICON_BELL = _svg(P.bellRing);

View File

@@ -12,7 +12,7 @@ import {
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL,
} from '../core/icons.js'; } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
@@ -56,6 +56,12 @@ class CSSEditorModal extends Modal {
audio_mirror: document.getElementById('css-editor-audio-mirror').checked, audio_mirror: document.getElementById('css-editor-audio-mirror').checked,
api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').value, api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').value,
api_input_timeout: document.getElementById('css-editor-api-input-timeout').value, api_input_timeout: document.getElementById('css-editor-api-input-timeout').value,
notification_effect: document.getElementById('css-editor-notification-effect').value,
notification_duration: document.getElementById('css-editor-notification-duration').value,
notification_default_color: document.getElementById('css-editor-notification-default-color').value,
notification_filter_mode: document.getElementById('css-editor-notification-filter-mode').value,
notification_filter_list: document.getElementById('css-editor-notification-filter-list').value,
notification_app_colors: JSON.stringify(_notificationAppColors),
clock_id: document.getElementById('css-editor-clock').value, clock_id: document.getElementById('css-editor-clock').value,
}; };
} }
@@ -76,6 +82,7 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-mapped-section').style.display = type === 'mapped' ? '' : 'none'; document.getElementById('css-editor-mapped-section').style.display = type === 'mapped' ? '' : 'none';
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none'; document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none'; document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none';
document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none';
if (type === 'effect') onEffectTypeChange(); if (type === 'effect') onEffectTypeChange();
if (type === 'audio') onAudioVizChange(); if (type === 'audio') onAudioVizChange();
@@ -576,6 +583,108 @@ function _resetAudioState() {
document.getElementById('css-editor-audio-mirror').checked = false; document.getElementById('css-editor-audio-mirror').checked = false;
} }
/* ── Notification helpers ────────────────────────────────────── */
let _notificationAppColors = []; // [{app: '', color: '#...'}]
export function onNotificationFilterModeChange() {
const mode = document.getElementById('css-editor-notification-filter-mode').value;
document.getElementById('css-editor-notification-filter-list-group').style.display = mode === 'off' ? 'none' : '';
}
function _notificationAppColorsRenderList() {
const list = document.getElementById('notification-app-colors-list');
if (!list) return;
list.innerHTML = _notificationAppColors.map((entry, i) => `
<div class="color-cycle-item">
<input type="text" class="notif-app-name" data-idx="${i}" value="${escapeHtml(entry.app)}" placeholder="App name" style="flex:1">
<input type="color" class="notif-app-color" data-idx="${i}" value="${entry.color}">
<button type="button" class="btn btn-secondary color-cycle-remove-btn"
onclick="notificationRemoveAppColor(${i})">&#x2715;</button>
</div>
`).join('');
}
export function notificationAddAppColor() {
_notificationAppColorsSyncFromDom();
_notificationAppColors.push({ app: '', color: '#ffffff' });
_notificationAppColorsRenderList();
}
export function notificationRemoveAppColor(i) {
_notificationAppColorsSyncFromDom();
_notificationAppColors.splice(i, 1);
_notificationAppColorsRenderList();
}
function _notificationAppColorsSyncFromDom() {
const list = document.getElementById('notification-app-colors-list');
if (!list) return;
const names = list.querySelectorAll('.notif-app-name');
const colors = list.querySelectorAll('.notif-app-color');
if (names.length === _notificationAppColors.length) {
for (let i = 0; i < names.length; i++) {
_notificationAppColors[i].app = names[i].value;
_notificationAppColors[i].color = colors[i].value;
}
}
}
function _notificationGetAppColorsDict() {
_notificationAppColorsSyncFromDom();
const dict = {};
for (const entry of _notificationAppColors) {
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
}
return dict;
}
function _loadNotificationState(css) {
document.getElementById('css-editor-notification-effect').value = css.notification_effect || 'flash';
const dur = css.duration_ms ?? 1500;
document.getElementById('css-editor-notification-duration').value = dur;
document.getElementById('css-editor-notification-duration-val').textContent = dur;
document.getElementById('css-editor-notification-default-color').value = css.default_color || '#ffffff';
document.getElementById('css-editor-notification-filter-mode').value = css.app_filter_mode || 'off';
document.getElementById('css-editor-notification-filter-list').value = (css.app_filter_list || []).join(', ');
onNotificationFilterModeChange();
// App colors dict → list
const ac = css.app_colors || {};
_notificationAppColors = Object.entries(ac).map(([app, color]) => ({ app, color }));
_notificationAppColorsRenderList();
_showNotificationEndpoint(css.id);
}
function _resetNotificationState() {
document.getElementById('css-editor-notification-effect').value = 'flash';
document.getElementById('css-editor-notification-duration').value = 1500;
document.getElementById('css-editor-notification-duration-val').textContent = '1500';
document.getElementById('css-editor-notification-default-color').value = '#ffffff';
document.getElementById('css-editor-notification-filter-mode').value = 'off';
document.getElementById('css-editor-notification-filter-list').value = '';
onNotificationFilterModeChange();
_notificationAppColors = [];
_notificationAppColorsRenderList();
_showNotificationEndpoint(null);
}
function _showNotificationEndpoint(cssId) {
const el = document.getElementById('css-editor-notification-endpoint');
if (!el) return;
if (!cssId) {
el.innerHTML = `<em data-i18n="color_strip.notification.save_first">${t('color_strip.notification.save_first')}</em>`;
return;
}
const base = `${window.location.origin}/api/v1`;
const url = `${base}/color-strip-sources/${cssId}/notify`;
el.innerHTML = `
<small class="endpoint-label">POST</small>
<div class="ws-url-row"><input type="text" value="${url}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">&#x1F4CB;</button></div>
`;
}
/* ── Card ─────────────────────────────────────────────────────── */ /* ── Card ─────────────────────────────────────────────────────── */
export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
@@ -587,6 +696,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const isMapped = source.source_type === 'mapped'; const isMapped = source.source_type === 'mapped';
const isAudio = source.source_type === 'audio'; const isAudio = source.source_type === 'audio';
const isApiInput = source.source_type === 'api_input'; const isApiInput = source.source_type === 'api_input';
const isNotification = source.source_type === 'notification';
// Clock crosslink badge (replaces speed badge when clock is assigned) // Clock crosslink badge (replaces speed badge when clock is assigned)
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null; const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
@@ -692,6 +802,20 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
</span> </span>
<span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">${ICON_TIMER} ${timeoutVal}s</span> <span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">${ICON_TIMER} ${timeoutVal}s</span>
`; `;
} else if (isNotification) {
const effectLabel = t('color_strip.notification.effect.' + (source.notification_effect || 'flash')) || source.notification_effect || 'flash';
const durationVal = source.duration_ms || 1500;
const defColor = source.default_color || '#FFFFFF';
const appCount = source.app_colors ? Object.keys(source.app_colors).length : 0;
propsHtml = `
<span class="stream-card-prop" title="${t('color_strip.notification.effect')}">${ICON_BELL} ${escapeHtml(effectLabel)}</span>
<span class="stream-card-prop" title="${t('color_strip.notification.duration')}">${ICON_TIMER} ${durationVal}ms</span>
<span class="stream-card-prop" title="${t('color_strip.notification.default_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${defColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${defColor.toUpperCase()}
</span>
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
`;
} else { } else {
const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id]; const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id];
const srcName = ps ? ps.name : source.picture_source_id || '—'; const srcName = ps ? ps.name : source.picture_source_id || '—';
@@ -710,7 +834,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
} }
const icon = getColorStripIcon(source.source_type); const icon = getColorStripIcon(source.source_type);
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput) const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>` ? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
: ''; : '';
@@ -809,6 +933,8 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-api-input-timeout-val').textContent = document.getElementById('css-editor-api-input-timeout-val').textContent =
parseFloat(css.timeout ?? 5.0).toFixed(1); parseFloat(css.timeout ?? 5.0).toFixed(1);
_showApiInputEndpoints(css.id); _showApiInputEndpoints(css.id);
} else if (sourceType === 'notification') {
_loadNotificationState(css);
} else { } else {
sourceSelect.value = css.picture_source_id || ''; sourceSelect.value = css.picture_source_id || '';
@@ -899,6 +1025,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-api-input-timeout').value = 5.0; document.getElementById('css-editor-api-input-timeout').value = 5.0;
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0'; document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
_showApiInputEndpoints(null); _showApiInputEndpoints(null);
_resetNotificationState();
document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`; document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`;
document.getElementById('css-editor-gradient-preset').value = ''; document.getElementById('css-editor-gradient-preset').value = '';
gradientInit([ gradientInit([
@@ -1030,6 +1157,20 @@ export async function saveCSSEditor() {
timeout: parseFloat(document.getElementById('css-editor-api-input-timeout').value), timeout: parseFloat(document.getElementById('css-editor-api-input-timeout').value),
}; };
if (!cssId) payload.source_type = 'api_input'; if (!cssId) payload.source_type = 'api_input';
} else if (sourceType === 'notification') {
const filterList = document.getElementById('css-editor-notification-filter-list').value
.split(',').map(s => s.trim()).filter(Boolean);
payload = {
name,
notification_effect: document.getElementById('css-editor-notification-effect').value,
duration_ms: parseInt(document.getElementById('css-editor-notification-duration').value) || 1500,
default_color: document.getElementById('css-editor-notification-default-color').value,
app_filter_mode: document.getElementById('css-editor-notification-filter-mode').value,
app_filter_list: filterList,
app_colors: _notificationGetAppColorsDict(),
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
if (!cssId) payload.source_type = 'notification';
} else { } else {
payload = { payload = {
name, name,

View File

@@ -808,6 +808,33 @@
"color_strip.api_input.endpoints": "Push Endpoints:", "color_strip.api_input.endpoints": "Push Endpoints:",
"color_strip.api_input.endpoints.hint": "Use these URLs to push LED color data from your external application. REST accepts JSON, WebSocket accepts both JSON and raw binary frames.", "color_strip.api_input.endpoints.hint": "Use these URLs to push LED color data from your external application. REST accepts JSON, WebSocket accepts both JSON and raw binary frames.",
"color_strip.api_input.save_first": "Save the source first to see the push endpoint URLs.", "color_strip.api_input.save_first": "Save the source first to see the push endpoint URLs.",
"color_strip.type.notification": "Notification",
"color_strip.type.notification.hint": "Fires a one-shot visual effect (flash, pulse, sweep) when triggered via a webhook. Designed for use as a composite layer over a persistent base source.",
"color_strip.notification.effect": "Effect:",
"color_strip.notification.effect.hint": "Visual effect when a notification fires. Flash fades linearly, Pulse uses a smooth bell curve, Sweep fills LEDs left-to-right then fades.",
"color_strip.notification.effect.flash": "Flash",
"color_strip.notification.effect.pulse": "Pulse",
"color_strip.notification.effect.sweep": "Sweep",
"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:",
"color_strip.notification.default_color.hint": "Color used when the notification has no app-specific color mapping.",
"color_strip.notification.filter_mode": "App Filter:",
"color_strip.notification.filter_mode.hint": "Filter notifications by app name. Off = accept all, Whitelist = only listed apps, Blacklist = all except listed apps.",
"color_strip.notification.filter_mode.off": "Off",
"color_strip.notification.filter_mode.whitelist": "Whitelist",
"color_strip.notification.filter_mode.blacklist": "Blacklist",
"color_strip.notification.filter_list": "App List:",
"color_strip.notification.filter_list.hint": "Comma-separated app names for the filter.",
"color_strip.notification.filter_list.placeholder": "Discord, Slack, Telegram",
"color_strip.notification.app_colors": "App Colors",
"color_strip.notification.app_colors.label": "Color Mappings:",
"color_strip.notification.app_colors.hint": "Per-app color overrides. Each row maps an app name to a specific notification color.",
"color_strip.notification.app_colors.add": "+ Add Mapping",
"color_strip.notification.endpoint": "Webhook Endpoint:",
"color_strip.notification.endpoint.hint": "Use this URL to trigger notifications from external systems. POST with optional JSON body: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
"color_strip.notification.save_first": "Save the source first to see the webhook endpoint URL.",
"color_strip.notification.app_count": "apps",
"color_strip.composite.layers": "Layers:", "color_strip.composite.layers": "Layers:",
"color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.", "color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.",
"color_strip.composite.add_layer": "+ Add Layer", "color_strip.composite.add_layer": "+ Add Layer",

View File

@@ -808,6 +808,33 @@
"color_strip.api_input.endpoints": "Эндпоинты для отправки:", "color_strip.api_input.endpoints": "Эндпоинты для отправки:",
"color_strip.api_input.endpoints.hint": "Используйте эти URL для отправки данных о цветах LED из вашего внешнего приложения. REST принимает JSON, WebSocket принимает как JSON, так и бинарные кадры.", "color_strip.api_input.endpoints.hint": "Используйте эти URL для отправки данных о цветах LED из вашего внешнего приложения. REST принимает JSON, WebSocket принимает как JSON, так и бинарные кадры.",
"color_strip.api_input.save_first": "Сначала сохраните источник, чтобы увидеть URL эндпоинтов.", "color_strip.api_input.save_first": "Сначала сохраните источник, чтобы увидеть URL эндпоинтов.",
"color_strip.type.notification": "Уведомления",
"color_strip.type.notification.hint": "Вспышка, пульс или волна при срабатывании через вебхук. Предназначен для использования как слой в композитном источнике.",
"color_strip.notification.effect": "Эффект:",
"color_strip.notification.effect.hint": "Визуальный эффект при уведомлении. Вспышка — линейное затухание, Пульс — плавная волна, Волна — заполнение и затухание.",
"color_strip.notification.effect.flash": "Вспышка",
"color_strip.notification.effect.pulse": "Пульс",
"color_strip.notification.effect.sweep": "Волна",
"color_strip.notification.duration": "Длительность (мс):",
"color_strip.notification.duration.hint": "Как долго длится эффект уведомления в миллисекундах.",
"color_strip.notification.default_color": "Цвет по умолчанию:",
"color_strip.notification.default_color.hint": "Цвет, когда для приложения нет специфического назначения цвета.",
"color_strip.notification.filter_mode": "Фильтр приложений:",
"color_strip.notification.filter_mode.hint": "Фильтр уведомлений по имени приложения. Выкл = все, Белый список = только указанные, Чёрный список = все кроме указанных.",
"color_strip.notification.filter_mode.off": "Выкл",
"color_strip.notification.filter_mode.whitelist": "Белый список",
"color_strip.notification.filter_mode.blacklist": "Чёрный список",
"color_strip.notification.filter_list": "Список приложений:",
"color_strip.notification.filter_list.hint": "Имена приложений через запятую.",
"color_strip.notification.filter_list.placeholder": "Discord, Slack, Telegram",
"color_strip.notification.app_colors": "Цвета приложений",
"color_strip.notification.app_colors.label": "Назначения цветов:",
"color_strip.notification.app_colors.hint": "Индивидуальные цвета для приложений. Каждая строка связывает имя приложения с цветом уведомления.",
"color_strip.notification.app_colors.add": "+ Добавить",
"color_strip.notification.endpoint": "Вебхук:",
"color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
"color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.",
"color_strip.notification.app_count": "прилож.",
"color_strip.composite.layers": "Слои:", "color_strip.composite.layers": "Слои:",
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.", "color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
"color_strip.composite.add_layer": "+ Добавить слой", "color_strip.composite.add_layer": "+ Добавить слой",

View File

@@ -808,6 +808,33 @@
"color_strip.api_input.endpoints": "推送端点:", "color_strip.api_input.endpoints": "推送端点:",
"color_strip.api_input.endpoints.hint": "使用这些 URL 从外部应用程序推送 LED 颜色数据。REST 接受 JSONWebSocket 接受 JSON 和原始二进制帧。", "color_strip.api_input.endpoints.hint": "使用这些 URL 从外部应用程序推送 LED 颜色数据。REST 接受 JSONWebSocket 接受 JSON 和原始二进制帧。",
"color_strip.api_input.save_first": "请先保存源以查看推送端点 URL。", "color_strip.api_input.save_first": "请先保存源以查看推送端点 URL。",
"color_strip.type.notification": "通知",
"color_strip.type.notification.hint": "通过 Webhook 触发时显示一次性视觉效果(闪烁、脉冲、扫描)。设计为组合源中的叠加层。",
"color_strip.notification.effect": "效果:",
"color_strip.notification.effect.hint": "通知触发时的视觉效果。闪烁线性衰减,脉冲平滑钟形曲线,扫描从左到右填充后衰减。",
"color_strip.notification.effect.flash": "闪烁",
"color_strip.notification.effect.pulse": "脉冲",
"color_strip.notification.effect.sweep": "扫描",
"color_strip.notification.duration": "持续时间(毫秒):",
"color_strip.notification.duration.hint": "通知效果播放的时长(毫秒)。",
"color_strip.notification.default_color": "默认颜色:",
"color_strip.notification.default_color.hint": "当通知没有应用特定颜色映射时使用的颜色。",
"color_strip.notification.filter_mode": "应用过滤:",
"color_strip.notification.filter_mode.hint": "按应用名称过滤通知。关闭=接受全部,白名单=仅列出的应用,黑名单=排除列出的应用。",
"color_strip.notification.filter_mode.off": "关闭",
"color_strip.notification.filter_mode.whitelist": "白名单",
"color_strip.notification.filter_mode.blacklist": "黑名单",
"color_strip.notification.filter_list": "应用列表:",
"color_strip.notification.filter_list.hint": "以逗号分隔的应用名称。",
"color_strip.notification.filter_list.placeholder": "Discord, Slack, Telegram",
"color_strip.notification.app_colors": "应用颜色",
"color_strip.notification.app_colors.label": "颜色映射:",
"color_strip.notification.app_colors.hint": "每个应用的自定义通知颜色。每行将一个应用名称映射到特定颜色。",
"color_strip.notification.app_colors.add": "+ 添加映射",
"color_strip.notification.endpoint": "Webhook 端点:",
"color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON{\"app\": \"AppName\", \"color\": \"#FF0000\"}。",
"color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。",
"color_strip.notification.app_count": "个应用",
"color_strip.composite.layers": "图层:", "color_strip.composite.layers": "图层:",
"color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。", "color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。",
"color_strip.composite.add_layer": "+ 添加图层", "color_strip.composite.add_layer": "+ 添加图层",

View File

@@ -7,7 +7,7 @@
* - Navigation: network-first with offline fallback * - Navigation: network-first with offline fallback
*/ */
const CACHE_NAME = 'ledgrab-v10'; const CACHE_NAME = 'ledgrab-v11';
// Only pre-cache static assets (no auth required). // Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page. // Do NOT pre-cache '/' — it requires API key auth and would cache an error page.

View File

@@ -11,6 +11,7 @@ Current types:
ColorCycleColorStripSource — smoothly cycles through a user-defined list of colors ColorCycleColorStripSource — smoothly cycles through a user-defined list of colors
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter) AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket ApiInputColorStripSource — receives raw LED colors from external clients via REST/WebSocket
NotificationColorStripSource — fires one-shot visual alerts (flash, pulse, sweep) via API
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -82,6 +83,13 @@ class ColorStripSource:
"color_peak": None, "color_peak": None,
"fallback_color": None, "fallback_color": None,
"timeout": None, "timeout": None,
"notification_effect": None,
"duration_ms": None,
"default_color": None,
"app_colors": None,
"app_filter_mode": None,
"app_filter_list": None,
"os_listener": None,
} }
@staticmethod @staticmethod
@@ -218,6 +226,25 @@ class ColorStripSource:
timeout=float(data.get("timeout") or 5.0), timeout=float(data.get("timeout") or 5.0),
) )
elif source_type == "notification":
raw_app_colors = data.get("app_colors")
app_colors = raw_app_colors if isinstance(raw_app_colors, dict) else {}
raw_app_filter_list = data.get("app_filter_list")
app_filter_list = raw_app_filter_list if isinstance(raw_app_filter_list, list) else []
return NotificationColorStripSource(
id=sid, name=name, source_type="notification",
created_at=created_at, updated_at=updated_at, description=description,
clock_id=clock_id,
notification_effect=data.get("notification_effect") or "flash",
duration_ms=int(data.get("duration_ms") or 1500),
default_color=data.get("default_color") or "#FFFFFF",
app_colors=app_colors,
app_filter_mode=data.get("app_filter_mode") or "off",
app_filter_list=app_filter_list,
os_listener=bool(data.get("os_listener", False)),
led_count=data.get("led_count") or 0,
)
# Default: "picture" type # Default: "picture" type
return PictureColorStripSource( return PictureColorStripSource(
id=sid, name=name, source_type=source_type, id=sid, name=name, source_type=source_type,
@@ -469,3 +496,36 @@ class ApiInputColorStripSource(ColorStripSource):
d["fallback_color"] = list(self.fallback_color) d["fallback_color"] = list(self.fallback_color)
d["timeout"] = self.timeout d["timeout"] = self.timeout
return d return d
@dataclass
class NotificationColorStripSource(ColorStripSource):
"""Color strip source that fires one-shot visual alerts (flash, pulse, sweep).
External clients trigger notifications via a REST webhook. The stream
renders a brief animated effect and then returns to black. Each notification
can carry an optional app name (for per-app color lookup) or an explicit
color override.
LED count auto-sizes from the connected device when led_count == 0.
"""
notification_effect: str = "flash" # flash | pulse | sweep
duration_ms: int = 1500 # effect duration in milliseconds
default_color: str = "#FFFFFF" # hex color for notifications without app match
app_colors: dict = field(default_factory=dict) # app name → hex color
app_filter_mode: str = "off" # off | whitelist | blacklist
app_filter_list: list = field(default_factory=list) # app names for filter
os_listener: bool = False # whether to listen for OS notifications
led_count: int = 0 # 0 = use device LED count
def to_dict(self) -> dict:
d = super().to_dict()
d["notification_effect"] = self.notification_effect
d["duration_ms"] = self.duration_ms
d["default_color"] = self.default_color
d["app_colors"] = dict(self.app_colors)
d["app_filter_mode"] = self.app_filter_mode
d["app_filter_list"] = list(self.app_filter_list)
d["os_listener"] = self.os_listener
d["led_count"] = self.led_count
return d

View File

@@ -16,6 +16,7 @@ from wled_controller.storage.color_strip_source import (
EffectColorStripSource, EffectColorStripSource,
GradientColorStripSource, GradientColorStripSource,
MappedColorStripSource, MappedColorStripSource,
NotificationColorStripSource,
PictureColorStripSource, PictureColorStripSource,
StaticColorStripSource, StaticColorStripSource,
) )
@@ -120,6 +121,13 @@ class ColorStripStore:
fallback_color: Optional[list] = None, fallback_color: Optional[list] = None,
timeout: Optional[float] = None, timeout: Optional[float] = None,
clock_id: Optional[str] = None, clock_id: Optional[str] = None,
notification_effect: Optional[str] = None,
duration_ms: Optional[int] = None,
default_color: Optional[str] = None,
app_colors: Optional[dict] = None,
app_filter_mode: Optional[str] = None,
app_filter_list: Optional[list] = None,
os_listener: Optional[bool] = None,
) -> ColorStripSource: ) -> ColorStripSource:
"""Create a new color strip source. """Create a new color strip source.
@@ -259,6 +267,24 @@ class ColorStripStore:
fallback_color=fb, fallback_color=fb,
timeout=float(timeout) if timeout is not None else 5.0, timeout=float(timeout) if timeout is not None else 5.0,
) )
elif source_type == "notification":
source = NotificationColorStripSource(
id=source_id,
name=name,
source_type="notification",
created_at=now,
updated_at=now,
description=description,
clock_id=clock_id,
notification_effect=notification_effect or "flash",
duration_ms=int(duration_ms) if duration_ms is not None else 1500,
default_color=default_color or "#FFFFFF",
app_colors=app_colors if isinstance(app_colors, dict) else {},
app_filter_mode=app_filter_mode or "off",
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
os_listener=bool(os_listener) if os_listener is not None else False,
led_count=led_count,
)
else: else:
if calibration is None: if calibration is None:
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left") calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
@@ -321,6 +347,13 @@ class ColorStripStore:
fallback_color: Optional[list] = None, fallback_color: Optional[list] = None,
timeout: Optional[float] = None, timeout: Optional[float] = None,
clock_id: Optional[str] = None, clock_id: Optional[str] = None,
notification_effect: Optional[str] = None,
duration_ms: Optional[int] = None,
default_color: Optional[str] = None,
app_colors: Optional[dict] = None,
app_filter_mode: Optional[str] = None,
app_filter_list: Optional[list] = None,
os_listener: Optional[bool] = None,
) -> ColorStripSource: ) -> ColorStripSource:
"""Update an existing color strip source. """Update an existing color strip source.
@@ -436,6 +469,23 @@ class ColorStripStore:
source.timeout = float(timeout) source.timeout = float(timeout)
if led_count is not None: if led_count is not None:
source.led_count = led_count source.led_count = led_count
elif isinstance(source, NotificationColorStripSource):
if notification_effect is not None:
source.notification_effect = notification_effect
if duration_ms is not None:
source.duration_ms = int(duration_ms)
if default_color is not None:
source.default_color = default_color
if app_colors is not None and isinstance(app_colors, dict):
source.app_colors = app_colors
if app_filter_mode is not None:
source.app_filter_mode = app_filter_mode
if app_filter_list is not None and isinstance(app_filter_list, list):
source.app_filter_list = app_filter_list
if os_listener is not None:
source.os_listener = bool(os_listener)
if led_count is not None:
source.led_count = led_count
source.updated_at = datetime.utcnow() source.updated_at = datetime.utcnow()
self._save() self._save()

View File

@@ -30,6 +30,7 @@
<option value="mapped" data-i18n="color_strip.type.mapped">Mapped</option> <option value="mapped" data-i18n="color_strip.type.mapped">Mapped</option>
<option value="audio" data-i18n="color_strip.type.audio">Audio Reactive</option> <option value="audio" data-i18n="color_strip.type.audio">Audio Reactive</option>
<option value="api_input" data-i18n="color_strip.type.api_input">API Input</option> <option value="api_input" data-i18n="color_strip.type.api_input">API Input</option>
<option value="notification" data-i18n="color_strip.type.notification">Notification</option>
</select> </select>
</div> </div>
@@ -441,6 +442,90 @@
</div> </div>
</div> </div>
<!-- Notification-specific fields -->
<div id="css-editor-notification-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-notification-effect" data-i18n="color_strip.notification.effect">Effect:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.effect.hint">Visual effect when a notification fires.</small>
<select id="css-editor-notification-effect">
<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>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-notification-duration">
<span data-i18n="color_strip.notification.duration">Duration (ms):</span>
<span id="css-editor-notification-duration-val">1500</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.duration.hint">How long the notification effect plays, in milliseconds.</small>
<input type="range" id="css-editor-notification-duration" min="100" max="10000" step="100" value="1500"
oninput="document.getElementById('css-editor-notification-duration-val').textContent = this.value">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-notification-default-color" data-i18n="color_strip.notification.default_color">Default Color:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.default_color.hint">Color used when the notification has no app-specific color mapping.</small>
<input type="color" id="css-editor-notification-default-color" value="#ffffff">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-notification-filter-mode" data-i18n="color_strip.notification.filter_mode">App Filter:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.filter_mode.hint">Filter notifications by app name. Off = accept all, Whitelist = only listed apps, Blacklist = all except listed apps.</small>
<select id="css-editor-notification-filter-mode" onchange="onNotificationFilterModeChange()">
<option value="off" data-i18n="color_strip.notification.filter_mode.off">Off</option>
<option value="whitelist" data-i18n="color_strip.notification.filter_mode.whitelist">Whitelist</option>
<option value="blacklist" data-i18n="color_strip.notification.filter_mode.blacklist">Blacklist</option>
</select>
</div>
<div id="css-editor-notification-filter-list-group" class="form-group" style="display:none">
<div class="label-row">
<label data-i18n="color_strip.notification.filter_list">App List:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.filter_list.hint">Comma-separated app names for the filter.</small>
<input type="text" id="css-editor-notification-filter-list" data-i18n-placeholder="color_strip.notification.filter_list.placeholder" placeholder="Discord, Slack, Telegram">
</div>
<details class="form-collapse">
<summary data-i18n="color_strip.notification.app_colors">App Colors</summary>
<div class="form-collapse-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.notification.app_colors.label">Color Mappings:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.app_colors.hint">Per-app color overrides. Each row maps an app name to a specific color.</small>
<div id="notification-app-colors-list"></div>
<button type="button" class="btn btn-secondary" onclick="notificationAddAppColor()" data-i18n="color_strip.notification.app_colors.add">+ Add Mapping</button>
</div>
</div>
</details>
<div class="form-group" id="css-editor-notification-endpoint-group">
<div class="label-row">
<label data-i18n="color_strip.notification.endpoint">Webhook Endpoint:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.endpoint.hint">Use this URL to trigger notifications from external systems.</small>
<div id="css-editor-notification-endpoint" class="template-config" style="font-family:monospace; font-size:0.85em; word-break:break-all;"></div>
</div>
</div>
<!-- Shared LED count field --> <!-- Shared LED count field -->
<div id="css-editor-led-count-group" class="form-group"> <div id="css-editor-led-count-group" class="form-group">
<div class="label-row"> <div class="label-row">