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:
118
BRAINSTORM.md
Normal file
118
BRAINSTORM.md
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"/>';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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})">✕</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">📋</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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "+ Добавить слой",
|
||||||
|
|||||||
@@ -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": "通过 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": "+ 添加图层",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user