17 Commits

Author SHA1 Message Date
89d1b13854 fix: rename HA Lights → Home Assistant, HA Light Targets → Light Targets
All checks were successful
Lint & Test / test (push) Successful in 1m38s
2026-03-28 11:30:40 +03:00
324a308805 feat: entity picker for HA light mapping — searchable EntitySelect for light entities
Some checks failed
Lint & Test / test (push) Failing after 11m19s
Replaces plain text input with EntitySelect dropdown that fetches
available light.* entities from the selected HA source. Changing the
HA source refreshes the entity list across all mapping rows.
2026-03-28 00:35:42 +03:00
cb9289f01f feat: HA light output targets — cast LED colors to Home Assistant lights
Some checks failed
Lint & Test / test (push) Has been cancelled
New output target type `ha_light` that sends averaged LED colors to HA
light entities via WebSocket service calls (light.turn_on/turn_off):

Backend:
- HARuntime.call_service(): fire-and-forget WS service calls
- HALightOutputTarget: data model with light mappings, update rate, transition
- HALightTargetProcessor: processing loop with delta detection, rate limiting
- ProcessorManager.add_ha_light_target(): registration
- API schemas/routes updated for ha_light target type

Frontend:
- HA Light Targets section in Targets tab tree nav
- Modal editor: HA source picker, CSS source picker, light entity mappings
- Target cards with start/stop/clone/edit actions
- i18n keys for all new UI strings
2026-03-28 00:08:49 +03:00
fb98e6e2b8 ci: add manual build workflow for testing artifacts
Some checks failed
Lint & Test / test (push) Has been cancelled
workflow_dispatch-triggered build.yml that produces Windows
installer/portable and Linux tarball as CI artifacts without
creating a release. Trigger from Gitea UI → Actions → Run.
2026-03-27 23:41:22 +03:00
3c2efd5e4a refactor: move Weather and Home Assistant sources to Integrations tree group
Separates external service connections from utility entities in the
Streams tree navigation for clearer organization.
2026-03-27 23:21:40 +03:00
2153dde4b7 feat: Home Assistant integration — WebSocket connection, automation conditions, UI
Add full Home Assistant integration via WebSocket API:
- HARuntime: persistent WebSocket client with auth, auto-reconnect, entity state cache
- HAManager: ref-counted runtime pool (like WeatherManager)
- HomeAssistantCondition: new automation trigger type matching entity states
- REST API: CRUD for HA sources + /test, /entities, /status endpoints
- /api/v1/system/integrations-status: combined MQTT + HA dashboard indicators
- Frontend: HA Sources tab in Streams, condition type in automation editor
- Modal editor with host, token, SSL, entity filters
- websockets>=13.0 dependency added
2026-03-27 22:42:48 +03:00
f3d07fc47f feat: donation banner, About tab, settings UI improvements
Some checks failed
Lint & Test / test (push) Has been cancelled
- Dismissible donation/open-source banner after 3+ sessions (30-day snooze)
- New About tab in Settings: version, repo link, license info
- Centralize project URLs (REPO_URL, DONATE_URL) in __init__.py, served via /health
- Center settings tab bar, reduce tab padding for 6-tab fit
- External URL save button: icon button instead of full-width text button
- Remove redundant settings footer close button
- Footer "Source Code" link replaced with "About" opening settings
- i18n keys for en/ru/zh
2026-03-27 21:09:34 +03:00
f61a0206d4 feat: custom file drop zone for asset upload modal; fix review issues
All checks were successful
Lint & Test / test (push) Successful in 1m29s
Replace plain <input type="file"> with a styled drag-and-drop zone
featuring hover/drag states, file info display, and remove button.

Fix 10 review issues: add 401 handling to upload, remove non-null
assertions, add missing i18n keys (common.remove, error.play_failed,
error.download_failed), normalize close button glyphs to &#x2715;,
i18n the dropzone aria-label, replace silent error catches with toast
notifications, use DataTransfer for cross-browser file assignment.
2026-03-26 21:43:08 +03:00
f345687600 chore: remove python3.11 version pin from pre-commit config
All checks were successful
Lint & Test / test (push) Successful in 1m6s
2026-03-26 20:41:34 +03:00
e2e1107df7 feat: asset-based image/video sources, notification sounds, UI improvements
Some checks failed
Lint & Test / test (push) Has been cancelled
- Replace URL-based image_source/url fields with image_asset_id/video_asset_id
  on StaticImagePictureSource and VideoCaptureSource (clean break, no migration)
