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

View File

@@ -11,6 +11,7 @@ Current types:
ColorCycleColorStripSource — smoothly cycles through a user-defined list of colors
AudioColorStripSource — audio-reactive visualization (spectrum, beat pulse, VU meter)
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
@@ -82,6 +83,13 @@ class ColorStripSource:
"color_peak": None,
"fallback_color": 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
@@ -218,6 +226,25 @@ class ColorStripSource:
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
return PictureColorStripSource(
id=sid, name=name, source_type=source_type,
@@ -469,3 +496,36 @@ class ApiInputColorStripSource(ColorStripSource):
d["fallback_color"] = list(self.fallback_color)
d["timeout"] = self.timeout
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,
GradientColorStripSource,
MappedColorStripSource,
NotificationColorStripSource,
PictureColorStripSource,
StaticColorStripSource,
)
@@ -120,6 +121,13 @@ class ColorStripStore:
fallback_color: Optional[list] = None,
timeout: Optional[float] = 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:
"""Create a new color strip source.
@@ -259,6 +267,24 @@ class ColorStripStore:
fallback_color=fb,
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:
if calibration is None:
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
@@ -321,6 +347,13 @@ class ColorStripStore:
fallback_color: Optional[list] = None,
timeout: Optional[float] = 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:
"""Update an existing color strip source.
@@ -436,6 +469,23 @@ class ColorStripStore:
source.timeout = float(timeout)
if led_count is not None:
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()
self._save()