Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89d1b13854 | |||
| 324a308805 | |||
| cb9289f01f | |||
| fb98e6e2b8 | |||
| 3c2efd5e4a | |||
| 2153dde4b7 | |||
| f3d07fc47f | |||
| f61a0206d4 | |||
| f345687600 | |||
| e2e1107df7 | |||
| c0853ce184 | |||
| 3e0bf8538c | |||
| be4c98b543 | |||
| dca2d212b1 | |||
| 53986f8d95 | |||
| a4a9f6f77f | |||
| 9fcfdb8570 |
80
.gitea/workflows/build.yml
Normal file
80
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
name: Build Artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version label (e.g. dev, 0.3.0-test)'
|
||||||
|
required: false
|
||||||
|
default: 'dev'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends zip libportaudio2 nsis msitools
|
||||||
|
|
||||||
|
- name: Cross-build Windows distribution
|
||||||
|
run: |
|
||||||
|
chmod +x build-dist-windows.sh
|
||||||
|
./build-dist-windows.sh "v${{ inputs.version }}"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: LedGrab-${{ inputs.version }}-win-x64
|
||||||
|
path: |
|
||||||
|
build/LedGrab-*.zip
|
||||||
|
build/LedGrab-*-setup.exe
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends libportaudio2
|
||||||
|
|
||||||
|
- name: Build Linux distribution
|
||||||
|
run: |
|
||||||
|
chmod +x build-dist.sh
|
||||||
|
./build-dist.sh "v${{ inputs.version }}"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: LedGrab-${{ inputs.version }}-linux-x64
|
||||||
|
path: build/LedGrab-*.tar.gz
|
||||||
|
retention-days: 90
|
||||||
@@ -12,8 +12,11 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
release_id: ${{ steps.create.outputs.release_id }}
|
release_id: ${{ steps.create.outputs.release_id }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Fetch RELEASE_NOTES.md only
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
sparse-checkout: RELEASE_NOTES.md
|
||||||
|
sparse-checkout-cone-mode: false
|
||||||
|
|
||||||
- name: Create Gitea release
|
- name: Create Gitea release
|
||||||
id: create
|
id: create
|
||||||
@@ -33,11 +36,9 @@ jobs:
|
|||||||
REPO=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
|
REPO=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||||
DOCKER_IMAGE="${SERVER_HOST}/${REPO}"
|
DOCKER_IMAGE="${SERVER_HOST}/${REPO}"
|
||||||
|
|
||||||
# Scan for RELEASE_NOTES.md (check repo root first, then recursively)
|
if [ -f RELEASE_NOTES.md ]; then
|
||||||
NOTES_FILE=$(find . -maxdepth 3 -name "RELEASE_NOTES.md" -type f | head -1)
|
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
|
||||||
if [ -n "$NOTES_FILE" ]; then
|
echo "Found RELEASE_NOTES.md"
|
||||||
export RELEASE_NOTES=$(cat "$NOTES_FILE")
|
|
||||||
echo "Found release notes: $NOTES_FILE"
|
|
||||||
else
|
else
|
||||||
export RELEASE_NOTES=""
|
export RELEASE_NOTES=""
|
||||||
echo "No RELEASE_NOTES.md found"
|
echo "No RELEASE_NOTES.md found"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
args: [--line-length=100, --target-version=py311]
|
args: [--line-length=100, --target-version=py311]
|
||||||
language_version: python3.11
|
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.8.0
|
rev: v0.8.0
|
||||||
|
|||||||
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.
|
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
|
## Architecture
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
|
|
||||||
## Donation / Open-Source Banner
|
## Donation / Open-Source Banner
|
||||||
|
|
||||||
- [ ] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
|
- [x] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
|
||||||
- [ ] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
|
- [x] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
|
||||||
- [ ] Remember dismissal in localStorage so it doesn't reappear every session
|
- [x] Remember dismissal in localStorage so it doesn't reappear every session
|
||||||
- [ ] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
|
- [x] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
|
||||||
|
- [ ] Configure `DONATE_URL` and `REPO_URL` constants in `donation.ts` once platform is chosen
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,9 +82,9 @@ Need to research HAOS communication options first (WebSocket API, REST API, MQTT
|
|||||||
|
|
||||||
### `api_input`
|
### `api_input`
|
||||||
|
|
||||||
- [ ] Crossfade transition when new data arrives
|
- [x] ~~Crossfade transition~~ — won't do: external client owns temporal transitions; crossfading on our side would double-smooth
|
||||||
- [ ] Interpolation when incoming LED count differs from strip count
|
- [x] Interpolation when incoming LED count differs from strip count (linear/nearest/none modes)
|
||||||
- [ ] Last-write-wins from any client (no multi-source blending)
|
- [x] Last-write-wins from any client — already the default behavior (push overwrites buffer)
|
||||||
|
|
||||||
## Architectural / Pipeline
|
## Architectural / Pipeline
|
||||||
|
|
||||||
@@ -110,5 +111,6 @@ Needs deeper design discussion. Likely a new entity type `ColorStripSourceTransi
|
|||||||
|
|
||||||
## Remaining Open Discussion
|
## Remaining Open Discussion
|
||||||
|
|
||||||
1. **`home_assistant` source** — Need to research HAOS communication protocols first
|
1. ~~**`home_assistant` source** — Need to research HAOS communication protocols first~~ **DONE** — WebSocket API chosen, connection layer + automation condition + UI implemented
|
||||||
2. **Transition engine** — Design as `ColorStripSourceTransition` entity: what transition types? (crossfade, wipe, dissolve?) How does a target reference its transition config? How do automations trigger it?
|
2. **Transition engine** — Design as `ColorStripSourceTransition` entity: what transition types? (crossfade, wipe, dissolve?) How does a target reference its transition config? How do automations trigger it?
|
||||||
|
3. **Home Assistant output targets** — Investigate casting LED colors TO Home Assistant lights (reverse direction). Use HA `light.turn_on` service call with `rgb_color` via WebSocket API. Could enable: ambient lighting on HA-controlled bulbs (Hue, WLED via HA, Zigbee lights), room-by-room color sync, whole-home ambient scenes. Need to research: rate limiting (don't spam HA with 30fps updates), grouping multiple lights, brightness/color_temp mapping, transition parameter support.
|
||||||
|
|||||||
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:
|
When you need a new icon:
|
||||||
1. Find the Lucide icon at https://lucide.dev
|
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)`
|
3. Add a corresponding `ICON_*` constant in `icons.ts` using `_svg(P.myIcon)`
|
||||||
4. Import and use the constant in your feature module
|
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`
|
- `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)
|
- 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
|
## Bundling & Development Workflow
|
||||||
|
|
||||||
@@ -266,11 +278,11 @@ See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, bro
|
|||||||
|
|
||||||
### Uptime / duration values
|
### 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
|
### 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
|
### 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/` |
|
| **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/` |
|
| **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
|
## Restart Procedure
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
- `src/wled_controller/api/schemas/` — Pydantic request/response models (one file per entity)
|
- `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/core/` — Core business logic (capture, devices, audio, processing, automations)
|
||||||
- `src/wled_controller/storage/` — Data models (dataclasses) and JSON persistence stores
|
- `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/static/` — Frontend files (TypeScript, CSS, locales)
|
||||||
- `src/wled_controller/templates/` — Jinja2 HTML templates
|
- `src/wled_controller/templates/` — Jinja2 HTML templates
|
||||||
- `config/` — Configuration files (YAML)
|
- `config/` — Configuration files (YAML)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The server component provides:
|
|||||||
- 🎯 **Real-time Screen Capture** - Multi-monitor support with configurable FPS
|
- 🎯 **Real-time Screen Capture** - Multi-monitor support with configurable FPS
|
||||||
- 🎨 **Advanced Processing** - Border pixel extraction with color correction
|
- 🎨 **Advanced Processing** - Border pixel extraction with color correction
|
||||||
- 🔧 **Flexible Calibration** - Map screen edges to any LED layout
|
- 🔧 **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
|
- 💾 **Persistent Storage** - JSON-based device and configuration management
|
||||||
- 📊 **Metrics & Monitoring** - Real-time FPS, status, and performance data
|
- 📊 **Metrics & Monitoring** - Real-time FPS, status, and performance data
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ dependencies = [
|
|||||||
"aiomqtt>=2.0.0",
|
"aiomqtt>=2.0.0",
|
||||||
"openrgb-python>=0.2.15",
|
"openrgb-python>=0.2.15",
|
||||||
"opencv-python-headless>=4.8.0",
|
"opencv-python-headless>=4.8.0",
|
||||||
|
"websockets>=13.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -10,3 +10,9 @@ except PackageNotFoundError:
|
|||||||
|
|
||||||
__author__ = "Alexei Dolgolyov"
|
__author__ = "Alexei Dolgolyov"
|
||||||
__email__ = "dolgolyov.alexei@gmail.com"
|
__email__ = "dolgolyov.alexei@gmail.com"
|
||||||
|
|
||||||
|
# ─── Project links ───────────────────────────────────────────
|
||||||
|
GITEA_BASE_URL = "https://git.dolgolyov-family.by"
|
||||||
|
GITEA_REPO = "alexei.dolgolyov/wled-screen-controller-mixed"
|
||||||
|
REPO_URL = f"{GITEA_BASE_URL}/{GITEA_REPO}"
|
||||||
|
DONATE_URL = "" # TODO: set once donation platform is chosen
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ from .routes.color_strip_processing import router as cspt_router
|
|||||||
from .routes.gradients import router as gradients_router
|
from .routes.gradients import router as gradients_router
|
||||||
from .routes.weather_sources import router as weather_sources_router
|
from .routes.weather_sources import router as weather_sources_router
|
||||||
from .routes.update import router as update_router
|
from .routes.update import router as update_router
|
||||||
|
from .routes.assets import router as assets_router
|
||||||
|
from .routes.home_assistant import router as home_assistant_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -52,5 +54,7 @@ router.include_router(cspt_router)
|
|||||||
router.include_router(gradients_router)
|
router.include_router(gradients_router)
|
||||||
router.include_router(weather_sources_router)
|
router.include_router(weather_sources_router)
|
||||||
router.include_router(update_router)
|
router.include_router(update_router)
|
||||||
|
router.include_router(assets_router)
|
||||||
|
router.include_router(home_assistant_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -21,14 +21,19 @@ from wled_controller.storage.value_source_store import ValueSourceStore
|
|||||||
from wled_controller.storage.automation_store import AutomationStore
|
from wled_controller.storage.automation_store import AutomationStore
|
||||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
from wled_controller.storage.color_strip_processing_template_store import (
|
||||||
|
ColorStripProcessingTemplateStore,
|
||||||
|
)
|
||||||
from wled_controller.storage.gradient_store import GradientStore
|
from wled_controller.storage.gradient_store import GradientStore
|
||||||
from wled_controller.storage.weather_source_store import WeatherSourceStore
|
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.automations.automation_engine import AutomationEngine
|
||||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
from wled_controller.core.weather.weather_manager import WeatherManager
|
||||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||||
from wled_controller.core.update.update_service import UpdateService
|
from wled_controller.core.update.update_service import UpdateService
|
||||||
|
from wled_controller.storage.home_assistant_store import HomeAssistantStore
|
||||||
|
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -131,6 +136,18 @@ def get_weather_manager() -> WeatherManager:
|
|||||||
return _get("weather_manager", "Weather manager")
|
return _get("weather_manager", "Weather manager")
|
||||||
|
|
||||||
|
|
||||||
|
def get_asset_store() -> AssetStore:
|
||||||
|
return _get("asset_store", "Asset store")
|
||||||
|
|
||||||
|
|
||||||
|
def get_ha_store() -> HomeAssistantStore:
|
||||||
|
return _get("ha_store", "Home Assistant store")
|
||||||
|
|
||||||
|
|
||||||
|
def get_ha_manager() -> HomeAssistantManager:
|
||||||
|
return _get("ha_manager", "Home Assistant manager")
|
||||||
|
|
||||||
|
|
||||||
def get_database() -> Database:
|
def get_database() -> Database:
|
||||||
return _get("database", "Database")
|
return _get("database", "Database")
|
||||||
|
|
||||||
@@ -152,12 +169,14 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
|||||||
"""
|
"""
|
||||||
pm = _deps.get("processor_manager")
|
pm = _deps.get("processor_manager")
|
||||||
if pm is not None:
|
if pm is not None:
|
||||||
pm.fire_event({
|
pm.fire_event(
|
||||||
|
{
|
||||||
"type": "entity_changed",
|
"type": "entity_changed",
|
||||||
"entity_type": entity_type,
|
"entity_type": entity_type,
|
||||||
"action": action,
|
"action": action,
|
||||||
"id": entity_id,
|
"id": entity_id,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Initialization ──────────────────────────────────────────────────────
|
# ── Initialization ──────────────────────────────────────────────────────
|
||||||
@@ -187,9 +206,13 @@ def init_dependencies(
|
|||||||
weather_source_store: WeatherSourceStore | None = None,
|
weather_source_store: WeatherSourceStore | None = None,
|
||||||
weather_manager: WeatherManager | None = None,
|
weather_manager: WeatherManager | None = None,
|
||||||
update_service: UpdateService | None = None,
|
update_service: UpdateService | None = None,
|
||||||
|
asset_store: AssetStore | None = None,
|
||||||
|
ha_store: HomeAssistantStore | None = None,
|
||||||
|
ha_manager: HomeAssistantManager | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
_deps.update({
|
_deps.update(
|
||||||
|
{
|
||||||
"database": database,
|
"database": database,
|
||||||
"device_store": device_store,
|
"device_store": device_store,
|
||||||
"template_store": template_store,
|
"template_store": template_store,
|
||||||
@@ -213,4 +236,8 @@ def init_dependencies(
|
|||||||
"weather_source_store": weather_source_store,
|
"weather_source_store": weather_source_store,
|
||||||
"weather_manager": weather_manager,
|
"weather_manager": weather_manager,
|
||||||
"update_service": update_service,
|
"update_service": update_service,
|
||||||
})
|
"asset_store": asset_store,
|
||||||
|
"ha_store": ha_store,
|
||||||
|
"ha_manager": ha_manager,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ async def stream_capture_test(
|
|||||||
if stream:
|
if stream:
|
||||||
try:
|
try:
|
||||||
stream.cleanup()
|
stream.cleanup()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Capture stream cleanup error: %s", e)
|
||||||
pass
|
pass
|
||||||
done_event.set()
|
done_event.set()
|
||||||
|
|
||||||
@@ -210,8 +211,9 @@ async def stream_capture_test(
|
|||||||
"avg_capture_ms": round(avg_ms, 1),
|
"avg_capture_ms": round(avg_ms, 1),
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
# WebSocket disconnect or send error — signal capture thread to stop
|
# WebSocket disconnect or send error — signal capture thread to stop
|
||||||
|
logger.debug("Capture preview WS error, stopping capture thread: %s", e)
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
await capture_future
|
await capture_future
|
||||||
raise
|
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)
|
template = template_store.get_template(audio_template_id)
|
||||||
engine_type = template.engine_type
|
engine_type = template.engine_type
|
||||||
engine_config = template.engine_config
|
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
|
pass # Fall back to best available engine
|
||||||
|
|
||||||
# Acquire shared audio stream
|
# Acquire shared audio stream
|
||||||
@@ -268,6 +269,7 @@ async def test_audio_source_ws(
|
|||||||
|
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("Audio test WebSocket disconnected for source %s", source_id)
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Audio test WebSocket error for {source_id}: {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))
|
return AudioTemplateListResponse(templates=responses, count=len(responses))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list audio templates: {e}")
|
logger.error("Failed to list audio templates: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/audio-templates", response_model=AudioTemplateResponse, tags=["Audio Templates"], status_code=201)
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create audio template: {e}")
|
logger.error("Failed to create audio template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update audio template: {e}")
|
logger.error("Failed to update audio template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/audio-templates/{template_id}", status_code=204, tags=["Audio Templates"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete audio template: {e}")
|
logger.error("Failed to delete audio template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
# ===== AUDIO ENGINE ENDPOINTS =====
|
# ===== AUDIO ENGINE ENDPOINTS =====
|
||||||
@@ -175,8 +175,8 @@ async def list_audio_engines(_auth: AuthRequired):
|
|||||||
|
|
||||||
return AudioEngineListResponse(engines=engines, count=len(engines))
|
return AudioEngineListResponse(engines=engines, count=len(engines))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list audio engines: {e}")
|
logger.error("Failed to list audio engines: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
# ===== REAL-TIME AUDIO TEMPLATE TEST WEBSOCKET =====
|
# ===== REAL-TIME AUDIO TEMPLATE TEST WEBSOCKET =====
|
||||||
@@ -237,6 +237,7 @@ async def test_audio_template_ws(
|
|||||||
})
|
})
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("Audio template test WebSocket disconnected")
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Audio template test WS error: {e}")
|
logger.error(f"Audio template test WS error: {e}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""System routes: backup, restore, auto-backup.
|
"""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
|
import asyncio
|
||||||
@@ -8,13 +9,14 @@ import io
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
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 (
|
from wled_controller.api.schemas.system import (
|
||||||
AutoBackupSettings,
|
AutoBackupSettings,
|
||||||
AutoBackupStatusResponse,
|
AutoBackupStatusResponse,
|
||||||
@@ -22,7 +24,9 @@ from wled_controller.api.schemas.system import (
|
|||||||
BackupListResponse,
|
BackupListResponse,
|
||||||
RestoreResponse,
|
RestoreResponse,
|
||||||
)
|
)
|
||||||
|
from wled_controller.config import get_config
|
||||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
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.storage.database import Database, freeze_writes
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
@@ -60,25 +64,43 @@ def _schedule_restart() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/system/backup", tags=["System"])
|
@router.get("/api/v1/system/backup", tags=["System"])
|
||||||
def backup_config(_: AuthRequired, db: Database = Depends(get_database)):
|
def backup_config(
|
||||||
"""Download a full database backup as a .db file."""
|
_: 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
|
import tempfile
|
||||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||||
tmp_path = Path(tmp.name)
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.backup_to(tmp_path)
|
db.backup_to(tmp_path)
|
||||||
content = tmp_path.read_bytes()
|
db_content = tmp_path.read_bytes()
|
||||||
finally:
|
finally:
|
||||||
tmp_path.unlink(missing_ok=True)
|
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
|
from datetime import datetime, timezone
|
||||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
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(
|
return StreamingResponse(
|
||||||
io.BytesIO(content),
|
zip_buffer,
|
||||||
media_type="application/octet-stream",
|
media_type="application/zip",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,21 +111,52 @@ async def restore_config(
|
|||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
db: Database = Depends(get_database),
|
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()
|
raw = await file.read()
|
||||||
if len(raw) > 50 * 1024 * 1024: # 50 MB limit
|
if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets)
|
||||||
raise HTTPException(status_code=400, detail="Backup file too large (max 50 MB)")
|
raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)")
|
||||||
|
|
||||||
if len(raw) < 100:
|
if len(raw) < 100:
|
||||||
raise HTTPException(status_code=400, detail="File too small to be a valid SQLite database")
|
raise HTTPException(status_code=400, detail="File too small to be a valid backup")
|
||||||
|
|
||||||
# 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")
|
|
||||||
|
|
||||||
import tempfile
|
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:
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||||
tmp.write(raw)
|
tmp.write(db_bytes)
|
||||||
tmp_path = Path(tmp.name)
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ async def list_cspt(
|
|||||||
responses = [_cspt_to_response(t) for t in templates]
|
responses = [_cspt_to_response(t) for t in templates]
|
||||||
return ColorStripProcessingTemplateListResponse(templates=responses, count=len(responses))
|
return ColorStripProcessingTemplateListResponse(templates=responses, count=len(responses))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list color strip processing templates: {e}")
|
logger.error("Failed to list color strip processing templates: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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)
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create color strip processing template: {e}")
|
logger.error("Failed to create color strip processing template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update color strip processing template: {e}")
|
logger.error("Failed to update color strip processing template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete color strip processing template: {e}")
|
logger.error("Failed to delete color strip processing template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
# ── Test / Preview WebSocket ──────────────────────────────────────────
|
# ── Test / Preview WebSocket ──────────────────────────────────────────
|
||||||
@@ -259,12 +259,14 @@ async def test_cspt_ws(
|
|||||||
result = flt.process_strip(colors)
|
result = flt.process_strip(colors)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
colors = result
|
colors = result
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Strip filter processing error: %s", e)
|
||||||
pass
|
pass
|
||||||
await websocket.send_bytes(colors.tobytes())
|
await websocket.send_bytes(colors.tobytes())
|
||||||
await asyncio.sleep(frame_interval)
|
await asyncio.sleep(frame_interval)
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("Color strip processing test WebSocket disconnected")
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"CSPT test WS error: {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
|
return 0
|
||||||
try:
|
try:
|
||||||
ps = picture_source_store.get_stream(picture_source_id)
|
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
|
return 0
|
||||||
if isinstance(ps, ScreenCapturePictureSource):
|
if isinstance(ps, ScreenCapturePictureSource):
|
||||||
return ps.display_index
|
return ps.display_index
|
||||||
@@ -160,8 +161,8 @@ async def create_color_strip_source(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create color strip source: {e}")
|
logger.error("Failed to create color strip source: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update color strip source: {e}")
|
logger.error("Failed to update color strip source: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete color strip source: {e}")
|
logger.error("Failed to delete color strip source: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
# ===== CALIBRATION TEST =====
|
# ===== CALIBRATION TEST =====
|
||||||
@@ -332,8 +333,8 @@ async def test_css_calibration(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to set CSS calibration test mode: {e}")
|
logger.error("Failed to set CSS calibration test mode: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
# ===== OVERLAY VISUALIZATION =====
|
# ===== OVERLAY VISUALIZATION =====
|
||||||
@@ -372,8 +373,8 @@ async def start_css_overlay(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start CSS overlay: {e}", exc_info=True)
|
logger.error("Failed to start CSS overlay: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/color-strip-sources/{source_id}/overlay/stop", tags=["Color Strip Sources"])
|
@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)
|
await manager.stop_css_overlay(source_id)
|
||||||
return {"status": "stopped", "source_id": source_id}
|
return {"status": "stopped", "source_id": source_id}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to stop CSS overlay: {e}", exc_info=True)
|
logger.error("Failed to stop CSS overlay: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/color-strip-sources/{source_id}/overlay/status", tags=["Color Strip Sources"])
|
@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:
|
try:
|
||||||
mgr = get_processor_manager()
|
mgr = get_processor_manager()
|
||||||
return getattr(mgr, "_sync_clock_manager", None)
|
return getattr(mgr, "_sync_clock_manager", None)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("SyncClockManager not available: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _build_source(config: dict):
|
def _build_source(config: dict):
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ async def create_device(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create device: {e}")
|
logger.error("Failed to create device: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
|
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
|
||||||
@@ -360,7 +360,8 @@ async def update_device(
|
|||||||
led_count=update_data.led_count,
|
led_count=update_data.led_count,
|
||||||
baud_rate=update_data.baud_rate,
|
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
|
pass
|
||||||
|
|
||||||
# Sync auto_shutdown and zone_mode in runtime state
|
# Sync auto_shutdown and zone_mode in runtime state
|
||||||
@@ -377,8 +378,8 @@ async def update_device(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update device: {e}")
|
logger.error("Failed to update device: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"])
|
@router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"])
|
||||||
@@ -417,8 +418,8 @@ async def delete_device(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete device: {e}")
|
logger.error("Failed to delete device: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
# ===== DEVICE STATE (health only) =====
|
# ===== DEVICE STATE (health only) =====
|
||||||
@@ -654,6 +655,7 @@ async def device_ws_stream(
|
|||||||
while True:
|
while True:
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("Device event WebSocket disconnected for %s", device_id)
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
broadcaster.remove_client(device_id, websocket)
|
broadcaster.remove_client(device_id, websocket)
|
||||||
|
|||||||
306
server/src/wled_controller/api/routes/home_assistant.py
Normal file
306
server/src/wled_controller/api/routes/home_assistant.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
"""Home Assistant source routes: CRUD + test + entity list + status."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_ha_manager,
|
||||||
|
get_ha_store,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.home_assistant import (
|
||||||
|
HomeAssistantConnectionStatus,
|
||||||
|
HomeAssistantEntityListResponse,
|
||||||
|
HomeAssistantEntityResponse,
|
||||||
|
HomeAssistantSourceCreate,
|
||||||
|
HomeAssistantSourceListResponse,
|
||||||
|
HomeAssistantSourceResponse,
|
||||||
|
HomeAssistantSourceUpdate,
|
||||||
|
HomeAssistantStatusResponse,
|
||||||
|
HomeAssistantTestResponse,
|
||||||
|
)
|
||||||
|
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
|
||||||
|
from wled_controller.core.home_assistant.ha_runtime import HARuntime
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
from wled_controller.storage.home_assistant_source import HomeAssistantSource
|
||||||
|
from wled_controller.storage.home_assistant_store import HomeAssistantStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(
|
||||||
|
source: HomeAssistantSource, manager: HomeAssistantManager
|
||||||
|
) -> HomeAssistantSourceResponse:
|
||||||
|
runtime = manager.get_runtime(source.id)
|
||||||
|
return HomeAssistantSourceResponse(
|
||||||
|
id=source.id,
|
||||||
|
name=source.name,
|
||||||
|
host=source.host,
|
||||||
|
use_ssl=source.use_ssl,
|
||||||
|
entity_filters=source.entity_filters,
|
||||||
|
connected=runtime.is_connected if runtime else False,
|
||||||
|
entity_count=len(runtime.get_all_states()) if runtime else 0,
|
||||||
|
description=source.description,
|
||||||
|
tags=source.tags,
|
||||||
|
created_at=source.created_at,
|
||||||
|
updated_at=source.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/home-assistant/sources",
|
||||||
|
response_model=HomeAssistantSourceListResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def list_ha_sources(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
sources = store.get_all_sources()
|
||||||
|
return HomeAssistantSourceListResponse(
|
||||||
|
sources=[_to_response(s, manager) for s in sources],
|
||||||
|
count=len(sources),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/home-assistant/sources",
|
||||||
|
response_model=HomeAssistantSourceResponse,
|
||||||
|
status_code=201,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def create_ha_source(
|
||||||
|
data: HomeAssistantSourceCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
source = store.create_source(
|
||||||
|
name=data.name,
|
||||||
|
host=data.host,
|
||||||
|
token=data.token,
|
||||||
|
use_ssl=data.use_ssl,
|
||||||
|
entity_filters=data.entity_filters,
|
||||||
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
fire_entity_event("home_assistant_source", "created", source.id)
|
||||||
|
return _to_response(source, manager)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/home-assistant/sources/{source_id}",
|
||||||
|
response_model=HomeAssistantSourceResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def get_ha_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
source = store.get_source(source_id)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||||
|
return _to_response(source, manager)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/home-assistant/sources/{source_id}",
|
||||||
|
response_model=HomeAssistantSourceResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def update_ha_source(
|
||||||
|
source_id: str,
|
||||||
|
data: HomeAssistantSourceUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
source = store.update_source(
|
||||||
|
source_id,
|
||||||
|
name=data.name,
|
||||||
|
host=data.host,
|
||||||
|
token=data.token,
|
||||||
|
use_ssl=data.use_ssl,
|
||||||
|
entity_filters=data.entity_filters,
|
||||||
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
await manager.update_source(source_id)
|
||||||
|
fire_entity_event("home_assistant_source", "updated", source.id)
|
||||||
|
return _to_response(source, manager)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/home-assistant/sources/{source_id}", status_code=204, tags=["Home Assistant"]
|
||||||
|
)
|
||||||
|
async def delete_ha_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
store.delete_source(source_id)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||||
|
# Release any active runtime
|
||||||
|
await manager.release(source_id)
|
||||||
|
fire_entity_event("home_assistant_source", "deleted", source_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/home-assistant/sources/{source_id}/entities",
|
||||||
|
response_model=HomeAssistantEntityListResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def list_ha_entities(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
"""List available entities from a HA instance (live query)."""
|
||||||
|
try:
|
||||||
|
source = store.get_source(source_id)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||||
|
|
||||||
|
# Try cached states first from running runtime
|
||||||
|
runtime = manager.get_runtime(source_id)
|
||||||
|
if runtime and runtime.is_connected:
|
||||||
|
states = runtime.get_all_states()
|
||||||
|
entities = [
|
||||||
|
HomeAssistantEntityResponse(
|
||||||
|
entity_id=s.entity_id,
|
||||||
|
state=s.state,
|
||||||
|
friendly_name=s.attributes.get("friendly_name", s.entity_id),
|
||||||
|
domain=s.entity_id.split(".")[0] if "." in s.entity_id else "",
|
||||||
|
)
|
||||||
|
for s in states.values()
|
||||||
|
]
|
||||||
|
return HomeAssistantEntityListResponse(entities=entities, count=len(entities))
|
||||||
|
|
||||||
|
# No active runtime — do a one-shot fetch
|
||||||
|
temp_runtime = HARuntime(source)
|
||||||
|
try:
|
||||||
|
raw_entities = await temp_runtime.fetch_entities()
|
||||||
|
finally:
|
||||||
|
await temp_runtime.stop()
|
||||||
|
|
||||||
|
entities = [HomeAssistantEntityResponse(**e) for e in raw_entities]
|
||||||
|
return HomeAssistantEntityListResponse(entities=entities, count=len(entities))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/home-assistant/sources/{source_id}/test",
|
||||||
|
response_model=HomeAssistantTestResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def test_ha_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
):
|
||||||
|
"""Test connection to a Home Assistant instance."""
|
||||||
|
try:
|
||||||
|
source = store.get_source(source_id)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError:
|
||||||
|
return HomeAssistantTestResponse(
|
||||||
|
success=False,
|
||||||
|
error="websockets package not installed",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with websockets.connect(source.ws_url) as ws:
|
||||||
|
# Wait for auth_required
|
||||||
|
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
|
||||||
|
if msg.get("type") != "auth_required":
|
||||||
|
return HomeAssistantTestResponse(
|
||||||
|
success=False, error=f"Unexpected message: {msg.get('type')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
await ws.send(json.dumps({"type": "auth", "access_token": source.token}))
|
||||||
|
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
|
||||||
|
if msg.get("type") != "auth_ok":
|
||||||
|
return HomeAssistantTestResponse(
|
||||||
|
success=False, error=msg.get("message", "Auth failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
ha_version = msg.get("ha_version")
|
||||||
|
|
||||||
|
# Get entity count
|
||||||
|
await ws.send(json.dumps({"id": 1, "type": "get_states"}))
|
||||||
|
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
|
||||||
|
entity_count = len(msg.get("result", [])) if msg.get("success") else 0
|
||||||
|
|
||||||
|
return HomeAssistantTestResponse(
|
||||||
|
success=True,
|
||||||
|
ha_version=ha_version,
|
||||||
|
entity_count=entity_count,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return HomeAssistantTestResponse(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/home-assistant/status",
|
||||||
|
response_model=HomeAssistantStatusResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def get_ha_status(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
"""Get overall HA integration status (for dashboard indicators)."""
|
||||||
|
all_sources = store.get_all_sources()
|
||||||
|
conn_statuses = manager.get_connection_status()
|
||||||
|
|
||||||
|
# Build a map for quick lookup
|
||||||
|
status_map = {s["source_id"]: s for s in conn_statuses}
|
||||||
|
|
||||||
|
connections = []
|
||||||
|
connected_count = 0
|
||||||
|
for source in all_sources:
|
||||||
|
status = status_map.get(source.id)
|
||||||
|
connected = status["connected"] if status else False
|
||||||
|
if connected:
|
||||||
|
connected_count += 1
|
||||||
|
connections.append(
|
||||||
|
HomeAssistantConnectionStatus(
|
||||||
|
source_id=source.id,
|
||||||
|
name=source.name,
|
||||||
|
connected=connected,
|
||||||
|
entity_count=status["entity_count"] if status else 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return HomeAssistantStatusResponse(
|
||||||
|
connections=connections,
|
||||||
|
total_sources=len(all_sources),
|
||||||
|
connected_count=connected_count,
|
||||||
|
)
|
||||||
@@ -25,6 +25,11 @@ from wled_controller.storage.key_colors_output_target import (
|
|||||||
KeyColorsSettings,
|
KeyColorsSettings,
|
||||||
KeyColorsOutputTarget,
|
KeyColorsOutputTarget,
|
||||||
)
|
)
|
||||||
|
from wled_controller.storage.ha_light_output_target import (
|
||||||
|
HALightMapping,
|
||||||
|
HALightOutputTarget,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.output_targets import HALightMappingSchema
|
||||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
from wled_controller.storage.base_store import EntityNotFoundError
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
@@ -76,7 +81,6 @@ def _target_to_response(target) -> OutputTargetResponse:
|
|||||||
protocol=target.protocol,
|
protocol=target.protocol,
|
||||||
description=target.description,
|
description=target.description,
|
||||||
tags=target.tags,
|
tags=target.tags,
|
||||||
|
|
||||||
created_at=target.created_at,
|
created_at=target.created_at,
|
||||||
updated_at=target.updated_at,
|
updated_at=target.updated_at,
|
||||||
)
|
)
|
||||||
@@ -89,7 +93,31 @@ def _target_to_response(target) -> OutputTargetResponse:
|
|||||||
key_colors_settings=_kc_settings_to_schema(target.settings),
|
key_colors_settings=_kc_settings_to_schema(target.settings),
|
||||||
description=target.description,
|
description=target.description,
|
||||||
tags=target.tags,
|
tags=target.tags,
|
||||||
|
created_at=target.created_at,
|
||||||
|
updated_at=target.updated_at,
|
||||||
|
)
|
||||||
|
elif isinstance(target, HALightOutputTarget):
|
||||||
|
return OutputTargetResponse(
|
||||||
|
id=target.id,
|
||||||
|
name=target.name,
|
||||||
|
target_type=target.target_type,
|
||||||
|
ha_source_id=target.ha_source_id,
|
||||||
|
color_strip_source_id=target.color_strip_source_id,
|
||||||
|
ha_light_mappings=[
|
||||||
|
HALightMappingSchema(
|
||||||
|
entity_id=m.entity_id,
|
||||||
|
led_start=m.led_start,
|
||||||
|
led_end=m.led_end,
|
||||||
|
brightness_scale=m.brightness_scale,
|
||||||
|
)
|
||||||
|
for m in target.light_mappings
|
||||||
|
],
|
||||||
|
update_rate=target.update_rate,
|
||||||
|
ha_transition=target.transition,
|
||||||
|
color_tolerance=target.color_tolerance,
|
||||||
|
min_brightness_threshold=target.min_brightness_threshold,
|
||||||
|
description=target.description,
|
||||||
|
tags=target.tags,
|
||||||
created_at=target.created_at,
|
created_at=target.created_at,
|
||||||
updated_at=target.updated_at,
|
updated_at=target.updated_at,
|
||||||
)
|
)
|
||||||
@@ -100,7 +128,6 @@ def _target_to_response(target) -> OutputTargetResponse:
|
|||||||
target_type=target.target_type,
|
target_type=target.target_type,
|
||||||
description=target.description,
|
description=target.description,
|
||||||
tags=target.tags,
|
tags=target.tags,
|
||||||
|
|
||||||
created_at=target.created_at,
|
created_at=target.created_at,
|
||||||
updated_at=target.updated_at,
|
updated_at=target.updated_at,
|
||||||
)
|
)
|
||||||
@@ -108,7 +135,10 @@ def _target_to_response(target) -> OutputTargetResponse:
|
|||||||
|
|
||||||
# ===== CRUD ENDPOINTS =====
|
# ===== CRUD ENDPOINTS =====
|
||||||
|
|
||||||
@router.post("/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201)
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
|
||||||
|
)
|
||||||
async def create_target(
|
async def create_target(
|
||||||
data: OutputTargetCreate,
|
data: OutputTargetCreate,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
@@ -125,7 +155,22 @@ async def create_target(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||||
|
|
||||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
kc_settings = (
|
||||||
|
_kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
||||||
|
)
|
||||||
|
ha_mappings = (
|
||||||
|
[
|
||||||
|
HALightMapping(
|
||||||
|
entity_id=m.entity_id,
|
||||||
|
led_start=m.led_start,
|
||||||
|
led_end=m.led_end,
|
||||||
|
brightness_scale=m.brightness_scale,
|
||||||
|
)
|
||||||
|
for m in data.ha_light_mappings
|
||||||
|
]
|
||||||
|
if data.ha_light_mappings
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
# Create in store
|
# Create in store
|
||||||
target = target_store.create_target(
|
target = target_store.create_target(
|
||||||
@@ -144,6 +189,11 @@ async def create_target(
|
|||||||
key_colors_settings=kc_settings,
|
key_colors_settings=kc_settings,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
|
ha_source_id=data.ha_source_id,
|
||||||
|
ha_light_mappings=ha_mappings,
|
||||||
|
update_rate=data.update_rate,
|
||||||
|
transition=data.transition,
|
||||||
|
color_tolerance=data.color_tolerance,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register in processor manager
|
# Register in processor manager
|
||||||
@@ -163,8 +213,8 @@ async def create_target(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create target: {e}")
|
logger.error("Failed to create target: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/output-targets", response_model=OutputTargetListResponse, tags=["Targets"])
|
@router.get("/api/v1/output-targets", response_model=OutputTargetListResponse, tags=["Targets"])
|
||||||
@@ -196,7 +246,9 @@ async def batch_target_metrics(
|
|||||||
return {"metrics": manager.get_all_target_metrics()}
|
return {"metrics": manager.get_all_target_metrics()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"])
|
@router.get(
|
||||||
|
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
|
||||||
|
)
|
||||||
async def get_target(
|
async def get_target(
|
||||||
target_id: str,
|
target_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
@@ -210,7 +262,9 @@ async def get_target(
|
|||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"])
|
@router.put(
|
||||||
|
"/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]
|
||||||
|
)
|
||||||
async def update_target(
|
async def update_target(
|
||||||
target_id: str,
|
target_id: str,
|
||||||
data: OutputTargetUpdate,
|
data: OutputTargetUpdate,
|
||||||
@@ -246,7 +300,9 @@ async def update_target(
|
|||||||
smoothing=incoming.get("smoothing", ex.smoothing),
|
smoothing=incoming.get("smoothing", ex.smoothing),
|
||||||
pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id),
|
pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id),
|
||||||
brightness=incoming.get("brightness", ex.brightness),
|
brightness=incoming.get("brightness", ex.brightness),
|
||||||
brightness_value_source_id=incoming.get("brightness_value_source_id", ex.brightness_value_source_id),
|
brightness_value_source_id=incoming.get(
|
||||||
|
"brightness_value_source_id", ex.brightness_value_source_id
|
||||||
|
),
|
||||||
)
|
)
|
||||||
kc_settings = _kc_schema_to_settings(merged)
|
kc_settings = _kc_schema_to_settings(merged)
|
||||||
else:
|
else:
|
||||||
@@ -282,23 +338,29 @@ async def update_target(
|
|||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
target.sync_with_manager,
|
target.sync_with_manager,
|
||||||
manager,
|
manager,
|
||||||
settings_changed=(data.fps is not None or
|
settings_changed=(
|
||||||
data.keepalive_interval is not None or
|
data.fps is not None
|
||||||
data.state_check_interval is not None or
|
or data.keepalive_interval is not None
|
||||||
data.min_brightness_threshold is not None or
|
or data.state_check_interval is not None
|
||||||
data.adaptive_fps is not None or
|
or data.min_brightness_threshold is not None
|
||||||
data.key_colors_settings is not None),
|
or data.adaptive_fps is not None
|
||||||
|
or data.key_colors_settings is not None
|
||||||
|
),
|
||||||
css_changed=data.color_strip_source_id is not None,
|
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),
|
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
|
pass
|
||||||
|
|
||||||
# Device change requires async stop -> swap -> start cycle
|
# Device change requires async stop -> swap -> start cycle
|
||||||
if data.device_id is not None:
|
if data.device_id is not None:
|
||||||
try:
|
try:
|
||||||
await manager.update_target_device(target_id, target.device_id)
|
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
|
pass
|
||||||
|
|
||||||
fire_entity_event("output_target", "updated", target_id)
|
fire_entity_event("output_target", "updated", target_id)
|
||||||
@@ -309,8 +371,8 @@ async def update_target(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update target: {e}")
|
logger.error("Failed to update target: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/output-targets/{target_id}", status_code=204, tags=["Targets"])
|
@router.delete("/api/v1/output-targets/{target_id}", status_code=204, tags=["Targets"])
|
||||||
@@ -325,13 +387,15 @@ async def delete_target(
|
|||||||
# Stop processing if running
|
# Stop processing if running
|
||||||
try:
|
try:
|
||||||
await manager.stop_processing(target_id)
|
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
|
pass
|
||||||
|
|
||||||
# Remove from manager
|
# Remove from manager
|
||||||
try:
|
try:
|
||||||
manager.remove_target(target_id)
|
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
|
pass
|
||||||
|
|
||||||
# Delete from store
|
# Delete from store
|
||||||
@@ -343,5 +407,5 @@ async def delete_target(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete target: {e}")
|
logger.error("Failed to delete target: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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}'")
|
msg = msg.replace(t.id, f"'{t.name}'")
|
||||||
raise HTTPException(status_code=409, detail=msg)
|
raise HTTPException(status_code=409, detail=msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start processing: {e}")
|
logger.error("Failed to start processing: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
|
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
|
||||||
@@ -140,8 +140,8 @@ async def stop_processing(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to stop processing: {e}")
|
logger.error("Failed to stop processing: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
# ===== STATE & METRICS ENDPOINTS =====
|
# ===== STATE & METRICS ENDPOINTS =====
|
||||||
@@ -160,8 +160,8 @@ async def get_target_state(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get target state: {e}")
|
logger.error("Failed to get target state: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get target metrics: {e}")
|
logger.error("Failed to get target metrics: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
# ===== STATE CHANGE EVENT STREAM =====
|
# ===== STATE CHANGE EVENT STREAM =====
|
||||||
@@ -268,8 +268,8 @@ async def start_target_overlay(
|
|||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
raise HTTPException(status_code=409, detail=str(e))
|
raise HTTPException(status_code=409, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start overlay: {e}", exc_info=True)
|
logger.error("Failed to start overlay: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to stop overlay: {e}", exc_info=True)
|
logger.error("Failed to stop overlay: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"])
|
@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),
|
pp_template_store=Depends(get_pp_template_store),
|
||||||
):
|
):
|
||||||
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
|
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
|
||||||
import httpx
|
|
||||||
|
|
||||||
stream = None
|
stream = None
|
||||||
try:
|
try:
|
||||||
@@ -130,21 +129,16 @@ async def test_kc_target(
|
|||||||
|
|
||||||
raw_stream = chain["raw_stream"]
|
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):
|
if isinstance(raw_stream, StaticImagePictureSource):
|
||||||
source = raw_stream.image_source
|
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
|
||||||
if source.startswith(("http://", "https://")):
|
|
||||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
asset_store = _get_asset_store()
|
||||||
resp = await client.get(source)
|
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
|
||||||
resp.raise_for_status()
|
if not image_path:
|
||||||
image = load_image_bytes(resp.content)
|
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
|
||||||
else:
|
image = load_image_file(image_path)
|
||||||
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)
|
|
||||||
|
|
||||||
elif isinstance(raw_stream, ScreenCapturePictureSource):
|
elif isinstance(raw_stream, ScreenCapturePictureSource):
|
||||||
try:
|
try:
|
||||||
@@ -264,10 +258,11 @@ async def test_kc_target(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except RuntimeError as 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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to test KC target: {e}", exc_info=True)
|
logger.error("Failed to test KC target: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
finally:
|
finally:
|
||||||
if stream:
|
if stream:
|
||||||
try:
|
try:
|
||||||
@@ -420,7 +415,8 @@ async def test_kc_target_ws(
|
|||||||
for pp_id in pp_template_ids:
|
for pp_id in pp_template_ids:
|
||||||
try:
|
try:
|
||||||
pp_template = pp_template_store_inst.get_template(pp_id)
|
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
|
continue
|
||||||
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
|
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
|
||||||
for fi in flat_filters:
|
for fi in flat_filters:
|
||||||
@@ -429,7 +425,8 @@ async def test_kc_target_ws(
|
|||||||
result = f.process_image(cur_image, image_pool)
|
result = f.process_image(cur_image, image_pool)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
cur_image = result
|
cur_image = result
|
||||||
except ValueError:
|
except ValueError as e:
|
||||||
|
logger.debug("Filter processing error during KC test: %s", e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Extract colors
|
# Extract colors
|
||||||
@@ -492,7 +489,8 @@ async def test_kc_target_ws(
|
|||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
live_stream_mgr.release, target.picture_source_id
|
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
|
pass
|
||||||
logger.info(f"KC test WS closed for {target_id}")
|
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)
|
# Keep alive — wait for client messages (or disconnect)
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("KC live WebSocket disconnected for target %s", target_id)
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
manager.remove_kc_ws_client(target_id, websocket)
|
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]
|
responses = [_pat_template_to_response(t) for t in templates]
|
||||||
return PatternTemplateListResponse(templates=responses, count=len(responses))
|
return PatternTemplateListResponse(templates=responses, count=len(responses))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list pattern templates: {e}")
|
logger.error("Failed to list pattern templates: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/pattern-templates", response_model=PatternTemplateResponse, tags=["Pattern Templates"], status_code=201)
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create pattern template: {e}")
|
logger.error("Failed to create pattern template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/pattern-templates/{template_id}", response_model=PatternTemplateResponse, tags=["Pattern Templates"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update pattern template: {e}")
|
logger.error("Failed to update pattern template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/pattern-templates/{template_id}", status_code=204, tags=["Pattern Templates"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete pattern template: {e}")
|
logger.error("Failed to delete pattern template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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),
|
target_fps=getattr(s, "target_fps", None),
|
||||||
source_stream_id=getattr(s, "source_stream_id", None),
|
source_stream_id=getattr(s, "source_stream_id", None),
|
||||||
postprocessing_template_id=getattr(s, "postprocessing_template_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,
|
created_at=s.created_at,
|
||||||
updated_at=s.updated_at,
|
updated_at=s.updated_at,
|
||||||
description=s.description,
|
description=s.description,
|
||||||
tags=s.tags,
|
tags=s.tags,
|
||||||
# Video fields
|
# Video fields
|
||||||
url=getattr(s, "url", None),
|
video_asset_id=getattr(s, "video_asset_id", None),
|
||||||
loop=getattr(s, "loop", None),
|
loop=getattr(s, "loop", None),
|
||||||
playback_speed=getattr(s, "playback_speed", None),
|
playback_speed=getattr(s, "playback_speed", None),
|
||||||
start_time=getattr(s, "start_time", 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]
|
responses = [_stream_to_response(s) for s in streams]
|
||||||
return PictureSourceListResponse(streams=responses, count=len(responses))
|
return PictureSourceListResponse(streams=responses, count=len(responses))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list picture sources: {e}")
|
logger.error("Failed to list picture sources: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/picture-sources/validate-image", response_model=ImageValidateResponse, tags=["Picture Sources"])
|
@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."""
|
"""Validate an image source (URL or file path) and return a preview thumbnail."""
|
||||||
try:
|
try:
|
||||||
from pathlib import Path
|
|
||||||
|
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
|
||||||
|
|
||||||
source = data.image_source.strip()
|
source = data.image_source.strip()
|
||||||
if not source:
|
if not source:
|
||||||
return ImageValidateResponse(valid=False, error="Image source is empty")
|
return ImageValidateResponse(valid=False, error="Image source is empty")
|
||||||
|
|
||||||
if source.startswith(("http://", "https://")):
|
if source.startswith(("http://", "https://")):
|
||||||
|
validate_image_url(source)
|
||||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||||
response = await client.get(source)
|
response = await client.get(source)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
img_bytes = response.content
|
img_bytes = response.content
|
||||||
else:
|
else:
|
||||||
path = Path(source)
|
path = validate_image_path(source)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
|
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
|
||||||
img_bytes = path
|
img_bytes = path
|
||||||
@@ -147,16 +149,18 @@ async def get_full_image(
|
|||||||
source: str = Query(..., description="Image URL or local file path"),
|
source: str = Query(..., description="Image URL or local file path"),
|
||||||
):
|
):
|
||||||
"""Serve the full-resolution image for lightbox preview."""
|
"""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:
|
try:
|
||||||
if source.startswith(("http://", "https://")):
|
if source.startswith(("http://", "https://")):
|
||||||
|
validate_image_url(source)
|
||||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||||
response = await client.get(source)
|
response = await client.get(source)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
img_bytes = response.content
|
img_bytes = response.content
|
||||||
else:
|
else:
|
||||||
path = Path(source)
|
path = validate_image_path(source)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
img_bytes = path
|
img_bytes = path
|
||||||
@@ -215,11 +219,11 @@ async def create_picture_source(
|
|||||||
target_fps=data.target_fps,
|
target_fps=data.target_fps,
|
||||||
source_stream_id=data.source_stream_id,
|
source_stream_id=data.source_stream_id,
|
||||||
postprocessing_template_id=data.postprocessing_template_id,
|
postprocessing_template_id=data.postprocessing_template_id,
|
||||||
image_source=data.image_source,
|
image_asset_id=data.image_asset_id,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
# Video fields
|
# Video fields
|
||||||
url=data.url,
|
video_asset_id=data.video_asset_id,
|
||||||
loop=data.loop,
|
loop=data.loop,
|
||||||
playback_speed=data.playback_speed,
|
playback_speed=data.playback_speed,
|
||||||
start_time=data.start_time,
|
start_time=data.start_time,
|
||||||
@@ -237,8 +241,8 @@ async def create_picture_source(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create picture source: {e}")
|
logger.error("Failed to create picture source: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
|
@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,
|
target_fps=data.target_fps,
|
||||||
source_stream_id=data.source_stream_id,
|
source_stream_id=data.source_stream_id,
|
||||||
postprocessing_template_id=data.postprocessing_template_id,
|
postprocessing_template_id=data.postprocessing_template_id,
|
||||||
image_source=data.image_source,
|
image_asset_id=data.image_asset_id,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
# Video fields
|
# Video fields
|
||||||
url=data.url,
|
video_asset_id=data.video_asset_id,
|
||||||
loop=data.loop,
|
loop=data.loop,
|
||||||
playback_speed=data.playback_speed,
|
playback_speed=data.playback_speed,
|
||||||
start_time=data.start_time,
|
start_time=data.start_time,
|
||||||
@@ -292,8 +296,8 @@ async def update_picture_source(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update picture source: {e}")
|
logger.error("Failed to update picture source: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/picture-sources/{stream_id}", status_code=204, tags=["Picture Sources"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete picture source: {e}")
|
logger.error("Failed to delete picture source: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/picture-sources/{stream_id}/thumbnail", tags=["Picture Sources"])
|
@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):
|
if not isinstance(source, VideoCaptureSource):
|
||||||
raise HTTPException(status_code=400, detail="Not a video source")
|
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(
|
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:
|
if frame is None:
|
||||||
raise HTTPException(status_code=404, detail="Could not extract thumbnail")
|
raise HTTPException(status_code=404, detail="Could not extract thumbnail")
|
||||||
@@ -360,8 +371,8 @@ async def get_video_thumbnail(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to extract video thumbnail: {e}")
|
logger.error("Failed to extract video thumbnail: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
|
@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"]
|
raw_stream = chain["raw_stream"]
|
||||||
|
|
||||||
if isinstance(raw_stream, StaticImagePictureSource):
|
if isinstance(raw_stream, StaticImagePictureSource):
|
||||||
# Static image stream: load image directly, no engine needed
|
# Static image stream: load image from asset
|
||||||
from pathlib import Path
|
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()
|
start_time = time.perf_counter()
|
||||||
|
image = await asyncio.to_thread(load_image_file, image_path)
|
||||||
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)
|
|
||||||
|
|
||||||
actual_duration = time.perf_counter() - start_time
|
actual_duration = time.perf_counter() - start_time
|
||||||
frame_count = 1
|
frame_count = 1
|
||||||
@@ -543,10 +547,11 @@ async def test_picture_source(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except RuntimeError as 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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to test picture source: {e}", exc_info=True)
|
logger.error("Failed to test picture source: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
finally:
|
finally:
|
||||||
if stream:
|
if stream:
|
||||||
try:
|
try:
|
||||||
@@ -602,12 +607,19 @@ async def test_picture_source_ws(
|
|||||||
# Video sources: use VideoCaptureLiveStream for test preview
|
# Video sources: use VideoCaptureLiveStream for test preview
|
||||||
if isinstance(raw_stream, VideoCaptureSource):
|
if isinstance(raw_stream, VideoCaptureSource):
|
||||||
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
|
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()
|
await websocket.accept()
|
||||||
logger.info(f"Video source test WS connected for {stream_id} ({duration}s)")
|
logger.info(f"Video source test WS connected for {stream_id} ({duration}s)")
|
||||||
|
|
||||||
video_stream = VideoCaptureLiveStream(
|
video_stream = VideoCaptureLiveStream(
|
||||||
url=raw_stream.url,
|
url=str(video_path),
|
||||||
loop=raw_stream.loop,
|
loop=raw_stream.loop,
|
||||||
playback_speed=raw_stream.playback_speed,
|
playback_speed=raw_stream.playback_speed,
|
||||||
start_time=raw_stream.start_time,
|
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),
|
"avg_fps": round(frame_count / max(duration, 0.001), 1),
|
||||||
})
|
})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("Video source test WebSocket disconnected for %s", stream_id)
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Video source test WS error for {stream_id}: {e}")
|
logger.error(f"Video source test WS error for {stream_id}: {e}")
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "error", "detail": str(e)})
|
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
|
pass
|
||||||
finally:
|
finally:
|
||||||
video_stream.stop()
|
video_stream.stop()
|
||||||
@@ -697,7 +711,8 @@ async def test_picture_source_ws(
|
|||||||
try:
|
try:
|
||||||
pp_template = pp_store.get_template(pp_template_ids[0])
|
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||||
pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
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
|
pass
|
||||||
|
|
||||||
# Engine factory — creates + initializes engine inside the capture thread
|
# 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,
|
preview_width=preview_width or None,
|
||||||
)
|
)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("Picture source test WebSocket disconnected for %s", stream_id)
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Picture source test WS error for {stream_id}: {e}")
|
logger.error(f"Picture source test WS error for {stream_id}: {e}")
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
@@ -87,8 +86,8 @@ async def create_pp_template(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create postprocessing template: {e}")
|
logger.error("Failed to create postprocessing template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update postprocessing template: {e}")
|
logger.error("Failed to update postprocessing template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/postprocessing-templates/{template_id}", status_code=204, tags=["Postprocessing Templates"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete postprocessing template: {e}")
|
logger.error("Failed to delete postprocessing template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"])
|
@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 (
|
from wled_controller.utils.image_codec import (
|
||||||
encode_jpeg_data_uri,
|
encode_jpeg_data_uri,
|
||||||
load_image_bytes,
|
|
||||||
load_image_file,
|
load_image_file,
|
||||||
thumbnail as make_thumbnail,
|
thumbnail as make_thumbnail,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(raw_stream, StaticImagePictureSource):
|
if isinstance(raw_stream, StaticImagePictureSource):
|
||||||
# Static image: load directly
|
# Static image: load from asset
|
||||||
from pathlib import 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")
|
||||||
|
|
||||||
source = raw_stream.image_source
|
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
|
image = load_image_file(image_path)
|
||||||
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)
|
|
||||||
|
|
||||||
actual_duration = time.perf_counter() - start_time
|
actual_duration = time.perf_counter() - start_time
|
||||||
frame_count = 1
|
frame_count = 1
|
||||||
@@ -330,13 +322,14 @@ async def test_pp_template(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Postprocessing template test failed: {e}")
|
logger.error("Postprocessing template test failed: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
finally:
|
finally:
|
||||||
if stream:
|
if stream:
|
||||||
try:
|
try:
|
||||||
stream.cleanup()
|
stream.cleanup()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("PP test capture stream cleanup: %s", e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -434,6 +427,7 @@ async def test_pp_template_ws(
|
|||||||
preview_width=preview_width or None,
|
preview_width=preview_width or None,
|
||||||
)
|
)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("PP template test WebSocket disconnected for %s", template_id)
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"PP template test WS error for {template_id}: {e}")
|
logger.error(f"PP template test WS error for {template_id}: {e}")
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import sys
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from wled_controller import __version__
|
from wled_controller import __version__, REPO_URL, DONATE_URL
|
||||||
from wled_controller.api.auth import AuthRequired, is_auth_enabled
|
from wled_controller.api.auth import AuthRequired, is_auth_enabled
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
get_audio_source_store,
|
get_audio_source_store,
|
||||||
@@ -21,6 +23,8 @@ from wled_controller.api.dependencies import (
|
|||||||
get_automation_store,
|
get_automation_store,
|
||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_device_store,
|
get_device_store,
|
||||||
|
get_ha_manager,
|
||||||
|
get_ha_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_pattern_template_store,
|
get_pattern_template_store,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
@@ -50,11 +54,17 @@ from wled_controller.api.routes.system_settings import load_external_url # noqa
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# Prime psutil CPU counter (first call always returns 0.0)
|
# Prime psutil CPU counters (first call always returns 0.0)
|
||||||
psutil.cpu_percent(interval=None)
|
psutil.cpu_percent(interval=None)
|
||||||
|
_process = psutil.Process(os.getpid())
|
||||||
|
_process.cpu_percent(interval=None) # prime process-level counter
|
||||||
|
|
||||||
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
|
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
|
||||||
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle # noqa: E402
|
from wled_controller.utils.gpu import ( # noqa: E402
|
||||||
|
nvml_available as _nvml_available,
|
||||||
|
nvml as _nvml,
|
||||||
|
nvml_handle as _nvml_handle,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_cpu_name() -> str | None:
|
def _get_cpu_name() -> str | None:
|
||||||
@@ -77,9 +87,7 @@ def _get_cpu_name() -> str | None:
|
|||||||
return line.split(":")[1].strip()
|
return line.split(":")[1].strip()
|
||||||
elif platform.system() == "Darwin":
|
elif platform.system() == "Darwin":
|
||||||
return (
|
return (
|
||||||
subprocess.check_output(
|
subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"])
|
||||||
["sysctl", "-n", "machdep.cpu.brand_string"]
|
|
||||||
)
|
|
||||||
.decode()
|
.decode()
|
||||||
.strip()
|
.strip()
|
||||||
)
|
)
|
||||||
@@ -107,6 +115,8 @@ async def health_check():
|
|||||||
version=__version__,
|
version=__version__,
|
||||||
demo_mode=get_config().demo,
|
demo_mode=get_config().demo,
|
||||||
auth_required=is_auth_enabled(),
|
auth_required=is_auth_enabled(),
|
||||||
|
repo_url=REPO_URL,
|
||||||
|
donate_url=DONATE_URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -130,17 +140,29 @@ async def get_version():
|
|||||||
async def list_all_tags(_: AuthRequired):
|
async def list_all_tags(_: AuthRequired):
|
||||||
"""Get all tags used across all entities."""
|
"""Get all tags used across all entities."""
|
||||||
all_tags: set[str] = set()
|
all_tags: set[str] = set()
|
||||||
|
from wled_controller.api.dependencies import get_asset_store
|
||||||
|
|
||||||
store_getters = [
|
store_getters = [
|
||||||
get_device_store, get_output_target_store, get_color_strip_store,
|
get_device_store,
|
||||||
get_picture_source_store, get_audio_source_store, get_value_source_store,
|
get_output_target_store,
|
||||||
get_sync_clock_store, get_automation_store, get_scene_preset_store,
|
get_color_strip_store,
|
||||||
get_template_store, get_audio_template_store, get_pp_template_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:
|
for getter in store_getters:
|
||||||
try:
|
try:
|
||||||
store = getter()
|
store = getter()
|
||||||
except RuntimeError:
|
except RuntimeError as e:
|
||||||
|
logger.debug("Store not available during entity count: %s", e)
|
||||||
continue
|
continue
|
||||||
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
|
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
|
||||||
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
|
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
|
||||||
@@ -207,15 +229,11 @@ async def get_displays(
|
|||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as 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(
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to retrieve display information: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/system/processes", response_model=ProcessListResponse, tags=["Config"])
|
@router.get("/api/v1/system/processes", response_model=ProcessListResponse, tags=["Config"])
|
||||||
@@ -232,11 +250,8 @@ async def get_running_processes(_: AuthRequired):
|
|||||||
sorted_procs = sorted(processes)
|
sorted_procs = sorted(processes)
|
||||||
return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs))
|
return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs))
|
||||||
except Exception as e:
|
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(
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to retrieve process list: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -253,20 +268,36 @@ def get_system_performance(_: AuthRequired):
|
|||||||
"""
|
"""
|
||||||
mem = psutil.virtual_memory()
|
mem = psutil.virtual_memory()
|
||||||
|
|
||||||
|
# App-level metrics
|
||||||
|
proc_mem = _process.memory_info()
|
||||||
|
app_cpu = _process.cpu_percent(interval=None)
|
||||||
|
app_ram_mb = round(proc_mem.rss / 1024 / 1024, 1)
|
||||||
|
|
||||||
gpu = None
|
gpu = None
|
||||||
if _nvml_available:
|
if _nvml_available:
|
||||||
try:
|
try:
|
||||||
util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle)
|
util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle)
|
||||||
mem_info = _nvml.nvmlDeviceGetMemoryInfo(_nvml_handle)
|
mem_info = _nvml.nvmlDeviceGetMemoryInfo(_nvml_handle)
|
||||||
temp = _nvml.nvmlDeviceGetTemperature(
|
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
|
||||||
_nvml_handle, _nvml.NVML_TEMPERATURE_GPU
|
|
||||||
)
|
# App GPU memory: sum memory used by this process on the GPU
|
||||||
|
app_gpu_mem: float | None = None
|
||||||
|
try:
|
||||||
|
pid = os.getpid()
|
||||||
|
for proc_info in _nvml.nvmlDeviceGetComputeRunningProcesses(_nvml_handle):
|
||||||
|
if proc_info.pid == pid and proc_info.usedGpuMemory:
|
||||||
|
app_gpu_mem = round(proc_info.usedGpuMemory / 1024 / 1024, 1)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass # not all drivers support per-process queries
|
||||||
|
|
||||||
gpu = GpuInfo(
|
gpu = GpuInfo(
|
||||||
name=_nvml.nvmlDeviceGetName(_nvml_handle),
|
name=_nvml.nvmlDeviceGetName(_nvml_handle),
|
||||||
utilization=float(util.gpu),
|
utilization=float(util.gpu),
|
||||||
memory_used_mb=round(mem_info.used / 1024 / 1024, 1),
|
memory_used_mb=round(mem_info.used / 1024 / 1024, 1),
|
||||||
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
|
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
|
||||||
temperature_c=float(temp),
|
temperature_c=float(temp),
|
||||||
|
app_memory_mb=app_gpu_mem,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("NVML query failed: %s", e)
|
logger.debug("NVML query failed: %s", e)
|
||||||
@@ -277,6 +308,8 @@ def get_system_performance(_: AuthRequired):
|
|||||||
ram_used_mb=round(mem.used / 1024 / 1024, 1),
|
ram_used_mb=round(mem.used / 1024 / 1024, 1),
|
||||||
ram_total_mb=round(mem.total / 1024 / 1024, 1),
|
ram_total_mb=round(mem.total / 1024 / 1024, 1),
|
||||||
ram_percent=mem.percent,
|
ram_percent=mem.percent,
|
||||||
|
app_cpu_percent=app_cpu,
|
||||||
|
app_ram_mb=app_ram_mb,
|
||||||
gpu=gpu,
|
gpu=gpu,
|
||||||
timestamp=datetime.now(timezone.utc),
|
timestamp=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
@@ -300,7 +333,56 @@ def list_api_keys(_: AuthRequired):
|
|||||||
"""List API key labels (read-only; keys are defined in the YAML config file)."""
|
"""List API key labels (read-only; keys are defined in the YAML config file)."""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
keys = [
|
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()
|
for label, key in config.auth.api_keys.items()
|
||||||
]
|
]
|
||||||
return {"keys": keys, "count": len(keys)}
|
return {"keys": keys, "count": len(keys)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/system/integrations-status", tags=["System"])
|
||||||
|
async def get_integrations_status(
|
||||||
|
_: AuthRequired,
|
||||||
|
ha_store=Depends(get_ha_store),
|
||||||
|
ha_manager=Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
"""Return connection status for external integrations (MQTT, Home Assistant).
|
||||||
|
|
||||||
|
Used by the dashboard to show connectivity indicators.
|
||||||
|
"""
|
||||||
|
from wled_controller.core.devices.mqtt_client import get_mqtt_service
|
||||||
|
|
||||||
|
# MQTT status
|
||||||
|
mqtt_service = get_mqtt_service()
|
||||||
|
mqtt_config = get_config().mqtt
|
||||||
|
mqtt_status = {
|
||||||
|
"enabled": mqtt_config.enabled,
|
||||||
|
"connected": mqtt_service.is_connected if mqtt_service else False,
|
||||||
|
"broker": (
|
||||||
|
f"{mqtt_config.broker_host}:{mqtt_config.broker_port}" if mqtt_config.enabled else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Home Assistant status
|
||||||
|
ha_sources = ha_store.get_all_sources()
|
||||||
|
ha_connections = ha_manager.get_connection_status()
|
||||||
|
ha_status_map = {s["source_id"]: s for s in ha_connections}
|
||||||
|
ha_items = []
|
||||||
|
for source in ha_sources:
|
||||||
|
status = ha_status_map.get(source.id)
|
||||||
|
ha_items.append(
|
||||||
|
{
|
||||||
|
"source_id": source.id,
|
||||||
|
"name": source.name,
|
||||||
|
"connected": status["connected"] if status else False,
|
||||||
|
"entity_count": status["entity_count"] if status else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mqtt": mqtt_status,
|
||||||
|
"home_assistant": {
|
||||||
|
"sources": ha_items,
|
||||||
|
"total": len(ha_sources),
|
||||||
|
"connected": sum(1 for s in ha_items if s["connected"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -191,8 +191,10 @@ async def logs_ws(
|
|||||||
except Exception:
|
except Exception:
|
||||||
break
|
break
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("Log stream WebSocket disconnected")
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Log stream WebSocket error: %s", e)
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
log_broadcaster.unsubscribe(queue)
|
log_broadcaster.unsubscribe(queue)
|
||||||
@@ -287,6 +289,7 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
|||||||
address = request.address.strip()
|
address = request.address.strip()
|
||||||
if not address:
|
if not address:
|
||||||
raise HTTPException(status_code=400, detail="Address is required")
|
raise HTTPException(status_code=400, detail="Address is required")
|
||||||
|
_validate_adb_address(address)
|
||||||
|
|
||||||
adb = _get_adb_path()
|
adb = _get_adb_path()
|
||||||
logger.info(f"Disconnecting ADB device: {address}")
|
logger.info(f"Disconnecting ADB device: {address}")
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ async def list_templates(
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list templates: {e}")
|
logger.error("Failed to list templates: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/capture-templates", response_model=TemplateResponse, tags=["Templates"], status_code=201)
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create template: {e}")
|
logger.error("Failed to create template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update template: {e}")
|
logger.error("Failed to update template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/capture-templates/{template_id}", status_code=204, tags=["Templates"])
|
@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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete template: {e}")
|
logger.error("Failed to delete template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
|
@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))
|
return EngineListResponse(engines=engines, count=len(engines))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list engines: {e}")
|
logger.error("Failed to list engines: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
|
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
|
||||||
@@ -365,10 +365,11 @@ def test_template(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except RuntimeError as 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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to test template: {e}", exc_info=True)
|
logger.error("Failed to test template: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
finally:
|
finally:
|
||||||
if stream:
|
if stream:
|
||||||
try:
|
try:
|
||||||
@@ -432,6 +433,7 @@ async def test_template_ws(
|
|||||||
try:
|
try:
|
||||||
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
|
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("Capture template test WebSocket disconnected")
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Capture template test WS error: {e}")
|
logger.error(f"Capture template test WS error: {e}")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import JSONResponse
|
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.dependencies import get_update_service
|
||||||
from wled_controller.api.schemas.update import (
|
from wled_controller.api.schemas.update import (
|
||||||
DismissRequest,
|
DismissRequest,
|
||||||
@@ -20,6 +21,7 @@ router = APIRouter(prefix="/api/v1/system/update", tags=["update"])
|
|||||||
|
|
||||||
@router.get("/status", response_model=UpdateStatusResponse)
|
@router.get("/status", response_model=UpdateStatusResponse)
|
||||||
async def get_update_status(
|
async def get_update_status(
|
||||||
|
_: AuthRequired,
|
||||||
service: UpdateService = Depends(get_update_service),
|
service: UpdateService = Depends(get_update_service),
|
||||||
):
|
):
|
||||||
return service.get_status()
|
return service.get_status()
|
||||||
@@ -27,6 +29,7 @@ async def get_update_status(
|
|||||||
|
|
||||||
@router.post("/check", response_model=UpdateStatusResponse)
|
@router.post("/check", response_model=UpdateStatusResponse)
|
||||||
async def check_for_updates(
|
async def check_for_updates(
|
||||||
|
_: AuthRequired,
|
||||||
service: UpdateService = Depends(get_update_service),
|
service: UpdateService = Depends(get_update_service),
|
||||||
):
|
):
|
||||||
return await service.check_now()
|
return await service.check_now()
|
||||||
@@ -34,6 +37,7 @@ async def check_for_updates(
|
|||||||
|
|
||||||
@router.post("/dismiss")
|
@router.post("/dismiss")
|
||||||
async def dismiss_update(
|
async def dismiss_update(
|
||||||
|
_: AuthRequired,
|
||||||
body: DismissRequest,
|
body: DismissRequest,
|
||||||
service: UpdateService = Depends(get_update_service),
|
service: UpdateService = Depends(get_update_service),
|
||||||
):
|
):
|
||||||
@@ -43,6 +47,7 @@ async def dismiss_update(
|
|||||||
|
|
||||||
@router.post("/apply")
|
@router.post("/apply")
|
||||||
async def apply_update(
|
async def apply_update(
|
||||||
|
_: AuthRequired,
|
||||||
service: UpdateService = Depends(get_update_service),
|
service: UpdateService = Depends(get_update_service),
|
||||||
):
|
):
|
||||||
"""Download (if needed) and apply the available update."""
|
"""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"}
|
return {"ok": True, "message": "Update applied, server shutting down"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to apply update: %s", exc, exc_info=True)
|
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)
|
@router.get("/settings", response_model=UpdateSettingsResponse)
|
||||||
async def get_update_settings(
|
async def get_update_settings(
|
||||||
|
_: AuthRequired,
|
||||||
service: UpdateService = Depends(get_update_service),
|
service: UpdateService = Depends(get_update_service),
|
||||||
):
|
):
|
||||||
return service.get_settings()
|
return service.get_settings()
|
||||||
@@ -71,6 +77,7 @@ async def get_update_settings(
|
|||||||
|
|
||||||
@router.put("/settings", response_model=UpdateSettingsResponse)
|
@router.put("/settings", response_model=UpdateSettingsResponse)
|
||||||
async def update_update_settings(
|
async def update_update_settings(
|
||||||
|
_: AuthRequired,
|
||||||
body: UpdateSettingsRequest,
|
body: UpdateSettingsRequest,
|
||||||
service: UpdateService = Depends(get_update_service),
|
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 websocket.send_json({"value": round(value, 4)})
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
logger.debug("Value source test WebSocket disconnected for %s", source_id)
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Value source test WebSocket error for {source_id}: {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.
|
the secret token itself authenticates the caller.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@ def _check_rate_limit(client_ip: str) -> None:
|
|||||||
)
|
)
|
||||||
_rate_hits[client_ip].append(now)
|
_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):
|
class WebhookPayload(BaseModel):
|
||||||
action: str = Field(description="'activate' or 'deactivate'")
|
action: str = Field(description="'activate' or 'deactivate'")
|
||||||
@@ -68,7 +75,7 @@ async def handle_webhook(
|
|||||||
# Find the automation that owns this token
|
# Find the automation that owns this token
|
||||||
for automation in store.get_all_automations():
|
for automation in store.get_all_automations():
|
||||||
for condition in automation.conditions:
|
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"
|
active = body.action == "activate"
|
||||||
await engine.set_webhook_state(token, active)
|
await engine.set_webhook_state(token, active)
|
||||||
logger.info(
|
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")
|
||||||
@@ -12,21 +12,41 @@ class ConditionSchema(BaseModel):
|
|||||||
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
|
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
|
||||||
# Application condition fields
|
# Application condition fields
|
||||||
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
|
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
|
||||||
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
|
match_type: Optional[str] = Field(
|
||||||
|
None, description="'running' or 'topmost' (for application condition)"
|
||||||
|
)
|
||||||
# Time-of-day condition fields
|
# Time-of-day condition fields
|
||||||
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day condition)")
|
start_time: Optional[str] = Field(
|
||||||
|
None, description="Start time HH:MM (for time_of_day condition)"
|
||||||
|
)
|
||||||
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
|
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
|
||||||
# System idle condition fields
|
# System idle condition fields
|
||||||
idle_minutes: Optional[int] = Field(None, description="Idle timeout in minutes (for system_idle condition)")
|
idle_minutes: Optional[int] = Field(
|
||||||
when_idle: Optional[bool] = Field(None, description="True=active when idle (for system_idle condition)")
|
None, description="Idle timeout in minutes (for system_idle condition)"
|
||||||
|
)
|
||||||
|
when_idle: Optional[bool] = Field(
|
||||||
|
None, description="True=active when idle (for system_idle condition)"
|
||||||
|
)
|
||||||
# Display state condition fields
|
# Display state condition fields
|
||||||
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
|
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
|
||||||
# MQTT condition fields
|
# MQTT condition fields
|
||||||
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
|
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
|
||||||
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
|
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
|
||||||
match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)")
|
match_mode: Optional[str] = Field(
|
||||||
|
None, description="'exact', 'contains', or 'regex' (for mqtt condition)"
|
||||||
|
)
|
||||||
# Webhook condition fields
|
# Webhook condition fields
|
||||||
token: Optional[str] = Field(None, description="Secret token for webhook URL (for webhook condition)")
|
token: Optional[str] = Field(
|
||||||
|
None, description="Secret token for webhook URL (for webhook condition)"
|
||||||
|
)
|
||||||
|
# Home Assistant condition fields
|
||||||
|
ha_source_id: Optional[str] = Field(
|
||||||
|
None, description="Home Assistant source ID (for home_assistant condition)"
|
||||||
|
)
|
||||||
|
entity_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant condition)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AutomationCreate(BaseModel):
|
class AutomationCreate(BaseModel):
|
||||||
@@ -35,10 +55,16 @@ class AutomationCreate(BaseModel):
|
|||||||
name: str = Field(description="Automation name", min_length=1, max_length=100)
|
name: str = Field(description="Automation name", min_length=1, max_length=100)
|
||||||
enabled: bool = Field(default=True, description="Whether the automation is enabled")
|
enabled: bool = Field(default=True, description="Whether the automation is enabled")
|
||||||
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
|
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
|
||||||
conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions")
|
conditions: List[ConditionSchema] = Field(
|
||||||
|
default_factory=list, description="List of conditions"
|
||||||
|
)
|
||||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||||
deactivation_mode: str = Field(default="none", description="'none', 'revert', or 'fallback_scene'")
|
deactivation_mode: str = Field(
|
||||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
|
default="none", description="'none', 'revert', or 'fallback_scene'"
|
||||||
|
)
|
||||||
|
deactivation_scene_preset_id: Optional[str] = Field(
|
||||||
|
None, description="Scene preset for fallback deactivation"
|
||||||
|
)
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
@@ -47,11 +73,17 @@ class AutomationUpdate(BaseModel):
|
|||||||
|
|
||||||
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
|
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
|
||||||
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
|
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
|
||||||
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
|
condition_logic: Optional[str] = Field(
|
||||||
|
None, description="How conditions combine: 'or' or 'and'"
|
||||||
|
)
|
||||||
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
|
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
|
||||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||||
deactivation_mode: Optional[str] = Field(None, description="'none', 'revert', or 'fallback_scene'")
|
deactivation_mode: Optional[str] = Field(
|
||||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
|
None, description="'none', 'revert', or 'fallback_scene'"
|
||||||
|
)
|
||||||
|
deactivation_scene_preset_id: Optional[str] = Field(
|
||||||
|
None, description="Scene preset for fallback deactivation"
|
||||||
|
)
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -67,10 +99,16 @@ class AutomationResponse(BaseModel):
|
|||||||
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
||||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
|
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
webhook_url: Optional[str] = Field(None, description="Webhook URL for the first webhook condition (if any)")
|
webhook_url: Optional[str] = Field(
|
||||||
|
None, description="Webhook URL for the first webhook condition (if any)"
|
||||||
|
)
|
||||||
is_active: bool = Field(default=False, description="Whether the automation is currently active")
|
is_active: bool = Field(default=False, description="Whether the automation is currently active")
|
||||||
last_activated_at: Optional[datetime] = Field(None, description="Last time this automation was activated")
|
last_activated_at: Optional[datetime] = Field(
|
||||||
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this automation was deactivated")
|
None, description="Last time this automation was activated"
|
||||||
|
)
|
||||||
|
last_deactivated_at: Optional[datetime] = Field(
|
||||||
|
None, description="Last time this automation was deactivated"
|
||||||
|
)
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ from pydantic import BaseModel, Field, model_validator
|
|||||||
from wled_controller.api.schemas.devices import Calibration
|
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):
|
class AnimationConfig(BaseModel):
|
||||||
"""Procedural animation configuration for static/gradient color strip sources."""
|
"""Procedural animation configuration for static/gradient color strip sources."""
|
||||||
|
|
||||||
@@ -93,6 +100,7 @@ class ColorStripSourceCreate(BaseModel):
|
|||||||
# api_input-type fields
|
# api_input-type fields
|
||||||
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)")
|
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)")
|
||||||
timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0)
|
timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0)
|
||||||
|
interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)")
|
||||||
# notification-type fields
|
# notification-type fields
|
||||||
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
|
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
|
||||||
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
|
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
|
||||||
@@ -101,6 +109,9 @@ class ColorStripSourceCreate(BaseModel):
|
|||||||
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
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")
|
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")
|
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
|
# daylight-type fields
|
||||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
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")
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||||
@@ -163,6 +174,7 @@ class ColorStripSourceUpdate(BaseModel):
|
|||||||
# api_input-type fields
|
# api_input-type fields
|
||||||
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
||||||
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0)
|
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0)
|
||||||
|
interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)")
|
||||||
# notification-type fields
|
# notification-type fields
|
||||||
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
|
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
|
||||||
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
|
duration_ms: Optional[int] = Field(None, ge=100, le=10000, description="Effect duration in milliseconds")
|
||||||
@@ -171,6 +183,9 @@ class ColorStripSourceUpdate(BaseModel):
|
|||||||
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
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")
|
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")
|
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
|
# daylight-type fields
|
||||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
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")
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||||
@@ -234,6 +249,7 @@ class ColorStripSourceResponse(BaseModel):
|
|||||||
# api_input-type fields
|
# api_input-type fields
|
||||||
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
||||||
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)")
|
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)")
|
||||||
|
interpolation: Optional[str] = Field(None, description="LED count interpolation mode: none|linear|nearest (api_input type)")
|
||||||
# notification-type fields
|
# notification-type fields
|
||||||
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
|
notification_effect: Optional[str] = Field(None, description="Notification effect: flash|pulse|sweep")
|
||||||
duration_ms: Optional[int] = Field(None, description="Effect duration in milliseconds")
|
duration_ms: Optional[int] = Field(None, description="Effect duration in milliseconds")
|
||||||
@@ -242,6 +258,9 @@ class ColorStripSourceResponse(BaseModel):
|
|||||||
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
|
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")
|
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")
|
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
|
# daylight-type fields
|
||||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
|
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")
|
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||||
|
|||||||
97
server/src/wled_controller/api/schemas/home_assistant.py
Normal file
97
server/src/wled_controller/api/schemas/home_assistant.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Home Assistant source schemas (CRUD + test + entities)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantSourceCreate(BaseModel):
|
||||||
|
"""Request to create a Home Assistant source."""
|
||||||
|
|
||||||
|
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||||
|
host: str = Field(description="HA host:port (e.g. '192.168.1.100:8123')", min_length=1)
|
||||||
|
token: str = Field(description="Long-Lived Access Token", min_length=1)
|
||||||
|
use_ssl: bool = Field(default=False, description="Use wss:// instead of ws://")
|
||||||
|
entity_filters: List[str] = Field(
|
||||||
|
default_factory=list, description="Entity ID filter patterns (e.g. ['sensor.*'])"
|
||||||
|
)
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantSourceUpdate(BaseModel):
|
||||||
|
"""Request to update a Home Assistant source."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||||
|
host: Optional[str] = Field(None, description="HA host:port", min_length=1)
|
||||||
|
token: Optional[str] = Field(None, description="Long-Lived Access Token", min_length=1)
|
||||||
|
use_ssl: Optional[bool] = Field(None, description="Use wss://")
|
||||||
|
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns")
|
||||||
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantSourceResponse(BaseModel):
|
||||||
|
"""Home Assistant source response."""
|
||||||
|
|
||||||
|
id: str = Field(description="Source ID")
|
||||||
|
name: str = Field(description="Source name")
|
||||||
|
host: str = Field(description="HA host:port")
|
||||||
|
use_ssl: bool = Field(description="Whether SSL is enabled")
|
||||||
|
entity_filters: List[str] = Field(default_factory=list, description="Entity filter patterns")
|
||||||
|
connected: bool = Field(default=False, description="Whether the WebSocket connection is active")
|
||||||
|
entity_count: int = Field(default=0, description="Number of cached entities")
|
||||||
|
description: Optional[str] = Field(None, description="Description")
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantSourceListResponse(BaseModel):
|
||||||
|
"""List of Home Assistant sources."""
|
||||||
|
|
||||||
|
sources: List[HomeAssistantSourceResponse] = Field(description="List of HA sources")
|
||||||
|
count: int = Field(description="Number of sources")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantEntityResponse(BaseModel):
|
||||||
|
"""A single HA entity."""
|
||||||
|
|
||||||
|
entity_id: str = Field(description="Entity ID (e.g. 'sensor.temperature')")
|
||||||
|
state: str = Field(description="Current state value")
|
||||||
|
friendly_name: str = Field(description="Human-readable name")
|
||||||
|
domain: str = Field(description="Entity domain (e.g. 'sensor', 'binary_sensor')")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantEntityListResponse(BaseModel):
|
||||||
|
"""List of entities from a HA instance."""
|
||||||
|
|
||||||
|
entities: List[HomeAssistantEntityResponse] = Field(description="List of entities")
|
||||||
|
count: int = Field(description="Number of entities")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantTestResponse(BaseModel):
|
||||||
|
"""Connection test result."""
|
||||||
|
|
||||||
|
success: bool = Field(description="Whether connection and auth succeeded")
|
||||||
|
ha_version: Optional[str] = Field(None, description="Home Assistant version")
|
||||||
|
entity_count: int = Field(default=0, description="Number of entities found")
|
||||||
|
error: Optional[str] = Field(None, description="Error message if connection failed")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantConnectionStatus(BaseModel):
|
||||||
|
"""Connection status for dashboard indicators."""
|
||||||
|
|
||||||
|
source_id: str
|
||||||
|
name: str
|
||||||
|
connected: bool
|
||||||
|
entity_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantStatusResponse(BaseModel):
|
||||||
|
"""Overall HA integration status for dashboard."""
|
||||||
|
|
||||||
|
connections: List[HomeAssistantConnectionStatus]
|
||||||
|
total_sources: int
|
||||||
|
connected_count: int
|
||||||
@@ -22,10 +22,18 @@ class KeyColorsSettingsSchema(BaseModel):
|
|||||||
"""Settings for key colors extraction."""
|
"""Settings for key colors extraction."""
|
||||||
|
|
||||||
fps: int = Field(default=10, description="Extraction rate (1-60)", ge=1, le=60)
|
fps: int = Field(default=10, description="Extraction rate (1-60)", ge=1, le=60)
|
||||||
interpolation_mode: str = Field(default="average", description="Color mode (average, median, dominant)")
|
interpolation_mode: str = Field(
|
||||||
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
default="average", description="Color mode (average, median, dominant)"
|
||||||
pattern_template_id: str = Field(default="", description="Pattern template ID for rectangle layout")
|
)
|
||||||
brightness: float = Field(default=1.0, description="Output brightness (0.0-1.0)", ge=0.0, le=1.0)
|
smoothing: float = Field(
|
||||||
|
default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0
|
||||||
|
)
|
||||||
|
pattern_template_id: str = Field(
|
||||||
|
default="", description="Pattern template ID for rectangle layout"
|
||||||
|
)
|
||||||
|
brightness: float = Field(
|
||||||
|
default=1.0, description="Output brightness (0.0-1.0)", ge=0.0, le=1.0
|
||||||
|
)
|
||||||
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
||||||
|
|
||||||
|
|
||||||
@@ -46,24 +54,79 @@ class KeyColorsResponse(BaseModel):
|
|||||||
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
|
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class HALightMappingSchema(BaseModel):
|
||||||
|
"""Maps an LED range to one HA light entity."""
|
||||||
|
|
||||||
|
entity_id: str = Field(description="HA light entity ID (e.g. 'light.living_room')")
|
||||||
|
led_start: int = Field(default=0, ge=0, description="Start LED index (0-based)")
|
||||||
|
led_end: int = Field(default=-1, description="End LED index (-1 = last)")
|
||||||
|
brightness_scale: float = Field(
|
||||||
|
default=1.0, ge=0.0, le=1.0, description="Brightness multiplier"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OutputTargetCreate(BaseModel):
|
class OutputTargetCreate(BaseModel):
|
||||||
"""Request to create an output target."""
|
"""Request to create an output target."""
|
||||||
|
|
||||||
name: str = Field(description="Target name", min_length=1, max_length=100)
|
name: str = Field(description="Target name", min_length=1, max_length=100)
|
||||||
target_type: str = Field(default="led", description="Target type (led, key_colors)")
|
target_type: str = Field(default="led", description="Target type (led, key_colors, ha_light)")
|
||||||
# LED target fields
|
# LED target fields
|
||||||
device_id: str = Field(default="", description="LED device ID")
|
device_id: str = Field(default="", description="LED device ID")
|
||||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
||||||
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
|
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
|
||||||
keepalive_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
|
keepalive_interval: float = Field(
|
||||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
|
default=1.0,
|
||||||
min_brightness_threshold: int = Field(default=0, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
|
description="Keepalive send interval when screen is static (0.5-5.0s)",
|
||||||
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
|
ge=0.5,
|
||||||
protocol: str = Field(default="ddp", pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)")
|
le=5.0,
|
||||||
|
)
|
||||||
|
state_check_interval: int = Field(
|
||||||
|
default=DEFAULT_STATE_CHECK_INTERVAL,
|
||||||
|
description="Device health check interval (5-600s)",
|
||||||
|
ge=5,
|
||||||
|
le=600,
|
||||||
|
)
|
||||||
|
min_brightness_threshold: int = Field(
|
||||||
|
default=0,
|
||||||
|
ge=0,
|
||||||
|
le=254,
|
||||||
|
description="Min brightness threshold (0=disabled); below this → off",
|
||||||
|
)
|
||||||
|
adaptive_fps: bool = Field(
|
||||||
|
default=False, description="Auto-reduce FPS when device is unresponsive"
|
||||||
|
)
|
||||||
|
protocol: str = Field(
|
||||||
|
default="ddp",
|
||||||
|
pattern="^(ddp|http)$",
|
||||||
|
description="Send protocol: ddp (UDP) or http (JSON API)",
|
||||||
|
)
|
||||||
# KC target fields
|
# KC target fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
|
picture_source_id: str = Field(
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
default="", description="Picture source ID (for key_colors targets)"
|
||||||
|
)
|
||||||
|
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
|
||||||
|
None, description="Key colors settings (for key_colors targets)"
|
||||||
|
)
|
||||||
|
# HA light target fields
|
||||||
|
ha_source_id: str = Field(
|
||||||
|
default="", description="Home Assistant source ID (for ha_light targets)"
|
||||||
|
)
|
||||||
|
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||||
|
None, description="LED-to-light mappings (for ha_light targets)"
|
||||||
|
)
|
||||||
|
update_rate: float = Field(
|
||||||
|
default=2.0, ge=0.5, le=5.0, description="Service call rate in Hz (for ha_light targets)"
|
||||||
|
)
|
||||||
|
transition: float = Field(
|
||||||
|
default=0.5, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
|
||||||
|
)
|
||||||
|
color_tolerance: int = Field(
|
||||||
|
default=5,
|
||||||
|
ge=0,
|
||||||
|
le=50,
|
||||||
|
description="Skip service call if RGB delta < this (for ha_light targets)",
|
||||||
|
)
|
||||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|
||||||
@@ -75,16 +138,48 @@ class OutputTargetUpdate(BaseModel):
|
|||||||
# LED target fields
|
# LED target fields
|
||||||
device_id: Optional[str] = Field(None, description="LED device ID")
|
device_id: Optional[str] = Field(None, description="LED device ID")
|
||||||
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
|
||||||
brightness_value_source_id: Optional[str] = Field(None, description="Brightness value source ID")
|
brightness_value_source_id: Optional[str] = Field(
|
||||||
|
None, description="Brightness value source ID"
|
||||||
|
)
|
||||||
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
|
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
|
||||||
keepalive_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
|
keepalive_interval: Optional[float] = Field(
|
||||||
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
|
None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0
|
||||||
min_brightness_threshold: Optional[int] = Field(None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off")
|
)
|
||||||
adaptive_fps: Optional[bool] = Field(None, description="Auto-reduce FPS when device is unresponsive")
|
state_check_interval: Optional[int] = Field(
|
||||||
protocol: Optional[str] = Field(None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)")
|
None, description="Health check interval (5-600s)", ge=5, le=600
|
||||||
|
)
|
||||||
|
min_brightness_threshold: Optional[int] = Field(
|
||||||
|
None, ge=0, le=254, description="Min brightness threshold (0=disabled); below this → off"
|
||||||
|
)
|
||||||
|
adaptive_fps: Optional[bool] = Field(
|
||||||
|
None, description="Auto-reduce FPS when device is unresponsive"
|
||||||
|
)
|
||||||
|
protocol: Optional[str] = Field(
|
||||||
|
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
|
||||||
|
)
|
||||||
# KC target fields
|
# KC target fields
|
||||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
|
picture_source_id: Optional[str] = Field(
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
|
None, description="Picture source ID (for key_colors targets)"
|
||||||
|
)
|
||||||
|
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
|
||||||
|
None, description="Key colors settings (for key_colors targets)"
|
||||||
|
)
|
||||||
|
# HA light target fields
|
||||||
|
ha_source_id: Optional[str] = Field(
|
||||||
|
None, description="Home Assistant source ID (for ha_light targets)"
|
||||||
|
)
|
||||||
|
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||||
|
None, description="LED-to-light mappings (for ha_light targets)"
|
||||||
|
)
|
||||||
|
update_rate: Optional[float] = Field(
|
||||||
|
None, ge=0.5, le=5.0, description="Service call rate Hz (for ha_light targets)"
|
||||||
|
)
|
||||||
|
transition: Optional[float] = Field(
|
||||||
|
None, ge=0.0, le=10.0, description="HA transition seconds (for ha_light targets)"
|
||||||
|
)
|
||||||
|
color_tolerance: Optional[int] = Field(
|
||||||
|
None, ge=0, le=50, description="RGB delta tolerance (for ha_light targets)"
|
||||||
|
)
|
||||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
@@ -101,13 +196,29 @@ class OutputTargetResponse(BaseModel):
|
|||||||
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
brightness_value_source_id: str = Field(default="", description="Brightness value source ID")
|
||||||
fps: Optional[int] = Field(None, description="Target send FPS")
|
fps: Optional[int] = Field(None, description="Target send FPS")
|
||||||
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)")
|
||||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
|
state_check_interval: int = Field(
|
||||||
min_brightness_threshold: int = Field(default=0, description="Min brightness threshold (0=disabled)")
|
default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)"
|
||||||
adaptive_fps: bool = Field(default=False, description="Auto-reduce FPS when device is unresponsive")
|
)
|
||||||
|
min_brightness_threshold: int = Field(
|
||||||
|
default=0, description="Min brightness threshold (0=disabled)"
|
||||||
|
)
|
||||||
|
adaptive_fps: bool = Field(
|
||||||
|
default=False, description="Auto-reduce FPS when device is unresponsive"
|
||||||
|
)
|
||||||
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
|
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
|
||||||
# KC target fields
|
# KC target fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
|
||||||
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
|
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(
|
||||||
|
None, description="Key colors settings"
|
||||||
|
)
|
||||||
|
# HA light target fields
|
||||||
|
ha_source_id: str = Field(default="", description="Home Assistant source ID (ha_light)")
|
||||||
|
ha_light_mappings: Optional[List[HALightMappingSchema]] = Field(
|
||||||
|
None, description="LED-to-light mappings (ha_light)"
|
||||||
|
)
|
||||||
|
update_rate: Optional[float] = Field(None, description="Service call rate Hz (ha_light)")
|
||||||
|
ha_transition: Optional[float] = Field(None, description="HA transition seconds (ha_light)")
|
||||||
|
color_tolerance: Optional[int] = Field(None, description="RGB delta tolerance (ha_light)")
|
||||||
description: Optional[str] = Field(None, description="Description")
|
description: Optional[str] = Field(None, description="Description")
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
@@ -129,23 +240,39 @@ class TargetProcessingState(BaseModel):
|
|||||||
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
color_strip_source_id: str = Field(default="", description="Color strip source ID")
|
||||||
processing: bool = Field(description="Whether processing is active")
|
processing: bool = Field(description="Whether processing is active")
|
||||||
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
|
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
|
||||||
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
|
fps_potential: Optional[float] = Field(
|
||||||
|
None, description="Potential FPS (processing speed without throttle)"
|
||||||
|
)
|
||||||
fps_target: Optional[int] = Field(None, description="Target FPS")
|
fps_target: Optional[int] = Field(None, description="Target FPS")
|
||||||
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
|
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
|
||||||
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
|
frames_keepalive: Optional[int] = Field(
|
||||||
|
None, description="Keepalive frames sent during standby"
|
||||||
|
)
|
||||||
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
|
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
|
||||||
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
|
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
|
||||||
timing_extract_ms: Optional[float] = Field(None, description="Border pixel extraction time (ms)")
|
timing_extract_ms: Optional[float] = Field(
|
||||||
|
None, description="Border pixel extraction time (ms)"
|
||||||
|
)
|
||||||
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
|
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
|
||||||
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
|
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
|
||||||
timing_total_ms: Optional[float] = Field(None, description="Total processing time per frame (ms)")
|
timing_total_ms: Optional[float] = Field(
|
||||||
|
None, description="Total processing time per frame (ms)"
|
||||||
|
)
|
||||||
timing_audio_read_ms: Optional[float] = Field(None, description="Audio device read time (ms)")
|
timing_audio_read_ms: Optional[float] = Field(None, description="Audio device read time (ms)")
|
||||||
timing_audio_fft_ms: Optional[float] = Field(None, description="Audio FFT analysis time (ms)")
|
timing_audio_fft_ms: Optional[float] = Field(None, description="Audio FFT analysis time (ms)")
|
||||||
timing_audio_render_ms: Optional[float] = Field(None, description="Audio visualization render time (ms)")
|
timing_audio_render_ms: Optional[float] = Field(
|
||||||
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
|
None, description="Audio visualization render time (ms)"
|
||||||
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
|
)
|
||||||
|
timing_calc_colors_ms: Optional[float] = Field(
|
||||||
|
None, description="Color calculation time (ms, KC targets)"
|
||||||
|
)
|
||||||
|
timing_broadcast_ms: Optional[float] = Field(
|
||||||
|
None, description="WebSocket broadcast time (ms, KC targets)"
|
||||||
|
)
|
||||||
display_index: Optional[int] = Field(None, description="Current display index")
|
display_index: Optional[int] = Field(None, description="Current display index")
|
||||||
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
|
overlay_active: bool = Field(
|
||||||
|
default=False, description="Whether visualization overlay is active"
|
||||||
|
)
|
||||||
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
||||||
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
||||||
device_online: bool = Field(default=False, description="Whether device is reachable")
|
device_online: bool = Field(default=False, description="Whether device is reachable")
|
||||||
@@ -154,11 +281,17 @@ class TargetProcessingState(BaseModel):
|
|||||||
device_version: Optional[str] = Field(None, description="Firmware version")
|
device_version: Optional[str] = Field(None, description="Firmware version")
|
||||||
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
|
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
|
||||||
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
|
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
|
||||||
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
|
device_led_type: Optional[str] = Field(
|
||||||
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
|
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
|
||||||
|
)
|
||||||
|
device_fps: Optional[int] = Field(
|
||||||
|
None, description="Device-reported FPS (WLED internal refresh rate)"
|
||||||
|
)
|
||||||
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
||||||
device_error: Optional[str] = Field(None, description="Last health check error")
|
device_error: Optional[str] = Field(None, description="Last health check error")
|
||||||
device_streaming_reachable: Optional[bool] = Field(None, description="Device reachable during streaming (HTTP probe)")
|
device_streaming_reachable: Optional[bool] = Field(
|
||||||
|
None, description="Device reachable during streaming (HTTP probe)"
|
||||||
|
)
|
||||||
fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction")
|
fps_effective: Optional[int] = Field(None, description="Effective FPS after adaptive reduction")
|
||||||
|
|
||||||
|
|
||||||
@@ -186,9 +319,15 @@ class BulkTargetRequest(BaseModel):
|
|||||||
class BulkTargetResponse(BaseModel):
|
class BulkTargetResponse(BaseModel):
|
||||||
"""Response for bulk start/stop operations."""
|
"""Response for bulk start/stop operations."""
|
||||||
|
|
||||||
started: List[str] = Field(default_factory=list, description="IDs that were successfully started")
|
started: List[str] = Field(
|
||||||
stopped: List[str] = Field(default_factory=list, description="IDs that were successfully stopped")
|
default_factory=list, description="IDs that were successfully started"
|
||||||
errors: Dict[str, str] = Field(default_factory=dict, description="Map of target ID to error message for failures")
|
)
|
||||||
|
stopped: List[str] = Field(
|
||||||
|
default_factory=list, description="IDs that were successfully stopped"
|
||||||
|
)
|
||||||
|
errors: Dict[str, str] = Field(
|
||||||
|
default_factory=dict, description="Map of target ID to error message for failures"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class KCTestRectangleResponse(BaseModel):
|
class KCTestRectangleResponse(BaseModel):
|
||||||
@@ -206,6 +345,8 @@ class KCTestResponse(BaseModel):
|
|||||||
"""Response from testing a KC target."""
|
"""Response from testing a KC target."""
|
||||||
|
|
||||||
image: str = Field(description="Base64 data URI of the captured frame")
|
image: str = Field(description="Base64 data URI of the captured frame")
|
||||||
rectangles: List[KCTestRectangleResponse] = Field(description="Rectangles with extracted colors")
|
rectangles: List[KCTestRectangleResponse] = Field(
|
||||||
|
description="Rectangles with extracted colors"
|
||||||
|
)
|
||||||
interpolation_mode: str = Field(description="Color extraction mode used")
|
interpolation_mode: str = Field(description="Color extraction mode used")
|
||||||
pattern_template_name: str = Field(description="Pattern template name")
|
pattern_template_name: str = Field(description="Pattern template name")
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ class PictureSourceCreate(BaseModel):
|
|||||||
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
|
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)")
|
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)")
|
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)
|
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
# Video fields
|
# 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")
|
loop: bool = Field(True, description="Loop video playback")
|
||||||
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
|
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)
|
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)
|
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)")
|
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)")
|
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)
|
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
# Video fields
|
# 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")
|
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)
|
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)
|
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")
|
target_fps: Optional[int] = Field(None, description="Target FPS")
|
||||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
|
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
|
||||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template 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")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
description: Optional[str] = Field(None, description="Stream description")
|
description: Optional[str] = Field(None, description="Stream description")
|
||||||
# Video fields
|
# 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")
|
loop: Optional[bool] = Field(None, description="Loop video playback")
|
||||||
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier")
|
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier")
|
||||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
|
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ class HealthResponse(BaseModel):
|
|||||||
timestamp: datetime = Field(description="Current server time")
|
timestamp: datetime = Field(description="Current server time")
|
||||||
version: str = Field(description="Application version")
|
version: str = Field(description="Application version")
|
||||||
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
|
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
|
||||||
auth_required: bool = Field(default=True, description="Whether API key authentication is required")
|
auth_required: bool = Field(
|
||||||
|
default=True, description="Whether API key authentication is required"
|
||||||
|
)
|
||||||
|
repo_url: str = Field(default="", description="Source code repository URL")
|
||||||
|
donate_url: str = Field(default="", description="Donation page URL")
|
||||||
|
|
||||||
|
|
||||||
class VersionResponse(BaseModel):
|
class VersionResponse(BaseModel):
|
||||||
@@ -60,6 +64,9 @@ class GpuInfo(BaseModel):
|
|||||||
memory_used_mb: float | None = Field(default=None, description="GPU memory used in MB")
|
memory_used_mb: float | None = Field(default=None, description="GPU memory used in MB")
|
||||||
memory_total_mb: float | None = Field(default=None, description="GPU total memory in MB")
|
memory_total_mb: float | None = Field(default=None, description="GPU total memory in MB")
|
||||||
temperature_c: float | None = Field(default=None, description="GPU temperature in Celsius")
|
temperature_c: float | None = Field(default=None, description="GPU temperature in Celsius")
|
||||||
|
app_memory_mb: float | None = Field(
|
||||||
|
default=None, description="GPU memory used by this app in MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PerformanceResponse(BaseModel):
|
class PerformanceResponse(BaseModel):
|
||||||
@@ -70,6 +77,8 @@ class PerformanceResponse(BaseModel):
|
|||||||
ram_used_mb: float = Field(description="RAM used in MB")
|
ram_used_mb: float = Field(description="RAM used in MB")
|
||||||
ram_total_mb: float = Field(description="RAM total in MB")
|
ram_total_mb: float = Field(description="RAM total in MB")
|
||||||
ram_percent: float = Field(description="RAM usage percent")
|
ram_percent: float = Field(description="RAM usage percent")
|
||||||
|
app_cpu_percent: float = Field(description="App process CPU usage percent")
|
||||||
|
app_ram_mb: float = Field(description="App process resident memory in MB")
|
||||||
gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)")
|
gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)")
|
||||||
timestamp: datetime = Field(description="Measurement timestamp")
|
timestamp: datetime = Field(description="Measurement timestamp")
|
||||||
|
|
||||||
@@ -84,6 +93,7 @@ class RestoreResponse(BaseModel):
|
|||||||
|
|
||||||
# ─── Auto-backup schemas ──────────────────────────────────────
|
# ─── Auto-backup schemas ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class AutoBackupSettings(BaseModel):
|
class AutoBackupSettings(BaseModel):
|
||||||
"""Settings for automatic backup."""
|
"""Settings for automatic backup."""
|
||||||
|
|
||||||
@@ -119,6 +129,7 @@ class BackupListResponse(BaseModel):
|
|||||||
|
|
||||||
# ─── MQTT schemas ──────────────────────────────────────────────
|
# ─── MQTT schemas ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class MQTTSettingsResponse(BaseModel):
|
class MQTTSettingsResponse(BaseModel):
|
||||||
"""MQTT broker settings response (password is masked)."""
|
"""MQTT broker settings response (password is masked)."""
|
||||||
|
|
||||||
@@ -138,17 +149,22 @@ class MQTTSettingsRequest(BaseModel):
|
|||||||
broker_host: str = Field(description="MQTT broker hostname or IP")
|
broker_host: str = Field(description="MQTT broker hostname or IP")
|
||||||
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
|
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
|
||||||
username: str = Field(default="", description="MQTT username (empty = anonymous)")
|
username: str = Field(default="", description="MQTT username (empty = anonymous)")
|
||||||
password: str = Field(default="", description="MQTT password (empty = keep existing if omitted)")
|
password: str = Field(
|
||||||
|
default="", description="MQTT password (empty = keep existing if omitted)"
|
||||||
|
)
|
||||||
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
||||||
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
||||||
|
|
||||||
|
|
||||||
# ─── External URL schema ───────────────────────────────────────
|
# ─── External URL schema ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class ExternalUrlResponse(BaseModel):
|
class ExternalUrlResponse(BaseModel):
|
||||||
"""External URL setting response."""
|
"""External URL setting response."""
|
||||||
|
|
||||||
external_url: str = Field(description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL.")
|
external_url: str = Field(
|
||||||
|
description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExternalUrlRequest(BaseModel):
|
class ExternalUrlRequest(BaseModel):
|
||||||
@@ -159,10 +175,13 @@ class ExternalUrlRequest(BaseModel):
|
|||||||
|
|
||||||
# ─── Log level schemas ─────────────────────────────────────────
|
# ─── Log level schemas ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class LogLevelResponse(BaseModel):
|
class LogLevelResponse(BaseModel):
|
||||||
"""Current log level response."""
|
"""Current log level response."""
|
||||||
|
|
||||||
level: str = Field(description="Current effective log level name (e.g. DEBUG, INFO, WARNING, ERROR, CRITICAL)")
|
level: str = Field(
|
||||||
|
description="Current effective log level name (e.g. DEBUG, INFO, WARNING, ERROR, CRITICAL)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LogLevelRequest(BaseModel):
|
class LogLevelRequest(BaseModel):
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class ServerConfig(BaseSettings):
|
|||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8080
|
port: int = 8080
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
cors_origins: List[str] = ["*"]
|
cors_origins: List[str] = ["http://localhost:8080"]
|
||||||
|
|
||||||
|
|
||||||
class AuthConfig(BaseSettings):
|
class AuthConfig(BaseSettings):
|
||||||
@@ -24,6 +24,13 @@ class AuthConfig(BaseSettings):
|
|||||||
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
|
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):
|
class StorageConfig(BaseSettings):
|
||||||
"""Storage configuration."""
|
"""Storage configuration."""
|
||||||
|
|
||||||
@@ -65,16 +72,21 @@ class Config(BaseSettings):
|
|||||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||||
auth: AuthConfig = Field(default_factory=AuthConfig)
|
auth: AuthConfig = Field(default_factory=AuthConfig)
|
||||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||||
|
assets: AssetsConfig = Field(default_factory=AssetsConfig)
|
||||||
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
|
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
|
||||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||||
|
|
||||||
def model_post_init(self, __context: object) -> None:
|
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:
|
if self.demo:
|
||||||
for field_name in self.storage.model_fields:
|
for field_name in StorageConfig.model_fields:
|
||||||
value = getattr(self.storage, field_name)
|
value = getattr(self.storage, field_name)
|
||||||
if isinstance(value, str) and value.startswith("data/"):
|
if isinstance(value, str) and value.startswith("data/"):
|
||||||
setattr(self.storage, field_name, value.replace("data/", "data/demo/", 1))
|
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
|
@classmethod
|
||||||
def from_yaml(cls, config_path: str | Path) -> "Config":
|
def from_yaml(cls, config_path: str | Path) -> "Config":
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ class ManagedAudioStream:
|
|||||||
if stream is not None:
|
if stream is not None:
|
||||||
try:
|
try:
|
||||||
stream.cleanup()
|
stream.cleanup()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Audio stream cleanup error: %s", e)
|
||||||
pass
|
pass
|
||||||
self._running = False
|
self._running = False
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ class SounddeviceCaptureStream(AudioCaptureStreamBase):
|
|||||||
try:
|
try:
|
||||||
self._sd_stream.stop()
|
self._sd_stream.stop()
|
||||||
self._sd_stream.close()
|
self._sd_stream.close()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Sounddevice stream cleanup: %s", e)
|
||||||
pass
|
pass
|
||||||
self._sd_stream = None
|
self._sd_stream = None
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
@@ -104,7 +105,8 @@ class SounddeviceEngine(AudioCaptureEngine):
|
|||||||
try:
|
try:
|
||||||
import sounddevice # noqa: F401
|
import sounddevice # noqa: F401
|
||||||
return True
|
return True
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
|
logger.debug("Sounddevice engine unavailable: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -118,7 +120,8 @@ class SounddeviceEngine(AudioCaptureEngine):
|
|||||||
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
||||||
try:
|
try:
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
|
logger.debug("Cannot enumerate sounddevice devices: %s", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -85,13 +85,15 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
|
|||||||
try:
|
try:
|
||||||
self._stream.stop_stream()
|
self._stream.stop_stream()
|
||||||
self._stream.close()
|
self._stream.close()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("WASAPI stream cleanup: %s", e)
|
||||||
pass
|
pass
|
||||||
self._stream = None
|
self._stream = None
|
||||||
if self._pa is not None:
|
if self._pa is not None:
|
||||||
try:
|
try:
|
||||||
self._pa.terminate()
|
self._pa.terminate()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("PyAudio terminate during cleanup: %s", e)
|
||||||
pass
|
pass
|
||||||
self._pa = None
|
self._pa = None
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
@@ -139,7 +141,8 @@ class WasapiEngine(AudioCaptureEngine):
|
|||||||
try:
|
try:
|
||||||
import pyaudiowpatch # noqa: F401
|
import pyaudiowpatch # noqa: F401
|
||||||
return True
|
return True
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
|
logger.debug("WASAPI engine unavailable (pyaudiowpatch not installed): %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -153,7 +156,8 @@ class WasapiEngine(AudioCaptureEngine):
|
|||||||
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
||||||
try:
|
try:
|
||||||
import pyaudiowpatch as pyaudio
|
import pyaudiowpatch as pyaudio
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
|
logger.debug("Cannot enumerate WASAPI devices (pyaudiowpatch not installed): %s", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
pa = None
|
pa = None
|
||||||
@@ -223,7 +227,8 @@ class WasapiEngine(AudioCaptureEngine):
|
|||||||
if pa is not None:
|
if pa is not None:
|
||||||
try:
|
try:
|
||||||
pa.terminate()
|
pa.terminate()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("PyAudio terminate in enumerate cleanup: %s", e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from wled_controller.storage.automation import (
|
|||||||
Automation,
|
Automation,
|
||||||
Condition,
|
Condition,
|
||||||
DisplayStateCondition,
|
DisplayStateCondition,
|
||||||
|
HomeAssistantCondition,
|
||||||
MQTTCondition,
|
MQTTCondition,
|
||||||
StartupCondition,
|
StartupCondition,
|
||||||
SystemIdleCondition,
|
SystemIdleCondition,
|
||||||
@@ -37,6 +38,7 @@ class AutomationEngine:
|
|||||||
scene_preset_store=None,
|
scene_preset_store=None,
|
||||||
target_store=None,
|
target_store=None,
|
||||||
device_store=None,
|
device_store=None,
|
||||||
|
ha_manager=None,
|
||||||
):
|
):
|
||||||
self._store = automation_store
|
self._store = automation_store
|
||||||
self._manager = processor_manager
|
self._manager = processor_manager
|
||||||
@@ -46,6 +48,7 @@ class AutomationEngine:
|
|||||||
self._scene_preset_store = scene_preset_store
|
self._scene_preset_store = scene_preset_store
|
||||||
self._target_store = target_store
|
self._target_store = target_store
|
||||||
self._device_store = device_store
|
self._device_store = device_store
|
||||||
|
self._ha_manager = ha_manager
|
||||||
self._task: Optional[asyncio.Task] = None
|
self._task: Optional[asyncio.Task] = None
|
||||||
self._eval_lock = asyncio.Lock()
|
self._eval_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -74,6 +77,7 @@ class AutomationEngine:
|
|||||||
try:
|
try:
|
||||||
await self._task
|
await self._task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("Automation engine task cancelled")
|
||||||
pass
|
pass
|
||||||
self._task = None
|
self._task = None
|
||||||
|
|
||||||
@@ -92,6 +96,7 @@ class AutomationEngine:
|
|||||||
logger.error(f"Automation evaluation error: {e}", exc_info=True)
|
logger.error(f"Automation evaluation error: {e}", exc_info=True)
|
||||||
await asyncio.sleep(self._poll_interval)
|
await asyncio.sleep(self._poll_interval)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("Automation poll loop cancelled")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def _evaluate_all(self) -> None:
|
async def _evaluate_all(self) -> None:
|
||||||
@@ -99,8 +104,12 @@ class AutomationEngine:
|
|||||||
await self._evaluate_all_locked()
|
await self._evaluate_all_locked()
|
||||||
|
|
||||||
def _detect_all_sync(
|
def _detect_all_sync(
|
||||||
self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool,
|
self,
|
||||||
needs_idle: bool, needs_display_state: bool,
|
needs_running: bool,
|
||||||
|
needs_topmost: bool,
|
||||||
|
needs_fullscreen: bool,
|
||||||
|
needs_idle: bool,
|
||||||
|
needs_display_state: bool,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Run all platform detection in a single thread call.
|
"""Run all platform detection in a single thread call.
|
||||||
|
|
||||||
@@ -113,10 +122,21 @@ class AutomationEngine:
|
|||||||
topmost_proc, topmost_fullscreen = self._detector._get_topmost_process_sync()
|
topmost_proc, topmost_fullscreen = self._detector._get_topmost_process_sync()
|
||||||
else:
|
else:
|
||||||
topmost_proc, topmost_fullscreen = None, False
|
topmost_proc, topmost_fullscreen = None, False
|
||||||
fullscreen_procs = self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set()
|
fullscreen_procs = (
|
||||||
|
self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set()
|
||||||
|
)
|
||||||
idle_seconds = self._detector._get_idle_seconds_sync() if needs_idle else None
|
idle_seconds = self._detector._get_idle_seconds_sync() if needs_idle else None
|
||||||
display_state = self._detector._get_display_power_state_sync() if needs_display_state else None
|
display_state = (
|
||||||
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state
|
self._detector._get_display_power_state_sync() if needs_display_state else None
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
running_procs,
|
||||||
|
topmost_proc,
|
||||||
|
topmost_fullscreen,
|
||||||
|
fullscreen_procs,
|
||||||
|
idle_seconds,
|
||||||
|
display_state,
|
||||||
|
)
|
||||||
|
|
||||||
async def _evaluate_all_locked(self) -> None:
|
async def _evaluate_all_locked(self) -> None:
|
||||||
automations = self._store.get_all_automations()
|
automations = self._store.get_all_automations()
|
||||||
@@ -146,24 +166,37 @@ class AutomationEngine:
|
|||||||
|
|
||||||
# Single executor call for all platform detection
|
# Single executor call for all platform detection
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
(running_procs, topmost_proc, topmost_fullscreen,
|
(
|
||||||
fullscreen_procs, idle_seconds, display_state) = (
|
running_procs,
|
||||||
await loop.run_in_executor(
|
topmost_proc,
|
||||||
None, self._detect_all_sync,
|
topmost_fullscreen,
|
||||||
needs_running, needs_topmost, needs_fullscreen,
|
fullscreen_procs,
|
||||||
needs_idle, needs_display_state,
|
idle_seconds,
|
||||||
)
|
display_state,
|
||||||
|
) = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
self._detect_all_sync,
|
||||||
|
needs_running,
|
||||||
|
needs_topmost,
|
||||||
|
needs_fullscreen,
|
||||||
|
needs_idle,
|
||||||
|
needs_display_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
active_automation_ids = set()
|
active_automation_ids = set()
|
||||||
|
|
||||||
for automation in automations:
|
for automation in automations:
|
||||||
should_be_active = (
|
should_be_active = automation.enabled and (
|
||||||
automation.enabled
|
len(automation.conditions) == 0
|
||||||
and (len(automation.conditions) == 0
|
|
||||||
or self._evaluate_conditions(
|
or self._evaluate_conditions(
|
||||||
automation, running_procs, topmost_proc, topmost_fullscreen,
|
automation,
|
||||||
fullscreen_procs, idle_seconds, display_state))
|
running_procs,
|
||||||
|
topmost_proc,
|
||||||
|
topmost_fullscreen,
|
||||||
|
fullscreen_procs,
|
||||||
|
idle_seconds,
|
||||||
|
display_state,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
is_active = automation.id in self._active_automations
|
is_active = automation.id in self._active_automations
|
||||||
@@ -182,15 +215,24 @@ class AutomationEngine:
|
|||||||
await self._deactivate_automation(aid)
|
await self._deactivate_automation(aid)
|
||||||
|
|
||||||
def _evaluate_conditions(
|
def _evaluate_conditions(
|
||||||
self, automation: Automation, running_procs: Set[str],
|
self,
|
||||||
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
automation: Automation,
|
||||||
|
running_procs: Set[str],
|
||||||
|
topmost_proc: Optional[str],
|
||||||
|
topmost_fullscreen: bool,
|
||||||
fullscreen_procs: Set[str],
|
fullscreen_procs: Set[str],
|
||||||
idle_seconds: Optional[float], display_state: Optional[str],
|
idle_seconds: Optional[float],
|
||||||
|
display_state: Optional[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
results = [
|
results = [
|
||||||
self._evaluate_condition(
|
self._evaluate_condition(
|
||||||
c, running_procs, topmost_proc, topmost_fullscreen,
|
c,
|
||||||
fullscreen_procs, idle_seconds, display_state,
|
running_procs,
|
||||||
|
topmost_proc,
|
||||||
|
topmost_fullscreen,
|
||||||
|
fullscreen_procs,
|
||||||
|
idle_seconds,
|
||||||
|
display_state,
|
||||||
)
|
)
|
||||||
for c in automation.conditions
|
for c in automation.conditions
|
||||||
]
|
]
|
||||||
@@ -200,20 +242,27 @@ class AutomationEngine:
|
|||||||
return any(results) # "or" is default
|
return any(results) # "or" is default
|
||||||
|
|
||||||
def _evaluate_condition(
|
def _evaluate_condition(
|
||||||
self, condition: Condition, running_procs: Set[str],
|
self,
|
||||||
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
condition: Condition,
|
||||||
|
running_procs: Set[str],
|
||||||
|
topmost_proc: Optional[str],
|
||||||
|
topmost_fullscreen: bool,
|
||||||
fullscreen_procs: Set[str],
|
fullscreen_procs: Set[str],
|
||||||
idle_seconds: Optional[float], display_state: Optional[str],
|
idle_seconds: Optional[float],
|
||||||
|
display_state: Optional[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
dispatch = {
|
dispatch = {
|
||||||
AlwaysCondition: lambda c: True,
|
AlwaysCondition: lambda c: True,
|
||||||
StartupCondition: lambda c: True,
|
StartupCondition: lambda c: True,
|
||||||
ApplicationCondition: lambda c: self._evaluate_app_condition(c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs),
|
ApplicationCondition: lambda c: self._evaluate_app_condition(
|
||||||
|
c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
|
||||||
|
),
|
||||||
TimeOfDayCondition: lambda c: self._evaluate_time_of_day(c),
|
TimeOfDayCondition: lambda c: self._evaluate_time_of_day(c),
|
||||||
SystemIdleCondition: lambda c: self._evaluate_idle(c, idle_seconds),
|
SystemIdleCondition: lambda c: self._evaluate_idle(c, idle_seconds),
|
||||||
DisplayStateCondition: lambda c: self._evaluate_display_state(c, display_state),
|
DisplayStateCondition: lambda c: self._evaluate_display_state(c, display_state),
|
||||||
MQTTCondition: lambda c: self._evaluate_mqtt(c),
|
MQTTCondition: lambda c: self._evaluate_mqtt(c),
|
||||||
WebhookCondition: lambda c: self._webhook_states.get(c.token, False),
|
WebhookCondition: lambda c: self._webhook_states.get(c.token, False),
|
||||||
|
HomeAssistantCondition: lambda c: self._evaluate_home_assistant(c),
|
||||||
}
|
}
|
||||||
handler = dispatch.get(type(condition))
|
handler = dispatch.get(type(condition))
|
||||||
if handler is None:
|
if handler is None:
|
||||||
@@ -241,7 +290,9 @@ class AutomationEngine:
|
|||||||
return is_idle if condition.when_idle else not is_idle
|
return is_idle if condition.when_idle else not is_idle
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _evaluate_display_state(condition: DisplayStateCondition, display_state: Optional[str]) -> bool:
|
def _evaluate_display_state(
|
||||||
|
condition: DisplayStateCondition, display_state: Optional[str]
|
||||||
|
) -> bool:
|
||||||
if display_state is None:
|
if display_state is None:
|
||||||
return False
|
return False
|
||||||
return display_state == condition.state
|
return display_state == condition.state
|
||||||
@@ -262,7 +313,29 @@ class AutomationEngine:
|
|||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
return matcher()
|
return matcher()
|
||||||
except re.error:
|
except re.error as e:
|
||||||
|
logger.debug("MQTT condition regex error: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _evaluate_home_assistant(self, condition: HomeAssistantCondition) -> bool:
|
||||||
|
if self._ha_manager is None:
|
||||||
|
return False
|
||||||
|
entity_state = self._ha_manager.get_state(condition.ha_source_id, condition.entity_id)
|
||||||
|
if entity_state is None:
|
||||||
|
return False
|
||||||
|
value = entity_state.state
|
||||||
|
matchers = {
|
||||||
|
"exact": lambda: value == condition.state,
|
||||||
|
"contains": lambda: condition.state in value,
|
||||||
|
"regex": lambda: bool(re.search(condition.state, value)),
|
||||||
|
}
|
||||||
|
matcher = matchers.get(condition.match_mode)
|
||||||
|
if matcher is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return matcher()
|
||||||
|
except re.error as e:
|
||||||
|
logger.debug("HA condition regex error: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _evaluate_app_condition(
|
def _evaluate_app_condition(
|
||||||
@@ -286,8 +359,7 @@ class AutomationEngine:
|
|||||||
and any(app == topmost_proc for app in apps_lower)
|
and any(app == topmost_proc for app in apps_lower)
|
||||||
),
|
),
|
||||||
"topmost": lambda: (
|
"topmost": lambda: (
|
||||||
topmost_proc is not None
|
topmost_proc is not None and any(app == topmost_proc for app in apps_lower)
|
||||||
and any(app == topmost_proc for app in apps_lower)
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
handler = match_handlers.get(condition.match_type)
|
handler = match_handlers.get(condition.match_type)
|
||||||
@@ -313,12 +385,15 @@ class AutomationEngine:
|
|||||||
try:
|
try:
|
||||||
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
|
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found")
|
logger.warning(
|
||||||
|
f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# For "revert" mode, capture current state before activating
|
# For "revert" mode, capture current state before activating
|
||||||
if automation.deactivation_mode == "revert":
|
if automation.deactivation_mode == "revert":
|
||||||
from wled_controller.core.scenes.scene_activator import capture_current_snapshot
|
from wled_controller.core.scenes.scene_activator import capture_current_snapshot
|
||||||
|
|
||||||
targets = capture_current_snapshot(self._target_store, self._manager)
|
targets = capture_current_snapshot(self._target_store, self._manager)
|
||||||
self._pre_activation_snapshots[automation.id] = ScenePreset(
|
self._pre_activation_snapshots[automation.id] = ScenePreset(
|
||||||
id=f"_revert_{automation.id}",
|
id=f"_revert_{automation.id}",
|
||||||
@@ -328,8 +403,11 @@ class AutomationEngine:
|
|||||||
|
|
||||||
# Apply the scene
|
# Apply the scene
|
||||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||||
|
|
||||||
status, errors = await apply_scene_state(
|
status, errors = await apply_scene_state(
|
||||||
preset, self._target_store, self._manager,
|
preset,
|
||||||
|
self._target_store,
|
||||||
|
self._manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._active_automations[automation.id] = True
|
self._active_automations[automation.id] = True
|
||||||
@@ -371,8 +449,11 @@ class AutomationEngine:
|
|||||||
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
||||||
if snapshot and self._target_store:
|
if snapshot and self._target_store:
|
||||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||||
|
|
||||||
status, errors = await apply_scene_state(
|
status, errors = await apply_scene_state(
|
||||||
snapshot, self._target_store, self._manager,
|
snapshot,
|
||||||
|
self._target_store,
|
||||||
|
self._manager,
|
||||||
)
|
)
|
||||||
if errors:
|
if errors:
|
||||||
logger.warning(f"Automation {automation_id} revert errors: {errors}")
|
logger.warning(f"Automation {automation_id} revert errors: {errors}")
|
||||||
@@ -388,25 +469,34 @@ class AutomationEngine:
|
|||||||
try:
|
try:
|
||||||
fallback = self._scene_preset_store.get_preset(fallback_id)
|
fallback = self._scene_preset_store.get_preset(fallback_id)
|
||||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||||
|
|
||||||
status, errors = await apply_scene_state(
|
status, errors = await apply_scene_state(
|
||||||
fallback, self._target_store, self._manager,
|
fallback,
|
||||||
|
self._target_store,
|
||||||
|
self._manager,
|
||||||
)
|
)
|
||||||
if errors:
|
if errors:
|
||||||
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
|
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)")
|
logger.info(
|
||||||
|
f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)"
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"Automation {automation_id}: fallback scene {fallback_id} not found")
|
logger.warning(
|
||||||
|
f"Automation {automation_id}: fallback scene {fallback_id} not found"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
|
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
|
||||||
|
|
||||||
def _fire_event(self, automation_id: str, action: str) -> None:
|
def _fire_event(self, automation_id: str, action: str) -> None:
|
||||||
try:
|
try:
|
||||||
self._manager.fire_event({
|
self._manager.fire_event(
|
||||||
|
{
|
||||||
"type": "automation_state_changed",
|
"type": "automation_state_changed",
|
||||||
"automation_id": automation_id,
|
"automation_id": automation_id,
|
||||||
"action": action,
|
"action": action,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Automation action failed: %s", e, exc_info=True)
|
logger.error("Automation action failed: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ class PlatformDetector:
|
|||||||
# Data: 0=off, 1=on, 2=dimmed (treat dimmed as on)
|
# Data: 0=off, 1=on, 2=dimmed (treat dimmed as on)
|
||||||
value = setting.Data[0]
|
value = setting.Data[0]
|
||||||
self._display_on = value != 0
|
self._display_on = value != 0
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Failed to parse display power setting: %s", e)
|
||||||
pass
|
pass
|
||||||
return 0
|
return 0
|
||||||
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
||||||
@@ -309,7 +310,8 @@ class PlatformDetector:
|
|||||||
and win_rect.right >= mr.right
|
and win_rect.right >= mr.right
|
||||||
and win_rect.bottom >= mr.bottom
|
and win_rect.bottom >= mr.bottom
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Fullscreen check failed for hwnd: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _get_fullscreen_processes_sync(self) -> Set[str]:
|
def _get_fullscreen_processes_sync(self) -> Set[str]:
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class AutoBackupEngine:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Auto-backup failed: {e}", exc_info=True)
|
logger.error(f"Auto-backup failed: {e}", exc_info=True)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("Auto-backup loop cancelled")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ─── Backup operations ─────────────────────────────────────
|
# ─── Backup operations ─────────────────────────────────────
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ class BetterCamCaptureStream(CaptureStream):
|
|||||||
# Clear global camera cache for fresh DXGI state
|
# Clear global camera cache for fresh DXGI state
|
||||||
try:
|
try:
|
||||||
self._bettercam.__factory.clean_up()
|
self._bettercam.__factory.clean_up()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("BetterCam factory cleanup on init: %s", e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self._camera = self._bettercam.create(
|
self._camera = self._bettercam.create(
|
||||||
@@ -59,7 +60,8 @@ class BetterCamCaptureStream(CaptureStream):
|
|||||||
try:
|
try:
|
||||||
if self._camera.is_capturing:
|
if self._camera.is_capturing:
|
||||||
self._camera.stop()
|
self._camera.stop()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("BetterCam camera stop during cleanup: %s", e)
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
self._camera.release()
|
self._camera.release()
|
||||||
@@ -70,7 +72,8 @@ class BetterCamCaptureStream(CaptureStream):
|
|||||||
if self._bettercam:
|
if self._bettercam:
|
||||||
try:
|
try:
|
||||||
self._bettercam.__factory.clean_up()
|
self._bettercam.__factory.clean_up()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("BetterCam factory cleanup on teardown: %s", e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
|||||||
@@ -408,7 +408,8 @@ class CameraEngine(CaptureEngine):
|
|||||||
try:
|
try:
|
||||||
import cv2 # noqa: F401
|
import cv2 # noqa: F401
|
||||||
return True
|
return True
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
|
logger.debug("Camera engine unavailable (cv2 not installed): %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ class DXcamCaptureStream(CaptureStream):
|
|||||||
# Clear global camera cache for fresh DXGI state
|
# Clear global camera cache for fresh DXGI state
|
||||||
try:
|
try:
|
||||||
self._dxcam.__factory.clean_up()
|
self._dxcam.__factory.clean_up()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("DXcam factory cleanup on init: %s", e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self._camera = self._dxcam.create(
|
self._camera = self._dxcam.create(
|
||||||
@@ -59,7 +60,8 @@ class DXcamCaptureStream(CaptureStream):
|
|||||||
try:
|
try:
|
||||||
if self._camera.is_capturing:
|
if self._camera.is_capturing:
|
||||||
self._camera.stop()
|
self._camera.stop()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("DXcam camera stop during cleanup: %s", e)
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
self._camera.release()
|
self._camera.release()
|
||||||
@@ -70,7 +72,8 @@ class DXcamCaptureStream(CaptureStream):
|
|||||||
if self._dxcam:
|
if self._dxcam:
|
||||||
try:
|
try:
|
||||||
self._dxcam.__factory.clean_up()
|
self._dxcam.__factory.clean_up()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("DXcam factory cleanup on teardown: %s", e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ class WGCCaptureStream(CaptureStream):
|
|||||||
import platform
|
import platform
|
||||||
build = int(platform.version().split(".")[2])
|
build = int(platform.version().split(".")[2])
|
||||||
return build >= 22621
|
return build >= 22621
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Failed to detect WGC border toggle support: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cleanup_internal(self) -> None:
|
def _cleanup_internal(self) -> None:
|
||||||
@@ -133,7 +134,8 @@ class WGCCaptureStream(CaptureStream):
|
|||||||
if self._capture_instance:
|
if self._capture_instance:
|
||||||
try:
|
try:
|
||||||
del self._capture_instance
|
del self._capture_instance
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("WGC capture instance cleanup: %s", e)
|
||||||
pass
|
pass
|
||||||
self._capture_instance = None
|
self._capture_instance = None
|
||||||
|
|
||||||
@@ -215,7 +217,8 @@ class WGCEngine(CaptureEngine):
|
|||||||
build = int(parts[2])
|
build = int(parts[2])
|
||||||
if major < 10 or (major == 10 and minor == 0 and build < 17134):
|
if major < 10 or (major == 10 and minor == 0 and build < 17134):
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Failed to check Windows version for WGC availability: %s", e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -201,8 +201,8 @@ def _build_picture_sources() -> dict:
|
|||||||
"updated_at": _NOW,
|
"updated_at": _NOW,
|
||||||
"source_stream_id": None,
|
"source_stream_id": None,
|
||||||
"postprocessing_template_id": None,
|
"postprocessing_template_id": None,
|
||||||
"image_source": None,
|
"image_asset_id": None,
|
||||||
"url": None,
|
"video_asset_id": None,
|
||||||
"loop": None,
|
"loop": None,
|
||||||
"playback_speed": None,
|
"playback_speed": None,
|
||||||
"start_time": None,
|
"start_time": None,
|
||||||
@@ -223,8 +223,8 @@ def _build_picture_sources() -> dict:
|
|||||||
"updated_at": _NOW,
|
"updated_at": _NOW,
|
||||||
"source_stream_id": None,
|
"source_stream_id": None,
|
||||||
"postprocessing_template_id": None,
|
"postprocessing_template_id": None,
|
||||||
"image_source": None,
|
"image_asset_id": None,
|
||||||
"url": None,
|
"video_asset_id": None,
|
||||||
"loop": None,
|
"loop": None,
|
||||||
"playback_speed": None,
|
"playback_speed": None,
|
||||||
"start_time": None,
|
"start_time": None,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Home Assistant integration — WebSocket client, entity state cache, manager."""
|
||||||
138
server/src/wled_controller/core/home_assistant/ha_manager.py
Normal file
138
server/src/wled_controller/core/home_assistant/ha_manager.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Home Assistant runtime manager — ref-counted pool of HA WebSocket connections.
|
||||||
|
|
||||||
|
Follows the WeatherManager pattern: multiple consumers (CSS streams, automation
|
||||||
|
conditions) sharing the same WebSocket connection per HA instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from wled_controller.core.home_assistant.ha_runtime import HAEntityState, HARuntime
|
||||||
|
from wled_controller.storage.home_assistant_store import HomeAssistantStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantManager:
|
||||||
|
"""Ref-counted pool of Home Assistant runtimes.
|
||||||
|
|
||||||
|
Each HA source gets at most one runtime (WebSocket connection).
|
||||||
|
Multiple consumers share the same runtime via acquire/release.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, store: HomeAssistantStore) -> None:
|
||||||
|
self._store = store
|
||||||
|
# source_id -> (runtime, ref_count)
|
||||||
|
self._runtimes: Dict[str, tuple] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def acquire(self, source_id: str) -> HARuntime:
|
||||||
|
"""Get or create a runtime for the given HA source. Increments ref count."""
|
||||||
|
async with self._lock:
|
||||||
|
if source_id in self._runtimes:
|
||||||
|
runtime, count = self._runtimes[source_id]
|
||||||
|
self._runtimes[source_id] = (runtime, count + 1)
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
source = self._store.get(source_id)
|
||||||
|
runtime = HARuntime(source)
|
||||||
|
await runtime.start()
|
||||||
|
self._runtimes[source_id] = (runtime, 1)
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
async def release(self, source_id: str) -> None:
|
||||||
|
"""Decrement ref count; stop runtime when it reaches zero."""
|
||||||
|
async with self._lock:
|
||||||
|
if source_id not in self._runtimes:
|
||||||
|
return
|
||||||
|
runtime, count = self._runtimes[source_id]
|
||||||
|
if count <= 1:
|
||||||
|
await runtime.stop()
|
||||||
|
del self._runtimes[source_id]
|
||||||
|
else:
|
||||||
|
self._runtimes[source_id] = (runtime, count - 1)
|
||||||
|
|
||||||
|
def get_state(self, source_id: str, entity_id: str) -> Optional[HAEntityState]:
|
||||||
|
"""Get cached entity state from a running runtime (synchronous)."""
|
||||||
|
entry = self._runtimes.get(source_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
runtime, _count = entry
|
||||||
|
return runtime.get_state(entity_id)
|
||||||
|
|
||||||
|
def get_runtime(self, source_id: str) -> Optional[HARuntime]:
|
||||||
|
"""Get a running runtime without changing ref count (for read-only access)."""
|
||||||
|
entry = self._runtimes.get(source_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
runtime, _count = entry
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
async def ensure_runtime(self, source_id: str) -> HARuntime:
|
||||||
|
"""Get or create a runtime (for API endpoints that need a connection)."""
|
||||||
|
async with self._lock:
|
||||||
|
if source_id in self._runtimes:
|
||||||
|
runtime, count = self._runtimes[source_id]
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
source = self._store.get(source_id)
|
||||||
|
runtime = HARuntime(source)
|
||||||
|
await runtime.start()
|
||||||
|
async with self._lock:
|
||||||
|
if source_id not in self._runtimes:
|
||||||
|
self._runtimes[source_id] = (runtime, 0)
|
||||||
|
else:
|
||||||
|
await runtime.stop()
|
||||||
|
runtime, _count = self._runtimes[source_id]
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
async def call_service(
|
||||||
|
self, source_id: str, domain: str, service: str, service_data: dict, target: dict
|
||||||
|
) -> bool:
|
||||||
|
"""Call a HA service via the runtime for the given source. Returns success."""
|
||||||
|
entry = self._runtimes.get(source_id)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
runtime, _count = entry
|
||||||
|
return await runtime.call_service(domain, service, service_data, target)
|
||||||
|
|
||||||
|
async def update_source(self, source_id: str) -> None:
|
||||||
|
"""Hot-update runtime config when the source is edited."""
|
||||||
|
entry = self._runtimes.get(source_id)
|
||||||
|
if entry is None:
|
||||||
|
return
|
||||||
|
runtime, _count = entry
|
||||||
|
try:
|
||||||
|
source = self._store.get(source_id)
|
||||||
|
runtime.update_config(source)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to update HA runtime {source_id}: {e}")
|
||||||
|
|
||||||
|
def get_connection_status(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get status of all active HA connections (for dashboard indicators)."""
|
||||||
|
result = []
|
||||||
|
for source_id, (runtime, ref_count) in self._runtimes.items():
|
||||||
|
try:
|
||||||
|
source = self._store.get(source_id)
|
||||||
|
name = source.name
|
||||||
|
except Exception:
|
||||||
|
name = source_id
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"source_id": source_id,
|
||||||
|
"name": name,
|
||||||
|
"connected": runtime.is_connected,
|
||||||
|
"ref_count": ref_count,
|
||||||
|
"entity_count": len(runtime.get_all_states()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
"""Stop all runtimes."""
|
||||||
|
async with self._lock:
|
||||||
|
for source_id, (runtime, _count) in list(self._runtimes.items()):
|
||||||
|
await runtime.stop()
|
||||||
|
self._runtimes.clear()
|
||||||
|
logger.info("Home Assistant manager shut down")
|
||||||
341
server/src/wled_controller/core/home_assistant/ha_runtime.py
Normal file
341
server/src/wled_controller/core/home_assistant/ha_runtime.py
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
"""Home Assistant WebSocket runtime — maintains a persistent connection and caches entity states.
|
||||||
|
|
||||||
|
Follows the MQTT service pattern: async background task with auto-reconnect.
|
||||||
|
Entity state cache is thread-safe for synchronous reads (automation engine).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
from wled_controller.storage.home_assistant_source import HomeAssistantSource
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HAEntityState:
|
||||||
|
"""Immutable snapshot of a Home Assistant entity state."""
|
||||||
|
|
||||||
|
entity_id: str
|
||||||
|
state: str # e.g. "23.5", "on", "off", "unavailable"
|
||||||
|
attributes: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
last_changed: str = "" # ISO timestamp from HA
|
||||||
|
fetched_at: float = 0.0 # time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
class HARuntime:
|
||||||
|
"""Persistent WebSocket connection to a Home Assistant instance.
|
||||||
|
|
||||||
|
- Authenticates via Long-Lived Access Token
|
||||||
|
- Subscribes to state_changed events
|
||||||
|
- Caches entity states for synchronous reads
|
||||||
|
- Auto-reconnects on connection loss
|
||||||
|
"""
|
||||||
|
|
||||||
|
_RECONNECT_DELAY = 5.0 # seconds between reconnect attempts
|
||||||
|
|
||||||
|
def __init__(self, source: HomeAssistantSource) -> None:
|
||||||
|
self._source_id = source.id
|
||||||
|
self._ws_url = source.ws_url
|
||||||
|
self._token = source.token
|
||||||
|
self._entity_filters: List[str] = list(source.entity_filters)
|
||||||
|
|
||||||
|
# Entity state cache (thread-safe)
|
||||||
|
self._states: Dict[str, HAEntityState] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# Callbacks: entity_id -> set of callbacks
|
||||||
|
self._callbacks: Dict[str, Set[Callable]] = {}
|
||||||
|
|
||||||
|
# Async task management
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._ws: Any = None # live websocket connection (set during _connection_loop)
|
||||||
|
self._connected = False
|
||||||
|
self._msg_id = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_id(self) -> str:
|
||||||
|
return self._source_id
|
||||||
|
|
||||||
|
def get_state(self, entity_id: str) -> Optional[HAEntityState]:
|
||||||
|
"""Get cached entity state (synchronous, thread-safe)."""
|
||||||
|
with self._lock:
|
||||||
|
return self._states.get(entity_id)
|
||||||
|
|
||||||
|
def get_all_states(self) -> Dict[str, HAEntityState]:
|
||||||
|
"""Get all cached entity states (synchronous, thread-safe)."""
|
||||||
|
with self._lock:
|
||||||
|
return dict(self._states)
|
||||||
|
|
||||||
|
def subscribe(self, entity_id: str, callback: Callable) -> None:
|
||||||
|
"""Register a callback for entity state changes."""
|
||||||
|
if entity_id not in self._callbacks:
|
||||||
|
self._callbacks[entity_id] = set()
|
||||||
|
self._callbacks[entity_id].add(callback)
|
||||||
|
|
||||||
|
def unsubscribe(self, entity_id: str, callback: Callable) -> None:
|
||||||
|
"""Remove a callback for entity state changes."""
|
||||||
|
if entity_id in self._callbacks:
|
||||||
|
self._callbacks[entity_id].discard(callback)
|
||||||
|
if not self._callbacks[entity_id]:
|
||||||
|
del self._callbacks[entity_id]
|
||||||
|
|
||||||
|
async def call_service(
|
||||||
|
self, domain: str, service: str, service_data: dict, target: dict
|
||||||
|
) -> bool:
|
||||||
|
"""Call a HA service (e.g. light.turn_on). Fire-and-forget.
|
||||||
|
|
||||||
|
Returns True if the message was sent, False if not connected.
|
||||||
|
"""
|
||||||
|
if not self._connected or self._ws is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
msg_id = self._next_id()
|
||||||
|
await self._ws.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": msg_id,
|
||||||
|
"type": "call_service",
|
||||||
|
"domain": domain,
|
||||||
|
"service": service,
|
||||||
|
"service_data": service_data,
|
||||||
|
"target": target,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"HA call_service failed ({domain}.{service}): {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start the WebSocket connection loop."""
|
||||||
|
if self._task is not None:
|
||||||
|
return
|
||||||
|
self._task = asyncio.create_task(self._connection_loop())
|
||||||
|
logger.info(f"HA runtime started: {self._source_id} -> {self._ws_url}")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the WebSocket connection."""
|
||||||
|
if self._task is None:
|
||||||
|
return
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
|
self._connected = False
|
||||||
|
logger.info(f"HA runtime stopped: {self._source_id}")
|
||||||
|
|
||||||
|
def update_config(self, source: HomeAssistantSource) -> None:
|
||||||
|
"""Hot-update config (token, filters). Connection will use new values on next reconnect."""
|
||||||
|
self._ws_url = source.ws_url
|
||||||
|
self._token = source.token
|
||||||
|
self._entity_filters = list(source.entity_filters)
|
||||||
|
|
||||||
|
def _next_id(self) -> int:
|
||||||
|
self._msg_id += 1
|
||||||
|
return self._msg_id
|
||||||
|
|
||||||
|
def _matches_filter(self, entity_id: str) -> bool:
|
||||||
|
"""Check if an entity matches the configured filters (empty = allow all)."""
|
||||||
|
if not self._entity_filters:
|
||||||
|
return True
|
||||||
|
import fnmatch
|
||||||
|
|
||||||
|
return any(fnmatch.fnmatch(entity_id, pattern) for pattern in self._entity_filters)
|
||||||
|
|
||||||
|
async def _connection_loop(self) -> None:
|
||||||
|
"""Persistent connection with auto-reconnect."""
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError:
|
||||||
|
logger.error(
|
||||||
|
"websockets package not installed — Home Assistant integration unavailable"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with websockets.connect(self._ws_url) as ws:
|
||||||
|
# Step 1: Wait for auth_required
|
||||||
|
msg = json.loads(await ws.recv())
|
||||||
|
if msg.get("type") != "auth_required":
|
||||||
|
logger.warning(f"HA unexpected initial message: {msg.get('type')}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Step 2: Send auth
|
||||||
|
await ws.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "auth",
|
||||||
|
"access_token": self._token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = json.loads(await ws.recv())
|
||||||
|
if msg.get("type") != "auth_ok":
|
||||||
|
logger.error(f"HA auth failed: {msg.get('message', 'unknown error')}")
|
||||||
|
await asyncio.sleep(self._RECONNECT_DELAY)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._ws = ws
|
||||||
|
self._connected = True
|
||||||
|
logger.info(
|
||||||
|
f"HA connected: {self._source_id} (version {msg.get('ha_version', '?')})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Fetch all current states
|
||||||
|
fetch_id = self._next_id()
|
||||||
|
await ws.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": fetch_id,
|
||||||
|
"type": "get_states",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Subscribe to state_changed events
|
||||||
|
sub_id = self._next_id()
|
||||||
|
await ws.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": sub_id,
|
||||||
|
"type": "subscribe_events",
|
||||||
|
"event_type": "state_changed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 5: Message loop
|
||||||
|
async for raw in ws:
|
||||||
|
msg = json.loads(raw)
|
||||||
|
msg_type = msg.get("type")
|
||||||
|
|
||||||
|
if msg_type == "result" and msg.get("id") == fetch_id:
|
||||||
|
# Initial state dump
|
||||||
|
self._handle_state_dump(msg.get("result", []))
|
||||||
|
|
||||||
|
elif msg_type == "event":
|
||||||
|
event = msg.get("event", {})
|
||||||
|
if event.get("event_type") == "state_changed":
|
||||||
|
self._handle_state_changed(event.get("data", {}))
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self._ws = None
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
self._ws = None
|
||||||
|
self._connected = False
|
||||||
|
logger.warning(
|
||||||
|
f"HA connection lost ({self._source_id}): {e}. Reconnecting in {self._RECONNECT_DELAY}s..."
|
||||||
|
)
|
||||||
|
await asyncio.sleep(self._RECONNECT_DELAY)
|
||||||
|
|
||||||
|
def _handle_state_dump(self, states: list) -> None:
|
||||||
|
"""Process initial state dump from get_states."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
for s in states:
|
||||||
|
eid = s.get("entity_id", "")
|
||||||
|
if not self._matches_filter(eid):
|
||||||
|
continue
|
||||||
|
self._states[eid] = HAEntityState(
|
||||||
|
entity_id=eid,
|
||||||
|
state=str(s.get("state", "")),
|
||||||
|
attributes=s.get("attributes", {}),
|
||||||
|
last_changed=s.get("last_changed", ""),
|
||||||
|
fetched_at=now,
|
||||||
|
)
|
||||||
|
logger.info(f"HA {self._source_id}: loaded {len(self._states)} entity states")
|
||||||
|
|
||||||
|
def _handle_state_changed(self, data: dict) -> None:
|
||||||
|
"""Process a state_changed event."""
|
||||||
|
new_state = data.get("new_state")
|
||||||
|
if new_state is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
eid = new_state.get("entity_id", "")
|
||||||
|
if not self._matches_filter(eid):
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
entity_state = HAEntityState(
|
||||||
|
entity_id=eid,
|
||||||
|
state=str(new_state.get("state", "")),
|
||||||
|
attributes=new_state.get("attributes", {}),
|
||||||
|
last_changed=new_state.get("last_changed", ""),
|
||||||
|
fetched_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._states[eid] = entity_state
|
||||||
|
|
||||||
|
# Dispatch callbacks
|
||||||
|
callbacks = self._callbacks.get(eid, set())
|
||||||
|
for cb in callbacks:
|
||||||
|
try:
|
||||||
|
cb(entity_state)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"HA callback error ({eid}): {e}")
|
||||||
|
|
||||||
|
async def fetch_entities(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch entity list via one-shot WebSocket call (for API /entities endpoint)."""
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with websockets.connect(self._ws_url) as ws:
|
||||||
|
# Auth
|
||||||
|
msg = json.loads(await ws.recv())
|
||||||
|
if msg.get("type") != "auth_required":
|
||||||
|
return []
|
||||||
|
await ws.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "auth",
|
||||||
|
"access_token": self._token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
msg = json.loads(await ws.recv())
|
||||||
|
if msg.get("type") != "auth_ok":
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get states
|
||||||
|
req_id = 1
|
||||||
|
await ws.send(json.dumps({"id": req_id, "type": "get_states"}))
|
||||||
|
msg = json.loads(await ws.recv())
|
||||||
|
if msg.get("type") == "result" and msg.get("success"):
|
||||||
|
entities = []
|
||||||
|
for s in msg.get("result", []):
|
||||||
|
eid = s.get("entity_id", "")
|
||||||
|
if self._matches_filter(eid):
|
||||||
|
entities.append(
|
||||||
|
{
|
||||||
|
"entity_id": eid,
|
||||||
|
"state": s.get("state", ""),
|
||||||
|
"friendly_name": s.get("attributes", {}).get(
|
||||||
|
"friendly_name", eid
|
||||||
|
),
|
||||||
|
"domain": eid.split(".")[0] if "." in eid else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return entities
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"HA fetch_entities failed ({self._source_id}): {e}")
|
||||||
|
return []
|
||||||
@@ -60,6 +60,7 @@ class MQTTService:
|
|||||||
try:
|
try:
|
||||||
await self._task
|
await self._task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("MQTT background task cancelled")
|
||||||
pass
|
pass
|
||||||
self._task = None
|
self._task = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
@@ -79,6 +80,7 @@ class MQTTService:
|
|||||||
try:
|
try:
|
||||||
self._publish_queue.put_nowait((topic, payload, retain, qos))
|
self._publish_queue.put_nowait((topic, payload, retain, qos))
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
|
logger.warning("MQTT publish queue full, dropping message for topic %s", topic)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def subscribe(self, topic: str, callback: Callable) -> None:
|
async def subscribe(self, topic: str, callback: Callable) -> None:
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
fallback = source.fallback_color
|
fallback = source.fallback_color
|
||||||
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
||||||
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
||||||
|
self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear"
|
||||||
self._led_count = _DEFAULT_LED_COUNT
|
self._led_count = _DEFAULT_LED_COUNT
|
||||||
|
|
||||||
# Build initial fallback buffer
|
# Build initial fallback buffer
|
||||||
@@ -77,31 +78,59 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
self._colors = self._fallback_array.copy()
|
self._colors = self._fallback_array.copy()
|
||||||
logger.debug(f"ApiInputColorStripStream buffer grown to {required} LEDs")
|
logger.debug(f"ApiInputColorStripStream buffer grown to {required} LEDs")
|
||||||
|
|
||||||
|
def _resize(self, colors: np.ndarray, target_count: int) -> np.ndarray:
|
||||||
|
"""Resize colors array to target_count using the configured interpolation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
colors: np.ndarray shape (N, 3) uint8
|
||||||
|
target_count: desired LED count
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
np.ndarray shape (target_count, 3) uint8
|
||||||
|
"""
|
||||||
|
n = len(colors)
|
||||||
|
if n == target_count:
|
||||||
|
return colors
|
||||||
|
|
||||||
|
if self._interpolation == "none":
|
||||||
|
# Truncate or zero-pad (legacy behavior)
|
||||||
|
result = np.zeros((target_count, 3), dtype=np.uint8)
|
||||||
|
copy_len = min(n, target_count)
|
||||||
|
result[:copy_len] = colors[:copy_len]
|
||||||
|
return result
|
||||||
|
|
||||||
|
if self._interpolation == "nearest":
|
||||||
|
indices = np.round(np.linspace(0, n - 1, target_count)).astype(int)
|
||||||
|
return colors[indices].copy()
|
||||||
|
|
||||||
|
# linear (default)
|
||||||
|
src_positions = np.linspace(0, 1, n)
|
||||||
|
dst_positions = np.linspace(0, 1, target_count)
|
||||||
|
result = np.empty((target_count, 3), dtype=np.uint8)
|
||||||
|
for ch in range(3):
|
||||||
|
result[:, ch] = np.interp(dst_positions, src_positions, colors[:, ch].astype(np.float32)).astype(np.uint8)
|
||||||
|
return result
|
||||||
|
|
||||||
def push_colors(self, colors: np.ndarray) -> None:
|
def push_colors(self, colors: np.ndarray) -> None:
|
||||||
"""Push a new frame of LED colors.
|
"""Push a new frame of LED colors.
|
||||||
|
|
||||||
Thread-safe. Auto-grows the buffer if the incoming array is larger
|
Thread-safe. When the incoming LED count differs from the device
|
||||||
than the current buffer; otherwise truncates or zero-pads.
|
LED count, the data is resized according to the configured
|
||||||
|
interpolation mode (none/linear/nearest).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
colors: np.ndarray shape (N, 3) uint8
|
colors: np.ndarray shape (N, 3) uint8
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
n = len(colors)
|
n = len(colors)
|
||||||
# Auto-grow if incoming data is larger
|
|
||||||
if n > self._led_count:
|
|
||||||
self._ensure_capacity(n)
|
|
||||||
if n == self._led_count:
|
if n == self._led_count:
|
||||||
if self._colors.shape == colors.shape:
|
if self._colors.shape == colors.shape:
|
||||||
np.copyto(self._colors, colors, casting='unsafe')
|
np.copyto(self._colors, colors, casting='unsafe')
|
||||||
else:
|
else:
|
||||||
self._colors = np.empty((n, 3), dtype=np.uint8)
|
self._colors = np.empty((n, 3), dtype=np.uint8)
|
||||||
np.copyto(self._colors, colors, casting='unsafe')
|
np.copyto(self._colors, colors, casting='unsafe')
|
||||||
elif n < self._led_count:
|
else:
|
||||||
# Zero-pad to led_count
|
self._colors = self._resize(colors, self._led_count)
|
||||||
padded = np.zeros((self._led_count, 3), dtype=np.uint8)
|
|
||||||
padded[:n] = colors[:n]
|
|
||||||
self._colors = padded
|
|
||||||
self._last_push_time = time.monotonic()
|
self._last_push_time = time.monotonic()
|
||||||
self._push_generation += 1
|
self._push_generation += 1
|
||||||
self._timed_out = False
|
self._timed_out = False
|
||||||
@@ -228,12 +257,13 @@ class ApiInputColorStripStream(ColorStripStream):
|
|||||||
return self._push_generation
|
return self._push_generation
|
||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
"""Hot-update fallback_color and timeout from updated source config."""
|
"""Hot-update fallback_color, timeout, and interpolation from updated source config."""
|
||||||
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
|
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
|
||||||
if isinstance(source, ApiInputColorStripSource):
|
if isinstance(source, ApiInputColorStripSource):
|
||||||
fallback = source.fallback_color
|
fallback = source.fallback_color
|
||||||
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
|
||||||
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
|
||||||
|
self._interpolation = source.interpolation if source.interpolation in ("none", "linear", "nearest") else "linear"
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._fallback_array = self._build_fallback(self._led_count)
|
self._fallback_array = self._build_fallback(self._led_count)
|
||||||
if self._timed_out:
|
if self._timed_out:
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ class AudioColorStripStream(ColorStripStream):
|
|||||||
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
|
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
|
||||||
self._audio_engine_type = tpl.engine_type
|
self._audio_engine_type = tpl.engine_type
|
||||||
self._audio_engine_config = tpl.engine_config
|
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
|
pass
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Failed to resolve audio source {audio_source_id}: {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}``.
|
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:
|
Args:
|
||||||
color_strip_store: ColorStripStore for resolving source configs
|
color_strip_store: ColorStripStore for resolving source configs
|
||||||
@@ -91,6 +91,7 @@ class ColorStripStreamManager:
|
|||||||
self._cspt_store = cspt_store
|
self._cspt_store = cspt_store
|
||||||
self._gradient_store = gradient_store
|
self._gradient_store = gradient_store
|
||||||
self._weather_manager = weather_manager
|
self._weather_manager = weather_manager
|
||||||
|
self._asset_store = asset_store
|
||||||
self._streams: Dict[str, _ColorStripEntry] = {}
|
self._streams: Dict[str, _ColorStripEntry] = {}
|
||||||
|
|
||||||
def _inject_clock(self, css_stream, source) -> Optional[str]:
|
def _inject_clock(self, css_stream, source) -> Optional[str]:
|
||||||
@@ -125,7 +126,8 @@ class ColorStripStreamManager:
|
|||||||
clock_id = getattr(source, "clock_id", None)
|
clock_id = getattr(source, "clock_id", None)
|
||||||
if clock_id:
|
if clock_id:
|
||||||
self._sync_clock_manager.release(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
|
pass # source may have been deleted already
|
||||||
|
|
||||||
def _resolve_key(self, css_id: str, consumer_id: str) -> str:
|
def _resolve_key(self, css_id: str, consumer_id: str) -> str:
|
||||||
@@ -186,6 +188,9 @@ class ColorStripStreamManager:
|
|||||||
# Inject gradient store for palette resolution
|
# Inject gradient store for palette resolution
|
||||||
if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
|
if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
|
||||||
css_stream.set_gradient_store(self._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
|
# Inject sync clock runtime if source references a clock
|
||||||
acquired_clock_id = self._inject_clock(css_stream, source)
|
acquired_clock_id = self._inject_clock(css_stream, source)
|
||||||
css_stream.start()
|
css_stream.start()
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
self._colors_lock = threading.Lock()
|
self._colors_lock = threading.Lock()
|
||||||
self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called
|
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)
|
# layer_index -> (source_id, consumer_id, stream)
|
||||||
self._sub_streams: Dict[int, tuple] = {}
|
self._sub_streams: Dict[int, tuple] = {}
|
||||||
# layer_index -> (vs_id, value_stream)
|
# layer_index -> (vs_id, value_stream)
|
||||||
@@ -560,9 +562,16 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
continue
|
continue
|
||||||
# Resize to zone length
|
# Resize to zone length
|
||||||
if len(colors) != zone_len:
|
if len(colors) != zone_len:
|
||||||
src_x = np.linspace(0, 1, len(colors))
|
rkey = (len(colors), zone_len)
|
||||||
dst_x = np.linspace(0, 1, zone_len)
|
cached = self._resize_cache.get(rkey)
|
||||||
resized = np.empty((zone_len, 3), dtype=np.uint8)
|
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):
|
for ch in range(3):
|
||||||
np.copyto(resized[:, ch], np.interp(dst_x, src_x, colors[:, ch]), casting="unsafe")
|
np.copyto(resized[:, ch], np.interp(dst_x, src_x, colors[:, ch]), casting="unsafe")
|
||||||
colors = resized
|
colors = resized
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ class DeviceHealthMixin:
|
|||||||
interval = ACTIVE_INTERVAL if self._is_device_streaming(device_id) else IDLE_INTERVAL
|
interval = ACTIVE_INTERVAL if self._is_device_streaming(device_id) else IDLE_INTERVAL
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("Device health monitor cancelled for %s", device_id)
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
|
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""Home Assistant light target processor — casts LED colors to HA lights.
|
||||||
|
|
||||||
|
Reads from a ColorStripStream, averages LED segments to single RGB values,
|
||||||
|
and calls light.turn_on / light.turn_off via the HA WebSocket connection.
|
||||||
|
Rate-limited to update_rate Hz (typically 1-5 Hz).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.processing.target_processor import TargetContext, TargetProcessor
|
||||||
|
from wled_controller.storage.ha_light_output_target import HALightMapping
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HALightTargetProcessor(TargetProcessor):
|
||||||
|
"""Streams averaged LED colors to Home Assistant light entities."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
target_id: str,
|
||||||
|
ha_source_id: str,
|
||||||
|
color_strip_source_id: str = "",
|
||||||
|
light_mappings: Optional[List[HALightMapping]] = None,
|
||||||
|
update_rate: float = 2.0,
|
||||||
|
transition: float = 0.5,
|
||||||
|
min_brightness_threshold: int = 0,
|
||||||
|
color_tolerance: int = 5,
|
||||||
|
ctx: Optional[TargetContext] = None,
|
||||||
|
):
|
||||||
|
super().__init__(target_id, ctx)
|
||||||
|
self._ha_source_id = ha_source_id
|
||||||
|
self._css_id = color_strip_source_id
|
||||||
|
self._light_mappings = light_mappings or []
|
||||||
|
self._update_rate = max(0.5, min(5.0, update_rate))
|
||||||
|
self._transition = transition
|
||||||
|
self._min_brightness_threshold = min_brightness_threshold
|
||||||
|
self._color_tolerance = color_tolerance
|
||||||
|
|
||||||
|
# Runtime state
|
||||||
|
self._css_stream = None
|
||||||
|
self._ha_runtime = None
|
||||||
|
self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
|
||||||
|
self._previous_on: Dict[str, bool] = {} # track on/off state per entity
|
||||||
|
self._start_time: Optional[float] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_id(self) -> Optional[str]:
|
||||||
|
return None # HA light targets don't use device providers
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._is_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Acquire CSS stream
|
||||||
|
if self._css_id and self._ctx.color_strip_stream_manager:
|
||||||
|
try:
|
||||||
|
self._css_stream = self._ctx.color_strip_stream_manager.acquire(
|
||||||
|
self._css_id, self._target_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"HA light {self._target_id}: failed to acquire CSS stream: {e}")
|
||||||
|
|
||||||
|
# Acquire HA runtime
|
||||||
|
try:
|
||||||
|
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
|
||||||
|
|
||||||
|
ha_manager: Optional[HomeAssistantManager] = getattr(self._ctx, "ha_manager", None)
|
||||||
|
if ha_manager:
|
||||||
|
self._ha_runtime = await ha_manager.acquire(self._ha_source_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"HA light {self._target_id}: failed to acquire HA runtime: {e}")
|
||||||
|
|
||||||
|
self._is_running = True
|
||||||
|
self._start_time = time.monotonic()
|
||||||
|
self._task = asyncio.create_task(self._processing_loop())
|
||||||
|
logger.info(f"HA light target started: {self._target_id}")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._is_running = False
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
# Release CSS stream
|
||||||
|
if self._css_stream and self._ctx.color_strip_stream_manager:
|
||||||
|
try:
|
||||||
|
self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._css_stream = None
|
||||||
|
|
||||||
|
# Release HA runtime
|
||||||
|
if self._ha_runtime:
|
||||||
|
try:
|
||||||
|
ha_manager = getattr(self._ctx, "ha_manager", None)
|
||||||
|
if ha_manager:
|
||||||
|
await ha_manager.release(self._ha_source_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._ha_runtime = None
|
||||||
|
|
||||||
|
self._previous_colors.clear()
|
||||||
|
self._previous_on.clear()
|
||||||
|
logger.info(f"HA light target stopped: {self._target_id}")
|
||||||
|
|
||||||
|
def update_settings(self, settings) -> None:
|
||||||
|
if isinstance(settings, dict):
|
||||||
|
if "update_rate" in settings:
|
||||||
|
self._update_rate = max(0.5, min(5.0, float(settings["update_rate"])))
|
||||||
|
if "transition" in settings:
|
||||||
|
self._transition = float(settings["transition"])
|
||||||
|
if "min_brightness_threshold" in settings:
|
||||||
|
self._min_brightness_threshold = int(settings["min_brightness_threshold"])
|
||||||
|
if "color_tolerance" in settings:
|
||||||
|
self._color_tolerance = int(settings["color_tolerance"])
|
||||||
|
if "light_mappings" in settings:
|
||||||
|
self._light_mappings = settings["light_mappings"]
|
||||||
|
|
||||||
|
def update_css_source(self, color_strip_source_id: str) -> None:
|
||||||
|
"""Hot-swap the CSS stream."""
|
||||||
|
old_id = self._css_id
|
||||||
|
self._css_id = color_strip_source_id
|
||||||
|
|
||||||
|
if self._is_running and self._ctx.color_strip_stream_manager:
|
||||||
|
try:
|
||||||
|
new_stream = self._ctx.color_strip_stream_manager.acquire(
|
||||||
|
color_strip_source_id, self._target_id
|
||||||
|
)
|
||||||
|
old_stream = self._css_stream
|
||||||
|
self._css_stream = new_stream
|
||||||
|
if old_stream:
|
||||||
|
self._ctx.color_strip_stream_manager.release(old_id, self._target_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"HA light {self._target_id}: CSS swap failed: {e}")
|
||||||
|
|
||||||
|
def get_state(self) -> dict:
|
||||||
|
return {
|
||||||
|
"target_id": self._target_id,
|
||||||
|
"ha_source_id": self._ha_source_id,
|
||||||
|
"css_id": self._css_id,
|
||||||
|
"is_running": self._is_running,
|
||||||
|
"ha_connected": self._ha_runtime.is_connected if self._ha_runtime else False,
|
||||||
|
"light_count": len(self._light_mappings),
|
||||||
|
"update_rate": self._update_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_metrics(self) -> dict:
|
||||||
|
return {
|
||||||
|
"target_id": self._target_id,
|
||||||
|
"uptime": time.monotonic() - self._start_time if self._start_time else 0,
|
||||||
|
"update_rate": self._update_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _processing_loop(self) -> None:
|
||||||
|
"""Main loop: read CSS colors, average per mapping, send to HA lights."""
|
||||||
|
interval = 1.0 / self._update_rate
|
||||||
|
|
||||||
|
while self._is_running:
|
||||||
|
try:
|
||||||
|
loop_start = time.monotonic()
|
||||||
|
|
||||||
|
if self._css_stream and self._ha_runtime and self._ha_runtime.is_connected:
|
||||||
|
colors = self._css_stream.get_latest_colors()
|
||||||
|
if colors is not None and len(colors) > 0:
|
||||||
|
await self._update_lights(colors)
|
||||||
|
|
||||||
|
# Sleep for remaining frame time
|
||||||
|
elapsed = time.monotonic() - loop_start
|
||||||
|
sleep_time = max(0.05, interval - elapsed)
|
||||||
|
await asyncio.sleep(sleep_time)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"HA light {self._target_id} loop error: {e}")
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
async def _update_lights(self, colors: np.ndarray) -> None:
|
||||||
|
"""Average LED segments and call HA services for changed lights."""
|
||||||
|
led_count = len(colors)
|
||||||
|
|
||||||
|
for mapping in self._light_mappings:
|
||||||
|
if not mapping.entity_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Resolve LED range
|
||||||
|
start = max(0, mapping.led_start)
|
||||||
|
end = mapping.led_end if mapping.led_end >= 0 else led_count
|
||||||
|
end = min(end, led_count)
|
||||||
|
if start >= end:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Average the LED segment
|
||||||
|
segment = colors[start:end]
|
||||||
|
avg = segment.mean(axis=0).astype(int)
|
||||||
|
r, g, b = int(avg[0]), int(avg[1]), int(avg[2])
|
||||||
|
|
||||||
|
# Calculate brightness (0-255) from max channel
|
||||||
|
brightness = max(r, g, b)
|
||||||
|
|
||||||
|
# Apply brightness scale
|
||||||
|
if mapping.brightness_scale < 1.0:
|
||||||
|
brightness = int(brightness * mapping.brightness_scale)
|
||||||
|
|
||||||
|
# Check brightness threshold
|
||||||
|
should_be_on = (
|
||||||
|
brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_id = mapping.entity_id
|
||||||
|
prev_color = self._previous_colors.get(entity_id)
|
||||||
|
was_on = self._previous_on.get(entity_id, True)
|
||||||
|
|
||||||
|
if should_be_on:
|
||||||
|
# Check if color changed beyond tolerance
|
||||||
|
new_color = (r, g, b)
|
||||||
|
if prev_color is not None and was_on:
|
||||||
|
dr = abs(r - prev_color[0])
|
||||||
|
dg = abs(g - prev_color[1])
|
||||||
|
db = abs(b - prev_color[2])
|
||||||
|
if max(dr, dg, db) < self._color_tolerance:
|
||||||
|
continue # skip — color hasn't changed enough
|
||||||
|
|
||||||
|
# Call light.turn_on
|
||||||
|
service_data = {
|
||||||
|
"rgb_color": [r, g, b],
|
||||||
|
"brightness": min(255, int(brightness * mapping.brightness_scale)),
|
||||||
|
}
|
||||||
|
if self._transition > 0:
|
||||||
|
service_data["transition"] = self._transition
|
||||||
|
|
||||||
|
await self._ha_runtime.call_service(
|
||||||
|
domain="light",
|
||||||
|
service="turn_on",
|
||||||
|
service_data=service_data,
|
||||||
|
target={"entity_id": entity_id},
|
||||||
|
)
|
||||||
|
self._previous_colors[entity_id] = new_color
|
||||||
|
self._previous_on[entity_id] = True
|
||||||
|
|
||||||
|
elif was_on:
|
||||||
|
# Brightness dropped below threshold — turn off
|
||||||
|
await self._ha_runtime.call_service(
|
||||||
|
domain="light",
|
||||||
|
service="turn_off",
|
||||||
|
service_data={},
|
||||||
|
target={"entity_id": entity_id},
|
||||||
|
)
|
||||||
|
self._previous_on[entity_id] = False
|
||||||
|
self._previous_colors.pop(entity_id, None)
|
||||||
@@ -193,6 +193,7 @@ class KCTargetProcessor(TargetProcessor):
|
|||||||
try:
|
try:
|
||||||
await self._task
|
await self._task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("KC target processor task cancelled")
|
||||||
pass
|
pass
|
||||||
self._task = None
|
self._task = None
|
||||||
|
|
||||||
@@ -476,7 +477,8 @@ class KCTargetProcessor(TargetProcessor):
|
|||||||
try:
|
try:
|
||||||
await ws.send_text(message)
|
await ws.send_text(message)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("KC WS send failed: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
clients = list(self._ws_clients)
|
clients = list(self._ws_clients)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ releases them.
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
import httpx
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from wled_controller.core.capture_engines import EngineRegistry
|
from wled_controller.core.capture_engines import EngineRegistry
|
||||||
@@ -54,17 +53,19 @@ class LiveStreamManager:
|
|||||||
enabling sharing at every level of the stream chain.
|
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.
|
"""Initialize the live stream manager.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
picture_source_store: PictureSourceStore for resolving stream configs
|
picture_source_store: PictureSourceStore for resolving stream configs
|
||||||
capture_template_store: TemplateStore for resolving capture engine settings
|
capture_template_store: TemplateStore for resolving capture engine settings
|
||||||
pp_template_store: PostprocessingTemplateStore for resolving filter chains
|
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._picture_source_store = picture_source_store
|
||||||
self._capture_template_store = capture_template_store
|
self._capture_template_store = capture_template_store
|
||||||
self._pp_template_store = pp_template_store
|
self._pp_template_store = pp_template_store
|
||||||
|
self._asset_store = asset_store
|
||||||
self._streams: Dict[str, _LiveStreamEntry] = {}
|
self._streams: Dict[str, _LiveStreamEntry] = {}
|
||||||
|
|
||||||
def acquire(self, picture_source_id: str) -> LiveStream:
|
def acquire(self, picture_source_id: str) -> LiveStream:
|
||||||
@@ -268,6 +269,21 @@ class LiveStreamManager:
|
|||||||
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
|
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
|
||||||
return resolved
|
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):
|
def _create_video_live_stream(self, config):
|
||||||
"""Create a VideoCaptureLiveStream from a VideoCaptureSource config."""
|
"""Create a VideoCaptureLiveStream from a VideoCaptureSource config."""
|
||||||
if not _has_video:
|
if not _has_video:
|
||||||
@@ -275,8 +291,9 @@ class LiveStreamManager:
|
|||||||
"OpenCV is required for video stream support. "
|
"OpenCV is required for video stream support. "
|
||||||
"Install it with: pip install opencv-python-headless"
|
"Install it with: pip install opencv-python-headless"
|
||||||
)
|
)
|
||||||
|
video_path = self._resolve_asset_path(config.video_asset_id, "video source")
|
||||||
stream = VideoCaptureLiveStream(
|
stream = VideoCaptureLiveStream(
|
||||||
url=config.url,
|
url=video_path,
|
||||||
loop=config.loop,
|
loop=config.loop,
|
||||||
playback_speed=config.playback_speed,
|
playback_speed=config.playback_speed,
|
||||||
start_time=config.start_time,
|
start_time=config.start_time,
|
||||||
@@ -300,27 +317,18 @@ class LiveStreamManager:
|
|||||||
|
|
||||||
def _create_static_image_live_stream(self, config) -> StaticImageLiveStream:
|
def _create_static_image_live_stream(self, config) -> StaticImageLiveStream:
|
||||||
"""Create a StaticImageLiveStream from a StaticImagePictureSource config."""
|
"""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)
|
return StaticImageLiveStream(image)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _load_static_image(image_source: str) -> np.ndarray:
|
def _load_static_image(file_path: str) -> np.ndarray:
|
||||||
"""Load a static image from URL or file path, return as RGB numpy array.
|
"""Load a static image from a local 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.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
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://")):
|
path = Path(file_path)
|
||||||
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():
|
if not path.exists():
|
||||||
raise FileNotFoundError(f"Image file not found: {image_source}")
|
raise FileNotFoundError(f"Image file not found: {file_path}")
|
||||||
return load_image_file(path)
|
return load_image_file(path)
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class MappedColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# zone_index -> (source_id, consumer_id, stream)
|
# zone_index -> (source_id, consumer_id, stream)
|
||||||
self._sub_streams: Dict[int, tuple] = {}
|
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
|
self._sub_lock = threading.Lock() # guards _sub_streams access across threads
|
||||||
|
|
||||||
# ── ColorStripStream interface ──────────────────────────────
|
# ── ColorStripStream interface ──────────────────────────────
|
||||||
@@ -210,9 +212,16 @@ class MappedColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
# Resize sub-stream output to zone length if needed
|
# Resize sub-stream output to zone length if needed
|
||||||
if len(colors) != zone_len:
|
if len(colors) != zone_len:
|
||||||
src_x = np.linspace(0, 1, len(colors))
|
rkey = (len(colors), zone_len)
|
||||||
dst_x = np.linspace(0, 1, zone_len)
|
cached = self._resize_cache.get(rkey)
|
||||||
resized = np.empty((zone_len, 3), dtype=np.uint8)
|
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):
|
for ch in range(3):
|
||||||
np.copyto(
|
np.copyto(
|
||||||
resized[:, ch],
|
resized[:, ch],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Server-side ring buffer for system and per-target metrics."""
|
"""Server-side ring buffer for system and per-target metrics."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
@@ -8,7 +9,11 @@ from typing import Dict, Optional
|
|||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
|
from wled_controller.utils.gpu import (
|
||||||
|
nvml_available as _nvml_available,
|
||||||
|
nvml as _nvml,
|
||||||
|
nvml_handle as _nvml_handle,
|
||||||
|
)
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -16,20 +21,28 @@ MAX_SAMPLES = 120 # ~2 minutes at 1-second interval
|
|||||||
SAMPLE_INTERVAL = 1.0 # seconds
|
SAMPLE_INTERVAL = 1.0 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
_process = psutil.Process(os.getpid())
|
||||||
|
_process.cpu_percent(interval=None) # prime process-level counter
|
||||||
|
|
||||||
|
|
||||||
def _collect_system_snapshot() -> dict:
|
def _collect_system_snapshot() -> dict:
|
||||||
"""Collect CPU/RAM/GPU metrics (blocking — run in thread pool).
|
"""Collect CPU/RAM/GPU metrics (blocking — run in thread pool).
|
||||||
|
|
||||||
Returns a dict suitable for direct JSON serialization.
|
Returns a dict suitable for direct JSON serialization.
|
||||||
"""
|
"""
|
||||||
mem = psutil.virtual_memory()
|
mem = psutil.virtual_memory()
|
||||||
|
proc_mem = _process.memory_info()
|
||||||
snapshot = {
|
snapshot = {
|
||||||
"t": datetime.now(timezone.utc).isoformat(),
|
"t": datetime.now(timezone.utc).isoformat(),
|
||||||
"cpu": psutil.cpu_percent(interval=None),
|
"cpu": psutil.cpu_percent(interval=None),
|
||||||
"ram_pct": mem.percent,
|
"ram_pct": mem.percent,
|
||||||
"ram_used": round(mem.used / 1024 / 1024, 1),
|
"ram_used": round(mem.used / 1024 / 1024, 1),
|
||||||
"ram_total": round(mem.total / 1024 / 1024, 1),
|
"ram_total": round(mem.total / 1024 / 1024, 1),
|
||||||
|
"app_cpu": _process.cpu_percent(interval=None),
|
||||||
|
"app_ram": round(proc_mem.rss / 1024 / 1024, 1),
|
||||||
"gpu_util": None,
|
"gpu_util": None,
|
||||||
"gpu_temp": None,
|
"gpu_temp": None,
|
||||||
|
"app_gpu_mem": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -38,8 +51,17 @@ def _collect_system_snapshot() -> dict:
|
|||||||
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
|
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
|
||||||
snapshot["gpu_util"] = float(util.gpu)
|
snapshot["gpu_util"] = float(util.gpu)
|
||||||
snapshot["gpu_temp"] = float(temp)
|
snapshot["gpu_temp"] = float(temp)
|
||||||
|
try:
|
||||||
|
pid = os.getpid()
|
||||||
|
for proc_info in _nvml.nvmlDeviceGetComputeRunningProcesses(_nvml_handle):
|
||||||
|
if proc_info.pid == pid and proc_info.usedGpuMemory:
|
||||||
|
snapshot["app_gpu_mem"] = round(proc_info.usedGpuMemory / 1024 / 1024, 1)
|
||||||
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("GPU metrics collection failed: %s", e)
|
||||||
|
pass
|
||||||
|
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
@@ -67,6 +89,7 @@ class MetricsHistory:
|
|||||||
try:
|
try:
|
||||||
await self._task
|
await self._task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("Metrics history collection task cancelled")
|
||||||
pass
|
pass
|
||||||
self._task = None
|
self._task = None
|
||||||
logger.info("Metrics history sampling stopped")
|
logger.info("Metrics history sampling stopped")
|
||||||
@@ -102,14 +125,16 @@ class MetricsHistory:
|
|||||||
if target_id not in self._targets:
|
if target_id not in self._targets:
|
||||||
self._targets[target_id] = deque(maxlen=MAX_SAMPLES)
|
self._targets[target_id] = deque(maxlen=MAX_SAMPLES)
|
||||||
if state.get("processing"):
|
if state.get("processing"):
|
||||||
self._targets[target_id].append({
|
self._targets[target_id].append(
|
||||||
|
{
|
||||||
"t": now,
|
"t": now,
|
||||||
"fps": state.get("fps_actual"),
|
"fps": state.get("fps_actual"),
|
||||||
"fps_current": state.get("fps_current"),
|
"fps_current": state.get("fps_current"),
|
||||||
"fps_target": state.get("fps_target"),
|
"fps_target": state.get("fps_target"),
|
||||||
"timing": state.get("timing_total_ms"),
|
"timing": state.get("timing_total_ms"),
|
||||||
"errors": state.get("errors_count", 0),
|
"errors": state.get("errors_count", 0),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Prune deques for targets no longer registered
|
# Prune deques for targets no longer registered
|
||||||
for tid in list(self._targets.keys()):
|
for tid in list(self._targets.keys()):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from any thread (REST handler) while get_latest_colors() is called from the
|
|||||||
target processor thread.
|
target processor thread.
|
||||||
|
|
||||||
Uses a background render loop at 30 FPS with double-buffered output.
|
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
|
import collections
|
||||||
@@ -61,8 +62,15 @@ class NotificationColorStripStream(ColorStripStream):
|
|||||||
# Active effect state
|
# Active effect state
|
||||||
self._active_effect: Optional[dict] = None # {"color": (r,g,b), "start": float}
|
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)
|
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:
|
def _update_from_source(self, source) -> None:
|
||||||
"""Parse config from source dataclass."""
|
"""Parse config from source dataclass."""
|
||||||
self._notification_effect = getattr(source, "notification_effect", "flash")
|
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._app_filter_list = [a.lower() for a in getattr(source, "app_filter_list", [])]
|
||||||
self._auto_size = not getattr(source, "led_count", 0)
|
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
|
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:
|
with self._colors_lock:
|
||||||
self._colors: Optional[np.ndarray] = np.zeros((self._led_count, 3), dtype=np.uint8)
|
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: 0 = normal, 1 = high (high interrupts current effect)
|
||||||
priority = 1 if color_override else 0
|
priority = 1 if color_override else 0
|
||||||
self._event_queue.append({"color": color, "start": time.monotonic(), "priority": priority})
|
self._event_queue.append({"color": color, "start": time.monotonic(), "priority": priority})
|
||||||
|
|
||||||
|
# Play notification sound
|
||||||
|
self._play_notification_sound(app_lower)
|
||||||
|
|
||||||
return True
|
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:
|
def configure(self, device_led_count: int) -> None:
|
||||||
"""Set LED count from the target device (called on target start)."""
|
"""Set LED count from the target device (called on target start)."""
|
||||||
if self._auto_size and device_led_count > 0:
|
if self._auto_size and device_led_count > 0:
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ Supported platforms:
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
import collections
|
||||||
|
import json
|
||||||
import platform
|
import platform
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Set
|
from typing import Dict, List, Optional, Set
|
||||||
|
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
@@ -22,6 +24,8 @@ from wled_controller.utils import get_logger
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
_POLL_INTERVAL = 0.5 # seconds between polls (Windows only)
|
_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
|
# Module-level singleton for dependency access
|
||||||
_instance: Optional["OsNotificationListener"] = None
|
_instance: Optional["OsNotificationListener"] = None
|
||||||
@@ -48,7 +52,8 @@ def _import_winrt_notifications():
|
|||||||
)
|
)
|
||||||
from winrt.windows.ui.notifications import NotificationKinds
|
from winrt.windows.ui.notifications import NotificationKinds
|
||||||
return UserNotificationListener, UserNotificationListenerAccessStatus, NotificationKinds, "winrt"
|
return UserNotificationListener, UserNotificationListenerAccessStatus, NotificationKinds, "winrt"
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
|
logger.debug("winrt notification packages not available, trying winsdk: %s", e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback: winsdk (~35MB, may already be installed)
|
# Fallback: winsdk (~35MB, may already be installed)
|
||||||
@@ -282,7 +287,8 @@ class OsNotificationListener:
|
|||||||
self._available = False
|
self._available = False
|
||||||
self._backend = None
|
self._backend = None
|
||||||
# Recent notification history (thread-safe deque, newest first)
|
# 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
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
@@ -308,6 +314,7 @@ class OsNotificationListener:
|
|||||||
if self._backend:
|
if self._backend:
|
||||||
self._backend.stop()
|
self._backend.stop()
|
||||||
self._backend = None
|
self._backend = None
|
||||||
|
self._save_history()
|
||||||
logger.info("OS notification listener stopped")
|
logger.info("OS notification listener stopped")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -315,6 +322,29 @@ class OsNotificationListener:
|
|||||||
"""Return recent notification history (newest first)."""
|
"""Return recent notification history (newest first)."""
|
||||||
return list(self._history)
|
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:
|
def _on_new_notification(self, app_name: Optional[str]) -> None:
|
||||||
"""Handle a new OS notification — fire matching streams."""
|
"""Handle a new OS notification — fire matching streams."""
|
||||||
from wled_controller.storage.color_strip_source import NotificationColorStripSource
|
from wled_controller.storage.color_strip_source import NotificationColorStripSource
|
||||||
@@ -347,5 +377,6 @@ class OsNotificationListener:
|
|||||||
"filtered": filtered,
|
"filtered": filtered,
|
||||||
}
|
}
|
||||||
self._history.appendleft(entry)
|
self._history.appendleft(entry)
|
||||||
|
self._save_history()
|
||||||
|
|
||||||
logger.info(f"OS notification captured: app={app_name!r}, fired={fired}, filtered={filtered}")
|
logger.info(f"OS notification captured: app={app_name!r}, fired={fired}, filtered={filtered}")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -28,6 +28,22 @@ from wled_controller.core.processing.auto_restart import (
|
|||||||
RESTART_MAX_ATTEMPTS as _RESTART_MAX_ATTEMPTS,
|
RESTART_MAX_ATTEMPTS as _RESTART_MAX_ATTEMPTS,
|
||||||
RESTART_WINDOW_SEC as _RESTART_WINDOW_SEC,
|
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_health import DeviceHealthMixin
|
||||||
from wled_controller.core.processing.device_test_mode import DeviceTestModeMixin
|
from wled_controller.core.processing.device_test_mode import DeviceTestModeMixin
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
@@ -44,19 +60,21 @@ class ProcessorDependencies:
|
|||||||
Keeps the constructor signature stable when new stores are added.
|
Keeps the constructor signature stable when new stores are added.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
picture_source_store: object = None
|
picture_source_store: Optional[PictureSourceStore] = None
|
||||||
capture_template_store: object = None
|
capture_template_store: Optional[TemplateStore] = None
|
||||||
pp_template_store: object = None
|
pp_template_store: Optional[PostprocessingTemplateStore] = None
|
||||||
pattern_template_store: object = None
|
pattern_template_store: Optional[PatternTemplateStore] = None
|
||||||
device_store: object = None
|
device_store: Optional[DeviceStore] = None
|
||||||
color_strip_store: object = None
|
color_strip_store: Optional[ColorStripStore] = None
|
||||||
audio_source_store: object = None
|
audio_source_store: Optional[AudioSourceStore] = None
|
||||||
audio_template_store: object = None
|
audio_template_store: Optional[AudioTemplateStore] = None
|
||||||
value_source_store: object = None
|
value_source_store: Optional[ValueSourceStore] = None
|
||||||
sync_clock_manager: object = None
|
sync_clock_manager: Optional[SyncClockManager] = None
|
||||||
cspt_store: object = None
|
cspt_store: Optional[ColorStripProcessingTemplateStore] = None
|
||||||
gradient_store: object = None
|
gradient_store: Optional[GradientStore] = None
|
||||||
weather_manager: object = None
|
weather_manager: Optional[WeatherManager] = None
|
||||||
|
asset_store: Optional[AssetStore] = None
|
||||||
|
ha_manager: Optional[Any] = None # HomeAssistantManager
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -119,7 +137,10 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
self._value_source_store = deps.value_source_store
|
self._value_source_store = deps.value_source_store
|
||||||
self._cspt_store = deps.cspt_store
|
self._cspt_store = deps.cspt_store
|
||||||
self._live_stream_manager = LiveStreamManager(
|
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._audio_capture_manager = AudioCaptureManager()
|
||||||
self._sync_clock_manager = deps.sync_clock_manager
|
self._sync_clock_manager = deps.sync_clock_manager
|
||||||
@@ -133,16 +154,22 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
cspt_store=deps.cspt_store,
|
cspt_store=deps.cspt_store,
|
||||||
gradient_store=deps.gradient_store,
|
gradient_store=deps.gradient_store,
|
||||||
weather_manager=deps.weather_manager,
|
weather_manager=deps.weather_manager,
|
||||||
|
asset_store=deps.asset_store,
|
||||||
)
|
)
|
||||||
self._value_stream_manager = ValueStreamManager(
|
self._value_stream_manager = (
|
||||||
|
ValueStreamManager(
|
||||||
value_source_store=deps.value_source_store,
|
value_source_store=deps.value_source_store,
|
||||||
audio_capture_manager=self._audio_capture_manager,
|
audio_capture_manager=self._audio_capture_manager,
|
||||||
audio_source_store=deps.audio_source_store,
|
audio_source_store=deps.audio_source_store,
|
||||||
live_stream_manager=self._live_stream_manager,
|
live_stream_manager=self._live_stream_manager,
|
||||||
audio_template_store=deps.audio_template_store,
|
audio_template_store=deps.audio_template_store,
|
||||||
) if deps.value_source_store else None
|
)
|
||||||
|
if deps.value_source_store
|
||||||
|
else None
|
||||||
|
)
|
||||||
# Wire value stream manager into CSS stream manager for composite layer brightness
|
# Wire value stream manager into CSS stream manager for composite layer brightness
|
||||||
self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager
|
self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager
|
||||||
|
self._ha_manager = deps.ha_manager
|
||||||
self._overlay_manager = OverlayManager()
|
self._overlay_manager = OverlayManager()
|
||||||
self._event_queues: List[asyncio.Queue] = []
|
self._event_queues: List[asyncio.Queue] = []
|
||||||
self._metrics_history = MetricsHistory(self)
|
self._metrics_history = MetricsHistory(self)
|
||||||
@@ -182,15 +209,24 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
cspt_store=self._cspt_store,
|
cspt_store=self._cspt_store,
|
||||||
fire_event=self.fire_event,
|
fire_event=self.fire_event,
|
||||||
get_device_info=self._get_device_info,
|
get_device_info=self._get_device_info,
|
||||||
|
ha_manager=self._ha_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default values for device-specific fields read from persistent storage
|
# Default values for device-specific fields read from persistent storage
|
||||||
_DEVICE_FIELD_DEFAULTS = {
|
_DEVICE_FIELD_DEFAULTS = {
|
||||||
"send_latency_ms": 0, "rgbw": False, "dmx_protocol": "artnet",
|
"send_latency_ms": 0,
|
||||||
"dmx_start_universe": 0, "dmx_start_channel": 1, "espnow_peer_mac": "",
|
"rgbw": False,
|
||||||
"espnow_channel": 1, "hue_username": "", "hue_client_key": "",
|
"dmx_protocol": "artnet",
|
||||||
"hue_entertainment_group_id": "", "spi_speed_hz": 800000,
|
"dmx_start_universe": 0,
|
||||||
"spi_led_type": "WS2812B", "chroma_device_type": "chromalink",
|
"dmx_start_channel": 1,
|
||||||
|
"espnow_peer_mac": "",
|
||||||
|
"espnow_channel": 1,
|
||||||
|
"hue_username": "",
|
||||||
|
"hue_client_key": "",
|
||||||
|
"hue_entertainment_group_id": "",
|
||||||
|
"spi_speed_hz": 800000,
|
||||||
|
"spi_led_type": "WS2812B",
|
||||||
|
"chroma_device_type": "chromalink",
|
||||||
"gamesense_device_type": "keyboard",
|
"gamesense_device_type": "keyboard",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,15 +242,21 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
dev = self._device_store.get_device(ds.device_id)
|
dev = self._device_store.get_device(ds.device_id)
|
||||||
for key, default in self._DEVICE_FIELD_DEFAULTS.items():
|
for key, default in self._DEVICE_FIELD_DEFAULTS.items():
|
||||||
extras[key] = getattr(dev, key, default)
|
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
|
pass
|
||||||
|
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
device_id=ds.device_id, device_url=ds.device_url,
|
device_id=ds.device_id,
|
||||||
led_count=ds.led_count, device_type=ds.device_type,
|
device_url=ds.device_url,
|
||||||
baud_rate=ds.baud_rate, software_brightness=ds.software_brightness,
|
led_count=ds.led_count,
|
||||||
test_mode_active=ds.test_mode_active, zone_mode=ds.zone_mode,
|
device_type=ds.device_type,
|
||||||
auto_shutdown=ds.auto_shutdown, **extras,
|
baud_rate=ds.baud_rate,
|
||||||
|
software_brightness=ds.software_brightness,
|
||||||
|
test_mode_active=ds.test_mode_active,
|
||||||
|
zone_mode=ds.zone_mode,
|
||||||
|
auto_shutdown=ds.auto_shutdown,
|
||||||
|
**extras,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===== EVENT SYSTEM (state change notifications) =====
|
# ===== EVENT SYSTEM (state change notifications) =====
|
||||||
@@ -296,7 +338,13 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
del self._devices[device_id]
|
del self._devices[device_id]
|
||||||
logger.info(f"Unregistered device {device_id}")
|
logger.info(f"Unregistered device {device_id}")
|
||||||
|
|
||||||
def update_device_info(self, device_id: str, device_url: Optional[str] = None, led_count: Optional[int] = None, baud_rate: Optional[int] = None):
|
def update_device_info(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
device_url: Optional[str] = None,
|
||||||
|
led_count: Optional[int] = None,
|
||||||
|
baud_rate: Optional[int] = None,
|
||||||
|
):
|
||||||
"""Update device connection info."""
|
"""Update device connection info."""
|
||||||
if device_id not in self._devices:
|
if device_id not in self._devices:
|
||||||
raise ValueError(f"Device {device_id} not found")
|
raise ValueError(f"Device {device_id} not found")
|
||||||
@@ -348,7 +396,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
try:
|
try:
|
||||||
dev = self._device_store.get_device(device_id)
|
dev = self._device_store.get_device(device_id)
|
||||||
rgbw = getattr(dev, "rgbw", False)
|
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
|
pass
|
||||||
return {
|
return {
|
||||||
"device_id": device_id,
|
"device_id": device_id,
|
||||||
@@ -421,6 +470,37 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
self._processors[target_id] = proc
|
self._processors[target_id] = proc
|
||||||
logger.info(f"Registered KC target: {target_id}")
|
logger.info(f"Registered KC target: {target_id}")
|
||||||
|
|
||||||
|
def add_ha_light_target(
|
||||||
|
self,
|
||||||
|
target_id: str,
|
||||||
|
ha_source_id: str,
|
||||||
|
color_strip_source_id: str = "",
|
||||||
|
light_mappings=None,
|
||||||
|
update_rate: float = 2.0,
|
||||||
|
transition: float = 0.5,
|
||||||
|
min_brightness_threshold: int = 0,
|
||||||
|
color_tolerance: int = 5,
|
||||||
|
) -> None:
|
||||||
|
"""Register a Home Assistant light target processor."""
|
||||||
|
if target_id in self._processors:
|
||||||
|
raise ValueError(f"HA light target {target_id} already registered")
|
||||||
|
|
||||||
|
from wled_controller.core.processing.ha_light_target_processor import HALightTargetProcessor
|
||||||
|
|
||||||
|
proc = HALightTargetProcessor(
|
||||||
|
target_id=target_id,
|
||||||
|
ha_source_id=ha_source_id,
|
||||||
|
color_strip_source_id=color_strip_source_id,
|
||||||
|
light_mappings=light_mappings or [],
|
||||||
|
update_rate=update_rate,
|
||||||
|
transition=transition,
|
||||||
|
min_brightness_threshold=min_brightness_threshold,
|
||||||
|
color_tolerance=color_tolerance,
|
||||||
|
ctx=self._build_context(),
|
||||||
|
)
|
||||||
|
self._processors[target_id] = proc
|
||||||
|
logger.info(f"Registered HA light target: {target_id}")
|
||||||
|
|
||||||
def remove_target(self, target_id: str):
|
def remove_target(self, target_id: str):
|
||||||
"""Unregister a target (any type)."""
|
"""Unregister a target (any type)."""
|
||||||
if target_id not in self._processors:
|
if target_id not in self._processors:
|
||||||
@@ -480,7 +560,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
await self.start_processing(target_id)
|
await self.start_processing(target_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Hot-switch complete for target %s -> device %s",
|
"Hot-switch complete for target %s -> device %s",
|
||||||
target_id, device_id,
|
target_id,
|
||||||
|
device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_target_brightness_vs(self, target_id: str, vs_id: str):
|
def update_target_brightness_vs(self, target_id: str, vs_id: str):
|
||||||
@@ -501,11 +582,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
# Enforce one-target-per-device for device-aware targets
|
# Enforce one-target-per-device for device-aware targets
|
||||||
if proc.device_id is not None:
|
if proc.device_id is not None:
|
||||||
for other_id, other in self._processors.items():
|
for other_id, other in self._processors.items():
|
||||||
if (
|
if other_id != target_id and other.device_id == proc.device_id and other.is_running:
|
||||||
other_id != target_id
|
|
||||||
and other.device_id == proc.device_id
|
|
||||||
and other.is_running
|
|
||||||
):
|
|
||||||
# Stale state guard: if the task is actually finished,
|
# Stale state guard: if the task is actually finished,
|
||||||
# clean up and allow starting instead of blocking.
|
# clean up and allow starting instead of blocking.
|
||||||
task = getattr(other, "_task", None)
|
task = getattr(other, "_task", None)
|
||||||
@@ -523,7 +600,10 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
try:
|
try:
|
||||||
dev = self._device_store.get_device(proc.device_id)
|
dev = self._device_store.get_device(proc.device_id)
|
||||||
dev_name = dev.name
|
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
|
pass
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Device '{dev_name}' is already being processed by target {tgt_name}"
|
f"Device '{dev_name}' is already being processed by target {tgt_name}"
|
||||||
@@ -553,9 +633,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
|
|
||||||
# Attach done callback to detect crashes
|
# Attach done callback to detect crashes
|
||||||
if proc._task is not None:
|
if proc._task is not None:
|
||||||
proc._task.add_done_callback(
|
proc._task.add_done_callback(lambda task, tid=target_id: self._on_task_done(tid, task))
|
||||||
lambda task, tid=target_id: self._on_task_done(tid, task)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def stop_processing(self, target_id: str):
|
async def stop_processing(self, target_id: str):
|
||||||
"""Stop processing for a target (any type).
|
"""Stop processing for a target (any type).
|
||||||
@@ -597,7 +675,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
# Merge device health for device-aware targets
|
# Merge device health for device-aware targets
|
||||||
if proc.device_id is not None and proc.device_id in self._devices:
|
if proc.device_id is not None and proc.device_id in self._devices:
|
||||||
h = self._devices[proc.device_id].health
|
h = self._devices[proc.device_id].health
|
||||||
state.update({
|
state.update(
|
||||||
|
{
|
||||||
"device_online": h.online,
|
"device_online": h.online,
|
||||||
"device_latency_ms": h.latency_ms,
|
"device_latency_ms": h.latency_ms,
|
||||||
"device_name": h.device_name,
|
"device_name": h.device_name,
|
||||||
@@ -608,7 +687,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
"device_fps": h.device_fps,
|
"device_fps": h.device_fps,
|
||||||
"device_last_checked": h.last_checked,
|
"device_last_checked": h.last_checked,
|
||||||
"device_error": h.error,
|
"device_error": h.error,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return state
|
return state
|
||||||
|
|
||||||
@@ -656,7 +736,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
"left": [255, 255, 0],
|
"left": [255, 255, 0],
|
||||||
}
|
}
|
||||||
|
|
||||||
async def start_overlay(self, target_id: str, target_name: str = None, calibration=None, display_info=None) -> None:
|
async def start_overlay(
|
||||||
|
self, target_id: str, target_name: str = None, calibration=None, display_info=None
|
||||||
|
) -> None:
|
||||||
proc = self._get_processor(target_id)
|
proc = self._get_processor(target_id)
|
||||||
if not proc.supports_overlay():
|
if not proc.supports_overlay():
|
||||||
raise ValueError(f"Target {target_id} does not support overlays")
|
raise ValueError(f"Target {target_id} does not support overlays")
|
||||||
@@ -687,10 +769,15 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
|
|
||||||
# ===== CSS OVERLAY (direct, no target processor required) =====
|
# ===== CSS OVERLAY (direct, no target processor required) =====
|
||||||
|
|
||||||
async def start_css_overlay(self, css_id: str, display_info, calibration, css_name: str = None) -> None:
|
async def start_css_overlay(
|
||||||
|
self, css_id: str, display_info, calibration, css_name: str = None
|
||||||
|
) -> None:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self._overlay_manager.start_overlay,
|
self._overlay_manager.start_overlay,
|
||||||
css_id, display_info, calibration, css_name,
|
css_id,
|
||||||
|
display_info,
|
||||||
|
calibration,
|
||||||
|
css_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stop_css_overlay(self, css_id: str) -> None:
|
async def stop_css_overlay(self, css_id: str) -> None:
|
||||||
|
|||||||
@@ -43,11 +43,18 @@ class SyncClockRuntime:
|
|||||||
"""Pause-aware elapsed seconds since creation/last reset.
|
"""Pause-aware elapsed seconds since creation/last reset.
|
||||||
|
|
||||||
Returns *real* (wall-clock) elapsed time, not speed-scaled.
|
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:
|
running = self._running
|
||||||
if not self._running:
|
offset = self._offset
|
||||||
return self._offset
|
epoch = self._epoch
|
||||||
return self._offset + (time.perf_counter() - self._epoch)
|
if not running:
|
||||||
|
return offset
|
||||||
|
return offset + (time.perf_counter() - epoch)
|
||||||
|
|
||||||
# ── Control ────────────────────────────────────────────────────
|
# ── Control ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import asyncio
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Callable, Dict, Optional, Tuple
|
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
|
||||||
@@ -26,13 +26,16 @@ if TYPE_CHECKING:
|
|||||||
from wled_controller.storage.template_store import TemplateStore
|
from wled_controller.storage.template_store import TemplateStore
|
||||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
from wled_controller.storage.color_strip_processing_template_store import (
|
||||||
|
ColorStripProcessingTemplateStore,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared dataclasses
|
# Shared dataclasses
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProcessingMetrics:
|
class ProcessingMetrics:
|
||||||
"""Metrics for processing performance."""
|
"""Metrics for processing performance."""
|
||||||
@@ -43,7 +46,9 @@ class ProcessingMetrics:
|
|||||||
errors_count: int = 0
|
errors_count: int = 0
|
||||||
last_error: Optional[str] = None
|
last_error: Optional[str] = None
|
||||||
last_update: Optional[datetime] = None
|
last_update: Optional[datetime] = None
|
||||||
last_update_mono: float = 0.0 # monotonic timestamp for hot-path; lazily converted to last_update on read
|
last_update_mono: float = (
|
||||||
|
0.0 # monotonic timestamp for hot-path; lazily converted to last_update on read
|
||||||
|
)
|
||||||
start_time: Optional[datetime] = None
|
start_time: Optional[datetime] = None
|
||||||
fps_actual: float = 0.0
|
fps_actual: float = 0.0
|
||||||
fps_potential: float = 0.0
|
fps_potential: float = 0.0
|
||||||
@@ -117,12 +122,14 @@ class TargetContext:
|
|||||||
cspt_store: Optional["ColorStripProcessingTemplateStore"] = None
|
cspt_store: Optional["ColorStripProcessingTemplateStore"] = None
|
||||||
fire_event: Callable[[dict], None] = lambda e: None
|
fire_event: Callable[[dict], None] = lambda e: None
|
||||||
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
|
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
|
||||||
|
ha_manager: Optional[Any] = None # HomeAssistantManager (avoid circular import)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Abstract base class
|
# Abstract base class
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TargetProcessor(ABC):
|
class TargetProcessor(ABC):
|
||||||
"""Abstract base class for target processors.
|
"""Abstract base class for target processors.
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ class AudioValueStream(ValueStream):
|
|||||||
tpl = self._audio_template_store.get_template(template_id)
|
tpl = self._audio_template_store.get_template(template_id)
|
||||||
self._audio_engine_type = tpl.engine_type
|
self._audio_engine_type = tpl.engine_type
|
||||||
self._audio_engine_config = tpl.engine_config
|
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
|
pass
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {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."""
|
"""Hot-update the shared stream for the given ValueSource."""
|
||||||
try:
|
try:
|
||||||
source = self._value_source_store.get_source(vs_id)
|
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
|
return
|
||||||
|
|
||||||
stream = self._streams.get(vs_id)
|
stream = self._streams.get(vs_id)
|
||||||
|
|||||||
@@ -181,7 +181,8 @@ class WeatherColorStripStream(ColorStripStream):
|
|||||||
if self._weather_source_id:
|
if self._weather_source_id:
|
||||||
try:
|
try:
|
||||||
self._weather_manager.release(self._weather_source_id)
|
self._weather_manager.release(self._weather_source_id)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Weather source release during update: %s", e)
|
||||||
pass
|
pass
|
||||||
self._weather_source_id = new_ws_id
|
self._weather_source_id = new_ws_id
|
||||||
if new_ws_id:
|
if new_ws_id:
|
||||||
@@ -208,7 +209,8 @@ class WeatherColorStripStream(ColorStripStream):
|
|||||||
# are looked up at the ProcessorManager level when the stream
|
# are looked up at the ProcessorManager level when the stream
|
||||||
# is created. For now, return None and use wall time.
|
# is created. For now, return None and use wall time.
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Sync clock lookup failed for weather stream: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _animate_loop(self) -> None:
|
def _animate_loop(self) -> None:
|
||||||
@@ -278,5 +280,6 @@ class WeatherColorStripStream(ColorStripStream):
|
|||||||
return DEFAULT_WEATHER
|
return DEFAULT_WEATHER
|
||||||
try:
|
try:
|
||||||
return self._weather_manager.get_data(self._weather_source_id)
|
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
|
return DEFAULT_WEATHER
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._fit_cache_key: tuple = (0, 0)
|
self._fit_cache_key: tuple = (0, 0)
|
||||||
self._fit_cache_src: Optional[np.ndarray] = None
|
self._fit_cache_src: Optional[np.ndarray] = None
|
||||||
self._fit_cache_dst: Optional[np.ndarray] = None
|
self._fit_cache_dst: Optional[np.ndarray] = None
|
||||||
|
self._fit_result_buf: Optional[np.ndarray] = None
|
||||||
|
|
||||||
# LED preview WebSocket clients
|
# LED preview WebSocket clients
|
||||||
self._preview_clients: list = []
|
self._preview_clients: list = []
|
||||||
@@ -207,6 +208,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
try:
|
try:
|
||||||
await self._task
|
await self._task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("WLED target processor task cancelled")
|
||||||
pass
|
pass
|
||||||
self._task = None
|
self._task = None
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
@@ -341,7 +343,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
try:
|
try:
|
||||||
resp = await client.get(f"{device_url}/json/info")
|
resp = await client.get(f"{device_url}/json/info")
|
||||||
return resp.status_code == 200
|
return resp.status_code == 200
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Device probe failed for %s: %s", device_url, e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_display_index(self) -> Optional[int]:
|
def get_display_index(self) -> Optional[int]:
|
||||||
@@ -525,7 +528,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
async def _send_preview_to(ws, data: bytes) -> None:
|
async def _send_preview_to(ws, data: bytes) -> None:
|
||||||
try:
|
try:
|
||||||
await ws.send_bytes(data)
|
await ws.send_bytes(data)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("LED preview WS send failed: %s", e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def remove_led_preview_client(self, ws) -> None:
|
def remove_led_preview_client(self, ws) -> None:
|
||||||
@@ -569,7 +573,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
try:
|
try:
|
||||||
await ws.send_bytes(data)
|
await ws.send_bytes(data)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("LED preview broadcast WS send failed: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
clients = list(self._preview_clients)
|
clients = list(self._preview_clients)
|
||||||
@@ -591,11 +596,62 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._fit_cache_src = np.linspace(0, 1, n)
|
self._fit_cache_src = np.linspace(0, 1, n)
|
||||||
self._fit_cache_dst = np.linspace(0, 1, device_led_count)
|
self._fit_cache_dst = np.linspace(0, 1, device_led_count)
|
||||||
self._fit_cache_key = key
|
self._fit_cache_key = key
|
||||||
result = np.column_stack([
|
self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8)
|
||||||
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]).astype(np.uint8)
|
buf = self._fit_result_buf
|
||||||
for ch in range(colors.shape[1])
|
for ch in range(min(colors.shape[1], 3)):
|
||||||
])
|
np.copyto(
|
||||||
return result
|
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:
|
async def _processing_loop(self) -> None:
|
||||||
"""Main processing loop — poll CSS stream -> brightness -> send."""
|
"""Main processing loop — poll CSS stream -> brightness -> send."""
|
||||||
@@ -608,7 +664,9 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
def _fps_current_from_timestamps():
|
def _fps_current_from_timestamps():
|
||||||
"""Count timestamps within the last second."""
|
"""Count timestamps within the last second."""
|
||||||
cutoff = time.perf_counter() - 1.0
|
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_send_time = 0.0
|
||||||
_last_preview_broadcast = 0.0
|
_last_preview_broadcast = 0.0
|
||||||
@@ -844,10 +902,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._fit_to_device(prev_frame_ref, _total_leds),
|
self._fit_to_device(prev_frame_ref, _total_leds),
|
||||||
cur_brightness,
|
cur_brightness,
|
||||||
)
|
)
|
||||||
if self._led_client.supports_fast_send:
|
await self._send_to_device(send_colors)
|
||||||
self._led_client.send_pixels_fast(send_colors)
|
|
||||||
else:
|
|
||||||
await self._led_client.send_pixels(send_colors)
|
|
||||||
now = time.perf_counter()
|
now = time.perf_counter()
|
||||||
last_send_time = now
|
last_send_time = now
|
||||||
send_timestamps.append(now)
|
send_timestamps.append(now)
|
||||||
@@ -879,10 +934,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._fit_to_device(prev_frame_ref, _total_leds),
|
self._fit_to_device(prev_frame_ref, _total_leds),
|
||||||
cur_brightness,
|
cur_brightness,
|
||||||
)
|
)
|
||||||
if self._led_client.supports_fast_send:
|
await self._send_to_device(send_colors)
|
||||||
self._led_client.send_pixels_fast(send_colors)
|
|
||||||
else:
|
|
||||||
await self._led_client.send_pixels(send_colors)
|
|
||||||
now = time.perf_counter()
|
now = time.perf_counter()
|
||||||
last_send_time = now
|
last_send_time = now
|
||||||
send_timestamps.append(now)
|
send_timestamps.append(now)
|
||||||
@@ -910,12 +962,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
# Send to LED device
|
# Send to LED device
|
||||||
if not self._is_running or self._led_client is None:
|
if not self._is_running or self._led_client is None:
|
||||||
break
|
break
|
||||||
t_send_start = time.perf_counter()
|
send_ms = await self._send_to_device(send_colors)
|
||||||
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
|
|
||||||
|
|
||||||
now = time.perf_counter()
|
now = time.perf_counter()
|
||||||
last_send_time = now
|
last_send_time = now
|
||||||
@@ -984,33 +1031,11 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
# Periodic diagnostics report
|
# Periodic diagnostics report
|
||||||
if iter_end >= _diag_next_report:
|
if iter_end >= _diag_next_report:
|
||||||
_diag_next_report = iter_end + _diag_interval
|
_diag_next_report = iter_end + _diag_interval
|
||||||
if _diag_sleep_jitters:
|
self._emit_diagnostics(
|
||||||
jitters = [a - r for r, a in _diag_sleep_jitters]
|
self._target_id, _diag_sleep_jitters,
|
||||||
avg_j = sum(jitters) / len(jitters)
|
_diag_iter_times, _diag_slow_iters,
|
||||||
max_j = max(jitters)
|
frame_time, _diag_interval,
|
||||||
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()
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info(f"Processing loop cancelled for target {self._target_id}")
|
logger.info(f"Processing loop cancelled for target {self._target_id}")
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ class UpdateService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Update check failed: %s", exc, exc_info=True)
|
logger.error("Update check failed: %s", exc, exc_info=True)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("Update check loop cancelled")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ── Core check logic ───────────────────────────────────────
|
# ── Core check logic ───────────────────────────────────────
|
||||||
@@ -172,7 +173,8 @@ class UpdateService:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
normalize_version(release.version)
|
normalize_version(release.version)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Skipping release with unparseable version %s: %s", release.version, e)
|
||||||
continue
|
continue
|
||||||
if is_newer(release.version, __version__):
|
if is_newer(release.version, __version__):
|
||||||
return release
|
return release
|
||||||
@@ -317,6 +319,10 @@ class UpdateService:
|
|||||||
shutil.rmtree(staging)
|
shutil.rmtree(staging)
|
||||||
staging.mkdir(parents=True)
|
staging.mkdir(parents=True)
|
||||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
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)
|
zf.extractall(staging)
|
||||||
|
|
||||||
await asyncio.to_thread(_extract)
|
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.
|
so that ``packaging.version.Version`` can compare them correctly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from packaging.version import InvalidVersion, Version
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
_PRE_MAP = {
|
_PRE_MAP = {
|
||||||
"alpha": "a",
|
"alpha": "a",
|
||||||
@@ -41,5 +44,6 @@ def is_newer(candidate: str, current: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return normalize_version(candidate) > normalize_version(current)
|
return normalize_version(candidate) > normalize_version(current)
|
||||||
except InvalidVersion:
|
except InvalidVersion as e:
|
||||||
|
logger.debug("Unparseable version string in comparison: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from wled_controller import __version__
|
from wled_controller import __version__, GITEA_BASE_URL, GITEA_REPO
|
||||||
from wled_controller.api import router
|
from wled_controller.api import router
|
||||||
from wled_controller.api.dependencies import init_dependencies
|
from wled_controller.api.dependencies import init_dependencies
|
||||||
from wled_controller.config import get_config
|
from wled_controller.config import get_config
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorDependencies, ProcessorManager
|
from wled_controller.core.processing.processor_manager import (
|
||||||
|
ProcessorDependencies,
|
||||||
|
ProcessorManager,
|
||||||
|
)
|
||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
from wled_controller.storage.template_store import TemplateStore
|
from wled_controller.storage.template_store import TemplateStore
|
||||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||||
@@ -31,11 +34,16 @@ from wled_controller.storage.value_source_store import ValueSourceStore
|
|||||||
from wled_controller.storage.automation_store import AutomationStore
|
from wled_controller.storage.automation_store import AutomationStore
|
||||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
from wled_controller.storage.color_strip_processing_template_store import (
|
||||||
|
ColorStripProcessingTemplateStore,
|
||||||
|
)
|
||||||
from wled_controller.storage.gradient_store import GradientStore
|
from wled_controller.storage.gradient_store import GradientStore
|
||||||
from wled_controller.storage.weather_source_store import WeatherSourceStore
|
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.processing.sync_clock_manager import SyncClockManager
|
||||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
from wled_controller.core.weather.weather_manager import WeatherManager
|
||||||
|
from wled_controller.storage.home_assistant_store import HomeAssistantStore
|
||||||
|
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
|
||||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||||
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
||||||
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
||||||
@@ -60,6 +68,7 @@ db = Database(config.storage.database_file)
|
|||||||
# Seed demo data after DB is ready (first-run only)
|
# Seed demo data after DB is ready (first-run only)
|
||||||
if config.demo:
|
if config.demo:
|
||||||
from wled_controller.core.demo_seed import seed_demo_data
|
from wled_controller.core.demo_seed import seed_demo_data
|
||||||
|
|
||||||
seed_demo_data(db)
|
seed_demo_data(db)
|
||||||
|
|
||||||
# Initialize storage and processing
|
# Initialize storage and processing
|
||||||
@@ -80,8 +89,16 @@ cspt_store = ColorStripProcessingTemplateStore(db)
|
|||||||
gradient_store = GradientStore(db)
|
gradient_store = GradientStore(db)
|
||||||
gradient_store.migrate_palette_references(color_strip_store)
|
gradient_store.migrate_palette_references(color_strip_store)
|
||||||
weather_source_store = WeatherSourceStore(db)
|
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)
|
sync_clock_manager = SyncClockManager(sync_clock_store)
|
||||||
weather_manager = WeatherManager(weather_source_store)
|
weather_manager = WeatherManager(weather_source_store)
|
||||||
|
ha_store = HomeAssistantStore(db)
|
||||||
|
ha_manager = HomeAssistantManager(ha_store)
|
||||||
|
|
||||||
processor_manager = ProcessorManager(
|
processor_manager = ProcessorManager(
|
||||||
ProcessorDependencies(
|
ProcessorDependencies(
|
||||||
@@ -98,6 +115,8 @@ processor_manager = ProcessorManager(
|
|||||||
cspt_store=cspt_store,
|
cspt_store=cspt_store,
|
||||||
gradient_store=gradient_store,
|
gradient_store=gradient_store,
|
||||||
weather_manager=weather_manager,
|
weather_manager=weather_manager,
|
||||||
|
asset_store=asset_store,
|
||||||
|
ha_manager=ha_manager,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,11 +159,13 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Create automation engine (needs processor_manager + mqtt_service + stores for scene activation)
|
# Create automation engine (needs processor_manager + mqtt_service + stores for scene activation)
|
||||||
automation_engine = AutomationEngine(
|
automation_engine = AutomationEngine(
|
||||||
automation_store, processor_manager,
|
automation_store,
|
||||||
|
processor_manager,
|
||||||
mqtt_service=mqtt_service,
|
mqtt_service=mqtt_service,
|
||||||
scene_preset_store=scene_preset_store,
|
scene_preset_store=scene_preset_store,
|
||||||
target_store=output_target_store,
|
target_store=output_target_store,
|
||||||
device_store=device_store,
|
device_store=device_store,
|
||||||
|
ha_manager=ha_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create auto-backup engine — derive paths from database location so that
|
# Create auto-backup engine — derive paths from database location so that
|
||||||
@@ -157,8 +178,8 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Create update service (checks for new releases)
|
# Create update service (checks for new releases)
|
||||||
_release_provider = GiteaReleaseProvider(
|
_release_provider = GiteaReleaseProvider(
|
||||||
base_url="https://git.dolgolyov-family.by",
|
base_url=GITEA_BASE_URL,
|
||||||
repo="alexei.dolgolyov/wled-screen-controller-mixed",
|
repo=GITEA_REPO,
|
||||||
)
|
)
|
||||||
update_service = UpdateService(
|
update_service = UpdateService(
|
||||||
provider=_release_provider,
|
provider=_release_provider,
|
||||||
@@ -169,7 +190,9 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Initialize API dependencies
|
# Initialize API dependencies
|
||||||
init_dependencies(
|
init_dependencies(
|
||||||
device_store, template_store, processor_manager,
|
device_store,
|
||||||
|
template_store,
|
||||||
|
processor_manager,
|
||||||
database=db,
|
database=db,
|
||||||
pp_template_store=pp_template_store,
|
pp_template_store=pp_template_store,
|
||||||
pattern_template_store=pattern_template_store,
|
pattern_template_store=pattern_template_store,
|
||||||
@@ -190,6 +213,9 @@ async def lifespan(app: FastAPI):
|
|||||||
weather_source_store=weather_source_store,
|
weather_source_store=weather_source_store,
|
||||||
weather_manager=weather_manager,
|
weather_manager=weather_manager,
|
||||||
update_service=update_service,
|
update_service=update_service,
|
||||||
|
asset_store=asset_store,
|
||||||
|
ha_store=ha_store,
|
||||||
|
ha_manager=ha_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register devices in processor manager for health monitoring
|
# Register devices in processor manager for health monitoring
|
||||||
@@ -256,6 +282,12 @@ async def lifespan(app: FastAPI):
|
|||||||
# where no CRUD happened during the session.
|
# where no CRUD happened during the session.
|
||||||
_save_all_stores()
|
_save_all_stores()
|
||||||
|
|
||||||
|
# Stop Home Assistant manager
|
||||||
|
try:
|
||||||
|
await ha_manager.shutdown()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping Home Assistant manager: {e}")
|
||||||
|
|
||||||
# Stop weather manager
|
# Stop weather manager
|
||||||
try:
|
try:
|
||||||
weather_manager.shutdown()
|
weather_manager.shutdown()
|
||||||
@@ -300,6 +332,7 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping MQTT service: {e}")
|
logger.error(f"Error stopping MQTT service: {e}")
|
||||||
|
|
||||||
|
|
||||||
# Create FastAPI application
|
# Create FastAPI application
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LED Grab",
|
title="LED Grab",
|
||||||
@@ -354,6 +387,7 @@ async def _no_cache_static(request: Request, call_next):
|
|||||||
return response
|
return response
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
# Mount static files
|
# Mount static files
|
||||||
static_path = Path(__file__).parent / "static"
|
static_path = Path(__file__).parent / "static"
|
||||||
if static_path.exists():
|
if static_path.exists():
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ mechanism the system tray "Shutdown" menu item uses.
|
|||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
_server: Optional[Any] = None # uvicorn.Server
|
_server: Optional[Any] = None # uvicorn.Server
|
||||||
_tray: Optional[Any] = None # TrayManager
|
_tray: Optional[Any] = None # TrayManager
|
||||||
|
|
||||||
@@ -39,7 +43,8 @@ def request_shutdown() -> None:
|
|||||||
try:
|
try:
|
||||||
from wled_controller.main import _save_all_stores
|
from wled_controller.main import _save_all_stores
|
||||||
_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
|
pass # best-effort; lifespan handler is the backup
|
||||||
|
|
||||||
if _server is not None:
|
if _server is not None:
|
||||||
@@ -55,5 +60,6 @@ def _broadcast_restarting() -> None:
|
|||||||
pm = _deps.get("processor_manager")
|
pm = _deps.get("processor_manager")
|
||||||
if pm is not None:
|
if pm is not None:
|
||||||
pm.fire_event({"type": "server_restarting"})
|
pm.fire_event({"type": "server_restarting"})
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.debug("Failed to broadcast server_restarting event: %s", e)
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -215,21 +215,6 @@
|
|||||||
align-items: center;
|
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 */
|
||||||
.webhook-url-row {
|
.webhook-url-row {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
--z-log-overlay: 2100;
|
--z-log-overlay: 2100;
|
||||||
--z-confirm: 2500;
|
--z-confirm: 2500;
|
||||||
--z-command-palette: 3000;
|
--z-command-palette: 3000;
|
||||||
--z-toast: 3000;
|
--z-toast: 3500;
|
||||||
--z-overlay-spinner: 9999;
|
--z-overlay-spinner: 9999;
|
||||||
--z-lightbox: 10000;
|
--z-lightbox: 10000;
|
||||||
--z-connection: 10000;
|
--z-connection: 10000;
|
||||||
|
|||||||
@@ -158,14 +158,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-target-metrics {
|
.dashboard-target-metrics {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 12px;
|
grid-template-columns: auto 72px 36px;
|
||||||
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-metric {
|
.dashboard-metric {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: 48px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-metric-value {
|
.dashboard-metric-value {
|
||||||
@@ -174,6 +175,7 @@
|
|||||||
color: var(--primary-text-color);
|
color: var(--primary-text-color);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-metric-label {
|
.dashboard-metric-label {
|
||||||
@@ -187,7 +189,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: auto;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-fps-sparkline {
|
.dashboard-fps-sparkline {
|
||||||
@@ -200,7 +202,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 36px;
|
width: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,3 +432,38 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.perf-mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perf-mode-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perf-mode-btn:not(:last-child) {
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perf-mode-btn:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perf-mode-btn.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -158,6 +158,58 @@ h2 {
|
|||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Donation banner ── */
|
||||||
|
.donation-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
animation: bannerSlideDown 0.3s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donation-banner-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donation-banner-text .icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: #e25555;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donation-banner-action {
|
||||||
|
padding: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donation-banner-action:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donation-banner-donate {
|
||||||
|
color: #e25555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donation-banner-donate:hover {
|
||||||
|
color: #ff6b6b;
|
||||||
|
background: rgba(226, 85, 85, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes bannerSlideDown {
|
@keyframes bannerSlideDown {
|
||||||
from { transform: translateY(-100%); opacity: 0; }
|
from { transform: translateY(-100%); opacity: 0; }
|
||||||
to { transform: translateY(0); opacity: 1; }
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
|||||||
@@ -352,21 +352,23 @@
|
|||||||
|
|
||||||
.settings-tab-bar {
|
.settings-tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border-bottom: 2px solid var(--border-color);
|
border-bottom: 2px solid var(--border-color);
|
||||||
padding: 0 1.25rem;
|
padding: 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab-btn {
|
.settings-tab-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 8px 16px;
|
padding: 8px 12px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
margin-bottom: -2px;
|
margin-bottom: -2px;
|
||||||
|
white-space: nowrap;
|
||||||
transition: color 0.2s ease, border-color 0.25s ease;
|
transition: color 0.2s ease, border-color 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +390,102 @@
|
|||||||
animation: tabFadeIn 0.25s ease-out;
|
animation: tabFadeIn 0.25s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── About panel ──────────────────────────────────────────── */
|
||||||
|
.about-section {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo .icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-title {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-text {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-license {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link .icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link .icon:last-child {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link span {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link-donate .icon:first-child {
|
||||||
|
color: #e25555;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Log viewer overlay (full-screen) ──────────────────────── */
|
/* ── Log viewer overlay (full-screen) ──────────────────────── */
|
||||||
|
|
||||||
.log-overlay {
|
.log-overlay {
|
||||||
@@ -1466,64 +1564,42 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Notification app color mappings ─────────────────────────── */
|
/* ── Notification per-app overrides (unified color + sound) ──── */
|
||||||
|
|
||||||
.notif-app-color-row {
|
.notif-override-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto auto;
|
||||||
|
gap: 4px 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
margin-bottom: 6px;
|
||||||
margin-bottom: 4px;
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-app-color-row .notif-app-name {
|
.notif-override-row .notif-override-name,
|
||||||
flex: 1;
|
.notif-override-row .notif-override-sound {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-app-color-row .notif-app-color {
|
/* Sound select spans the first column, volume spans browse+color columns */
|
||||||
width: 28px;
|
.notif-override-row .notif-override-sound {
|
||||||
height: 28px;
|
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: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
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 ─────────────────────────────────── */
|
/* ── Notification history list ─────────────────────────────────── */
|
||||||
|
|
||||||
@@ -1866,3 +1942,128 @@ body.composite-layer-dragging .composite-layer-drag-handle {
|
|||||||
opacity: 0 !important;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Modal } from './core/modal.ts';
|
|||||||
import { queryEl } from './core/dom-utils.ts';
|
import { queryEl } from './core/dom-utils.ts';
|
||||||
|
|
||||||
// Layer 1: api, i18n
|
// Layer 1: api, i18n
|
||||||
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor } from './core/api.ts';
|
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor, serverRepoUrl, serverDonateUrl } from './core/api.ts';
|
||||||
import { t, initLocale, changeLocale } from './core/i18n.ts';
|
import { t, initLocale, changeLocale } from './core/i18n.ts';
|
||||||
|
|
||||||
// Layer 1.5: visual effects
|
// Layer 1.5: visual effects
|
||||||
@@ -51,7 +51,7 @@ import {
|
|||||||
import { startEventsWS, stopEventsWS } from './core/events-ws.ts';
|
import { startEventsWS, stopEventsWS } from './core/events-ws.ts';
|
||||||
import { startEntityEventListeners } from './core/entity-events.ts';
|
import { startEntityEventListeners } from './core/entity-events.ts';
|
||||||
import {
|
import {
|
||||||
startPerfPolling, stopPerfPolling,
|
startPerfPolling, stopPerfPolling, setPerfMode,
|
||||||
} from './features/perf-charts.ts';
|
} from './features/perf-charts.ts';
|
||||||
import {
|
import {
|
||||||
loadPictureSources, switchStreamTab,
|
loadPictureSources, switchStreamTab,
|
||||||
@@ -137,7 +137,7 @@ import {
|
|||||||
previewCSSFromEditor,
|
previewCSSFromEditor,
|
||||||
copyEndpointUrl,
|
copyEndpointUrl,
|
||||||
onNotificationFilterModeChange,
|
onNotificationFilterModeChange,
|
||||||
notificationAddAppColor, notificationRemoveAppColor,
|
notificationAddAppOverride, notificationRemoveAppOverride,
|
||||||
testNotification,
|
testNotification,
|
||||||
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||||
@@ -205,6 +205,9 @@ import {
|
|||||||
initUpdateSettingsPanel, applyUpdate,
|
initUpdateSettingsPanel, applyUpdate,
|
||||||
openReleaseNotes, closeReleaseNotes,
|
openReleaseNotes, closeReleaseNotes,
|
||||||
} from './features/update.ts';
|
} from './features/update.ts';
|
||||||
|
import {
|
||||||
|
initDonationBanner, dismissDonation, snoozeDonation, renderAboutPanel, setProjectUrls,
|
||||||
|
} from './features/donation.ts';
|
||||||
|
|
||||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||||
|
|
||||||
@@ -287,6 +290,7 @@ Object.assign(window, {
|
|||||||
stopUptimeTimer,
|
stopUptimeTimer,
|
||||||
startPerfPolling,
|
startPerfPolling,
|
||||||
stopPerfPolling,
|
stopPerfPolling,
|
||||||
|
setPerfMode,
|
||||||
|
|
||||||
// streams / capture templates / PP templates
|
// streams / capture templates / PP templates
|
||||||
loadPictureSources,
|
loadPictureSources,
|
||||||
@@ -464,7 +468,7 @@ Object.assign(window, {
|
|||||||
previewCSSFromEditor,
|
previewCSSFromEditor,
|
||||||
copyEndpointUrl,
|
copyEndpointUrl,
|
||||||
onNotificationFilterModeChange,
|
onNotificationFilterModeChange,
|
||||||
notificationAddAppColor, notificationRemoveAppColor,
|
notificationAddAppOverride, notificationRemoveAppOverride,
|
||||||
testNotification,
|
testNotification,
|
||||||
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||||
@@ -576,6 +580,11 @@ Object.assign(window, {
|
|||||||
openReleaseNotes,
|
openReleaseNotes,
|
||||||
closeReleaseNotes,
|
closeReleaseNotes,
|
||||||
|
|
||||||
|
// donation
|
||||||
|
dismissDonation,
|
||||||
|
snoozeDonation,
|
||||||
|
renderAboutPanel,
|
||||||
|
|
||||||
// appearance
|
// appearance
|
||||||
applyStylePreset,
|
applyStylePreset,
|
||||||
applyBgEffect,
|
applyBgEffect,
|
||||||
@@ -723,6 +732,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
initUpdateListener();
|
initUpdateListener();
|
||||||
loadUpdateStatus();
|
loadUpdateStatus();
|
||||||
|
|
||||||
|
// Show donation banner (after a few sessions)
|
||||||
|
setProjectUrls(serverRepoUrl, serverDonateUrl);
|
||||||
|
initDonationBanner();
|
||||||
|
|
||||||
// Show getting-started tutorial on first visit
|
// Show getting-started tutorial on first visit
|
||||||
if (!localStorage.getItem('tour_completed')) {
|
if (!localStorage.getItem('tour_completed')) {
|
||||||
setTimeout(() => startGettingStartedTutorial(), 600);
|
setTimeout(() => startGettingStartedTutorial(), 600);
|
||||||
|
|||||||
@@ -82,6 +82,27 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
|
|||||||
throw new Error('fetchWithAuth: unreachable code — retry loop exhausted');
|
throw new Error('fetchWithAuth: unreachable code — retry loop exhausted');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cached metrics-history fetch ────────────────────────────
|
||||||
|
let _metricsHistoryCache: { data: any; ts: number } | null = null;
|
||||||
|
const _METRICS_CACHE_TTL = 5000; // 5 seconds
|
||||||
|
|
||||||
|
/** Fetch metrics history with a short TTL cache to avoid duplicate requests across tabs. */
|
||||||
|
export async function fetchMetricsHistory(): Promise<any | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (_metricsHistoryCache && now - _metricsHistoryCache.ts < _METRICS_CACHE_TTL) {
|
||||||
|
return _metricsHistoryCache.data;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() });
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json();
|
||||||
|
_metricsHistoryCache = { data, ts: now };
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function escapeHtml(text: string) {
|
export function escapeHtml(text: string) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -215,6 +236,8 @@ function _setConnectionState(online: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export let demoMode = false;
|
export let demoMode = false;
|
||||||
|
export let serverRepoUrl = '';
|
||||||
|
export let serverDonateUrl = '';
|
||||||
|
|
||||||
export async function loadServerInfo() {
|
export async function loadServerInfo() {
|
||||||
try {
|
try {
|
||||||
@@ -238,6 +261,10 @@ export async function loadServerInfo() {
|
|||||||
setAuthRequired(authNeeded);
|
setAuthRequired(authNeeded);
|
||||||
(window as any)._authRequired = authNeeded;
|
(window as any)._authRequired = authNeeded;
|
||||||
|
|
||||||
|
// Project URLs (repo, donate)
|
||||||
|
if (data.repo_url) serverRepoUrl = data.repo_url;
|
||||||
|
if (data.donate_url) serverDonateUrl = data.donate_url;
|
||||||
|
|
||||||
// Demo mode detection
|
// Demo mode detection
|
||||||
if (data.demo_mode && !demoMode) {
|
if (data.demo_mode && !demoMode) {
|
||||||
demoMode = true;
|
demoMode = true;
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
* Requires Chart.js to be registered globally (done by perf-charts.js).
|
* Requires Chart.js to be registered globally (done by perf-charts.js).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_MAX_SAMPLES = 120;
|
||||||
|
|
||||||
|
/** Left-pad an array with nulls so it always has `maxSamples` entries. */
|
||||||
|
function _padLeft(arr: number[], maxSamples: number): (number | null)[] {
|
||||||
|
const pad = maxSamples - arr.length;
|
||||||
|
return pad > 0 ? [...new Array(pad).fill(null), ...arr] : arr.slice(-maxSamples);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an FPS sparkline Chart.js instance.
|
* Create an FPS sparkline Chart.js instance.
|
||||||
*
|
*
|
||||||
@@ -23,23 +31,29 @@ export function createFpsSparkline(canvasId: string, actualHistory: number[], cu
|
|||||||
const canvas = document.getElementById(canvasId);
|
const canvas = document.getElementById(canvasId);
|
||||||
if (!canvas) return null;
|
if (!canvas) return null;
|
||||||
|
|
||||||
|
const maxSamples = opts.maxSamples || DEFAULT_MAX_SAMPLES;
|
||||||
|
const paddedActual = _padLeft(actualHistory, maxSamples);
|
||||||
|
const paddedCurrent = _padLeft(currentHistory, maxSamples);
|
||||||
|
|
||||||
const datasets: any[] = [
|
const datasets: any[] = [
|
||||||
{
|
{
|
||||||
data: [...actualHistory],
|
data: paddedActual,
|
||||||
borderColor: '#2196F3',
|
borderColor: '#2196F3',
|
||||||
backgroundColor: 'rgba(33,150,243,0.12)',
|
backgroundColor: 'rgba(33,150,243,0.12)',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: true,
|
fill: true,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
|
spanGaps: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: [...currentHistory],
|
data: paddedCurrent,
|
||||||
borderColor: '#4CAF50',
|
borderColor: '#4CAF50',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: false,
|
fill: false,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
|
spanGaps: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -47,7 +61,7 @@ export function createFpsSparkline(canvasId: string, actualHistory: number[], cu
|
|||||||
const maxHwFps = opts.maxHwFps;
|
const maxHwFps = opts.maxHwFps;
|
||||||
if (maxHwFps && maxHwFps < fpsTarget * 1.15) {
|
if (maxHwFps && maxHwFps < fpsTarget * 1.15) {
|
||||||
datasets.push({
|
datasets.push({
|
||||||
data: actualHistory.map(() => maxHwFps),
|
data: paddedActual.map(() => maxHwFps),
|
||||||
borderColor: 'rgba(255,152,0,0.5)',
|
borderColor: 'rgba(255,152,0,0.5)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderDash: [4, 3],
|
borderDash: [4, 3],
|
||||||
@@ -56,10 +70,12 @@ export function createFpsSparkline(canvasId: string, actualHistory: number[], cu
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const labels = new Array(maxSamples).fill('');
|
||||||
|
|
||||||
return new Chart(canvas, {
|
return new Chart(canvas, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: actualHistory.map(() => ''),
|
labels,
|
||||||
datasets,
|
datasets,
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -57,26 +57,31 @@ function _buildItems(results: any[], states: any = {}) {
|
|||||||
nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
|
nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Action items: start or stop
|
// Action item: toggle start/stop
|
||||||
if (running) {
|
const actionItem: any = {
|
||||||
items.push({
|
name: tgt.name, group: 'actions',
|
||||||
name: tgt.name, detail: t('search.action.stop'), group: 'actions', icon: '■',
|
detail: running ? t('search.action.stop') : t('search.action.start'),
|
||||||
|
icon: running ? '■' : '▶',
|
||||||
|
_running: running, _targetId: tgt.id,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/stop`, { method: 'POST' });
|
const isRunning = actionItem._running;
|
||||||
if (resp.ok) { showToast(t('device.stopped'), 'success'); }
|
const endpoint = isRunning ? 'stop' : 'start';
|
||||||
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('target.error.stop_failed'), 'error'); }
|
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/${endpoint}`, { method: 'POST' });
|
||||||
},
|
if (resp.ok) {
|
||||||
});
|
showToast(t(isRunning ? 'device.stopped' : 'device.started'), 'success');
|
||||||
|
actionItem._running = !isRunning;
|
||||||
|
actionItem.detail = !isRunning ? t('search.action.stop') : t('search.action.start');
|
||||||
|
actionItem.icon = !isRunning ? '■' : '▶';
|
||||||
|
_render();
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
const err = await resp.json().catch(() => ({}));
|
||||||
name: tgt.name, detail: t('search.action.start'), group: 'actions', icon: '▶',
|
const d = err.detail || err.message || '';
|
||||||
action: async () => {
|
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
|
||||||
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/start`, { method: 'POST' });
|
showToast(ds || t(`target.error.${endpoint}_failed`), 'error');
|
||||||
if (resp.ok) { showToast(t('device.started'), 'success'); }
|
|
||||||
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('target.error.start_failed'), 'error'); }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
items.push(actionItem);
|
||||||
});
|
});
|
||||||
|
|
||||||
_mapEntities(css, c => items.push({
|
_mapEntities(css, c => items.push({
|
||||||
@@ -89,25 +94,28 @@ function _buildItems(results: any[], states: any = {}) {
|
|||||||
name: a.name, detail: a.enabled ? 'enabled' : '', group: 'automations', icon: ICON_AUTOMATION,
|
name: a.name, detail: a.enabled ? 'enabled' : '', group: 'automations', icon: ICON_AUTOMATION,
|
||||||
nav: ['automations', null, 'automations', 'data-automation-id', a.id],
|
nav: ['automations', null, 'automations', 'data-automation-id', a.id],
|
||||||
});
|
});
|
||||||
if (a.enabled) {
|
const autoItem: any = {
|
||||||
items.push({
|
name: a.name, group: 'actions', icon: ICON_AUTOMATION,
|
||||||
name: a.name, detail: t('search.action.disable'), group: 'actions', icon: ICON_AUTOMATION,
|
detail: a.enabled ? t('search.action.disable') : t('search.action.enable'),
|
||||||
|
_enabled: a.enabled,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const resp = await fetchWithAuth(`/automations/${a.id}/disable`, { method: 'POST' });
|
const isEnabled = autoItem._enabled;
|
||||||
if (resp.ok) { showToast(t('search.action.disable') + ': ' + a.name, 'success'); }
|
const endpoint = isEnabled ? 'disable' : 'enable';
|
||||||
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || (t('search.action.disable') + ' failed'), 'error'); }
|
const resp = await fetchWithAuth(`/automations/${a.id}/${endpoint}`, { method: 'POST' });
|
||||||
},
|
if (resp.ok) {
|
||||||
});
|
showToast(t('search.action.' + endpoint) + ': ' + a.name, 'success');
|
||||||
|
autoItem._enabled = !isEnabled;
|
||||||
|
autoItem.detail = !isEnabled ? t('search.action.disable') : t('search.action.enable');
|
||||||
|
_render();
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
const err = await resp.json().catch(() => ({}));
|
||||||
name: a.name, detail: t('search.action.enable'), group: 'actions', icon: ICON_AUTOMATION,
|
const d = err.detail || err.message || '';
|
||||||
action: async () => {
|
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
|
||||||
const resp = await fetchWithAuth(`/automations/${a.id}/enable`, { method: 'POST' });
|
showToast(ds || (t('search.action.' + endpoint) + ' failed'), 'error');
|
||||||
if (resp.ok) { showToast(t('search.action.enable') + ': ' + a.name, 'success'); }
|
|
||||||
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || (t('search.action.enable') + ' failed'), 'error'); }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
items.push(autoItem);
|
||||||
});
|
});
|
||||||
|
|
||||||
_mapEntities(capTempl, ct => items.push({
|
_mapEntities(capTempl, ct => items.push({
|
||||||
@@ -378,13 +386,13 @@ function _onClick(e: Event) {
|
|||||||
function _selectCurrent() {
|
function _selectCurrent() {
|
||||||
if (_selectedIdx < 0 || _selectedIdx >= _filtered.length) return;
|
if (_selectedIdx < 0 || _selectedIdx >= _filtered.length) return;
|
||||||
const item = _filtered[_selectedIdx];
|
const item = _filtered[_selectedIdx];
|
||||||
closeCommandPalette();
|
|
||||||
if (item.action) {
|
if (item.action) {
|
||||||
item.action().catch(err => {
|
item.action().catch(err => {
|
||||||
if (!err.isAuth) showToast(err.message || 'Action failed', 'error');
|
if (!err.isAuth) showToast(err.message || 'Action failed', 'error');
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
closeCommandPalette();
|
||||||
// If graph tab is active, navigate to graph node instead of card
|
// If graph tab is active, navigate to graph node instead of card
|
||||||
const graphTabActive = document.querySelector('.tab-btn[data-tab="graph"].active');
|
const graphTabActive = document.querySelector('.tab-btn[data-tab="graph"].active');
|
||||||
if (graphTabActive) {
|
if (graphTabActive) {
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ export class FilterListManager {
|
|||||||
if (filterDef && !isExpanded) {
|
if (filterDef && !isExpanded) {
|
||||||
summary = filterDef.options_schema.map(opt => {
|
summary = filterDef.options_schema.map(opt => {
|
||||||
const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
|
const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
|
||||||
|
if (opt.type === 'select' && Array.isArray(opt.choices)) {
|
||||||
|
const choice = opt.choices.find(c => c.value === val);
|
||||||
|
if (choice) return choice.label;
|
||||||
|
}
|
||||||
return val;
|
return val;
|
||||||
}).join(', ');
|
}).join(', ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,10 +336,17 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
|
|||||||
g.appendChild(dot);
|
g.appendChild(dot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clip path for title/subtitle text (prevent overflow past icon area)
|
||||||
|
const clipId = `clip-text-${id.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
|
||||||
|
const clipPath = svgEl('clipPath', { id: clipId });
|
||||||
|
clipPath.appendChild(svgEl('rect', { x: 14, y: 0, width: width - 48, height }));
|
||||||
|
g.appendChild(clipPath);
|
||||||
|
|
||||||
// Title (shift left edge for icon to have room)
|
// Title (shift left edge for icon to have room)
|
||||||
const title = svgEl('text', {
|
const title = svgEl('text', {
|
||||||
class: 'graph-node-title',
|
class: 'graph-node-title',
|
||||||
x: 16, y: 24,
|
x: 16, y: 24,
|
||||||
|
'clip-path': `url(#${clipId})`,
|
||||||
});
|
});
|
||||||
title.textContent = name;
|
title.textContent = name;
|
||||||
g.appendChild(title);
|
g.appendChild(title);
|
||||||
@@ -349,6 +356,7 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
|
|||||||
const sub = svgEl('text', {
|
const sub = svgEl('text', {
|
||||||
class: 'graph-node-subtitle',
|
class: 'graph-node-subtitle',
|
||||||
x: 16, y: 42,
|
x: 16, y: 42,
|
||||||
|
'clip-path': `url(#${clipId})`,
|
||||||
});
|
});
|
||||||
sub.textContent = subtype.replace(/_/g, ' ');
|
sub.textContent = subtype.replace(/_/g, ' ');
|
||||||
g.appendChild(sub);
|
g.appendChild(sub);
|
||||||
|
|||||||
@@ -82,4 +82,12 @@ export const trash2 = '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c
|
|||||||
export const listChecks = '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>';
|
export const listChecks = '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>';
|
||||||
export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/>';
|
export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/>';
|
||||||
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 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 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"/>';
|
||||||
|
export const heart = '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>';
|
||||||
|
export const github = '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>';
|
||||||
|
export const home = '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
|
||||||
|
export const lock = '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>';
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export const ICON_UNDO = _svg(P.undo2);
|
|||||||
export const ICON_SCENE = _svg(P.sparkles);
|
export const ICON_SCENE = _svg(P.sparkles);
|
||||||
export const ICON_CAPTURE = _svg(P.camera);
|
export const ICON_CAPTURE = _svg(P.camera);
|
||||||
export const ICON_BELL = _svg(P.bellRing);
|
export const ICON_BELL = _svg(P.bellRing);
|
||||||
|
export const ICON_THERMOMETER = _svg(P.thermometer);
|
||||||
export const ICON_CPU = _svg(P.cpu);
|
export const ICON_CPU = _svg(P.cpu);
|
||||||
export const ICON_KEYBOARD = _svg(P.keyboard);
|
export const ICON_KEYBOARD = _svg(P.keyboard);
|
||||||
export const ICON_MOUSE = _svg(P.mouse);
|
export const ICON_MOUSE = _svg(P.mouse);
|
||||||
@@ -185,3 +186,19 @@ export const ICON_LIST_CHECKS = _svg(P.listChecks);
|
|||||||
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
|
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
|
||||||
export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
|
export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
|
||||||
export const ICON_X = _svg(P.xIcon);
|
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);
|
||||||
|
export const ICON_HEART = _svg(P.heart);
|
||||||
|
export const ICON_GITHUB = _svg(P.github);
|
||||||
|
|
||||||
|
/** 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 {
|
import type {
|
||||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||||
SyncClock, WeatherSource, Automation, Display, FilterDef, EngineInfo,
|
SyncClock, WeatherSource, HomeAssistantSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||||
} from '../types.ts';
|
} from '../types.ts';
|
||||||
@@ -226,6 +226,8 @@ export let _cachedValueSources: ValueSource[] = [];
|
|||||||
// Sync clocks
|
// Sync clocks
|
||||||
export let _cachedSyncClocks: SyncClock[] = [];
|
export let _cachedSyncClocks: SyncClock[] = [];
|
||||||
export let _cachedWeatherSources: WeatherSource[] = [];
|
export let _cachedWeatherSources: WeatherSource[] = [];
|
||||||
|
export let _cachedHASources: HomeAssistantSource[] = [];
|
||||||
|
export let _cachedAssets: Asset[] = [];
|
||||||
|
|
||||||
// Automations
|
// Automations
|
||||||
export let _automationsCache: Automation[] | null = null;
|
export let _automationsCache: Automation[] | null = null;
|
||||||
@@ -289,6 +291,18 @@ export const weatherSourcesCache = new DataCache<WeatherSource[]>({
|
|||||||
});
|
});
|
||||||
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
|
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
|
||||||
|
|
||||||
|
export const haSourcesCache = new DataCache<HomeAssistantSource[]>({
|
||||||
|
endpoint: '/home-assistant/sources',
|
||||||
|
extractData: json => json.sources || [],
|
||||||
|
});
|
||||||
|
haSourcesCache.subscribe(v => { _cachedHASources = v; });
|
||||||
|
|
||||||
|
export const assetsCache = new DataCache<Asset[]>({
|
||||||
|
endpoint: '/assets',
|
||||||
|
extractData: json => json.assets || [],
|
||||||
|
});
|
||||||
|
assetsCache.subscribe(v => { _cachedAssets = v; });
|
||||||
|
|
||||||
export const filtersCache = new DataCache<FilterDef[]>({
|
export const filtersCache = new DataCache<FilterDef[]>({
|
||||||
endpoint: '/filters',
|
endpoint: '/filters',
|
||||||
extractData: json => json.filters || [],
|
extractData: json => json.filters || [],
|
||||||
|
|||||||
483
server/src/wled_controller/static/js/features/assets.ts
Normal file
483
server/src/wled_controller/static/js/features/assets.ts
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
// Use DataTransfer to safely assign files cross-browser
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(files[0]);
|
||||||
|
fileInput.files = dt.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(() => showToast(t('asset.error.play_failed'), 'error'));
|
||||||
|
_currentAudio = audio;
|
||||||
|
} catch {
|
||||||
|
showToast(t('asset.error.play_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 {
|
||||||
|
// Use raw fetch for multipart — fetchWithAuth forces Content-Type: application/json
|
||||||
|
// which breaks the browser's automatic multipart boundary generation
|
||||||
|
const headers = getHeaders();
|
||||||
|
delete headers['Content-Type'];
|
||||||
|
const res = await fetch(url, { method: 'POST', headers, body: formData });
|
||||||
|
if (res.status === 401) {
|
||||||
|
throw new Error('Session expired');
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
showToast(t('asset.error.download_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
* Automations — automation cards, editor, condition builder, process picker, scene selector.
|
* Automations — automation cards, editor, condition builder, process picker, scene selector.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.ts';
|
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
|
||||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
|
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { CardSection } from '../core/card-sections.ts';
|
import { CardSection } from '../core/card-sections.ts';
|
||||||
import { updateTabBadge, updateSubTabHash } from './tabs.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 * as P from '../core/icon-paths.ts';
|
||||||
import { wrapCard } from '../core/card-colors.ts';
|
import { wrapCard } from '../core/card-colors.ts';
|
||||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||||
@@ -248,6 +248,7 @@ const CONDITION_PILL_RENDERERS: Record<string, ConditionPillRenderer> = {
|
|||||||
},
|
},
|
||||||
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
|
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
|
||||||
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.condition.webhook')}</span>`,
|
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.condition.webhook')}</span>`,
|
||||||
|
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.condition.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
|
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
|
||||||
@@ -515,11 +516,11 @@ export function addAutomationCondition() {
|
|||||||
_autoGenerateAutomationName();
|
_autoGenerateAutomationName();
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook'];
|
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
|
||||||
const CONDITION_TYPE_ICONS = {
|
const CONDITION_TYPE_ICONS = {
|
||||||
always: P.refreshCw, startup: P.power, application: P.smartphone,
|
always: P.refreshCw, startup: P.power, application: P.smartphone,
|
||||||
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
|
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
|
||||||
mqtt: P.radio, webhook: P.globe,
|
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
|
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
|
||||||
@@ -610,7 +611,7 @@ function addAutomationConditionRow(condition: any) {
|
|||||||
<select class="condition-type-select">
|
<select class="condition-type-select">
|
||||||
${CONDITION_TYPE_KEYS.map(k => `<option value="${k}" ${condType === k ? 'selected' : ''}>${t('automations.condition.' + k)}</option>`).join('')}
|
${CONDITION_TYPE_KEYS.map(k => `<option value="${k}" ${condType === k ? 'selected' : ''}>${t('automations.condition.' + k)}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">✕</button>
|
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="condition-fields-container"></div>
|
<div class="condition-fields-container"></div>
|
||||||
`;
|
`;
|
||||||
@@ -726,6 +727,44 @@ function addAutomationConditionRow(condition: any) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (type === 'home_assistant') {
|
||||||
|
const haSourceId = data.ha_source_id || '';
|
||||||
|
const entityId = data.entity_id || '';
|
||||||
|
const haState = data.state || '';
|
||||||
|
const matchMode = data.match_mode || 'exact';
|
||||||
|
// Build HA source options from cached data
|
||||||
|
const haOptions = _cachedHASources.map((s: any) =>
|
||||||
|
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="condition-fields">
|
||||||
|
<small class="condition-always-desc">${t('automations.condition.home_assistant.hint')}</small>
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('automations.condition.home_assistant.ha_source')}</label>
|
||||||
|
<select class="condition-ha-source-id">
|
||||||
|
<option value="">—</option>
|
||||||
|
${haOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('automations.condition.home_assistant.entity_id')}</label>
|
||||||
|
<input type="text" class="condition-ha-entity-id" value="${escapeHtml(entityId)}" placeholder="binary_sensor.front_door">
|
||||||
|
</div>
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('automations.condition.home_assistant.state')}</label>
|
||||||
|
<input type="text" class="condition-ha-state" value="${escapeHtml(haState)}" placeholder="on">
|
||||||
|
</div>
|
||||||
|
<div class="condition-field">
|
||||||
|
<label>${t('automations.condition.home_assistant.match_mode')}</label>
|
||||||
|
<select class="condition-ha-match-mode">
|
||||||
|
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
|
||||||
|
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
|
||||||
|
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (type === 'webhook') {
|
if (type === 'webhook') {
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
|
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
|
||||||
@@ -766,7 +805,7 @@ function addAutomationConditionRow(condition: any) {
|
|||||||
<div class="condition-field">
|
<div class="condition-field">
|
||||||
<div class="condition-apps-header">
|
<div class="condition-apps-header">
|
||||||
<label>${t('automations.condition.application.apps')}</label>
|
<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>
|
</div>
|
||||||
<textarea class="condition-apps" rows="3" placeholder="firefox.exe chrome.exe">${escapeHtml(appsValue)}</textarea>
|
<textarea class="condition-apps" rows="3" placeholder="firefox.exe chrome.exe">${escapeHtml(appsValue)}</textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -835,6 +874,14 @@ function getAutomationEditorConditions() {
|
|||||||
const cond: any = { condition_type: 'webhook' };
|
const cond: any = { condition_type: 'webhook' };
|
||||||
if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
|
if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
|
||||||
conditions.push(cond);
|
conditions.push(cond);
|
||||||
|
} else if (condType === 'home_assistant') {
|
||||||
|
conditions.push({
|
||||||
|
condition_type: 'home_assistant',
|
||||||
|
ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value,
|
||||||
|
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLInputElement).value.trim(),
|
||||||
|
state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value,
|
||||||
|
match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value;
|
const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value;
|
||||||
const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim();
|
const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim();
|
||||||
|
|||||||
@@ -7,23 +7,33 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
|||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast } from '../core/ui.ts';
|
import { showToast } from '../core/ui.ts';
|
||||||
import {
|
import {
|
||||||
ICON_SEARCH, ICON_CLONE,
|
ICON_SEARCH, ICON_CLONE, getAssetTypeIcon,
|
||||||
} from '../core/icons.ts';
|
} from '../core/icons.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
import { IconSelect } from '../core/icon-select.ts';
|
import { IconSelect } from '../core/icon-select.ts';
|
||||||
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
|
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
|
||||||
|
import { _cachedAssets, assetsCache } from '../core/state.ts';
|
||||||
import { getBaseOrigin } from './settings.ts';
|
import { getBaseOrigin } from './settings.ts';
|
||||||
|
|
||||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||||
|
|
||||||
/* ── Notification state ───────────────────────────────────────── */
|
/* ── Notification state ───────────────────────────────────────── */
|
||||||
|
|
||||||
let _notificationAppColors: any[] = []; // [{app: '', color: '#...'}]
|
interface AppOverride {
|
||||||
|
app: string;
|
||||||
/** Return current app colors array (for dirty-check snapshot). */
|
color: string;
|
||||||
export function notificationGetRawAppColors() {
|
sound_asset_id: string | null;
|
||||||
return _notificationAppColors;
|
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 _notificationEffectIconSelect: any = null;
|
||||||
let _notificationFilterModeIconSelect: 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' : '';
|
(document.getElementById('css-editor-notification-filter-list-group') as HTMLElement).style.display = mode === 'off' ? 'none' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _notificationAppColorsRenderList() {
|
/* ── Unified per-app overrides (color + sound) ────────────────── */
|
||||||
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('');
|
|
||||||
|
|
||||||
// Wire up browse buttons to open process palette
|
let _overrideEntitySelects: EntitySelect[] = [];
|
||||||
list.querySelectorAll<HTMLButtonElement>('.notif-app-browse').forEach(btn => {
|
|
||||||
|
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 () => {
|
btn.addEventListener('click', async () => {
|
||||||
const idx = parseInt(btn.dataset.idx!);
|
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;
|
if (!nameInput) return;
|
||||||
const picked = await NotificationAppPalette.pick({
|
const picked = await NotificationAppPalette.pick({
|
||||||
current: nameInput.value,
|
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) {
|
if (picked !== undefined) {
|
||||||
nameInput.value = picked;
|
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() {
|
export function notificationAddAppOverride() {
|
||||||
_notificationAppColorsSyncFromDom();
|
_overridesSyncFromDom();
|
||||||
_notificationAppColors.push({ app: '', color: '#ffffff' });
|
_notificationAppOverrides.push({ app: '', color: '#ffffff', sound_asset_id: null, volume: 100 });
|
||||||
_notificationAppColorsRenderList();
|
_overridesRenderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notificationRemoveAppColor(i: number) {
|
export function notificationRemoveAppOverride(i: number) {
|
||||||
_notificationAppColorsSyncFromDom();
|
_overridesSyncFromDom();
|
||||||
_notificationAppColors.splice(i, 1);
|
_notificationAppOverrides.splice(i, 1);
|
||||||
_notificationAppColorsRenderList();
|
_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) {
|
export async function testNotification(sourceId: string) {
|
||||||
try {
|
try {
|
||||||
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
|
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
|
||||||
@@ -194,29 +316,24 @@ async function _loadNotificationHistory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _notificationAppColorsSyncFromDom() {
|
/* ── Load / Reset state ───────────────────────────────────────── */
|
||||||
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
|
|
||||||
if (!list) return;
|
/**
|
||||||
const names = list.querySelectorAll<HTMLInputElement>('.notif-app-name');
|
* Merge app_colors and app_sounds dicts into unified overrides list.
|
||||||
const colors = list.querySelectorAll<HTMLInputElement>('.notif-app-color');
|
* app_colors: {app: color}
|
||||||
if (names.length === _notificationAppColors.length) {
|
* app_sounds: {app: {sound_asset_id, volume}}
|
||||||
for (let i = 0; i < names.length; i++) {
|
*/
|
||||||
_notificationAppColors[i].app = names[i].value;
|
function _mergeOverrides(appColors: Record<string, string>, appSounds: Record<string, any>): AppOverride[] {
|
||||||
_notificationAppColors[i].color = colors[i].value;
|
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() {
|
export async function loadNotificationState(css: any) {
|
||||||
_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) {
|
|
||||||
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener;
|
(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';
|
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
|
||||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
|
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
|
||||||
@@ -230,15 +347,27 @@ export function loadNotificationState(css: any) {
|
|||||||
onNotificationFilterModeChange();
|
onNotificationFilterModeChange();
|
||||||
_attachNotificationProcessPicker();
|
_attachNotificationProcessPicker();
|
||||||
|
|
||||||
// App colors dict -> list
|
// Ensure assets are loaded before populating sound dropdowns
|
||||||
const ac = css.app_colors || {};
|
await assetsCache.fetch();
|
||||||
_notificationAppColors = Object.entries(ac).map(([app, color]) => ({ app, color }));
|
|
||||||
_notificationAppColorsRenderList();
|
// 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);
|
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-os-listener') as HTMLInputElement).checked = true;
|
||||||
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
|
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
|
||||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
|
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
|
||||||
@@ -250,8 +379,19 @@ export function resetNotificationState() {
|
|||||||
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
|
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
|
||||||
onNotificationFilterModeChange();
|
onNotificationFilterModeChange();
|
||||||
_attachNotificationProcessPicker();
|
_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);
|
showNotificationEndpoint(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
|
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
|
||||||
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.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 ───────────────────────────────────── */
|
/* ── Preview config builder ───────────────────────────────────── */
|
||||||
|
|
||||||
@@ -38,9 +38,8 @@ function _collectPreviewConfig() {
|
|||||||
if (colors.length < 2) return null;
|
if (colors.length < 2) return null;
|
||||||
config = { source_type: 'color_cycle', colors };
|
config = { source_type: 'color_cycle', colors };
|
||||||
} else if (sourceType === 'effect') {
|
} else if (sourceType === 'effect') {
|
||||||
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
|
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
|
||||||
if (['meteor', 'comet', 'bouncing_ball'].includes(config.effect_type)) { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
|
if (['meteor', 'comet', 'bouncing_ball'].includes(config.effect_type)) { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
|
||||||
if (config.palette === 'custom') { const cpText = (document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement)?.value?.trim(); if (cpText) { try { config.custom_palette = JSON.parse(cpText); } catch {} } }
|
|
||||||
} else if (sourceType === 'daylight') {
|
} else if (sourceType === 'daylight') {
|
||||||
config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value), longitude: parseFloat((document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value) };
|
config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value), longitude: parseFloat((document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value) };
|
||||||
} else if (sourceType === 'candlelight') {
|
} else if (sourceType === 'candlelight') {
|
||||||
@@ -55,6 +54,9 @@ function _collectPreviewConfig() {
|
|||||||
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||||
app_filter_list: filterList,
|
app_filter_list: filterList,
|
||||||
app_colors: notificationGetAppColorsDict(),
|
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;
|
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user