- Resolve asset IDs to file paths at runtime via AssetStore.get_file_path()
- Add EntitySelect asset pickers for image/video in stream editor modal
- Add notification sound configuration (global sound + per-app overrides)
- Unify per-app color and sound overrides into single "Per-App Overrides" section
- Persist notification history between server restarts
- Add asset management system (upload, edit, delete, soft-delete)
- Replace emoji buttons with SVG icons throughout UI
- Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
2026-03-26 20:40:25 +03:00
c0853ce184 fix: improve command palette actions and automation condition button
All checks were successful
Lint & Test / test (push) Successful in 1m16s
- Action items (start/stop, enable/disable) no longer close the palette
- Action items toggle state after success (Start→Stop, Enable→Disable)
- Toast z-index raised above command palette backdrop (3000→3500)
- Automation condition remove button uses ICON_TRASH SVG instead of ✕
2026-03-26 02:21:52 +03:00
3e0bf8538c feat: add api_input LED interpolation; fix LED preview, FPS charts, dashboard layout
All checks were successful
Lint & Test / test (push) Successful in 1m26s
API Input:
- Add interpolation mode (linear/nearest/none) for LED count mismatch
  between incoming data and device LED count
- New IconSelect in editor, i18n for en/ru/zh
- Mark crossfade as won't-do (client owns temporal transitions)
- Mark last-write-wins as already implemented

LED Preview:
- Fix zone-mode preview parsing composite wire format (0xFE header
  bytes were rendered as color data, garbling multi-zone previews)
- Fix _restoreLedPreviewState to handle zone-mode panels

FPS Charts:
- Seed target card charts from server metrics-history on first load
- Add fetchMetricsHistory() with 5s TTL cache shared across
  dashboard, targets, perf-charts, and graph-editor
- Fix chart padding: pass maxSamples per caller (120 for dashboard,
  30 for target cards) instead of hardcoded 120
- Fix dashboard chart empty on tab switch (always fetch server history)
- Left-pad with nulls for consistent chart width across targets

Dashboard:
- Fix metrics row alignment (grid layout with fixed column widths)
- Fix FPS label overflow into uptime column
2026-03-26 02:06:49 +03:00
be4c98b543 fix: show template name instead of ID in filter list and card badges
All checks were successful
Lint & Test / test (push) Successful in 1m7s
Collapsed filter cards in the modal showed raw template IDs (e.g.
pp_cb72e227) instead of resolving select options to their labels.
Card filter chain badges now include the referenced template name.
2026-03-25 23:56:40 +03:00
dca2d212b1 fix: clip graph node title and subtitle to prevent overflow
Long entity names overflowed past the icon area on graph cards.
Added SVG clipPath to constrain text within the node bounds.
2026-03-25 23:56:30 +03:00
53986f8d95 fix: replace emoji with SVG icons on weather and daylight cards
Weather card used  and 🌡 emoji, daylight card used 🕐 and .
Replaced with ICON_FAST_FORWARD, ICON_THERMOMETER, and ICON_CLOCK.
Added thermometer icon path.
2026-03-25 23:56:21 +03:00
a4a9f6f77f fix: send gradient_id instead of palette in effect transient preview
All checks were successful
Lint & Test / test (push) Successful in 1m19s
The preview config was sending `palette` which defaults to "fire" on
the server, ignoring the user's selected gradient. Also removed the
dead fallback notification block and stale custom_palette check.
2026-03-25 23:43:33 +03:00
9fcfdb8570 ci: use sparse checkout for release notes in release workflow
Only fetch RELEASE_NOTES.md instead of full repo checkout, and
simplify the file detection to a direct path check.
2026-03-25 23:43:31 +03:00
141 changed files with 7521 additions and 1435 deletions

View 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

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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"]

View File

@@ -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,
}
)

View File

@@ -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

View 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],
}

View File

@@ -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}")

View File

@@ -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}")

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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):

View File

@@ -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)

View 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,
)

View File

@@ -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")

View File

@@ -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"])

View File

@@ -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)

View File

@@ -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")

View File

@@ -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}")

View File

@@ -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}")

View File

@@ -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"]),
},
}

View File

@@ -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}")

View File

@@ -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}")

View File

@@ -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),
): ):

View File

@@ -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}")

View File

@@ -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(

View 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")

View File

@@ -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")

View File

@@ -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")

View 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

View File

@@ -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")

View File

@@ -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")

View File

@@ -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):

View File

@@ -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":

View File

@@ -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(

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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]:

View File

@@ -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 ─────────────────────────────────────

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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,

View File

@@ -0,0 +1 @@
"""Home Assistant integration — WebSocket client, entity state cache, manager."""

View 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")

View 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 []

View File

@@ -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:

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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()

View File

@@ -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

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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],

View File

@@ -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()):

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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:

View File

@@ -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 ────────────────────────────────────────────────────

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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

View File

@@ -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():

View File

@@ -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

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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; }

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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) {

View File

@@ -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(', ');
} }

View File

@@ -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);

View File

@@ -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"/>';

View File

@@ -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);
}

View File

@@ -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 || [],

View 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;

View File

@@ -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">&#x2715;</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&#10;chrome.exe">${escapeHtml(appsValue)}</textarea> <textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;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();

View File

@@ -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; // 0100
} }
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})">&#x2715;</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})">&#x2715;</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);
} }

View File

@@ -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