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 = ''; export const hash = ''; export const camera = ''; +export const bellRing = ''; export const wrench = ''; export const music = ''; export const search = ''; diff --git a/server/src/wled_controller/static/js/core/icons.js b/server/src/wled_controller/static/js/core/icons.js index 4c2d673..a4d766f 100644 --- a/server/src/wled_controller/static/js/core/icons.js +++ b/server/src/wled_controller/static/js/core/icons.js @@ -22,6 +22,7 @@ const _colorStripTypeIcons = { mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin), audio: _svg(P.music), audio_visualization: _svg(P.music), api_input: _svg(P.send), + notification: _svg(P.bellRing), }; const _valueSourceTypeIcons = { 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_SCENE = _svg(P.sparkles); export const ICON_CAPTURE = _svg(P.camera); +export const ICON_BELL = _svg(P.bellRing); diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 8012cad..4c81d7c 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -12,7 +12,7 @@ import { ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, 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'; import { wrapCard } from '../core/card-colors.js'; @@ -56,6 +56,12 @@ class CSSEditorModal extends Modal { audio_mirror: document.getElementById('css-editor-audio-mirror').checked, api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').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, }; } @@ -76,6 +82,7 @@ export function onCSSTypeChange() { 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-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 === 'audio') onAudioVizChange(); @@ -576,6 +583,108 @@ function _resetAudioState() { 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) => ` +
+ + + +
+ `).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 = `${t('color_strip.notification.save_first')}`; + return; + } + const base = `${window.location.origin}/api/v1`; + const url = `${base}/color-strip-sources/${cssId}/notify`; + el.innerHTML = ` + POST +
+ `; +} + /* ── Card ─────────────────────────────────────────────────────── */ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { @@ -587,6 +696,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { const isMapped = source.source_type === 'mapped'; const isAudio = source.source_type === 'audio'; const isApiInput = source.source_type === 'api_input'; + const isNotification = source.source_type === 'notification'; // Clock crosslink badge (replaces speed badge when clock is assigned) const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null; @@ -692,6 +802,20 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { ${ICON_TIMER} ${timeoutVal}s `; + } 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 = ` + ${ICON_BELL} ${escapeHtml(effectLabel)} + ${ICON_TIMER} ${durationVal}ms + + ${defColor.toUpperCase()} + + ${appCount > 0 ? `${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}` : ''} + ${source.led_count ? `${ICON_LED} ${source.led_count}` : ''} + `; } else { const ps = pictureSourceMap && pictureSourceMap[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 calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput) + const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification) ? `` : ''; @@ -809,6 +933,8 @@ export async function showCSSEditor(cssId = null, cloneData = null) { document.getElementById('css-editor-api-input-timeout-val').textContent = parseFloat(css.timeout ?? 5.0).toFixed(1); _showApiInputEndpoints(css.id); + } else if (sourceType === 'notification') { + _loadNotificationState(css); } else { 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-val').textContent = '5.0'; _showApiInputEndpoints(null); + _resetNotificationState(); document.getElementById('css-editor-title').innerHTML = `${ICON_FILM} ${t('color_strip.add')}`; document.getElementById('css-editor-gradient-preset').value = ''; gradientInit([ @@ -1030,6 +1157,20 @@ export async function saveCSSEditor() { timeout: parseFloat(document.getElementById('css-editor-api-input-timeout').value), }; 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 { payload = { name, diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index a26b307..62b20b8 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -808,6 +808,33 @@ "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.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.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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 97d4814..e515256 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -808,6 +808,33 @@ "color_strip.api_input.endpoints": "Эндпоинты для отправки:", "color_strip.api_input.endpoints.hint": "Используйте эти URL для отправки данных о цветах LED из вашего внешнего приложения. REST принимает JSON, WebSocket принимает как JSON, так и бинарные кадры.", "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.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.", "color_strip.composite.add_layer": "+ Добавить слой", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index c20f44f..a8a353b 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -808,6 +808,33 @@ "color_strip.api_input.endpoints": "推送端点:", "color_strip.api_input.endpoints.hint": "使用这些 URL 从外部应用程序推送 LED 颜色数据。REST 接受 JSON,WebSocket 接受 JSON 和原始二进制帧。", "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.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。", "color_strip.composite.add_layer": "+ 添加图层", diff --git a/server/src/wled_controller/static/sw.js b/server/src/wled_controller/static/sw.js index ef4fb4a..1bb42d7 100644 --- a/server/src/wled_controller/static/sw.js +++ b/server/src/wled_controller/static/sw.js @@ -7,7 +7,7 @@ * - Navigation: network-first with offline fallback */ -const CACHE_NAME = 'ledgrab-v10'; +const CACHE_NAME = 'ledgrab-v11'; // Only pre-cache static assets (no auth required). // Do NOT pre-cache '/' — it requires API key auth and would cache an error page. diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 133e2d6..9d875bd 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -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 diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index c617be0..ba34e84 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -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() diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 2c39c4d..ae27f9e 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -30,6 +30,7 @@ + @@ -441,6 +442,90 @@ + + +