feat: asset-based image/video sources, notification sounds, UI improvements
Some checks failed
Lint & Test / test (push) Has been cancelled
Some checks failed
Lint & Test / test (push) Has been cancelled
- Replace URL-based image_source/url fields with image_asset_id/video_asset_id on StaticImagePictureSource and VideoCaptureSource (clean break, no migration) - Resolve asset IDs to file paths at runtime via AssetStore.get_file_path() - Add EntitySelect asset pickers for image/video in stream editor modal - Add notification sound configuration (global sound + per-app overrides) - Unify per-app color and sound overrides into single "Per-App Overrides" section - Persist notification history between server restarts - Add asset management system (upload, edit, delete, soft-delete) - Replace emoji buttons with SVG icons throughout UI - Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
This commit is contained in:
118
BRAINSTORM.md
118
BRAINSTORM.md
@@ -1,118 +0,0 @@
|
||||
# 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
|
||||
20
README.md
20
README.md
@@ -121,6 +121,26 @@ Open **http://localhost:8080** to access the dashboard.
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and Home Assistant setup.
|
||||
|
||||
## Demo Mode
|
||||
|
||||
Demo mode runs the server with virtual devices, sample data, and isolated storage — useful for exploring the UI without real hardware.
|
||||
|
||||
Set the `WLED_DEMO` environment variable to `true`, `1`, or `yes`:
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker compose run -e WLED_DEMO=true server
|
||||
|
||||
# Python
|
||||
WLED_DEMO=true uvicorn wled_controller.main:app --host 0.0.0.0 --port 8081
|
||||
|
||||
# Windows (installed app)
|
||||
set WLED_DEMO=true
|
||||
LedGrab.bat
|
||||
```
|
||||
|
||||
Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores data in `data/demo/` (separate from production data). It can run alongside the main server.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
|
||||
37
TODO.md
37
TODO.md
@@ -1,37 +0,0 @@
|
||||
# Build Size Reduction
|
||||
|
||||
## Phase 1: Quick Wins (build scripts)
|
||||
|
||||
- [x] Strip unused NumPy submodules (polynomial, linalg, ma, lib, distutils)
|
||||
- [x] Strip debug symbols from .pyd/.dll/.so files
|
||||
- [x] Remove zeroconf service database
|
||||
- [x] Remove .py source from site-packages after compiling to .pyc
|
||||
- [x] Strip unused PIL image plugins (keep JPEG/PNG/ICO/BMP for tray)
|
||||
|
||||
## Phase 2: Replace Pillow with cv2
|
||||
|
||||
- [x] Create `utils/image_codec.py` with cv2-based image helpers
|
||||
- [x] Replace PIL in `_preview_helpers.py`
|
||||
- [x] Replace PIL in `picture_sources.py`
|
||||
- [x] Replace PIL in `color_strip_sources.py`
|
||||
- [x] Replace PIL in `templates.py`
|
||||
- [x] Replace PIL in `postprocessing.py`
|
||||
- [x] Replace PIL in `output_targets_keycolors.py`
|
||||
- [x] Replace PIL in `kc_target_processor.py`
|
||||
- [x] Replace PIL in `pixelate.py` filter
|
||||
- [x] Replace PIL in `downscaler.py` filter
|
||||
- [x] Replace PIL in `scrcpy_engine.py`
|
||||
- [x] Replace PIL in `live_stream_manager.py`
|
||||
- [x] Move Pillow from core deps to [tray] optional in pyproject.toml
|
||||
- [x] Make PIL import conditional in `tray.py`
|
||||
- [x] Move opencv-python-headless to core dependencies
|
||||
|
||||
## Phase 4: OpenCV stripping (build scripts)
|
||||
|
||||
- [x] Strip ffmpeg DLL, Haar cascades, dev files (already existed)
|
||||
- [x] Strip typing stubs (already existed)
|
||||
|
||||
## Verification
|
||||
|
||||
- [x] Lint: `ruff check src/ tests/ --fix`
|
||||
- [x] Tests: 341 passed
|
||||
@@ -1,143 +0,0 @@
|
||||
# Auto-Update Plan — Phase 1: Check & Notify
|
||||
|
||||
> Created: 2026-03-25. Status: **planned, not started.**
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Release Provider Abstraction
|
||||
|
||||
```
|
||||
core/update/
|
||||
release_provider.py — ABC: get_releases(), get_releases_page_url()
|
||||
gitea_provider.py — Gitea REST API implementation
|
||||
version_check.py — normalize_version(), is_newer() using packaging.version
|
||||
update_service.py — Background asyncio task + state machine
|
||||
```
|
||||
|
||||
**`ReleaseProvider` interface** — two methods:
|
||||
- `get_releases(limit) → list[ReleaseInfo]` — fetch releases (newest first)
|
||||
- `get_releases_page_url() → str` — link for "view on web"
|
||||
|
||||
**`GiteaReleaseProvider`** calls `GET {base_url}/api/v1/repos/{repo}/releases`. Swapping to GitHub later means implementing the same interface against `api.github.com`.
|
||||
|
||||
**Data models:**
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class AssetInfo:
|
||||
name: str # "LedGrab-v0.3.0-win-x64.zip"
|
||||
size: int # bytes
|
||||
download_url: str
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseInfo:
|
||||
tag: str # "v0.3.0"
|
||||
version: str # "0.3.0"
|
||||
name: str # "LedGrab v0.3.0"
|
||||
body: str # release notes markdown
|
||||
prerelease: bool
|
||||
published_at: str # ISO 8601
|
||||
assets: tuple[AssetInfo, ...]
|
||||
```
|
||||
|
||||
### Version Comparison
|
||||
|
||||
`version_check.py` — normalize Gitea tags to PEP 440:
|
||||
- `v0.3.0-alpha.1` → `0.3.0a1`
|
||||
- `v0.3.0-beta.2` → `0.3.0b2`
|
||||
- `v0.3.0-rc.3` → `0.3.0rc3`
|
||||
|
||||
Uses `packaging.version.Version` for comparison.
|
||||
|
||||
### Update Service
|
||||
|
||||
Follows the **AutoBackupEngine pattern**:
|
||||
- Settings in `Database.get_setting("auto_update")`
|
||||
- asyncio.Task for periodic checks
|
||||
- 30s startup delay (avoid slowing boot)
|
||||
- 60s debounce on manual checks
|
||||
|
||||
**State machine (Phase 1):** `IDLE → CHECKING → UPDATE_AVAILABLE`
|
||||
|
||||
No download/apply in Phase 1 — just detection and notification.
|
||||
|
||||
**Settings:** `enabled` (bool), `check_interval_hours` (float), `channel` ("stable" | "pre-release")
|
||||
|
||||
**Persisted state:** `dismissed_version`, `last_check` (survives restarts)
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/v1/system/update/status` | Current state + available version |
|
||||
| `POST` | `/api/v1/system/update/check` | Trigger immediate check |
|
||||
| `POST` | `/api/v1/system/update/dismiss` | Dismiss notification for current version |
|
||||
| `GET` | `/api/v1/system/update/settings` | Get settings |
|
||||
| `PUT` | `/api/v1/system/update/settings` | Update settings |
|
||||
|
||||
### Wiring
|
||||
|
||||
- New `get_update_service()` in `dependencies.py`
|
||||
- `UpdateService` created in `main.py` lifespan, `start()`/`stop()` alongside other engines
|
||||
- Router registered in `api/__init__.py`
|
||||
- WebSocket event: `update_available` fired via `processor_manager.fire_event()`
|
||||
|
||||
## Frontend
|
||||
|
||||
### Version badge highlight
|
||||
|
||||
The existing `#server-version` pill in the header gets a CSS class `has-update` when a newer version exists — changes the background to `var(--warning-color)` with a subtle pulse, making it clickable to open the update panel in settings.
|
||||
|
||||
### Notification popup
|
||||
|
||||
On `server:update_available` WebSocket event (and on page load if status says `has_update` and not dismissed):
|
||||
- A **persistent dismissible banner** slides in below the header (not the ephemeral 3s toast)
|
||||
- Shows: "Version {x.y.z} is available" + [View Release Notes] + [Dismiss]
|
||||
- Dismiss calls `POST /dismiss` and hides the bar for that version
|
||||
- Stored in `localStorage` so it doesn't re-show after page refresh for dismissed versions
|
||||
|
||||
### Settings tab: "Updates"
|
||||
|
||||
New 5th tab in the settings modal:
|
||||
- Current version display
|
||||
- "Check for updates" button + spinner
|
||||
- Channel selector (stable / pre-release) via IconSelect
|
||||
- Auto-check toggle + interval selector
|
||||
- When update available: release name, notes preview, link to releases page
|
||||
|
||||
### i18n keys
|
||||
|
||||
New `update.*` keys in `en.json`, `ru.json`, `zh.json` for all labels.
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `core/update/__init__.py` | Package init |
|
||||
| `core/update/release_provider.py` | Abstract provider interface + data models |
|
||||
| `core/update/gitea_provider.py` | Gitea API implementation |
|
||||
| `core/update/version_check.py` | Semver normalization + comparison |
|
||||
| `core/update/update_service.py` | Background service + state machine |
|
||||
| `api/routes/update.py` | REST endpoints |
|
||||
| `api/schemas/update.py` | Pydantic request/response models |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `api/__init__.py` | Register update router |
|
||||
| `api/dependencies.py` | Add `get_update_service()` |
|
||||
| `main.py` | Create & start/stop UpdateService in lifespan |
|
||||
| `templates/modals/settings.html` | Add Updates tab |
|
||||
| `static/js/features/settings.ts` | Update check/settings UI logic |
|
||||
| `static/js/core/api.ts` | Version badge highlight on health check |
|
||||
| `static/css/layout.css` | `.has-update` styles for version badge |
|
||||
| `static/locales/en.json` | i18n keys |
|
||||
| `static/locales/ru.json` | i18n keys |
|
||||
| `static/locales/zh.json` | i18n keys |
|
||||
|
||||
## Future Phases (not in scope)
|
||||
|
||||
- **Phase 2**: Download & stage artifacts
|
||||
- **Phase 3**: Apply update & restart (external updater script, NSIS silent mode)
|
||||
- **Phase 4**: Checksums, "What's new" dialog, update history
|
||||
@@ -175,7 +175,7 @@ When adding **new tabs, sections, or major UI elements**, update the correspondi
|
||||
|
||||
When you need a new icon:
|
||||
1. Find the Lucide icon at https://lucide.dev
|
||||
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.js` as a new export
|
||||
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.ts` as a new export
|
||||
3. Add a corresponding `ICON_*` constant in `icons.ts` using `_svg(P.myIcon)`
|
||||
4. Import and use the constant in your feature module
|
||||
|
||||
@@ -213,7 +213,19 @@ Static HTML using `data-i18n` attributes is handled automatically by the i18n sy
|
||||
- `fetchWithAuth('/devices/dev_123', { method: 'DELETE' })` → `DELETE /api/v1/devices/dev_123`
|
||||
- Passing `/api/v1/gradients` results in **double prefix**: `/api/v1/api/v1/gradients` (404)
|
||||
|
||||
For raw `fetch()` without auth (rare), use the full path manually.
|
||||
**NEVER use raw `fetch()` or `new Audio(url)` / `new Image()` for authenticated endpoints.** These bypass the auth token and will fail with 401. Always use `fetchWithAuth()` and convert to blob URLs when needed (e.g. for `<audio>`, `<img>`, or download links):
|
||||
|
||||
```typescript
|
||||
// WRONG: no auth header — 401
|
||||
const audio = new Audio(`${API_BASE}/assets/${id}/file`);
|
||||
|
||||
// CORRECT: fetch with auth, then create blob URL
|
||||
const res = await fetchWithAuth(`/assets/${id}/file`);
|
||||
const blob = await res.blob();
|
||||
const audio = new Audio(URL.createObjectURL(blob));
|
||||
```
|
||||
|
||||
The only exception is raw `fetch()` for multipart uploads where you must set `Content-Type` to let the browser handle the boundary — but still use `getHeaders()` for the auth token.
|
||||
|
||||
## Bundling & Development Workflow
|
||||
|
||||
@@ -266,11 +278,11 @@ See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, bro
|
||||
|
||||
### Uptime / duration values
|
||||
|
||||
Use `formatUptime(seconds)` from `core/ui.js`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
|
||||
Use `formatUptime(seconds)` from `core/ui.ts`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
|
||||
|
||||
### Large numbers
|
||||
|
||||
Use `formatCompact(n)` from `core/ui.js`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
|
||||
Use `formatCompact(n)` from `core/ui.ts`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
|
||||
|
||||
### Preventing layout shift
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ Two independent server modes with separate configs, ports, and data directories:
|
||||
| **Real** | `python -m wled_controller` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
|
||||
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
|
||||
|
||||
Both can run simultaneously on different ports.
|
||||
Demo mode can also be triggered via the `WLED_DEMO` environment variable (`true`, `1`, or `yes`). This works with any launch method — Python, Docker (`-e WLED_DEMO=true`), or the installed app (`set WLED_DEMO=true` before `LedGrab.bat`).
|
||||
|
||||
Both modes can run simultaneously on different ports.
|
||||
|
||||
## Restart Procedure
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- `src/wled_controller/api/schemas/` — Pydantic request/response models (one file per entity)
|
||||
- `src/wled_controller/core/` — Core business logic (capture, devices, audio, processing, automations)
|
||||
- `src/wled_controller/storage/` — Data models (dataclasses) and JSON persistence stores
|
||||
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection)
|
||||
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
|
||||
- `src/wled_controller/static/` — Frontend files (TypeScript, CSS, locales)
|
||||
- `src/wled_controller/templates/` — Jinja2 HTML templates
|
||||
- `config/` — Configuration files (YAML)
|
||||
|
||||
@@ -8,7 +8,7 @@ The server component provides:
|
||||
- 🎯 **Real-time Screen Capture** - Multi-monitor support with configurable FPS
|
||||
- 🎨 **Advanced Processing** - Border pixel extraction with color correction
|
||||
- 🔧 **Flexible Calibration** - Map screen edges to any LED layout
|
||||
- 🌐 **REST API** - Complete control via 17 REST endpoints
|
||||
- 🌐 **REST API** - Complete control via 25+ REST endpoints
|
||||
- 💾 **Persistent Storage** - JSON-based device and configuration management
|
||||
- 📊 **Metrics & Monitoring** - Real-time FPS, status, and performance data
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from .routes.color_strip_processing import router as cspt_router
|
||||
from .routes.gradients import router as gradients_router
|
||||
from .routes.weather_sources import router as weather_sources_router
|
||||
from .routes.update import router as update_router
|
||||
from .routes.assets import router as assets_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -52,5 +53,6 @@ router.include_router(cspt_router)
|
||||
router.include_router(gradients_router)
|
||||
router.include_router(weather_sources_router)
|
||||
router.include_router(update_router)
|
||||
router.include_router(assets_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -24,6 +24,7 @@ from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.gradient_store import GradientStore
|
||||
from wled_controller.storage.weather_source_store import WeatherSourceStore
|
||||
from wled_controller.storage.asset_store import AssetStore
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
@@ -131,6 +132,10 @@ def get_weather_manager() -> WeatherManager:
|
||||
return _get("weather_manager", "Weather manager")
|
||||
|
||||
|
||||
def get_asset_store() -> AssetStore:
|
||||
return _get("asset_store", "Asset store")
|
||||
|
||||
|
||||
def get_database() -> Database:
|
||||
return _get("database", "Database")
|
||||
|
||||
@@ -187,6 +192,7 @@ def init_dependencies(
|
||||
weather_source_store: WeatherSourceStore | None = None,
|
||||
weather_manager: WeatherManager | None = None,
|
||||
update_service: UpdateService | None = None,
|
||||
asset_store: AssetStore | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
_deps.update({
|
||||
@@ -213,4 +219,5 @@ def init_dependencies(
|
||||
"weather_source_store": weather_source_store,
|
||||
"weather_manager": weather_manager,
|
||||
"update_service": update_service,
|
||||
"asset_store": asset_store,
|
||||
})
|
||||
|
||||
@@ -123,7 +123,8 @@ async def stream_capture_test(
|
||||
if stream:
|
||||
try:
|
||||
stream.cleanup()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Capture stream cleanup error: %s", e)
|
||||
pass
|
||||
done_event.set()
|
||||
|
||||
@@ -210,8 +211,9 @@ async def stream_capture_test(
|
||||
"avg_capture_ms": round(avg_ms, 1),
|
||||
})
|
||||
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
# WebSocket disconnect or send error — signal capture thread to stop
|
||||
logger.debug("Capture preview WS error, stopping capture thread: %s", e)
|
||||
stop_event.set()
|
||||
await capture_future
|
||||
raise
|
||||
|
||||
226
server/src/wled_controller/api/routes/assets.py
Normal file
226
server/src/wled_controller/api/routes/assets.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Asset routes: CRUD, file upload/download, prebuilt restore."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import fire_entity_event, get_asset_store
|
||||
from wled_controller.api.schemas.assets import (
|
||||
AssetListResponse,
|
||||
AssetResponse,
|
||||
AssetUpdate,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.storage.asset_store import AssetStore
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Prebuilt sounds directory (shipped with the app)
|
||||
_PREBUILT_SOUNDS_DIR = Path(__file__).resolve().parents[2] / "data" / "prebuilt_sounds"
|
||||
|
||||
|
||||
def _asset_to_response(asset) -> AssetResponse:
|
||||
d = asset.to_dict()
|
||||
return AssetResponse(**d)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/assets",
|
||||
response_model=AssetListResponse,
|
||||
tags=["Assets"],
|
||||
)
|
||||
async def list_assets(
|
||||
_: AuthRequired,
|
||||
asset_type: str | None = Query(None, description="Filter by type: sound, image, video, other"),
|
||||
store: AssetStore = Depends(get_asset_store),
|
||||
):
|
||||
"""List all assets, optionally filtered by type."""
|
||||
if asset_type:
|
||||
assets = store.get_assets_by_type(asset_type)
|
||||
else:
|
||||
assets = store.get_visible_assets()
|
||||
return AssetListResponse(
|
||||
assets=[_asset_to_response(a) for a in assets],
|
||||
count=len(assets),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/assets/{asset_id}",
|
||||
response_model=AssetResponse,
|
||||
tags=["Assets"],
|
||||
)
|
||||
async def get_asset(
|
||||
asset_id: str,
|
||||
_: AuthRequired,
|
||||
store: AssetStore = Depends(get_asset_store),
|
||||
):
|
||||
"""Get asset metadata by ID."""
|
||||
try:
|
||||
asset = store.get_asset(asset_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
|
||||
if asset.deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
|
||||
return _asset_to_response(asset)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/assets",
|
||||
response_model=AssetResponse,
|
||||
status_code=201,
|
||||
tags=["Assets"],
|
||||
)
|
||||
async def upload_asset(
|
||||
_: AuthRequired,
|
||||
file: UploadFile = File(...),
|
||||
name: str | None = Query(None, description="Display name (defaults to filename)"),
|
||||
description: str | None = Query(None, description="Optional description"),
|
||||
store: AssetStore = Depends(get_asset_store),
|
||||
):
|
||||
"""Upload a new asset file."""
|
||||
config = get_config()
|
||||
max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > max_size:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File too large (max {max_size // (1024 * 1024)} MB)",
|
||||
)
|
||||
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="Empty file")
|
||||
|
||||
display_name = name or Path(file.filename or "unnamed").stem.replace("_", " ").replace("-", " ").title()
|
||||
|
||||
try:
|
||||
asset = store.create_asset(
|
||||
name=display_name,
|
||||
filename=file.filename or "unnamed",
|
||||
file_data=data,
|
||||
mime_type=file.content_type,
|
||||
description=description,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
fire_entity_event("asset", "created", asset.id)
|
||||
return _asset_to_response(asset)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/assets/{asset_id}",
|
||||
response_model=AssetResponse,
|
||||
tags=["Assets"],
|
||||
)
|
||||
async def update_asset(
|
||||
asset_id: str,
|
||||
body: AssetUpdate,
|
||||
_: AuthRequired,
|
||||
store: AssetStore = Depends(get_asset_store),
|
||||
):
|
||||
"""Update asset metadata."""
|
||||
try:
|
||||
asset = store.update_asset(
|
||||
asset_id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
tags=body.tags,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
fire_entity_event("asset", "updated", asset.id)
|
||||
return _asset_to_response(asset)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/assets/{asset_id}",
|
||||
tags=["Assets"],
|
||||
)
|
||||
async def delete_asset(
|
||||
asset_id: str,
|
||||
_: AuthRequired,
|
||||
store: AssetStore = Depends(get_asset_store),
|
||||
):
|
||||
"""Delete an asset. Prebuilt assets are soft-deleted and can be restored."""
|
||||
try:
|
||||
asset = store.get_asset(asset_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
|
||||
|
||||
store.delete_asset(asset_id)
|
||||
fire_entity_event("asset", "deleted", asset_id)
|
||||
|
||||
return {
|
||||
"status": "deleted",
|
||||
"id": asset_id,
|
||||
"restorable": asset.prebuilt,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File serving
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/assets/{asset_id}/file",
|
||||
tags=["Assets"],
|
||||
)
|
||||
async def serve_asset_file(
|
||||
asset_id: str,
|
||||
_: AuthRequired,
|
||||
store: AssetStore = Depends(get_asset_store),
|
||||
):
|
||||
"""Serve the actual asset file for playback/display."""
|
||||
file_path = store.get_file_path(asset_id)
|
||||
if file_path is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset file not found: {asset_id}")
|
||||
|
||||
try:
|
||||
asset = store.get_asset(asset_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type=asset.mime_type,
|
||||
filename=asset.filename,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prebuilt restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/assets/restore-prebuilt",
|
||||
tags=["Assets"],
|
||||
)
|
||||
async def restore_prebuilt_assets(
|
||||
_: AuthRequired,
|
||||
store: AssetStore = Depends(get_asset_store),
|
||||
):
|
||||
"""Re-import any deleted prebuilt assets."""
|
||||
restored = store.restore_prebuilt(_PREBUILT_SOUNDS_DIR)
|
||||
return {
|
||||
"status": "ok",
|
||||
"restored_count": len(restored),
|
||||
"restored": [_asset_to_response(a) for a in restored],
|
||||
}
|
||||
@@ -221,7 +221,8 @@ async def test_audio_source_ws(
|
||||
template = template_store.get_template(audio_template_id)
|
||||
engine_type = template.engine_type
|
||||
engine_config = template.engine_config
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("Audio template not found, falling back to best available engine: %s", e)
|
||||
pass # Fall back to best available engine
|
||||
|
||||
# Acquire shared audio stream
|
||||
@@ -268,6 +269,7 @@ async def test_audio_source_ws(
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Audio test WebSocket disconnected for source %s", source_id)
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Audio test WebSocket error for {source_id}: {e}")
|
||||
|
||||
@@ -46,8 +46,8 @@ async def list_audio_templates(
|
||||
]
|
||||
return AudioTemplateListResponse(templates=responses, count=len(responses))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list audio templates: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to list audio templates: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/audio-templates", response_model=AudioTemplateResponse, tags=["Audio Templates"], status_code=201)
|
||||
@@ -76,8 +76,8 @@ async def create_audio_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create audio template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to create audio template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
|
||||
@@ -126,8 +126,8 @@ async def update_audio_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update audio template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to update audio template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/api/v1/audio-templates/{template_id}", status_code=204, tags=["Audio Templates"])
|
||||
@@ -149,8 +149,8 @@ async def delete_audio_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete audio template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to delete audio template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== AUDIO ENGINE ENDPOINTS =====
|
||||
@@ -175,8 +175,8 @@ async def list_audio_engines(_auth: AuthRequired):
|
||||
|
||||
return AudioEngineListResponse(engines=engines, count=len(engines))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list audio engines: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to list audio engines: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== REAL-TIME AUDIO TEMPLATE TEST WEBSOCKET =====
|
||||
@@ -237,6 +237,7 @@ async def test_audio_template_ws(
|
||||
})
|
||||
await asyncio.sleep(0.05)
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Audio template test WebSocket disconnected")
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Audio template test WS error: {e}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""System routes: backup, restore, auto-backup.
|
||||
|
||||
All backups are SQLite database snapshots (.db files).
|
||||
Backups are ZIP files containing a SQLite database snapshot (.db)
|
||||
and any uploaded asset files from data/assets/.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -8,13 +9,14 @@ import io
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import get_auto_backup_engine, get_database
|
||||
from wled_controller.api.dependencies import get_asset_store, get_auto_backup_engine, get_database
|
||||
from wled_controller.api.schemas.system import (
|
||||
AutoBackupSettings,
|
||||
AutoBackupStatusResponse,
|
||||
@@ -22,7 +24,9 @@ from wled_controller.api.schemas.system import (
|
||||
BackupListResponse,
|
||||
RestoreResponse,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.storage.asset_store import AssetStore
|
||||
from wled_controller.storage.database import Database, freeze_writes
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
@@ -60,25 +64,43 @@ def _schedule_restart() -> None:
|
||||
|
||||
|
||||
@router.get("/api/v1/system/backup", tags=["System"])
|
||||
def backup_config(_: AuthRequired, db: Database = Depends(get_database)):
|
||||
"""Download a full database backup as a .db file."""
|
||||
def backup_config(
|
||||
_: AuthRequired,
|
||||
db: Database = Depends(get_database),
|
||||
asset_store: AssetStore = Depends(get_asset_store),
|
||||
):
|
||||
"""Download a full backup as a .zip containing the database and asset files."""
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
db.backup_to(tmp_path)
|
||||
content = tmp_path.read_bytes()
|
||||
db_content = tmp_path.read_bytes()
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
# Build ZIP: database + asset files
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("ledgrab.db", db_content)
|
||||
|
||||
# Include all asset files
|
||||
assets_dir = Path(get_config().assets.assets_dir)
|
||||
if assets_dir.exists():
|
||||
for asset_file in assets_dir.iterdir():
|
||||
if asset_file.is_file():
|
||||
zf.write(asset_file, f"assets/{asset_file.name}")
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||
filename = f"ledgrab-backup-{timestamp}.db"
|
||||
filename = f"ledgrab-backup-{timestamp}.zip"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content),
|
||||
media_type="application/octet-stream",
|
||||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
@@ -89,21 +111,52 @@ async def restore_config(
|
||||
file: UploadFile = File(...),
|
||||
db: Database = Depends(get_database),
|
||||
):
|
||||
"""Upload a .db backup file to restore all configuration. Triggers server restart."""
|
||||
"""Upload a .db or .zip backup file to restore all configuration. Triggers server restart.
|
||||
|
||||
ZIP backups contain the database and asset files. Plain .db backups are
|
||||
also supported for backward compatibility (assets are not restored).
|
||||
"""
|
||||
raw = await file.read()
|
||||
if len(raw) > 50 * 1024 * 1024: # 50 MB limit
|
||||
raise HTTPException(status_code=400, detail="Backup file too large (max 50 MB)")
|
||||
if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets)
|
||||
raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)")
|
||||
|
||||
if len(raw) < 100:
|
||||
raise HTTPException(status_code=400, detail="File too small to be a valid SQLite database")
|
||||
|
||||
# SQLite files start with "SQLite format 3\000"
|
||||
if not raw[:16].startswith(b"SQLite format 3"):
|
||||
raise HTTPException(status_code=400, detail="Not a valid SQLite database file")
|
||||
raise HTTPException(status_code=400, detail="File too small to be a valid backup")
|
||||
|
||||
import tempfile
|
||||
|
||||
is_zip = raw[:4] == b"PK\x03\x04"
|
||||
is_sqlite = raw[:16].startswith(b"SQLite format 3")
|
||||
|
||||
if not is_zip and not is_sqlite:
|
||||
raise HTTPException(status_code=400, detail="Not a valid backup file (expected .zip or .db)")
|
||||
|
||||
if is_zip:
|
||||
# Extract DB and assets from ZIP
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
||||
names = zf.namelist()
|
||||
if "ledgrab.db" not in names:
|
||||
raise HTTPException(status_code=400, detail="ZIP backup missing ledgrab.db")
|
||||
|
||||
db_bytes = zf.read("ledgrab.db")
|
||||
|
||||
# Restore asset files
|
||||
assets_dir = Path(get_config().assets.assets_dir)
|
||||
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in names:
|
||||
if name.startswith("assets/") and not name.endswith("/"):
|
||||
asset_filename = name.split("/", 1)[1]
|
||||
dest = assets_dir / asset_filename
|
||||
dest.write_bytes(zf.read(name))
|
||||
logger.info(f"Restored asset file: {asset_filename}")
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(status_code=400, detail="Invalid ZIP file")
|
||||
else:
|
||||
db_bytes = raw
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
tmp.write(raw)
|
||||
tmp.write(db_bytes)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
|
||||
@@ -57,8 +57,8 @@ async def list_cspt(
|
||||
responses = [_cspt_to_response(t) for t in templates]
|
||||
return ColorStripProcessingTemplateListResponse(templates=responses, count=len(responses))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list color strip processing templates: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to list color strip processing templates: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201)
|
||||
@@ -84,8 +84,8 @@ async def create_cspt(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to create color strip processing template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
|
||||
@@ -127,8 +127,8 @@ async def update_cspt(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to update color strip processing template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"])
|
||||
@@ -159,8 +159,8 @@ async def delete_cspt(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to delete color strip processing template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ── Test / Preview WebSocket ──────────────────────────────────────────
|
||||
@@ -259,12 +259,14 @@ async def test_cspt_ws(
|
||||
result = flt.process_strip(colors)
|
||||
if result is not None:
|
||||
colors = result
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Strip filter processing error: %s", e)
|
||||
pass
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
await asyncio.sleep(frame_interval)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Color strip processing test WebSocket disconnected")
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"CSPT test WS error: {e}")
|
||||
|
||||
@@ -96,7 +96,8 @@ def _resolve_display_index(picture_source_id: str, picture_source_store: Picture
|
||||
return 0
|
||||
try:
|
||||
ps = picture_source_store.get_stream(picture_source_id)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to resolve display index for picture source %s: %s", picture_source_id, e)
|
||||
return 0
|
||||
if isinstance(ps, ScreenCapturePictureSource):
|
||||
return ps.display_index
|
||||
@@ -160,8 +161,8 @@ async def create_color_strip_source(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create color strip source: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to create color strip source: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"])
|
||||
@@ -204,8 +205,8 @@ async def update_color_strip_source(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update color strip source: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to update color strip source: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"])
|
||||
@@ -256,8 +257,8 @@ async def delete_color_strip_source(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete color strip source: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to delete color strip source: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== CALIBRATION TEST =====
|
||||
@@ -332,8 +333,8 @@ async def test_css_calibration(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set CSS calibration test mode: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to set CSS calibration test mode: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== OVERLAY VISUALIZATION =====
|
||||
@@ -372,8 +373,8 @@ async def start_css_overlay(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start CSS overlay: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to start CSS overlay: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/color-strip-sources/{source_id}/overlay/stop", tags=["Color Strip Sources"])
|
||||
@@ -387,8 +388,8 @@ async def stop_css_overlay(
|
||||
await manager.stop_css_overlay(source_id)
|
||||
return {"status": "stopped", "source_id": source_id}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop CSS overlay: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to stop CSS overlay: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-sources/{source_id}/overlay/status", tags=["Color Strip Sources"])
|
||||
@@ -549,7 +550,8 @@ async def preview_color_strip_ws(
|
||||
try:
|
||||
mgr = get_processor_manager()
|
||||
return getattr(mgr, "_sync_clock_manager", None)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("SyncClockManager not available: %s", e)
|
||||
return None
|
||||
|
||||
def _build_source(config: dict):
|
||||
|
||||
@@ -177,8 +177,8 @@ async def create_device(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create device: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to create device: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
|
||||
@@ -360,7 +360,8 @@ async def update_device(
|
||||
led_count=update_data.led_count,
|
||||
baud_rate=update_data.baud_rate,
|
||||
)
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("Processor manager device update skipped for %s: %s", device_id, e)
|
||||
pass
|
||||
|
||||
# Sync auto_shutdown and zone_mode in runtime state
|
||||
@@ -377,8 +378,8 @@ async def update_device(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update device: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to update device: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"])
|
||||
@@ -417,8 +418,8 @@ async def delete_device(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete device: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to delete device: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== DEVICE STATE (health only) =====
|
||||
@@ -654,6 +655,7 @@ async def device_ws_stream(
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Device event WebSocket disconnected for %s", device_id)
|
||||
pass
|
||||
finally:
|
||||
broadcaster.remove_client(device_id, websocket)
|
||||
|
||||
@@ -163,8 +163,8 @@ async def create_target(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create target: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to create target: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/output-targets", response_model=OutputTargetListResponse, tags=["Targets"])
|
||||
@@ -291,14 +291,16 @@ async def update_target(
|
||||
css_changed=data.color_strip_source_id is not None,
|
||||
brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed),
|
||||
)
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
|
||||
pass
|
||||
|
||||
# Device change requires async stop -> swap -> start cycle
|
||||
if data.device_id is not None:
|
||||
try:
|
||||
await manager.update_target_device(target_id, target.device_id)
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("Device update skipped for target %s: %s", target_id, e)
|
||||
pass
|
||||
|
||||
fire_entity_event("output_target", "updated", target_id)
|
||||
@@ -309,8 +311,8 @@ async def update_target(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update target: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to update target: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/api/v1/output-targets/{target_id}", status_code=204, tags=["Targets"])
|
||||
@@ -325,13 +327,15 @@ async def delete_target(
|
||||
# Stop processing if running
|
||||
try:
|
||||
await manager.stop_processing(target_id)
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("Stop processing skipped for target %s (not running): %s", target_id, e)
|
||||
pass
|
||||
|
||||
# Remove from manager
|
||||
try:
|
||||
manager.remove_target(target_id)
|
||||
except (ValueError, RuntimeError):
|
||||
except (ValueError, RuntimeError) as e:
|
||||
logger.debug("Remove target from manager skipped for %s: %s", target_id, e)
|
||||
pass
|
||||
|
||||
# Delete from store
|
||||
@@ -343,5 +347,5 @@ async def delete_target(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete target: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to delete target: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@@ -120,8 +120,8 @@ async def start_processing(
|
||||
msg = msg.replace(t.id, f"'{t.name}'")
|
||||
raise HTTPException(status_code=409, detail=msg)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start processing: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to start processing: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
|
||||
@@ -140,8 +140,8 @@ async def stop_processing(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop processing: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to stop processing: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== STATE & METRICS ENDPOINTS =====
|
||||
@@ -160,8 +160,8 @@ async def get_target_state(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get target state: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to get target state: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
|
||||
@@ -178,8 +178,8 @@ async def get_target_metrics(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get target metrics: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to get target metrics: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ===== STATE CHANGE EVENT STREAM =====
|
||||
@@ -268,8 +268,8 @@ async def start_target_overlay(
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start overlay: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to start overlay: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"])
|
||||
@@ -286,8 +286,8 @@ async def stop_target_overlay(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop overlay: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to stop overlay: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"])
|
||||
|
||||
@@ -88,7 +88,6 @@ async def test_kc_target(
|
||||
pp_template_store=Depends(get_pp_template_store),
|
||||
):
|
||||
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
|
||||
import httpx
|
||||
|
||||
stream = None
|
||||
try:
|
||||
@@ -130,21 +129,16 @@ async def test_kc_target(
|
||||
|
||||
raw_stream = chain["raw_stream"]
|
||||
|
||||
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
|
||||
from wled_controller.utils.image_codec import load_image_file
|
||||
|
||||
if isinstance(raw_stream, StaticImagePictureSource):
|
||||
source = raw_stream.image_source
|
||||
if source.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(source)
|
||||
resp.raise_for_status()
|
||||
image = load_image_bytes(resp.content)
|
||||
else:
|
||||
from pathlib import Path
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
image = load_image_file(path)
|
||||
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
|
||||
|
||||
asset_store = _get_asset_store()
|
||||
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
|
||||
if not image_path:
|
||||
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
|
||||
image = load_image_file(image_path)
|
||||
|
||||
elif isinstance(raw_stream, ScreenCapturePictureSource):
|
||||
try:
|
||||
@@ -264,10 +258,11 @@ async def test_kc_target(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}")
|
||||
logger.error("Capture error during KC target test: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test KC target: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to test KC target: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
finally:
|
||||
if stream:
|
||||
try:
|
||||
@@ -420,7 +415,8 @@ async def test_kc_target_ws(
|
||||
for pp_id in pp_template_ids:
|
||||
try:
|
||||
pp_template = pp_template_store_inst.get_template(pp_id)
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("PP template %s not found during KC test: %s", pp_id, e)
|
||||
continue
|
||||
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
|
||||
for fi in flat_filters:
|
||||
@@ -429,7 +425,8 @@ async def test_kc_target_ws(
|
||||
result = f.process_image(cur_image, image_pool)
|
||||
if result is not None:
|
||||
cur_image = result
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("Filter processing error during KC test: %s", e)
|
||||
pass
|
||||
|
||||
# Extract colors
|
||||
@@ -492,7 +489,8 @@ async def test_kc_target_ws(
|
||||
await asyncio.to_thread(
|
||||
live_stream_mgr.release, target.picture_source_id
|
||||
)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Live stream release during KC test cleanup: %s", e)
|
||||
pass
|
||||
logger.info(f"KC test WS closed for {target_id}")
|
||||
|
||||
@@ -524,6 +522,7 @@ async def target_colors_ws(
|
||||
# Keep alive — wait for client messages (or disconnect)
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("KC live WebSocket disconnected for target %s", target_id)
|
||||
pass
|
||||
finally:
|
||||
manager.remove_kc_ws_client(target_id, websocket)
|
||||
|
||||
@@ -53,8 +53,8 @@ async def list_pattern_templates(
|
||||
responses = [_pat_template_to_response(t) for t in templates]
|
||||
return PatternTemplateListResponse(templates=responses, count=len(responses))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list pattern templates: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to list pattern templates: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/pattern-templates", response_model=PatternTemplateResponse, tags=["Pattern Templates"], status_code=201)
|
||||
@@ -83,8 +83,8 @@ async def create_pattern_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create pattern template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to create pattern template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/pattern-templates/{template_id}", response_model=PatternTemplateResponse, tags=["Pattern Templates"])
|
||||
@@ -131,8 +131,8 @@ async def update_pattern_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update pattern template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to update pattern template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/api/v1/pattern-templates/{template_id}", status_code=204, tags=["Pattern Templates"])
|
||||
@@ -162,5 +162,5 @@ async def delete_pattern_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete pattern template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to delete pattern template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@@ -56,13 +56,13 @@ def _stream_to_response(s) -> PictureSourceResponse:
|
||||
target_fps=getattr(s, "target_fps", None),
|
||||
source_stream_id=getattr(s, "source_stream_id", None),
|
||||
postprocessing_template_id=getattr(s, "postprocessing_template_id", None),
|
||||
image_source=getattr(s, "image_source", None),
|
||||
image_asset_id=getattr(s, "image_asset_id", None),
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
description=s.description,
|
||||
tags=s.tags,
|
||||
# Video fields
|
||||
url=getattr(s, "url", None),
|
||||
video_asset_id=getattr(s, "video_asset_id", None),
|
||||
loop=getattr(s, "loop", None),
|
||||
playback_speed=getattr(s, "playback_speed", None),
|
||||
start_time=getattr(s, "start_time", None),
|
||||
@@ -83,8 +83,8 @@ async def list_picture_sources(
|
||||
responses = [_stream_to_response(s) for s in streams]
|
||||
return PictureSourceListResponse(streams=responses, count=len(responses))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list picture sources: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to list picture sources: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/picture-sources/validate-image", response_model=ImageValidateResponse, tags=["Picture Sources"])
|
||||
@@ -94,19 +94,21 @@ async def validate_image(
|
||||
):
|
||||
"""Validate an image source (URL or file path) and return a preview thumbnail."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
|
||||
|
||||
source = data.image_source.strip()
|
||||
if not source:
|
||||
return ImageValidateResponse(valid=False, error="Image source is empty")
|
||||
|
||||
if source.startswith(("http://", "https://")):
|
||||
validate_image_url(source)
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
response = await client.get(source)
|
||||
response.raise_for_status()
|
||||
img_bytes = response.content
|
||||
else:
|
||||
path = Path(source)
|
||||
path = validate_image_path(source)
|
||||
if not path.exists():
|
||||
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
|
||||
img_bytes = path
|
||||
@@ -147,16 +149,18 @@ async def get_full_image(
|
||||
source: str = Query(..., description="Image URL or local file path"),
|
||||
):
|
||||
"""Serve the full-resolution image for lightbox preview."""
|
||||
from pathlib import Path
|
||||
|
||||
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
|
||||
|
||||
try:
|
||||
if source.startswith(("http://", "https://")):
|
||||
validate_image_url(source)
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
response = await client.get(source)
|
||||
response.raise_for_status()
|
||||
img_bytes = response.content
|
||||
else:
|
||||
path = Path(source)
|
||||
path = validate_image_path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
img_bytes = path
|
||||
@@ -215,11 +219,11 @@ async def create_picture_source(
|
||||
target_fps=data.target_fps,
|
||||
source_stream_id=data.source_stream_id,
|
||||
postprocessing_template_id=data.postprocessing_template_id,
|
||||
image_source=data.image_source,
|
||||
image_asset_id=data.image_asset_id,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
# Video fields
|
||||
url=data.url,
|
||||
video_asset_id=data.video_asset_id,
|
||||
loop=data.loop,
|
||||
playback_speed=data.playback_speed,
|
||||
start_time=data.start_time,
|
||||
@@ -237,8 +241,8 @@ async def create_picture_source(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create picture source: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to create picture source: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
|
||||
@@ -272,11 +276,11 @@ async def update_picture_source(
|
||||
target_fps=data.target_fps,
|
||||
source_stream_id=data.source_stream_id,
|
||||
postprocessing_template_id=data.postprocessing_template_id,
|
||||
image_source=data.image_source,
|
||||
image_asset_id=data.image_asset_id,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
# Video fields
|
||||
url=data.url,
|
||||
video_asset_id=data.video_asset_id,
|
||||
loop=data.loop,
|
||||
playback_speed=data.playback_speed,
|
||||
start_time=data.start_time,
|
||||
@@ -292,8 +296,8 @@ async def update_picture_source(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update picture source: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to update picture source: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/api/v1/picture-sources/{stream_id}", status_code=204, tags=["Picture Sources"])
|
||||
@@ -324,8 +328,8 @@ async def delete_picture_source(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete picture source: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to delete picture source: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/picture-sources/{stream_id}/thumbnail", tags=["Picture Sources"])
|
||||
@@ -344,8 +348,15 @@ async def get_video_thumbnail(
|
||||
if not isinstance(source, VideoCaptureSource):
|
||||
raise HTTPException(status_code=400, detail="Not a video source")
|
||||
|
||||
# Resolve video asset to file path
|
||||
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
|
||||
asset_store = _get_asset_store()
|
||||
video_path = asset_store.get_file_path(source.video_asset_id) if source.video_asset_id else None
|
||||
if not video_path:
|
||||
raise HTTPException(status_code=400, detail="Video asset not found or missing file")
|
||||
|
||||
frame = await asyncio.get_event_loop().run_in_executor(
|
||||
None, extract_thumbnail, source.url, source.resolution_limit
|
||||
None, extract_thumbnail, str(video_path), source.resolution_limit
|
||||
)
|
||||
if frame is None:
|
||||
raise HTTPException(status_code=404, detail="Could not extract thumbnail")
|
||||
@@ -360,8 +371,8 @@ async def get_video_thumbnail(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract video thumbnail: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to extract video thumbnail: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
|
||||
@@ -394,24 +405,17 @@ async def test_picture_source(
|
||||
raw_stream = chain["raw_stream"]
|
||||
|
||||
if isinstance(raw_stream, StaticImagePictureSource):
|
||||
# Static image stream: load image directly, no engine needed
|
||||
from pathlib import Path
|
||||
# Static image stream: load image from asset
|
||||
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
|
||||
from wled_controller.utils.image_codec import load_image_file
|
||||
|
||||
asset_store = _get_asset_store()
|
||||
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
|
||||
if not image_path:
|
||||
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
|
||||
|
||||
source = raw_stream.image_source
|
||||
start_time = time.perf_counter()
|
||||
|
||||
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
|
||||
|
||||
if source.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(source)
|
||||
resp.raise_for_status()
|
||||
image = load_image_bytes(resp.content)
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
image = await asyncio.to_thread(load_image_file, path)
|
||||
image = await asyncio.to_thread(load_image_file, image_path)
|
||||
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
frame_count = 1
|
||||
@@ -543,10 +547,11 @@ async def test_picture_source(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Engine error: {str(e)}")
|
||||
logger.error("Engine error during picture source test: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test picture source: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to test picture source: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
finally:
|
||||
if stream:
|
||||
try:
|
||||
@@ -602,12 +607,19 @@ async def test_picture_source_ws(
|
||||
# Video sources: use VideoCaptureLiveStream for test preview
|
||||
if isinstance(raw_stream, VideoCaptureSource):
|
||||
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
|
||||
from wled_controller.api.dependencies import get_asset_store as _get_asset_store2
|
||||
|
||||
asset_store = _get_asset_store2()
|
||||
video_path = asset_store.get_file_path(raw_stream.video_asset_id) if raw_stream.video_asset_id else None
|
||||
if not video_path:
|
||||
await websocket.close(code=4004, reason="Video asset not found or missing file")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
logger.info(f"Video source test WS connected for {stream_id} ({duration}s)")
|
||||
|
||||
video_stream = VideoCaptureLiveStream(
|
||||
url=raw_stream.url,
|
||||
url=str(video_path),
|
||||
loop=raw_stream.loop,
|
||||
playback_speed=raw_stream.playback_speed,
|
||||
start_time=raw_stream.start_time,
|
||||
@@ -663,12 +675,14 @@ async def test_picture_source_ws(
|
||||
"avg_fps": round(frame_count / max(duration, 0.001), 1),
|
||||
})
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Video source test WebSocket disconnected for %s", stream_id)
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Video source test WS error for {stream_id}: {e}")
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "detail": str(e)})
|
||||
except Exception:
|
||||
except Exception as e2:
|
||||
logger.debug("Failed to send error to video test WS: %s", e2)
|
||||
pass
|
||||
finally:
|
||||
video_stream.stop()
|
||||
@@ -697,7 +711,8 @@ async def test_picture_source_ws(
|
||||
try:
|
||||
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||
pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("PP template not found for picture source test: %s", e)
|
||||
pass
|
||||
|
||||
# Engine factory — creates + initializes engine inside the capture thread
|
||||
@@ -721,6 +736,7 @@ async def test_picture_source_ws(
|
||||
preview_width=preview_width or None,
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Picture source test WebSocket disconnected for %s", stream_id)
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Picture source test WS error for {stream_id}: {e}")
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -87,8 +86,8 @@ async def create_pp_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create postprocessing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to create postprocessing template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"])
|
||||
@@ -130,8 +129,8 @@ async def update_pp_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update postprocessing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to update postprocessing template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/api/v1/postprocessing-templates/{template_id}", status_code=204, tags=["Postprocessing Templates"])
|
||||
@@ -162,8 +161,8 @@ async def delete_pp_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete postprocessing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to delete postprocessing template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"])
|
||||
@@ -197,28 +196,21 @@ async def test_pp_template(
|
||||
|
||||
from wled_controller.utils.image_codec import (
|
||||
encode_jpeg_data_uri,
|
||||
load_image_bytes,
|
||||
load_image_file,
|
||||
thumbnail as make_thumbnail,
|
||||
)
|
||||
|
||||
if isinstance(raw_stream, StaticImagePictureSource):
|
||||
# Static image: load directly
|
||||
from pathlib import Path
|
||||
# Static image: load from asset
|
||||
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
|
||||
|
||||
asset_store = _get_asset_store()
|
||||
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
|
||||
if not image_path:
|
||||
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
|
||||
|
||||
source = raw_stream.image_source
|
||||
start_time = time.perf_counter()
|
||||
|
||||
if source.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(source)
|
||||
resp.raise_for_status()
|
||||
image = load_image_bytes(resp.content)
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
image = load_image_file(path)
|
||||
image = load_image_file(image_path)
|
||||
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
frame_count = 1
|
||||
@@ -330,13 +322,14 @@ async def test_pp_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Postprocessing template test failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Postprocessing template test failed: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
finally:
|
||||
if stream:
|
||||
try:
|
||||
stream.cleanup()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("PP test capture stream cleanup: %s", e)
|
||||
pass
|
||||
|
||||
|
||||
@@ -434,6 +427,7 @@ async def test_pp_template_ws(
|
||||
preview_width=preview_width or None,
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("PP template test WebSocket disconnected for %s", template_id)
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"PP template test WS error for {template_id}: {e}")
|
||||
|
||||
@@ -130,17 +130,19 @@ async def get_version():
|
||||
async def list_all_tags(_: AuthRequired):
|
||||
"""Get all tags used across all entities."""
|
||||
all_tags: set[str] = set()
|
||||
from wled_controller.api.dependencies import get_asset_store
|
||||
store_getters = [
|
||||
get_device_store, get_output_target_store, get_color_strip_store,
|
||||
get_picture_source_store, get_audio_source_store, get_value_source_store,
|
||||
get_sync_clock_store, get_automation_store, get_scene_preset_store,
|
||||
get_template_store, get_audio_template_store, get_pp_template_store,
|
||||
get_pattern_template_store,
|
||||
get_pattern_template_store, get_asset_store,
|
||||
]
|
||||
for getter in store_getters:
|
||||
try:
|
||||
store = getter()
|
||||
except RuntimeError:
|
||||
except RuntimeError as e:
|
||||
logger.debug("Store not available during entity count: %s", e)
|
||||
continue
|
||||
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
|
||||
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
|
||||
@@ -211,10 +213,10 @@ async def get_displays(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get displays: {e}")
|
||||
logger.error("Failed to get displays: %s", e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve display information: {str(e)}"
|
||||
detail="Internal server error"
|
||||
)
|
||||
|
||||
|
||||
@@ -232,10 +234,10 @@ async def get_running_processes(_: AuthRequired):
|
||||
sorted_procs = sorted(processes)
|
||||
return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get processes: {e}")
|
||||
logger.error("Failed to get processes: %s", e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve process list: {str(e)}"
|
||||
detail="Internal server error"
|
||||
)
|
||||
|
||||
|
||||
@@ -300,7 +302,7 @@ def list_api_keys(_: AuthRequired):
|
||||
"""List API key labels (read-only; keys are defined in the YAML config file)."""
|
||||
config = get_config()
|
||||
keys = [
|
||||
{"label": label, "masked": key[:4] + "****" + key[-4:] if len(key) >= 8 else "****"}
|
||||
{"label": label, "masked": key[:4] + "****" if len(key) >= 8 else "****"}
|
||||
for label, key in config.auth.api_keys.items()
|
||||
]
|
||||
return {"keys": keys, "count": len(keys)}
|
||||
|
||||
@@ -191,8 +191,10 @@ async def logs_ws(
|
||||
except Exception:
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Log stream WebSocket disconnected")
|
||||
pass
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Log stream WebSocket error: %s", e)
|
||||
pass
|
||||
finally:
|
||||
log_broadcaster.unsubscribe(queue)
|
||||
@@ -287,6 +289,7 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
||||
address = request.address.strip()
|
||||
if not address:
|
||||
raise HTTPException(status_code=400, detail="Address is required")
|
||||
_validate_adb_address(address)
|
||||
|
||||
adb = _get_adb_path()
|
||||
logger.info(f"Disconnecting ADB device: {address}")
|
||||
|
||||
@@ -76,8 +76,8 @@ async def list_templates(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list templates: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to list templates: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/capture-templates", response_model=TemplateResponse, tags=["Templates"], status_code=201)
|
||||
@@ -115,8 +115,8 @@ async def create_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to create template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
|
||||
@@ -180,8 +180,8 @@ async def update_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to update template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/api/v1/capture-templates/{template_id}", status_code=204, tags=["Templates"])
|
||||
@@ -222,8 +222,8 @@ async def delete_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to delete template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
|
||||
@@ -252,8 +252,8 @@ async def list_engines(_auth: AuthRequired):
|
||||
return EngineListResponse(engines=engines, count=len(engines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list engines: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to list engines: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
|
||||
@@ -365,10 +365,11 @@ def test_template(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Engine error: {str(e)}")
|
||||
logger.error("Engine error during template test: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test template: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error("Failed to test template: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
finally:
|
||||
if stream:
|
||||
try:
|
||||
@@ -432,6 +433,7 @@ async def test_template_ws(
|
||||
try:
|
||||
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Capture template test WebSocket disconnected")
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Capture template test WS error: {e}")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import get_update_service
|
||||
from wled_controller.api.schemas.update import (
|
||||
DismissRequest,
|
||||
@@ -20,6 +21,7 @@ router = APIRouter(prefix="/api/v1/system/update", tags=["update"])
|
||||
|
||||
@router.get("/status", response_model=UpdateStatusResponse)
|
||||
async def get_update_status(
|
||||
_: AuthRequired,
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
return service.get_status()
|
||||
@@ -27,6 +29,7 @@ async def get_update_status(
|
||||
|
||||
@router.post("/check", response_model=UpdateStatusResponse)
|
||||
async def check_for_updates(
|
||||
_: AuthRequired,
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
return await service.check_now()
|
||||
@@ -34,6 +37,7 @@ async def check_for_updates(
|
||||
|
||||
@router.post("/dismiss")
|
||||
async def dismiss_update(
|
||||
_: AuthRequired,
|
||||
body: DismissRequest,
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
@@ -43,6 +47,7 @@ async def dismiss_update(
|
||||
|
||||
@router.post("/apply")
|
||||
async def apply_update(
|
||||
_: AuthRequired,
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
"""Download (if needed) and apply the available update."""
|
||||
@@ -59,11 +64,12 @@ async def apply_update(
|
||||
return {"ok": True, "message": "Update applied, server shutting down"}
|
||||
except Exception as exc:
|
||||
logger.error("Failed to apply update: %s", exc, exc_info=True)
|
||||
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||
|
||||
|
||||
@router.get("/settings", response_model=UpdateSettingsResponse)
|
||||
async def get_update_settings(
|
||||
_: AuthRequired,
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
return service.get_settings()
|
||||
@@ -71,6 +77,7 @@ async def get_update_settings(
|
||||
|
||||
@router.put("/settings", response_model=UpdateSettingsResponse)
|
||||
async def update_update_settings(
|
||||
_: AuthRequired,
|
||||
body: UpdateSettingsRequest,
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
|
||||
@@ -245,6 +245,7 @@ async def test_value_source_ws(
|
||||
await websocket.send_json({"value": round(value, 4)})
|
||||
await asyncio.sleep(0.05)
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Value source test WebSocket disconnected for %s", source_id)
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Value source test WebSocket error for {source_id}: {e}")
|
||||
|
||||
@@ -6,6 +6,7 @@ automations that have a webhook condition. No API-key auth is required —
|
||||
the secret token itself authenticates the caller.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -43,6 +44,12 @@ def _check_rate_limit(client_ip: str) -> None:
|
||||
)
|
||||
_rate_hits[client_ip].append(now)
|
||||
|
||||
# Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth
|
||||
if len(_rate_hits) > 100:
|
||||
stale = [ip for ip, ts in _rate_hits.items() if not ts or ts[-1] < window_start]
|
||||
for ip in stale:
|
||||
del _rate_hits[ip]
|
||||
|
||||
|
||||
class WebhookPayload(BaseModel):
|
||||
action: str = Field(description="'activate' or 'deactivate'")
|
||||
@@ -68,7 +75,7 @@ async def handle_webhook(
|
||||
# Find the automation that owns this token
|
||||
for automation in store.get_all_automations():
|
||||
for condition in automation.conditions:
|
||||
if isinstance(condition, WebhookCondition) and condition.token == token:
|
||||
if isinstance(condition, WebhookCondition) and secrets.compare_digest(condition.token, token):
|
||||
active = body.action == "activate"
|
||||
await engine.set_webhook_state(token, active)
|
||||
logger.info(
|
||||
|
||||
37
server/src/wled_controller/api/schemas/assets.py
Normal file
37
server/src/wled_controller/api/schemas/assets.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Asset schemas (CRUD)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AssetUpdate(BaseModel):
|
||||
"""Request to update asset metadata."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name")
|
||||
description: Optional[str] = Field(None, max_length=500, description="Optional description")
|
||||
tags: Optional[List[str]] = Field(None, description="User-defined tags")
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
"""Asset response."""
|
||||
|
||||
id: str = Field(description="Asset ID")
|
||||
name: str = Field(description="Display name")
|
||||
filename: str = Field(description="Original upload filename")
|
||||
mime_type: str = Field(description="MIME type")
|
||||
asset_type: str = Field(description="Asset type: sound, image, video, other")
|
||||
size_bytes: int = Field(description="File size in bytes")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
class AssetListResponse(BaseModel):
|
||||
"""List of assets."""
|
||||
|
||||
assets: List[AssetResponse] = Field(description="List of assets")
|
||||
count: int = Field(description="Number of assets")
|
||||
@@ -8,6 +8,13 @@ from pydantic import BaseModel, Field, model_validator
|
||||
from wled_controller.api.schemas.devices import Calibration
|
||||
|
||||
|
||||
class AppSoundOverride(BaseModel):
|
||||
"""Per-application sound override for notification sources."""
|
||||
|
||||
sound_asset_id: Optional[str] = Field(None, description="Asset ID for the sound (None = mute this app)")
|
||||
volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Volume override (None = use global)")
|
||||
|
||||
|
||||
class AnimationConfig(BaseModel):
|
||||
"""Procedural animation configuration for static/gradient color strip sources."""
|
||||
|
||||
@@ -102,6 +109,9 @@ class ColorStripSourceCreate(BaseModel):
|
||||
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")
|
||||
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
|
||||
sound_volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Global notification sound volume")
|
||||
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(None, description="Per-app sound overrides")
|
||||
# daylight-type fields
|
||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||
@@ -173,6 +183,9 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
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")
|
||||
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
|
||||
sound_volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Global notification sound volume")
|
||||
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(None, description="Per-app sound overrides")
|
||||
# daylight-type fields
|
||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||
@@ -245,6 +258,9 @@ class ColorStripSourceResponse(BaseModel):
|
||||
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")
|
||||
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
|
||||
sound_volume: Optional[float] = Field(None, description="Global notification sound volume")
|
||||
app_sounds: Optional[Dict[str, dict]] = Field(None, description="Per-app sound overrides")
|
||||
# daylight-type fields
|
||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||
|
||||
@@ -16,11 +16,11 @@ class PictureSourceCreate(BaseModel):
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
|
||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||
image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
# Video fields
|
||||
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL")
|
||||
video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
|
||||
loop: bool = Field(True, description="Loop video playback")
|
||||
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
|
||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
|
||||
@@ -38,11 +38,11 @@ class PictureSourceUpdate(BaseModel):
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
|
||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||
image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
# Video fields
|
||||
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL")
|
||||
video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
|
||||
loop: Optional[bool] = Field(None, description="Loop video playback")
|
||||
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier", ge=0.1, le=10.0)
|
||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
|
||||
@@ -62,13 +62,13 @@ class PictureSourceResponse(BaseModel):
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS")
|
||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
|
||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID")
|
||||
image_source: Optional[str] = Field(None, description="Image URL or file path")
|
||||
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Stream description")
|
||||
# Video fields
|
||||
url: Optional[str] = Field(None, description="Video URL")
|
||||
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
|
||||
loop: Optional[bool] = Field(None, description="Loop video playback")
|
||||
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier")
|
||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
|
||||
|
||||
@@ -15,7 +15,7 @@ class ServerConfig(BaseSettings):
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8080
|
||||
log_level: str = "INFO"
|
||||
cors_origins: List[str] = ["*"]
|
||||
cors_origins: List[str] = ["http://localhost:8080"]
|
||||
|
||||
|
||||
class AuthConfig(BaseSettings):
|
||||
@@ -24,6 +24,13 @@ class AuthConfig(BaseSettings):
|
||||
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
|
||||
|
||||
|
||||
class AssetsConfig(BaseSettings):
|
||||
"""Assets configuration."""
|
||||
|
||||
max_file_size_mb: int = 50 # Max upload size in MB
|
||||
assets_dir: str = "data/assets" # Directory for uploaded asset files
|
||||
|
||||
|
||||
class StorageConfig(BaseSettings):
|
||||
"""Storage configuration."""
|
||||
|
||||
@@ -65,16 +72,21 @@ class Config(BaseSettings):
|
||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||
auth: AuthConfig = Field(default_factory=AuthConfig)
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
assets: AssetsConfig = Field(default_factory=AssetsConfig)
|
||||
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
|
||||
def model_post_init(self, __context: object) -> None:
|
||||
"""Override storage paths when demo mode is active."""
|
||||
"""Override storage and assets paths when demo mode is active."""
|
||||
if self.demo:
|
||||
for field_name in self.storage.model_fields:
|
||||
for field_name in StorageConfig.model_fields:
|
||||
value = getattr(self.storage, field_name)
|
||||
if isinstance(value, str) and value.startswith("data/"):
|
||||
setattr(self.storage, field_name, value.replace("data/", "data/demo/", 1))
|
||||
for field_name in AssetsConfig.model_fields:
|
||||
value = getattr(self.assets, field_name)
|
||||
if isinstance(value, str) and value.startswith("data/"):
|
||||
setattr(self.assets, field_name, value.replace("data/", "data/demo/", 1))
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, config_path: str | Path) -> "Config":
|
||||
|
||||
@@ -141,7 +141,8 @@ class ManagedAudioStream:
|
||||
if stream is not None:
|
||||
try:
|
||||
stream.cleanup()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Audio stream cleanup error: %s", e)
|
||||
pass
|
||||
self._running = False
|
||||
logger.info(
|
||||
|
||||
@@ -75,7 +75,8 @@ class SounddeviceCaptureStream(AudioCaptureStreamBase):
|
||||
try:
|
||||
self._sd_stream.stop()
|
||||
self._sd_stream.close()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Sounddevice stream cleanup: %s", e)
|
||||
pass
|
||||
self._sd_stream = None
|
||||
self._initialized = False
|
||||
@@ -104,7 +105,8 @@ class SounddeviceEngine(AudioCaptureEngine):
|
||||
try:
|
||||
import sounddevice # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
logger.debug("Sounddevice engine unavailable: %s", e)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
@@ -118,7 +120,8 @@ class SounddeviceEngine(AudioCaptureEngine):
|
||||
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
||||
try:
|
||||
import sounddevice as sd
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
logger.debug("Cannot enumerate sounddevice devices: %s", e)
|
||||
return []
|
||||
|
||||
try:
|
||||
|
||||
@@ -85,13 +85,15 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
|
||||
try:
|
||||
self._stream.stop_stream()
|
||||
self._stream.close()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("WASAPI stream cleanup: %s", e)
|
||||
pass
|
||||
self._stream = None
|
||||
if self._pa is not None:
|
||||
try:
|
||||
self._pa.terminate()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("PyAudio terminate during cleanup: %s", e)
|
||||
pass
|
||||
self._pa = None
|
||||
self._initialized = False
|
||||
@@ -139,7 +141,8 @@ class WasapiEngine(AudioCaptureEngine):
|
||||
try:
|
||||
import pyaudiowpatch # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
logger.debug("WASAPI engine unavailable (pyaudiowpatch not installed): %s", e)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
@@ -153,7 +156,8 @@ class WasapiEngine(AudioCaptureEngine):
|
||||
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
||||
try:
|
||||
import pyaudiowpatch as pyaudio
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
logger.debug("Cannot enumerate WASAPI devices (pyaudiowpatch not installed): %s", e)
|
||||
return []
|
||||
|
||||
pa = None
|
||||
@@ -223,7 +227,8 @@ class WasapiEngine(AudioCaptureEngine):
|
||||
if pa is not None:
|
||||
try:
|
||||
pa.terminate()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("PyAudio terminate in enumerate cleanup: %s", e)
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -74,6 +74,7 @@ class AutomationEngine:
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Automation engine task cancelled")
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
@@ -92,6 +93,7 @@ class AutomationEngine:
|
||||
logger.error(f"Automation evaluation error: {e}", exc_info=True)
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Automation poll loop cancelled")
|
||||
pass
|
||||
|
||||
async def _evaluate_all(self) -> None:
|
||||
@@ -262,7 +264,8 @@ class AutomationEngine:
|
||||
return False
|
||||
try:
|
||||
return matcher()
|
||||
except re.error:
|
||||
except re.error as e:
|
||||
logger.debug("MQTT condition regex error: %s", e)
|
||||
return False
|
||||
|
||||
def _evaluate_app_condition(
|
||||
|
||||
@@ -75,7 +75,8 @@ class PlatformDetector:
|
||||
# Data: 0=off, 1=on, 2=dimmed (treat dimmed as on)
|
||||
value = setting.Data[0]
|
||||
self._display_on = value != 0
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to parse display power setting: %s", e)
|
||||
pass
|
||||
return 0
|
||||
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
||||
@@ -309,7 +310,8 @@ class PlatformDetector:
|
||||
and win_rect.right >= mr.right
|
||||
and win_rect.bottom >= mr.bottom
|
||||
)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Fullscreen check failed for hwnd: %s", e)
|
||||
return False
|
||||
|
||||
def _get_fullscreen_processes_sync(self) -> Set[str]:
|
||||
|
||||
@@ -108,6 +108,7 @@ class AutoBackupEngine:
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-backup failed: {e}", exc_info=True)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Auto-backup loop cancelled")
|
||||
pass
|
||||
|
||||
# ─── Backup operations ─────────────────────────────────────
|
||||
|
||||
@@ -39,7 +39,8 @@ class BetterCamCaptureStream(CaptureStream):
|
||||
# Clear global camera cache for fresh DXGI state
|
||||
try:
|
||||
self._bettercam.__factory.clean_up()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("BetterCam factory cleanup on init: %s", e)
|
||||
pass
|
||||
|
||||
self._camera = self._bettercam.create(
|
||||
@@ -59,7 +60,8 @@ class BetterCamCaptureStream(CaptureStream):
|
||||
try:
|
||||
if self._camera.is_capturing:
|
||||
self._camera.stop()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("BetterCam camera stop during cleanup: %s", e)
|
||||
pass
|
||||
try:
|
||||
self._camera.release()
|
||||
@@ -70,7 +72,8 @@ class BetterCamCaptureStream(CaptureStream):
|
||||
if self._bettercam:
|
||||
try:
|
||||
self._bettercam.__factory.clean_up()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("BetterCam factory cleanup on teardown: %s", e)
|
||||
pass
|
||||
|
||||
self._initialized = False
|
||||
|
||||
@@ -408,7 +408,8 @@ class CameraEngine(CaptureEngine):
|
||||
try:
|
||||
import cv2 # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
logger.debug("Camera engine unavailable (cv2 not installed): %s", e)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -39,7 +39,8 @@ class DXcamCaptureStream(CaptureStream):
|
||||
# Clear global camera cache for fresh DXGI state
|
||||
try:
|
||||
self._dxcam.__factory.clean_up()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("DXcam factory cleanup on init: %s", e)
|
||||
pass
|
||||
|
||||
self._camera = self._dxcam.create(
|
||||
@@ -59,7 +60,8 @@ class DXcamCaptureStream(CaptureStream):
|
||||
try:
|
||||
if self._camera.is_capturing:
|
||||
self._camera.stop()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("DXcam camera stop during cleanup: %s", e)
|
||||
pass
|
||||
try:
|
||||
self._camera.release()
|
||||
@@ -70,7 +72,8 @@ class DXcamCaptureStream(CaptureStream):
|
||||
if self._dxcam:
|
||||
try:
|
||||
self._dxcam.__factory.clean_up()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("DXcam factory cleanup on teardown: %s", e)
|
||||
pass
|
||||
|
||||
self._initialized = False
|
||||
|
||||
@@ -115,7 +115,8 @@ class WGCCaptureStream(CaptureStream):
|
||||
import platform
|
||||
build = int(platform.version().split(".")[2])
|
||||
return build >= 22621
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to detect WGC border toggle support: %s", e)
|
||||
return False
|
||||
|
||||
def _cleanup_internal(self) -> None:
|
||||
@@ -133,7 +134,8 @@ class WGCCaptureStream(CaptureStream):
|
||||
if self._capture_instance:
|
||||
try:
|
||||
del self._capture_instance
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("WGC capture instance cleanup: %s", e)
|
||||
pass
|
||||
self._capture_instance = None
|
||||
|
||||
@@ -215,7 +217,8 @@ class WGCEngine(CaptureEngine):
|
||||
build = int(parts[2])
|
||||
if major < 10 or (major == 10 and minor == 0 and build < 17134):
|
||||
return False
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to check Windows version for WGC availability: %s", e)
|
||||
pass
|
||||
|
||||
try:
|
||||
|
||||
@@ -201,8 +201,8 @@ def _build_picture_sources() -> dict:
|
||||
"updated_at": _NOW,
|
||||
"source_stream_id": None,
|
||||
"postprocessing_template_id": None,
|
||||
"image_source": None,
|
||||
"url": None,
|
||||
"image_asset_id": None,
|
||||
"video_asset_id": None,
|
||||
"loop": None,
|
||||
"playback_speed": None,
|
||||
"start_time": None,
|
||||
@@ -223,8 +223,8 @@ def _build_picture_sources() -> dict:
|
||||
"updated_at": _NOW,
|
||||
"source_stream_id": None,
|
||||
"postprocessing_template_id": None,
|
||||
"image_source": None,
|
||||
"url": None,
|
||||
"image_asset_id": None,
|
||||
"video_asset_id": None,
|
||||
"loop": None,
|
||||
"playback_speed": None,
|
||||
"start_time": None,
|
||||
|
||||
@@ -60,6 +60,7 @@ class MQTTService:
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("MQTT background task cancelled")
|
||||
pass
|
||||
self._task = None
|
||||
self._connected = False
|
||||
@@ -79,6 +80,7 @@ class MQTTService:
|
||||
try:
|
||||
self._publish_queue.put_nowait((topic, payload, retain, qos))
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("MQTT publish queue full, dropping message for topic %s", topic)
|
||||
pass
|
||||
|
||||
async def subscribe(self, topic: str, callback: Callable) -> None:
|
||||
|
||||
@@ -115,7 +115,8 @@ class AudioColorStripStream(ColorStripStream):
|
||||
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
|
||||
self._audio_engine_type = tpl.engine_type
|
||||
self._audio_engine_config = tpl.engine_config
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.warning("Audio template %s not found, using default engine: %s", resolved.audio_template_id, e)
|
||||
pass
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}")
|
||||
|
||||
@@ -69,7 +69,7 @@ class ColorStripStreamManager:
|
||||
keyed by ``{css_id}:{consumer_id}``.
|
||||
"""
|
||||
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None, gradient_store=None, weather_manager=None):
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None, gradient_store=None, weather_manager=None, asset_store=None):
|
||||
"""
|
||||
Args:
|
||||
color_strip_store: ColorStripStore for resolving source configs
|
||||
@@ -91,6 +91,7 @@ class ColorStripStreamManager:
|
||||
self._cspt_store = cspt_store
|
||||
self._gradient_store = gradient_store
|
||||
self._weather_manager = weather_manager
|
||||
self._asset_store = asset_store
|
||||
self._streams: Dict[str, _ColorStripEntry] = {}
|
||||
|
||||
def _inject_clock(self, css_stream, source) -> Optional[str]:
|
||||
@@ -125,7 +126,8 @@ class ColorStripStreamManager:
|
||||
clock_id = getattr(source, "clock_id", None)
|
||||
if clock_id:
|
||||
self._sync_clock_manager.release(clock_id)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Sync clock release during stream cleanup: %s", e)
|
||||
pass # source may have been deleted already
|
||||
|
||||
def _resolve_key(self, css_id: str, consumer_id: str) -> str:
|
||||
@@ -186,6 +188,9 @@ class ColorStripStreamManager:
|
||||
# Inject gradient store for palette resolution
|
||||
if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
|
||||
css_stream.set_gradient_store(self._gradient_store)
|
||||
# Inject asset store for notification sound playback
|
||||
if self._asset_store and hasattr(css_stream, "set_asset_store"):
|
||||
css_stream.set_asset_store(self._asset_store)
|
||||
# Inject sync clock runtime if source references a clock
|
||||
acquired_clock_id = self._inject_clock(css_stream, source)
|
||||
css_stream.start()
|
||||
|
||||
@@ -55,6 +55,8 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
self._colors_lock = threading.Lock()
|
||||
self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called
|
||||
|
||||
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
|
||||
self._resize_cache: Dict[tuple, tuple] = {}
|
||||
# layer_index -> (source_id, consumer_id, stream)
|
||||
self._sub_streams: Dict[int, tuple] = {}
|
||||
# layer_index -> (vs_id, value_stream)
|
||||
@@ -560,9 +562,16 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
continue
|
||||
# Resize to zone length
|
||||
if len(colors) != zone_len:
|
||||
src_x = np.linspace(0, 1, len(colors))
|
||||
dst_x = np.linspace(0, 1, zone_len)
|
||||
resized = np.empty((zone_len, 3), dtype=np.uint8)
|
||||
rkey = (len(colors), zone_len)
|
||||
cached = self._resize_cache.get(rkey)
|
||||
if cached is None:
|
||||
cached = (
|
||||
np.linspace(0, 1, len(colors)),
|
||||
np.linspace(0, 1, zone_len),
|
||||
np.empty((zone_len, 3), dtype=np.uint8),
|
||||
)
|
||||
self._resize_cache[rkey] = cached
|
||||
src_x, dst_x, resized = cached
|
||||
for ch in range(3):
|
||||
np.copyto(resized[:, ch], np.interp(dst_x, src_x, colors[:, ch]), casting="unsafe")
|
||||
colors = resized
|
||||
|
||||
@@ -100,6 +100,7 @@ class DeviceHealthMixin:
|
||||
interval = ACTIVE_INTERVAL if self._is_device_streaming(device_id) else IDLE_INTERVAL
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Device health monitor cancelled for %s", device_id)
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
|
||||
|
||||
@@ -193,6 +193,7 @@ class KCTargetProcessor(TargetProcessor):
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("KC target processor task cancelled")
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
@@ -476,7 +477,8 @@ class KCTargetProcessor(TargetProcessor):
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("KC WS send failed: %s", e)
|
||||
return False
|
||||
|
||||
clients = list(self._ws_clients)
|
||||
|
||||
@@ -11,7 +11,6 @@ releases them.
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
@@ -54,17 +53,19 @@ class LiveStreamManager:
|
||||
enabling sharing at every level of the stream chain.
|
||||
"""
|
||||
|
||||
def __init__(self, picture_source_store, capture_template_store=None, pp_template_store=None):
|
||||
def __init__(self, picture_source_store, capture_template_store=None, pp_template_store=None, asset_store=None):
|
||||
"""Initialize the live stream manager.
|
||||
|
||||
Args:
|
||||
picture_source_store: PictureSourceStore for resolving stream configs
|
||||
capture_template_store: TemplateStore for resolving capture engine settings
|
||||
pp_template_store: PostprocessingTemplateStore for resolving filter chains
|
||||
asset_store: AssetStore for resolving asset IDs to file paths
|
||||
"""
|
||||
self._picture_source_store = picture_source_store
|
||||
self._capture_template_store = capture_template_store
|
||||
self._pp_template_store = pp_template_store
|
||||
self._asset_store = asset_store
|
||||
self._streams: Dict[str, _LiveStreamEntry] = {}
|
||||
|
||||
def acquire(self, picture_source_id: str) -> LiveStream:
|
||||
@@ -268,6 +269,21 @@ class LiveStreamManager:
|
||||
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
|
||||
return resolved
|
||||
|
||||
def _resolve_asset_path(self, asset_id: str | None, label: str) -> str:
|
||||
"""Resolve an asset ID to its on-disk file path string.
|
||||
|
||||
Raises:
|
||||
ValueError: If asset not found, deleted, or file missing.
|
||||
"""
|
||||
if not asset_id:
|
||||
raise ValueError(f"{label} has no asset ID configured")
|
||||
if not self._asset_store:
|
||||
raise ValueError(f"AssetStore not available to resolve {label}")
|
||||
path = self._asset_store.get_file_path(asset_id)
|
||||
if not path:
|
||||
raise ValueError(f"Asset {asset_id} not found or file missing for {label}")
|
||||
return str(path)
|
||||
|
||||
def _create_video_live_stream(self, config):
|
||||
"""Create a VideoCaptureLiveStream from a VideoCaptureSource config."""
|
||||
if not _has_video:
|
||||
@@ -275,8 +291,9 @@ class LiveStreamManager:
|
||||
"OpenCV is required for video stream support. "
|
||||
"Install it with: pip install opencv-python-headless"
|
||||
)
|
||||
video_path = self._resolve_asset_path(config.video_asset_id, "video source")
|
||||
stream = VideoCaptureLiveStream(
|
||||
url=config.url,
|
||||
url=video_path,
|
||||
loop=config.loop,
|
||||
playback_speed=config.playback_speed,
|
||||
start_time=config.start_time,
|
||||
@@ -300,27 +317,18 @@ class LiveStreamManager:
|
||||
|
||||
def _create_static_image_live_stream(self, config) -> StaticImageLiveStream:
|
||||
"""Create a StaticImageLiveStream from a StaticImagePictureSource config."""
|
||||
image = self._load_static_image(config.image_source)
|
||||
image_path = self._resolve_asset_path(config.image_asset_id, "static image source")
|
||||
image = self._load_static_image(image_path)
|
||||
return StaticImageLiveStream(image)
|
||||
|
||||
@staticmethod
|
||||
def _load_static_image(image_source: str) -> np.ndarray:
|
||||
"""Load a static image from URL or file path, return as RGB numpy array.
|
||||
|
||||
Note: Uses synchronous httpx.get() for URLs, which blocks up to 15s.
|
||||
This is acceptable because acquire() (the only caller chain) is always
|
||||
invoked from background worker threads, never from the async event loop.
|
||||
"""
|
||||
def _load_static_image(file_path: str) -> np.ndarray:
|
||||
"""Load a static image from a local file path, return as RGB numpy array."""
|
||||
from pathlib import Path
|
||||
|
||||
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
|
||||
from wled_controller.utils.image_codec import load_image_file
|
||||
|
||||
if image_source.startswith(("http://", "https://")):
|
||||
response = httpx.get(image_source, timeout=15.0, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
return load_image_bytes(response.content)
|
||||
else:
|
||||
path = Path(image_source)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Image file not found: {image_source}")
|
||||
return load_image_file(path)
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Image file not found: {file_path}")
|
||||
return load_image_file(path)
|
||||
|
||||
@@ -40,6 +40,8 @@ class MappedColorStripStream(ColorStripStream):
|
||||
|
||||
# zone_index -> (source_id, consumer_id, stream)
|
||||
self._sub_streams: Dict[int, tuple] = {}
|
||||
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
|
||||
self._resize_cache: Dict[tuple, tuple] = {}
|
||||
self._sub_lock = threading.Lock() # guards _sub_streams access across threads
|
||||
|
||||
# ── ColorStripStream interface ──────────────────────────────
|
||||
@@ -210,9 +212,16 @@ class MappedColorStripStream(ColorStripStream):
|
||||
|
||||
# Resize sub-stream output to zone length if needed
|
||||
if len(colors) != zone_len:
|
||||
src_x = np.linspace(0, 1, len(colors))
|
||||
dst_x = np.linspace(0, 1, zone_len)
|
||||
resized = np.empty((zone_len, 3), dtype=np.uint8)
|
||||
rkey = (len(colors), zone_len)
|
||||
cached = self._resize_cache.get(rkey)
|
||||
if cached is None:
|
||||
cached = (
|
||||
np.linspace(0, 1, len(colors)),
|
||||
np.linspace(0, 1, zone_len),
|
||||
np.empty((zone_len, 3), dtype=np.uint8),
|
||||
)
|
||||
self._resize_cache[rkey] = cached
|
||||
src_x, dst_x, resized = cached
|
||||
for ch in range(3):
|
||||
np.copyto(
|
||||
resized[:, ch],
|
||||
|
||||
@@ -38,7 +38,8 @@ def _collect_system_snapshot() -> dict:
|
||||
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
|
||||
snapshot["gpu_util"] = float(util.gpu)
|
||||
snapshot["gpu_temp"] = float(temp)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("GPU metrics collection failed: %s", e)
|
||||
pass
|
||||
|
||||
return snapshot
|
||||
@@ -67,6 +68,7 @@ class MetricsHistory:
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Metrics history collection task cancelled")
|
||||
pass
|
||||
self._task = None
|
||||
logger.info("Metrics history sampling stopped")
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.
|
||||
Optionally plays a notification sound via the asset store and sound player.
|
||||
"""
|
||||
|
||||
import collections
|
||||
@@ -61,8 +62,15 @@ class NotificationColorStripStream(ColorStripStream):
|
||||
# Active effect state
|
||||
self._active_effect: Optional[dict] = None # {"color": (r,g,b), "start": float}
|
||||
|
||||
# Asset store for resolving sound file paths (injected via set_asset_store)
|
||||
self._asset_store = None
|
||||
|
||||
self._update_from_source(source)
|
||||
|
||||
def set_asset_store(self, asset_store) -> None:
|
||||
"""Inject asset store for resolving notification sound file paths."""
|
||||
self._asset_store = asset_store
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
"""Parse config from source dataclass."""
|
||||
self._notification_effect = getattr(source, "notification_effect", "flash")
|
||||
@@ -73,6 +81,11 @@ class NotificationColorStripStream(ColorStripStream):
|
||||
self._app_filter_list = [a.lower() for a in getattr(source, "app_filter_list", [])]
|
||||
self._auto_size = not getattr(source, "led_count", 0)
|
||||
self._led_count = getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1
|
||||
# Sound config
|
||||
self._sound_asset_id = getattr(source, "sound_asset_id", None)
|
||||
self._sound_volume = float(getattr(source, "sound_volume", 1.0))
|
||||
raw_app_sounds = dict(getattr(source, "app_sounds", {}))
|
||||
self._app_sounds = {k.lower(): v for k, v in raw_app_sounds.items()}
|
||||
with self._colors_lock:
|
||||
self._colors: Optional[np.ndarray] = np.zeros((self._led_count, 3), dtype=np.uint8)
|
||||
|
||||
@@ -109,8 +122,52 @@ class NotificationColorStripStream(ColorStripStream):
|
||||
# Priority: 0 = normal, 1 = high (high interrupts current effect)
|
||||
priority = 1 if color_override else 0
|
||||
self._event_queue.append({"color": color, "start": time.monotonic(), "priority": priority})
|
||||
|
||||
# Play notification sound
|
||||
self._play_notification_sound(app_lower)
|
||||
|
||||
return True
|
||||
|
||||
def _play_notification_sound(self, app_lower: Optional[str]) -> None:
|
||||
"""Resolve and play the notification sound for the given app."""
|
||||
if self._asset_store is None:
|
||||
return
|
||||
|
||||
# Resolve sound: per-app override > global sound_asset_id
|
||||
sound_asset_id = None
|
||||
volume = self._sound_volume
|
||||
|
||||
if app_lower and app_lower in self._app_sounds:
|
||||
override = self._app_sounds[app_lower]
|
||||
if isinstance(override, dict):
|
||||
# sound_asset_id=None in override means mute this app
|
||||
if "sound_asset_id" not in override:
|
||||
# No override entry, fall through to global
|
||||
sound_asset_id = self._sound_asset_id
|
||||
else:
|
||||
sound_asset_id = override.get("sound_asset_id")
|
||||
if sound_asset_id is None:
|
||||
return # Muted for this app
|
||||
override_volume = override.get("volume")
|
||||
if override_volume is not None:
|
||||
volume = float(override_volume)
|
||||
else:
|
||||
sound_asset_id = self._sound_asset_id
|
||||
|
||||
if not sound_asset_id:
|
||||
return
|
||||
|
||||
file_path = self._asset_store.get_file_path(sound_asset_id)
|
||||
if file_path is None:
|
||||
logger.debug(f"Sound asset not found: {sound_asset_id}")
|
||||
return
|
||||
|
||||
try:
|
||||
from wled_controller.utils.sound_player import play_sound_async
|
||||
play_sound_async(file_path, volume=volume)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to play notification sound: {e}")
|
||||
|
||||
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:
|
||||
|
||||
@@ -12,9 +12,11 @@ Supported platforms:
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import json
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -22,6 +24,8 @@ from wled_controller.utils import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_POLL_INTERVAL = 0.5 # seconds between polls (Windows only)
|
||||
_HISTORY_FILE = Path("data/notification_history.json")
|
||||
_HISTORY_MAX = 50
|
||||
|
||||
# Module-level singleton for dependency access
|
||||
_instance: Optional["OsNotificationListener"] = None
|
||||
@@ -48,7 +52,8 @@ def _import_winrt_notifications():
|
||||
)
|
||||
from winrt.windows.ui.notifications import NotificationKinds
|
||||
return UserNotificationListener, UserNotificationListenerAccessStatus, NotificationKinds, "winrt"
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
logger.debug("winrt notification packages not available, trying winsdk: %s", e)
|
||||
pass
|
||||
|
||||
# Fallback: winsdk (~35MB, may already be installed)
|
||||
@@ -282,7 +287,8 @@ class OsNotificationListener:
|
||||
self._available = False
|
||||
self._backend = None
|
||||
# Recent notification history (thread-safe deque, newest first)
|
||||
self._history: collections.deque = collections.deque(maxlen=50)
|
||||
self._history: collections.deque = collections.deque(maxlen=_HISTORY_MAX)
|
||||
self._load_history()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -308,6 +314,7 @@ class OsNotificationListener:
|
||||
if self._backend:
|
||||
self._backend.stop()
|
||||
self._backend = None
|
||||
self._save_history()
|
||||
logger.info("OS notification listener stopped")
|
||||
|
||||
@property
|
||||
@@ -315,6 +322,29 @@ class OsNotificationListener:
|
||||
"""Return recent notification history (newest first)."""
|
||||
return list(self._history)
|
||||
|
||||
def _load_history(self) -> None:
|
||||
"""Load persisted notification history from disk."""
|
||||
try:
|
||||
if _HISTORY_FILE.exists():
|
||||
data = json.loads(_HISTORY_FILE.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
for entry in data[:_HISTORY_MAX]:
|
||||
self._history.append(entry)
|
||||
logger.info(f"Loaded {len(self._history)} notification history entries")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to load notification history: {exc}")
|
||||
|
||||
def _save_history(self) -> None:
|
||||
"""Persist notification history to disk."""
|
||||
try:
|
||||
_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_HISTORY_FILE.write_text(
|
||||
json.dumps(list(self._history), ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to save notification history: {exc}")
|
||||
|
||||
def _on_new_notification(self, app_name: Optional[str]) -> None:
|
||||
"""Handle a new OS notification — fire matching streams."""
|
||||
from wled_controller.storage.color_strip_source import NotificationColorStripSource
|
||||
@@ -347,5 +377,6 @@ class OsNotificationListener:
|
||||
"filtered": filtered,
|
||||
}
|
||||
self._history.appendleft(entry)
|
||||
self._save_history()
|
||||
|
||||
logger.info(f"OS notification captured: app={app_name!r}, fired={fired}, filtered={filtered}")
|
||||
|
||||
@@ -28,6 +28,20 @@ from wled_controller.core.processing.auto_restart import (
|
||||
RESTART_MAX_ATTEMPTS as _RESTART_MAX_ATTEMPTS,
|
||||
RESTART_WINDOW_SEC as _RESTART_WINDOW_SEC,
|
||||
)
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.audio_template_store import AudioTemplateStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.gradient_store import GradientStore
|
||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
from wled_controller.storage.asset_store import AssetStore
|
||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
||||
from wled_controller.core.processing.device_health import DeviceHealthMixin
|
||||
from wled_controller.core.processing.device_test_mode import DeviceTestModeMixin
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -44,19 +58,20 @@ class ProcessorDependencies:
|
||||
Keeps the constructor signature stable when new stores are added.
|
||||
"""
|
||||
|
||||
picture_source_store: object = None
|
||||
capture_template_store: object = None
|
||||
pp_template_store: object = None
|
||||
pattern_template_store: object = None
|
||||
device_store: object = None
|
||||
color_strip_store: object = None
|
||||
audio_source_store: object = None
|
||||
audio_template_store: object = None
|
||||
value_source_store: object = None
|
||||
sync_clock_manager: object = None
|
||||
cspt_store: object = None
|
||||
gradient_store: object = None
|
||||
weather_manager: object = None
|
||||
picture_source_store: Optional[PictureSourceStore] = None
|
||||
capture_template_store: Optional[TemplateStore] = None
|
||||
pp_template_store: Optional[PostprocessingTemplateStore] = None
|
||||
pattern_template_store: Optional[PatternTemplateStore] = None
|
||||
device_store: Optional[DeviceStore] = None
|
||||
color_strip_store: Optional[ColorStripStore] = None
|
||||
audio_source_store: Optional[AudioSourceStore] = None
|
||||
audio_template_store: Optional[AudioTemplateStore] = None
|
||||
value_source_store: Optional[ValueSourceStore] = None
|
||||
sync_clock_manager: Optional[SyncClockManager] = None
|
||||
cspt_store: Optional[ColorStripProcessingTemplateStore] = None
|
||||
gradient_store: Optional[GradientStore] = None
|
||||
weather_manager: Optional[WeatherManager] = None
|
||||
asset_store: Optional[AssetStore] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -119,7 +134,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
self._value_source_store = deps.value_source_store
|
||||
self._cspt_store = deps.cspt_store
|
||||
self._live_stream_manager = LiveStreamManager(
|
||||
deps.picture_source_store, deps.capture_template_store, deps.pp_template_store
|
||||
deps.picture_source_store, deps.capture_template_store, deps.pp_template_store,
|
||||
asset_store=deps.asset_store,
|
||||
)
|
||||
self._audio_capture_manager = AudioCaptureManager()
|
||||
self._sync_clock_manager = deps.sync_clock_manager
|
||||
@@ -133,6 +149,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
cspt_store=deps.cspt_store,
|
||||
gradient_store=deps.gradient_store,
|
||||
weather_manager=deps.weather_manager,
|
||||
asset_store=deps.asset_store,
|
||||
)
|
||||
self._value_stream_manager = ValueStreamManager(
|
||||
value_source_store=deps.value_source_store,
|
||||
@@ -206,7 +223,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
dev = self._device_store.get_device(ds.device_id)
|
||||
for key, default in self._DEVICE_FIELD_DEFAULTS.items():
|
||||
extras[key] = getattr(dev, key, default)
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("Device %s not found in store, using defaults: %s", ds.device_id, e)
|
||||
pass
|
||||
|
||||
return DeviceInfo(
|
||||
@@ -348,7 +366,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
try:
|
||||
dev = self._device_store.get_device(device_id)
|
||||
rgbw = getattr(dev, "rgbw", False)
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("Device %s not found for RGBW lookup: %s", device_id, e)
|
||||
pass
|
||||
return {
|
||||
"device_id": device_id,
|
||||
@@ -523,7 +542,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
try:
|
||||
dev = self._device_store.get_device(proc.device_id)
|
||||
dev_name = dev.name
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("Device %s not found for name lookup: %s", proc.device_id, e)
|
||||
pass
|
||||
raise RuntimeError(
|
||||
f"Device '{dev_name}' is already being processed by target {tgt_name}"
|
||||
|
||||
@@ -43,11 +43,18 @@ class SyncClockRuntime:
|
||||
"""Pause-aware elapsed seconds since creation/last reset.
|
||||
|
||||
Returns *real* (wall-clock) elapsed time, not speed-scaled.
|
||||
|
||||
Lock-free: under CPython's GIL, reading individual attributes is
|
||||
atomic. pause()/resume() update _offset and _epoch under a lock,
|
||||
so the reader sees a consistent pre- or post-update snapshot.
|
||||
The worst case is a one-frame stale value, which is imperceptible.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._running:
|
||||
return self._offset
|
||||
return self._offset + (time.perf_counter() - self._epoch)
|
||||
running = self._running
|
||||
offset = self._offset
|
||||
epoch = self._epoch
|
||||
if not running:
|
||||
return offset
|
||||
return offset + (time.perf_counter() - epoch)
|
||||
|
||||
# ── Control ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -207,7 +207,8 @@ class AudioValueStream(ValueStream):
|
||||
tpl = self._audio_template_store.get_template(template_id)
|
||||
self._audio_engine_type = tpl.engine_type
|
||||
self._audio_engine_config = tpl.engine_config
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.warning("Audio template %s not found for value stream, using default engine: %s", template_id, e)
|
||||
pass
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {e}")
|
||||
@@ -671,7 +672,8 @@ class ValueStreamManager:
|
||||
"""Hot-update the shared stream for the given ValueSource."""
|
||||
try:
|
||||
source = self._value_source_store.get_source(vs_id)
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.debug("Value source %s not found for hot-update: %s", vs_id, e)
|
||||
return
|
||||
|
||||
stream = self._streams.get(vs_id)
|
||||
|
||||
@@ -181,7 +181,8 @@ class WeatherColorStripStream(ColorStripStream):
|
||||
if self._weather_source_id:
|
||||
try:
|
||||
self._weather_manager.release(self._weather_source_id)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Weather source release during update: %s", e)
|
||||
pass
|
||||
self._weather_source_id = new_ws_id
|
||||
if new_ws_id:
|
||||
@@ -208,7 +209,8 @@ class WeatherColorStripStream(ColorStripStream):
|
||||
# are looked up at the ProcessorManager level when the stream
|
||||
# is created. For now, return None and use wall time.
|
||||
return None
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Sync clock lookup failed for weather stream: %s", e)
|
||||
return None
|
||||
|
||||
def _animate_loop(self) -> None:
|
||||
@@ -278,5 +280,6 @@ class WeatherColorStripStream(ColorStripStream):
|
||||
return DEFAULT_WEATHER
|
||||
try:
|
||||
return self._weather_manager.get_data(self._weather_source_id)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Weather data fetch failed, using default: %s", e)
|
||||
return DEFAULT_WEATHER
|
||||
|
||||
@@ -72,6 +72,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._fit_cache_key: tuple = (0, 0)
|
||||
self._fit_cache_src: Optional[np.ndarray] = None
|
||||
self._fit_cache_dst: Optional[np.ndarray] = None
|
||||
self._fit_result_buf: Optional[np.ndarray] = None
|
||||
|
||||
# LED preview WebSocket clients
|
||||
self._preview_clients: list = []
|
||||
@@ -207,6 +208,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("WLED target processor task cancelled")
|
||||
pass
|
||||
self._task = None
|
||||
await asyncio.sleep(0.05)
|
||||
@@ -341,7 +343,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
try:
|
||||
resp = await client.get(f"{device_url}/json/info")
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Device probe failed for %s: %s", device_url, e)
|
||||
return False
|
||||
|
||||
def get_display_index(self) -> Optional[int]:
|
||||
@@ -525,7 +528,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
async def _send_preview_to(ws, data: bytes) -> None:
|
||||
try:
|
||||
await ws.send_bytes(data)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("LED preview WS send failed: %s", e)
|
||||
pass
|
||||
|
||||
def remove_led_preview_client(self, ws) -> None:
|
||||
@@ -569,7 +573,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
try:
|
||||
await ws.send_bytes(data)
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("LED preview broadcast WS send failed: %s", e)
|
||||
return False
|
||||
|
||||
clients = list(self._preview_clients)
|
||||
@@ -591,11 +596,62 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._fit_cache_src = np.linspace(0, 1, n)
|
||||
self._fit_cache_dst = np.linspace(0, 1, device_led_count)
|
||||
self._fit_cache_key = key
|
||||
result = np.column_stack([
|
||||
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]).astype(np.uint8)
|
||||
for ch in range(colors.shape[1])
|
||||
])
|
||||
return result
|
||||
self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8)
|
||||
buf = self._fit_result_buf
|
||||
for ch in range(min(colors.shape[1], 3)):
|
||||
np.copyto(
|
||||
buf[:, ch],
|
||||
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]),
|
||||
casting="unsafe",
|
||||
)
|
||||
return buf
|
||||
|
||||
async def _send_to_device(self, send_colors: np.ndarray) -> float:
|
||||
"""Send colors to LED device and return send time in ms."""
|
||||
t_start = time.perf_counter()
|
||||
if self._led_client.supports_fast_send:
|
||||
self._led_client.send_pixels_fast(send_colors)
|
||||
else:
|
||||
await self._led_client.send_pixels(send_colors)
|
||||
return (time.perf_counter() - t_start) * 1000
|
||||
|
||||
@staticmethod
|
||||
def _emit_diagnostics(
|
||||
target_id: str,
|
||||
sleep_jitters: collections.deque,
|
||||
iter_times: collections.deque,
|
||||
slow_iters: collections.deque,
|
||||
frame_time: float,
|
||||
diag_interval: float,
|
||||
) -> None:
|
||||
"""Log periodic timing diagnostics and clear the deques."""
|
||||
if sleep_jitters:
|
||||
jitters = [a - r for r, a in sleep_jitters]
|
||||
avg_j = sum(jitters) / len(jitters)
|
||||
max_j = max(jitters)
|
||||
p95_j = sorted(jitters)[int(len(jitters) * 0.95)] if len(jitters) >= 20 else max_j
|
||||
logger.info(
|
||||
f"[DIAG] {target_id} sleep jitter: "
|
||||
f"avg={avg_j:.1f}ms max={max_j:.1f}ms p95={p95_j:.1f}ms "
|
||||
f"(n={len(sleep_jitters)})"
|
||||
)
|
||||
if iter_times:
|
||||
avg_iter = sum(iter_times) / len(iter_times)
|
||||
max_iter = max(iter_times)
|
||||
logger.info(
|
||||
f"[DIAG] {target_id} iter: "
|
||||
f"avg={avg_iter:.1f}ms max={max_iter:.1f}ms "
|
||||
f"target={frame_time*1000:.1f}ms iters={len(iter_times)}"
|
||||
)
|
||||
if slow_iters:
|
||||
logger.warning(
|
||||
f"[DIAG] {target_id} slow iterations: "
|
||||
f"{len(slow_iters)} in last {diag_interval}s — "
|
||||
f"{list(slow_iters)[:5]}"
|
||||
)
|
||||
sleep_jitters.clear()
|
||||
slow_iters.clear()
|
||||
iter_times.clear()
|
||||
|
||||
async def _processing_loop(self) -> None:
|
||||
"""Main processing loop — poll CSS stream -> brightness -> send."""
|
||||
@@ -608,7 +664,9 @@ class WledTargetProcessor(TargetProcessor):
|
||||
def _fps_current_from_timestamps():
|
||||
"""Count timestamps within the last second."""
|
||||
cutoff = time.perf_counter() - 1.0
|
||||
return sum(1 for ts in send_timestamps if ts > cutoff)
|
||||
while send_timestamps and send_timestamps[0] <= cutoff:
|
||||
send_timestamps.popleft()
|
||||
return len(send_timestamps)
|
||||
|
||||
last_send_time = 0.0
|
||||
_last_preview_broadcast = 0.0
|
||||
@@ -844,10 +902,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._fit_to_device(prev_frame_ref, _total_leds),
|
||||
cur_brightness,
|
||||
)
|
||||
if self._led_client.supports_fast_send:
|
||||
self._led_client.send_pixels_fast(send_colors)
|
||||
else:
|
||||
await self._led_client.send_pixels(send_colors)
|
||||
await self._send_to_device(send_colors)
|
||||
now = time.perf_counter()
|
||||
last_send_time = now
|
||||
send_timestamps.append(now)
|
||||
@@ -879,10 +934,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._fit_to_device(prev_frame_ref, _total_leds),
|
||||
cur_brightness,
|
||||
)
|
||||
if self._led_client.supports_fast_send:
|
||||
self._led_client.send_pixels_fast(send_colors)
|
||||
else:
|
||||
await self._led_client.send_pixels(send_colors)
|
||||
await self._send_to_device(send_colors)
|
||||
now = time.perf_counter()
|
||||
last_send_time = now
|
||||
send_timestamps.append(now)
|
||||
@@ -910,12 +962,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
# Send to LED device
|
||||
if not self._is_running or self._led_client is None:
|
||||
break
|
||||
t_send_start = time.perf_counter()
|
||||
if self._led_client.supports_fast_send:
|
||||
self._led_client.send_pixels_fast(send_colors)
|
||||
else:
|
||||
await self._led_client.send_pixels(send_colors)
|
||||
send_ms = (time.perf_counter() - t_send_start) * 1000
|
||||
send_ms = await self._send_to_device(send_colors)
|
||||
|
||||
now = time.perf_counter()
|
||||
last_send_time = now
|
||||
@@ -984,33 +1031,11 @@ class WledTargetProcessor(TargetProcessor):
|
||||
# Periodic diagnostics report
|
||||
if iter_end >= _diag_next_report:
|
||||
_diag_next_report = iter_end + _diag_interval
|
||||
if _diag_sleep_jitters:
|
||||
jitters = [a - r for r, a in _diag_sleep_jitters]
|
||||
avg_j = sum(jitters) / len(jitters)
|
||||
max_j = max(jitters)
|
||||
p95_j = sorted(jitters)[int(len(jitters) * 0.95)] if len(jitters) >= 20 else max_j
|
||||
logger.info(
|
||||
f"[DIAG] {self._target_id} sleep jitter: "
|
||||
f"avg={avg_j:.1f}ms max={max_j:.1f}ms p95={p95_j:.1f}ms "
|
||||
f"(n={len(_diag_sleep_jitters)})"
|
||||
)
|
||||
if _diag_iter_times:
|
||||
avg_iter = sum(_diag_iter_times) / len(_diag_iter_times)
|
||||
max_iter = max(_diag_iter_times)
|
||||
logger.info(
|
||||
f"[DIAG] {self._target_id} iter: "
|
||||
f"avg={avg_iter:.1f}ms max={max_iter:.1f}ms "
|
||||
f"target={frame_time*1000:.1f}ms iters={len(_diag_iter_times)}"
|
||||
)
|
||||
if _diag_slow_iters:
|
||||
logger.warning(
|
||||
f"[DIAG] {self._target_id} slow iterations: "
|
||||
f"{len(_diag_slow_iters)} in last {_diag_interval}s — "
|
||||
f"{list(_diag_slow_iters)[:5]}"
|
||||
)
|
||||
_diag_sleep_jitters.clear()
|
||||
_diag_slow_iters.clear()
|
||||
_diag_iter_times.clear()
|
||||
self._emit_diagnostics(
|
||||
self._target_id, _diag_sleep_jitters,
|
||||
_diag_iter_times, _diag_slow_iters,
|
||||
frame_time, _diag_interval,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"Processing loop cancelled for target {self._target_id}")
|
||||
|
||||
@@ -131,6 +131,7 @@ class UpdateService:
|
||||
except Exception as exc:
|
||||
logger.error("Update check failed: %s", exc, exc_info=True)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Update check loop cancelled")
|
||||
pass
|
||||
|
||||
# ── Core check logic ───────────────────────────────────────
|
||||
@@ -172,7 +173,8 @@ class UpdateService:
|
||||
continue
|
||||
try:
|
||||
normalize_version(release.version)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Skipping release with unparseable version %s: %s", release.version, e)
|
||||
continue
|
||||
if is_newer(release.version, __version__):
|
||||
return release
|
||||
@@ -317,6 +319,10 @@ class UpdateService:
|
||||
shutil.rmtree(staging)
|
||||
staging.mkdir(parents=True)
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for member in zf.namelist():
|
||||
target = (staging / member).resolve()
|
||||
if not target.is_relative_to(staging.resolve()):
|
||||
raise ValueError(f"Zip entry escapes target directory: {member}")
|
||||
zf.extractall(staging)
|
||||
|
||||
await asyncio.to_thread(_extract)
|
||||
|
||||
@@ -4,10 +4,13 @@ Normalizes Gitea-style tags (v0.3.0-alpha.1) to PEP 440 (0.3.0a1)
|
||||
so that ``packaging.version.Version`` can compare them correctly.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_PRE_MAP = {
|
||||
"alpha": "a",
|
||||
@@ -41,5 +44,6 @@ def is_newer(candidate: str, current: str) -> bool:
|
||||
"""
|
||||
try:
|
||||
return normalize_version(candidate) > normalize_version(current)
|
||||
except InvalidVersion:
|
||||
except InvalidVersion as e:
|
||||
logger.debug("Unparseable version string in comparison: %s", e)
|
||||
return False
|
||||
|
||||
@@ -34,6 +34,7 @@ from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.gradient_store import GradientStore
|
||||
from wled_controller.storage.weather_source_store import WeatherSourceStore
|
||||
from wled_controller.storage.asset_store import AssetStore
|
||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
@@ -80,6 +81,12 @@ cspt_store = ColorStripProcessingTemplateStore(db)
|
||||
gradient_store = GradientStore(db)
|
||||
gradient_store.migrate_palette_references(color_strip_store)
|
||||
weather_source_store = WeatherSourceStore(db)
|
||||
asset_store = AssetStore(db, config.assets.assets_dir)
|
||||
|
||||
# Import prebuilt notification sounds on first run
|
||||
_prebuilt_sounds_dir = Path(__file__).parent / "data" / "prebuilt_sounds"
|
||||
asset_store.import_prebuilt_sounds(_prebuilt_sounds_dir)
|
||||
|
||||
sync_clock_manager = SyncClockManager(sync_clock_store)
|
||||
weather_manager = WeatherManager(weather_source_store)
|
||||
|
||||
@@ -98,6 +105,7 @@ processor_manager = ProcessorManager(
|
||||
cspt_store=cspt_store,
|
||||
gradient_store=gradient_store,
|
||||
weather_manager=weather_manager,
|
||||
asset_store=asset_store,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -190,6 +198,7 @@ async def lifespan(app: FastAPI):
|
||||
weather_source_store=weather_source_store,
|
||||
weather_manager=weather_manager,
|
||||
update_service=update_service,
|
||||
asset_store=asset_store,
|
||||
)
|
||||
|
||||
# Register devices in processor manager for health monitoring
|
||||
|
||||
@@ -7,6 +7,10 @@ mechanism the system tray "Shutdown" menu item uses.
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_server: Optional[Any] = None # uvicorn.Server
|
||||
_tray: Optional[Any] = None # TrayManager
|
||||
|
||||
@@ -39,7 +43,8 @@ def request_shutdown() -> None:
|
||||
try:
|
||||
from wled_controller.main import _save_all_stores
|
||||
_save_all_stores()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Best-effort store save on shutdown failed: %s", e)
|
||||
pass # best-effort; lifespan handler is the backup
|
||||
|
||||
if _server is not None:
|
||||
@@ -55,5 +60,6 @@ def _broadcast_restarting() -> None:
|
||||
pm = _deps.get("processor_manager")
|
||||
if pm is not None:
|
||||
pm.fire_event({"type": "server_restarting"})
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to broadcast server_restarting event: %s", e)
|
||||
pass
|
||||
|
||||
@@ -215,21 +215,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-browse-apps {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.btn-browse-apps:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(33, 150, 243, 0.1);
|
||||
}
|
||||
|
||||
/* Webhook URL row */
|
||||
.webhook-url-row {
|
||||
|
||||
@@ -1466,64 +1466,42 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Notification app color mappings ─────────────────────────── */
|
||||
/* ── Notification per-app overrides (unified color + sound) ──── */
|
||||
|
||||
.notif-app-color-row {
|
||||
display: flex;
|
||||
.notif-override-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
gap: 4px 4px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.notif-app-color-row .notif-app-name {
|
||||
flex: 1;
|
||||
.notif-override-row .notif-override-name,
|
||||
.notif-override-row .notif-override-sound {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-app-color-row .notif-app-color {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
/* Sound select spans the first column, volume spans browse+color columns */
|
||||
.notif-override-row .notif-override-sound {
|
||||
grid-column: 1;
|
||||
}
|
||||
.notif-override-row .notif-override-volume {
|
||||
grid-column: 2 / 4;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notif-override-row .notif-override-color {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 1px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-app-browse,
|
||||
.notif-app-color-remove {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: unset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.notif-app-browse svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.notif-app-color-remove {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notif-app-browse:hover,
|
||||
.notif-app-color-remove:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* ── Notification history list ─────────────────────────────────── */
|
||||
|
||||
@@ -1866,3 +1844,128 @@ body.composite-layer-dragging .composite-layer-drag-handle {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
/* ── File drop zone ── */
|
||||
.file-dropzone {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 28px 20px;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-color);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--duration-normal) ease,
|
||||
background var(--duration-normal) ease,
|
||||
box-shadow var(--duration-normal) ease;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-dropzone:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 5%, var(--bg-color));
|
||||
}
|
||||
|
||||
.file-dropzone:focus-visible {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15);
|
||||
}
|
||||
|
||||
.file-dropzone.dragover {
|
||||
border-color: var(--primary-color);
|
||||
border-style: solid;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-color));
|
||||
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15),
|
||||
inset 0 0 20px rgba(76, 175, 80, 0.06);
|
||||
}
|
||||
|
||||
.file-dropzone.has-file {
|
||||
border-style: solid;
|
||||
border-color: var(--primary-color);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.file-dropzone-icon {
|
||||
color: var(--text-muted);
|
||||
transition: color var(--duration-normal) ease, transform var(--duration-normal) var(--ease-spring);
|
||||
}
|
||||
|
||||
.file-dropzone:hover .file-dropzone-icon,
|
||||
.file-dropzone.dragover .file-dropzone-icon {
|
||||
color: var(--primary-color);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.file-dropzone.has-file .file-dropzone-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.file-dropzone-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-dropzone-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
transition: color var(--duration-normal) ease;
|
||||
}
|
||||
|
||||
.file-dropzone:hover .file-dropzone-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.file-dropzone-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-dropzone-filename {
|
||||
flex: 1;
|
||||
font-size: 0.88rem;
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-dropzone-filesize {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.file-dropzone-remove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) ease, color var(--duration-fast) ease;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.file-dropzone-remove:hover {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ import {
|
||||
previewCSSFromEditor,
|
||||
copyEndpointUrl,
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppColor, notificationRemoveAppColor,
|
||||
notificationAddAppOverride, notificationRemoveAppOverride,
|
||||
testNotification,
|
||||
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||
@@ -464,7 +464,7 @@ Object.assign(window, {
|
||||
previewCSSFromEditor,
|
||||
copyEndpointUrl,
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppColor, notificationRemoveAppColor,
|
||||
notificationAddAppOverride, notificationRemoveAppOverride,
|
||||
testNotification,
|
||||
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||
|
||||
@@ -84,3 +84,6 @@ export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0
|
||||
export const externalLink = '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>';
|
||||
export const thermometer = '<path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/>';
|
||||
export const xIcon = '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>';
|
||||
export const fileUp = '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>';
|
||||
export const fileAudio = '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><circle cx="10" cy="16" r="2"/><path d="M12 12v4"/>';
|
||||
export const packageIcon = '<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>';
|
||||
|
||||
@@ -186,3 +186,17 @@ export const ICON_LIST_CHECKS = _svg(P.listChecks);
|
||||
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
|
||||
export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
|
||||
export const ICON_X = _svg(P.xIcon);
|
||||
export const ICON_FILE_UP = _svg(P.fileUp);
|
||||
export const ICON_FILE_AUDIO = _svg(P.fileAudio);
|
||||
export const ICON_ASSET = _svg(P.packageIcon);
|
||||
|
||||
/** Asset type → icon (fallback: file) */
|
||||
export function getAssetTypeIcon(assetType: string): string {
|
||||
const map: Record<string, string> = {
|
||||
sound: _svg(P.volume2),
|
||||
image: _svg(P.image),
|
||||
video: _svg(P.film),
|
||||
other: _svg(P.fileText),
|
||||
};
|
||||
return map[assetType] || _svg(P.fileText);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DataCache } from './cache.ts';
|
||||
import type {
|
||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||
SyncClock, WeatherSource, Automation, Display, FilterDef, EngineInfo,
|
||||
SyncClock, WeatherSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||
} from '../types.ts';
|
||||
@@ -226,6 +226,7 @@ export let _cachedValueSources: ValueSource[] = [];
|
||||
// Sync clocks
|
||||
export let _cachedSyncClocks: SyncClock[] = [];
|
||||
export let _cachedWeatherSources: WeatherSource[] = [];
|
||||
export let _cachedAssets: Asset[] = [];
|
||||
|
||||
// Automations
|
||||
export let _automationsCache: Automation[] | null = null;
|
||||
@@ -289,6 +290,12 @@ export const weatherSourcesCache = new DataCache<WeatherSource[]>({
|
||||
});
|
||||
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
|
||||
|
||||
export const assetsCache = new DataCache<Asset[]>({
|
||||
endpoint: '/assets',
|
||||
extractData: json => json.assets || [],
|
||||
});
|
||||
assetsCache.subscribe(v => { _cachedAssets = v; });
|
||||
|
||||
export const filtersCache = new DataCache<FilterDef[]>({
|
||||
endpoint: '/filters',
|
||||
extractData: json => json.filters || [],
|
||||
|
||||
475
server/src/wled_controller/static/js/features/assets.ts
Normal file
475
server/src/wled_controller/static/js/features/assets.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Assets — file upload/download, CRUD, cards, modal.
|
||||
*/
|
||||
|
||||
import { _cachedAssets, assetsCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml, API_BASE, getHeaders } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { ICON_CLONE, ICON_EDIT, ICON_DOWNLOAD, ICON_ASSET, ICON_TRASH, getAssetTypeIcon } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import type { Asset } from '../types.ts';
|
||||
|
||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
const ICON_PLAY_SOUND = _icon(P.play);
|
||||
const ICON_UPLOAD = _icon(P.fileUp);
|
||||
const ICON_RESTORE = _icon(P.rotateCcw);
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
let _dropzoneInitialized = false;
|
||||
|
||||
/** Initialise the drop-zone wiring for the upload modal (once). */
|
||||
function initUploadDropzone(): void {
|
||||
if (_dropzoneInitialized) return;
|
||||
_dropzoneInitialized = true;
|
||||
const dropzone = document.getElementById('asset-upload-dropzone')!;
|
||||
const fileInput = document.getElementById('asset-upload-file') as HTMLInputElement;
|
||||
const infoEl = document.getElementById('asset-upload-file-info')!;
|
||||
const nameEl = document.getElementById('asset-upload-file-name')!;
|
||||
const sizeEl = document.getElementById('asset-upload-file-size')!;
|
||||
const removeBtn = document.getElementById('asset-upload-file-remove')!;
|
||||
|
||||
const showFile = (file: File) => {
|
||||
nameEl.textContent = file.name;
|
||||
sizeEl.textContent = formatFileSize(file.size);
|
||||
infoEl.style.display = '';
|
||||
dropzone.classList.add('has-file');
|
||||
// Hide the prompt text when a file is selected
|
||||
const labelEl = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
|
||||
if (labelEl) labelEl.style.display = 'none';
|
||||
const iconEl = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
|
||||
if (iconEl) iconEl.style.display = 'none';
|
||||
};
|
||||
|
||||
const clearFile = () => {
|
||||
fileInput.value = '';
|
||||
infoEl.style.display = 'none';
|
||||
dropzone.classList.remove('has-file');
|
||||
const labelEl = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
|
||||
if (labelEl) labelEl.style.display = '';
|
||||
const iconEl = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
|
||||
if (iconEl) iconEl.style.display = '';
|
||||
};
|
||||
|
||||
// Click → open file picker
|
||||
dropzone.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).closest('.file-dropzone-remove')) return;
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
// Keyboard: Enter/Space
|
||||
dropzone.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
// File input change
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files && fileInput.files.length > 0) {
|
||||
showFile(fileInput.files[0]);
|
||||
} else {
|
||||
clearFile();
|
||||
}
|
||||
});
|
||||
|
||||
// Drag events
|
||||
let dragCounter = 0;
|
||||
dropzone.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter++;
|
||||
dropzone.classList.add('dragover');
|
||||
});
|
||||
dropzone.addEventListener('dragleave', () => {
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
dropzone.classList.remove('dragover');
|
||||
}
|
||||
});
|
||||
dropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
dropzone.classList.remove('dragover');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove button
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
clearFile();
|
||||
});
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function getAssetTypeLabel(assetType: string): string {
|
||||
const map: Record<string, string> = {
|
||||
sound: t('asset.type.sound'),
|
||||
image: t('asset.type.image'),
|
||||
video: t('asset.type.video'),
|
||||
other: t('asset.type.other'),
|
||||
};
|
||||
return map[assetType] || assetType;
|
||||
}
|
||||
|
||||
// ── Card builder ──
|
||||
|
||||
export function createAssetCard(asset: Asset): string {
|
||||
const icon = getAssetTypeIcon(asset.asset_type);
|
||||
const sizeStr = formatFileSize(asset.size_bytes);
|
||||
const prebuiltBadge = asset.prebuilt
|
||||
? `<span class="stream-card-prop" title="${escapeHtml(t('asset.prebuilt'))}">${_icon(P.star)} ${t('asset.prebuilt')}</span>`
|
||||
: '';
|
||||
|
||||
let playBtn = '';
|
||||
if (asset.asset_type === 'sound') {
|
||||
playBtn = `<button class="btn btn-icon btn-secondary" data-action="play" title="${escapeHtml(t('asset.play'))}">${ICON_PLAY_SOUND}</button>`;
|
||||
}
|
||||
|
||||
return wrapCard({
|
||||
dataAttr: 'data-id',
|
||||
id: asset.id,
|
||||
removeOnclick: `deleteAsset('${asset.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="card-header">
|
||||
<div class="card-title" title="${escapeHtml(asset.name)}">
|
||||
${icon} <span class="card-title-text">${escapeHtml(asset.name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">${getAssetTypeIcon(asset.asset_type)} ${escapeHtml(getAssetTypeLabel(asset.asset_type))}</span>
|
||||
<span class="stream-card-prop">${_icon(P.fileText)} ${sizeStr}</span>
|
||||
${prebuiltBadge}
|
||||
</div>
|
||||
${renderTagChips(asset.tags)}`,
|
||||
actions: `
|
||||
${playBtn}
|
||||
<button class="btn btn-icon btn-secondary" data-action="download" title="${escapeHtml(t('asset.download'))}">${ICON_DOWNLOAD}</button>
|
||||
<button class="btn btn-icon btn-secondary" data-action="edit" title="${escapeHtml(t('common.edit'))}">${ICON_EDIT}</button>`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sound playback ──
|
||||
|
||||
let _currentAudio: HTMLAudioElement | null = null;
|
||||
|
||||
async function _playAssetSound(assetId: string) {
|
||||
if (_currentAudio) {
|
||||
_currentAudio.pause();
|
||||
_currentAudio = null;
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithAuth(`/assets/${assetId}/file`);
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const audio = new Audio(blobUrl);
|
||||
audio.addEventListener('ended', () => URL.revokeObjectURL(blobUrl));
|
||||
audio.play().catch(() => {});
|
||||
_currentAudio = audio;
|
||||
} catch { /* ignore playback errors */ }
|
||||
}
|
||||
|
||||
// ── Modal ──
|
||||
|
||||
let _assetTagsInput: TagInput | null = null;
|
||||
let _uploadTagsInput: TagInput | null = null;
|
||||
|
||||
class AssetEditorModal extends Modal {
|
||||
constructor() { super('asset-editor-modal'); }
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: (document.getElementById('asset-editor-name') as HTMLInputElement).value,
|
||||
description: (document.getElementById('asset-editor-description') as HTMLInputElement).value,
|
||||
tags: JSON.stringify(_assetTagsInput ? _assetTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_assetTagsInput) { _assetTagsInput.destroy(); _assetTagsInput = null; }
|
||||
}
|
||||
}
|
||||
const assetEditorModal = new AssetEditorModal();
|
||||
|
||||
class AssetUploadModal extends Modal {
|
||||
constructor() { super('asset-upload-modal'); }
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: (document.getElementById('asset-upload-name') as HTMLInputElement).value,
|
||||
file: (document.getElementById('asset-upload-file') as HTMLInputElement).value,
|
||||
tags: JSON.stringify(_uploadTagsInput ? _uploadTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
onForceClose() {
|
||||
if (_uploadTagsInput) { _uploadTagsInput.destroy(); _uploadTagsInput = null; }
|
||||
}
|
||||
}
|
||||
const assetUploadModal = new AssetUploadModal();
|
||||
|
||||
// ── CRUD: Upload ──
|
||||
|
||||
export async function showAssetUploadModal(): Promise<void> {
|
||||
const titleEl = document.getElementById('asset-upload-title')!;
|
||||
titleEl.innerHTML = `${ICON_UPLOAD} ${t('asset.upload')}`;
|
||||
|
||||
(document.getElementById('asset-upload-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('asset-upload-description') as HTMLInputElement).value = '';
|
||||
(document.getElementById('asset-upload-file') as HTMLInputElement).value = '';
|
||||
document.getElementById('asset-upload-error')!.style.display = 'none';
|
||||
|
||||
// Tags
|
||||
if (_uploadTagsInput) { _uploadTagsInput.destroy(); _uploadTagsInput = null; }
|
||||
const tagsContainer = document.getElementById('asset-upload-tags-container')!;
|
||||
_uploadTagsInput = new TagInput(tagsContainer, { entityType: 'asset' });
|
||||
|
||||
// Reset dropzone visual state
|
||||
const dropzone = document.getElementById('asset-upload-dropzone')!;
|
||||
dropzone.classList.remove('has-file', 'dragover');
|
||||
const dzLabel = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
|
||||
if (dzLabel) dzLabel.style.display = '';
|
||||
const dzIcon = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
|
||||
if (dzIcon) dzIcon.style.display = '';
|
||||
document.getElementById('asset-upload-file-info')!.style.display = 'none';
|
||||
|
||||
initUploadDropzone();
|
||||
|
||||
assetUploadModal.open();
|
||||
assetUploadModal.snapshot();
|
||||
}
|
||||
|
||||
export async function uploadAsset(): Promise<void> {
|
||||
const fileInput = document.getElementById('asset-upload-file') as HTMLInputElement;
|
||||
const nameInput = document.getElementById('asset-upload-name') as HTMLInputElement;
|
||||
const descInput = document.getElementById('asset-upload-description') as HTMLInputElement;
|
||||
const errorEl = document.getElementById('asset-upload-error')!;
|
||||
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
errorEl.textContent = t('asset.error.no_file');
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileInput.files[0];
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
let url = `${API_BASE}/assets`;
|
||||
const params = new URLSearchParams();
|
||||
const name = nameInput.value.trim();
|
||||
if (name) params.set('name', name);
|
||||
const desc = descInput.value.trim();
|
||||
if (desc) params.set('description', desc);
|
||||
if (params.toString()) url += `?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const headers = getHeaders();
|
||||
delete headers['Content-Type']; // Let browser set multipart boundary
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
// Set tags via metadata update if any were specified
|
||||
const tags = _uploadTagsInput ? _uploadTagsInput.getValue() : [];
|
||||
const result = await res.json();
|
||||
if (tags.length > 0 && result.id) {
|
||||
await fetchWithAuth(`/assets/${result.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ tags }),
|
||||
});
|
||||
}
|
||||
|
||||
showToast(t('asset.uploaded'), 'success');
|
||||
assetsCache.invalidate();
|
||||
assetUploadModal.forceClose();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
export function closeAssetUploadModal() {
|
||||
assetUploadModal.close();
|
||||
}
|
||||
|
||||
// ── CRUD: Edit metadata ──
|
||||
|
||||
export async function showAssetEditor(editId: string): Promise<void> {
|
||||
const titleEl = document.getElementById('asset-editor-title')!;
|
||||
const idInput = document.getElementById('asset-editor-id') as HTMLInputElement;
|
||||
const nameInput = document.getElementById('asset-editor-name') as HTMLInputElement;
|
||||
const descInput = document.getElementById('asset-editor-description') as HTMLInputElement;
|
||||
const errorEl = document.getElementById('asset-editor-error')!;
|
||||
|
||||
errorEl.style.display = 'none';
|
||||
const assets = await assetsCache.fetch();
|
||||
const asset = assets.find(a => a.id === editId);
|
||||
if (!asset) return;
|
||||
|
||||
titleEl.innerHTML = `${ICON_ASSET} ${t('asset.edit')}`;
|
||||
idInput.value = asset.id;
|
||||
nameInput.value = asset.name;
|
||||
descInput.value = asset.description || '';
|
||||
|
||||
// Tags
|
||||
if (_assetTagsInput) { _assetTagsInput.destroy(); _assetTagsInput = null; }
|
||||
const tagsContainer = document.getElementById('asset-editor-tags-container')!;
|
||||
_assetTagsInput = new TagInput(tagsContainer, { entityType: 'asset' });
|
||||
_assetTagsInput.setValue(asset.tags || []);
|
||||
|
||||
assetEditorModal.open();
|
||||
assetEditorModal.snapshot();
|
||||
}
|
||||
|
||||
export async function saveAssetMetadata(): Promise<void> {
|
||||
const id = (document.getElementById('asset-editor-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('asset-editor-name') as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById('asset-editor-description') as HTMLInputElement).value.trim();
|
||||
const errorEl = document.getElementById('asset-editor-error')!;
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('asset.error.name_required');
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = _assetTagsInput ? _assetTagsInput.getValue() : [];
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth(`/assets/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description: description || null, tags }),
|
||||
});
|
||||
if (!res!.ok) {
|
||||
const err = await res!.json();
|
||||
throw new Error(err.detail);
|
||||
}
|
||||
|
||||
showToast(t('asset.updated'), 'success');
|
||||
assetsCache.invalidate();
|
||||
assetEditorModal.forceClose();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
errorEl.textContent = e.message;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
export function closeAssetEditorModal() {
|
||||
assetEditorModal.close();
|
||||
}
|
||||
|
||||
// ── CRUD: Delete ──
|
||||
|
||||
export async function deleteAsset(assetId: string): Promise<void> {
|
||||
const ok = await showConfirm(t('asset.confirm_delete'));
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await fetchWithAuth(`/assets/${assetId}`, { method: 'DELETE' });
|
||||
showToast(t('asset.deleted'), 'success');
|
||||
assetsCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message || t('asset.error.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Restore prebuilt ──
|
||||
|
||||
export async function restorePrebuiltAssets(): Promise<void> {
|
||||
try {
|
||||
const res = await fetchWithAuth('/assets/restore-prebuilt', { method: 'POST' });
|
||||
if (!res!.ok) {
|
||||
const err = await res!.json();
|
||||
throw new Error(err.detail);
|
||||
}
|
||||
const data = await res!.json();
|
||||
if (data.restored_count > 0) {
|
||||
showToast(t('asset.prebuilt_restored').replace('{count}', String(data.restored_count)), 'success');
|
||||
} else {
|
||||
showToast(t('asset.prebuilt_none_to_restore'), 'info');
|
||||
}
|
||||
assetsCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download ──
|
||||
|
||||
async function _downloadAsset(assetId: string) {
|
||||
const asset = _cachedAssets.find(a => a.id === assetId);
|
||||
const filename = asset ? asset.filename : 'download';
|
||||
try {
|
||||
const res = await fetchWithAuth(`/assets/${assetId}/file`);
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Event delegation ──
|
||||
|
||||
export function initAssetDelegation(container: HTMLElement): void {
|
||||
container.addEventListener('click', (e: Event) => {
|
||||
const btn = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
|
||||
if (!btn) return;
|
||||
const card = btn.closest('[data-id]') as HTMLElement | null;
|
||||
if (!card || !card.closest('#stream-tab-assets')) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
const id = card.getAttribute('data-id');
|
||||
if (!action || !id) return;
|
||||
|
||||
e.stopPropagation();
|
||||
if (action === 'edit') showAssetEditor(id);
|
||||
else if (action === 'delete') deleteAsset(id);
|
||||
else if (action === 'download') _downloadAsset(id);
|
||||
else if (action === 'play') _playAssetSound(id);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Expose to global scope for HTML template onclick handlers ──
|
||||
|
||||
window.showAssetUploadModal = showAssetUploadModal;
|
||||
window.closeAssetUploadModal = closeAssetUploadModal;
|
||||
window.uploadAsset = uploadAsset;
|
||||
window.showAssetEditor = showAssetEditor;
|
||||
window.closeAssetEditorModal = closeAssetEditorModal;
|
||||
window.saveAssetMetadata = saveAssetMetadata;
|
||||
window.deleteAsset = deleteAsset;
|
||||
window.restorePrebuiltAssets = restorePrebuiltAssets;
|
||||
@@ -9,7 +9,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
import { CardSection } from '../core/card-sections.ts';
|
||||
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
|
||||
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB } from '../core/icons.ts';
|
||||
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
@@ -766,7 +766,7 @@ function addAutomationConditionRow(condition: any) {
|
||||
<div class="condition-field">
|
||||
<div class="condition-apps-header">
|
||||
<label>${t('automations.condition.application.apps')}</label>
|
||||
<button type="button" class="btn-browse-apps" title="${t('automations.condition.application.browse')}">${t('automations.condition.application.browse')}</button>
|
||||
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
|
||||
</div>
|
||||
<textarea class="condition-apps" rows="3" placeholder="firefox.exe chrome.exe">${escapeHtml(appsValue)}</textarea>
|
||||
</div>
|
||||
|
||||
@@ -7,23 +7,33 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast } from '../core/ui.ts';
|
||||
import {
|
||||
ICON_SEARCH, ICON_CLONE,
|
||||
ICON_SEARCH, ICON_CLONE, getAssetTypeIcon,
|
||||
} from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
|
||||
import { _cachedAssets, assetsCache } from '../core/state.ts';
|
||||
import { getBaseOrigin } from './settings.ts';
|
||||
|
||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
/* ── Notification state ───────────────────────────────────────── */
|
||||
|
||||
let _notificationAppColors: any[] = []; // [{app: '', color: '#...'}]
|
||||
|
||||
/** Return current app colors array (for dirty-check snapshot). */
|
||||
export function notificationGetRawAppColors() {
|
||||
return _notificationAppColors;
|
||||
interface AppOverride {
|
||||
app: string;
|
||||
color: string;
|
||||
sound_asset_id: string | null;
|
||||
volume: number; // 0–100
|
||||
}
|
||||
|
||||
let _notificationAppOverrides: AppOverride[] = [];
|
||||
|
||||
/** Return current overrides array (for dirty-check snapshot). */
|
||||
export function notificationGetRawAppOverrides() {
|
||||
return _notificationAppOverrides;
|
||||
}
|
||||
|
||||
let _notificationEffectIconSelect: any = null;
|
||||
let _notificationFilterModeIconSelect: any = null;
|
||||
|
||||
@@ -58,50 +68,162 @@ export function onNotificationFilterModeChange() {
|
||||
(document.getElementById('css-editor-notification-filter-list-group') as HTMLElement).style.display = mode === 'off' ? 'none' : '';
|
||||
}
|
||||
|
||||
function _notificationAppColorsRenderList() {
|
||||
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
|
||||
if (!list) return;
|
||||
list.innerHTML = _notificationAppColors.map((entry, i) => `
|
||||
<div class="notif-app-color-row">
|
||||
<input type="text" class="notif-app-name" data-idx="${i}" value="${escapeHtml(entry.app)}" placeholder="App name">
|
||||
<button type="button" class="notif-app-browse" data-idx="${i}"
|
||||
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
|
||||
<input type="color" class="notif-app-color" data-idx="${i}" value="${entry.color}">
|
||||
<button type="button" class="notif-app-color-remove"
|
||||
onclick="notificationRemoveAppColor(${i})">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
/* ── Unified per-app overrides (color + sound) ────────────────── */
|
||||
|
||||
// Wire up browse buttons to open process palette
|
||||
list.querySelectorAll<HTMLButtonElement>('.notif-app-browse').forEach(btn => {
|
||||
let _overrideEntitySelects: EntitySelect[] = [];
|
||||
|
||||
function _getSoundAssetItems() {
|
||||
return _cachedAssets
|
||||
.filter(a => a.asset_type === 'sound')
|
||||
.map(a => ({ value: a.id, label: a.name, icon: getAssetTypeIcon('sound'), desc: a.filename }));
|
||||
}
|
||||
|
||||
function _overridesSyncFromDom() {
|
||||
const list = document.getElementById('notification-app-overrides-list') as HTMLElement | null;
|
||||
if (!list) return;
|
||||
const names = list.querySelectorAll<HTMLInputElement>('.notif-override-name');
|
||||
const colors = list.querySelectorAll<HTMLInputElement>('.notif-override-color');
|
||||
const sounds = list.querySelectorAll<HTMLSelectElement>('.notif-override-sound');
|
||||
const volumes = list.querySelectorAll<HTMLInputElement>('.notif-override-volume');
|
||||
if (names.length === _notificationAppOverrides.length) {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
_notificationAppOverrides[i].app = names[i].value;
|
||||
_notificationAppOverrides[i].color = colors[i].value;
|
||||
_notificationAppOverrides[i].sound_asset_id = sounds[i].value || null;
|
||||
_notificationAppOverrides[i].volume = parseInt(volumes[i].value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _overridesRenderList() {
|
||||
const list = document.getElementById('notification-app-overrides-list') as HTMLElement | null;
|
||||
if (!list) return;
|
||||
|
||||
_overrideEntitySelects.forEach(es => es.destroy());
|
||||
_overrideEntitySelects = [];
|
||||
|
||||
const soundAssets = _cachedAssets.filter(a => a.asset_type === 'sound');
|
||||
|
||||
list.innerHTML = _notificationAppOverrides.map((entry, i) => {
|
||||
const soundOpts = `<option value="">${t('color_strip.notification.sound.none')}</option>` +
|
||||
soundAssets.map(a =>
|
||||
`<option value="${a.id}"${a.id === entry.sound_asset_id ? ' selected' : ''}>${escapeHtml(a.name)}</option>`
|
||||
).join('');
|
||||
const volPct = entry.volume ?? 100;
|
||||
return `
|
||||
<div class="notif-override-row">
|
||||
<input type="text" class="notif-override-name" data-idx="${i}" value="${escapeHtml(entry.app)}"
|
||||
placeholder="${t('color_strip.notification.app_overrides.app_placeholder') || 'App name'}">
|
||||
<button type="button" class="btn btn-icon btn-secondary notif-override-browse" data-idx="${i}"
|
||||
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
|
||||
<input type="color" class="notif-override-color" data-idx="${i}" value="${entry.color}">
|
||||
<button type="button" class="btn btn-icon btn-secondary"
|
||||
onclick="notificationRemoveAppOverride(${i})">✕</button>
|
||||
<select class="notif-override-sound" data-idx="${i}">${soundOpts}</select>
|
||||
<input type="range" class="notif-override-volume" data-idx="${i}" min="0" max="100" step="5" value="${volPct}"
|
||||
title="${volPct}%"
|
||||
oninput="this.title = this.value + '%'">
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Wire browse buttons
|
||||
list.querySelectorAll<HTMLButtonElement>('.notif-override-browse').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const idx = parseInt(btn.dataset.idx!);
|
||||
const nameInput = list.querySelector<HTMLInputElement>(`.notif-app-name[data-idx="${idx}"]`);
|
||||
const nameInput = list.querySelector<HTMLInputElement>(`.notif-override-name[data-idx="${idx}"]`);
|
||||
if (!nameInput) return;
|
||||
const picked = await NotificationAppPalette.pick({
|
||||
current: nameInput.value,
|
||||
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps...',
|
||||
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps…',
|
||||
});
|
||||
if (picked !== undefined) {
|
||||
nameInput.value = picked;
|
||||
_notificationAppColorsSyncFromDom();
|
||||
_overridesSyncFromDom();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Wire EntitySelects for sound dropdowns
|
||||
list.querySelectorAll<HTMLSelectElement>('.notif-override-sound').forEach(sel => {
|
||||
const items = _getSoundAssetItems();
|
||||
if (items.length > 0) {
|
||||
const es = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => _getSoundAssetItems(),
|
||||
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
|
||||
});
|
||||
_overrideEntitySelects.push(es);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function notificationAddAppColor() {
|
||||
_notificationAppColorsSyncFromDom();
|
||||
_notificationAppColors.push({ app: '', color: '#ffffff' });
|
||||
_notificationAppColorsRenderList();
|
||||
export function notificationAddAppOverride() {
|
||||
_overridesSyncFromDom();
|
||||
_notificationAppOverrides.push({ app: '', color: '#ffffff', sound_asset_id: null, volume: 100 });
|
||||
_overridesRenderList();
|
||||
}
|
||||
|
||||
export function notificationRemoveAppColor(i: number) {
|
||||
_notificationAppColorsSyncFromDom();
|
||||
_notificationAppColors.splice(i, 1);
|
||||
_notificationAppColorsRenderList();
|
||||
export function notificationRemoveAppOverride(i: number) {
|
||||
_overridesSyncFromDom();
|
||||
_notificationAppOverrides.splice(i, 1);
|
||||
_overridesRenderList();
|
||||
}
|
||||
|
||||
/** Split overrides into app_colors dict for the API. */
|
||||
export function notificationGetAppColorsDict() {
|
||||
_overridesSyncFromDom();
|
||||
const dict: Record<string, string> = {};
|
||||
for (const entry of _notificationAppOverrides) {
|
||||
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
/** Split overrides into app_sounds dict for the API. */
|
||||
export function notificationGetAppSoundsDict() {
|
||||
_overridesSyncFromDom();
|
||||
const dict: Record<string, any> = {};
|
||||
for (const entry of _notificationAppOverrides) {
|
||||
if (!entry.app.trim()) continue;
|
||||
if (entry.sound_asset_id || entry.volume !== 100) {
|
||||
dict[entry.app.trim()] = {
|
||||
sound_asset_id: entry.sound_asset_id || null,
|
||||
volume: (entry.volume ?? 100) / 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
/* ── Notification sound — global EntitySelect ─────────────────── */
|
||||
|
||||
let _notifSoundEntitySelect: EntitySelect | null = null;
|
||||
|
||||
function _populateSoundOptions(sel: HTMLSelectElement, selectedId?: string | null) {
|
||||
const sounds = _cachedAssets.filter(a => a.asset_type === 'sound');
|
||||
sel.innerHTML = `<option value="">${t('color_strip.notification.sound.none')}</option>` +
|
||||
sounds.map(a =>
|
||||
`<option value="${a.id}"${a.id === selectedId ? ' selected' : ''}>${escapeHtml(a.name)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
export function ensureNotifSoundEntitySelect() {
|
||||
const sel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
_populateSoundOptions(sel);
|
||||
if (_notifSoundEntitySelect) _notifSoundEntitySelect.destroy();
|
||||
const items = _getSoundAssetItems();
|
||||
if (items.length > 0) {
|
||||
_notifSoundEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => _getSoundAssetItems(),
|
||||
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Test notification ────────────────────────────────────────── */
|
||||
|
||||
export async function testNotification(sourceId: string) {
|
||||
try {
|
||||
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
|
||||
@@ -194,29 +316,24 @@ async function _loadNotificationHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
function _notificationAppColorsSyncFromDom() {
|
||||
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
|
||||
if (!list) return;
|
||||
const names = list.querySelectorAll<HTMLInputElement>('.notif-app-name');
|
||||
const colors = list.querySelectorAll<HTMLInputElement>('.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;
|
||||
}
|
||||
}
|
||||
/* ── Load / Reset state ───────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Merge app_colors and app_sounds dicts into unified overrides list.
|
||||
* app_colors: {app: color}
|
||||
* app_sounds: {app: {sound_asset_id, volume}}
|
||||
*/
|
||||
function _mergeOverrides(appColors: Record<string, string>, appSounds: Record<string, any>): AppOverride[] {
|
||||
const appNames = new Set([...Object.keys(appColors), ...Object.keys(appSounds)]);
|
||||
return [...appNames].map(app => ({
|
||||
app,
|
||||
color: appColors[app] || '#ffffff',
|
||||
sound_asset_id: appSounds[app]?.sound_asset_id || null,
|
||||
volume: Math.round((appSounds[app]?.volume ?? 1.0) * 100),
|
||||
}));
|
||||
}
|
||||
|
||||
export function notificationGetAppColorsDict() {
|
||||
_notificationAppColorsSyncFromDom();
|
||||
const dict: Record<string, any> = {};
|
||||
for (const entry of _notificationAppColors) {
|
||||
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
export function loadNotificationState(css: any) {
|
||||
export async function loadNotificationState(css: any) {
|
||||
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener;
|
||||
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
|
||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
|
||||
@@ -230,15 +347,27 @@ export function loadNotificationState(css: any) {
|
||||
onNotificationFilterModeChange();
|
||||
_attachNotificationProcessPicker();
|
||||
|
||||
// App colors dict -> list
|
||||
const ac = css.app_colors || {};
|
||||
_notificationAppColors = Object.entries(ac).map(([app, color]) => ({ app, color }));
|
||||
_notificationAppColorsRenderList();
|
||||
// Ensure assets are loaded before populating sound dropdowns
|
||||
await assetsCache.fetch();
|
||||
|
||||
// Sound (global)
|
||||
const soundSel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement;
|
||||
_populateSoundOptions(soundSel, css.sound_asset_id);
|
||||
if (soundSel) soundSel.value = css.sound_asset_id || '';
|
||||
ensureNotifSoundEntitySelect();
|
||||
if (_notifSoundEntitySelect && css.sound_asset_id) _notifSoundEntitySelect.setValue(css.sound_asset_id);
|
||||
const volPct = Math.round((css.sound_volume ?? 1.0) * 100);
|
||||
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = volPct as any;
|
||||
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = `${volPct}%`;
|
||||
|
||||
// Unified per-app overrides (merge app_colors + app_sounds)
|
||||
_notificationAppOverrides = _mergeOverrides(css.app_colors || {}, css.app_sounds || {});
|
||||
_overridesRenderList();
|
||||
|
||||
showNotificationEndpoint(css.id);
|
||||
}
|
||||
|
||||
export function resetNotificationState() {
|
||||
export async function resetNotificationState() {
|
||||
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = true;
|
||||
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
|
||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
|
||||
@@ -250,8 +379,19 @@ export function resetNotificationState() {
|
||||
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
|
||||
onNotificationFilterModeChange();
|
||||
_attachNotificationProcessPicker();
|
||||
_notificationAppColors = [];
|
||||
_notificationAppColorsRenderList();
|
||||
|
||||
// Sound reset
|
||||
const soundSel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement;
|
||||
_populateSoundOptions(soundSel);
|
||||
if (soundSel) soundSel.value = '';
|
||||
ensureNotifSoundEntitySelect();
|
||||
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = 100 as any;
|
||||
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = '100%';
|
||||
|
||||
// Clear overrides
|
||||
_notificationAppOverrides = [];
|
||||
_overridesRenderList();
|
||||
|
||||
showNotificationEndpoint(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
|
||||
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
|
||||
import { notificationGetAppColorsDict } from './color-strips-notification.ts';
|
||||
import { notificationGetAppColorsDict, notificationGetAppSoundsDict } from './color-strips-notification.ts';
|
||||
|
||||
/* ── Preview config builder ───────────────────────────────────── */
|
||||
|
||||
@@ -54,6 +54,9 @@ function _collectPreviewConfig() {
|
||||
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||
app_filter_list: filterList,
|
||||
app_colors: notificationGetAppColorsDict(),
|
||||
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
|
||||
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
|
||||
app_sounds: notificationGetAppSoundsDict(),
|
||||
};
|
||||
}
|
||||
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
|
||||
|
||||
@@ -34,17 +34,19 @@ import {
|
||||
} from './color-strips-composite.ts';
|
||||
import {
|
||||
ensureNotificationEffectIconSelect, ensureNotificationFilterModeIconSelect,
|
||||
onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor,
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppOverride, notificationRemoveAppOverride,
|
||||
notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides,
|
||||
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
notificationGetAppColorsDict, loadNotificationState, resetNotificationState, showNotificationEndpoint,
|
||||
notificationGetRawAppColors,
|
||||
loadNotificationState, resetNotificationState, showNotificationEndpoint,
|
||||
} from './color-strips-notification.ts';
|
||||
|
||||
// Re-export for app.js window global bindings
|
||||
export { gradientInit, gradientRenderAll, gradientAddStop };
|
||||
export { compositeAddLayer, compositeRemoveLayer };
|
||||
export {
|
||||
onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor,
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppOverride, notificationRemoveAppOverride,
|
||||
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
};
|
||||
export { _getAnimationPayload, _colorCycleGetColors };
|
||||
@@ -97,7 +99,7 @@ class CSSEditorModal extends Modal {
|
||||
notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
||||
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
|
||||
notification_app_colors: JSON.stringify(notificationGetRawAppColors()),
|
||||
notification_app_overrides: JSON.stringify(notificationGetRawAppOverrides()),
|
||||
clock_id: (document.getElementById('css-editor-clock') as HTMLInputElement).value,
|
||||
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
|
||||
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
|
||||
@@ -1473,6 +1475,9 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||
app_filter_list: filterList,
|
||||
app_colors: notificationGetAppColorsDict(),
|
||||
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
|
||||
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
|
||||
app_sounds: notificationGetAppSoundsDict(),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
currentTestingTemplate, setCurrentTestingTemplate,
|
||||
_currentTestStreamId, set_currentTestStreamId,
|
||||
_currentTestPPTemplateId, set_currentTestPPTemplateId,
|
||||
_lastValidatedImageSource, set_lastValidatedImageSource,
|
||||
_cachedAudioSources,
|
||||
_cachedValueSources,
|
||||
_cachedSyncClocks,
|
||||
@@ -35,7 +34,7 @@ import {
|
||||
_sourcesLoading, set_sourcesLoading,
|
||||
apiKey,
|
||||
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, filtersCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, assetsCache, _cachedAssets, filtersCache,
|
||||
colorStripSourcesCache,
|
||||
csptCache, stripFiltersCache,
|
||||
gradientsCache, GradientEntity,
|
||||
@@ -51,6 +50,7 @@ import { updateSubTabHash } from './tabs.ts';
|
||||
import { createValueSourceCard } from './value-sources.ts';
|
||||
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||
import { createAssetCard, initAssetDelegation } from './assets.ts';
|
||||
import { createColorStripCard } from './color-strips.ts';
|
||||
import { initAudioSourceDelegation } from './audio-sources.ts';
|
||||
import {
|
||||
@@ -58,7 +58,8 @@ import {
|
||||
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
|
||||
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
|
||||
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_ACTIVITY,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, ICON_ASSET,
|
||||
getAssetTypeIcon,
|
||||
} from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
|
||||
@@ -97,8 +98,61 @@ const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon:
|
||||
const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }];
|
||||
const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }];
|
||||
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
|
||||
const _assetDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('assets', assetsCache, 'asset.deleted') }];
|
||||
const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
|
||||
|
||||
/** Resolve an asset ID to its display name. */
|
||||
function _getAssetName(assetId?: string | null): string {
|
||||
if (!assetId) return '—';
|
||||
const asset = _cachedAssets.find((a: any) => a.id === assetId);
|
||||
return asset ? asset.name : assetId;
|
||||
}
|
||||
|
||||
/** Get EntitySelect items for a given asset type (image/video). */
|
||||
function _getAssetItems(assetType: string) {
|
||||
return _cachedAssets
|
||||
.filter((a: any) => a.asset_type === assetType)
|
||||
.map((a: any) => ({ value: a.id, label: a.name, icon: getAssetTypeIcon(assetType), desc: a.filename }));
|
||||
}
|
||||
|
||||
let _imageAssetEntitySelect: EntitySelect | null = null;
|
||||
let _videoAssetEntitySelect: EntitySelect | null = null;
|
||||
|
||||
function _ensureImageAssetEntitySelect() {
|
||||
const sel = document.getElementById('stream-image-asset') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
if (_imageAssetEntitySelect) _imageAssetEntitySelect.destroy();
|
||||
_imageAssetEntitySelect = null;
|
||||
const items = _getAssetItems('image');
|
||||
sel.innerHTML = `<option value="">${t('streams.image_asset.select')}</option>` +
|
||||
items.map(a => `<option value="${a.value}">${escapeHtml(a.label)}</option>`).join('');
|
||||
_imageAssetEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => _getAssetItems('image'),
|
||||
placeholder: t('streams.image_asset.search') || 'Search image assets…',
|
||||
});
|
||||
}
|
||||
|
||||
function _ensureVideoAssetEntitySelect() {
|
||||
const sel = document.getElementById('stream-video-asset') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
if (_videoAssetEntitySelect) _videoAssetEntitySelect.destroy();
|
||||
_videoAssetEntitySelect = null;
|
||||
const items = _getAssetItems('video');
|
||||
sel.innerHTML = `<option value="">${t('streams.video_asset.select')}</option>` +
|
||||
items.map(a => `<option value="${a.value}">${escapeHtml(a.label)}</option>`).join('');
|
||||
_videoAssetEntitySelect = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: () => _getAssetItems('video'),
|
||||
placeholder: t('streams.video_asset.search') || 'Search video assets…',
|
||||
});
|
||||
}
|
||||
|
||||
function _destroyAssetEntitySelects() {
|
||||
if (_imageAssetEntitySelect) { _imageAssetEntitySelect.destroy(); _imageAssetEntitySelect = null; }
|
||||
if (_videoAssetEntitySelect) { _videoAssetEntitySelect.destroy(); _videoAssetEntitySelect = null; }
|
||||
}
|
||||
|
||||
// ── Card section instances ──
|
||||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction });
|
||||
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates', bulkActions: _captureTemplateDeleteAction });
|
||||
@@ -114,6 +168,7 @@ const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.secti
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
|
||||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
|
||||
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
|
||||
const csAssets = new CardSection('assets', { titleKey: 'asset.group.title', gridClass: 'templates-grid', addCardOnclick: "showAssetUploadModal()", keyAttr: 'data-id', emptyKey: 'section.empty.assets', bulkActions: _assetDeleteAction });
|
||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
|
||||
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
|
||||
const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction });
|
||||
@@ -137,13 +192,14 @@ class StreamEditorModal extends Modal {
|
||||
targetFps: (document.getElementById('stream-target-fps') as HTMLInputElement).value,
|
||||
source: (document.getElementById('stream-source') as HTMLSelectElement).value,
|
||||
ppTemplate: (document.getElementById('stream-pp-template') as HTMLSelectElement).value,
|
||||
imageSource: (document.getElementById('stream-image-source') as HTMLInputElement).value,
|
||||
imageAsset: (document.getElementById('stream-image-asset') as HTMLSelectElement).value,
|
||||
tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
|
||||
_destroyAssetEntitySelects();
|
||||
(document.getElementById('stream-type') as HTMLSelectElement).disabled = false;
|
||||
set_streamNameManuallyEdited(false);
|
||||
}
|
||||
@@ -226,6 +282,7 @@ export async function loadPictureSources() {
|
||||
valueSourcesCache.fetch(),
|
||||
syncClocksCache.fetch(),
|
||||
weatherSourcesCache.fetch(),
|
||||
assetsCache.fetch(),
|
||||
audioTemplatesCache.fetch(),
|
||||
colorStripSourcesCache.fetch(),
|
||||
csptCache.fetch(),
|
||||
@@ -316,16 +373,15 @@ const PICTURE_SOURCE_CARD_RENDERERS: Record<string, StreamCardRenderer> = {
|
||||
</div>`;
|
||||
},
|
||||
static_image: (stream) => {
|
||||
const src = stream.image_source || '';
|
||||
const assetName = _getAssetName(stream.image_asset_id);
|
||||
return `<div class="stream-card-props">
|
||||
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">${ICON_WEB} ${escapeHtml(src)}</span>
|
||||
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span>
|
||||
</div>`;
|
||||
},
|
||||
video: (stream) => {
|
||||
const url = stream.url || '';
|
||||
const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url;
|
||||
const assetName = _getAssetName(stream.video_asset_id);
|
||||
return `<div class="stream-card-props">
|
||||
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(url)}">${ICON_WEB} ${escapeHtml(shortUrl)}</span>
|
||||
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span>
|
||||
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
|
||||
${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''}
|
||||
${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''}
|
||||
@@ -510,6 +566,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||||
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
||||
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
|
||||
{ key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length },
|
||||
];
|
||||
|
||||
// Build tree navigation structure
|
||||
@@ -563,6 +620,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
{ key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
|
||||
{ key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length },
|
||||
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
|
||||
{ key: 'assets', titleKey: 'streams.group.assets', icon: ICON_ASSET, count: _cachedAssets.length },
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -723,6 +781,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||||
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
|
||||
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
|
||||
const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) })));
|
||||
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
||||
|
||||
if (csRawStreams.isMounted()) {
|
||||
@@ -742,6 +801,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
value: _cachedValueSources.length,
|
||||
sync: _cachedSyncClocks.length,
|
||||
weather: _cachedWeatherSources.length,
|
||||
assets: _cachedAssets.length,
|
||||
});
|
||||
csRawStreams.reconcile(rawStreamItems);
|
||||
csRawTemplates.reconcile(rawTemplateItems);
|
||||
@@ -759,6 +819,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
csValueSources.reconcile(valueItems);
|
||||
csSyncClocks.reconcile(syncClockItems);
|
||||
csWeatherSources.reconcile(weatherSourceItems);
|
||||
csAssets.reconcile(assetItems);
|
||||
} else {
|
||||
// First render: build full HTML
|
||||
const panels = tabs.map(tab => {
|
||||
@@ -777,18 +838,20 @@ function renderPictureSourcesList(streams: any) {
|
||||
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
||||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||||
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
||||
else if (tab.key === 'assets') panelContent = csAssets.render(assetItems);
|
||||
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
||||
else panelContent = csStaticStreams.render(staticItems);
|
||||
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = panels;
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources]);
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csAssets]);
|
||||
|
||||
// Event delegation for card actions (replaces inline onclick handlers)
|
||||
initSyncClockDelegation(container);
|
||||
initWeatherSourceDelegation(container);
|
||||
initAudioSourceDelegation(container);
|
||||
initAssetDelegation(container);
|
||||
|
||||
// Render tree sidebar with expand/collapse buttons
|
||||
_streamsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||
@@ -806,6 +869,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
'value-sources': 'value',
|
||||
'sync-clocks': 'sync',
|
||||
'weather-sources': 'weather',
|
||||
'assets': 'assets',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -867,14 +931,8 @@ export async function showAddStreamModal(presetType: any, cloneData: any = null)
|
||||
document.getElementById('stream-display-picker-label')!.textContent = t('displays.picker.select');
|
||||
document.getElementById('stream-error')!.style.display = 'none';
|
||||
(document.getElementById('stream-type') as HTMLSelectElement).value = streamType;
|
||||
set_lastValidatedImageSource('');
|
||||
const imgSrcInput = document.getElementById('stream-image-source') as HTMLInputElement;
|
||||
imgSrcInput.value = '';
|
||||
document.getElementById('stream-image-preview-container')!.style.display = 'none';
|
||||
document.getElementById('stream-image-validation-status')!.style.display = 'none';
|
||||
imgSrcInput.onblur = () => validateStaticImage();
|
||||
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
|
||||
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
|
||||
_ensureImageAssetEntitySelect();
|
||||
_ensureVideoAssetEntitySelect();
|
||||
onStreamTypeChange();
|
||||
|
||||
set_streamNameManuallyEdited(!!cloneData);
|
||||
@@ -906,10 +964,15 @@ export async function showAddStreamModal(presetType: any, cloneData: any = null)
|
||||
(document.getElementById('stream-source') as HTMLSelectElement).value = cloneData.source_stream_id || '';
|
||||
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = cloneData.postprocessing_template_id || '';
|
||||
} else if (streamType === 'static_image') {
|
||||
(document.getElementById('stream-image-source') as HTMLInputElement).value = cloneData.image_source || '';
|
||||
if (cloneData.image_source) validateStaticImage();
|
||||
if (cloneData.image_asset_id) {
|
||||
(document.getElementById('stream-image-asset') as HTMLSelectElement).value = cloneData.image_asset_id;
|
||||
if (_imageAssetEntitySelect) _imageAssetEntitySelect.setValue(cloneData.image_asset_id);
|
||||
}
|
||||
} else if (streamType === 'video') {
|
||||
(document.getElementById('stream-video-url') as HTMLInputElement).value = cloneData.url || '';
|
||||
if (cloneData.video_asset_id) {
|
||||
(document.getElementById('stream-video-asset') as HTMLSelectElement).value = cloneData.video_asset_id;
|
||||
if (_videoAssetEntitySelect) _videoAssetEntitySelect.setValue(cloneData.video_asset_id);
|
||||
}
|
||||
(document.getElementById('stream-video-loop') as HTMLInputElement).checked = cloneData.loop !== false;
|
||||
(document.getElementById('stream-video-speed') as HTMLInputElement).value = cloneData.playback_speed || 1.0;
|
||||
const cloneSpeedLabel = document.getElementById('stream-video-speed-value');
|
||||
@@ -951,13 +1014,8 @@ export async function editStream(streamId: any) {
|
||||
(document.getElementById('stream-description') as HTMLInputElement).value = stream.description || '';
|
||||
|
||||
(document.getElementById('stream-type') as HTMLSelectElement).value = stream.stream_type;
|
||||
set_lastValidatedImageSource('');
|
||||
const imgSrcInput = document.getElementById('stream-image-source') as HTMLInputElement;
|
||||
document.getElementById('stream-image-preview-container')!.style.display = 'none';
|
||||
document.getElementById('stream-image-validation-status')!.style.display = 'none';
|
||||
imgSrcInput.onblur = () => validateStaticImage();
|
||||
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
|
||||
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
|
||||
_ensureImageAssetEntitySelect();
|
||||
_ensureVideoAssetEntitySelect();
|
||||
onStreamTypeChange();
|
||||
|
||||
await populateStreamModalDropdowns();
|
||||
@@ -976,10 +1034,15 @@ export async function editStream(streamId: any) {
|
||||
(document.getElementById('stream-source') as HTMLSelectElement).value = stream.source_stream_id || '';
|
||||
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = stream.postprocessing_template_id || '';
|
||||
} else if (stream.stream_type === 'static_image') {
|
||||
(document.getElementById('stream-image-source') as HTMLInputElement).value = stream.image_source || '';
|
||||
if (stream.image_source) validateStaticImage();
|
||||
if (stream.image_asset_id) {
|
||||
(document.getElementById('stream-image-asset') as HTMLSelectElement).value = stream.image_asset_id;
|
||||
if (_imageAssetEntitySelect) _imageAssetEntitySelect.setValue(stream.image_asset_id);
|
||||
}
|
||||
} else if (stream.stream_type === 'video') {
|
||||
(document.getElementById('stream-video-url') as HTMLInputElement).value = stream.url || '';
|
||||
if (stream.video_asset_id) {
|
||||
(document.getElementById('stream-video-asset') as HTMLSelectElement).value = stream.video_asset_id;
|
||||
if (_videoAssetEntitySelect) _videoAssetEntitySelect.setValue(stream.video_asset_id);
|
||||
}
|
||||
(document.getElementById('stream-video-loop') as HTMLInputElement).checked = stream.loop !== false;
|
||||
(document.getElementById('stream-video-speed') as HTMLInputElement).value = stream.playback_speed || 1.0;
|
||||
const speedLabel = document.getElementById('stream-video-speed-value');
|
||||
@@ -1164,13 +1227,13 @@ export async function saveStream() {
|
||||
payload.source_stream_id = (document.getElementById('stream-source') as HTMLSelectElement).value;
|
||||
payload.postprocessing_template_id = (document.getElementById('stream-pp-template') as HTMLSelectElement).value;
|
||||
} else if (streamType === 'static_image') {
|
||||
const imageSource = (document.getElementById('stream-image-source') as HTMLInputElement).value.trim();
|
||||
if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; }
|
||||
payload.image_source = imageSource;
|
||||
const imageAssetId = (document.getElementById('stream-image-asset') as HTMLSelectElement).value;
|
||||
if (!imageAssetId) { showToast(t('streams.error.required'), 'error'); return; }
|
||||
payload.image_asset_id = imageAssetId;
|
||||
} else if (streamType === 'video') {
|
||||
const url = (document.getElementById('stream-video-url') as HTMLInputElement).value.trim();
|
||||
if (!url) { showToast(t('streams.error.required'), 'error'); return; }
|
||||
payload.url = url;
|
||||
const videoAssetId = (document.getElementById('stream-video-asset') as HTMLSelectElement).value;
|
||||
if (!videoAssetId) { showToast(t('streams.error.required'), 'error'); return; }
|
||||
payload.video_asset_id = videoAssetId;
|
||||
payload.loop = (document.getElementById('stream-video-loop') as HTMLInputElement).checked;
|
||||
payload.playback_speed = parseFloat((document.getElementById('stream-video-speed') as HTMLInputElement).value) || 1.0;
|
||||
payload.target_fps = parseInt((document.getElementById('stream-video-fps') as HTMLInputElement).value) || 30;
|
||||
@@ -1239,55 +1302,6 @@ export async function closeStreamModal() {
|
||||
await streamModal.close();
|
||||
}
|
||||
|
||||
async function validateStaticImage() {
|
||||
const source = (document.getElementById('stream-image-source') as HTMLInputElement).value.trim();
|
||||
const previewContainer = document.getElementById('stream-image-preview-container')!;
|
||||
const previewImg = document.getElementById('stream-image-preview') as HTMLImageElement;
|
||||
const infoEl = document.getElementById('stream-image-info')!;
|
||||
const statusEl = document.getElementById('stream-image-validation-status')!;
|
||||
|
||||
if (!source) {
|
||||
set_lastValidatedImageSource('');
|
||||
previewContainer.style.display = 'none';
|
||||
statusEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === _lastValidatedImageSource) return;
|
||||
|
||||
statusEl.textContent = t('streams.validate_image.validating');
|
||||
statusEl.className = 'validation-status loading';
|
||||
statusEl.style.display = 'block';
|
||||
previewContainer.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/picture-sources/validate-image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ image_source: source }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
set_lastValidatedImageSource(source);
|
||||
if (data.valid) {
|
||||
previewImg.src = data.preview;
|
||||
previewImg.style.cursor = 'pointer';
|
||||
previewImg.onclick = () => openFullImageLightbox(source);
|
||||
infoEl.textContent = `${data.width} × ${data.height} px`;
|
||||
previewContainer.style.display = '';
|
||||
statusEl.textContent = t('streams.validate_image.valid');
|
||||
statusEl.className = 'validation-status success';
|
||||
} else {
|
||||
previewContainer.style.display = 'none';
|
||||
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
|
||||
statusEl.className = 'validation-status error';
|
||||
}
|
||||
} catch (err) {
|
||||
previewContainer.style.display = 'none';
|
||||
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
|
||||
statusEl.className = 'validation-status error';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Picture Source Test =====
|
||||
|
||||
export async function showTestStreamModal(streamId: any) {
|
||||
|
||||
@@ -266,8 +266,8 @@ interface Window {
|
||||
previewCSSFromEditor: (...args: any[]) => any;
|
||||
copyEndpointUrl: (...args: any[]) => any;
|
||||
onNotificationFilterModeChange: (...args: any[]) => any;
|
||||
notificationAddAppColor: (...args: any[]) => any;
|
||||
notificationRemoveAppColor: (...args: any[]) => any;
|
||||
notificationAddAppOverride: (...args: any[]) => any;
|
||||
notificationRemoveAppOverride: (...args: any[]) => any;
|
||||
testNotification: (...args: any[]) => any;
|
||||
showNotificationHistory: (...args: any[]) => any;
|
||||
closeNotificationHistory: (...args: any[]) => any;
|
||||
|
||||
@@ -345,10 +345,10 @@ export interface PictureSource {
|
||||
postprocessing_template_id?: string;
|
||||
|
||||
// Static image
|
||||
image_source?: string;
|
||||
image_asset_id?: string;
|
||||
|
||||
// Video
|
||||
url?: string;
|
||||
video_asset_id?: string;
|
||||
loop?: boolean;
|
||||
playback_speed?: number;
|
||||
start_time?: number;
|
||||
@@ -413,6 +413,27 @@ export interface WeatherSourceListResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ── Asset ────────────────────────────────────────────────────
|
||||
|
||||
export interface Asset {
|
||||
id: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
asset_type: string;
|
||||
size_bytes: number;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
prebuilt: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AssetListResponse {
|
||||
assets: Asset[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ── Automation ────────────────────────────────────────────────
|
||||
|
||||
export type ConditionType =
|
||||
|
||||
@@ -593,22 +593,19 @@
|
||||
"streams.add.video": "Add Video Source",
|
||||
"streams.edit.video": "Edit Video Source",
|
||||
"picture_source.type.video": "Video",
|
||||
"picture_source.type.video.desc": "Stream frames from video file, URL, or YouTube",
|
||||
"picture_source.video.url": "Video URL:",
|
||||
"picture_source.video.url.hint": "Local file path, HTTP URL, or YouTube URL",
|
||||
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
|
||||
"picture_source.type.video.desc": "Stream frames from an uploaded video asset",
|
||||
"picture_source.video.loop": "Loop:",
|
||||
"picture_source.video.speed": "Playback Speed:",
|
||||
"picture_source.video.start_time": "Start Time (s):",
|
||||
"picture_source.video.end_time": "End Time (s):",
|
||||
"picture_source.video.resolution_limit": "Max Width (px):",
|
||||
"picture_source.video.resolution_limit.hint": "Downscale video at decode time for performance",
|
||||
"streams.image_source": "Image Source:",
|
||||
"streams.image_source.placeholder": "https://example.com/image.jpg or C:\\path\\to\\image.png",
|
||||
"streams.image_source.hint": "Enter a URL (http/https) or local file path to an image",
|
||||
"streams.validate_image.validating": "Validating...",
|
||||
"streams.validate_image.valid": "Image accessible",
|
||||
"streams.validate_image.invalid": "Image not accessible",
|
||||
"streams.image_asset": "Image Asset:",
|
||||
"streams.image_asset.select": "Select image asset…",
|
||||
"streams.image_asset.search": "Search image assets…",
|
||||
"streams.video_asset": "Video Asset:",
|
||||
"streams.video_asset.select": "Select video asset…",
|
||||
"streams.video_asset.search": "Search video assets…",
|
||||
"targets.title": "Targets",
|
||||
"targets.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.",
|
||||
"targets.subtab.wled": "LED",
|
||||
@@ -1105,6 +1102,22 @@
|
||||
"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.app_overrides": "Per-App Overrides",
|
||||
"color_strip.notification.app_overrides.label": "App Overrides:",
|
||||
"color_strip.notification.app_overrides.hint": "Per-app overrides for color and sound. Each row can set a custom color, sound asset, and volume for a specific app.",
|
||||
"color_strip.notification.app_overrides.add": "+ Add Override",
|
||||
"color_strip.notification.app_overrides.app_placeholder": "App name",
|
||||
"color_strip.notification.sound": "Sound",
|
||||
"color_strip.notification.sound.asset": "Sound Asset:",
|
||||
"color_strip.notification.sound.asset.hint": "Pick a sound asset to play when a notification fires. Leave empty for silent.",
|
||||
"color_strip.notification.sound.none": "None (silent)",
|
||||
"color_strip.notification.sound.search": "Search sounds…",
|
||||
"color_strip.notification.sound.volume": "Volume:",
|
||||
"color_strip.notification.sound.volume.hint": "Global volume for notification sounds (0–100%).",
|
||||
"color_strip.notification.sound.app_sounds": "Per-App Sounds:",
|
||||
"color_strip.notification.sound.app_sounds.hint": "Override sound and volume for specific apps. Empty sound = mute that app.",
|
||||
"color_strip.notification.sound.app_sounds.add": "+ Add Override",
|
||||
"color_strip.notification.sound.app_name_placeholder": "App name",
|
||||
"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.",
|
||||
@@ -1963,9 +1976,35 @@
|
||||
"update.install_type.docker": "Docker",
|
||||
"update.install_type.dev": "Development",
|
||||
|
||||
"color_strip": {
|
||||
"notification": {
|
||||
"search_apps": "Search notification apps…"
|
||||
}
|
||||
}
|
||||
"color_strip.notification.search_apps": "Search notification apps…",
|
||||
|
||||
"asset.group.title": "Assets",
|
||||
"asset.upload": "Upload Asset",
|
||||
"asset.edit": "Edit Asset",
|
||||
"asset.name": "Name:",
|
||||
"asset.name.hint": "Display name for this asset.",
|
||||
"asset.description": "Description:",
|
||||
"asset.description.hint": "Optional description for this asset.",
|
||||
"asset.file": "File:",
|
||||
"asset.file.hint": "Select a file to upload (sound, image, video, or other).",
|
||||
"asset.drop_or_browse": "Drop file here or click to browse",
|
||||
"asset.uploaded": "Asset uploaded",
|
||||
"asset.updated": "Asset updated",
|
||||
"asset.deleted": "Asset deleted",
|
||||
"asset.confirm_delete": "Delete this asset?",
|
||||
"asset.error.name_required": "Name is required",
|
||||
"asset.error.no_file": "Please select a file to upload",
|
||||
"asset.error.delete_failed": "Failed to delete asset",
|
||||
"asset.play": "Play",
|
||||
"asset.download": "Download",
|
||||
"asset.prebuilt": "Prebuilt",
|
||||
"asset.prebuilt_restored": "{count} prebuilt asset(s) restored",
|
||||
"asset.prebuilt_none_to_restore": "All prebuilt assets are already available",
|
||||
"asset.restore_prebuilt": "Restore Prebuilt Sounds",
|
||||
"asset.type.sound": "Sound",
|
||||
"asset.type.image": "Image",
|
||||
"asset.type.video": "Video",
|
||||
"asset.type.other": "Other",
|
||||
"streams.group.assets": "Assets",
|
||||
"section.empty.assets": "No assets yet. Click + to upload one."
|
||||
}
|
||||
@@ -593,22 +593,19 @@
|
||||
"streams.add.video": "Добавить видеоисточник",
|
||||
"streams.edit.video": "Редактировать видеоисточник",
|
||||
"picture_source.type.video": "Видео",
|
||||
"picture_source.type.video.desc": "Потоковые кадры из видеофайла, URL или YouTube",
|
||||
"picture_source.video.url": "URL видео:",
|
||||
"picture_source.video.url.hint": "Локальный файл, HTTP URL или YouTube URL",
|
||||
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
|
||||
"picture_source.type.video.desc": "Потоковые кадры из загруженного видео",
|
||||
"picture_source.video.loop": "Зацикливание:",
|
||||
"picture_source.video.speed": "Скорость воспроизведения:",
|
||||
"picture_source.video.start_time": "Время начала (с):",
|
||||
"picture_source.video.end_time": "Время окончания (с):",
|
||||
"picture_source.video.resolution_limit": "Макс. ширина (px):",
|
||||
"picture_source.video.resolution_limit.hint": "Уменьшение видео при декодировании для производительности",
|
||||
"streams.image_source": "Источник изображения:",
|
||||
"streams.image_source.placeholder": "https://example.com/image.jpg или C:\\path\\to\\image.png",
|
||||
"streams.image_source.hint": "Введите URL (http/https) или локальный путь к изображению",
|
||||
"streams.validate_image.validating": "Проверка...",
|
||||
"streams.validate_image.valid": "Изображение доступно",
|
||||
"streams.validate_image.invalid": "Изображение недоступно",
|
||||
"streams.image_asset": "Изображение:",
|
||||
"streams.image_asset.select": "Выберите изображение…",
|
||||
"streams.image_asset.search": "Поиск изображений…",
|
||||
"streams.video_asset": "Видео:",
|
||||
"streams.video_asset.select": "Выберите видео…",
|
||||
"streams.video_asset.search": "Поиск видео…",
|
||||
"targets.title": "Цели",
|
||||
"targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.",
|
||||
"targets.subtab.wled": "LED",
|
||||
@@ -1084,6 +1081,22 @@
|
||||
"color_strip.notification.app_colors.label": "Назначения цветов:",
|
||||
"color_strip.notification.app_colors.hint": "Индивидуальные цвета для приложений. Каждая строка связывает имя приложения с цветом уведомления.",
|
||||
"color_strip.notification.app_colors.add": "+ Добавить",
|
||||
"color_strip.notification.app_overrides": "Настройки приложений",
|
||||
"color_strip.notification.app_overrides.label": "Переопределения:",
|
||||
"color_strip.notification.app_overrides.hint": "Индивидуальные настройки цвета и звука для каждого приложения.",
|
||||
"color_strip.notification.app_overrides.add": "+ Добавить",
|
||||
"color_strip.notification.app_overrides.app_placeholder": "Имя приложения",
|
||||
"color_strip.notification.sound": "Звук",
|
||||
"color_strip.notification.sound.asset": "Звуковой ассет:",
|
||||
"color_strip.notification.sound.asset.hint": "Выберите звуковой ассет для воспроизведения при уведомлении. Оставьте пустым для тишины.",
|
||||
"color_strip.notification.sound.none": "Нет (без звука)",
|
||||
"color_strip.notification.sound.search": "Поиск звуков…",
|
||||
"color_strip.notification.sound.volume": "Громкость:",
|
||||
"color_strip.notification.sound.volume.hint": "Общая громкость звуков уведомлений (0–100%).",
|
||||
"color_strip.notification.sound.app_sounds": "Звуки приложений:",
|
||||
"color_strip.notification.sound.app_sounds.hint": "Переопределение звука и громкости для конкретных приложений. Пустой звук = отключить для этого приложения.",
|
||||
"color_strip.notification.sound.app_sounds.add": "+ Добавить",
|
||||
"color_strip.notification.sound.app_name_placeholder": "Имя приложения",
|
||||
"color_strip.notification.endpoint": "Вебхук:",
|
||||
"color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
|
||||
"color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.",
|
||||
@@ -1892,9 +1905,35 @@
|
||||
"update.install_type.docker": "Docker",
|
||||
"update.install_type.dev": "Разработка",
|
||||
|
||||
"color_strip": {
|
||||
"notification": {
|
||||
"search_apps": "Поиск приложений…"
|
||||
}
|
||||
}
|
||||
"color_strip.notification.search_apps": "Поиск приложений…",
|
||||
|
||||
"asset.group.title": "Ресурсы",
|
||||
"asset.upload": "Загрузить ресурс",
|
||||
"asset.edit": "Редактировать ресурс",
|
||||
"asset.name": "Название:",
|
||||
"asset.name.hint": "Отображаемое название ресурса.",
|
||||
"asset.description": "Описание:",
|
||||
"asset.description.hint": "Необязательное описание ресурса.",
|
||||
"asset.file": "Файл:",
|
||||
"asset.file.hint": "Выберите файл для загрузки (звук, изображение, видео или другое).",
|
||||
"asset.drop_or_browse": "Перетащите файл сюда или нажмите для выбора",
|
||||
"asset.uploaded": "Ресурс загружен",
|
||||
"asset.updated": "Ресурс обновлён",
|
||||
"asset.deleted": "Ресурс удалён",
|
||||
"asset.confirm_delete": "Удалить этот ресурс?",
|
||||
"asset.error.name_required": "Название обязательно",
|
||||
"asset.error.no_file": "Выберите файл для загрузки",
|
||||
"asset.error.delete_failed": "Не удалось удалить ресурс",
|
||||
"asset.play": "Воспроизвести",
|
||||
"asset.download": "Скачать",
|
||||
"asset.prebuilt": "Встроенный",
|
||||
"asset.prebuilt_restored": "Восстановлено встроенных ресурсов: {count}",
|
||||
"asset.prebuilt_none_to_restore": "Все встроенные ресурсы уже доступны",
|
||||
"asset.restore_prebuilt": "Восстановить встроенные звуки",
|
||||
"asset.type.sound": "Звук",
|
||||
"asset.type.image": "Изображение",
|
||||
"asset.type.video": "Видео",
|
||||
"asset.type.other": "Другое",
|
||||
"streams.group.assets": "Ресурсы",
|
||||
"section.empty.assets": "Ресурсов пока нет. Нажмите +, чтобы загрузить."
|
||||
}
|
||||
@@ -593,22 +593,19 @@
|
||||
"streams.add.video": "添加视频源",
|
||||
"streams.edit.video": "编辑视频源",
|
||||
"picture_source.type.video": "视频",
|
||||
"picture_source.type.video.desc": "从视频文件、URL或YouTube流式传输帧",
|
||||
"picture_source.video.url": "视频URL:",
|
||||
"picture_source.video.url.hint": "本地文件路径、HTTP URL或YouTube URL",
|
||||
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
|
||||
"picture_source.type.video.desc": "从上传的视频素材中流式传输帧",
|
||||
"picture_source.video.loop": "循环:",
|
||||
"picture_source.video.speed": "播放速度:",
|
||||
"picture_source.video.start_time": "开始时间(秒):",
|
||||
"picture_source.video.end_time": "结束时间(秒):",
|
||||
"picture_source.video.resolution_limit": "最大宽度(像素):",
|
||||
"picture_source.video.resolution_limit.hint": "解码时缩小视频以提高性能",
|
||||
"streams.image_source": "图片源:",
|
||||
"streams.image_source.placeholder": "https://example.com/image.jpg 或 C:\\path\\to\\image.png",
|
||||
"streams.image_source.hint": "输入图片的 URL(http/https)或本地文件路径",
|
||||
"streams.validate_image.validating": "正在验证...",
|
||||
"streams.validate_image.valid": "图片可访问",
|
||||
"streams.validate_image.invalid": "图片不可访问",
|
||||
"streams.image_asset": "图片素材:",
|
||||
"streams.image_asset.select": "选择图片素材…",
|
||||
"streams.image_asset.search": "搜索图片素材…",
|
||||
"streams.video_asset": "视频素材:",
|
||||
"streams.video_asset.select": "选择视频素材…",
|
||||
"streams.video_asset.search": "搜索视频素材…",
|
||||
"targets.title": "目标",
|
||||
"targets.description": "目标将色带源桥接到输出设备。每个目标引用一个设备和一个色带源。",
|
||||
"targets.subtab.wled": "LED",
|
||||
@@ -1084,6 +1081,22 @@
|
||||
"color_strip.notification.app_colors.label": "颜色映射:",
|
||||
"color_strip.notification.app_colors.hint": "每个应用的自定义通知颜色。每行将一个应用名称映射到特定颜色。",
|
||||
"color_strip.notification.app_colors.add": "+ 添加映射",
|
||||
"color_strip.notification.app_overrides": "按应用覆盖",
|
||||
"color_strip.notification.app_overrides.label": "应用覆盖:",
|
||||
"color_strip.notification.app_overrides.hint": "为特定应用自定义颜色和声音。每行可设置颜色、声音资源和音量。",
|
||||
"color_strip.notification.app_overrides.add": "+ 添加覆盖",
|
||||
"color_strip.notification.app_overrides.app_placeholder": "应用名称",
|
||||
"color_strip.notification.sound": "声音",
|
||||
"color_strip.notification.sound.asset": "声音资源:",
|
||||
"color_strip.notification.sound.asset.hint": "选择通知触发时播放的声音资源。留空表示静音。",
|
||||
"color_strip.notification.sound.none": "无(静音)",
|
||||
"color_strip.notification.sound.search": "搜索声音…",
|
||||
"color_strip.notification.sound.volume": "音量:",
|
||||
"color_strip.notification.sound.volume.hint": "通知声音的全局音量(0–100%)。",
|
||||
"color_strip.notification.sound.app_sounds": "按应用声音:",
|
||||
"color_strip.notification.sound.app_sounds.hint": "为特定应用覆盖声音和音量。空声音 = 静音该应用。",
|
||||
"color_strip.notification.sound.app_sounds.add": "+ 添加覆盖",
|
||||
"color_strip.notification.sound.app_name_placeholder": "应用名称",
|
||||
"color_strip.notification.endpoint": "Webhook 端点:",
|
||||
"color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON:{\"app\": \"AppName\", \"color\": \"#FF0000\"}。",
|
||||
"color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。",
|
||||
@@ -1890,9 +1903,35 @@
|
||||
"update.install_type.docker": "Docker",
|
||||
"update.install_type.dev": "开发环境",
|
||||
|
||||
"color_strip": {
|
||||
"notification": {
|
||||
"search_apps": "搜索通知应用…"
|
||||
}
|
||||
}
|
||||
"color_strip.notification.search_apps": "搜索通知应用…",
|
||||
|
||||
"asset.group.title": "资源",
|
||||
"asset.upload": "上传资源",
|
||||
"asset.edit": "编辑资源",
|
||||
"asset.name": "名称:",
|
||||
"asset.name.hint": "资源的显示名称。",
|
||||
"asset.description": "描述:",
|
||||
"asset.description.hint": "资源的可选描述。",
|
||||
"asset.file": "文件:",
|
||||
"asset.file.hint": "选择要上传的文件(声音、图片、视频或其他)。",
|
||||
"asset.drop_or_browse": "拖放文件到此处或点击浏览",
|
||||
"asset.uploaded": "资源已上传",
|
||||
"asset.updated": "资源已更新",
|
||||
"asset.deleted": "资源已删除",
|
||||
"asset.confirm_delete": "删除此资源?",
|
||||
"asset.error.name_required": "名称为必填项",
|
||||
"asset.error.no_file": "请选择要上传的文件",
|
||||
"asset.error.delete_failed": "删除资源失败",
|
||||
"asset.play": "播放",
|
||||
"asset.download": "下载",
|
||||
"asset.prebuilt": "内置",
|
||||
"asset.prebuilt_restored": "已恢复 {count} 个内置资源",
|
||||
"asset.prebuilt_none_to_restore": "所有内置资源均已可用",
|
||||
"asset.restore_prebuilt": "恢复内置声音",
|
||||
"asset.type.sound": "声音",
|
||||
"asset.type.image": "图片",
|
||||
"asset.type.video": "视频",
|
||||
"asset.type.other": "其他",
|
||||
"streams.group.assets": "资源",
|
||||
"section.empty.assets": "暂无资源。点击 + 上传一个。"
|
||||
}
|
||||
80
server/src/wled_controller/storage/asset.py
Normal file
80
server/src/wled_controller/storage/asset.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Asset data model.
|
||||
|
||||
An Asset represents an uploaded file (sound, image, video, or other)
|
||||
stored on the server. Assets are referenced by ID from other entities
|
||||
(e.g. NotificationColorStripSource uses sound assets for alert sounds).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
# Map MIME type prefixes to asset_type categories
|
||||
_MIME_TO_ASSET_TYPE = {
|
||||
"audio/": "sound",
|
||||
"image/": "image",
|
||||
"video/": "video",
|
||||
}
|
||||
|
||||
|
||||
def asset_type_from_mime(mime_type: str) -> str:
|
||||
"""Derive asset_type from a MIME type string."""
|
||||
for prefix, asset_type in _MIME_TO_ASSET_TYPE.items():
|
||||
if mime_type.startswith(prefix):
|
||||
return asset_type
|
||||
return "other"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Asset:
|
||||
"""Persistent metadata for an uploaded file asset."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
filename: str # original upload filename
|
||||
stored_filename: str # on-disk filename (uuid-based)
|
||||
mime_type: str # e.g. "audio/wav", "image/png"
|
||||
asset_type: str # "sound" | "image" | "video" | "other"
|
||||
size_bytes: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
prebuilt: bool = False # True for shipped assets
|
||||
deleted: bool = False # soft-delete for prebuilt assets
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"filename": self.filename,
|
||||
"stored_filename": self.stored_filename,
|
||||
"mime_type": self.mime_type,
|
||||
"asset_type": self.asset_type,
|
||||
"size_bytes": self.size_bytes,
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
"prebuilt": self.prebuilt,
|
||||
"deleted": self.deleted,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "Asset":
|
||||
return Asset(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
filename=data.get("filename", ""),
|
||||
stored_filename=data.get("stored_filename", ""),
|
||||
mime_type=data.get("mime_type", "application/octet-stream"),
|
||||
asset_type=data.get("asset_type", "other"),
|
||||
size_bytes=int(data.get("size_bytes", 0)),
|
||||
description=data.get("description"),
|
||||
tags=data.get("tags", []),
|
||||
prebuilt=bool(data.get("prebuilt", False)),
|
||||
deleted=bool(data.get("deleted", False)),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"]),
|
||||
)
|
||||
219
server/src/wled_controller/storage/asset_store.py
Normal file
219
server/src/wled_controller/storage/asset_store.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Asset storage with file management.
|
||||
|
||||
Metadata is stored in SQLite via BaseSqliteStore; actual files live
|
||||
in the assets directory on disk.
|
||||
"""
|
||||
|
||||
import mimetypes
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.storage.asset import Asset, asset_type_from_mime
|
||||
from wled_controller.storage.base_sqlite_store import BaseSqliteStore
|
||||
from wled_controller.storage.database import Database
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AssetStore(BaseSqliteStore[Asset]):
|
||||
"""Persistent storage for uploaded file assets."""
|
||||
|
||||
_table_name = "assets"
|
||||
_entity_name = "Asset"
|
||||
|
||||
def __init__(self, db: Database, assets_dir: str | Path):
|
||||
self._assets_dir = Path(assets_dir)
|
||||
self._assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
super().__init__(db, Asset.from_dict)
|
||||
|
||||
# -- Aliases for consistency with other stores ----------------------------
|
||||
|
||||
get_all_assets = BaseSqliteStore.get_all
|
||||
get_asset = BaseSqliteStore.get
|
||||
|
||||
def get_visible_assets(self) -> List[Asset]:
|
||||
"""Return all assets that are not soft-deleted."""
|
||||
return [a for a in self._items.values() if not a.deleted]
|
||||
|
||||
def get_assets_by_type(self, asset_type: str) -> List[Asset]:
|
||||
"""Return visible assets filtered by type."""
|
||||
return [a for a in self._items.values()
|
||||
if not a.deleted and a.asset_type == asset_type]
|
||||
|
||||
def get_file_path(self, asset_id: str) -> Optional[Path]:
|
||||
"""Resolve the on-disk path for an asset's file. Returns None if missing."""
|
||||
asset = self._items.get(asset_id)
|
||||
if asset is None or asset.deleted:
|
||||
return None
|
||||
path = self._assets_dir / asset.stored_filename
|
||||
return path if path.exists() else None
|
||||
|
||||
def create_asset(
|
||||
self,
|
||||
name: str,
|
||||
filename: str,
|
||||
file_data: bytes,
|
||||
mime_type: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
prebuilt: bool = False,
|
||||
) -> Asset:
|
||||
"""Create a new asset from uploaded file data.
|
||||
|
||||
Args:
|
||||
name: Display name for the asset.
|
||||
filename: Original upload filename.
|
||||
file_data: Raw file bytes.
|
||||
mime_type: MIME type (auto-detected from filename if not provided).
|
||||
description: Optional description.
|
||||
tags: Optional tags.
|
||||
prebuilt: Whether this is a shipped prebuilt asset.
|
||||
|
||||
Returns:
|
||||
The created Asset.
|
||||
"""
|
||||
self._check_name_unique(name)
|
||||
|
||||
if not mime_type:
|
||||
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
|
||||
asset_type = asset_type_from_mime(mime_type)
|
||||
|
||||
# Generate unique stored filename
|
||||
ext = Path(filename).suffix
|
||||
stored_filename = f"{uuid.uuid4().hex}{ext}"
|
||||
|
||||
# Write file to disk
|
||||
dest = self._assets_dir / stored_filename
|
||||
dest.write_bytes(file_data)
|
||||
|
||||
asset_id = f"asset_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
asset = Asset(
|
||||
id=asset_id,
|
||||
name=name,
|
||||
filename=filename,
|
||||
stored_filename=stored_filename,
|
||||
mime_type=mime_type,
|
||||
asset_type=asset_type,
|
||||
size_bytes=len(file_data),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
prebuilt=prebuilt,
|
||||
)
|
||||
|
||||
self._items[asset_id] = asset
|
||||
self._save_item(asset_id, asset)
|
||||
logger.info(f"Created asset: {name} ({asset_id}, type={asset_type}, {len(file_data)} bytes)")
|
||||
return asset
|
||||
|
||||
def update_asset(
|
||||
self,
|
||||
asset_id: str,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> Asset:
|
||||
"""Update asset metadata (not the file itself)."""
|
||||
asset = self.get(asset_id)
|
||||
|
||||
if name is not None:
|
||||
self._check_name_unique(name, exclude_id=asset_id)
|
||||
asset.name = name
|
||||
if description is not None:
|
||||
asset.description = description
|
||||
if tags is not None:
|
||||
asset.tags = tags
|
||||
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
self._save_item(asset_id, asset)
|
||||
logger.info(f"Updated asset: {asset_id}")
|
||||
return asset
|
||||
|
||||
def delete_asset(self, asset_id: str) -> None:
|
||||
"""Delete an asset. Prebuilt assets are soft-deleted; others are fully removed."""
|
||||
asset = self.get(asset_id)
|
||||
|
||||
if asset.prebuilt:
|
||||
# Soft-delete: mark as deleted, remove file but keep metadata
|
||||
asset.deleted = True
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
self._save_item(asset_id, asset)
|
||||
file_path = self._assets_dir / asset.stored_filename
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
logger.info(f"Soft-deleted prebuilt asset: {asset_id}")
|
||||
else:
|
||||
# Hard delete: remove file and metadata
|
||||
file_path = self._assets_dir / asset.stored_filename
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
del self._items[asset_id]
|
||||
self._delete_item(asset_id)
|
||||
logger.info(f"Deleted asset: {asset_id}")
|
||||
|
||||
def restore_prebuilt(self, prebuilt_dir: Path) -> List[Asset]:
|
||||
"""Re-import any soft-deleted prebuilt assets from the prebuilt directory.
|
||||
|
||||
Returns list of restored assets.
|
||||
"""
|
||||
restored = []
|
||||
for asset in list(self._items.values()):
|
||||
if asset.prebuilt and asset.deleted:
|
||||
# Find original file in prebuilt dir
|
||||
src = prebuilt_dir / asset.filename
|
||||
if src.exists():
|
||||
dest = self._assets_dir / asset.stored_filename
|
||||
shutil.copy2(src, dest)
|
||||
asset.deleted = False
|
||||
asset.size_bytes = dest.stat().st_size
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
self._save_item(asset.id, asset)
|
||||
restored.append(asset)
|
||||
logger.info(f"Restored prebuilt asset: {asset.name} ({asset.id})")
|
||||
return restored
|
||||
|
||||
def import_prebuilt_sounds(self, prebuilt_dir: Path) -> List[Asset]:
|
||||
"""Import prebuilt sound files that don't already exist as assets.
|
||||
|
||||
Called on startup. Skips files that are already imported (by original
|
||||
filename match with prebuilt=True), including soft-deleted ones.
|
||||
|
||||
Returns list of newly imported assets.
|
||||
"""
|
||||
if not prebuilt_dir.exists():
|
||||
return []
|
||||
|
||||
# Build set of known prebuilt filenames (including deleted ones)
|
||||
known_filenames = {
|
||||
a.filename for a in self._items.values() if a.prebuilt
|
||||
}
|
||||
|
||||
imported = []
|
||||
for src in sorted(prebuilt_dir.iterdir()):
|
||||
if not src.is_file():
|
||||
continue
|
||||
if src.name in known_filenames:
|
||||
continue
|
||||
|
||||
file_data = src.read_bytes()
|
||||
# Derive a friendly name from filename: "chime.wav" -> "Chime"
|
||||
friendly_name = src.stem.replace("_", " ").replace("-", " ").title()
|
||||
|
||||
asset = self.create_asset(
|
||||
name=friendly_name,
|
||||
filename=src.name,
|
||||
file_data=file_data,
|
||||
prebuilt=True,
|
||||
)
|
||||
imported.append(asset)
|
||||
logger.info(f"Imported prebuilt sound: {src.name} -> {asset.id}")
|
||||
|
||||
return imported
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Automation and Condition data models."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Condition:
|
||||
@@ -226,7 +229,8 @@ class Automation:
|
||||
for c_data in data.get("conditions", []):
|
||||
try:
|
||||
conditions.append(Condition.from_dict(c_data))
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.warning("Skipping unknown condition type on load: %s", e)
|
||||
pass # skip unknown condition types on load
|
||||
|
||||
return cls(
|
||||
|
||||
@@ -838,6 +838,9 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
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
|
||||
sound_asset_id: Optional[str] = None # global notification sound (asset ID)
|
||||
sound_volume: float = 1.0 # global volume 0.0-1.0
|
||||
app_sounds: dict = field(default_factory=dict) # app name -> {"sound_asset_id": str|None, "volume": float|None}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
@@ -848,6 +851,9 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
d["app_filter_mode"] = self.app_filter_mode
|
||||
d["app_filter_list"] = list(self.app_filter_list)
|
||||
d["os_listener"] = self.os_listener
|
||||
d["sound_asset_id"] = self.sound_asset_id
|
||||
d["sound_volume"] = self.sound_volume
|
||||
d["app_sounds"] = dict(self.app_sounds)
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -855,6 +861,7 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
common = _parse_css_common(data)
|
||||
raw_app_colors = data.get("app_colors")
|
||||
raw_app_filter_list = data.get("app_filter_list")
|
||||
raw_app_sounds = data.get("app_sounds")
|
||||
return cls(
|
||||
**common, source_type="notification",
|
||||
notification_effect=data.get("notification_effect") or "flash",
|
||||
@@ -864,6 +871,9 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
app_filter_mode=data.get("app_filter_mode") or "off",
|
||||
app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [],
|
||||
os_listener=bool(data.get("os_listener", False)),
|
||||
sound_asset_id=data.get("sound_asset_id"),
|
||||
sound_volume=float(data.get("sound_volume", 1.0)),
|
||||
app_sounds=raw_app_sounds if isinstance(raw_app_sounds, dict) else {},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -873,7 +883,9 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
notification_effect=None, duration_ms=None,
|
||||
default_color=None, app_colors=None,
|
||||
app_filter_mode=None, app_filter_list=None,
|
||||
os_listener=None, **_kwargs):
|
||||
os_listener=None, sound_asset_id=None,
|
||||
sound_volume=None, app_sounds=None,
|
||||
**_kwargs):
|
||||
return cls(
|
||||
id=id, name=name, source_type="notification",
|
||||
created_at=created_at, updated_at=updated_at,
|
||||
@@ -885,6 +897,9 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
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,
|
||||
sound_asset_id=sound_asset_id,
|
||||
sound_volume=float(sound_volume) if sound_volume is not None else 1.0,
|
||||
app_sounds=app_sounds if isinstance(app_sounds, dict) else {},
|
||||
)
|
||||
|
||||
def apply_update(self, **kwargs) -> None:
|
||||
@@ -904,6 +919,13 @@ class NotificationColorStripSource(ColorStripSource):
|
||||
self.app_filter_list = app_filter_list
|
||||
if kwargs.get("os_listener") is not None:
|
||||
self.os_listener = bool(kwargs["os_listener"])
|
||||
if "sound_asset_id" in kwargs:
|
||||
self.sound_asset_id = kwargs["sound_asset_id"]
|
||||
if kwargs.get("sound_volume") is not None:
|
||||
self.sound_volume = float(kwargs["sound_volume"])
|
||||
app_sounds = kwargs.get("app_sounds")
|
||||
if app_sounds is not None and isinstance(app_sounds, dict):
|
||||
self.app_sounds = app_sounds
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -55,9 +55,19 @@ _ENTITY_TABLES = [
|
||||
"color_strip_processing_templates",
|
||||
"gradients",
|
||||
"weather_sources",
|
||||
"assets",
|
||||
]
|
||||
|
||||
|
||||
_VALID_TABLES = frozenset(_ENTITY_TABLES) | {"settings", "schema_version"}
|
||||
|
||||
|
||||
def _check_table(table: str) -> None:
|
||||
"""Raise ValueError if *table* is not a known entity table."""
|
||||
if table not in _VALID_TABLES:
|
||||
raise ValueError(f"Invalid table name: {table!r}")
|
||||
|
||||
|
||||
class Database:
|
||||
"""Thread-safe SQLite connection wrapper with WAL mode.
|
||||
|
||||
@@ -169,6 +179,7 @@ class Database:
|
||||
|
||||
Returns list of dicts parsed from the ``data`` JSON column.
|
||||
"""
|
||||
_check_table(table)
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
f"SELECT id, data FROM [{table}]"
|
||||
@@ -187,6 +198,7 @@ class Database:
|
||||
|
||||
Skipped silently when writes are frozen.
|
||||
"""
|
||||
_check_table(table)
|
||||
if _writes_frozen:
|
||||
return
|
||||
json_data = json.dumps(data, ensure_ascii=False)
|
||||
@@ -202,6 +214,7 @@ class Database:
|
||||
|
||||
Skipped silently when writes are frozen.
|
||||
"""
|
||||
_check_table(table)
|
||||
if _writes_frozen:
|
||||
return
|
||||
with self._lock:
|
||||
@@ -215,6 +228,7 @@ class Database:
|
||||
|
||||
Skipped silently when writes are frozen.
|
||||
"""
|
||||
_check_table(table)
|
||||
if _writes_frozen:
|
||||
return
|
||||
with self._lock:
|
||||
@@ -226,6 +240,7 @@ class Database:
|
||||
|
||||
Skipped silently when writes are frozen.
|
||||
"""
|
||||
_check_table(table)
|
||||
if _writes_frozen:
|
||||
return
|
||||
with self._lock:
|
||||
@@ -237,6 +252,7 @@ class Database:
|
||||
|
||||
def count(self, table: str) -> int:
|
||||
"""Count rows in an entity table."""
|
||||
_check_table(table)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
f"SELECT COUNT(*) as cnt FROM [{table}]"
|
||||
@@ -245,13 +261,15 @@ class Database:
|
||||
|
||||
def table_exists_with_data(self, table: str) -> bool:
|
||||
"""Check if a table exists and has at least one row."""
|
||||
_check_table(table)
|
||||
with self._lock:
|
||||
try:
|
||||
row = self._conn.execute(
|
||||
f"SELECT COUNT(*) as cnt FROM [{table}]"
|
||||
).fetchone()
|
||||
return row["cnt"] > 0
|
||||
except sqlite3.OperationalError:
|
||||
except sqlite3.OperationalError as e:
|
||||
logger.debug("Table %s does not exist or is inaccessible: %s", table, e)
|
||||
return False
|
||||
|
||||
# -- Settings (key-value) ------------------------------------------------
|
||||
@@ -266,7 +284,8 @@ class Database:
|
||||
return None
|
||||
try:
|
||||
return json.loads(row["value"])
|
||||
except json.JSONDecodeError:
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("Corrupt JSON in setting '%s': %s", key, e)
|
||||
return None
|
||||
|
||||
def set_setting(self, key: str, value: dict) -> None:
|
||||
|
||||
@@ -12,8 +12,8 @@ class PictureSource:
|
||||
A picture source is either:
|
||||
- "raw": captures from a display using a capture engine template at a target FPS
|
||||
- "processed": applies postprocessing to another picture source
|
||||
- "static_image": returns a static frame from a URL or local file path
|
||||
- "video": decodes frames from a video file, URL, or YouTube link
|
||||
- "static_image": returns a static frame from an uploaded asset
|
||||
- "video": decodes frames from an uploaded video asset
|
||||
"""
|
||||
|
||||
id: str
|
||||
@@ -40,9 +40,9 @@ class PictureSource:
|
||||
"target_fps": None,
|
||||
"source_stream_id": None,
|
||||
"postprocessing_template_id": None,
|
||||
"image_source": None,
|
||||
"image_asset_id": None,
|
||||
# Video fields
|
||||
"url": None,
|
||||
"video_asset_id": None,
|
||||
"loop": None,
|
||||
"playback_speed": None,
|
||||
"start_time": None,
|
||||
@@ -138,13 +138,13 @@ class ProcessedPictureSource(PictureSource):
|
||||
|
||||
@dataclass
|
||||
class StaticImagePictureSource(PictureSource):
|
||||
"""A static image stream from a URL or file path."""
|
||||
"""A static image stream from an uploaded asset."""
|
||||
|
||||
image_source: str = ""
|
||||
image_asset_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["image_source"] = self.image_source
|
||||
d["image_asset_id"] = self.image_asset_id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -153,15 +153,15 @@ class StaticImagePictureSource(PictureSource):
|
||||
return cls(
|
||||
**common,
|
||||
stream_type="static_image",
|
||||
image_source=data.get("image_source") or "",
|
||||
image_asset_id=data.get("image_asset_id") or data.get("image_source") or None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoCaptureSource(PictureSource):
|
||||
"""A video stream from a file, HTTP URL, or YouTube link."""
|
||||
"""A video stream from an uploaded video asset."""
|
||||
|
||||
url: str = ""
|
||||
video_asset_id: Optional[str] = None
|
||||
loop: bool = True
|
||||
playback_speed: float = 1.0
|
||||
start_time: Optional[float] = None
|
||||
@@ -172,7 +172,7 @@ class VideoCaptureSource(PictureSource):
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["url"] = self.url
|
||||
d["video_asset_id"] = self.video_asset_id
|
||||
d["loop"] = self.loop
|
||||
d["playback_speed"] = self.playback_speed
|
||||
d["start_time"] = self.start_time
|
||||
@@ -188,7 +188,7 @@ class VideoCaptureSource(PictureSource):
|
||||
return cls(
|
||||
**common,
|
||||
stream_type="video",
|
||||
url=data.get("url") or "",
|
||||
video_asset_id=data.get("video_asset_id") or data.get("url") or None,
|
||||
loop=data.get("loop", True),
|
||||
playback_speed=data.get("playback_speed", 1.0),
|
||||
start_time=data.get("start_time"),
|
||||
|
||||
@@ -82,11 +82,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
|
||||
target_fps: Optional[int] = None,
|
||||
source_stream_id: Optional[str] = None,
|
||||
postprocessing_template_id: Optional[str] = None,
|
||||
image_source: Optional[str] = None,
|
||||
image_asset_id: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
# Video fields
|
||||
url: Optional[str] = None,
|
||||
video_asset_id: Optional[str] = None,
|
||||
loop: bool = True,
|
||||
playback_speed: float = 1.0,
|
||||
start_time: Optional[float] = None,
|
||||
@@ -121,11 +121,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
|
||||
if self._detect_cycle(source_stream_id):
|
||||
raise ValueError("Cycle detected in stream chain")
|
||||
elif stream_type == "static_image":
|
||||
if not image_source:
|
||||
raise ValueError("Static image streams require image_source")
|
||||
if not image_asset_id:
|
||||
raise ValueError("Static image streams require image_asset_id")
|
||||
elif stream_type == "video":
|
||||
if not url:
|
||||
raise ValueError("Video streams require url")
|
||||
if not video_asset_id:
|
||||
raise ValueError("Video streams require video_asset_id")
|
||||
|
||||
# Check for duplicate name
|
||||
self._check_name_unique(name)
|
||||
@@ -156,7 +156,7 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
|
||||
elif stream_type == "video":
|
||||
stream = VideoCaptureSource(
|
||||
**common,
|
||||
url=url, # type: ignore[arg-type]
|
||||
video_asset_id=video_asset_id,
|
||||
loop=loop,
|
||||
playback_speed=playback_speed,
|
||||
start_time=start_time,
|
||||
@@ -168,7 +168,7 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
|
||||
else:
|
||||
stream = StaticImagePictureSource(
|
||||
**common,
|
||||
image_source=image_source, # type: ignore[arg-type]
|
||||
image_asset_id=image_asset_id,
|
||||
)
|
||||
|
||||
self._items[stream_id] = stream
|
||||
@@ -186,11 +186,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
|
||||
target_fps: Optional[int] = None,
|
||||
source_stream_id: Optional[str] = None,
|
||||
postprocessing_template_id: Optional[str] = None,
|
||||
image_source: Optional[str] = None,
|
||||
image_asset_id: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
# Video fields
|
||||
url: Optional[str] = None,
|
||||
video_asset_id: Optional[str] = None,
|
||||
loop: Optional[bool] = None,
|
||||
playback_speed: Optional[float] = None,
|
||||
start_time: Optional[float] = None,
|
||||
@@ -234,11 +234,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
|
||||
if postprocessing_template_id is not None:
|
||||
stream.postprocessing_template_id = resolve_ref(postprocessing_template_id, stream.postprocessing_template_id)
|
||||
elif isinstance(stream, StaticImagePictureSource):
|
||||
if image_source is not None:
|
||||
stream.image_source = image_source
|
||||
if image_asset_id is not None:
|
||||
stream.image_asset_id = resolve_ref(image_asset_id, stream.image_asset_id)
|
||||
elif isinstance(stream, VideoCaptureSource):
|
||||
if url is not None:
|
||||
stream.url = url
|
||||
if video_asset_id is not None:
|
||||
stream.video_asset_id = resolve_ref(video_asset_id, stream.video_asset_id)
|
||||
if loop is not None:
|
||||
stream.loop = loop
|
||||
if playback_speed is not None:
|
||||
|
||||
@@ -212,6 +212,8 @@
|
||||
{% include 'modals/test-value-source.html' %}
|
||||
{% include 'modals/sync-clock-editor.html' %}
|
||||
{% include 'modals/weather-source-editor.html' %}
|
||||
{% include 'modals/asset-upload.html' %}
|
||||
{% include 'modals/asset-editor.html' %}
|
||||
{% include 'modals/settings.html' %}
|
||||
|
||||
{% include 'partials/tutorial-overlay.html' %}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<div id="asset-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-editor-title">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="asset-editor-title" data-i18n="asset.edit">Edit Asset</h2>
|
||||
<button class="modal-close-btn" onclick="closeAssetEditorModal()" data-i18n-aria-label="aria.close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="asset-editor-id">
|
||||
<div id="asset-editor-error" class="modal-error" style="display:none"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="asset-editor-name" data-i18n="asset.name">Name:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Display name for this asset.</small>
|
||||
<input type="text" id="asset-editor-name" required maxlength="100">
|
||||
<div id="asset-editor-tags-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="asset-editor-description" data-i18n="asset.description">Description:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
|
||||
<input type="text" id="asset-editor-description" maxlength="500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeAssetEditorModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveAssetMetadata()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<div id="asset-upload-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-upload-title">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="asset-upload-title" data-i18n="asset.upload">Upload Asset</h2>
|
||||
<button class="modal-close-btn" onclick="closeAssetUploadModal()" data-i18n-aria-label="aria.close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="asset-upload-error" class="modal-error" style="display:none"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="asset-upload-name" data-i18n="asset.name">Name:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Optional display name. If blank, derived from filename.</small>
|
||||
<input type="text" id="asset-upload-name" maxlength="100" placeholder="">
|
||||
<div id="asset-upload-tags-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="asset-upload-description" data-i18n="asset.description">Description:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
|
||||
<input type="text" id="asset-upload-description" maxlength="500">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="asset.file">File:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="asset.file.hint">Select a file to upload (sound, image, video, or other).</small>
|
||||
<input type="file" id="asset-upload-file" required hidden>
|
||||
<div id="asset-upload-dropzone" class="file-dropzone" tabindex="0" role="button"
|
||||
aria-label="Choose file or drag and drop">
|
||||
<div class="file-dropzone-icon">
|
||||
<svg class="icon" viewBox="0 0 24 24" style="width:32px;height:32px">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4"/>
|
||||
<path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="file-dropzone-text">
|
||||
<span class="file-dropzone-label" data-i18n="asset.drop_or_browse">Drop file here or click to browse</span>
|
||||
</div>
|
||||
<div id="asset-upload-file-info" class="file-dropzone-info" style="display:none">
|
||||
<span id="asset-upload-file-name" class="file-dropzone-filename"></span>
|
||||
<span id="asset-upload-file-size" class="file-dropzone-filesize"></span>
|
||||
<button type="button" class="file-dropzone-remove" id="asset-upload-file-remove"
|
||||
title="Remove" data-i18n-title="common.remove">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeAssetUploadModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="uploadAsset()" title="Upload" data-i18n-title="asset.upload" data-i18n-aria-label="asset.upload">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -458,23 +458,52 @@
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.filter_list.hint">One app name per line. Use Browse to pick from running processes.</small>
|
||||
<div class="condition-field" id="css-editor-notification-filter-picker-container">
|
||||
<div class="condition-apps-header">
|
||||
<button type="button" class="btn-browse-apps" data-i18n="automations.condition.application.browse">Browse</button>
|
||||
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" data-i18n-title="automations.condition.application.browse" title="Browse"><svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg></button>
|
||||
</div>
|
||||
<textarea id="css-editor-notification-filter-list" class="condition-apps" rows="3" data-i18n-placeholder="color_strip.notification.filter_list.placeholder" placeholder="Discord Slack Telegram"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="form-collapse">
|
||||
<summary data-i18n="color_strip.notification.app_colors">App Colors</summary>
|
||||
<summary data-i18n="color_strip.notification.sound">Sound</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>
|
||||
<label for="css-editor-notification-sound" data-i18n="color_strip.notification.sound.asset">Sound Asset:</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>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.sound.asset.hint">Pick a sound asset to play when a notification fires. Leave empty for silent.</small>
|
||||
<select id="css-editor-notification-sound">
|
||||
<option value="" data-i18n="color_strip.notification.sound.none">None (silent)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-notification-volume">
|
||||
<span data-i18n="color_strip.notification.sound.volume">Volume:</span>
|
||||
<span id="css-editor-notification-volume-val">100%</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.sound.volume.hint">Global volume for notification sounds (0–100%).</small>
|
||||
<input type="range" id="css-editor-notification-volume" min="0" max="100" step="5" value="100"
|
||||
oninput="document.getElementById('css-editor-notification-volume-val').textContent = this.value + '%'">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="form-collapse">
|
||||
<summary data-i18n="color_strip.notification.app_overrides">Per-App Overrides</summary>
|
||||
<div class="form-collapse-body">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="color_strip.notification.app_overrides.label">App Overrides:</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_overrides.hint">Per-app overrides for color and sound. Each row can set a custom color, sound asset, and volume for a specific app.</small>
|
||||
<div id="notification-app-overrides-list"></div>
|
||||
<button type="button" class="btn btn-secondary" onclick="notificationAddAppOverride()" data-i18n="color_strip.notification.app_overrides.add">+ Add Override</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<button class="modal-close-btn" onclick="closeNotificationHistory()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p>
|
||||
<p class="form-hint" style="margin-bottom:0.75rem" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p>
|
||||
<div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div>
|
||||
<div id="notification-history-list" style="max-height:340px;overflow-y:auto;border:1px solid var(--border-color);border-radius:4px;padding:0.25rem 0"></div>
|
||||
</div>
|
||||
|
||||
@@ -78,28 +78,15 @@
|
||||
<!-- Static image fields -->
|
||||
<div id="stream-static-image-fields" style="display: none;">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="streams.image_source.hint">Enter a URL (http/https) or local file path to an image</small>
|
||||
<input type="text" id="stream-image-source" data-i18n-placeholder="streams.image_source.placeholder" placeholder="https://example.com/image.jpg or C:\path\to\image.png">
|
||||
<label for="stream-image-asset" data-i18n="streams.image_asset">Image Asset:</label>
|
||||
<select id="stream-image-asset"></select>
|
||||
</div>
|
||||
<div id="stream-image-preview-container" class="image-preview-container" style="display: none;">
|
||||
<img id="stream-image-preview" class="stream-image-preview" src="" alt="Preview">
|
||||
<div id="stream-image-info" class="stream-image-info"></div>
|
||||
</div>
|
||||
<div id="stream-image-validation-status" class="validation-status" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="stream-video-fields" style="display: none;">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="stream-video-url" data-i18n="picture_source.video.url">Video URL:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="picture_source.video.url.hint">Local file path, HTTP URL, or YouTube URL</small>
|
||||
<input type="text" id="stream-video-url" data-i18n-placeholder="picture_source.video.url.placeholder" placeholder="https://example.com/video.mp4">
|
||||
<label for="stream-video-asset" data-i18n="streams.video_asset">Video Asset:</label>
|
||||
<select id="stream-video-asset"></select>
|
||||
</div>
|
||||
<div class="form-group settings-toggle-group">
|
||||
<label data-i18n="picture_source.video.loop">Loop:</label>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Atomic file write utilities."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def atomic_write_json(file_path: Path, data: dict, indent: int = 2) -> None:
|
||||
"""Write JSON data to file atomically via temp file + rename.
|
||||
@@ -29,6 +32,7 @@ def atomic_write_json(file_path: Path, data: dict, indent: int = 2) -> None:
|
||||
# Clean up temp file on any error
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
except OSError as e:
|
||||
logger.debug("Failed to clean up temp file %s: %s", tmp_path, e)
|
||||
pass
|
||||
raise
|
||||
|
||||
54
server/src/wled_controller/utils/safe_source.py
Normal file
54
server/src/wled_controller/utils/safe_source.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Validation utilities for image sources (URLs and local file paths).
|
||||
|
||||
Prevents SSRF via dangerous URL schemes and restricts file path access
|
||||
to prevent arbitrary file reads through API query parameters.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
# Image file extensions considered safe to serve
|
||||
_IMAGE_EXTENSIONS = frozenset({
|
||||
".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tiff", ".tif", ".ico",
|
||||
})
|
||||
|
||||
|
||||
def validate_image_url(url: str) -> None:
|
||||
"""Validate that *url* uses a safe scheme (http/https only).
|
||||
|
||||
Blocks ``file://``, ``ftp://``, ``gopher://``, and other dangerous schemes
|
||||
that could be used for SSRF.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported URL scheme: {parsed.scheme!r}. Only http and https are allowed.",
|
||||
)
|
||||
|
||||
if not parsed.hostname:
|
||||
raise HTTPException(status_code=400, detail="URL has no hostname")
|
||||
|
||||
|
||||
def validate_image_path(file_path: str | Path) -> Path:
|
||||
"""Validate a local file path points to a real image file.
|
||||
|
||||
Checks:
|
||||
- The extension is a known image format
|
||||
- The resolved path does not escape via symlinks to unexpected locations
|
||||
|
||||
Returns the resolved Path on success, raises HTTPException on violation.
|
||||
"""
|
||||
resolved = Path(file_path).resolve()
|
||||
|
||||
suffix = resolved.suffix.lower()
|
||||
if suffix not in _IMAGE_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported file type: {suffix!r}. Only image files are allowed.",
|
||||
)
|
||||
|
||||
return resolved
|
||||
126
server/src/wled_controller/utils/sound_player.py
Normal file
126
server/src/wled_controller/utils/sound_player.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Cross-platform asynchronous sound playback for notification alerts.
|
||||
|
||||
Windows: uses winsound.PlaySound (stdlib, no dependencies).
|
||||
Linux: uses paplay (PulseAudio) or aplay (ALSA) via subprocess.
|
||||
|
||||
All playback is fire-and-forget on a background thread. A new notification
|
||||
sound cancels any currently playing sound to prevent overlap.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Lock + handle for cancelling previous sound
|
||||
_play_lock = threading.Lock()
|
||||
_current_process: subprocess.Popen | None = None
|
||||
|
||||
|
||||
def _play_windows(file_path: Path, volume: float) -> None:
|
||||
"""Play a WAV file on Windows using winsound."""
|
||||
import winsound
|
||||
|
||||
# winsound doesn't support volume control natively,
|
||||
# but SND_ASYNC plays non-blocking within this thread
|
||||
try:
|
||||
winsound.PlaySound(str(file_path), winsound.SND_FILENAME | winsound.SND_ASYNC)
|
||||
except Exception as e:
|
||||
logger.error(f"winsound playback failed: {e}")
|
||||
|
||||
|
||||
def _play_linux(file_path: Path, volume: float) -> None:
|
||||
"""Play a sound file on Linux using paplay or aplay."""
|
||||
global _current_process
|
||||
|
||||
# Cancel previous sound
|
||||
with _play_lock:
|
||||
if _current_process is not None:
|
||||
try:
|
||||
_current_process.terminate()
|
||||
except OSError as e:
|
||||
logger.debug("Failed to terminate previous sound process: %s", e)
|
||||
pass
|
||||
_current_process = None
|
||||
|
||||
try:
|
||||
# Try paplay first (PulseAudio/PipeWire) — supports volume
|
||||
pa_volume = max(0, min(65536, int(volume * 65536)))
|
||||
proc = subprocess.Popen(
|
||||
["paplay", f"--volume={pa_volume}", str(file_path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
# Fallback to aplay (ALSA) — no volume control
|
||||
proc = subprocess.Popen(
|
||||
["aplay", "-q", str(file_path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.warning("Neither paplay nor aplay found — cannot play notification sound")
|
||||
return
|
||||
|
||||
with _play_lock:
|
||||
_current_process = proc
|
||||
|
||||
# Wait for completion
|
||||
proc.wait()
|
||||
|
||||
with _play_lock:
|
||||
if _current_process is proc:
|
||||
_current_process = None
|
||||
|
||||
|
||||
def play_sound_async(file_path: Path, volume: float = 1.0) -> None:
|
||||
"""Play a sound file asynchronously (fire-and-forget).
|
||||
|
||||
Args:
|
||||
file_path: Path to the sound file (.wav).
|
||||
volume: Volume level 0.0-1.0 (best-effort, not all backends support it).
|
||||
"""
|
||||
if not file_path.exists():
|
||||
logger.warning(f"Sound file not found: {file_path}")
|
||||
return
|
||||
|
||||
volume = max(0.0, min(1.0, volume))
|
||||
|
||||
if sys.platform == "win32":
|
||||
player = _play_windows
|
||||
else:
|
||||
player = _play_linux
|
||||
|
||||
thread = threading.Thread(
|
||||
target=player,
|
||||
args=(file_path, volume),
|
||||
name="sound-player",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
def stop_current_sound() -> None:
|
||||
"""Stop any currently playing notification sound."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import winsound
|
||||
winsound.PlaySound(None, winsound.SND_PURGE)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to stop winsound playback: %s", e)
|
||||
pass
|
||||
else:
|
||||
with _play_lock:
|
||||
global _current_process
|
||||
if _current_process is not None:
|
||||
try:
|
||||
_current_process.terminate()
|
||||
except OSError as e:
|
||||
logger.debug("Failed to terminate sound process: %s", e)
|
||||
pass
|
||||
_current_process = None
|
||||
@@ -1,18 +1,47 @@
|
||||
"""Shared fixtures for end-to-end API tests.
|
||||
|
||||
Uses the real FastAPI app with a module-scoped TestClient to avoid
|
||||
repeated lifespan startup/shutdown issues. Each test function gets
|
||||
fresh, empty stores via the _clear_stores helper.
|
||||
Uses the real FastAPI app with a session-scoped TestClient.
|
||||
All e2e tests run against an ISOLATED temporary database and assets
|
||||
directory — never the production data.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.config import get_config
|
||||
# ---------------------------------------------------------------------------
|
||||
# Isolate e2e tests from production data.
|
||||
#
|
||||
# We must set the config singleton BEFORE wled_controller.main is imported,
|
||||
# because main.py reads get_config() at module level to create the DB and
|
||||
# all stores. By forcing the singleton here we guarantee the app opens a
|
||||
# throwaway SQLite file in a temp directory.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_e2e_tmp = Path(tempfile.mkdtemp(prefix="wled_e2e_"))
|
||||
_test_db_path = str(_e2e_tmp / "test_ledgrab.db")
|
||||
_test_assets_dir = str(_e2e_tmp / "test_assets")
|
||||
|
||||
# Resolve the API key from the real config (same key used in production tests)
|
||||
_config = get_config()
|
||||
API_KEY = next(iter(_config.auth.api_keys.values()), "")
|
||||
import wled_controller.config as _config_mod # noqa: E402
|
||||
|
||||
# Build a Config that mirrors production settings but with isolated paths.
|
||||
_original_config = _config_mod.Config.load()
|
||||
_test_config = _original_config.model_copy(
|
||||
update={
|
||||
"storage": _config_mod.StorageConfig(database_file=_test_db_path),
|
||||
"assets": _config_mod.AssetsConfig(
|
||||
assets_dir=_test_assets_dir,
|
||||
max_file_size_mb=_original_config.assets.max_file_size_mb,
|
||||
),
|
||||
},
|
||||
)
|
||||
# Install as the global singleton so all subsequent get_config() calls
|
||||
# (including main.py module-level code) use isolated paths.
|
||||
_config_mod.config = _test_config
|
||||
|
||||
API_KEY = next(iter(_test_config.auth.api_keys.values()), "")
|
||||
AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"}
|
||||
|
||||
|
||||
@@ -22,7 +51,7 @@ def _test_client():
|
||||
|
||||
The app's lifespan (MQTT, automation engine, health monitoring, etc.)
|
||||
starts once for the entire e2e test session and shuts down after all
|
||||
tests complete.
|
||||
tests complete. The app uses the isolated test database set above.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from wled_controller.main import app
|
||||
@@ -30,6 +59,9 @@ def _test_client():
|
||||
with TestClient(app, raise_server_exceptions=False) as c:
|
||||
yield c
|
||||
|
||||
# Clean up temp directory after all e2e tests finish
|
||||
shutil.rmtree(_e2e_tmp, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(_test_client):
|
||||
@@ -63,6 +95,7 @@ def _clear_stores():
|
||||
(deps.get_sync_clock_store, "get_all", "delete"),
|
||||
(deps.get_automation_store, "get_all", "delete"),
|
||||
(deps.get_scene_preset_store, "get_all", "delete"),
|
||||
(deps.get_asset_store, "get_all_assets", "delete_asset"),
|
||||
]
|
||||
for getter, list_method, delete_method in store_clearers:
|
||||
try:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""E2E: Backup and restore flow.
|
||||
|
||||
Tests creating entities, backing up (SQLite .db file), deleting, then restoring.
|
||||
Tests creating entities, backing up (ZIP containing SQLite .db + asset files),
|
||||
deleting, then restoring.
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
|
||||
class TestBackupRestoreFlow:
|
||||
@@ -40,12 +42,17 @@ class TestBackupRestoreFlow:
|
||||
resp = client.get("/api/v1/color-strip-sources")
|
||||
assert resp.json()["count"] == 1
|
||||
|
||||
# 2. Create a backup (GET returns a SQLite .db file)
|
||||
# 2. Create a backup (GET returns a ZIP containing ledgrab.db + assets)
|
||||
resp = client.get("/api/v1/system/backup")
|
||||
assert resp.status_code == 200
|
||||
backup_bytes = resp.content
|
||||
# SQLite files start with this magic header
|
||||
assert backup_bytes[:16].startswith(b"SQLite format 3")
|
||||
# Backup is a ZIP file (PK magic bytes)
|
||||
assert backup_bytes[:4] == b"PK\x03\x04"
|
||||
# ZIP should contain ledgrab.db
|
||||
with zipfile.ZipFile(io.BytesIO(backup_bytes)) as zf:
|
||||
assert "ledgrab.db" in zf.namelist()
|
||||
db_data = zf.read("ledgrab.db")
|
||||
assert db_data[:16].startswith(b"SQLite format 3")
|
||||
|
||||
# 3. Delete all created entities
|
||||
resp = client.delete(f"/api/v1/color-strip-sources/{css_id}")
|
||||
@@ -59,23 +66,27 @@ class TestBackupRestoreFlow:
|
||||
resp = client.get("/api/v1/color-strip-sources")
|
||||
assert resp.json()["count"] == 0
|
||||
|
||||
# 4. Restore from backup (POST with the .db file upload)
|
||||
# 4. Restore from backup (POST with the .zip file upload)
|
||||
resp = client.post(
|
||||
"/api/v1/system/restore",
|
||||
files={"file": ("backup.db", io.BytesIO(backup_bytes), "application/octet-stream")},
|
||||
files={"file": ("backup.zip", io.BytesIO(backup_bytes), "application/zip")},
|
||||
)
|
||||
assert resp.status_code == 200, f"Restore failed: {resp.text}"
|
||||
restore_result = resp.json()
|
||||
assert restore_result["status"] == "restored"
|
||||
assert restore_result["restart_scheduled"] is True
|
||||
|
||||
def test_backup_is_valid_sqlite(self, client):
|
||||
"""Backup response is a valid SQLite database file."""
|
||||
def test_backup_is_valid_zip(self, client):
|
||||
"""Backup response is a valid ZIP containing a SQLite database."""
|
||||
resp = client.get("/api/v1/system/backup")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content[:16].startswith(b"SQLite format 3")
|
||||
assert resp.content[:4] == b"PK\x03\x04"
|
||||
# Should have Content-Disposition header for download
|
||||
assert "attachment" in resp.headers.get("content-disposition", "")
|
||||
# ZIP should contain ledgrab.db with valid SQLite header
|
||||
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
|
||||
assert "ledgrab.db" in zf.namelist()
|
||||
assert zf.read("ledgrab.db")[:16].startswith(b"SQLite format 3")
|
||||
|
||||
def test_restore_rejects_invalid_format(self, client):
|
||||
"""Uploading a non-SQLite file should fail validation."""
|
||||
|
||||
Reference in New Issue
Block a user