Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a12f39f49 | |||
| dd43f3836d | |||
| d32961085d | |||
| 6cd5e057da | |||
| 81b18089e1 | |||
| abc204c04e | |||
| 9550688c1e | |||
| 9dcd76d264 | |||
| 0409cd8b66 | |||
| 6180569b10 | |||
| f71e10ee06 | |||
| ca59546711 | |||
| 4a82595f26 | |||
| 1ada5ac334 | |||
| e18d56c838 | |||
| 7728aecb4f | |||
| e28ab5a956 | |||
| 1e395fd09e | |||
| ffee156c17 | |||
| 02e2ea37f3 | |||
| fdc9201660 | |||
| 5686ae5468 | |||
| 9960f15a1b | |||
| 397a53ed1c | |||
| 1c1bbe2551 | |||
| 68040173c6 | |||
| 4bf3fe65db | |||
| 34db5de8c3 | |||
| 0be3f833df | |||
| 4b2e8fc5ec | |||
| 487259a96d | |||
| fd62db1720 | |||
| 669ae20824 | |||
| 6de61b965e | |||
| 12b40e6071 | |||
| 498854f04d | |||
| 15cfb821d3 | |||
| 2e51f46dfd | |||
| 05cf121666 | |||
| d505388f0e |
@@ -1,36 +1,58 @@
|
|||||||
# LED Grab
|
# LED Grab
|
||||||
|
|
||||||
Ambient lighting system that captures screen content and drives LED strips in real time. Supports WLED, Adalight, AmbileD, and DDP devices with audio-reactive effects, pattern generation, and automated profile switching.
|
Ambient lighting system that captures screen content and drives LED strips and smart lights in real time. Supports a wide range of devices — WLED, DDP, Adalight, smart bulbs, PC peripherals, Bluetooth strips, and more — with audio-reactive effects, pattern generation, and condition-based automation.
|
||||||
|
|
||||||
|
**Free and open source.** LedGrab is released under the [MIT license](LICENSE) — free to use, modify, and self-host, with no accounts, telemetry, or cloud dependency. Everything runs locally on your own machine and network.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
The server captures pixels from a screen (or Android device via ADB), extracts border colors, applies post-processing filters, and streams the result to LED strips at up to 60 fps. A built-in web dashboard provides device management, calibration, live LED preview, and real-time metrics — no external UI required.
|
The server captures pixels from a screen (or from a connected Android phone via ADB), extracts border colors, applies a post-processing filter pipeline, and streams the result to your LED devices at up to 60 fps. A built-in web dashboard provides device management, calibration, a visual wiring editor, live LED preview, and real-time metrics — no external UI required.
|
||||||
|
|
||||||
A Home Assistant integration exposes devices as entities for smart home automation.
|
A separate Home Assistant integration exposes devices as entities for smart-home automation.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Dashboard — live system performance, integrations, automations, and scene presets at a glance.*
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Channels — start, stop, and monitor each source-to-device pipeline with live FPS.*
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Live preview — inspect the processed capture output in real time before it reaches the LEDs.*
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Screen Capture
|
### Screen Capture
|
||||||
|
|
||||||
- Multi-monitor support with per-target display selection
|
- Multi-monitor support with per-target display selection
|
||||||
- 6 capture engine backends — MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows), Scrcpy (Android via ADB), Camera/Webcam (OpenCV)
|
- Capture engine backends: MSS (cross-platform), DXCam, BetterCam, Windows Graphics Capture (Windows only), and Camera/Webcam (OpenCV)
|
||||||
|
- Capture from a connected Android phone's screen via scrcpy (ADB) — the device is a *source*; LedGrab itself runs on your desktop
|
||||||
- Configurable capture regions, FPS, and border width
|
- Configurable capture regions, FPS, and border width
|
||||||
- Capture templates for reusable configurations
|
- Reusable capture templates
|
||||||
|
|
||||||
### LED Device Support
|
### LED Device Support
|
||||||
|
|
||||||
- WLED (HTTP/UDP) with mDNS auto-discovery
|
LedGrab speaks many protocols, so a single setup can drive everything from a DIY strip to off-the-shelf smart bulbs:
|
||||||
- Adalight (serial) — Arduino-compatible LED controllers
|
|
||||||
- AmbileD (serial)
|

|
||||||
- DDP (Distributed Display Protocol, UDP)
|
|
||||||
- OpenRGB — PC peripherals (keyboard, mouse, RAM, fans, LED strips)
|
- **Network LED controllers** — WLED (HTTP/UDP, with mDNS auto-discovery), DDP (Pixelblaze, ESPixelStick, Falcon), Open Pixel Control (OPC), Art-Net / sACN (E1.31), ESP-NOW, and generic WebSocket streaming
|
||||||
- Serial port auto-detection and baud rate configuration
|
- **Serial / direct hardware** — Adalight (Arduino-compatible), AmbiLED, SPI-attached strips (e.g. WS2812B), and USB HID controllers
|
||||||
|
- **Smart bulbs & panels** — Philips Hue (Entertainment API), Nanoleaf, Yeelight, WiZ, LIFX, and Govee (Wi-Fi LAN)
|
||||||
|
- **Bluetooth LE strips** — SP110E, Triones / HappyLighting, Zengge, and Govee BLE
|
||||||
|
- **PC peripherals** — OpenRGB, Razer Chroma, and SteelSeries GameSense (keyboards, mice, RAM, fans, etc.)
|
||||||
|
- **Device groups** — combine multiple devices into one logical target
|
||||||
|
- Serial port auto-detection and baud-rate configuration
|
||||||
|
|
||||||
### Color Processing
|
### Color Processing
|
||||||
|
|
||||||
- Post-processing filter pipeline: brightness, gamma, saturation, color correction, auto-crop, frame interpolation, pixelation, flip
|
- Post-processing filter pipeline: brightness, gamma, saturation, color correction, auto-crop, frame interpolation, pixelation, flip, and more
|
||||||
- Reusable post-processing templates
|
- Reusable post-processing templates
|
||||||
- Color strip sources: audio-reactive, pattern generator, composite layering, audio-to-color mapping
|
- Color strip sources: audio-reactive, pattern generator, gradients, composite layering, and audio-to-color mapping
|
||||||
- Pattern templates with customizable effects
|
- Pattern templates with customizable effects
|
||||||
|
|
||||||
### Audio Integration
|
### Audio Integration
|
||||||
@@ -38,17 +60,20 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
|||||||
- Multichannel audio capture from any system device (input or loopback)
|
- Multichannel audio capture from any system device (input or loopback)
|
||||||
- WASAPI engine on Windows, Sounddevice (PortAudio) engine on Linux/macOS
|
- WASAPI engine on Windows, Sounddevice (PortAudio) engine on Linux/macOS
|
||||||
- Per-channel mono extraction
|
- Per-channel mono extraction
|
||||||
- Audio-reactive color strip sources driven by frequency analysis
|
- Audio filter / processing pipeline feeding audio-reactive color sources driven by frequency analysis
|
||||||
|
|
||||||
### Automation
|
### Automation
|
||||||
|
|
||||||
- Profile engine with condition-based switching (time of day, active window, etc.)
|
- Automations engine with condition-based rules — switch targets, scenes, or brightness by time of day, active window/process, MQTT, webhooks, or game events
|
||||||
- Dynamic brightness value sources (schedule-based, scene-aware)
|
- Scene presets for one-click lighting changes
|
||||||
- Key Colors (KC) targets with live WebSocket color streaming
|
- Dynamic value sources for brightness and other parameters (schedule-based, weather-based, scene-aware)
|
||||||
|
- Weather sources, clock sync, webhooks, and inbound/outbound HTTP endpoints
|
||||||
|
- Game integration adapters (e.g. League of Legends)
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
|
|
||||||
- Web UI at `http://localhost:8080` — no installation needed on the client side
|
- Web UI at `http://localhost:8080` — nothing to install on the client side
|
||||||
|
- Visual node-graph editor for wiring sources → processing → targets
|
||||||
- Progressive Web App (PWA) — installable on phones and tablets with offline caching
|
- Progressive Web App (PWA) — installable on phones and tablets with offline caching
|
||||||
- Responsive mobile layout with bottom tab navigation
|
- Responsive mobile layout with bottom tab navigation
|
||||||
- Device management with auto-discovery wizard
|
- Device management with auto-discovery wizard
|
||||||
@@ -59,32 +84,57 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
|||||||
|
|
||||||
### Home Assistant Integration
|
### Home Assistant Integration
|
||||||
|
|
||||||
- HACS-compatible custom component
|
- HACS-compatible custom component (separate repository)
|
||||||
- Light, switch, sensor, and number entities per device
|
- Light, switch, sensor, and number entities per device
|
||||||
- Real-time metrics via data coordinator
|
- Real-time metrics via a data coordinator
|
||||||
- WebSocket-based live LED preview in HA
|
- WebSocket-based live LED preview in HA
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
|
||||||
|
LedGrab runs as a desktop / server application:
|
||||||
|
|
||||||
|
| Platform | Status | Notes |
|
||||||
|
| -------- | ------ | ----- |
|
||||||
|
| Windows | ✅ Supported | Installer (`.exe`) and portable ZIP; all capture/audio backends |
|
||||||
|
| Linux | ✅ Supported | Tarball and Docker image; X11 capture (Wayland in-container capture not supported) |
|
||||||
|
| macOS | ✅ Supported | Runs from source / Docker; MSS capture |
|
||||||
|
| Docker | ✅ Supported | Multi-arch container image |
|
||||||
|
| Android (TV) | ⚠️ Experimental | An on-device Android-TV build exists (APK attached to releases) but is emulator-verified only and **not officially supported** |
|
||||||
|
|
||||||
|
> **There is no production Android app.** Android phones are only supported as a *capture source* (via scrcpy/ADB) from a desktop host. The on-device Android-TV build is experimental.
|
||||||
|
|
||||||
|
### Feature support by OS
|
||||||
|
|
||||||
|
| Feature | Windows | Linux / macOS | Android TV (experimental) |
|
||||||
|
| ------- | ------- | ------------- | ------------------------- |
|
||||||
|
| Screen capture | DXCam, BetterCam, WGC, MSS | MSS | MediaProjection; root `screenrecord` (rooted devices) |
|
||||||
|
| Webcam capture | OpenCV (DirectShow) | OpenCV (V4L2) | Camera2 (on-demand, while capture is running) |
|
||||||
|
| Audio capture | WASAPI, Sounddevice | Sounddevice (PulseAudio/PipeWire) | AudioPlaybackCapture (API 29+) |
|
||||||
|
| GPU monitoring | NVIDIA (nvidia-ml-py) | NVIDIA (nvidia-ml-py) | — (CPU/RAM/battery/thermal via `/proc`) |
|
||||||
|
| Capture from Android phone | scrcpy (ADB) | scrcpy (ADB) | — (captures its own screen instead) |
|
||||||
|
| Notification capture | WinRT | dbus (Linux) | NotificationListenerService |
|
||||||
|
| Monitor names | Friendly names (WMI) | Generic ("Display 0") | Single built-in display |
|
||||||
|
| LED transports | Network, USB-serial, BLE | Network, USB-serial, BLE | Network, USB-serial (Android driver), BLE (Android bridge) |
|
||||||
|
| Automation: window/process conditions | Supported | Partial | Foreground-app condition (UsageStatsManager) |
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.11+ (or Docker)
|
- Python 3.11+ (or Docker)
|
||||||
- A supported LED device on the local network or connected via USB
|
- A supported LED device on the local network, connected via USB/serial, or reachable over Bluetooth
|
||||||
- Windows, Linux, or macOS — all core features work cross-platform
|
- Windows, Linux, or macOS
|
||||||
|
|
||||||
### Platform Notes
|
|
||||||
|
|
||||||
| Feature | Windows | Linux / macOS |
|
|
||||||
| ------- | ------- | ------------- |
|
|
||||||
| Screen capture | DXCam, BetterCam, WGC, MSS | MSS |
|
|
||||||
| Webcam capture | OpenCV (DirectShow) | OpenCV (V4L2) |
|
|
||||||
| Audio capture | WASAPI, Sounddevice | Sounddevice (PulseAudio/PipeWire) |
|
|
||||||
| GPU monitoring | NVIDIA (pynvml) | NVIDIA (pynvml) |
|
|
||||||
| Android capture | Scrcpy (ADB) | Scrcpy (ADB) |
|
|
||||||
| Monitor names | Friendly names (WMI) | Generic ("Display 0") |
|
|
||||||
| Profile conditions | Process/window detection | Not yet implemented |
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Docker (recommended)
|
### Prebuilt downloads
|
||||||
|
|
||||||
|
Grab a ready-to-run build from the [Releases page](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/releases):
|
||||||
|
|
||||||
|
- **Windows** — `LedGrab-<version>-setup.exe` (installer, no admin required) or `LedGrab-<version>-win-x64.zip` (portable)
|
||||||
|
- **Linux** — `LedGrab-<version>-linux-x64.tar.gz`
|
||||||
|
- **Docker** — see below
|
||||||
|
- **Android TV** — `.apk` (experimental, see [Platforms](#platforms))
|
||||||
|
|
||||||
|
### Docker (recommended for servers)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
|
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
|
||||||
@@ -115,11 +165,11 @@ export PYTHONPATH=$(pwd)/src # Linux/Mac
|
|||||||
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
|
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **http://localhost:8080** to access the dashboard.
|
Open <http://localhost:8080> to access the dashboard.
|
||||||
|
|
||||||
> **Important:** The default API key is `development-key-change-in-production`. Change it before exposing the server outside localhost. See [INSTALLATION.md](INSTALLATION.md) for details.
|
> **Network access:** By default, LedGrab allows anonymous access only from `localhost`. Any request from another machine on your LAN is rejected unless you configure an API key (`auth.api_keys`). Set a key before exposing the server on your network — see [INSTALLATION.md](INSTALLATION.md).
|
||||||
|
|
||||||
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 CORS setup.
|
||||||
|
|
||||||
## Demo Mode
|
## Demo Mode
|
||||||
|
|
||||||
@@ -133,50 +183,9 @@ docker compose run -e LEDGRAB_DEMO=true server
|
|||||||
|
|
||||||
# Python
|
# Python
|
||||||
LEDGRAB_DEMO=true uvicorn ledgrab.main:app --host 0.0.0.0 --port 8081
|
LEDGRAB_DEMO=true uvicorn ledgrab.main:app --host 0.0.0.0 --port 8081
|
||||||
|
|
||||||
# Windows (installed app)
|
|
||||||
set LEDGRAB_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.
|
Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores data under `data/demo/` (separate from production data). It can run alongside the main server.
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```text
|
|
||||||
ledgrab/
|
|
||||||
├── server/ # Python FastAPI backend
|
|
||||||
│ ├── src/ledgrab/
|
|
||||||
│ │ ├── main.py # Application entry point
|
|
||||||
│ │ ├── config.py # YAML + env var configuration
|
|
||||||
│ │ ├── api/
|
|
||||||
│ │ │ ├── routes/ # REST + WebSocket endpoints
|
|
||||||
│ │ │ └── schemas/ # Pydantic request/response models
|
|
||||||
│ │ ├── core/
|
|
||||||
│ │ │ ├── capture/ # Screen capture, calibration, pixel processing
|
|
||||||
│ │ │ ├── capture_engines/ # MSS, DXCam, BetterCam, WGC, Scrcpy, Camera backends
|
|
||||||
│ │ │ ├── devices/ # WLED, Adalight, AmbileD, DDP, OpenRGB clients
|
|
||||||
│ │ │ ├── audio/ # Audio capture engines
|
|
||||||
│ │ │ ├── filters/ # Post-processing filter pipeline
|
|
||||||
│ │ │ ├── processing/ # Stream orchestration and target processors
|
|
||||||
│ │ │ └── profiles/ # Condition-based profile automation
|
|
||||||
│ │ ├── storage/ # JSON-based persistence layer
|
|
||||||
│ │ ├── static/ # Web dashboard (vanilla JS, CSS, HTML)
|
|
||||||
│ │ │ ├── js/core/ # API client, state, i18n, modals, events
|
|
||||||
│ │ │ ├── js/features/ # Feature modules (devices, streams, targets, etc.)
|
|
||||||
│ │ │ ├── css/ # Stylesheets
|
|
||||||
│ │ │ └── locales/ # en.json, ru.json, zh.json
|
|
||||||
│ │ └── utils/ # Logging, monitor detection
|
|
||||||
│ ├── config/ # default_config.yaml
|
|
||||||
│ ├── tests/ # pytest suite
|
|
||||||
│ ├── Dockerfile
|
|
||||||
│ └── docker-compose.yml
|
|
||||||
├── docs/
|
|
||||||
│ ├── API.md # REST API reference
|
|
||||||
│ └── CALIBRATION.md # LED calibration guide
|
|
||||||
├── INSTALLATION.md
|
|
||||||
└── LICENSE # MIT
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -187,14 +196,15 @@ server:
|
|||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
port: 8080
|
port: 8080
|
||||||
log_level: "INFO"
|
log_level: "INFO"
|
||||||
|
cors_origins:
|
||||||
|
- "http://localhost:8080"
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
api_keys:
|
# Empty (default) → loopback-only anonymous access; LAN requests are rejected.
|
||||||
dev: "development-key-change-in-production"
|
# Add a key to enable LAN/remote access (generate one with: openssl rand -hex 32).
|
||||||
|
api_keys: {}
|
||||||
storage:
|
# api_keys:
|
||||||
devices_file: "data/devices.json"
|
# dev: "your-secret-key-here"
|
||||||
templates_file: "data/capture_templates.json"
|
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
format: "json"
|
format: "json"
|
||||||
@@ -202,25 +212,26 @@ logging:
|
|||||||
max_size_mb: 100
|
max_size_mb: 100
|
||||||
```
|
```
|
||||||
|
|
||||||
Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
|
- Application data is stored in a SQLite database (`data/ledgrab.db` by default). Set `LEDGRAB_DATA_DIR` to relocate the data root (database + assets).
|
||||||
|
- Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
|
||||||
|
|
||||||
|
See [INSTALLATION.md](INSTALLATION.md) and [`server/.env.example`](server/.env.example) for the full configuration reference.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
The server exposes a REST API (with Swagger docs at `/docs`) covering:
|
The server exposes a REST API (with interactive Swagger docs at `/docs`) plus WebSocket endpoints. Resources include:
|
||||||
|
|
||||||
- **Devices** — CRUD, discovery, validation, state, metrics
|
- **Devices** — CRUD, discovery, validation, state, metrics
|
||||||
- **Capture Templates** — Screen capture configurations
|
- **Capture Templates** & **Picture Sources** — screen capture configuration and stream definitions
|
||||||
- **Picture Sources** — Screen capture stream definitions
|
- **Output Targets** — LED target management, start/stop processing, live color stream
|
||||||
- **Picture Targets** — LED target management, start/stop processing
|
- **Post-Processing Templates** — filter pipeline configurations
|
||||||
- **Post-Processing Templates** — Filter pipeline configurations
|
- **Color Strip Sources**, **Pattern Templates**, **Gradients** — color generation
|
||||||
- **Color Strip Sources** — Audio, pattern, composite, mapped sources
|
- **Audio Sources / Templates / Filters** — audio capture and reactive processing
|
||||||
- **Audio Sources** — Multichannel and mono audio device configuration
|
- **Value Sources**, **Weather Sources**, **Scene Presets** — dynamic parameters and presets
|
||||||
- **Pattern Templates** — Effect pattern definitions
|
- **Automations**, **Webhooks**, **HTTP Endpoints**, **Game Integration** — triggers and rules
|
||||||
- **Value Sources** — Dynamic brightness/value providers
|
- **MQTT** & **Home Assistant** — broker sources and HA integration
|
||||||
- **Key Colors Targets** — KC targets with WebSocket live color stream
|
|
||||||
- **Profiles** — Condition-based automation profiles
|
|
||||||
|
|
||||||
All endpoints require API key authentication via `X-API-Key` header or `?token=` query parameter.
|
Authentication uses a Bearer token (`Authorization: Bearer <api-key>`) when API keys are configured; loopback requests are anonymous by default. WebSocket connections authenticate via a first-message handshake.
|
||||||
|
|
||||||
See [docs/API.md](docs/API.md) for the full reference.
|
See [docs/API.md](docs/API.md) for the full reference.
|
||||||
|
|
||||||
@@ -253,16 +264,16 @@ ruff check src/ tests/
|
|||||||
Optional extras:
|
Optional extras:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e ".[perf]" # High-performance capture engines (Windows)
|
pip install -e ".[perf]" # High-performance capture engines (Windows: DXCam, BetterCam, WGC)
|
||||||
pip install -e ".[camera]" # Webcam capture via OpenCV
|
pip install -e ".[notifications]" # OS notification capture (WinRT / dbus)
|
||||||
|
pip install -e ".[scrcpy]" # Capture from an Android phone via scrcpy
|
||||||
|
pip install -e ".[ble]" # Bluetooth LE LED controllers (desktop only)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome. LedGrab is MIT-licensed, so you're free to fork, modify, and self-host. Please open an issue or pull request on the [repository](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE). Free and open source.
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
- [WLED](https://github.com/Aircoookie/WLED) — LED control firmware
|
|
||||||
- [FastAPI](https://fastapi.tiangolo.com/) — Python web framework
|
|
||||||
- [MSS](https://python-mss.readthedocs.io/) — Cross-platform screen capture
|
|
||||||
|
|||||||
+69
-23
@@ -1,54 +1,100 @@
|
|||||||
## v0.8.1 (2026-05-28)
|
## v0.8.2 (2026-06-08)
|
||||||
|
|
||||||
### User-facing changes
|
### User-facing changes
|
||||||
|
|
||||||
#### Features
|
#### Features
|
||||||
|
|
||||||
##### Multi-broker MQTT devices
|
##### WLED native realtime UDP output
|
||||||
|
- New realtime UDP sink speaking WLED's **DRGB / DRGBW / DNRGB** protocols, with automatic revert to the device's prior state when streaming stops ([7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec))
|
||||||
|
|
||||||
- The device editor now shows an MQTT **broker picker** for `device_type=mqtt` (in both the add-device and device-settings modals), wired into load / save / validate / dirty-check / clone. An empty selection means "first available broker"
|
##### Automatic brightness limiting (ABL) / power budget
|
||||||
- `mqtt_source_id` is now threaded end-to-end through `DeviceCreate` / `DeviceUpdate` / `DeviceResponse` and the device routes; the referenced broker is validated on create **and** update ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Per-LED power budgeting that caps total draw by scaling brightness to a configurable current/PSU limit, preventing brownouts on long strips ([ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156))
|
||||||
|
|
||||||
##### Schema-driven wiring-graph editor
|
##### Scene playlists
|
||||||
|
- Scenes can be grouped into **playlists with timed auto-cycling**, so a target can rotate through looks on a schedule ([f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e))
|
||||||
|
- Playlist + cycling state is included in the aggregated `/snapshot` response ([abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c))
|
||||||
|
|
||||||
- The visual graph editor now renders ports and edges generically from a backend-served schema (`GET /api/v1/graph/schema`) instead of hard-coding the connectable-field topology in two places — so client and server can no longer drift
|
##### Auto edge-calibration + guided first-run setup wizard
|
||||||
- New `GET /api/v1/graph` returns the full nodes + edges + validation topology, and `GET /api/v1/graph/dependents/{kind}/{id}` reports what references an entity ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Backend core for **automatic screen-edge calibration** ([0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8)), a one-call setup scaffold with an onboarding flag ([9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d)), and a browser-driven calibration UI ([9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688))
|
||||||
|
- A **guided first-run setup wizard** ties it together for new installs ([81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808)), with all-provider source discovery and a spatial corner picker ([dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38))
|
||||||
|
|
||||||
##### Aggregated snapshot endpoint
|
##### Region-of-interest (ROI) screen capture
|
||||||
|
- Screen sampling can now be cropped to a **region of interest** instead of the whole display ([ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546))
|
||||||
|
|
||||||
- New `GET /api/v1/snapshot` returns all output targets (with processing state + metrics), devices (with brightness), the source / preset / clock lists, and the system block in a **single response** — collapsing the Home Assistant integration's previous ~2N+M request fan-out into one round trip
|
##### Built-in "look" presets
|
||||||
- `?include=` fetches only a subset of sections, and an excluded section also skips its server-side work (e.g. cold-cache hardware brightness probes or the blocking NVML performance query) ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- One-click looks: **Cinematic / Vivid / Cozy / Soft / Cool** ([e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c))
|
||||||
|
|
||||||
|
##### Weekday + timezone scheduling
|
||||||
|
- The time-of-day automation rule now supports **weekday selection and explicit timezones** ([1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac))
|
||||||
|
|
||||||
|
##### Value sources
|
||||||
|
- New **sandboxed-Jinja template combinator** for composing value sources ([6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9)) and optional normalization for magnitude sources ([669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20))
|
||||||
|
|
||||||
|
##### Visual graph editor
|
||||||
|
- The editor is now a **full wiring control surface** ([2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46)), and you can **duplicate a selected subgraph** server-side ([15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82))
|
||||||
|
|
||||||
|
##### Android on-device capture
|
||||||
|
- **System audio playback capture** ([fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1)), **OS notification capture** via NotificationListenerService ([0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83)), **webcam capture** via Camera2 ([4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6)), and a **foreground-app automation condition** ([1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2))
|
||||||
|
|
||||||
#### Bug Fixes
|
#### Bug Fixes
|
||||||
|
- **Security:** removed an active **weak default API key** from the shipped config — fresh installs no longer ship with a guessable key. Set your own key on first run ([5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5))
|
||||||
- **Graceful shutdown no longer hangs:** uvicorn's graceful-shutdown wait is now bounded (`GRACEFUL_SHUTDOWN_TIMEOUT`, shared by the desktop, Android, and demo launchers). A lingering events WebSocket (which the browser auto-reconnects) used to keep connections from draining, so the lifespan shutdown never ran — leaving LED targets lit and blocking process exit. Ctrl+C / OS shutdown with the UI open now reliably stops targets and checkpoints the DB ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Removed a broken legacy `/system/mqtt/settings` route ([fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201))
|
||||||
- **Device update error codes:** `update_device` no longer masks an intentional 4xx (e.g. an unknown `mqtt_source_id` or failed group validation) as a generic 500 ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
- Scene brightness value-source changes now sync to the live processor immediately ([02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3))
|
||||||
|
- Wizard hardening: scaffolded targets are registered with the ProcessorManager and the final review step is more robust ([6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05))
|
||||||
|
- Installer opens the WebUI only once after "Launch LedGrab" ([05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Development / Internal
|
### Development / Internal
|
||||||
|
|
||||||
#### Backend
|
#### Backend / Storage
|
||||||
|
- `clone()` is now gated behind an **opt-in allowlist**, with expanded duplicate-handling tests ([498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f))
|
||||||
- **Wiring-graph schema engine** (`api/graph_schema.py`): a pure, unit-tested module that is the single source of truth for which reference fields connect which entity kinds; builds the topology and performs dependency lookup plus cycle / dangling-reference detection without booting the app or any store. The route layer only gathers serialized entities and delegates ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
- **Structured access log:** a new middleware emits one structured line per request, attributing it to the authenticated token's friendly label (the key name, **never** the secret) so traffic can be traced to a client (e.g. `homeassistant` vs `android`). uvicorn's own access log is disabled to avoid duplicate lines ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
- Shared `validate_mqtt_source_exists` (`_mqtt_validation.py`) deduplicates the MQTT-source existence check between the device and output-target routes ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
- In-progress dashboard customization groundwork ([6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569))
|
||||||
|
|
||||||
- Service-worker refresh for the new bundle ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
#### Docs
|
||||||
|
- Actualized README + API reference with embedded screenshots ([12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6)), graph-editor wiring-control roadmap ([d505388](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d505388)), Android audio-capture design notes ([4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc)); removed stale ANDROID-REVIEW planning docs ([9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15))
|
||||||
|
|
||||||
#### Tests
|
#### Tests
|
||||||
|
- Large new suites for calibration solver/session (incl. adversarial), setup & scene-playlist routes, playlist engine, and ROI capture. Full suite: **2149 passing, 2 skipped**
|
||||||
- New suites: graph routes + schema engine, snapshot routes, access-log middleware, `mqtt_source_id` device regressions, and the bounded-shutdown entrypoint. Full suite: **1614 passing** ([a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>All Commits (1)</summary>
|
<summary>All Commits (31)</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
| Hash | Message | Author |
|
||||||
| ---- | ------- | ------ |
|
| ---- | ------- | ------ |
|
||||||
| [a5effba](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a5effba) | feat: aggregated snapshot + wiring-graph APIs, MQTT device brokers | alexei.dolgolyov |
|
| [dd43f38](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dd43f38) | fix(calibration-wizard): all-provider discovery + spatial corner picker | alexei.dolgolyov |
|
||||||
|
| [6cd5e05](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6cd5e05) | fix(setup): register scaffolded target with ProcessorManager + final-review hardening | alexei.dolgolyov |
|
||||||
|
| [81b1808](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/81b1808) | feat(onboarding): guided first-run setup wizard (phase 4, final) | alexei.dolgolyov |
|
||||||
|
| [abc204c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/abc204c) | feat(snapshot): include scene playlists + cycling state in snapshot | alexei.dolgolyov |
|
||||||
|
| [9550688](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9550688) | feat(calibration): browser-driven auto edge-calibration UI (phase 3) | alexei.dolgolyov |
|
||||||
|
| [9dcd76d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9dcd76d) | feat(setup): one-call setup scaffold + onboarding flag (phase 2) | alexei.dolgolyov |
|
||||||
|
| [0409cd8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0409cd8) | feat(calibration): auto edge-calibration backend core (phase 1) | alexei.dolgolyov |
|
||||||
|
| [6180569](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6180569) | wip(dashboard): in-progress dashboard customization changes | alexei.dolgolyov |
|
||||||
|
| [f71e10e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f71e10e) | feat(scenes): scene playlists with timed auto-cycling | alexei.dolgolyov |
|
||||||
|
| [ca59546](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ca59546) | feat(capture): region-of-interest (ROI) crop for screen sampling | alexei.dolgolyov |
|
||||||
|
| [1ada5ac](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ada5ac) | feat(automations): weekday + timezone scheduling for time-of-day rule | alexei.dolgolyov |
|
||||||
|
| [e18d56c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e18d56c) | feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool) | alexei.dolgolyov |
|
||||||
|
| [7728aec](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7728aec) | feat(wled): native realtime UDP output (DRGB/DRGBW/DNRGB) with auto-revert | alexei.dolgolyov |
|
||||||
|
| [ffee156](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ffee156) | feat(targets): automatic brightness limiting (ABL) / per-LED power budget | alexei.dolgolyov |
|
||||||
|
| [02e2ea3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/02e2ea3) | fix(scenes): sync brightness value-source change to live processor | alexei.dolgolyov |
|
||||||
|
| [fdc9201](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fdc9201) | fix(api): remove broken legacy /system/mqtt/settings route | alexei.dolgolyov |
|
||||||
|
| [5686ae5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5686ae5) | fix(security): remove active weak default API key from shipped config | alexei.dolgolyov |
|
||||||
|
| [9960f15](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9960f15) | docs(android): remove ANDROID-REVIEW planning/review docs | alexei.dolgolyov |
|
||||||
|
| [1c1bbe2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1c1bbe2) | feat(android): foreground-app automation condition | alexei.dolgolyov |
|
||||||
|
| [4bf3fe6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4bf3fe6) | feat(android): on-device webcam capture via Camera2 (AndroidCameraEngine) | alexei.dolgolyov |
|
||||||
|
| [0be3f83](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0be3f83) | feat(android): on-device OS notification capture (NotificationListenerService) | alexei.dolgolyov |
|
||||||
|
| [4b2e8fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b2e8fc) | docs(android): add audio-capture design + missing-functionality review | alexei.dolgolyov |
|
||||||
|
| [fd62db1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd62db1) | feat(audio): Android on-device system playback capture | alexei.dolgolyov |
|
||||||
|
| [669ae20](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/669ae20) | feat(value-sources): optional normalization for magnitude sources | alexei.dolgolyov |
|
||||||
|
| [6de61b9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6de61b9) | feat(value-sources): add sandboxed-Jinja template combinator | alexei.dolgolyov |
|
||||||
|
| [12b40e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/12b40e6) | docs: actualize README and API reference, embed screenshots | alexei.dolgolyov |
|
||||||
|
| [498854f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/498854f) | refactor(storage): gate clone() behind an opt-in allowlist; expand duplicate tests | alexei.dolgolyov |
|
||||||
|
| [15cfb82](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/15cfb82) | feat(graph): duplicate a selected subgraph server-side | alexei.dolgolyov |
|
||||||
|
| [2e51f46](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2e51f46) | feat(graph): make the visual editor a full wiring control surface | alexei.dolgolyov |
|
||||||
|
| [05cf121](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/05cf121) | fix(installer): open WebUI once after "Launch LedGrab" | alexei.dolgolyov |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -993,3 +993,123 @@ After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen f
|
|||||||
- LOW: Nanoleaf `.port` property added; pair-then-create E2E test
|
- LOW: Nanoleaf `.port` property added; pair-then-create E2E test
|
||||||
added.
|
added.
|
||||||
- Tests: 1379 pass (+21 regression tests).
|
- Tests: 1379 pass (+21 regression tests).
|
||||||
|
|
||||||
|
## Graph editor — "full control of wiring via graph" (in progress)
|
||||||
|
|
||||||
|
Goal: make the visual graph a first-class wiring control surface, not just a
|
||||||
|
viewer. Driven by the ULTRA-DEEP review (findings A1–A5, B1–B6, C1–C6, D1–D6).
|
||||||
|
|
||||||
|
### Done (NOT yet committed — awaiting review/commit)
|
||||||
|
|
||||||
|
- [x] **A1** Undo/redo wired to connect/detach/move (was dead code); inverse ops
|
||||||
|
throw on failure so the stack can't silently desync.
|
||||||
|
- [x] **A2** Manual node layout persists to `localStorage` (`graph_node_positions`),
|
||||||
|
cleared on relayout.
|
||||||
|
- [x] **A3** Scene-preset disambiguation — deactivation scene now reachable via a
|
||||||
|
field picker (was always picking the first match).
|
||||||
|
- [x] **B6** Edge field labels (revealed on zoom ≥ 0.9).
|
||||||
|
- [x] **C3** Health overlay — broken refs (referrer exists, target missing),
|
||||||
|
dependency cycles, orphans; node warning badges + an issues toolbar button.
|
||||||
|
- [x] **D1** `GET /api/v1/graph/schema` — authoritative connectable-field registry
|
||||||
|
(`api/graph_schema.py`, pure + unit-tested).
|
||||||
|
- [x] **D2** `GET /api/v1/graph` (nodes+edges+validation) and
|
||||||
|
`GET /api/v1/graph/dependents/{kind}/{id}`.
|
||||||
|
- [x] **D4** `POST /api/v1/graph/validate-connection` — existence + source-kind +
|
||||||
|
cycle pre-flight; frontend validates before every write (fails open if the
|
||||||
|
endpoint is unreachable). List/double-nested fields rejected.
|
||||||
|
- [x] **B2** Drop-on-node connect — empty top-level slots are now wireable (drop a
|
||||||
|
source onto any compatible node body, not just an existing port).
|
||||||
|
- [x] **C4** Overwrite-occupied-slot confirm + delete-with-dependents warning
|
||||||
|
(single delete only; bulk keeps the batch confirm).
|
||||||
|
- [x] **D5** Create-and-connect — drag a port onto empty canvas → pick a compatible
|
||||||
|
new entity kind → it's created and auto-wired (kind-scoped watcher).
|
||||||
|
- [x] **D6 (read-only half)** "Export graph (JSON)" toolbar action.
|
||||||
|
- [x] Custom per-entity `icon` + `icon_color` now render on graph nodes (parity
|
||||||
|
with custom node colours; fallback to kind/subtype glyph).
|
||||||
|
- [x] **B1** Edit single-level **BindableFloat** value slots from the graph
|
||||||
|
(`brightness`, `smoothing`, `intensity`, `scale`, `speed`, … on
|
||||||
|
color_strip_source; `brightness`/`transition` on output_target). Subtype-safe
|
||||||
|
(only offers slots the target entity actually has). Writes the partial
|
||||||
|
`{ <slot>: { source_id } }` payload → backend `Bindable*.apply_update` merges,
|
||||||
|
preserving the static value. Verified data-safe (no `from_raw`/value-reset path).
|
||||||
|
- [x] Render the two functional value-source references `buildGraph` was missing —
|
||||||
|
`value_source.value_source_id` (gradient_map → inner value source) and
|
||||||
|
`value_source.color_strip_source_id` (css_extract → strip). Both are runtime-
|
||||||
|
resolved and already drag-editable; now visible/detachable in the graph.
|
||||||
|
- [x] **B4 foundation:** backend schema now authoritative about graph-editability
|
||||||
|
(`is_editable()` + `editable` flag in `/graph/schema`); `validate-connection`
|
||||||
|
hardened to reject non-editable fields (colour/list/double-nested), not just lists.
|
||||||
|
- [x] **B4 drift guard + gap fixes:** `checkSchemaDrift()` (graph-connections.ts) warns
|
||||||
|
once if the frontend `CONNECTION_MAP` editable set diverges from `/graph/schema`
|
||||||
|
(the automated "10-step checklist"). Surfacing it found 3 real gaps; fixed 2:
|
||||||
|
`color_strip_source.input_source_id` + `processing_template_id` are now drag-editable
|
||||||
|
(processed-strip wiring; `apply_update` is partial-safe). The 3rd —
|
||||||
|
`device.default_css_processing_template_id` — is intentionally NOT drag-editable
|
||||||
|
(the device PUT route isn't partial-safe; a one-field PUT could null the URL) and is
|
||||||
|
in the drift-check exclude set. Also broadened `_availableMatches` to hide any slot
|
||||||
|
the target entity doesn't expose (subtype-accurate; refs are always-emitted so empty
|
||||||
|
slots stay wireable). Review also caught a **dead `output_target.picture_source_id`
|
||||||
|
slot** (no output target stores it — not a field/schema, never emitted) — removed
|
||||||
|
from both registries + `buildGraph`.
|
||||||
|
- [x] **Comprehensive review pass (4 subagents: backend/frontend-core/orchestrator/security).**
|
||||||
|
Findings fixed:
|
||||||
|
- **CRITICAL (security):** `GET /api/v1/graph` leaked plaintext **webhook tokens**
|
||||||
|
(`asdict` recursed `Automation.rules[].token`, an auth-equivalent secret). Fixed with
|
||||||
|
**field-projection** — `serialize_entity_for_graph()` / `graph_field_roots()` project
|
||||||
|
each entity to only `{id, name, subtype, reference-roots}`; secrets can't survive.
|
||||||
|
Added a structural regression test asserting no projection root is secret-bearing for
|
||||||
|
any kind (drift-proof boundary) + a token-drop test.
|
||||||
|
- MEDIUM: added missing `value_source.clock_id` (AnimatedColorValueSource → sync_clock)
|
||||||
|
to the backend registry for topology/dependents completeness (drift-excluded on the
|
||||||
|
frontend — value-source PUT needs a `source_type` discriminator, so it's editor-only).
|
||||||
|
- MEDIUM/LOW: `CSS.escape` on the markIssues id selector; grouped/clarified
|
||||||
|
`_DRIFT_EXCLUDE`; fixed the stale `_availableMatches` JSDoc; documented the
|
||||||
|
`checkSchemaDrift` forward-reference. Orchestrator + frontend-core + security: APPROVE.
|
||||||
|
- Verification: `npm --prefix server run typecheck` + `run build` clean; ruff clean;
|
||||||
|
graph backend tests 35 pass; full backend suite green. ~8 code-review passes,
|
||||||
|
all CRITICAL/HIGH findings fixed.
|
||||||
|
|
||||||
|
### Left to do (deferred)
|
||||||
|
|
||||||
|
- [x] **BindableColor slots** — CHECKED, decision: keep read-only (won't fix).
|
||||||
|
Value sources are scalar-only (`ValueStream.get_value() -> float`) and every
|
||||||
|
colour consumer (`color_strip/single.py`, `effect_stream.py`) reads the static
|
||||||
|
RGB via `bcolor()`, ignoring `source_id`. So a value_source cannot drive a
|
||||||
|
colour — wiring `color`/`color_peak`/… would be a dead binding. Documented in
|
||||||
|
`api/graph_schema.py` next to the BindableColor entries. (Would only become
|
||||||
|
viable if a colour-producing value-source type is added.)
|
||||||
|
- [~] **B4 — delete the frontend `CONNECTION_MAP` duplication.**
|
||||||
|
- [x] **Foundation done:** the backend schema now carries an authoritative
|
||||||
|
`editable` flag per field (`is_editable()` in `api/graph_schema.py`, mirroring
|
||||||
|
the frontend `_isEditable`: top-level refs + single-level BindableFloat slots;
|
||||||
|
NOT colour/list/double-nested). `validate-connection` is hardened to reject any
|
||||||
|
non-editable field (was list-only). `editable` is surfaced in `/graph/schema`.
|
||||||
|
- [ ] **Remaining (the refactor):** frontend fetches `/graph/schema` on load and
|
||||||
|
derives connection metadata + edges from it (port the `extract_refs` dot-path/list
|
||||||
|
grammar to TS), keeping only a tiny `kind → {endpoint, cache}` write-routing table;
|
||||||
|
then delete the field-level `CONNECTION_MAP` + the `buildGraph` edge loops
|
||||||
|
(graph-connections.ts / graph-layout.ts). Removes the 10-step sync checklist in
|
||||||
|
`contexts/graph-editor.md`. **A backend apply-write endpoint is NOT required** —
|
||||||
|
keep the proven per-entity PUT. Risk: regressing drag-connect/bindable; keep a
|
||||||
|
dev drift-check (frontend editable set vs `/graph/schema`) during the transition.
|
||||||
|
Note: frontend `CONNECTION_MAP` also has inert `ha_source_id`/`gradient_id` entries
|
||||||
|
(no graph node kind) — drop them, the backend schema already omits them.
|
||||||
|
- [ ] **D6 — blueprint import/instantiate.** Export exists; the apply half (serialize
|
||||||
|
a selected subgraph's topology + entities, re-import with id remapping, conflict
|
||||||
|
handling) is large and data-integrity-sensitive (see Data Migration Policy in
|
||||||
|
CLAUDE.md). Scope as its own feature.
|
||||||
|
- [ ] **List-slot editing** (composite `layers[]`, mapped `zones[]`, scene preset
|
||||||
|
`targets[]`) — needs an element index in the write + validate paths
|
||||||
|
(`validate_connection` currently rejects list fields). Edit via entity modal
|
||||||
|
for now.
|
||||||
|
|
||||||
|
### Notes / decisions
|
||||||
|
|
||||||
|
- The backend `CONNECTION_SCHEMA` (`api/graph_schema.py`) is the authoritative
|
||||||
|
superset; it already declares the bindable + list + value_source-chain edges. The
|
||||||
|
frontend `CONNECTION_MAP` still owns write-routing (endpoint/cache) — that's the
|
||||||
|
only reason it survives (see B4).
|
||||||
|
- Bindable edges render dashed (`.graph-edge-nested`) but ARE editable — the dashed
|
||||||
|
style intentionally distinguishes value bindings from structural edges.
|
||||||
|
- `validate-connection` and `dependents` fail **open/safe** on the frontend so the
|
||||||
|
graph keeps working against an older server without these endpoints.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ android {
|
|||||||
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
|
||||||
// sideload updates silently refused to install.
|
// sideload updates silently refused to install.
|
||||||
versionCode = ledgrabVersionCode
|
versionCode = ledgrabVersionCode
|
||||||
versionName = "0.8.1"
|
versionName = "0.8.2"
|
||||||
|
|
||||||
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
// ABI selection. Detect armeabi-v7a wheel presence and opt the
|
||||||
// ABI in only when the matching pydantic-core wheel is on disk —
|
// ABI in only when the matching pydantic-core wheel is on disk —
|
||||||
|
|||||||
@@ -35,10 +35,48 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<!-- FOREGROUND_SERVICE_CAMERA (API 34+): required to keep camera access while
|
||||||
|
the app is backgrounded during on-device webcam capture. The service is
|
||||||
|
promoted with the `camera` FGS type ONLY when CAMERA is already granted
|
||||||
|
(see CaptureService.onStartCommand) — unlike audio playback capture (which
|
||||||
|
rides the MediaProjection token under the mediaProjection type), the camera
|
||||||
|
has no such coupling and needs its own FGS type to survive backgrounding. -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||||
|
|
||||||
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
|
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- RECORD_AUDIO for on-device system-playback capture (AudioPlaybackCapture,
|
||||||
|
API 29+) feeding audio-reactive lighting. Runtime "dangerous" permission,
|
||||||
|
requested in MainActivity; capture degrades gracefully when denied.
|
||||||
|
Playback capture runs under the existing mediaProjection FGS type, so no
|
||||||
|
FOREGROUND_SERVICE_MICROPHONE / microphone FGS type is needed (that would
|
||||||
|
only be required if the mic-fallback path ran inside the service). -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<!-- CAMERA for on-device webcam capture (Camera2). Runtime "dangerous"
|
||||||
|
permission, requested in MainActivity gated on FEATURE_CAMERA_ANY so
|
||||||
|
camera-less TV boxes never see the prompt; capture degrades gracefully
|
||||||
|
when denied. The camera is opened ON DEMAND (only while a camera
|
||||||
|
capture source is active). To keep capturing after the app is
|
||||||
|
backgrounded, the service is promoted with the `camera` FGS type
|
||||||
|
(FOREGROUND_SERVICE_CAMERA above) — but only when CAMERA is already
|
||||||
|
granted, so a camera-less / not-yet-granted box never risks a failed
|
||||||
|
service start. -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<!-- PACKAGE_USAGE_STATS — read the foreground app for the "Application"
|
||||||
|
automation rule (foreground app -> activate scene) via UsageStatsManager.
|
||||||
|
A special-access permission: it can't be granted at runtime; the user
|
||||||
|
toggles it under Settings > Usage access (opened from MainActivity).
|
||||||
|
tools:ignore="ProtectedPermissions" silences the build warning that this
|
||||||
|
is a system/signature-level permission — it is honoured as a user-grantable
|
||||||
|
special access. NO QUERY_ALL_PACKAGES is needed: matching only compares the
|
||||||
|
foreground package NAME, and the app picker uses LauncherApps. -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
|
||||||
<!-- Autostart on boot — BootReceiver spawns CaptureService in root
|
<!-- Autostart on boot — BootReceiver spawns CaptureService in root
|
||||||
mode so capture resumes without the user touching the remote. -->
|
mode so capture resumes without the user touching the remote. -->
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
@@ -63,6 +101,15 @@
|
|||||||
android:name="android.hardware.usb.host"
|
android:name="android.hardware.usb.host"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|
||||||
|
<!-- Camera hardware — for on-device webcam capture. required=false so
|
||||||
|
camera-less TV boxes (the common case) still install; the camera
|
||||||
|
engine simply reports no displays on such devices. camera.any covers
|
||||||
|
built-in (front/back) and external/USB-UVC cameras the platform
|
||||||
|
routes through Camera2. -->
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.camera.any"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".LedGrabApp"
|
android:name=".LedGrabApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@@ -95,13 +142,30 @@
|
|||||||
PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale below. -->
|
PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale below. -->
|
||||||
<service
|
<service
|
||||||
android:name=".CaptureService"
|
android:name=".CaptureService"
|
||||||
android:foregroundServiceType="mediaProjection|specialUse"
|
android:foregroundServiceType="mediaProjection|specialUse|camera"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<property
|
<property
|
||||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
android:value="Root-mode screen capture for ambient LED sync. Uses /system/bin/screenrecord on rooted devices to avoid MediaProjection's persistent capture indicator overlay, which is required for the always-on ambient-lighting use case." />
|
android:value="Root-mode screen capture for ambient LED sync. Uses /system/bin/screenrecord on rooted devices to avoid MediaProjection's persistent capture indicator overlay, which is required for the always-on ambient-lighting use case." />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<!-- Notification capture — a NotificationListenerService bound by
|
||||||
|
system_server. exported="true" is REQUIRED here (the system binds
|
||||||
|
it cross-process) and intentionally diverges from CaptureService
|
||||||
|
(exported="false"); access is gated by the system-held
|
||||||
|
BIND_NOTIFICATION_LISTENER_SERVICE permission, so no new
|
||||||
|
<uses-permission> is needed. The user grants access via
|
||||||
|
Settings > Notification access (opened from MainActivity). -->
|
||||||
|
<service
|
||||||
|
android:name=".LedGrabNotificationListener"
|
||||||
|
android:label="@string/notification_listener_label"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.notification.NotificationListenerService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<!-- Autostart — fires on device boot (and package replace).
|
<!-- Autostart — fires on device boot (and package replace).
|
||||||
On rooted devices, launches CaptureService directly so capture
|
On rooted devices, launches CaptureService directly so capture
|
||||||
resumes without the user tapping Start. Unrooted devices are
|
resumes without the user tapping Start. Unrooted devices are
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.ledgrab.android
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioPlaybackCaptureConfiguration
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.media.projection.MediaProjection
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures audio with [AudioRecord] and pushes interleaved float32 PCM to
|
||||||
|
* the LedGrab Python server via [PythonBridge], where the
|
||||||
|
* `android_audio_engine` feeds it into the unchanged audio-analysis
|
||||||
|
* pipeline.
|
||||||
|
*
|
||||||
|
* Two sources:
|
||||||
|
* - [start] — system playback capture via `AudioPlaybackCapture` (API 29+),
|
||||||
|
* reusing the same [MediaProjection] token the app already holds for
|
||||||
|
* screen capture. This is the primary path on the consent flow.
|
||||||
|
* - [startMic] — microphone fallback (`AudioSource.MIC`) for paths with no
|
||||||
|
* MediaProjection (root mode) or API < 29.
|
||||||
|
*
|
||||||
|
* Mirrors [ScreenCapture]'s shape: a dedicated capture thread, a single
|
||||||
|
* reusable cross-JNI buffer (no per-block allocation → no GC churn on
|
||||||
|
* low-end TV boxes), and graceful teardown in [stop].
|
||||||
|
*
|
||||||
|
* The capture format is negotiated by [AudioRecord]; the **actual**
|
||||||
|
* channel count and sample rate are read back and forwarded to
|
||||||
|
* `configureAudio` so the Python analyzer's interleaving matches the bytes
|
||||||
|
* we push (e.g. a stereo request that the device satisfies as mono).
|
||||||
|
*/
|
||||||
|
class AudioCapture(
|
||||||
|
private val projection: MediaProjection?,
|
||||||
|
private val bridge: PythonBridge,
|
||||||
|
private val sampleRate: Int = 48000,
|
||||||
|
private val channels: Int = 2,
|
||||||
|
private val chunkFrames: Int = 1024,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AudioCapture"
|
||||||
|
private const val BYTES_PER_FLOAT = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
private var audioRecord: AudioRecord? = null
|
||||||
|
private var captureThread: Thread? = null
|
||||||
|
@Volatile private var running = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start system playback capture (API 29+). Requires the app to hold
|
||||||
|
* RECORD_AUDIO and a valid [projection]. Returns true if capture began.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun start(): Boolean {
|
||||||
|
if (running) return true
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
Log.i(TAG, "Playback capture needs API 29+; skipping (have ${Build.VERSION.SDK_INT})")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val proj = projection
|
||||||
|
if (proj == null) {
|
||||||
|
Log.i(TAG, "No MediaProjection; playback capture unavailable")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = AudioPlaybackCaptureConfiguration.Builder(proj)
|
||||||
|
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||||
|
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val record = try {
|
||||||
|
AudioRecord.Builder()
|
||||||
|
.setAudioFormat(audioFormat())
|
||||||
|
.setBufferSizeInBytes(bufferBytes())
|
||||||
|
.setAudioPlaybackCaptureConfig(config)
|
||||||
|
.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to build playback AudioRecord: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return begin(record, "playback")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start microphone capture (fallback). Works on API 24+ and needs no
|
||||||
|
* MediaProjection. Requires RECORD_AUDIO. Returns true if capture began.
|
||||||
|
*
|
||||||
|
* ⚠️ SECURITY/POLICY: currently UNWIRED (no caller). Microphone capture is
|
||||||
|
* a materially different posture than playback capture — it records real
|
||||||
|
* room audio (bystander voices). Before wiring this into [CaptureService]:
|
||||||
|
* - add FOREGROUND_SERVICE_MICROPHONE permission + the `microphone` FGS
|
||||||
|
* type (on API 34+ the service is killed without it), and
|
||||||
|
* - add the Play Store privacy disclosure for microphone use,
|
||||||
|
* - re-trigger a security review.
|
||||||
|
* Do NOT call this from inside the foreground service without the above.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun startMic(): Boolean {
|
||||||
|
if (running) return true
|
||||||
|
val record = try {
|
||||||
|
AudioRecord.Builder()
|
||||||
|
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
.setAudioFormat(audioFormat())
|
||||||
|
.setBufferSizeInBytes(bufferBytes())
|
||||||
|
.build()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to build mic AudioRecord: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return begin(record, "mic")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop capturing and release all resources. Idempotent. */
|
||||||
|
fun stop() {
|
||||||
|
running = false
|
||||||
|
// AudioRecord.stop() unblocks a pending READ_BLOCKING read within
|
||||||
|
// milliseconds, so the loop sees running=false and returns well inside
|
||||||
|
// the 500ms join window — release() below won't race a live read.
|
||||||
|
// (Mirrors ScreenCapture's bounded join.)
|
||||||
|
runCatching { audioRecord?.stop() }
|
||||||
|
captureThread?.let { runCatching { it.join(500) } }
|
||||||
|
captureThread = null
|
||||||
|
runCatching { audioRecord?.release() }
|
||||||
|
audioRecord = null
|
||||||
|
runCatching { bridge.shutdownAudio() }
|
||||||
|
Log.i(TAG, "Audio capture stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun begin(record: AudioRecord, mode: String): Boolean {
|
||||||
|
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
Log.e(TAG, "AudioRecord ($mode) failed to initialize")
|
||||||
|
runCatching { record.release() }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val actualChannels = record.channelCount.coerceAtLeast(1)
|
||||||
|
val actualRate = record.sampleRate
|
||||||
|
|
||||||
|
// Confirm recording actually started before reporting success —
|
||||||
|
// startRecording() can throw (exclusive-capture contention) or
|
||||||
|
// leave the record in a non-recording state, in which case read()
|
||||||
|
// would only ever return errors.
|
||||||
|
val started = runCatching { record.startRecording() }.isSuccess &&
|
||||||
|
record.recordingState == AudioRecord.RECORDSTATE_RECORDING
|
||||||
|
if (!started) {
|
||||||
|
Log.e(TAG, "AudioRecord ($mode) failed to start recording")
|
||||||
|
runCatching { record.release() }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recording confirmed — tell Python the real negotiated format
|
||||||
|
// before frames flow, so the analyzer's channel/sample-rate match
|
||||||
|
// the interleaving we push.
|
||||||
|
bridge.configureAudio(actualRate, actualChannels, chunkFrames)
|
||||||
|
|
||||||
|
audioRecord = record
|
||||||
|
running = true
|
||||||
|
captureThread = Thread(
|
||||||
|
{ captureLoop(record, actualChannels) },
|
||||||
|
"LedGrab-AudioCapture",
|
||||||
|
).also { it.start() }
|
||||||
|
Log.i(TAG, "Audio capture started ($mode, sr=$actualRate ch=$actualChannels chunk=$chunkFrames)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking read loop. Accumulates into fixed `chunkFrames * channels`
|
||||||
|
* float blocks and pushes only COMPLETE blocks — [AudioRecord.read]
|
||||||
|
* returns a variable count, so partial reads are stitched here rather
|
||||||
|
* than handed to Python as ragged chunks (the analyzer requires
|
||||||
|
* whole-frame, ≤ chunk-size blocks).
|
||||||
|
*/
|
||||||
|
private fun captureLoop(record: AudioRecord, actualChannels: Int) {
|
||||||
|
val blockFloats = chunkFrames * actualChannels
|
||||||
|
val floatBuf = FloatArray(blockFloats)
|
||||||
|
// Reusable little-endian byte buffer — Python copies on push, so the
|
||||||
|
// same backing array is safe to overwrite next block. Default
|
||||||
|
// ByteBuffer order is BIG_ENDIAN, which would corrupt every sample;
|
||||||
|
// LITTLE_ENDIAN matches numpy's native float32 on all Android ABIs.
|
||||||
|
val byteBuf = ByteArray(blockFloats * BYTES_PER_FLOAT)
|
||||||
|
val floatView = ByteBuffer.wrap(byteBuf).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
|
|
||||||
|
var filled = 0
|
||||||
|
while (running) {
|
||||||
|
val n = record.read(floatBuf, filled, blockFloats - filled, AudioRecord.READ_BLOCKING)
|
||||||
|
if (n < 0) {
|
||||||
|
if (running) {
|
||||||
|
// A negative read (e.g. ERROR_DEAD_OBJECT after an audio-route
|
||||||
|
// change, ERROR_INVALID_OPERATION) means this AudioRecord is
|
||||||
|
// finished. Deactivate the Python engine so is_available() stops
|
||||||
|
// advertising a dead stream and the audio-reactive consumer isn't
|
||||||
|
// left polling an empty queue forever. We're on the capture thread,
|
||||||
|
// so we can't call stop() (it would self-join) — just flip running
|
||||||
|
// and shut the engine down; onDestroy's stop() releases the record.
|
||||||
|
Log.w(TAG, "AudioRecord.read error: $n — stopping audio capture")
|
||||||
|
running = false
|
||||||
|
runCatching { bridge.shutdownAudio() }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
filled += n
|
||||||
|
if (filled < blockFloats) continue
|
||||||
|
|
||||||
|
floatView.clear()
|
||||||
|
floatView.put(floatBuf, 0, blockFloats)
|
||||||
|
bridge.pushAudio(byteBuf)
|
||||||
|
filled = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun channelMask(): Int =
|
||||||
|
if (channels >= 2) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO
|
||||||
|
|
||||||
|
private fun audioFormat(): AudioFormat =
|
||||||
|
AudioFormat.Builder()
|
||||||
|
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
|
||||||
|
.setSampleRate(sampleRate)
|
||||||
|
.setChannelMask(channelMask())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun bufferBytes(): Int {
|
||||||
|
val minBuf = AudioRecord.getMinBufferSize(sampleRate, channelMask(), AudioFormat.ENCODING_PCM_FLOAT)
|
||||||
|
// A few blocks of headroom so a slow consumer doesn't overrun the
|
||||||
|
// hardware buffer between reads.
|
||||||
|
val want = chunkFrames * channels * BYTES_PER_FLOAT * 4
|
||||||
|
return if (minBuf > 0) maxOf(minBuf, want) else want
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
package com.ledgrab.android
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.ImageFormat
|
||||||
|
import android.hardware.camera2.CameraCaptureSession
|
||||||
|
import android.hardware.camera2.CameraCharacteristics
|
||||||
|
import android.hardware.camera2.CameraDevice
|
||||||
|
import android.hardware.camera2.CameraManager
|
||||||
|
import android.media.Image
|
||||||
|
import android.media.ImageReader
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import android.util.Size
|
||||||
|
import android.view.Surface
|
||||||
|
import com.chaquo.python.PyObject
|
||||||
|
import com.chaquo.python.Python
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android camera bridge exposed to the Python server via Chaquopy.
|
||||||
|
*
|
||||||
|
* Wraps the Camera2 API into synchronous, blocking calls that can be
|
||||||
|
* invoked from a Python thread (Chaquopy proxy threads are real OS
|
||||||
|
* threads). The physical camera is opened **on demand** — Python's
|
||||||
|
* `android_camera_engine` calls [startCamera] when a capture stream
|
||||||
|
* initializes and [stopCamera] when it cleans up, so the camera-in-use
|
||||||
|
* indicator and battery cost are limited to actual use.
|
||||||
|
*
|
||||||
|
* Each captured frame is converted YUV_420_888 → RGB and pushed to the
|
||||||
|
* Python engine's `push_frame`, mirroring how [ScreenCapture] feeds
|
||||||
|
* `mediaprojection_engine`. Camera2 callbacks run on a private
|
||||||
|
* [HandlerThread] so they never touch the main looper.
|
||||||
|
*
|
||||||
|
* Python callers access the singleton via
|
||||||
|
* `jclass("com.ledgrab.android.CameraBridge").INSTANCE` — see
|
||||||
|
* `server/src/ledgrab/core/capture_engines/android_camera_engine.py`.
|
||||||
|
*/
|
||||||
|
object CameraBridge {
|
||||||
|
private const val TAG = "CameraBridge"
|
||||||
|
private const val ENGINE_MODULE = "ledgrab.core.capture_engines.android_camera_engine"
|
||||||
|
private const val OPEN_TIMEOUT_MS = 8_000L
|
||||||
|
private const val MAX_IMAGES = 2
|
||||||
|
private const val TARGET_FPS = 20
|
||||||
|
// "auto" capture size — balanced for ambient LED sampling (the LED
|
||||||
|
// pipeline downscales anyway), kept modest so the per-frame YUV→RGB
|
||||||
|
// conversion stays cheap on low-end TV boxes.
|
||||||
|
private const val DEFAULT_W = 1280
|
||||||
|
private const val DEFAULT_H = 720
|
||||||
|
private const val BYTES_PER_RGB = 3
|
||||||
|
|
||||||
|
@Volatile private var appContext: Context? = null
|
||||||
|
|
||||||
|
// Dedicated looper thread so Camera2 callbacks don't land on main.
|
||||||
|
private val camThread = HandlerThread("LedGrab-Camera").also { it.start() }
|
||||||
|
private val camHandler = Handler(camThread.looper)
|
||||||
|
|
||||||
|
// Active session state — guarded by [lock]. One camera at a time.
|
||||||
|
private val lock = Any()
|
||||||
|
private var cameraDevice: CameraDevice? = null
|
||||||
|
private var captureSession: CameraCaptureSession? = null
|
||||||
|
private var imageReader: ImageReader? = null
|
||||||
|
@Volatile private var running = false
|
||||||
|
private var activeIndex = -1
|
||||||
|
|
||||||
|
// Cached Python engine module handle for the per-frame push fast path.
|
||||||
|
@Volatile private var engineModule: PyObject? = null
|
||||||
|
|
||||||
|
// Reusable conversion buffers — sized once per session (output size is
|
||||||
|
// fixed for the session), reused to avoid per-frame GC churn on TV boxes.
|
||||||
|
private var rgbBuffer: ByteArray? = null
|
||||||
|
private var yBuf: ByteArray? = null
|
||||||
|
private var uBuf: ByteArray? = null
|
||||||
|
private var vBuf: ByteArray? = null
|
||||||
|
|
||||||
|
// Monotonic frame pacing (mirrors ScreenCapture's accumulator).
|
||||||
|
private val frameIntervalNanos = 1_000_000_000L / TARGET_FPS.coerceAtLeast(1)
|
||||||
|
private var nextFrameNanos = 0L
|
||||||
|
|
||||||
|
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
|
||||||
|
@JvmStatic
|
||||||
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate cameras as a JSON array string the Python engine parses:
|
||||||
|
* `[{"index":0,"name":"Back camera","facing":"back","cameraId":"0"}, ...]`
|
||||||
|
*
|
||||||
|
* Indices are stable (positional in [CameraManager.cameraIdList]) so
|
||||||
|
* Python's `display_index` maps 1:1 to [startCamera]'s `index`.
|
||||||
|
* Enumeration needs no CAMERA permission. Returns `[]` on any error.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun listCameras(): String {
|
||||||
|
val arr = JSONArray()
|
||||||
|
val ctx = appContext
|
||||||
|
if (ctx == null) {
|
||||||
|
Log.w(TAG, "listCameras: context not bound (init not called)")
|
||||||
|
return arr.toString()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val mgr = ctx.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||||
|
mgr.cameraIdList.forEachIndexed { idx, id ->
|
||||||
|
val facing = facingOf(mgr, id)
|
||||||
|
val name = when (facing) {
|
||||||
|
"front" -> "Front camera"
|
||||||
|
"back" -> "Back camera"
|
||||||
|
"external" -> "External camera $idx"
|
||||||
|
else -> "Camera $idx"
|
||||||
|
}
|
||||||
|
arr.put(
|
||||||
|
JSONObject()
|
||||||
|
.put("index", idx)
|
||||||
|
.put("name", name)
|
||||||
|
.put("facing", facing)
|
||||||
|
.put("cameraId", id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "listCameras failed: ${e.message}")
|
||||||
|
}
|
||||||
|
return arr.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open camera [index] and start streaming RGB frames to Python.
|
||||||
|
* Blocks until the capture session is configured (or fails/times out).
|
||||||
|
*
|
||||||
|
* Returns false — without throwing across the JNI boundary — when the
|
||||||
|
* CAMERA permission is missing, the index is out of range, or the
|
||||||
|
* device/session fails to configure. Closes any previously-open camera
|
||||||
|
* first (one active at a time).
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
@JvmStatic
|
||||||
|
fun startCamera(index: Int, width: Int, height: Int): Boolean {
|
||||||
|
synchronized(lock) {
|
||||||
|
closeLocked()
|
||||||
|
|
||||||
|
val ctx = appContext ?: run {
|
||||||
|
Log.w(TAG, "startCamera: context not bound")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (ctx.checkSelfPermission(Manifest.permission.CAMERA)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "startCamera: CAMERA permission not granted")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val mgr = ctx.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||||
|
val ids = try {
|
||||||
|
mgr.cameraIdList
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "startCamera: cameraIdList failed: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (index < 0 || index >= ids.size) {
|
||||||
|
Log.w(TAG, "startCamera: index $index out of range (${ids.size} cameras)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val cameraId = ids[index]
|
||||||
|
val size = chooseSize(mgr, cameraId, width, height) ?: run {
|
||||||
|
Log.w(TAG, "startCamera: no YUV output sizes for camera $index")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val reader = ImageReader.newInstance(
|
||||||
|
size.width, size.height, ImageFormat.YUV_420_888, MAX_IMAGES,
|
||||||
|
)
|
||||||
|
// Size the conversion buffers once for this session.
|
||||||
|
rgbBuffer = ByteArray(size.width * size.height * BYTES_PER_RGB)
|
||||||
|
yBuf = null; uBuf = null; vBuf = null
|
||||||
|
nextFrameNanos = SystemClock.elapsedRealtimeNanos()
|
||||||
|
reader.setOnImageAvailableListener({ r -> onFrame(r) }, camHandler)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(OPEN_TIMEOUT_MS) {
|
||||||
|
// Publish each resource to its field as soon as it exists so
|
||||||
|
// closeLocked() (in the catch) can release it if a LATER step
|
||||||
|
// throws. Assigning only after setRepeatingRequest succeeds
|
||||||
|
// would orphan the opened CameraDevice on a createSession /
|
||||||
|
// setRepeatingRequest failure (camera stuck on; subsequent
|
||||||
|
// opens fail with CAMERA_IN_USE).
|
||||||
|
imageReader = reader
|
||||||
|
val device = openCamera(mgr, cameraId)
|
||||||
|
cameraDevice = device
|
||||||
|
val session = createSession(device, reader.surface)
|
||||||
|
captureSession = session
|
||||||
|
val request = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
|
||||||
|
.apply { addTarget(reader.surface) }
|
||||||
|
.build()
|
||||||
|
session.setRepeatingRequest(request, null, camHandler)
|
||||||
|
activeIndex = index
|
||||||
|
running = true
|
||||||
|
Log.i(TAG, "Camera $index opened (${size.width}x${size.height} @ ${TARGET_FPS}fps)")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "startCamera($index) failed: ${e.message}")
|
||||||
|
// imageReader/cameraDevice/captureSession are now whatever got
|
||||||
|
// assigned before the failure — closeLocked releases each exactly
|
||||||
|
// once (idempotent, runCatching-wrapped).
|
||||||
|
closeLocked()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop streaming and release the camera. Idempotent; safe if not started. */
|
||||||
|
@JvmStatic
|
||||||
|
fun stopCamera() {
|
||||||
|
synchronized(lock) { closeLocked() }
|
||||||
|
Log.i(TAG, "Camera stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun facingOf(mgr: CameraManager, id: String): String =
|
||||||
|
when (mgr.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING)) {
|
||||||
|
CameraCharacteristics.LENS_FACING_FRONT -> "front"
|
||||||
|
CameraCharacteristics.LENS_FACING_BACK -> "back"
|
||||||
|
CameraCharacteristics.LENS_FACING_EXTERNAL -> "external"
|
||||||
|
else -> "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the supported YUV size closest in area to the request (or the
|
||||||
|
* balanced default for `auto`/0). */
|
||||||
|
private fun chooseSize(mgr: CameraManager, cameraId: String, reqW: Int, reqH: Int): Size? {
|
||||||
|
val map = mgr.getCameraCharacteristics(cameraId)
|
||||||
|
.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) ?: return null
|
||||||
|
val sizes = map.getOutputSizes(ImageFormat.YUV_420_888)
|
||||||
|
if (sizes == null || sizes.isEmpty()) return null
|
||||||
|
val targetArea = (if (reqW > 0) reqW else DEFAULT_W).toLong() *
|
||||||
|
(if (reqH > 0) reqH else DEFAULT_H)
|
||||||
|
return sizes.minByOrNull { kotlin.math.abs(it.width.toLong() * it.height - targetArea) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private suspend fun openCamera(mgr: CameraManager, cameraId: String): CameraDevice =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
mgr.openCamera(cameraId, object : CameraDevice.StateCallback() {
|
||||||
|
override fun onOpened(device: CameraDevice) {
|
||||||
|
if (cont.isActive) cont.resume(device) else device.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisconnected(device: CameraDevice) {
|
||||||
|
device.close()
|
||||||
|
if (cont.isActive) cont.resumeWithException(IllegalStateException("camera disconnected"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(device: CameraDevice, error: Int) {
|
||||||
|
device.close()
|
||||||
|
if (cont.isActive) cont.resumeWithException(IllegalStateException("camera error $error"))
|
||||||
|
}
|
||||||
|
}, camHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private suspend fun createSession(device: CameraDevice, surface: Surface): CameraCaptureSession =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
// createCaptureSession(List, callback, handler) is deprecated at
|
||||||
|
// API 30 but is the correct API down to minSdk 24 (the
|
||||||
|
// SessionConfiguration overload is API 28+).
|
||||||
|
device.createCaptureSession(
|
||||||
|
listOf(surface),
|
||||||
|
object : CameraCaptureSession.StateCallback() {
|
||||||
|
override fun onConfigured(session: CameraCaptureSession) {
|
||||||
|
if (cont.isActive) cont.resume(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConfigureFailed(session: CameraCaptureSession) {
|
||||||
|
if (cont.isActive) cont.resumeWithException(IllegalStateException("session configure failed"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
camHandler,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ImageReader callback — paced, converts YUV→RGB, pushes to Python. */
|
||||||
|
private fun onFrame(reader: ImageReader) {
|
||||||
|
if (!running) {
|
||||||
|
runCatching { reader.acquireLatestImage()?.close() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val now = SystemClock.elapsedRealtimeNanos()
|
||||||
|
if (now < nextFrameNanos) {
|
||||||
|
runCatching { reader.acquireLatestImage()?.close() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val image = runCatching { reader.acquireLatestImage() }.getOrNull() ?: return
|
||||||
|
try {
|
||||||
|
val w = image.width
|
||||||
|
val h = image.height
|
||||||
|
val out = ensureRgbBuffer(w * h * BYTES_PER_RGB)
|
||||||
|
yuv420ToRgb(image, out, w, h)
|
||||||
|
pushFrame(out, w, h)
|
||||||
|
nextFrameNanos += frameIntervalNanos
|
||||||
|
if (now - nextFrameNanos > frameIntervalNanos * 4) {
|
||||||
|
nextFrameNanos = now + frameIntervalNanos
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "frame processing error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
runCatching { image.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureRgbBuffer(size: Int): ByteArray {
|
||||||
|
val buf = rgbBuffer
|
||||||
|
if (buf != null && buf.size == size) return buf
|
||||||
|
return ByteArray(size).also { rgbBuffer = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stride-aware YUV_420_888 → packed RGB (3 bytes/px) using BT.601
|
||||||
|
* fixed-point coefficients. Handles both planar and semi-planar
|
||||||
|
* (NV21-like, pixelStride 2) chroma layouts via the plane strides.
|
||||||
|
*/
|
||||||
|
private fun yuv420ToRgb(image: Image, out: ByteArray, width: Int, height: Int) {
|
||||||
|
val planes = image.planes
|
||||||
|
val yPlane = planes[0]
|
||||||
|
val uPlane = planes[1]
|
||||||
|
val vPlane = planes[2]
|
||||||
|
|
||||||
|
val yRowStride = yPlane.rowStride
|
||||||
|
val yPixStride = yPlane.pixelStride
|
||||||
|
val uRowStride = uPlane.rowStride
|
||||||
|
val uPixStride = uPlane.pixelStride
|
||||||
|
val vRowStride = vPlane.rowStride
|
||||||
|
val vPixStride = vPlane.pixelStride
|
||||||
|
|
||||||
|
// Copy each plane to a reusable array for fast indexed access
|
||||||
|
// (ByteBuffer absolute-get per pixel is far slower).
|
||||||
|
val yByteBuf = yPlane.buffer
|
||||||
|
val uByteBuf = uPlane.buffer
|
||||||
|
val vByteBuf = vPlane.buffer
|
||||||
|
val yArr = ensurePlane(yBuf, yByteBuf.remaining()).also { yBuf = it }
|
||||||
|
val uArr = ensurePlane(uBuf, uByteBuf.remaining()).also { uBuf = it }
|
||||||
|
val vArr = ensurePlane(vBuf, vByteBuf.remaining()).also { vBuf = it }
|
||||||
|
yByteBuf.get(yArr, 0, yArr.size)
|
||||||
|
uByteBuf.get(uArr, 0, uArr.size)
|
||||||
|
vByteBuf.get(vArr, 0, vArr.size)
|
||||||
|
|
||||||
|
var o = 0
|
||||||
|
for (row in 0 until height) {
|
||||||
|
val yRowBase = row * yRowStride
|
||||||
|
val uvRow = row shr 1
|
||||||
|
val uRowBase = uvRow * uRowStride
|
||||||
|
val vRowBase = uvRow * vRowStride
|
||||||
|
for (col in 0 until width) {
|
||||||
|
val y = (yArr[yRowBase + col * yPixStride].toInt() and 0xFF)
|
||||||
|
val uvCol = col shr 1
|
||||||
|
val u = (uArr[uRowBase + uvCol * uPixStride].toInt() and 0xFF) - 128
|
||||||
|
val v = (vArr[vRowBase + uvCol * vPixStride].toInt() and 0xFF) - 128
|
||||||
|
// BT.601 full-range, fixed-point (<<16).
|
||||||
|
var r = y + ((91881 * v) shr 16)
|
||||||
|
var g = y - ((22554 * u + 46802 * v) shr 16)
|
||||||
|
var b = y + ((116130 * u) shr 16)
|
||||||
|
if (r < 0) r = 0 else if (r > 255) r = 255
|
||||||
|
if (g < 0) g = 0 else if (g > 255) g = 255
|
||||||
|
if (b < 0) b = 0 else if (b > 255) b = 255
|
||||||
|
out[o++] = r.toByte()
|
||||||
|
out[o++] = g.toByte()
|
||||||
|
out[o++] = b.toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return [cached] if it already fits [n] bytes, else a fresh array. */
|
||||||
|
private fun ensurePlane(cached: ByteArray?, n: Int): ByteArray =
|
||||||
|
if (cached != null && cached.size == n) cached else ByteArray(n)
|
||||||
|
|
||||||
|
private fun pushFrame(rgb: ByteArray, width: Int, height: Int) {
|
||||||
|
val module = engineModule ?: runCatching {
|
||||||
|
Python.getInstance().getModule(ENGINE_MODULE)
|
||||||
|
}.getOrNull()?.also { engineModule = it } ?: return
|
||||||
|
try {
|
||||||
|
module.callAttr("push_frame", rgb, width, height)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "push_frame failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear down the active session. Caller holds [lock]. */
|
||||||
|
private fun closeLocked() {
|
||||||
|
running = false
|
||||||
|
activeIndex = -1
|
||||||
|
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
|
||||||
|
runCatching { captureSession?.stopRepeating() }
|
||||||
|
runCatching { captureSession?.close() }
|
||||||
|
captureSession = null
|
||||||
|
runCatching { cameraDevice?.close() }
|
||||||
|
cameraDevice = null
|
||||||
|
runCatching { imageReader?.close() }
|
||||||
|
imageReader = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import android.app.Notification
|
|||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
|
import android.Manifest
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.media.projection.MediaProjection
|
import android.media.projection.MediaProjection
|
||||||
import android.media.projection.MediaProjectionManager
|
import android.media.projection.MediaProjectionManager
|
||||||
@@ -85,6 +87,7 @@ class CaptureService : Service() {
|
|||||||
private var bridge: PythonBridge? = null
|
private var bridge: PythonBridge? = null
|
||||||
private var screenCapture: ScreenCapture? = null
|
private var screenCapture: ScreenCapture? = null
|
||||||
private var rootCapture: RootScreenrecord? = null
|
private var rootCapture: RootScreenrecord? = null
|
||||||
|
private var audioCapture: AudioCapture? = null
|
||||||
private var mediaProjection: MediaProjection? = null
|
private var mediaProjection: MediaProjection? = null
|
||||||
|
|
||||||
// Service-scoped coroutine scope for the root-capture watchdog.
|
// Service-scoped coroutine scope for the root-capture watchdog.
|
||||||
@@ -110,11 +113,25 @@ class CaptureService : Service() {
|
|||||||
val url = "http://$localIp:$SERVER_PORT"
|
val url = "http://$localIp:$SERVER_PORT"
|
||||||
try {
|
try {
|
||||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
if (useRoot) {
|
var t = if (useRoot) {
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
} else {
|
} else {
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||||
}
|
}
|
||||||
|
// On-demand webcam capture opens the camera from this service.
|
||||||
|
// To retain camera access once the app is backgrounded (the
|
||||||
|
// always-on ambient-lighting case), API 34+ requires the camera
|
||||||
|
// FGS type. Add it ONLY when CAMERA is already granted — promoting
|
||||||
|
// with the camera type without the runtime permission throws and
|
||||||
|
// would kill the whole service on the (common) camera-less or
|
||||||
|
// not-yet-granted box. If CAMERA is granted later, it takes effect
|
||||||
|
// on the next Start (matches the audio/permission UX).
|
||||||
|
if (checkSelfPermission(Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
t = t or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
||||||
|
}
|
||||||
|
t
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
@@ -338,6 +355,25 @@ class CaptureService : Service() {
|
|||||||
onProjectionStopped = { stopSelf() },
|
onProjectionStopped = { stopSelf() },
|
||||||
).also { it.start() }
|
).also { it.start() }
|
||||||
|
|
||||||
|
// Reuse the same projection to capture system playback audio so
|
||||||
|
// audio-reactive lighting works on-device (API 29+, RECORD_AUDIO
|
||||||
|
// granted). Best-effort: screen capture and the server keep running
|
||||||
|
// if audio is unavailable. Started AFTER ScreenCapture so the
|
||||||
|
// projection's callback is already registered.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||||
|
checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
audioCapture = AudioCapture(projection, newBridge).also { ac ->
|
||||||
|
if (!ac.start()) {
|
||||||
|
Log.i(TAG, "Playback audio capture unavailable — continuing without audio")
|
||||||
|
audioCapture = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "RECORD_AUDIO not granted or API < 29 — audio-reactive capture disabled")
|
||||||
|
}
|
||||||
|
|
||||||
Log.i(TAG, "LedGrab service started (MediaProjection) — web UI at $url")
|
Log.i(TAG, "LedGrab service started (MediaProjection) — web UI at $url")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +387,10 @@ class CaptureService : Service() {
|
|||||||
screenCapture?.stop()
|
screenCapture?.stop()
|
||||||
screenCapture = null
|
screenCapture = null
|
||||||
|
|
||||||
|
// Stop audio before the server: stop() calls bridge.shutdownAudio().
|
||||||
|
audioCapture?.stop()
|
||||||
|
audioCapture = null
|
||||||
|
|
||||||
rootCapture?.stop()
|
rootCapture?.stop()
|
||||||
rootCapture = null
|
rootCapture = null
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package com.ledgrab.android
|
||||||
|
|
||||||
|
import android.app.AppOpsManager
|
||||||
|
import android.app.usage.UsageEvents
|
||||||
|
import android.app.usage.UsageStatsManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.LauncherApps
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Process
|
||||||
|
import android.util.Log
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground-app + installed-app bridge exposed to the Python server via Chaquopy.
|
||||||
|
*
|
||||||
|
* Backs the Android implementation of the "Application" automation rule
|
||||||
|
* (foreground app -> activate scene). Desktop detects the foreground process via
|
||||||
|
* Win32 ctypes in ``platform_detector.py``; Android has no such API, so this
|
||||||
|
* bridge wraps two in-platform services into synchronous calls a Python thread
|
||||||
|
* can invoke (Chaquopy proxy threads are real OS threads):
|
||||||
|
*
|
||||||
|
* - [getForegroundPackage] via [UsageStatsManager] (needs PACKAGE_USAGE_STATS,
|
||||||
|
* a special-access permission granted from Settings — see MainActivity).
|
||||||
|
* - [listLaunchableApps] via [LauncherApps] for the automation editor's app
|
||||||
|
* picker (no QUERY_ALL_PACKAGES needed — getActivityList is the sanctioned
|
||||||
|
* launchable-app enumeration API).
|
||||||
|
* - [hasUsageAccess] so the server / UI can detect the missing grant.
|
||||||
|
*
|
||||||
|
* Detection only ever string-compares the foreground *package name*, so no label
|
||||||
|
* resolution / package visibility is required at match time.
|
||||||
|
*
|
||||||
|
* Python callers access the singleton via
|
||||||
|
* `jclass("com.ledgrab.android.ForegroundAppBridge").INSTANCE` — see
|
||||||
|
* `server/src/ledgrab/core/automations/platform_detector.py`.
|
||||||
|
*/
|
||||||
|
object ForegroundAppBridge {
|
||||||
|
private const val TAG = "ForegroundAppBridge"
|
||||||
|
|
||||||
|
// Trailing window for queryEvents. queryEvents reports discrete foreground
|
||||||
|
// transitions (not "current app"), and events can lag a few seconds, so we
|
||||||
|
// look back far enough to reliably catch the latest MOVE_TO_FOREGROUND while
|
||||||
|
// staying recent enough not to report a stale app on the ~1s automation tick.
|
||||||
|
private const val WINDOW_MS = 10_000L
|
||||||
|
|
||||||
|
@Volatile private var appContext: Context? = null
|
||||||
|
|
||||||
|
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
|
||||||
|
@JvmStatic
|
||||||
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package name of the most recently foregrounded app, or null when none is
|
||||||
|
* found in the trailing window, Usage Access is not granted, or on any error.
|
||||||
|
* Never throws across the JNI boundary.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getForegroundPackage(): String? {
|
||||||
|
val ctx = appContext ?: run {
|
||||||
|
Log.w(TAG, "getForegroundPackage: context not bound (init not called)")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val usm = ctx.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
|
||||||
|
?: return null
|
||||||
|
val end = System.currentTimeMillis()
|
||||||
|
val events = usm.queryEvents(end - WINDOW_MS, end)
|
||||||
|
val event = UsageEvents.Event()
|
||||||
|
var latestPkg: String? = null
|
||||||
|
var latestTs = Long.MIN_VALUE
|
||||||
|
while (events.hasNextEvent()) {
|
||||||
|
events.getNextEvent(event)
|
||||||
|
// ACTIVITY_RESUMED (API 29+) shares the value of the legacy
|
||||||
|
// MOVE_TO_FOREGROUND constant, so the single check covers both.
|
||||||
|
// >= (not >) so that on an exact-timestamp tie the later-iterated
|
||||||
|
// event wins — events arrive chronologically, so that is the most
|
||||||
|
// recent foreground transition.
|
||||||
|
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND &&
|
||||||
|
event.timeStamp >= latestTs
|
||||||
|
) {
|
||||||
|
latestTs = event.timeStamp
|
||||||
|
latestPkg = event.packageName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latestPkg
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// SecurityException when access is missing, plus any service error.
|
||||||
|
Log.w(TAG, "getForegroundPackage failed: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the user has granted Usage Access (PACKAGE_USAGE_STATS) to this app. */
|
||||||
|
@JvmStatic
|
||||||
|
fun hasUsageAccess(): Boolean {
|
||||||
|
val ctx = appContext ?: return false
|
||||||
|
return try {
|
||||||
|
val appOps = ctx.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager
|
||||||
|
?: return false
|
||||||
|
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
appOps.unsafeCheckOpNoThrow(
|
||||||
|
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), ctx.packageName,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
appOps.checkOpNoThrow(
|
||||||
|
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), ctx.packageName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mode == AppOpsManager.MODE_ALLOWED
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "hasUsageAccess failed: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launchable apps as a JSON array string the Python server parses:
|
||||||
|
* `[{"package":"com.netflix.mediaclient","label":"Netflix"}, ...]`
|
||||||
|
*
|
||||||
|
* Uses [LauncherApps.getActivityList] (launcher + leanback launchables) —
|
||||||
|
* no QUERY_ALL_PACKAGES. De-duplicated by package, sorted by label.
|
||||||
|
* Returns `[]` on any error.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun listLaunchableApps(): String {
|
||||||
|
val arr = JSONArray()
|
||||||
|
val ctx = appContext ?: run {
|
||||||
|
Log.w(TAG, "listLaunchableApps: context not bound (init not called)")
|
||||||
|
return arr.toString()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val launcher = ctx.getSystemService(Context.LAUNCHER_APPS_SERVICE) as? LauncherApps
|
||||||
|
?: return arr.toString()
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
val items = ArrayList<Pair<String, String>>()
|
||||||
|
for (info in launcher.getActivityList(null, Process.myUserHandle())) {
|
||||||
|
val pkg = info.applicationInfo?.packageName ?: continue
|
||||||
|
if (!seen.add(pkg)) continue
|
||||||
|
val label = info.label?.toString().takeUnless { it.isNullOrBlank() } ?: pkg
|
||||||
|
items.add(pkg to label)
|
||||||
|
}
|
||||||
|
items.sortBy { it.second.lowercase() }
|
||||||
|
for ((pkg, label) in items) {
|
||||||
|
arr.put(JSONObject().put("package", pkg).put("label", label))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "listLaunchableApps failed: ${e.message}")
|
||||||
|
}
|
||||||
|
return arr.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,13 @@ class LedGrabApp : Application() {
|
|||||||
// Bind application context for the BLE bridge so Python can
|
// Bind application context for the BLE bridge so Python can
|
||||||
// scan and connect to BLE LED controllers.
|
// scan and connect to BLE LED controllers.
|
||||||
BleBridge.init(this)
|
BleBridge.init(this)
|
||||||
|
// Bind application context for the camera bridge so Python can
|
||||||
|
// enumerate cameras and open them on demand (webcam capture).
|
||||||
|
CameraBridge.init(this)
|
||||||
|
// Bind application context for the foreground-app bridge so Python can
|
||||||
|
// detect the foreground app (Application automation rule) and list
|
||||||
|
// launchable apps for the editor's picker.
|
||||||
|
ForegroundAppBridge.init(this)
|
||||||
|
|
||||||
// Pre-warm the API key on a background thread. First-launch
|
// Pre-warm the API key on a background thread. First-launch
|
||||||
// generation does a SharedPreferences.commit() (synchronous
|
// generation does a SharedPreferences.commit() (synchronous
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.ledgrab.android
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.service.notification.NotificationListenerService
|
||||||
|
import android.service.notification.StatusBarNotification
|
||||||
|
import android.util.Log
|
||||||
|
import com.chaquo.python.Python
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures posted OS notifications and forwards the posting app's display
|
||||||
|
* label to the Python notification pipeline, where the existing
|
||||||
|
* `NotificationColorStripSource` fires its one-shot LED effect.
|
||||||
|
*
|
||||||
|
* Direction is Kotlin -> Python via the process-global Chaquopy instance
|
||||||
|
* (NOT a per-[CaptureService] [PythonBridge]): `system_server` binds this
|
||||||
|
* service independently of [CaptureService], so it resolves Python itself.
|
||||||
|
* The Python receiver (`os_notification_listener.push_notification`) is a
|
||||||
|
* no-op whenever the server/listener isn't running, so a notification
|
||||||
|
* arriving before — or after — a capture session is safely ignored.
|
||||||
|
*/
|
||||||
|
class LedGrabNotificationListener : NotificationListenerService() {
|
||||||
|
|
||||||
|
// Serial executor: the Python receiver does a (non-concurrency-safe) history
|
||||||
|
// disk write and may play a sound, so pushes must not overlap. Off the main
|
||||||
|
// looper to keep the system service responsive.
|
||||||
|
private val pushExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
// packageName -> resolved human-readable label. Matches the app_name the
|
||||||
|
// Windows/Linux backends pass, so per-app colors/filters keep working.
|
||||||
|
// Naturally bounded by the number of notification-posting apps (tens) and
|
||||||
|
// cleared with the process — no eviction needed.
|
||||||
|
private val labelCache = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||||
|
val notification = sbn ?: return
|
||||||
|
|
||||||
|
// The Python server (and thus the listener) only exists during a capture
|
||||||
|
// session. isRunning is a coarse early-out — the authoritative gate is the
|
||||||
|
// Python receiver's None-check — but it avoids needless JNI churn here.
|
||||||
|
if (!CaptureService.isRunning) return
|
||||||
|
|
||||||
|
// Filter notifications that should never drive an effect:
|
||||||
|
// - ongoing (media transport, downloads): not user-facing "alerts"
|
||||||
|
// - group summaries: duplicate their child notifications
|
||||||
|
// - our own foreground-service notification: would self-trigger
|
||||||
|
if (notification.isOngoing) return
|
||||||
|
if ((notification.notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) return
|
||||||
|
if (notification.packageName == packageName) return
|
||||||
|
|
||||||
|
val label = resolveAppLabel(notification.packageName)
|
||||||
|
|
||||||
|
pushExecutor.execute {
|
||||||
|
try {
|
||||||
|
Python.getInstance()
|
||||||
|
.getModule(PY_MODULE)
|
||||||
|
.callAttr("push_notification", label)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// Never crash a system-bound service. Python.getInstance() throws
|
||||||
|
// IllegalStateException if Python.start() hasn't run (e.g. the
|
||||||
|
// service was bound at boot before the app process initialized).
|
||||||
|
// Log at debug — the label is potentially sensitive on a shared TV.
|
||||||
|
Log.d(TAG, "push_notification failed: ${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
|
||||||
|
private fun resolveAppLabel(pkg: String): String {
|
||||||
|
labelCache[pkg]?.let { return it }
|
||||||
|
val resolved = runCatching {
|
||||||
|
val info = packageManager.getApplicationInfo(pkg, 0)
|
||||||
|
packageManager.getApplicationLabel(info).toString()
|
||||||
|
}.getOrDefault(pkg)
|
||||||
|
labelCache[pkg] = resolved
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onListenerConnected() {
|
||||||
|
Log.i(TAG, "Notification listener connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onListenerDisconnected() {
|
||||||
|
Log.i(TAG, "Notification listener disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
pushExecutor.shutdown()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "LedGrabNotifListener"
|
||||||
|
private const val PY_MODULE = "ledgrab.core.processing.os_notification_listener"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.ScrollView
|
import android.widget.ScrollView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.google.zxing.BarcodeFormat
|
import com.google.zxing.BarcodeFormat
|
||||||
@@ -53,7 +54,11 @@ class MainActivity : Activity() {
|
|||||||
private const val SERVER_PORT = 8080
|
private const val SERVER_PORT = 8080
|
||||||
private const val REQUEST_MEDIA_PROJECTION = 1001
|
private const val REQUEST_MEDIA_PROJECTION = 1001
|
||||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
||||||
|
private const val REQUEST_RECORD_AUDIO = 1003
|
||||||
|
private const val REQUEST_CAMERA = 1004
|
||||||
private const val QR_SIZE_PX = 560
|
private const val QR_SIZE_PX = 560
|
||||||
|
private const val NOTIF_PREFS = "ledgrab_notif"
|
||||||
|
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stopped-state views (always inflated).
|
// Stopped-state views (always inflated).
|
||||||
@@ -63,6 +68,8 @@ class MainActivity : Activity() {
|
|||||||
private lateinit var versionText: TextView
|
private lateinit var versionText: TextView
|
||||||
private lateinit var autostartCheck: CheckBox
|
private lateinit var autostartCheck: CheckBox
|
||||||
private lateinit var autostartPrefs: AutostartPrefs
|
private lateinit var autostartPrefs: AutostartPrefs
|
||||||
|
private lateinit var grantNotificationButton: Button
|
||||||
|
private lateinit var grantUsageAccessButton: Button
|
||||||
|
|
||||||
// Running-state views (lazy-inflated via ViewStub).
|
// Running-state views (lazy-inflated via ViewStub).
|
||||||
private lateinit var runningPanelStub: ViewStub
|
private lateinit var runningPanelStub: ViewStub
|
||||||
@@ -106,6 +113,8 @@ class MainActivity : Activity() {
|
|||||||
toggleButton = findViewById(R.id.toggle_button)
|
toggleButton = findViewById(R.id.toggle_button)
|
||||||
versionText = findViewById(R.id.version_text)
|
versionText = findViewById(R.id.version_text)
|
||||||
autostartCheck = findViewById(R.id.autostart_check)
|
autostartCheck = findViewById(R.id.autostart_check)
|
||||||
|
grantNotificationButton = findViewById(R.id.grant_notification_button)
|
||||||
|
grantUsageAccessButton = findViewById(R.id.grant_usage_access_button)
|
||||||
|
|
||||||
val versionName = packageManager.getPackageInfo(packageName, 0).versionName
|
val versionName = packageManager.getPackageInfo(packageName, 0).versionName
|
||||||
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
|
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
|
||||||
@@ -126,8 +135,11 @@ class MainActivity : Activity() {
|
|||||||
autostartCheck.visibility = View.GONE
|
autostartCheck.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
grantNotificationButton.setOnClickListener { openNotificationListenerSettings() }
|
||||||
|
grantUsageAccessButton.setOnClickListener { openUsageAccessSettings() }
|
||||||
toggleButton.setOnClickListener { startCapture() }
|
toggleButton.setOnClickListener { startCapture() }
|
||||||
|
|
||||||
|
updateStoppedPermissionButtons()
|
||||||
updateUI()
|
updateUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,12 +160,16 @@ class MainActivity : Activity() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
if (!::stoppedPanel.isInitialized) return
|
||||||
// Restart the pulse if we returned to the foreground while the
|
// Restart the pulse if we returned to the foreground while the
|
||||||
// service is still running. The running panel's view may have
|
// service is still running. The running panel's view may have been
|
||||||
// been recreated; ensureRunningPanelInflated already keys off
|
// recreated; ensureRunningPanelInflated already keys off the field
|
||||||
// the field reference.
|
// reference. When stopped, refresh the notification-access button —
|
||||||
if (CaptureService.isRunning && ::stoppedPanel.isInitialized) {
|
// the user may have just granted/revoked access in Settings.
|
||||||
|
if (CaptureService.isRunning) {
|
||||||
updateUI()
|
updateUI()
|
||||||
|
} else {
|
||||||
|
updateStoppedPermissionButtons()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +212,8 @@ class MainActivity : Activity() {
|
|||||||
|
|
||||||
private fun startRootCaptureService() {
|
private fun startRootCaptureService() {
|
||||||
ensureNotificationPermission()
|
ensureNotificationPermission()
|
||||||
|
ensureNotificationListenerAccess()
|
||||||
|
ensureCameraPermission()
|
||||||
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
||||||
updateUI()
|
updateUI()
|
||||||
}
|
}
|
||||||
@@ -215,6 +233,9 @@ class MainActivity : Activity() {
|
|||||||
|
|
||||||
private fun startCaptureService(resultCode: Int, resultData: Intent) {
|
private fun startCaptureService(resultCode: Int, resultData: Intent) {
|
||||||
ensureNotificationPermission()
|
ensureNotificationPermission()
|
||||||
|
ensureNotificationListenerAccess()
|
||||||
|
ensureAudioPermission()
|
||||||
|
ensureCameraPermission()
|
||||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||||
ContextCompat.startForegroundService(this, intent)
|
ContextCompat.startForegroundService(this, intent)
|
||||||
updateUI()
|
updateUI()
|
||||||
@@ -471,4 +492,128 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request RECORD_AUDIO (API 29+) so the capture service can capture
|
||||||
|
* system playback audio for audio-reactive lighting. Fire-and-forget,
|
||||||
|
* like [ensureNotificationPermission]: capture still works without it
|
||||||
|
* (just no audio), so we don't block on the result. If first granted
|
||||||
|
* here, audio becomes available on the next Start.
|
||||||
|
*/
|
||||||
|
private fun ensureAudioPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
|
||||||
|
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
requestPermissions(
|
||||||
|
arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||||
|
REQUEST_RECORD_AUDIO,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request CAMERA so the capture service can open the device camera for
|
||||||
|
* on-device webcam capture. Fire-and-forget, like [ensureAudioPermission]:
|
||||||
|
* capture still works without it (just no camera engine), so we don't block
|
||||||
|
* on the result. Gated on actual camera hardware via FEATURE_CAMERA_ANY so
|
||||||
|
* camera-less TV boxes (the common case) never see the prompt. The camera
|
||||||
|
* is opened on demand only while a camera source is active — granting this
|
||||||
|
* does not keep the camera on. If first granted here, the camera engine
|
||||||
|
* becomes available on the next Start.
|
||||||
|
*/
|
||||||
|
private fun ensureCameraPermission() {
|
||||||
|
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) return
|
||||||
|
if (checkSelfPermission(Manifest.permission.CAMERA)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
requestPermissions(
|
||||||
|
arrayOf(Manifest.permission.CAMERA),
|
||||||
|
REQUEST_CAMERA,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the user has granted notification-listener access to this app. */
|
||||||
|
private fun isNotificationAccessGranted(): Boolean =
|
||||||
|
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
|
||||||
|
|
||||||
|
/** Open the system Notification-access screen (manual affordance / re-grant). */
|
||||||
|
private fun openNotificationListenerSettings() {
|
||||||
|
runCatching {
|
||||||
|
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||||
|
}.onFailure { Log.w(TAG, "Notification-access settings unavailable: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Usage Access (PACKAGE_USAGE_STATS) is granted — needed by the
|
||||||
|
* foreground-app automation rule. Delegates to the bridge's AppOps check.
|
||||||
|
*/
|
||||||
|
private fun isUsageAccessGranted(): Boolean = ForegroundAppBridge.hasUsageAccess()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the system Usage-Access screen so the user can grant LedGrab access
|
||||||
|
* for the foreground-app automation rule. Falls back to the generic Settings
|
||||||
|
* screen on TV-box OEM builds that strip the dedicated intent.
|
||||||
|
*/
|
||||||
|
private fun openUsageAccessSettings() {
|
||||||
|
runCatching {
|
||||||
|
startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "Usage-access settings unavailable: ${it.message}")
|
||||||
|
runCatching { startActivity(Intent(Settings.ACTION_SETTINGS)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt-once-then-remember: the first time capture starts without
|
||||||
|
* notification-listener access, open the settings screen so the user can
|
||||||
|
* grant it — then never nag again (the manual "Grant notification access"
|
||||||
|
* button stays available). Fire-and-forget like [ensureNotificationPermission].
|
||||||
|
*/
|
||||||
|
private fun ensureNotificationListenerAccess() {
|
||||||
|
if (isNotificationAccessGranted()) return
|
||||||
|
val prefs = getSharedPreferences(NOTIF_PREFS, MODE_PRIVATE)
|
||||||
|
if (prefs.getBoolean(KEY_NOTIF_ACCESS_PROMPTED, false)) return
|
||||||
|
prefs.edit().putBoolean(KEY_NOTIF_ACCESS_PROMPTED, true).apply()
|
||||||
|
openNotificationListenerSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show each "Grant <permission> access" button only while that access is
|
||||||
|
* missing, then re-wire the D-pad focus chain. Called on create and on resume
|
||||||
|
* (access can change in Settings while we're backgrounded). The usage-access
|
||||||
|
* button is a passive affordance (no auto-prompt at capture start) — the
|
||||||
|
* primary guidance is the web-UI banner when an Android app rule needs it.
|
||||||
|
*/
|
||||||
|
private fun updateStoppedPermissionButtons() {
|
||||||
|
if (!::grantNotificationButton.isInitialized) return
|
||||||
|
grantNotificationButton.visibility =
|
||||||
|
if (isNotificationAccessGranted()) View.GONE else View.VISIBLE
|
||||||
|
grantUsageAccessButton.visibility =
|
||||||
|
if (isUsageAccessGranted()) View.GONE else View.VISIBLE
|
||||||
|
wireStoppedFocusChain()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link the visible stopped-panel controls into a single up/down D-pad chain.
|
||||||
|
* The optional controls (the grant-access buttons and the root-only autostart
|
||||||
|
* checkbox) may be GONE, so the chain is computed from whatever is visible —
|
||||||
|
* a static nextFocus pointing at a GONE view would strand the focus on a TV
|
||||||
|
* remote.
|
||||||
|
*/
|
||||||
|
private fun wireStoppedFocusChain() {
|
||||||
|
val chain = listOfNotNull(
|
||||||
|
toggleButton,
|
||||||
|
grantNotificationButton.takeIf { it.visibility == View.VISIBLE },
|
||||||
|
grantUsageAccessButton.takeIf { it.visibility == View.VISIBLE },
|
||||||
|
autostartCheck.takeIf { it.visibility == View.VISIBLE },
|
||||||
|
)
|
||||||
|
chain.forEachIndexed { i, view ->
|
||||||
|
view.nextFocusUpId = (chain.getOrNull(i - 1) ?: view).id
|
||||||
|
view.nextFocusDownId = (chain.getOrNull(i + 1) ?: view).id
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class PythonBridge(private val context: Context) {
|
|||||||
// single-writer/single-reader pattern we have here.
|
// single-writer/single-reader pattern we have here.
|
||||||
@Volatile private var mediaProjectionEngine: PyObject? = null
|
@Volatile private var mediaProjectionEngine: PyObject? = null
|
||||||
@Volatile private var rootEngine: PyObject? = null
|
@Volatile private var rootEngine: PyObject? = null
|
||||||
|
@Volatile private var androidAudioEngine: PyObject? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure the MediaProjection engine with screen dimensions.
|
* Configure the MediaProjection engine with screen dimensions.
|
||||||
@@ -53,6 +54,49 @@ class PythonBridge(private val context: Context) {
|
|||||||
Log.i(TAG, "Root screenrecord engine configured: ${width}x${height}")
|
Log.i(TAG, "Root screenrecord engine configured: ${width}x${height}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the Android playback-capture audio engine with the format
|
||||||
|
* actually negotiated by [AudioCapture]'s `AudioRecord`. Must be called
|
||||||
|
* before [pushAudio]. Caches the module handle for the per-block fast
|
||||||
|
* path (same pattern as [configureCapture]).
|
||||||
|
*/
|
||||||
|
fun configureAudio(sampleRate: Int, channels: Int, chunkFrames: Int) {
|
||||||
|
val py = Python.getInstance()
|
||||||
|
val engine = py.getModule("ledgrab.core.audio.android_audio_engine")
|
||||||
|
engine.callAttr("configure", sampleRate, channels, chunkFrames)
|
||||||
|
androidAudioEngine = engine
|
||||||
|
Log.i(TAG, "Android audio engine configured: sr=$sampleRate ch=$channels chunk=$chunkFrames")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push one interleaved little-endian float32 PCM block to the Python
|
||||||
|
* audio engine. Called from [AudioCapture]'s capture thread. The byte
|
||||||
|
* array crosses the JNI boundary; Python copies it on receipt, so the
|
||||||
|
* caller may reuse the same buffer for the next block.
|
||||||
|
*/
|
||||||
|
fun pushAudio(pcmFloat32: ByteArray) {
|
||||||
|
if (!running) return
|
||||||
|
val engine = androidAudioEngine ?: return
|
||||||
|
try {
|
||||||
|
engine.callAttr("push_samples", pcmFloat32)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to push audio: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate the Python audio engine. Called from [AudioCapture.stop].
|
||||||
|
*/
|
||||||
|
fun shutdownAudio() {
|
||||||
|
val engine = androidAudioEngine ?: return
|
||||||
|
try {
|
||||||
|
engine.callAttr("shutdown")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to shut down audio engine: ${e.message}")
|
||||||
|
}
|
||||||
|
androidAudioEngine = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the LedGrab FastAPI server on a background thread.
|
* Start the LedGrab FastAPI server on a background thread.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -66,6 +66,36 @@
|
|||||||
android:focusableInTouchMode="true"
|
android:focusableInTouchMode="true"
|
||||||
android:nextFocusDown="@+id/autostart_check" />
|
android:nextFocusDown="@+id/autostart_check" />
|
||||||
|
|
||||||
|
<!-- Shown only while notification-listener access is missing. The D-pad
|
||||||
|
focus chain is wired at runtime (wireStoppedFocusChain) because this
|
||||||
|
button and the autostart checkbox are both conditionally visible. -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/grant_notification_button"
|
||||||
|
style="@style/Widget.LedGrab.Button.Secondary"
|
||||||
|
android:layout_width="320dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:text="@string/btn_grant_notification_access"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="true"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- Shown only while Usage Access is missing (needed by the foreground-app
|
||||||
|
automation rule). Like the grant-notification button, its D-pad focus
|
||||||
|
chain is wired at runtime (wireStoppedFocusChain). -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/grant_usage_access_button"
|
||||||
|
style="@style/Widget.LedGrab.Button.Secondary"
|
||||||
|
android:layout_width="320dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:text="@string/btn_grant_usage_access"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="true"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<CheckBox
|
<CheckBox
|
||||||
android:id="@+id/autostart_check"
|
android:id="@+id/autostart_check"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
|||||||
@@ -25,4 +25,7 @@
|
|||||||
<string name="notification_channel_description">Отображается, пока LedGrab захватывает экран.</string>
|
<string name="notification_channel_description">Отображается, пока LedGrab захватывает экран.</string>
|
||||||
<string name="notification_title">LedGrab работает</string>
|
<string name="notification_title">LedGrab работает</string>
|
||||||
<string name="notification_text">Веб-интерфейс: %1$s</string>
|
<string name="notification_text">Веб-интерфейс: %1$s</string>
|
||||||
|
<string name="notification_listener_label">Захват уведомлений LedGrab</string>
|
||||||
|
<string name="btn_grant_notification_access">Разрешить доступ к уведомлениям</string>
|
||||||
|
<string name="btn_grant_usage_access">Разрешить доступ к статистике использования</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -25,4 +25,7 @@
|
|||||||
<string name="notification_channel_description">LedGrab 捕获屏幕时显示。</string>
|
<string name="notification_channel_description">LedGrab 捕获屏幕时显示。</string>
|
||||||
<string name="notification_title">LedGrab 运行中</string>
|
<string name="notification_title">LedGrab 运行中</string>
|
||||||
<string name="notification_text">Web界面:%1$s</string>
|
<string name="notification_text">Web界面:%1$s</string>
|
||||||
|
<string name="notification_listener_label">LedGrab 通知捕获</string>
|
||||||
|
<string name="btn_grant_notification_access">授予通知访问权限</string>
|
||||||
|
<string name="btn_grant_usage_access">授予使用情况访问权限</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -25,4 +25,7 @@
|
|||||||
<string name="notification_channel_description">Shows while LedGrab is capturing the screen.</string>
|
<string name="notification_channel_description">Shows while LedGrab is capturing the screen.</string>
|
||||||
<string name="notification_title">LedGrab Running</string>
|
<string name="notification_title">LedGrab Running</string>
|
||||||
<string name="notification_text">Web UI: %1$s</string>
|
<string name="notification_text">Web UI: %1$s</string>
|
||||||
|
<string name="notification_listener_label">LedGrab notification capture</string>
|
||||||
|
<string name="btn_grant_notification_access">Grant notification access</string>
|
||||||
|
<string name="btn_grant_usage_access">Grant usage access</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+3
-2
@@ -56,9 +56,10 @@ SetCompressor /SOLID lzma
|
|||||||
; ── Functions ─────────────────────────────────────────────
|
; ── Functions ─────────────────────────────────────────────
|
||||||
|
|
||||||
Function LaunchApp
|
Function LaunchApp
|
||||||
|
; Only launch the app — do NOT open the browser here. A manual launch (no
|
||||||
|
; --autostart) makes the app open the WebUI itself once /health responds,
|
||||||
|
; so opening the URL here too made the page appear twice.
|
||||||
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
|
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
|
||||||
Sleep 2000
|
|
||||||
ExecShell "open" "http://localhost:8080/"
|
|
||||||
FunctionEnd
|
FunctionEnd
|
||||||
|
|
||||||
; Detect running instance before install (file lock check on python.exe)
|
; Detect running instance before install (file lock check on python.exe)
|
||||||
|
|||||||
+721
-251
File diff suppressed because it is too large
Load Diff
+42
-1
@@ -54,7 +54,48 @@ When you attach a device, a default calibration is created:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Custom Calibration
|
## Automatic Calibration
|
||||||
|
|
||||||
|
The easiest way to calibrate your strip is the **Auto-Calibrate** wizard, available directly
|
||||||
|
from the calibration modal. No LED counting required — just answer three questions and tap four
|
||||||
|
corners.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- A **Color Strip Source** (not a device-only target) associated with the strip.
|
||||||
|
- A **WLED device** connected and reachable by LedGrab.
|
||||||
|
|
||||||
|
### How to Start
|
||||||
|
|
||||||
|
1. Open the **Calibration** modal for your strip source (pencil icon → Calibration tab).
|
||||||
|
2. Click the **Auto-calibrate** button in the modal footer.
|
||||||
|
3. Follow the five-step wizard.
|
||||||
|
|
||||||
|
### Wizard Steps
|
||||||
|
|
||||||
|
| Step | What you do |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| 1. Device | Select the WLED device that drives the strip. |
|
||||||
|
| 2. Start corner | LED #0 lights up on your device. Tap the corner where you see it. |
|
||||||
|
| 3. Direction | Sweep a few LEDs light up in sequence. Tap the direction they move. |
|
||||||
|
| 4. Mark corners | Use the step buttons to sweep to each remaining corner, then tap **Mark corner**. Repeat for all 4 corners. |
|
||||||
|
| 5. Preview & Save | Review the detected layout (start position, direction, LED counts per edge). Click **Save** to apply. |
|
||||||
|
|
||||||
|
### What Happens in the Background
|
||||||
|
|
||||||
|
- A calibration session takes exclusive control of the device for the duration of the wizard;
|
||||||
|
any previously running effect is paused and automatically restored when the wizard exits
|
||||||
|
(whether by saving, cancelling, or closing the modal).
|
||||||
|
- The solved `CalibrationConfig` is written directly to the Color Strip Source via the existing
|
||||||
|
PUT endpoint and takes effect immediately (no restart needed).
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- If LED #0 is hard to see, reduce ambient lighting briefly.
|
||||||
|
- The wizard works in the browser — desktop and Android TV app both supported.
|
||||||
|
- If you make a mistake in step 4, use **Step back** to re-mark the previous corner.
|
||||||
|
|
||||||
|
## Manual Calibration
|
||||||
|
|
||||||
### Step 1: Identify Your LED Layout
|
### Step 1: Identify Your LED Layout
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 358 KiB |
+12
-10
@@ -6,33 +6,35 @@
|
|||||||
- `src/ledgrab/api/routes/` — REST API endpoints (one file per entity)
|
- `src/ledgrab/api/routes/` — REST API endpoints (one file per entity)
|
||||||
- `src/ledgrab/api/schemas/` — Pydantic request/response models (one file per entity)
|
- `src/ledgrab/api/schemas/` — Pydantic request/response models (one file per entity)
|
||||||
- `src/ledgrab/core/` — Core business logic (capture, devices, audio, processing, automations)
|
- `src/ledgrab/core/` — Core business logic (capture, devices, audio, processing, automations)
|
||||||
- `src/ledgrab/storage/` — Data models (dataclasses) and JSON persistence stores
|
- `src/ledgrab/storage/` — Data models (dataclasses) and SQLite-backed persistence stores (`BaseSqliteStore`)
|
||||||
- `src/ledgrab/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
|
- `src/ledgrab/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
|
||||||
- `src/ledgrab/static/` — Frontend files (TypeScript, CSS, locales)
|
- `src/ledgrab/static/` — Frontend files (TypeScript, CSS, locales)
|
||||||
- `src/ledgrab/templates/` — Jinja2 HTML templates
|
- `src/ledgrab/templates/` — Jinja2 HTML templates
|
||||||
- `config/` — Configuration files (YAML)
|
- `config/` — Configuration files (YAML)
|
||||||
- `data/` — Runtime data (JSON stores, persisted state)
|
- `data/` — Runtime data: SQLite database (`ledgrab.db`) + assets. Relocate the root with `LEDGRAB_DATA_DIR`.
|
||||||
|
|
||||||
## Entity & Storage Pattern
|
## Entity & Storage Pattern
|
||||||
|
|
||||||
Each entity follows: dataclass model (`storage/`) + JSON store (`storage/*_store.py`) + Pydantic schemas (`api/schemas/`) + routes (`api/routes/`).
|
Each entity follows: dataclass model (`storage/`) + SQLite store (`storage/*_store.py`, subclassing `BaseSqliteStore`) + Pydantic schemas (`api/schemas/`) + routes (`api/routes/`).
|
||||||
|
|
||||||
|
Stores keep an in-memory write-through cache over a per-entity SQLite table (the legacy `BaseJsonStore` still exists for reference but new stores use `BaseSqliteStore`). Schema/data shape changes go through `storage/data_migrations.py` — migrations are idempotent and tracked in a dedicated `data_migrations` audit table, so they run safely on every startup. **When renaming or restructuring stored fields, add a migration there** (see the Data Migration Policy in the root `CLAUDE.md`).
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Server uses API key authentication via Bearer token in `Authorization` header.
|
API key authentication via Bearer token in the `Authorization` header (`Authorization: Bearer <key>`). WebSocket connections authenticate with a first-message handshake (`{"type":"auth","token":"<key>"}`). See `src/ledgrab/api/auth.py` for the canonical logic.
|
||||||
|
|
||||||
- Config: `config/default_config.yaml` under `auth.api_keys`
|
- Config: `config/default_config.yaml` under `auth.api_keys`; env var `LEDGRAB_AUTH__API_KEYS`
|
||||||
- Env var: `LEDGRAB_AUTH__API_KEYS`
|
- When `api_keys` is **empty** (default): **loopback** requests (`127.0.0.1` / `::1` / `localhost`) are allowed anonymously, but **LAN / remote** requests are rejected with `401`. Auth is *not* fully open.
|
||||||
- When `api_keys` is empty (default), auth is disabled — all endpoints are open
|
- When `api_keys` is **set**: a valid Bearer token is required from every client (loopback included).
|
||||||
- To enable auth, add key entries (e.g. `dev: "your-secret-key"`)
|
- `require_authenticated()` rejects even loopback-anonymous callers on sensitive endpoints (e.g. backup download, secret reveal).
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
### Adding a new API endpoint
|
### Adding a new API endpoint
|
||||||
|
|
||||||
1. Create route file in `api/routes/`
|
1. Create route file in `api/routes/` (define an `APIRouter(prefix="/api/v1/...")`)
|
||||||
2. Define request/response schemas in `api/schemas/`
|
2. Define request/response schemas in `api/schemas/`
|
||||||
3. Register the router in `main.py`
|
3. Register the router in `api/__init__.py` (it aggregates every route module into the single `router` that `main.py` mounts)
|
||||||
4. Restart the server
|
4. Restart the server
|
||||||
5. Test via `/docs` (Swagger UI)
|
5. Test via `/docs` (Swagger UI)
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ auth:
|
|||||||
# - LAN requests are REJECTED with 401 (security default)
|
# - LAN requests are REJECTED with 401 (security default)
|
||||||
# To enable LAN access, uncomment the example below and replace the value
|
# To enable LAN access, uncomment the example below and replace the value
|
||||||
# with a secret you generated yourself (e.g. `openssl rand -hex 32`).
|
# with a secret you generated yourself (e.g. `openssl rand -hex 32`).
|
||||||
# The previous default `dev: "development-key-change-in-production"` has
|
# Do NOT ship a hard-coded key here — a publicly-known token grants full
|
||||||
# been removed — it shipped as a publicly-known token and any deployment
|
# LAN access to anyone on the network.
|
||||||
# that still uses it grants full LAN access to anyone on the network.
|
|
||||||
api_keys:
|
api_keys:
|
||||||
dev: "development-key-change-in-production"
|
default: "development-key-change-in-production"
|
||||||
|
# api_keys:
|
||||||
|
# my-client: "replace-with-output-of-openssl-rand-hex-32"
|
||||||
|
|
||||||
# Storage paths default to ./data relative to the server's working directory.
|
# Storage paths default to ./data relative to the server's working directory.
|
||||||
# Set LEDGRAB_DATA_DIR in the environment to point at a different data root
|
# Set LEDGRAB_DATA_DIR in the environment to point at a different data root
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ledgrab"
|
name = "ledgrab"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .routes.audio_templates import router as audio_templates_router
|
|||||||
from .routes.value_sources import router as value_sources_router
|
from .routes.value_sources import router as value_sources_router
|
||||||
from .routes.automations import router as automations_router
|
from .routes.automations import router as automations_router
|
||||||
from .routes.scene_presets import router as scene_presets_router
|
from .routes.scene_presets import router as scene_presets_router
|
||||||
|
from .routes.scene_playlists import router as scene_playlists_router
|
||||||
from .routes.webhooks import router as webhooks_router
|
from .routes.webhooks import router as webhooks_router
|
||||||
from .routes.sync_clocks import router as sync_clocks_router
|
from .routes.sync_clocks import router as sync_clocks_router
|
||||||
from .routes.color_strip_processing import router as cspt_router
|
from .routes.color_strip_processing import router as cspt_router
|
||||||
@@ -35,6 +36,8 @@ from .routes.pattern_templates import router as pattern_templates_router
|
|||||||
from .routes.preferences import router as preferences_router
|
from .routes.preferences import router as preferences_router
|
||||||
from .routes.snapshot import router as snapshot_router
|
from .routes.snapshot import router as snapshot_router
|
||||||
from .routes.graph import router as graph_router
|
from .routes.graph import router as graph_router
|
||||||
|
from .routes.calibration import router as calibration_router
|
||||||
|
from .routes.setup import router as setup_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -53,6 +56,7 @@ router.include_router(output_targets_router)
|
|||||||
router.include_router(output_targets_control_router)
|
router.include_router(output_targets_control_router)
|
||||||
router.include_router(automations_router)
|
router.include_router(automations_router)
|
||||||
router.include_router(scene_presets_router)
|
router.include_router(scene_presets_router)
|
||||||
|
router.include_router(scene_playlists_router)
|
||||||
router.include_router(webhooks_router)
|
router.include_router(webhooks_router)
|
||||||
router.include_router(sync_clocks_router)
|
router.include_router(sync_clocks_router)
|
||||||
router.include_router(cspt_router)
|
router.include_router(cspt_router)
|
||||||
@@ -70,5 +74,7 @@ router.include_router(pattern_templates_router)
|
|||||||
router.include_router(preferences_router)
|
router.include_router(preferences_router)
|
||||||
router.include_router(snapshot_router)
|
router.include_router(snapshot_router)
|
||||||
router.include_router(graph_router)
|
router.include_router(graph_router)
|
||||||
|
router.include_router(calibration_router)
|
||||||
|
router.include_router(setup_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ledgrab.storage.audio_template_store import AudioTemplateStore
|
|||||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||||
from ledgrab.storage.automation_store import AutomationStore
|
from ledgrab.storage.automation_store import AutomationStore
|
||||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||||
from ledgrab.storage.sync_clock_store import SyncClockStore
|
from ledgrab.storage.sync_clock_store import SyncClockStore
|
||||||
from ledgrab.storage.color_strip_processing_template_store import (
|
from ledgrab.storage.color_strip_processing_template_store import (
|
||||||
ColorStripProcessingTemplateStore,
|
ColorStripProcessingTemplateStore,
|
||||||
@@ -27,6 +28,7 @@ from ledgrab.storage.gradient_store import GradientStore
|
|||||||
from ledgrab.storage.weather_source_store import WeatherSourceStore
|
from ledgrab.storage.weather_source_store import WeatherSourceStore
|
||||||
from ledgrab.storage.asset_store import AssetStore
|
from ledgrab.storage.asset_store import AssetStore
|
||||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||||
|
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
|
||||||
from ledgrab.core.weather.weather_manager import WeatherManager
|
from ledgrab.core.weather.weather_manager import WeatherManager
|
||||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||||
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||||
@@ -110,6 +112,14 @@ def get_automation_engine() -> AutomationEngine:
|
|||||||
return _get("automation_engine", "Automation engine")
|
return _get("automation_engine", "Automation engine")
|
||||||
|
|
||||||
|
|
||||||
|
def get_scene_playlist_store() -> ScenePlaylistStore:
|
||||||
|
return _get("scene_playlist_store", "Scene playlist store")
|
||||||
|
|
||||||
|
|
||||||
|
def get_playlist_engine() -> PlaylistEngine:
|
||||||
|
return _get("playlist_engine", "Playlist engine")
|
||||||
|
|
||||||
|
|
||||||
def get_auto_backup_engine() -> AutoBackupEngine:
|
def get_auto_backup_engine() -> AutoBackupEngine:
|
||||||
return _get("auto_backup_engine", "Auto-backup engine")
|
return _get("auto_backup_engine", "Auto-backup engine")
|
||||||
|
|
||||||
@@ -226,7 +236,9 @@ def init_dependencies(
|
|||||||
value_source_store: ValueSourceStore | None = None,
|
value_source_store: ValueSourceStore | None = None,
|
||||||
automation_store: AutomationStore | None = None,
|
automation_store: AutomationStore | None = None,
|
||||||
scene_preset_store: ScenePresetStore | None = None,
|
scene_preset_store: ScenePresetStore | None = None,
|
||||||
|
scene_playlist_store: ScenePlaylistStore | None = None,
|
||||||
automation_engine: AutomationEngine | None = None,
|
automation_engine: AutomationEngine | None = None,
|
||||||
|
playlist_engine: PlaylistEngine | None = None,
|
||||||
auto_backup_engine: AutoBackupEngine | None = None,
|
auto_backup_engine: AutoBackupEngine | None = None,
|
||||||
sync_clock_store: SyncClockStore | None = None,
|
sync_clock_store: SyncClockStore | None = None,
|
||||||
sync_clock_manager: SyncClockManager | None = None,
|
sync_clock_manager: SyncClockManager | None = None,
|
||||||
@@ -262,7 +274,9 @@ def init_dependencies(
|
|||||||
"value_source_store": value_source_store,
|
"value_source_store": value_source_store,
|
||||||
"automation_store": automation_store,
|
"automation_store": automation_store,
|
||||||
"scene_preset_store": scene_preset_store,
|
"scene_preset_store": scene_preset_store,
|
||||||
|
"scene_playlist_store": scene_playlist_store,
|
||||||
"automation_engine": automation_engine,
|
"automation_engine": automation_engine,
|
||||||
|
"playlist_engine": playlist_engine,
|
||||||
"auto_backup_engine": auto_backup_engine,
|
"auto_backup_engine": auto_backup_engine,
|
||||||
"sync_clock_store": sync_clock_store,
|
"sync_clock_store": sync_clock_store,
|
||||||
"sync_clock_manager": sync_clock_manager,
|
"sync_clock_manager": sync_clock_manager,
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
|||||||
ConnectionField("value_source", "picture_source_id", "picture_source", "picture"),
|
ConnectionField("value_source", "picture_source_id", "picture_source", "picture"),
|
||||||
ConnectionField("value_source", "value_source_id", "value_source", "value"),
|
ConnectionField("value_source", "value_source_id", "value_source", "value"),
|
||||||
ConnectionField("value_source", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
ConnectionField("value_source", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
||||||
|
# AnimatedColorValueSource references a sync clock for shared timing.
|
||||||
|
ConnectionField("value_source", "clock_id", "sync_clock", "clock"),
|
||||||
# ── Color strip sources (top-level) ──
|
# ── Color strip sources (top-level) ──
|
||||||
ConnectionField("color_strip_source", "picture_source_id", "picture_source", "picture"),
|
ConnectionField("color_strip_source", "picture_source_id", "picture_source", "picture"),
|
||||||
ConnectionField("color_strip_source", "audio_source_id", "audio_source", "audio"),
|
ConnectionField("color_strip_source", "audio_source_id", "audio_source", "audio"),
|
||||||
@@ -129,6 +131,11 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
# ── Color strip sources (BindableColor value bindings) ──
|
# ── Color strip sources (BindableColor value bindings) ──
|
||||||
|
# NOTE: `bindable` here is *structural* (these are BindableColor fields). They
|
||||||
|
# are NOT usefully wireable from the graph: a ValueStream yields a scalar
|
||||||
|
# (`get_value() -> float`) and every colour consumer reads the static RGB via
|
||||||
|
# `bcolor()` (source_id ignored at runtime). The graph editor keeps them
|
||||||
|
# read-only; do not enable them without a colour-producing value source.
|
||||||
*(
|
*(
|
||||||
ConnectionField(
|
ConnectionField(
|
||||||
"color_strip_source",
|
"color_strip_source",
|
||||||
@@ -168,7 +175,6 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
|||||||
# ── Output targets ──
|
# ── Output targets ──
|
||||||
ConnectionField("output_target", "device_id", "device", "device"),
|
ConnectionField("output_target", "device_id", "device", "device"),
|
||||||
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
||||||
ConnectionField("output_target", "picture_source_id", "picture_source", "picture"),
|
|
||||||
ConnectionField(
|
ConnectionField(
|
||||||
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
|
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
|
||||||
),
|
),
|
||||||
@@ -201,6 +207,34 @@ def schema_for_kind(kind: str) -> list[ConnectionField]:
|
|||||||
return [c for c in CONNECTION_SCHEMA if c.target_kind == kind]
|
return [c for c in CONNECTION_SCHEMA if c.target_kind == kind]
|
||||||
|
|
||||||
|
|
||||||
|
# BindableColor slots are structurally bindable but NOT graph-editable: a
|
||||||
|
# ValueStream yields a scalar (``get_value() -> float``) and colour consumers
|
||||||
|
# read the static RGB via ``bcolor()`` (source_id ignored at runtime), so a
|
||||||
|
# value source cannot drive a colour.
|
||||||
|
_COLOR_BINDABLE_FIELDS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"color.source_id",
|
||||||
|
"color_peak.source_id",
|
||||||
|
"fallback_color.source_id",
|
||||||
|
"default_color.source_id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_editable(cf: ConnectionField) -> bool:
|
||||||
|
"""Whether a field can be wired from the graph.
|
||||||
|
|
||||||
|
Editable = a top-level reference, or a single-level ``BindableFloat`` slot.
|
||||||
|
List slots (need an element index), double-nested fields, and the dead
|
||||||
|
colour bindings stay read-only.
|
||||||
|
"""
|
||||||
|
if cf.is_list:
|
||||||
|
return False
|
||||||
|
if not cf.nested:
|
||||||
|
return True
|
||||||
|
return cf.bindable and cf.field.count(".") == 1 and cf.field not in _COLOR_BINDABLE_FIELDS
|
||||||
|
|
||||||
|
|
||||||
def schema_as_dicts() -> list[dict[str, Any]]:
|
def schema_as_dicts() -> list[dict[str, Any]]:
|
||||||
"""Serialize the registry for the ``/graph/schema`` endpoint."""
|
"""Serialize the registry for the ``/graph/schema`` endpoint."""
|
||||||
return [
|
return [
|
||||||
@@ -212,6 +246,7 @@ def schema_as_dicts() -> list[dict[str, Any]]:
|
|||||||
"bindable": c.bindable,
|
"bindable": c.bindable,
|
||||||
"nested": c.nested,
|
"nested": c.nested,
|
||||||
"is_list": c.is_list,
|
"is_list": c.is_list,
|
||||||
|
"editable": is_editable(c),
|
||||||
}
|
}
|
||||||
for c in CONNECTION_SCHEMA
|
for c in CONNECTION_SCHEMA
|
||||||
]
|
]
|
||||||
@@ -244,6 +279,54 @@ def extract_refs(entity: dict[str, Any], field_path: str) -> list[str]:
|
|||||||
return [v for v in current if isinstance(v, str) and v]
|
return [v for v in current if isinstance(v, str) and v]
|
||||||
|
|
||||||
|
|
||||||
|
def remap_refs(entity: dict[str, Any], field_path: str, id_map: dict[str, str]) -> int:
|
||||||
|
"""Rewrite referenced ids under ``field_path`` *in place*, using ``id_map``.
|
||||||
|
|
||||||
|
The write-twin of :func:`extract_refs`: it walks the same dot/list/bindable
|
||||||
|
grammar and replaces any leaf id present in ``id_map`` with its mapped value.
|
||||||
|
Ids absent from ``id_map`` (references to entities outside the remap set) are
|
||||||
|
left untouched, so a clone keeps sharing its un-cloned dependencies. Unbound
|
||||||
|
bindables (a plain number where an object was expected) and missing keys are
|
||||||
|
tolerated. Returns the number of ids rewritten.
|
||||||
|
"""
|
||||||
|
segments = field_path.split(".")
|
||||||
|
# Descend to the container(s) that hold the final key.
|
||||||
|
parents: list[Any] = [entity]
|
||||||
|
for segment in segments[:-1]:
|
||||||
|
is_list = segment.endswith("[]")
|
||||||
|
key = segment[:-2] if is_list else segment
|
||||||
|
nxt: list[Any] = []
|
||||||
|
for obj in parents:
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
continue
|
||||||
|
val = obj.get(key)
|
||||||
|
if is_list:
|
||||||
|
if isinstance(val, list):
|
||||||
|
nxt.extend(val)
|
||||||
|
elif isinstance(val, dict):
|
||||||
|
nxt.append(val)
|
||||||
|
parents = nxt
|
||||||
|
|
||||||
|
last = segments[-1]
|
||||||
|
last_is_list = last.endswith("[]")
|
||||||
|
key = last[:-2] if last_is_list else last
|
||||||
|
count = 0
|
||||||
|
for obj in parents:
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
continue
|
||||||
|
val = obj.get(key)
|
||||||
|
if last_is_list:
|
||||||
|
if isinstance(val, list):
|
||||||
|
for i, item in enumerate(val):
|
||||||
|
if isinstance(item, str) and item in id_map:
|
||||||
|
val[i] = id_map[item]
|
||||||
|
count += 1
|
||||||
|
elif isinstance(val, str) and val in id_map:
|
||||||
|
obj[key] = id_map[val]
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
def serialize_entity(model: Any) -> dict[str, Any]:
|
def serialize_entity(model: Any) -> dict[str, Any]:
|
||||||
"""Best-effort serialize a storage model to a plain dict for graph use.
|
"""Best-effort serialize a storage model to a plain dict for graph use.
|
||||||
|
|
||||||
@@ -269,6 +352,32 @@ def serialize_entity(model: Any) -> dict[str, Any]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def graph_field_roots(kind: str) -> set[str]:
|
||||||
|
"""Top-level keys the graph needs for ``kind``: ``id``/``name``, the subtype
|
||||||
|
field, and the root segment of every reference path for that kind."""
|
||||||
|
roots: set[str] = {"id", "name"}
|
||||||
|
type_field = NODE_TYPE_FIELD.get(kind, "")
|
||||||
|
if type_field:
|
||||||
|
roots.add(type_field)
|
||||||
|
for cf in CONNECTION_SCHEMA:
|
||||||
|
if cf.target_kind == kind:
|
||||||
|
roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_entity_for_graph(kind: str, model: Any) -> dict[str, Any]:
|
||||||
|
"""Serialize a model and project it to ONLY the keys the graph needs.
|
||||||
|
|
||||||
|
This projection is a **security boundary**: a full ``asdict``/``to_dict``
|
||||||
|
can carry secrets (webhook tokens, device/HA/MQTT credentials), so every
|
||||||
|
field except ``id``/``name``, the subtype field and reference-path roots is
|
||||||
|
dropped before the data reaches the graph API.
|
||||||
|
"""
|
||||||
|
full = serialize_entity(model)
|
||||||
|
roots = graph_field_roots(kind)
|
||||||
|
return {k: v for k, v in full.items() if k in roots}
|
||||||
|
|
||||||
|
|
||||||
# ── Topology / validation ───────────────────────────────────────────────────
|
# ── Topology / validation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -480,12 +589,11 @@ def validate_connection(
|
|||||||
)
|
)
|
||||||
if cf is None:
|
if cf is None:
|
||||||
return False, f"Unknown connection field: {target_kind}.{field}"
|
return False, f"Unknown connection field: {target_kind}.{field}"
|
||||||
if cf.is_list:
|
if not is_editable(cf):
|
||||||
# List slots (layers/zones/scene targets) hold many edges sharing the
|
# List slots (need an element index), double-nested fields, and dead
|
||||||
# same (to, field); without an element index this endpoint can't model
|
# colour bindings can't be wired from the graph — edit via the entity
|
||||||
# which one is being replaced for the cycle check. Edit those via the
|
# editor instead.
|
||||||
# entity editor.
|
return False, f"Field '{field}' is not editable via the graph"
|
||||||
return False, f"List connection '{field}' must be edited via the entity editor"
|
|
||||||
if not _entity_exists(entities_by_kind, target_kind, target_id):
|
if not _entity_exists(entities_by_kind, target_kind, target_id):
|
||||||
return False, f"Target entity not found: {target_id}"
|
return False, f"Target entity not found: {target_id}"
|
||||||
if not source_id:
|
if not source_id:
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
|
|||||||
"time_of_day": lambda: TimeOfDayRule(
|
"time_of_day": lambda: TimeOfDayRule(
|
||||||
start_time=s.start_time or "00:00",
|
start_time=s.start_time or "00:00",
|
||||||
end_time=s.end_time or "23:59",
|
end_time=s.end_time or "23:59",
|
||||||
|
days_of_week=s.days_of_week or [],
|
||||||
|
timezone=s.timezone or "",
|
||||||
),
|
),
|
||||||
"system_idle": lambda: SystemIdleRule(
|
"system_idle": lambda: SystemIdleRule(
|
||||||
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Calibration session and solver API routes.
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
---------
|
||||||
|
POST /api/v1/calibration/session
|
||||||
|
Start a calibration session on a device (stops any running target on that
|
||||||
|
device and remembers it for restore on stop).
|
||||||
|
|
||||||
|
POST /api/v1/calibration/session/position
|
||||||
|
Advance the chase pixel to a specific LED index on the active device.
|
||||||
|
|
||||||
|
POST /api/v1/calibration/session/stop
|
||||||
|
End the session: clear the device to black and restore the prior target.
|
||||||
|
|
||||||
|
POST /api/v1/calibration/session/cancel
|
||||||
|
Alias for stop (does not apply any solved calibration).
|
||||||
|
|
||||||
|
GET /api/v1/calibration/session/state
|
||||||
|
Return the current session state (active, device, last_activity, …).
|
||||||
|
|
||||||
|
POST /api/v1/calibration/solve
|
||||||
|
Pure-logic: solve a CalibrationConfig from 4 corner tap indices.
|
||||||
|
Does NOT persist — the caller must follow up with
|
||||||
|
``PUT /api/v1/color-strip-sources/{id}`` to persist.
|
||||||
|
|
||||||
|
Persist path
|
||||||
|
------------
|
||||||
|
The existing ``PUT /api/v1/color-strip-sources/{id}`` already accepts a
|
||||||
|
``calibration`` field on ``PictureCSSUpdate`` / ``PictureAdvancedCSSUpdate``
|
||||||
|
and hot-reloads running streams automatically (see
|
||||||
|
``api/routes/color_strip_sources/crud.py``). There is NO duplicate endpoint
|
||||||
|
here. Phase 3 UI calls the existing PUT to persist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.api.dependencies import get_processor_manager
|
||||||
|
from ledgrab.api.schemas.calibration import (
|
||||||
|
CalibrationSessionPositionRequest,
|
||||||
|
CalibrationSessionStartRequest,
|
||||||
|
CalibrationSessionStateResponse,
|
||||||
|
CalibrationSolveRequest,
|
||||||
|
CalibrationSolvedResponse,
|
||||||
|
)
|
||||||
|
from ledgrab.core.capture.calibration import solve_calibration
|
||||||
|
from ledgrab.core.capture.calibration_session import get_calibration_session
|
||||||
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session endpoints ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def start_calibration_session(
|
||||||
|
body: CalibrationSessionStartRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Start a calibration session on a device.
|
||||||
|
|
||||||
|
Stops any target currently processing on that device (it will be restored
|
||||||
|
when the session ends). Only one session can be active at a time; starting
|
||||||
|
a new one terminates the previous one first.
|
||||||
|
"""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.start(body.device_id, manager)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to start calibration session: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSessionStateResponse(**session.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session/position",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def calibration_session_position(
|
||||||
|
body: CalibrationSessionPositionRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Advance the chase pixel to a specific LED index on the active device.
|
||||||
|
|
||||||
|
``index`` must be 0-based and < ``led_count``. Returns 422 when out of
|
||||||
|
range (Pydantic ``ge=0``) or 400 if the session is not active / index
|
||||||
|
exceeds led_count.
|
||||||
|
"""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.position(body.index, body.window)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to set calibration pixel index=%d: %s", body.index, exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSessionStateResponse(**session.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session/stop",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def stop_calibration_session(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""End the calibration session.
|
||||||
|
|
||||||
|
Clears the device to black and restores the previously-running target (if
|
||||||
|
any). Safe to call even when no session is active (returns inactive state).
|
||||||
|
"""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.stop()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to stop calibration session: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSessionStateResponse(**session.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/session/cancel",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def cancel_calibration_session(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager), # noqa: ARG001
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Cancel the calibration session (alias for stop — no calibration is applied)."""
|
||||||
|
session = get_calibration_session()
|
||||||
|
try:
|
||||||
|
await session.cancel()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to cancel calibration session: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSessionStateResponse(**session.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/calibration/session/state",
|
||||||
|
response_model=CalibrationSessionStateResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def get_calibration_session_state(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
) -> CalibrationSessionStateResponse:
|
||||||
|
"""Return the current calibration session state."""
|
||||||
|
return CalibrationSessionStateResponse(**get_calibration_session().get_state())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Solver endpoint ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/calibration/solve",
|
||||||
|
response_model=CalibrationSolvedResponse,
|
||||||
|
tags=["Calibration"],
|
||||||
|
)
|
||||||
|
async def solve_calibration_endpoint(
|
||||||
|
body: CalibrationSolveRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
) -> CalibrationSolvedResponse:
|
||||||
|
"""Solve a CalibrationConfig from 4 corner tap indices.
|
||||||
|
|
||||||
|
Returns the computed per-edge LED counts. Does NOT persist — call
|
||||||
|
``PUT /api/v1/color-strip-sources/{id}`` with ``calibration`` in the body
|
||||||
|
to save.
|
||||||
|
|
||||||
|
Provide either *device_id* (preferred, server derives led_count) or
|
||||||
|
*led_count* directly. Returns 404 if *device_id* is not found, 422 on
|
||||||
|
invalid enum values, 400 on logical errors (e.g. corner_indices length).
|
||||||
|
"""
|
||||||
|
# Resolve led_count
|
||||||
|
led_count = body.led_count
|
||||||
|
if body.device_id is not None:
|
||||||
|
if body.device_id not in manager._devices:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Device {body.device_id!r} not found",
|
||||||
|
)
|
||||||
|
ds = manager._devices[body.device_id]
|
||||||
|
led_count = ds.led_count
|
||||||
|
|
||||||
|
if led_count is None or led_count <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="led_count must be a positive integer",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = solve_calibration(
|
||||||
|
led_count=led_count,
|
||||||
|
start_position=body.start_position,
|
||||||
|
layout=body.layout,
|
||||||
|
corner_indices=body.corner_indices,
|
||||||
|
offset=body.offset,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to solve calibration: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
return CalibrationSolvedResponse(
|
||||||
|
mode="simple",
|
||||||
|
layout=cfg.layout,
|
||||||
|
start_position=cfg.start_position,
|
||||||
|
leds_top=cfg.leds_top,
|
||||||
|
leds_right=cfg.leds_right,
|
||||||
|
leds_bottom=cfg.leds_bottom,
|
||||||
|
leds_left=cfg.leds_left,
|
||||||
|
offset=cfg.offset,
|
||||||
|
)
|
||||||
@@ -26,9 +26,13 @@ from ledgrab.api.graph_schema import (
|
|||||||
ENTITY_KINDS,
|
ENTITY_KINDS,
|
||||||
NODE_TYPE_FIELD,
|
NODE_TYPE_FIELD,
|
||||||
build_topology,
|
build_topology,
|
||||||
|
extract_refs,
|
||||||
find_dependents,
|
find_dependents,
|
||||||
|
remap_refs,
|
||||||
schema_as_dicts,
|
schema_as_dicts,
|
||||||
|
schema_for_kind,
|
||||||
serialize_entity,
|
serialize_entity,
|
||||||
|
serialize_entity_for_graph,
|
||||||
validate_connection,
|
validate_connection,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,7 +82,7 @@ def _gather_entities() -> dict[str, list[dict[str, Any]]]:
|
|||||||
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
|
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
|
||||||
out[kind] = []
|
out[kind] = []
|
||||||
continue
|
continue
|
||||||
out[kind] = [serialize_entity(m) for m in models]
|
out[kind] = [serialize_entity_for_graph(kind, m) for m in models]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -122,3 +126,124 @@ async def validate_graph_connection(
|
|||||||
entities, body.target_kind, body.target_id, body.field, body.source_id
|
entities, body.target_kind, body.target_id, body.field, body.source_id
|
||||||
)
|
)
|
||||||
return {"ok": ok, "error": error}
|
return {"ok": ok, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Subgraph duplication (server-side blueprint instantiate) ─────────────────
|
||||||
|
# Only these kinds are cloned. They carry no inline secrets — they *reference*
|
||||||
|
# shared secret-bearing entities (devices, HA sources, HTTP endpoints) by id,
|
||||||
|
# and those are NOT cloned — and they have no hardware identity to conflict
|
||||||
|
# over. Output targets, automations, devices and integrations are out of scope.
|
||||||
|
_DUPLICABLE_KINDS: tuple[str, ...] = ("value_source", "color_strip_source")
|
||||||
|
_MAX_DUPLICATE = 200
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateRequest(BaseModel):
|
||||||
|
"""Duplicate a selected subgraph of value / colour-strip sources."""
|
||||||
|
|
||||||
|
node_ids: list[str] = Field(..., min_length=1, max_length=_MAX_DUPLICATE)
|
||||||
|
name_suffix: str = Field(default=" (copy)", max_length=40)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_name(existing: set[str], desired: str) -> str:
|
||||||
|
"""A name not already in ``existing`` (appends ' 2', ' 3', … on collision)."""
|
||||||
|
if desired not in existing:
|
||||||
|
return desired
|
||||||
|
i = 2
|
||||||
|
while f"{desired} {i}" in existing:
|
||||||
|
i += 1
|
||||||
|
return f"{desired} {i}"
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_subgraph(node_ids: list[str], name_suffix: str) -> dict[str, Any]:
|
||||||
|
"""Deep-clone selected value/colour-strip sources with new ids, rewiring
|
||||||
|
references that point *within* the selection (shared deps are left alone)."""
|
||||||
|
# Index every duplicable entity by id → (kind, store, model); track names.
|
||||||
|
index: dict[str, tuple[str, Any, Any]] = {}
|
||||||
|
existing_names: dict[str, set[str]] = {}
|
||||||
|
for kind in _DUPLICABLE_KINDS:
|
||||||
|
try:
|
||||||
|
store = _KIND_STORES[kind]()
|
||||||
|
models = store.get_all()
|
||||||
|
except Exception as exc: # noqa: BLE001 — a failing store must not 500 the request
|
||||||
|
logger.warning("graph.duplicate: store for %s unavailable: %s", kind, exc)
|
||||||
|
continue
|
||||||
|
names = existing_names.setdefault(kind, set())
|
||||||
|
for m in models:
|
||||||
|
mid = getattr(m, "id", None)
|
||||||
|
mname = getattr(m, "name", None)
|
||||||
|
if isinstance(mname, str):
|
||||||
|
names.add(mname)
|
||||||
|
if isinstance(mid, str) and mid:
|
||||||
|
index[mid] = (kind, store, m)
|
||||||
|
|
||||||
|
selected: list[str] = []
|
||||||
|
skipped: list[dict[str, str]] = []
|
||||||
|
for nid in dict.fromkeys(node_ids): # de-dupe, preserve order
|
||||||
|
if nid in index:
|
||||||
|
selected.append(nid)
|
||||||
|
else:
|
||||||
|
skipped.append(
|
||||||
|
{"id": nid, "reason": "only value and colour-strip sources can be duplicated"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pass 1 — create clones; their refs still point at the originals (valid).
|
||||||
|
id_map: dict[str, str] = {}
|
||||||
|
created: list[dict[str, str]] = []
|
||||||
|
clones: list[tuple[str, Any, str]] = []
|
||||||
|
for old_id in selected:
|
||||||
|
kind, store, model = index[old_id]
|
||||||
|
base = (getattr(model, "name", None) or old_id) + name_suffix
|
||||||
|
name = _unique_name(existing_names[kind], base)
|
||||||
|
existing_names[kind].add(name)
|
||||||
|
try:
|
||||||
|
new = store.clone(old_id, name)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("graph.duplicate: clone of %s %s failed: %s", kind, old_id, exc)
|
||||||
|
skipped.append({"id": old_id, "reason": f"clone failed: {exc}"})
|
||||||
|
continue
|
||||||
|
id_map[old_id] = new.id
|
||||||
|
created.append({"id": new.id, "kind": kind, "name": new.name})
|
||||||
|
clones.append((kind, store, new.id))
|
||||||
|
|
||||||
|
# Pass 2 — rewrite references that point within the cloned set.
|
||||||
|
warnings: list[dict[str, str]] = []
|
||||||
|
for kind, store, new_id in clones:
|
||||||
|
clone = serialize_entity(store.get(new_id))
|
||||||
|
changed_roots: set[str] = set()
|
||||||
|
for cf in schema_for_kind(kind):
|
||||||
|
if remap_refs(clone, cf.field, id_map):
|
||||||
|
changed_roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
|
||||||
|
if not changed_roots:
|
||||||
|
continue
|
||||||
|
# `clone` is the FULL serialized entity, so each changed root carries a
|
||||||
|
# complete, structurally-intact value (the whole `layers` list / bindable
|
||||||
|
# dict) that ``update_source`` replaces or merges wholesale. (Within the
|
||||||
|
# duplicable set the only roots that change are scalar ids, `layers` and
|
||||||
|
# bindable slots — never a partially-built nested object.)
|
||||||
|
updates = {root: clone[root] for root in changed_roots if root in clone}
|
||||||
|
try:
|
||||||
|
store.update_source(new_id, **updates)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("graph.duplicate: ref remap of %s failed: %s", new_id, exc)
|
||||||
|
warnings.append({"id": new_id, "reason": f"reference remap failed: {exc}"})
|
||||||
|
|
||||||
|
# Safety net — a clone must never still reference an OLD (in-selection) id.
|
||||||
|
for kind, store, new_id in clones:
|
||||||
|
clone = serialize_entity(store.get(new_id))
|
||||||
|
for cf in schema_for_kind(kind):
|
||||||
|
if any(ref in id_map for ref in extract_refs(clone, cf.field)):
|
||||||
|
warnings.append({"id": new_id, "reason": f"unremapped reference at {cf.field}"})
|
||||||
|
|
||||||
|
return {"id_map": id_map, "created": created, "skipped": skipped, "warnings": warnings}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/graph/duplicate", tags=["Graph"])
|
||||||
|
async def duplicate_subgraph(body: DuplicateRequest, _auth: AuthRequired) -> dict[str, Any]:
|
||||||
|
"""Deep-clone the selected value/colour-strip sources (new ids, wiring remapped).
|
||||||
|
|
||||||
|
References that point *within* the selection are rewired to the new clones;
|
||||||
|
references to entities outside it (devices, HA sources, …) stay shared with
|
||||||
|
the originals. Only value and colour-strip sources are cloned — they carry no
|
||||||
|
inline secrets — so any other kind in the selection is reported in ``skipped``.
|
||||||
|
"""
|
||||||
|
return await run_in_threadpool(_duplicate_subgraph, body.node_ids, body.name_suffix)
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
|
|||||||
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
|
||||||
adaptive_fps=target.adaptive_fps,
|
adaptive_fps=target.adaptive_fps,
|
||||||
protocol=target.protocol,
|
protocol=target.protocol,
|
||||||
|
max_milliamps=target.max_milliamps,
|
||||||
|
milliamps_per_led=target.milliamps_per_led,
|
||||||
description=target.description,
|
description=target.description,
|
||||||
tags=target.tags,
|
tags=target.tags,
|
||||||
icon=getattr(target, "icon", "") or "",
|
icon=getattr(target, "icon", "") or "",
|
||||||
@@ -302,6 +304,8 @@ async def create_target(
|
|||||||
min_brightness_threshold=data.min_brightness_threshold,
|
min_brightness_threshold=data.min_brightness_threshold,
|
||||||
adaptive_fps=data.adaptive_fps,
|
adaptive_fps=data.adaptive_fps,
|
||||||
protocol=data.protocol,
|
protocol=data.protocol,
|
||||||
|
max_milliamps=data.max_milliamps,
|
||||||
|
milliamps_per_led=data.milliamps_per_led,
|
||||||
)
|
)
|
||||||
case HALightOutputTargetCreate():
|
case HALightOutputTargetCreate():
|
||||||
if data.source_kind == "color_vs":
|
if data.source_kind == "color_vs":
|
||||||
@@ -464,6 +468,8 @@ async def update_target(
|
|||||||
min_brightness_threshold=data.min_brightness_threshold,
|
min_brightness_threshold=data.min_brightness_threshold,
|
||||||
adaptive_fps=data.adaptive_fps,
|
adaptive_fps=data.adaptive_fps,
|
||||||
protocol=data.protocol,
|
protocol=data.protocol,
|
||||||
|
max_milliamps=data.max_milliamps,
|
||||||
|
milliamps_per_led=data.milliamps_per_led,
|
||||||
)
|
)
|
||||||
css_changed = data.color_strip_source_id is not None
|
css_changed = data.color_strip_source_id is not None
|
||||||
brightness_changed = data.brightness is not None
|
brightness_changed = data.brightness is not None
|
||||||
@@ -476,6 +482,8 @@ async def update_target(
|
|||||||
data.min_brightness_threshold,
|
data.min_brightness_threshold,
|
||||||
data.adaptive_fps,
|
data.adaptive_fps,
|
||||||
data.brightness,
|
data.brightness,
|
||||||
|
data.max_milliamps,
|
||||||
|
data.milliamps_per_led,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
device_changed = data.device_id is not None
|
device_changed = data.device_id is not None
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
|||||||
tags=t.tags,
|
tags=t.tags,
|
||||||
icon=getattr(t, "icon", "") or "",
|
icon=getattr(t, "icon", "") or "",
|
||||||
icon_color=getattr(t, "icon_color", "") or "",
|
icon_color=getattr(t, "icon_color", "") or "",
|
||||||
|
is_builtin=getattr(t, "is_builtin", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ daylight value-source / color-strip-source. Stored as
|
|||||||
empty/missing meaning "use system local time".
|
empty/missing meaning "use system local time".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
@@ -38,6 +39,7 @@ router = APIRouter()
|
|||||||
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
||||||
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
||||||
_CARD_MODES_KEY = "card_modes"
|
_CARD_MODES_KEY = "card_modes"
|
||||||
|
_ONBOARDING_KEY = "onboarded"
|
||||||
|
|
||||||
|
|
||||||
class DaylightTimezonePreference(BaseModel):
|
class DaylightTimezonePreference(BaseModel):
|
||||||
@@ -285,4 +287,75 @@ async def put_daylight_timezone_preference(
|
|||||||
return DaylightTimezonePreference(timezone=saved)
|
return DaylightTimezonePreference(timezone=saved)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Onboarding flag
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingPreference(BaseModel):
|
||||||
|
"""Persistent first-run onboarding flag."""
|
||||||
|
|
||||||
|
onboarded: bool = Field(
|
||||||
|
False,
|
||||||
|
description="True once the user has completed the first-run wizard.",
|
||||||
|
)
|
||||||
|
completed_at: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="ISO timestamp of when onboarding was first marked complete; null otherwise.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/preferences/onboarding",
|
||||||
|
response_model=OnboardingPreference,
|
||||||
|
tags=["Preferences"],
|
||||||
|
)
|
||||||
|
async def get_onboarding(
|
||||||
|
_: AuthRequired,
|
||||||
|
db: Database = Depends(get_database),
|
||||||
|
) -> OnboardingPreference:
|
||||||
|
"""Return the first-run onboarding status.
|
||||||
|
|
||||||
|
Defaults to ``{onboarded: false, completed_at: null}`` when the flag has
|
||||||
|
never been set.
|
||||||
|
"""
|
||||||
|
raw = db.get_setting(_ONBOARDING_KEY)
|
||||||
|
if not raw:
|
||||||
|
return OnboardingPreference()
|
||||||
|
try:
|
||||||
|
return OnboardingPreference.model_validate(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Stored onboarding preference invalid (%s); using default", exc)
|
||||||
|
return OnboardingPreference()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/preferences/onboarding",
|
||||||
|
response_model=OnboardingPreference,
|
||||||
|
tags=["Preferences"],
|
||||||
|
)
|
||||||
|
async def put_onboarding(
|
||||||
|
_: AuthRequired,
|
||||||
|
body: OnboardingPreference,
|
||||||
|
db: Database = Depends(get_database),
|
||||||
|
) -> OnboardingPreference:
|
||||||
|
"""Persist the onboarding flag.
|
||||||
|
|
||||||
|
When ``onboarded`` is set to ``true`` and ``completed_at`` is not provided,
|
||||||
|
the server stamps the current UTC time automatically.
|
||||||
|
When ``onboarded`` is ``false``, ``completed_at`` is cleared.
|
||||||
|
"""
|
||||||
|
if body.onboarded and body.completed_at is None:
|
||||||
|
body = OnboardingPreference(
|
||||||
|
onboarded=True,
|
||||||
|
completed_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
elif not body.onboarded:
|
||||||
|
body = OnboardingPreference(onboarded=False, completed_at=None)
|
||||||
|
|
||||||
|
db.set_setting(_ONBOARDING_KEY, body.model_dump())
|
||||||
|
logger.info("Onboarding flag updated: onboarded=%s", body.onboarded)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
|
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""Scene playlist API routes — CRUD plus start/stop/state cycling control."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_playlist_engine,
|
||||||
|
get_scene_playlist_store,
|
||||||
|
get_scene_preset_store,
|
||||||
|
)
|
||||||
|
from ledgrab.api.schemas.scene_playlists import (
|
||||||
|
PlaylistRuntimeStateSchema,
|
||||||
|
ScenePlaylistCreate,
|
||||||
|
ScenePlaylistListResponse,
|
||||||
|
ScenePlaylistResponse,
|
||||||
|
ScenePlaylistUpdate,
|
||||||
|
)
|
||||||
|
from ledgrab.core.scenes.playlist_engine import PlaylistEngine, PlaylistError
|
||||||
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
|
from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist
|
||||||
|
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||||
|
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _playlist_to_response(playlist: ScenePlaylist, engine: PlaylistEngine) -> ScenePlaylistResponse:
|
||||||
|
return ScenePlaylistResponse(
|
||||||
|
id=playlist.id,
|
||||||
|
name=playlist.name,
|
||||||
|
description=playlist.description,
|
||||||
|
items=[
|
||||||
|
{"scene_preset_id": i.scene_preset_id, "duration_seconds": i.duration_seconds}
|
||||||
|
for i in playlist.items
|
||||||
|
],
|
||||||
|
loop=playlist.loop,
|
||||||
|
shuffle=playlist.shuffle,
|
||||||
|
order=playlist.order,
|
||||||
|
tags=playlist.tags,
|
||||||
|
icon=getattr(playlist, "icon", "") or "",
|
||||||
|
icon_color=getattr(playlist, "icon_color", "") or "",
|
||||||
|
is_running=engine.get_running_playlist_id() == playlist.id,
|
||||||
|
created_at=playlist.created_at,
|
||||||
|
updated_at=playlist.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _items_from_schema(items) -> list[PlaylistItem]:
|
||||||
|
return [
|
||||||
|
PlaylistItem(scene_preset_id=i.scene_preset_id, duration_seconds=i.duration_seconds)
|
||||||
|
for i in items
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_preset_refs(items, preset_store: ScenePresetStore) -> None:
|
||||||
|
"""Reject playlist items that reference a non-existent scene preset."""
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
preset_store.get_preset(item.scene_preset_id)
|
||||||
|
except (ValueError, EntityNotFoundError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Scene preset not found: {item.scene_preset_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CRUD =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-playlists",
|
||||||
|
response_model=ScenePlaylistResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_scene_playlist(
|
||||||
|
data: ScenePlaylistCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Create a new scene playlist."""
|
||||||
|
_validate_preset_refs(data.items, preset_store)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
playlist = ScenePlaylist(
|
||||||
|
id=f"playlist_{uuid.uuid4().hex[:8]}",
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
items=_items_from_schema(data.items),
|
||||||
|
loop=data.loop,
|
||||||
|
shuffle=data.shuffle,
|
||||||
|
order=store.count(),
|
||||||
|
tags=data.tags if data.tags is not None else [],
|
||||||
|
icon=data.icon or "",
|
||||||
|
icon_color=data.icon_color or "",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = store.create_playlist(playlist)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("scene_playlist", "created", playlist.id)
|
||||||
|
return _playlist_to_response(playlist, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-playlists",
|
||||||
|
response_model=ScenePlaylistListResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def list_scene_playlists(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""List all scene playlists plus the current cycling state."""
|
||||||
|
playlists = store.get_all_playlists()
|
||||||
|
return ScenePlaylistListResponse(
|
||||||
|
playlists=[_playlist_to_response(p, engine) for p in playlists],
|
||||||
|
count=len(playlists),
|
||||||
|
state=PlaylistRuntimeStateSchema(**engine.get_state()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: the static ``/state`` path is declared before ``/{playlist_id}`` so it
|
||||||
|
# is matched first and not swallowed by the path parameter.
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-playlists/state",
|
||||||
|
response_model=PlaylistRuntimeStateSchema,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def get_playlist_state(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Get the current playlist cycling state (idle if nothing is running)."""
|
||||||
|
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}",
|
||||||
|
response_model=ScenePlaylistResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def get_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Get a single scene playlist."""
|
||||||
|
try:
|
||||||
|
playlist = store.get_playlist(playlist_id)
|
||||||
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
return _playlist_to_response(playlist, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}",
|
||||||
|
response_model=ScenePlaylistResponse,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def update_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
data: ScenePlaylistUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Update a scene playlist's metadata, items, and playback flags."""
|
||||||
|
new_items = None
|
||||||
|
if data.items is not None:
|
||||||
|
_validate_preset_refs(data.items, preset_store)
|
||||||
|
new_items = _items_from_schema(data.items)
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = store.update_playlist(
|
||||||
|
playlist_id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
items=new_items,
|
||||||
|
loop=data.loop,
|
||||||
|
shuffle=data.shuffle,
|
||||||
|
order=data.order,
|
||||||
|
tags=data.tags,
|
||||||
|
icon=data.icon,
|
||||||
|
icon_color=data.icon_color,
|
||||||
|
)
|
||||||
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404 if "not found" in str(e).lower() else 400, detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
fire_entity_event("scene_playlist", "updated", playlist_id)
|
||||||
|
return _playlist_to_response(playlist, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}",
|
||||||
|
status_code=204,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def delete_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Delete a scene playlist (stops it first if it is currently cycling)."""
|
||||||
|
try:
|
||||||
|
store.delete_playlist(playlist_id)
|
||||||
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
await engine.stop_if_running(playlist_id)
|
||||||
|
fire_entity_event("scene_playlist", "deleted", playlist_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Cycling control =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-playlists/{playlist_id}/start",
|
||||||
|
response_model=PlaylistRuntimeStateSchema,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def start_scene_playlist(
|
||||||
|
playlist_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Start cycling a playlist (stops any currently-running playlist first)."""
|
||||||
|
try:
|
||||||
|
store.get_playlist(playlist_id)
|
||||||
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await engine.start_playlist(playlist_id)
|
||||||
|
except PlaylistError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
fire_entity_event("scene_playlist", "updated", playlist_id)
|
||||||
|
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/scene-playlists/stop",
|
||||||
|
response_model=PlaylistRuntimeStateSchema,
|
||||||
|
tags=["Scene Playlists"],
|
||||||
|
)
|
||||||
|
async def stop_scene_playlist(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Stop the active playlist (leaves the last applied scene in place)."""
|
||||||
|
stopped_id = engine.get_running_playlist_id()
|
||||||
|
await engine.stop()
|
||||||
|
if stopped_id:
|
||||||
|
fire_entity_event("scene_playlist", "updated", stopped_id)
|
||||||
|
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""Setup scaffold endpoint.
|
||||||
|
|
||||||
|
Wires a complete capture → color-strip → output chain in one call, with
|
||||||
|
automatic rollback if any step fails so no orphan entities are left behind.
|
||||||
|
|
||||||
|
POST /api/v1/setup/scaffold
|
||||||
|
Body: ScaffoldRequest — device_id (required, must already exist),
|
||||||
|
display_index, optional calibration dict.
|
||||||
|
Returns: ScaffoldResponse — ids of every created/reused entity.
|
||||||
|
Fires ``entity_changed`` events for every entity created in this call,
|
||||||
|
but ONLY after the full chain succeeds (no mid-chain events).
|
||||||
|
Does NOT auto-start the target (the frontend starts it after calibration).
|
||||||
|
|
||||||
|
Rollback contract
|
||||||
|
-----------------
|
||||||
|
Entities created during THIS request are tracked in a local list. If any
|
||||||
|
step raises, they are deleted in reverse-creation order before re-raising.
|
||||||
|
Because "created" events are deferred until after the chain completes, a
|
||||||
|
rollback never produces ghost UI cards — no event for a rolled-back entity
|
||||||
|
is ever emitted.
|
||||||
|
|
||||||
|
The device is never part of the rollback set: scaffold requires an existing
|
||||||
|
device (created via ``POST /api/v1/devices`` which runs full validation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_color_strip_store,
|
||||||
|
get_device_store,
|
||||||
|
get_output_target_store,
|
||||||
|
get_picture_source_store,
|
||||||
|
get_processor_manager,
|
||||||
|
get_template_store,
|
||||||
|
)
|
||||||
|
from ledgrab.api.schemas.setup import ScaffoldRequest, ScaffoldResponse
|
||||||
|
from ledgrab.core.capture.calibration import calibration_from_dict, create_default_calibration
|
||||||
|
from ledgrab.core.capture_engines.factory import EngineRegistry
|
||||||
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||||
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
|
from ledgrab.storage.color_strip_store import ColorStripStore
|
||||||
|
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||||
|
from ledgrab.storage.picture_source_store import PictureSourceStore
|
||||||
|
from ledgrab.storage import DeviceStore
|
||||||
|
from ledgrab.storage.template_store import TemplateStore
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_DEFAULT_TARGET_FPS = 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: capture template
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_capture_template(
|
||||||
|
template_store: TemplateStore,
|
||||||
|
created_ids: list[tuple[str, str]],
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
"""Return (template_id, reused).
|
||||||
|
|
||||||
|
Tries to find an existing template whose engine_type matches the platform's
|
||||||
|
best available engine. Falls back to creating a fresh one.
|
||||||
|
"""
|
||||||
|
best_engine = EngineRegistry.get_best_available_engine()
|
||||||
|
if not best_engine:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="No capture engine available on this platform; cannot scaffold.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to reuse an existing template with the same engine
|
||||||
|
for tpl in template_store.get_all_templates():
|
||||||
|
if tpl.engine_type == best_engine:
|
||||||
|
logger.info(
|
||||||
|
"Scaffold: reusing existing capture template %s (engine=%s)",
|
||||||
|
tpl.id,
|
||||||
|
best_engine,
|
||||||
|
)
|
||||||
|
return tpl.id, True
|
||||||
|
|
||||||
|
# None found — create a fresh one
|
||||||
|
engine_class = EngineRegistry.get_engine(best_engine)
|
||||||
|
default_config = engine_class.get_default_config()
|
||||||
|
try:
|
||||||
|
tpl = template_store.create_template(
|
||||||
|
name=f"Scaffold capture ({best_engine})",
|
||||||
|
engine_type=best_engine,
|
||||||
|
engine_config=default_config,
|
||||||
|
description="Auto-created by first-run scaffold",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
created_ids.append(("capture_template", tpl.id))
|
||||||
|
logger.info("Scaffold: created capture template %s (engine=%s)", tpl.id, best_engine)
|
||||||
|
return tpl.id, False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: rollback
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _rollback(
|
||||||
|
created_ids: list[tuple[str, str]],
|
||||||
|
*,
|
||||||
|
template_store: TemplateStore,
|
||||||
|
picture_source_store: PictureSourceStore,
|
||||||
|
css_store: ColorStripStore,
|
||||||
|
output_target_store: OutputTargetStore,
|
||||||
|
manager: ProcessorManager | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Delete entities created during this call, in reverse order.
|
||||||
|
|
||||||
|
Only entities listed in ``created_ids`` are deleted; reused/pre-existing
|
||||||
|
entities (including the device) are never touched.
|
||||||
|
|
||||||
|
If *manager* is provided, any ``output_target`` entity in the rollback set
|
||||||
|
is also unregistered from the ProcessorManager before store deletion, so no
|
||||||
|
half-registered target is left behind.
|
||||||
|
"""
|
||||||
|
store_map: dict[str, Any] = {
|
||||||
|
"capture_template": template_store,
|
||||||
|
"picture_source": picture_source_store,
|
||||||
|
"color_strip_source": css_store,
|
||||||
|
"output_target": output_target_store,
|
||||||
|
}
|
||||||
|
for entity_type, entity_id in reversed(created_ids):
|
||||||
|
# Unregister output targets from the processor manager first
|
||||||
|
if entity_type == "output_target" and manager is not None:
|
||||||
|
try:
|
||||||
|
manager.remove_target(entity_id)
|
||||||
|
logger.info("Scaffold rollback: unregistered target %s from manager", entity_id)
|
||||||
|
except (ValueError, RuntimeError) as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Scaffold rollback: manager unregister skipped for %s — %s",
|
||||||
|
entity_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
store = store_map.get(entity_type)
|
||||||
|
if store is None:
|
||||||
|
logger.warning("Scaffold rollback: unknown entity type %r — skipping", entity_type)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
store.delete(entity_id)
|
||||||
|
logger.info("Scaffold rollback: deleted %s %s", entity_type, entity_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Scaffold rollback: failed to delete %s %s — %s",
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/setup/scaffold",
|
||||||
|
response_model=ScaffoldResponse,
|
||||||
|
status_code=201,
|
||||||
|
tags=["Setup"],
|
||||||
|
)
|
||||||
|
async def scaffold_setup(
|
||||||
|
data: ScaffoldRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
device_store: DeviceStore = Depends(get_device_store),
|
||||||
|
template_store: TemplateStore = Depends(get_template_store),
|
||||||
|
picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
|
||||||
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
|
output_target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
) -> ScaffoldResponse:
|
||||||
|
"""Create a ready-to-start LED capture chain.
|
||||||
|
|
||||||
|
Steps (each uses the real store create method for validation and ID gen):
|
||||||
|
|
||||||
|
1. Look up the existing device (404 if not found).
|
||||||
|
2. Find or create a capture template for the platform-best engine.
|
||||||
|
3. Create a raw picture source (``display_index`` + ``capture_template_id``).
|
||||||
|
4. Create a picture color-strip source with either the provided calibration
|
||||||
|
or ``create_default_calibration(led_count)``.
|
||||||
|
5. Create a LED output target linking the device to the CSS.
|
||||||
|
|
||||||
|
All created entities emit ``entity_changed`` events, but ONLY after the
|
||||||
|
full chain succeeds — events are collected and fired at the very end.
|
||||||
|
On any error the entities created so far are deleted in reverse order
|
||||||
|
(rollback), and no "created" events are emitted (no ghost UI cards).
|
||||||
|
The output target is NOT started — the frontend starts it after the
|
||||||
|
optional calibration step.
|
||||||
|
"""
|
||||||
|
created_ids: list[tuple[str, str]] = []
|
||||||
|
# Deferred "created" events: (entity_type, entity_id) — fired only on success.
|
||||||
|
pending_events: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
rollback_stores = dict(
|
||||||
|
template_store=template_store,
|
||||||
|
picture_source_store=picture_source_store,
|
||||||
|
css_store=css_store,
|
||||||
|
output_target_store=output_target_store,
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── Step 1: resolve existing device ─────────────────────────────────
|
||||||
|
try:
|
||||||
|
device = device_store.get(data.device_id)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Device not found: {data.device_id}")
|
||||||
|
device_id = device.id
|
||||||
|
led_count = device.led_count
|
||||||
|
|
||||||
|
# ── Step 2: capture template ─────────────────────────────────────────
|
||||||
|
capture_template_id, template_reused = _get_or_create_capture_template(
|
||||||
|
template_store, created_ids
|
||||||
|
)
|
||||||
|
if not template_reused:
|
||||||
|
pending_events.append(("capture_template", capture_template_id))
|
||||||
|
|
||||||
|
# ── Step 3: picture source ───────────────────────────────────────────
|
||||||
|
ps_name = f"Screen {data.display_index} (scaffold)"
|
||||||
|
try:
|
||||||
|
picture_source = picture_source_store.create_stream(
|
||||||
|
name=ps_name,
|
||||||
|
stream_type="raw",
|
||||||
|
display_index=data.display_index,
|
||||||
|
capture_template_id=capture_template_id,
|
||||||
|
target_fps=_DEFAULT_TARGET_FPS,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
created_ids.append(("picture_source", picture_source.id))
|
||||||
|
pending_events.append(("picture_source", picture_source.id))
|
||||||
|
logger.info("Scaffold: created picture source %s", picture_source.id)
|
||||||
|
|
||||||
|
# ── Step 4: color-strip source ───────────────────────────────────────
|
||||||
|
if data.calibration is not None:
|
||||||
|
try:
|
||||||
|
calibration = calibration_from_dict(data.calibration)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Invalid calibration dict: {exc}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
calibration = create_default_calibration(led_count)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
css_name = "Screen capture (scaffold)"
|
||||||
|
try:
|
||||||
|
css = css_store.create_source(
|
||||||
|
name=css_name,
|
||||||
|
source_type="picture",
|
||||||
|
picture_source_id=picture_source.id,
|
||||||
|
calibration=calibration,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
created_ids.append(("color_strip_source", css.id))
|
||||||
|
pending_events.append(("color_strip_source", css.id))
|
||||||
|
logger.info("Scaffold: created color-strip source %s", css.id)
|
||||||
|
|
||||||
|
# ── Step 5: LED output target ────────────────────────────────────────
|
||||||
|
target_name = "LED output (scaffold)"
|
||||||
|
try:
|
||||||
|
target = output_target_store.create_wled_target(
|
||||||
|
name=target_name,
|
||||||
|
device_id=device_id,
|
||||||
|
color_strip_source_id=css.id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
created_ids.append(("output_target", target.id))
|
||||||
|
pending_events.append(("output_target", target.id))
|
||||||
|
logger.info("Scaffold: created output target %s", target.id)
|
||||||
|
|
||||||
|
# ── Step 5b: register target with ProcessorManager ───────────────────
|
||||||
|
try:
|
||||||
|
target.register_with_manager(manager)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Scaffold: could not register target %s in processor manager: %s",
|
||||||
|
target.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
_rollback(created_ids, **rollback_stores)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Scaffold: unexpected error — rolling back: %s", exc, exc_info=True)
|
||||||
|
_rollback(created_ids, **rollback_stores)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error during scaffold")
|
||||||
|
|
||||||
|
# ── Full chain succeeded — fire all deferred "created" events ───────────
|
||||||
|
for entity_type, entity_id in pending_events:
|
||||||
|
fire_entity_event(entity_type, "created", entity_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Scaffold complete: device=%s tpl=%s ps=%s css=%s target=%s",
|
||||||
|
device_id,
|
||||||
|
capture_template_id,
|
||||||
|
picture_source.id,
|
||||||
|
css.id,
|
||||||
|
target.id,
|
||||||
|
)
|
||||||
|
return ScaffoldResponse(
|
||||||
|
device_id=device_id,
|
||||||
|
capture_template_id=capture_template_id,
|
||||||
|
picture_source_id=picture_source.id,
|
||||||
|
color_strip_source_id=css.id,
|
||||||
|
output_target_id=target.id,
|
||||||
|
capture_template_reused=template_reused,
|
||||||
|
)
|
||||||
@@ -30,7 +30,9 @@ from ledgrab.api.dependencies import (
|
|||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_device_store,
|
get_device_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
|
get_playlist_engine,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
|
get_scene_playlist_store,
|
||||||
get_scene_preset_store,
|
get_scene_preset_store,
|
||||||
get_sync_clock_manager,
|
get_sync_clock_manager,
|
||||||
get_sync_clock_store,
|
get_sync_clock_store,
|
||||||
@@ -43,6 +45,7 @@ from ledgrab.utils import get_logger
|
|||||||
from .color_strip_sources.crud import list_color_strip_sources
|
from .color_strip_sources.crud import list_color_strip_sources
|
||||||
from .devices import list_devices, resolve_device_brightness
|
from .devices import list_devices, resolve_device_brightness
|
||||||
from .output_targets import batch_target_metrics, batch_target_states, list_targets
|
from .output_targets import batch_target_metrics, batch_target_states, list_targets
|
||||||
|
from .scene_playlists import list_scene_playlists
|
||||||
from .scene_presets import list_scene_presets
|
from .scene_presets import list_scene_presets
|
||||||
from .sync_clocks import list_sync_clocks
|
from .sync_clocks import list_sync_clocks
|
||||||
from .system import get_system_performance, health_check
|
from .system import get_system_performance, health_check
|
||||||
@@ -53,7 +56,9 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Selectable snapshot sections — these are exactly the response top-level keys.
|
# Selectable snapshot sections — these are exactly the response top-level keys,
|
||||||
|
# except ``scene_playlists`` which also emits a companion ``playlist_state`` key
|
||||||
|
# (the single global cycling state; see the handler).
|
||||||
SNAPSHOT_SECTIONS = (
|
SNAPSHOT_SECTIONS = (
|
||||||
"targets",
|
"targets",
|
||||||
"target_states",
|
"target_states",
|
||||||
@@ -63,6 +68,7 @@ SNAPSHOT_SECTIONS = (
|
|||||||
"css_sources",
|
"css_sources",
|
||||||
"value_sources",
|
"value_sources",
|
||||||
"scene_presets",
|
"scene_presets",
|
||||||
|
"scene_playlists",
|
||||||
"sync_clocks",
|
"sync_clocks",
|
||||||
"system",
|
"system",
|
||||||
)
|
)
|
||||||
@@ -135,6 +141,8 @@ async def get_snapshot(
|
|||||||
css_store=Depends(get_color_strip_store),
|
css_store=Depends(get_color_strip_store),
|
||||||
value_store=Depends(get_value_source_store),
|
value_store=Depends(get_value_source_store),
|
||||||
preset_store=Depends(get_scene_preset_store),
|
preset_store=Depends(get_scene_preset_store),
|
||||||
|
playlist_store=Depends(get_scene_playlist_store),
|
||||||
|
playlist_engine=Depends(get_playlist_engine),
|
||||||
clock_store=Depends(get_sync_clock_store),
|
clock_store=Depends(get_sync_clock_store),
|
||||||
clock_manager=Depends(get_sync_clock_manager),
|
clock_manager=Depends(get_sync_clock_manager),
|
||||||
update_service=Depends(get_update_service),
|
update_service=Depends(get_update_service),
|
||||||
@@ -152,6 +160,8 @@ async def get_snapshot(
|
|||||||
"css_sources": [...],
|
"css_sources": [...],
|
||||||
"value_sources": [...],
|
"value_sources": [...],
|
||||||
"scene_presets": [...],
|
"scene_presets": [...],
|
||||||
|
"scene_playlists": [...],
|
||||||
|
"playlist_state": {...}, # companion to scene_playlists
|
||||||
"sync_clocks": [...],
|
"sync_clocks": [...],
|
||||||
"system": {"performance": {...}, "health": {...}, "update": {...}}
|
"system": {"performance": {...}, "health": {...}, "update": {...}}
|
||||||
}
|
}
|
||||||
@@ -184,6 +194,14 @@ async def get_snapshot(
|
|||||||
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
|
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
|
||||||
if "scene_presets" in sections:
|
if "scene_presets" in sections:
|
||||||
result["scene_presets"] = (await list_scene_presets(_auth, preset_store)).presets
|
result["scene_presets"] = (await list_scene_presets(_auth, preset_store)).presets
|
||||||
|
if "scene_playlists" in sections:
|
||||||
|
# One call returns both the playlist list (each with ``is_running``) and
|
||||||
|
# the single global cycling state (current index / preset / dwell). The
|
||||||
|
# state is emitted as a companion top-level key because it describes the
|
||||||
|
# one running playlist, not any individual list entry.
|
||||||
|
playlists = await list_scene_playlists(_auth, playlist_store, playlist_engine)
|
||||||
|
result["scene_playlists"] = playlists.playlists
|
||||||
|
result["playlist_state"] = playlists.state
|
||||||
if "sync_clocks" in sections:
|
if "sync_clocks" in sections:
|
||||||
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
|
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
|
||||||
result["sync_clocks"] = clocks.clocks
|
result["sync_clocks"] = clocks.clocks
|
||||||
|
|||||||
@@ -39,8 +39,11 @@ from ledgrab.api.schemas.system import (
|
|||||||
DisplayListResponse,
|
DisplayListResponse,
|
||||||
GpuInfo,
|
GpuInfo,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
|
InstalledAppItem,
|
||||||
|
InstalledAppsResponse,
|
||||||
PerformanceResponse,
|
PerformanceResponse,
|
||||||
ProcessListResponse,
|
ProcessListResponse,
|
||||||
|
SystemInfoResponse,
|
||||||
VersionResponse,
|
VersionResponse,
|
||||||
)
|
)
|
||||||
from ledgrab.config import get_config, is_demo_mode
|
from ledgrab.config import get_config, is_demo_mode
|
||||||
@@ -278,6 +281,52 @@ async def get_running_processes(_: AuthRequired):
|
|||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/system/installed-apps",
|
||||||
|
response_model=InstalledAppsResponse,
|
||||||
|
tags=["Config"],
|
||||||
|
)
|
||||||
|
def get_installed_apps(_: AuthRequired):
|
||||||
|
"""List launchable apps for the application-rule app picker (Android only).
|
||||||
|
|
||||||
|
Returns launchable apps (package + human label) on Android, where the
|
||||||
|
foreground-app automation rule matches package names. Returns an empty list
|
||||||
|
on desktop, where the process picker (``/system/processes``) is used instead.
|
||||||
|
Sync ``def`` so FastAPI runs the (potentially blocking) bridge call in a
|
||||||
|
thread pool.
|
||||||
|
"""
|
||||||
|
from ledgrab.core.automations import platform_detector as pd
|
||||||
|
|
||||||
|
try:
|
||||||
|
apps = pd.list_installed_apps()
|
||||||
|
items = [InstalledAppItem(package=a["package"], label=a["label"]) for a in apps]
|
||||||
|
return InstalledAppsResponse(apps=items, count=len(items))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to list installed apps: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/system/info", response_model=SystemInfoResponse, tags=["Info"])
|
||||||
|
def get_system_info(_: AuthRequired):
|
||||||
|
"""Platform capability signal for the automation editor.
|
||||||
|
|
||||||
|
Tells the frontend whether the server is on Android (so the application-rule
|
||||||
|
editor uses the launchable-app picker + package matching and surfaces the
|
||||||
|
Usage-Access banner) vs desktop (process picker + process names), and whether
|
||||||
|
Usage Access is currently granted. Sync ``def`` so the bridge call runs in a
|
||||||
|
thread pool.
|
||||||
|
"""
|
||||||
|
from ledgrab.core.automations import platform_detector as pd
|
||||||
|
from ledgrab.utils.platform import is_android
|
||||||
|
|
||||||
|
android = is_android()
|
||||||
|
return SystemInfoResponse(
|
||||||
|
is_android=android,
|
||||||
|
app_match_kind="package" if android else "process",
|
||||||
|
usage_access_granted=(pd.has_usage_access() if android else True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/v1/system/performance",
|
"/api/v1/system/performance",
|
||||||
response_model=PerformanceResponse,
|
response_model=PerformanceResponse,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""System routes: MQTT, external URL, ADB, logs WebSocket, log level.
|
"""System routes: external URL, shutdown action, ADB, logs WebSocket, log level.
|
||||||
|
|
||||||
Extracted from system.py to keep files under 800 lines.
|
Extracted from system.py to keep files under 800 lines.
|
||||||
"""
|
"""
|
||||||
@@ -17,13 +17,10 @@ from ledgrab.api.schemas.system import (
|
|||||||
ExternalUrlResponse,
|
ExternalUrlResponse,
|
||||||
LogLevelRequest,
|
LogLevelRequest,
|
||||||
LogLevelResponse,
|
LogLevelResponse,
|
||||||
MQTTSettingsRequest,
|
|
||||||
MQTTSettingsResponse,
|
|
||||||
ShutdownAction,
|
ShutdownAction,
|
||||||
ShutdownActionRequest,
|
ShutdownActionRequest,
|
||||||
ShutdownActionResponse,
|
ShutdownActionResponse,
|
||||||
)
|
)
|
||||||
from ledgrab.config import get_config
|
|
||||||
from ledgrab.storage.database import Database
|
from ledgrab.storage.database import Database
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
@@ -32,85 +29,6 @@ logger = get_logger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# MQTT settings
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _load_mqtt_settings(db: Database) -> dict:
|
|
||||||
"""Load MQTT settings: YAML config defaults overridden by DB settings."""
|
|
||||||
cfg = get_config()
|
|
||||||
defaults = {
|
|
||||||
"enabled": cfg.mqtt.enabled,
|
|
||||||
"broker_host": cfg.mqtt.broker_host,
|
|
||||||
"broker_port": cfg.mqtt.broker_port,
|
|
||||||
"username": cfg.mqtt.username,
|
|
||||||
"password": cfg.mqtt.password,
|
|
||||||
"client_id": cfg.mqtt.client_id,
|
|
||||||
"base_topic": cfg.mqtt.base_topic,
|
|
||||||
}
|
|
||||||
overrides = db.get_setting("mqtt")
|
|
||||||
if overrides:
|
|
||||||
defaults.update(overrides)
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/api/v1/system/mqtt/settings",
|
|
||||||
response_model=MQTTSettingsResponse,
|
|
||||||
tags=["System"],
|
|
||||||
)
|
|
||||||
async def get_mqtt_settings(_: AuthRequired, db: Database = Depends(get_database)):
|
|
||||||
"""Get current MQTT broker settings. Password is masked."""
|
|
||||||
s = _load_mqtt_settings(db)
|
|
||||||
return MQTTSettingsResponse(
|
|
||||||
enabled=s["enabled"],
|
|
||||||
broker_host=s["broker_host"],
|
|
||||||
broker_port=s["broker_port"],
|
|
||||||
username=s["username"],
|
|
||||||
password_set=bool(s.get("password")),
|
|
||||||
client_id=s["client_id"],
|
|
||||||
base_topic=s["base_topic"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
|
||||||
"/api/v1/system/mqtt/settings",
|
|
||||||
response_model=MQTTSettingsResponse,
|
|
||||||
tags=["System"],
|
|
||||||
)
|
|
||||||
async def update_mqtt_settings(
|
|
||||||
_: AuthRequired, body: MQTTSettingsRequest, db: Database = Depends(get_database)
|
|
||||||
):
|
|
||||||
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
|
|
||||||
current = _load_mqtt_settings(db)
|
|
||||||
|
|
||||||
# If caller sends an empty password, keep the existing one
|
|
||||||
password = body.password if body.password else current.get("password", "")
|
|
||||||
|
|
||||||
new_settings = {
|
|
||||||
"enabled": body.enabled,
|
|
||||||
"broker_host": body.broker_host,
|
|
||||||
"broker_port": body.broker_port,
|
|
||||||
"username": body.username,
|
|
||||||
"password": password,
|
|
||||||
"client_id": body.client_id,
|
|
||||||
"base_topic": body.base_topic,
|
|
||||||
}
|
|
||||||
db.set_setting("mqtt", new_settings)
|
|
||||||
logger.info("MQTT settings updated")
|
|
||||||
|
|
||||||
return MQTTSettingsResponse(
|
|
||||||
enabled=new_settings["enabled"],
|
|
||||||
broker_host=new_settings["broker_host"],
|
|
||||||
broker_port=new_settings["broker_port"],
|
|
||||||
username=new_settings["username"],
|
|
||||||
password_set=bool(new_settings["password"]),
|
|
||||||
client_id=new_settings["client_id"],
|
|
||||||
base_topic=new_settings["base_topic"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# External URL setting
|
# External URL setting
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ledgrab.api.auth import AuthRequired
|
from ledgrab.api.auth import AuthRequired
|
||||||
from ledgrab.api.dependencies import (
|
from ledgrab.api.dependencies import (
|
||||||
@@ -27,6 +28,8 @@ from ledgrab.api.schemas.value_sources import (
|
|||||||
StaticColorValueSourceResponse,
|
StaticColorValueSourceResponse,
|
||||||
StaticValueSourceResponse,
|
StaticValueSourceResponse,
|
||||||
SystemMetricsValueSourceResponse,
|
SystemMetricsValueSourceResponse,
|
||||||
|
TemplateInput,
|
||||||
|
TemplateValueSourceResponse,
|
||||||
ValueSourceCreate,
|
ValueSourceCreate,
|
||||||
ValueSourceListResponse,
|
ValueSourceListResponse,
|
||||||
ValueSourceResponse,
|
ValueSourceResponse,
|
||||||
@@ -46,6 +49,7 @@ from ledgrab.storage.value_source import (
|
|||||||
StaticColorValueSource,
|
StaticColorValueSource,
|
||||||
StaticValueSource,
|
StaticValueSource,
|
||||||
SystemMetricsValueSource,
|
SystemMetricsValueSource,
|
||||||
|
TemplateValueSource,
|
||||||
ValueSource,
|
ValueSource,
|
||||||
)
|
)
|
||||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||||
@@ -170,6 +174,7 @@ _RESPONSE_MAP = {
|
|||||||
min_ha_value=s.min_ha_value,
|
min_ha_value=s.min_ha_value,
|
||||||
max_ha_value=s.max_ha_value,
|
max_ha_value=s.max_ha_value,
|
||||||
smoothing=s.smoothing,
|
smoothing=s.smoothing,
|
||||||
|
normalize=s.normalize,
|
||||||
),
|
),
|
||||||
GradientMapValueSource: lambda s: GradientMapValueSourceResponse(
|
GradientMapValueSource: lambda s: GradientMapValueSourceResponse(
|
||||||
id=s.id,
|
id=s.id,
|
||||||
@@ -214,6 +219,7 @@ _RESPONSE_MAP = {
|
|||||||
sensor_label=s.sensor_label,
|
sensor_label=s.sensor_label,
|
||||||
poll_interval=s.poll_interval,
|
poll_interval=s.poll_interval,
|
||||||
smoothing=s.smoothing,
|
smoothing=s.smoothing,
|
||||||
|
normalize=s.normalize,
|
||||||
),
|
),
|
||||||
HTTPValueSource: lambda s: HTTPValueSourceResponse(
|
HTTPValueSource: lambda s: HTTPValueSourceResponse(
|
||||||
id=s.id,
|
id=s.id,
|
||||||
@@ -230,6 +236,23 @@ _RESPONSE_MAP = {
|
|||||||
min_value=s.min_value,
|
min_value=s.min_value,
|
||||||
max_value=s.max_value,
|
max_value=s.max_value,
|
||||||
smoothing=s.smoothing,
|
smoothing=s.smoothing,
|
||||||
|
normalize=s.normalize,
|
||||||
|
),
|
||||||
|
TemplateValueSource: lambda s: TemplateValueSourceResponse(
|
||||||
|
id=s.id,
|
||||||
|
name=s.name,
|
||||||
|
description=s.description,
|
||||||
|
tags=s.tags,
|
||||||
|
icon=getattr(s, "icon", "") or "",
|
||||||
|
icon_color=getattr(s, "icon_color", "") or "",
|
||||||
|
created_at=s.created_at,
|
||||||
|
updated_at=s.updated_at,
|
||||||
|
template=s.template,
|
||||||
|
inputs=[
|
||||||
|
TemplateInput(name=i["name"], value_source_id=i["value_source_id"]) for i in s.inputs
|
||||||
|
],
|
||||||
|
default_value=s.default_value,
|
||||||
|
eval_interval=s.eval_interval,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +418,13 @@ async def delete_value_source(
|
|||||||
if getattr(target, "brightness_value_source_id", "") == source_id:
|
if getattr(target, "brightness_value_source_id", "") == source_id:
|
||||||
raise ValueError(f"Cannot delete: referenced by target '{target.name}'")
|
raise ValueError(f"Cannot delete: referenced by target '{target.name}'")
|
||||||
|
|
||||||
|
# Check if any other value source (template / gradient_map) references it.
|
||||||
|
referencing = store.find_referencing_sources(source_id)
|
||||||
|
if referencing:
|
||||||
|
raise ValueError(
|
||||||
|
"Cannot delete: referenced by value source(s) " + ", ".join(referencing)
|
||||||
|
)
|
||||||
|
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
fire_entity_event("value_source", "deleted", source_id)
|
fire_entity_event("value_source", "deleted", source_id)
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
@@ -404,6 +434,121 @@ async def delete_value_source(
|
|||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateTemplateRequest(BaseModel):
|
||||||
|
"""Request body for the advisory template-validation endpoint."""
|
||||||
|
|
||||||
|
template: str = Field(description="Jinja2 expression to validate", max_length=2000)
|
||||||
|
inputs: list[TemplateInput] = Field(default_factory=list, description="Named input bindings")
|
||||||
|
id: str | None = Field(None, description="Source id when editing (enables cycle detection)")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/value-sources/validate-template", tags=["Value Sources"])
|
||||||
|
async def validate_template_value_source(
|
||||||
|
payload: ValidateTemplateRequest,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ValueSourceStore = Depends(get_value_source_store),
|
||||||
|
):
|
||||||
|
"""Validate a template expression + inputs without persisting anything.
|
||||||
|
|
||||||
|
Advisory: always returns HTTP 200 with ``{valid, error, errors, warnings,
|
||||||
|
variables}``. Powers the live editor validator (which must run before a
|
||||||
|
source exists), reusing the exact factory/store validation so the client and
|
||||||
|
server can never disagree. ``errors`` are blocking (save disabled);
|
||||||
|
``warnings`` are non-blocking (e.g. unknown/unbound inputs — create is
|
||||||
|
lenient about those).
|
||||||
|
"""
|
||||||
|
from ledgrab.utils.template_expr import (
|
||||||
|
TemplateValidationError,
|
||||||
|
extract_variables,
|
||||||
|
validate_input_name,
|
||||||
|
validate_template_expression,
|
||||||
|
)
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
# 1) Expression compiles and is safe (cost-guarded).
|
||||||
|
try:
|
||||||
|
validate_template_expression(payload.template)
|
||||||
|
except TemplateValidationError as e:
|
||||||
|
errors.append(str(e))
|
||||||
|
|
||||||
|
# 2) Input names valid / unique / non-reserved (blocking).
|
||||||
|
seen: set[str] = set()
|
||||||
|
for inp in payload.inputs:
|
||||||
|
try:
|
||||||
|
validate_input_name(inp.name)
|
||||||
|
except TemplateValidationError as e:
|
||||||
|
errors.append(str(e))
|
||||||
|
continue
|
||||||
|
if inp.name in seen:
|
||||||
|
errors.append(f"duplicate input name: {inp.name}")
|
||||||
|
seen.add(inp.name)
|
||||||
|
|
||||||
|
# 3) Referenced sources exist (non-blocking warning — create is lenient).
|
||||||
|
missing = [
|
||||||
|
inp.value_source_id
|
||||||
|
for inp in payload.inputs
|
||||||
|
if inp.value_source_id and not _source_exists(store, inp.value_source_id)
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
warnings.append("unknown value source(s): " + ", ".join(sorted(set(missing))))
|
||||||
|
|
||||||
|
# 4) Variables referenced in the expression but not bound to an input
|
||||||
|
# (blocking): at runtime they raise UndefinedError, so the template would
|
||||||
|
# silently always return default_value. This is almost always a typo, so
|
||||||
|
# flag it as an error rather than letting "valid" mislead the user.
|
||||||
|
used = set(extract_variables(payload.template))
|
||||||
|
undeclared = used - seen
|
||||||
|
if undeclared:
|
||||||
|
errors.append("unbound variable(s): " + ", ".join(sorted(undeclared)))
|
||||||
|
|
||||||
|
# 5) Cycle check when editing an existing source (blocking).
|
||||||
|
if payload.id:
|
||||||
|
child_ids = [i.value_source_id for i in payload.inputs if i.value_source_id]
|
||||||
|
try:
|
||||||
|
store.validate_nesting(payload.id, child_ids)
|
||||||
|
except ValueError as e:
|
||||||
|
errors.append(str(e))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": not errors,
|
||||||
|
"error": errors[0] if errors else None,
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
"variables": extract_variables(payload.template),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _source_exists(store: ValueSourceStore, source_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
store.get_source(source_id)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Per-stream (min, max) attribute pairs for the normalization range, so the
|
||||||
|
# preview can show where the raw value maps. Attribute names differ per stream
|
||||||
|
# type (historical), so probe each pair rather than assume one.
|
||||||
|
_RAW_RANGE_ATTRS: tuple[tuple[str, str], ...] = (
|
||||||
|
("_min_ha", "_max_ha"), # HAEntityValueStream
|
||||||
|
("_min_value", "_max_value"), # HTTPValueStream
|
||||||
|
("_min_val", "_max_val"), # SystemMetricsValueStream
|
||||||
|
("_min_game", "_max_game"), # GameEventValueStream
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_raw_range(stream) -> list | None:
|
||||||
|
"""Return ``[min, max]`` for the stream's normalization range, or None."""
|
||||||
|
for lo_attr, hi_attr in _RAW_RANGE_ATTRS:
|
||||||
|
lo = getattr(stream, lo_attr, None)
|
||||||
|
hi = getattr(stream, hi_attr, None)
|
||||||
|
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
|
||||||
|
return [lo, hi]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ===== REAL-TIME VALUE SOURCE TEST WEBSOCKET =====
|
# ===== REAL-TIME VALUE SOURCE TEST WEBSOCKET =====
|
||||||
|
|
||||||
|
|
||||||
@@ -467,10 +612,22 @@ async def test_value_source_ws(
|
|||||||
msg["input_value"] = round(stream.get_input_value(), 4)
|
msg["input_value"] = round(stream.get_input_value(), 4)
|
||||||
if hasattr(stream, "get_raw_value"):
|
if hasattr(stream, "get_raw_value"):
|
||||||
raw = stream.get_raw_value()
|
raw = stream.get_raw_value()
|
||||||
if raw is not None:
|
if isinstance(raw, bool):
|
||||||
msg["raw_value"] = round(raw, 4)
|
# bool is a subclass of int — send as-is (don't coerce/round).
|
||||||
if hasattr(stream, "_min_ha"):
|
msg["raw_value"] = raw
|
||||||
msg["raw_range"] = [stream._min_ha, stream._max_ha]
|
elif isinstance(raw, (int, float)):
|
||||||
|
msg["raw_value"] = round(float(raw), 4)
|
||||||
|
elif raw is not None:
|
||||||
|
# Non-numeric raw (e.g. an HTTP string payload) — send verbatim
|
||||||
|
# rather than crash the socket on round().
|
||||||
|
msg["raw_value"] = raw
|
||||||
|
rng = _stream_raw_range(stream)
|
||||||
|
if rng is not None:
|
||||||
|
msg["raw_range"] = rng
|
||||||
|
# Tell the client whether this source is currently normalizing, so the
|
||||||
|
# preview can render the value as a fraction vs a clamped passthrough.
|
||||||
|
if hasattr(stream, "_normalize_enabled"):
|
||||||
|
msg["normalized"] = bool(stream._normalize_enabled)
|
||||||
await websocket.send_json(msg)
|
await websocket.send_json(msg)
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
|||||||
@@ -11,13 +11,33 @@ class RuleSchema(BaseModel):
|
|||||||
|
|
||||||
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
|
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
|
||||||
# Application rule fields
|
# Application rule fields
|
||||||
apps: List[str] | None = Field(None, description="Process names (for application rule)")
|
apps: List[str] | None = Field(
|
||||||
|
None,
|
||||||
|
description=(
|
||||||
|
"App identifiers for the application rule. Platform-specific and not "
|
||||||
|
"portable: process names on Windows (e.g. 'chrome.exe'), package names "
|
||||||
|
"on Android (e.g. 'com.android.chrome'). Matched case-insensitively."
|
||||||
|
),
|
||||||
|
)
|
||||||
match_type: str | None = Field(
|
match_type: str | None = Field(
|
||||||
None, description="'running' or 'topmost' (for application rule)"
|
None,
|
||||||
|
description=(
|
||||||
|
"'running', 'topmost', 'fullscreen', or 'topmost_fullscreen' (application "
|
||||||
|
"rule). On Android only the foreground app is detectable, so all values "
|
||||||
|
"behave as 'foreground'."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
# Time-of-day rule fields
|
# Time-of-day rule fields
|
||||||
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
|
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
|
||||||
end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
|
end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
|
||||||
|
days_of_week: list[int] | None = Field(
|
||||||
|
None,
|
||||||
|
description="Active weekdays for time_of_day rule (0=Mon..6=Sun). Empty/null = every day.",
|
||||||
|
)
|
||||||
|
timezone: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="IANA timezone for time_of_day rule (e.g. 'Europe/Berlin'). Empty = server local.",
|
||||||
|
)
|
||||||
# System idle rule fields
|
# System idle rule fields
|
||||||
idle_minutes: int | None = Field(
|
idle_minutes: int | None = Field(
|
||||||
None, description="Idle timeout in minutes (for system_idle rule)"
|
None, description="Idle timeout in minutes (for system_idle rule)"
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Pydantic schemas for the calibration session and solver API."""
|
||||||
|
|
||||||
|
from typing import Annotated, List, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSessionStartRequest(BaseModel):
|
||||||
|
"""Request to start a calibration session on a device."""
|
||||||
|
|
||||||
|
device_id: str = Field(description="ID of the device to drive during calibration")
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSessionPositionRequest(BaseModel):
|
||||||
|
"""Request to advance the chase pixel to a specific LED index."""
|
||||||
|
|
||||||
|
index: int = Field(ge=0, description="LED index to illuminate (0-based)")
|
||||||
|
window: int = Field(
|
||||||
|
default=1,
|
||||||
|
ge=0,
|
||||||
|
le=10,
|
||||||
|
description="Number of dim neighbour LEDs to show on each side (0 = centre only)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSessionStateResponse(BaseModel):
|
||||||
|
"""Current calibration session state."""
|
||||||
|
|
||||||
|
active: bool = Field(description="Whether a session is currently active")
|
||||||
|
device_id: str | None = Field(None, description="Device being driven (null if inactive)")
|
||||||
|
led_count: int = Field(0, description="LED count of the active device")
|
||||||
|
prior_target_id: str | None = Field(
|
||||||
|
None, description="Target that was running before the session (will be restored on stop)"
|
||||||
|
)
|
||||||
|
last_activity: str | None = Field(
|
||||||
|
None, description="ISO timestamp of the last position call (null if inactive)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Solver ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSolveRequest(BaseModel):
|
||||||
|
"""Request to solve a CalibrationConfig from 4 corner tap indices.
|
||||||
|
|
||||||
|
Provide either *device_id* (the server derives led_count from the device)
|
||||||
|
or *led_count* directly. *device_id* takes precedence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
device_id: str | None = Field(
|
||||||
|
None,
|
||||||
|
description=("Device ID to derive led_count from (preferred over led_count field)"),
|
||||||
|
)
|
||||||
|
led_count: int | None = Field(
|
||||||
|
None,
|
||||||
|
ge=1,
|
||||||
|
description="Total LED count (used when device_id is not provided)",
|
||||||
|
)
|
||||||
|
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field(
|
||||||
|
description="Starting corner of the strip"
|
||||||
|
)
|
||||||
|
layout: Literal["clockwise", "counterclockwise"] = Field(
|
||||||
|
description="Winding direction of the strip"
|
||||||
|
)
|
||||||
|
corner_indices: List[Annotated[int, Field(ge=0)]] = Field(
|
||||||
|
description=(
|
||||||
|
"Four strip indices — one per screen corner — in the strip-walk order "
|
||||||
|
"defined by (start_position, layout). Index 0 of the strip is the "
|
||||||
|
"start corner; the four tap positions are recorded in strip order "
|
||||||
|
"beginning from that start corner (the solver lays edges out from "
|
||||||
|
"led_start=0, so a non-zero physical start would require the `offset` "
|
||||||
|
"field rather than a shifted corner_indices[0]). Each element must be "
|
||||||
|
"non-negative (ge=0); out-of-range values yield a 422."
|
||||||
|
),
|
||||||
|
min_length=4,
|
||||||
|
max_length=4,
|
||||||
|
)
|
||||||
|
offset: int = Field(
|
||||||
|
default=0,
|
||||||
|
ge=0,
|
||||||
|
description="Physical LED offset (0 = no offset)",
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _require_device_or_led_count(self) -> "CalibrationSolveRequest":
|
||||||
|
if self.device_id is None and self.led_count is None:
|
||||||
|
raise ValueError("Either 'device_id' or 'led_count' must be provided")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSolvedResponse(BaseModel):
|
||||||
|
"""Solved calibration config in simple-mode dict form."""
|
||||||
|
|
||||||
|
mode: Literal["simple"] = "simple"
|
||||||
|
layout: str = Field(description="Winding direction")
|
||||||
|
start_position: str = Field(description="Starting corner")
|
||||||
|
leds_top: int = Field(ge=0, description="LEDs on the top edge")
|
||||||
|
leds_right: int = Field(ge=0, description="LEDs on the right edge")
|
||||||
|
leds_bottom: int = Field(ge=0, description="LEDs on the bottom edge")
|
||||||
|
leds_left: int = Field(ge=0, description="LEDs on the left edge")
|
||||||
|
offset: int = Field(ge=0, description="Physical LED offset")
|
||||||
@@ -344,6 +344,18 @@ class Calibration(BaseModel):
|
|||||||
border_width: int = Field(
|
border_width: int = Field(
|
||||||
default=10, ge=1, le=100, description="Border width in pixels for edge sampling"
|
default=10, ge=1, le=100, description="Border width in pixels for edge sampling"
|
||||||
)
|
)
|
||||||
|
roi_x: float = Field(
|
||||||
|
default=0.0, ge=0.0, le=1.0, description="ROI left edge as a fraction of width (0..1)"
|
||||||
|
)
|
||||||
|
roi_y: float = Field(
|
||||||
|
default=0.0, ge=0.0, le=1.0, description="ROI top edge as a fraction of height (0..1)"
|
||||||
|
)
|
||||||
|
roi_width: float = Field(
|
||||||
|
default=1.0, gt=0.0, le=1.0, description="ROI width as a fraction of width (0..1)"
|
||||||
|
)
|
||||||
|
roi_height: float = Field(
|
||||||
|
default=1.0, gt=0.0, le=1.0, description="ROI height as a fraction of height (0..1)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalibrationTestModeRequest(BaseModel):
|
class CalibrationTestModeRequest(BaseModel):
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
|
|||||||
adaptive_fps: bool = Field(
|
adaptive_fps: bool = Field(
|
||||||
default=False, description="Auto-reduce FPS when device is unresponsive"
|
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, udp, or http)")
|
||||||
|
max_milliamps: int = Field(
|
||||||
|
default=0, description="ABL: PSU current budget in mA (0 = unlimited)"
|
||||||
|
)
|
||||||
|
milliamps_per_led: int = Field(default=55, description="ABL: full-white draw of one LED in mA")
|
||||||
|
|
||||||
|
|
||||||
class HALightOutputTargetResponse(_OutputTargetResponseBase):
|
class HALightOutputTargetResponse(_OutputTargetResponseBase):
|
||||||
@@ -233,8 +237,20 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
|
|||||||
)
|
)
|
||||||
protocol: str = Field(
|
protocol: str = Field(
|
||||||
default="ddp",
|
default="ddp",
|
||||||
pattern="^(ddp|http)$",
|
pattern="^(ddp|http|udp)$",
|
||||||
description="Send protocol: ddp (UDP) or http (JSON API)",
|
description="Send protocol: ddp (DDP/UDP), udp (WLED native realtime UDP), or http (JSON API)",
|
||||||
|
)
|
||||||
|
max_milliamps: int = Field(
|
||||||
|
default=0,
|
||||||
|
ge=0,
|
||||||
|
le=200000,
|
||||||
|
description="Automatic brightness limiting: PSU current budget in mA (0 = unlimited)",
|
||||||
|
)
|
||||||
|
milliamps_per_led: int = Field(
|
||||||
|
default=55,
|
||||||
|
ge=1,
|
||||||
|
le=200,
|
||||||
|
description="ABL: estimated full-white draw of a single LED, in mA",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -370,7 +386,15 @@ class LedOutputTargetUpdate(_OutputTargetUpdateBase):
|
|||||||
None, description="Auto-reduce FPS when device is unresponsive"
|
None, description="Auto-reduce FPS when device is unresponsive"
|
||||||
)
|
)
|
||||||
protocol: str | None = Field(
|
protocol: str | None = Field(
|
||||||
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
|
None,
|
||||||
|
pattern="^(ddp|http|udp)$",
|
||||||
|
description="Send protocol: ddp (DDP/UDP), udp (WLED native realtime UDP), or http (JSON API)",
|
||||||
|
)
|
||||||
|
max_milliamps: int | None = Field(
|
||||||
|
None, ge=0, le=200000, description="ABL: PSU current budget in mA (0 = unlimited)"
|
||||||
|
)
|
||||||
|
milliamps_per_led: int | None = Field(
|
||||||
|
None, ge=1, le=200, description="ABL: full-white draw of one LED in mA"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class PostprocessingTemplateResponse(BaseModel):
|
|||||||
max_length=32,
|
max_length=32,
|
||||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
||||||
)
|
)
|
||||||
|
is_builtin: bool = Field(default=False, description="True for read-only curated 'look' presets")
|
||||||
|
|
||||||
|
|
||||||
class PostprocessingTemplateListResponse(BaseModel):
|
class PostprocessingTemplateListResponse(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Scene playlist API schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ledgrab.storage.scene_playlist import (
|
||||||
|
MAX_DURATION_SECONDS,
|
||||||
|
MIN_DURATION_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistItemSchema(BaseModel):
|
||||||
|
scene_preset_id: str = Field(min_length=1, description="Referenced scene preset id")
|
||||||
|
duration_seconds: float = Field(
|
||||||
|
default=30.0,
|
||||||
|
ge=MIN_DURATION_SECONDS,
|
||||||
|
le=MAX_DURATION_SECONDS,
|
||||||
|
description="How long to hold this scene before advancing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistCreate(BaseModel):
|
||||||
|
"""Create a scene playlist."""
|
||||||
|
|
||||||
|
name: str = Field(description="Playlist name", min_length=1, max_length=100)
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
items: List[PlaylistItemSchema] = Field(
|
||||||
|
default_factory=list, description="Ordered playlist items"
|
||||||
|
)
|
||||||
|
loop: bool = Field(default=True, description="Restart from the first item after the last")
|
||||||
|
shuffle: bool = Field(default=False, description="Randomise item order each cycle")
|
||||||
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
icon: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=64,
|
||||||
|
description="Icon id from the curated icon library. Pass empty string to clear.",
|
||||||
|
)
|
||||||
|
icon_color: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=32,
|
||||||
|
description="Optional CSS color override for the icon.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistUpdate(BaseModel):
|
||||||
|
"""Update scene playlist metadata, items, and playback flags."""
|
||||||
|
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
description: str | None = Field(None, max_length=500)
|
||||||
|
items: List[PlaylistItemSchema] | None = Field(None, description="Replace the item list")
|
||||||
|
loop: bool | None = None
|
||||||
|
shuffle: bool | None = None
|
||||||
|
order: int | None = None
|
||||||
|
tags: List[str] | None = None
|
||||||
|
icon: str | None = Field(None, max_length=64)
|
||||||
|
icon_color: str | None = Field(None, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistRuntimeStateSchema(BaseModel):
|
||||||
|
is_running: bool = False
|
||||||
|
playlist_id: str | None = None
|
||||||
|
playlist_name: str | None = None
|
||||||
|
current_index: int = 0
|
||||||
|
item_count: int = 0
|
||||||
|
current_preset_id: str | None = None
|
||||||
|
started_at: datetime | None = None
|
||||||
|
step_started_at: datetime | None = None
|
||||||
|
step_duration: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistResponse(BaseModel):
|
||||||
|
"""Scene playlist with items and runtime running flag."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
items: List[PlaylistItemSchema]
|
||||||
|
loop: bool
|
||||||
|
shuffle: bool
|
||||||
|
order: int
|
||||||
|
tags: List[str] = Field(default_factory=list)
|
||||||
|
icon: str | None = Field(None, max_length=64)
|
||||||
|
icon_color: str | None = Field(None, max_length=32)
|
||||||
|
is_running: bool = Field(default=False, description="True if this playlist is cycling now")
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ScenePlaylistListResponse(BaseModel):
|
||||||
|
playlists: List[ScenePlaylistResponse]
|
||||||
|
count: int
|
||||||
|
state: PlaylistRuntimeStateSchema
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Pydantic schemas for the setup scaffold endpoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ScaffoldRequest(BaseModel):
|
||||||
|
"""Request body for ``POST /api/v1/setup/scaffold``.
|
||||||
|
|
||||||
|
Creates a full capture-to-output chain:
|
||||||
|
capture template → picture source → picture color-strip source → LED output target
|
||||||
|
|
||||||
|
``device_id`` must reference an existing, validated device (created via
|
||||||
|
``POST /api/v1/devices``). The wizard flow is: discover/create the device
|
||||||
|
via the canonical device endpoint first, then call scaffold with the
|
||||||
|
resulting ``device_id``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Existing device (required) ────────────────────────────────────────────
|
||||||
|
device_id: str = Field(
|
||||||
|
description="ID of an existing device to wire into the chain.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Capture / picture source ──────────────────────────────────────────────
|
||||||
|
display_index: int = Field(
|
||||||
|
0,
|
||||||
|
ge=0,
|
||||||
|
le=63,
|
||||||
|
description="Index of the monitor to capture (0 = primary; max 63).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Optional calibration override ─────────────────────────────────────────
|
||||||
|
calibration: dict[str, Any] | None = Field(
|
||||||
|
None,
|
||||||
|
description=(
|
||||||
|
"Optional CalibrationConfig dict to use for the color-strip source. "
|
||||||
|
"When omitted, ``create_default_calibration(led_count)`` is used."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScaffoldResponse(BaseModel):
|
||||||
|
"""IDs of every entity created (or reused) by the scaffold.
|
||||||
|
|
||||||
|
``capture_template_reused`` is ``True`` when the scaffold matched an
|
||||||
|
existing template by engine type instead of creating a new one.
|
||||||
|
The device is always pre-existing (created via the canonical device endpoint
|
||||||
|
before calling scaffold).
|
||||||
|
"""
|
||||||
|
|
||||||
|
device_id: str = Field(description="Device id (pre-existing).")
|
||||||
|
capture_template_id: str = Field(description="Capture template id.")
|
||||||
|
picture_source_id: str = Field(description="Raw picture source id.")
|
||||||
|
color_strip_source_id: str = Field(description="Picture color-strip source id.")
|
||||||
|
output_target_id: str = Field(description="LED output target id.")
|
||||||
|
|
||||||
|
capture_template_reused: bool = Field(
|
||||||
|
False,
|
||||||
|
description="True when an existing matching capture template was reused.",
|
||||||
|
)
|
||||||
@@ -68,6 +68,42 @@ class ProcessListResponse(BaseModel):
|
|||||||
count: int = Field(description="Number of unique processes")
|
count: int = Field(description="Number of unique processes")
|
||||||
|
|
||||||
|
|
||||||
|
class InstalledAppItem(BaseModel):
|
||||||
|
"""A launchable Android app, for the automation app picker."""
|
||||||
|
|
||||||
|
package: str = Field(description="Android package name, e.g. 'com.netflix.mediaclient'")
|
||||||
|
label: str = Field(description="Human-readable app label, e.g. 'Netflix'")
|
||||||
|
|
||||||
|
|
||||||
|
class InstalledAppsResponse(BaseModel):
|
||||||
|
"""Launchable apps for the application-rule picker (Android only; empty elsewhere)."""
|
||||||
|
|
||||||
|
apps: List[InstalledAppItem] = Field(description="Launchable apps, sorted by label")
|
||||||
|
count: int = Field(description="Number of apps")
|
||||||
|
|
||||||
|
|
||||||
|
class SystemInfoResponse(BaseModel):
|
||||||
|
"""Platform capability signal for the frontend (automation editor).
|
||||||
|
|
||||||
|
Lets the application-rule editor choose the right app source and matching
|
||||||
|
semantics per platform, and surface the Usage-Access permission state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
is_android: bool = Field(description="True when the server runs on Android (Chaquopy)")
|
||||||
|
app_match_kind: Literal["process", "package"] = Field(
|
||||||
|
description=(
|
||||||
|
"What ApplicationRule.apps values represent: 'process' names on desktop, "
|
||||||
|
"'package' names on Android."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
usage_access_granted: bool = Field(
|
||||||
|
description=(
|
||||||
|
"Android: whether PACKAGE_USAGE_STATS (Usage Access) is granted, gating "
|
||||||
|
"foreground-app detection. Always True (not applicable) off-Android."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GpuInfo(BaseModel):
|
class GpuInfo(BaseModel):
|
||||||
"""GPU performance information."""
|
"""GPU performance information."""
|
||||||
|
|
||||||
@@ -158,35 +194,6 @@ class BackupListResponse(BaseModel):
|
|||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
# ─── MQTT schemas ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class MQTTSettingsResponse(BaseModel):
|
|
||||||
"""MQTT broker settings response (password is masked)."""
|
|
||||||
|
|
||||||
enabled: bool = Field(description="Whether MQTT is enabled")
|
|
||||||
broker_host: str = Field(description="MQTT broker hostname or IP")
|
|
||||||
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
|
|
||||||
username: str = Field(description="MQTT username (empty = anonymous)")
|
|
||||||
password_set: bool = Field(description="Whether a password is configured")
|
|
||||||
client_id: str = Field(description="MQTT client ID")
|
|
||||||
base_topic: str = Field(description="Base topic prefix")
|
|
||||||
|
|
||||||
|
|
||||||
class MQTTSettingsRequest(BaseModel):
|
|
||||||
"""MQTT broker settings update request."""
|
|
||||||
|
|
||||||
enabled: bool = Field(description="Whether MQTT is enabled")
|
|
||||||
broker_host: str = Field(description="MQTT broker hostname or IP")
|
|
||||||
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
|
|
||||||
username: str = Field(default="", description="MQTT username (empty = anonymous)")
|
|
||||||
password: str = Field(
|
|
||||||
default="", description="MQTT password (empty = keep existing if omitted)"
|
|
||||||
)
|
|
||||||
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
|
||||||
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
|
||||||
|
|
||||||
|
|
||||||
# ─── External URL schema ───────────────────────────────────────
|
# ─── External URL schema ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ from pydantic import BaseModel, Discriminator, Field, Tag
|
|||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateInput(BaseModel):
|
||||||
|
"""A single ``{name -> value_source_id}`` binding for a template source."""
|
||||||
|
|
||||||
|
name: str = Field(
|
||||||
|
description="Variable name used in the expression (valid identifier)",
|
||||||
|
min_length=1,
|
||||||
|
max_length=64,
|
||||||
|
)
|
||||||
|
value_source_id: str = Field("", description="Bound value source ID (empty = unbound)")
|
||||||
|
|
||||||
|
|
||||||
class _ValueSourceResponseBase(BaseModel):
|
class _ValueSourceResponseBase(BaseModel):
|
||||||
"""Shared fields for all value source responses."""
|
"""Shared fields for all value source responses."""
|
||||||
|
|
||||||
@@ -120,6 +131,9 @@ class HAEntityValueSourceResponse(_ValueSourceResponseBase):
|
|||||||
min_ha_value: float = Field(description="Raw HA value mapped to output 0.0")
|
min_ha_value: float = Field(description="Raw HA value mapped to output 0.0")
|
||||||
max_ha_value: float = Field(description="Raw HA value mapped to output 1.0")
|
max_ha_value: float = Field(description="Raw HA value mapped to output 1.0")
|
||||||
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
||||||
|
normalize: bool = Field(
|
||||||
|
description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GradientMapValueSourceResponse(_ValueSourceResponseBase):
|
class GradientMapValueSourceResponse(_ValueSourceResponseBase):
|
||||||
@@ -149,6 +163,9 @@ class SystemMetricsValueSourceResponse(_ValueSourceResponseBase):
|
|||||||
sensor_label: str = Field(description="Sensor label for cpu_temp/fan_speed")
|
sensor_label: str = Field(description="Sensor label for cpu_temp/fan_speed")
|
||||||
poll_interval: float = Field(description="Seconds between reads")
|
poll_interval: float = Field(description="Seconds between reads")
|
||||||
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
||||||
|
normalize: bool = Field(
|
||||||
|
description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HTTPValueSourceResponse(_ValueSourceResponseBase):
|
class HTTPValueSourceResponse(_ValueSourceResponseBase):
|
||||||
@@ -160,6 +177,22 @@ class HTTPValueSourceResponse(_ValueSourceResponseBase):
|
|||||||
min_value: float = Field(description="Raw value mapped to output 0.0")
|
min_value: float = Field(description="Raw value mapped to output 0.0")
|
||||||
max_value: float = Field(description="Raw value mapped to output 1.0")
|
max_value: float = Field(description="Raw value mapped to output 1.0")
|
||||||
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
|
||||||
|
normalize: bool = Field(
|
||||||
|
description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateValueSourceResponse(_ValueSourceResponseBase):
|
||||||
|
source_type: Literal["template"] = "template"
|
||||||
|
return_type: Literal["float"] = "float"
|
||||||
|
template: str = Field(description="Jinja2 expression")
|
||||||
|
inputs: List[TemplateInput] = Field(
|
||||||
|
default_factory=list, description="Named value-source bindings"
|
||||||
|
)
|
||||||
|
default_value: float = Field(description="Fallback when the expression errors (0.0-1.0)")
|
||||||
|
eval_interval: float | None = Field(
|
||||||
|
None, description="Re-eval throttle in seconds (None/0 = every poll)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ValueSourceResponse = Annotated[
|
ValueSourceResponse = Annotated[
|
||||||
@@ -176,7 +209,8 @@ ValueSourceResponse = Annotated[
|
|||||||
| Annotated[GradientMapValueSourceResponse, Tag("gradient_map")]
|
| Annotated[GradientMapValueSourceResponse, Tag("gradient_map")]
|
||||||
| Annotated[CSSExtractValueSourceResponse, Tag("css_extract")]
|
| Annotated[CSSExtractValueSourceResponse, Tag("css_extract")]
|
||||||
| Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")]
|
| Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")]
|
||||||
| Annotated[HTTPValueSourceResponse, Tag("http")],
|
| Annotated[HTTPValueSourceResponse, Tag("http")]
|
||||||
|
| Annotated[TemplateValueSourceResponse, Tag("template")],
|
||||||
Discriminator("source_type"),
|
Discriminator("source_type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -292,6 +326,9 @@ class HAEntityValueSourceCreate(_ValueSourceCreateBase):
|
|||||||
min_ha_value: float = Field(0.0, description="Raw HA value mapped to output 0.0")
|
min_ha_value: float = Field(0.0, description="Raw HA value mapped to output 0.0")
|
||||||
max_ha_value: float = Field(100.0, description="Raw HA value mapped to output 1.0")
|
max_ha_value: float = Field(100.0, description="Raw HA value mapped to output 1.0")
|
||||||
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
normalize: bool = Field(
|
||||||
|
True, description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GradientMapValueSourceCreate(_ValueSourceCreateBase):
|
class GradientMapValueSourceCreate(_ValueSourceCreateBase):
|
||||||
@@ -318,6 +355,9 @@ class SystemMetricsValueSourceCreate(_ValueSourceCreateBase):
|
|||||||
sensor_label: str = Field("", description="Sensor label for cpu_temp/fan_speed")
|
sensor_label: str = Field("", description="Sensor label for cpu_temp/fan_speed")
|
||||||
poll_interval: float = Field(1.0, description="Poll interval in seconds", ge=0.1, le=60.0)
|
poll_interval: float = Field(1.0, description="Poll interval in seconds", ge=0.1, le=60.0)
|
||||||
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
normalize: bool = Field(
|
||||||
|
True, description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HTTPValueSourceCreate(_ValueSourceCreateBase):
|
class HTTPValueSourceCreate(_ValueSourceCreateBase):
|
||||||
@@ -328,6 +368,30 @@ class HTTPValueSourceCreate(_ValueSourceCreateBase):
|
|||||||
min_value: float = Field(0.0, description="Raw value mapped to output 0.0")
|
min_value: float = Field(0.0, description="Raw value mapped to output 0.0")
|
||||||
max_value: float = Field(100.0, description="Raw value mapped to output 1.0")
|
max_value: float = Field(100.0, description="Raw value mapped to output 1.0")
|
||||||
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||||
|
normalize: bool = Field(
|
||||||
|
True, description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateValueSourceCreate(_ValueSourceCreateBase):
|
||||||
|
source_type: Literal["template"] = "template"
|
||||||
|
template: str = Field(
|
||||||
|
description=(
|
||||||
|
"Jinja2 expression (no statements/blocks). Inputs are exposed by name and via "
|
||||||
|
"raw[name]; globals: min, max, abs, round, clamp(x, lo=0, hi=1)."
|
||||||
|
),
|
||||||
|
min_length=1,
|
||||||
|
max_length=2000,
|
||||||
|
)
|
||||||
|
inputs: List[TemplateInput] = Field(
|
||||||
|
default_factory=list, description="Named value-source bindings"
|
||||||
|
)
|
||||||
|
default_value: float = Field(
|
||||||
|
0.0, description="Fallback when the expression errors (0.0-1.0)", ge=0.0, le=1.0
|
||||||
|
)
|
||||||
|
eval_interval: float | None = Field(
|
||||||
|
None, description="Re-eval throttle in seconds (None/0 = every poll)", ge=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ValueSourceCreate = Annotated[
|
ValueSourceCreate = Annotated[
|
||||||
@@ -344,7 +408,8 @@ ValueSourceCreate = Annotated[
|
|||||||
| Annotated[GradientMapValueSourceCreate, Tag("gradient_map")]
|
| Annotated[GradientMapValueSourceCreate, Tag("gradient_map")]
|
||||||
| Annotated[CSSExtractValueSourceCreate, Tag("css_extract")]
|
| Annotated[CSSExtractValueSourceCreate, Tag("css_extract")]
|
||||||
| Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")]
|
| Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")]
|
||||||
| Annotated[HTTPValueSourceCreate, Tag("http")],
|
| Annotated[HTTPValueSourceCreate, Tag("http")]
|
||||||
|
| Annotated[TemplateValueSourceCreate, Tag("template")],
|
||||||
Discriminator("source_type"),
|
Discriminator("source_type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -452,6 +517,9 @@ class HAEntityValueSourceUpdate(_ValueSourceUpdateBase):
|
|||||||
min_ha_value: float | None = Field(None, description="Min HA value")
|
min_ha_value: float | None = Field(None, description="Min HA value")
|
||||||
max_ha_value: float | None = Field(None, description="Max HA value")
|
max_ha_value: float | None = Field(None, description="Max HA value")
|
||||||
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||||
|
normalize: bool | None = Field(
|
||||||
|
None, description="Rescale raw via min/max (false = clamp as-is)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
|
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
|
||||||
@@ -478,6 +546,9 @@ class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
|
|||||||
sensor_label: str | None = Field(None, description="Sensor label")
|
sensor_label: str | None = Field(None, description="Sensor label")
|
||||||
poll_interval: float | None = Field(None, description="Poll interval", ge=0.1, le=60.0)
|
poll_interval: float | None = Field(None, description="Poll interval", ge=0.1, le=60.0)
|
||||||
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||||
|
normalize: bool | None = Field(
|
||||||
|
None, description="Rescale raw via min/max (false = clamp as-is)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
|
class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
|
||||||
@@ -488,6 +559,23 @@ class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
|
|||||||
min_value: float | None = Field(None, description="Raw value mapped to 0.0")
|
min_value: float | None = Field(None, description="Raw value mapped to 0.0")
|
||||||
max_value: float | None = Field(None, description="Raw value mapped to 1.0")
|
max_value: float | None = Field(None, description="Raw value mapped to 1.0")
|
||||||
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
smoothing: float | None = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
|
||||||
|
normalize: bool | None = Field(
|
||||||
|
None, description="Rescale raw via min/max (false = clamp as-is)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateValueSourceUpdate(_ValueSourceUpdateBase):
|
||||||
|
source_type: Literal["template"] = "template"
|
||||||
|
template: str | None = Field(
|
||||||
|
None, description="Jinja2 expression", min_length=1, max_length=2000
|
||||||
|
)
|
||||||
|
inputs: List[TemplateInput] | None = Field(None, description="Named value-source bindings")
|
||||||
|
default_value: float | None = Field(
|
||||||
|
None, description="Fallback when the expression errors (0.0-1.0)", ge=0.0, le=1.0
|
||||||
|
)
|
||||||
|
eval_interval: float | None = Field(
|
||||||
|
None, description="Re-eval throttle in seconds (0 = every poll)", ge=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ValueSourceUpdate = Annotated[
|
ValueSourceUpdate = Annotated[
|
||||||
@@ -504,7 +592,8 @@ ValueSourceUpdate = Annotated[
|
|||||||
| Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")]
|
| Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")]
|
||||||
| Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")]
|
| Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")]
|
||||||
| Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")]
|
| Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")]
|
||||||
| Annotated[HTTPValueSourceUpdate, Tag("http")],
|
| Annotated[HTTPValueSourceUpdate, Tag("http")]
|
||||||
|
| Annotated[TemplateValueSourceUpdate, Tag("template")],
|
||||||
Discriminator("source_type"),
|
Discriminator("source_type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
_has_sounddevice = False
|
_has_sounddevice = False
|
||||||
|
|
||||||
|
# Android playback-capture engine — pure Python (numpy only), but the
|
||||||
|
# guard keeps the registration pattern uniform and tolerant of any future
|
||||||
|
# import-time dependency.
|
||||||
|
try:
|
||||||
|
from ledgrab.core.audio.android_audio_engine import (
|
||||||
|
AndroidAudioEngine,
|
||||||
|
AndroidAudioCaptureStream,
|
||||||
|
)
|
||||||
|
|
||||||
|
_has_android_audio = True
|
||||||
|
except ImportError:
|
||||||
|
_has_android_audio = False
|
||||||
|
|
||||||
from ledgrab.core.audio.demo_engine import DemoAudioEngine, DemoAudioCaptureStream
|
from ledgrab.core.audio.demo_engine import DemoAudioEngine, DemoAudioCaptureStream
|
||||||
|
|
||||||
# Auto-register available engines
|
# Auto-register available engines
|
||||||
@@ -45,6 +58,8 @@ if _has_wasapi:
|
|||||||
AudioEngineRegistry.register(WasapiEngine)
|
AudioEngineRegistry.register(WasapiEngine)
|
||||||
if _has_sounddevice:
|
if _has_sounddevice:
|
||||||
AudioEngineRegistry.register(SounddeviceEngine)
|
AudioEngineRegistry.register(SounddeviceEngine)
|
||||||
|
if _has_android_audio:
|
||||||
|
AudioEngineRegistry.register(AndroidAudioEngine)
|
||||||
AudioEngineRegistry.register(DemoAudioEngine)
|
AudioEngineRegistry.register(DemoAudioEngine)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -65,3 +80,5 @@ if _has_wasapi:
|
|||||||
__all__ += ["WasapiEngine", "WasapiCaptureStream"]
|
__all__ += ["WasapiEngine", "WasapiCaptureStream"]
|
||||||
if _has_sounddevice:
|
if _has_sounddevice:
|
||||||
__all__ += ["SounddeviceEngine", "SounddeviceCaptureStream"]
|
__all__ += ["SounddeviceEngine", "SounddeviceCaptureStream"]
|
||||||
|
if _has_android_audio:
|
||||||
|
__all__ += ["AndroidAudioEngine", "AndroidAudioCaptureStream"]
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""Android playback-capture audio engine.
|
||||||
|
|
||||||
|
Receives PCM pushed from Kotlin (via Chaquopy) through a module-level
|
||||||
|
sample queue. The Kotlin layer captures system playback audio with
|
||||||
|
``AudioRecord`` + ``AudioPlaybackCaptureConfiguration`` (reusing the
|
||||||
|
app's ``MediaProjection`` token) and calls :func:`push_samples` with
|
||||||
|
interleaved float32 PCM for each fixed-size block.
|
||||||
|
|
||||||
|
Mirrors the screen-capture bridge
|
||||||
|
(``core/capture_engines/mediaprojection_engine.py``): a module-level
|
||||||
|
queue plus ``configure`` / ``push_samples`` / ``shutdown`` filled by
|
||||||
|
Kotlin, consumed through the standard :class:`AudioCaptureStreamBase`
|
||||||
|
interface so :class:`~ledgrab.core.audio.audio_capture.ManagedAudioStream`
|
||||||
|
and :class:`~ledgrab.core.audio.analysis.AudioAnalyzer` work unchanged.
|
||||||
|
|
||||||
|
This engine is only available when running inside the LedGrab Android
|
||||||
|
app, which has set up the sample queue via :func:`configure`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import queue
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ledgrab.core.audio.base import (
|
||||||
|
AudioCaptureEngine,
|
||||||
|
AudioCaptureStreamBase,
|
||||||
|
AudioDeviceInfo,
|
||||||
|
)
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.platform import is_android
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sample queue — the bridge between Kotlin and Python
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_pcm_queue: "queue.Queue[np.ndarray]" = queue.Queue(maxsize=8)
|
||||||
|
_sample_rate = 48000
|
||||||
|
_channels = 2
|
||||||
|
_chunk_size = 1024
|
||||||
|
_active = False
|
||||||
|
_frames_received = 0
|
||||||
|
|
||||||
|
|
||||||
|
def configure(sample_rate: int, channels: int, chunk_size: int) -> None:
|
||||||
|
"""Set the stream format. Called from Kotlin before frames flow.
|
||||||
|
|
||||||
|
Drains any stale PCM from a previous capture session so the first
|
||||||
|
chunk after a restart is actually current. ``channels`` /
|
||||||
|
``sample_rate`` should be the values the Kotlin ``AudioRecord``
|
||||||
|
actually negotiated (which can differ from the requested values,
|
||||||
|
e.g. a stereo request that falls back to mono) — the analyzer keys
|
||||||
|
off these, so they must match the interleaving of pushed samples.
|
||||||
|
"""
|
||||||
|
global _sample_rate, _channels, _chunk_size, _active, _frames_received
|
||||||
|
while not _pcm_queue.empty():
|
||||||
|
try:
|
||||||
|
_pcm_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
_sample_rate = sample_rate
|
||||||
|
_channels = max(1, channels)
|
||||||
|
_chunk_size = max(1, chunk_size)
|
||||||
|
_frames_received = 0
|
||||||
|
_active = True
|
||||||
|
logger.info(
|
||||||
|
"Android audio engine configured: sr=%d channels=%d chunk=%d",
|
||||||
|
_sample_rate,
|
||||||
|
_channels,
|
||||||
|
_chunk_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def push_samples(pcm_float32: bytes) -> None:
|
||||||
|
"""Push one interleaved float32 PCM block from Kotlin.
|
||||||
|
|
||||||
|
The byte buffer is interpreted as native-endian float32 (Kotlin
|
||||||
|
packs little-endian; all Android ABIs are little-endian). Drops the
|
||||||
|
oldest queued block if the consumer is slow (non-blocking).
|
||||||
|
|
||||||
|
Defensive framing: the downstream :class:`AudioAnalyzer` reshapes to
|
||||||
|
``(-1, channels)`` and copies into ``chunk_size``-sized scratch
|
||||||
|
buffers, so it raises on a block whose length is not a whole number
|
||||||
|
of frames or that exceeds ``chunk_size`` frames. We trim to a whole
|
||||||
|
multiple of ``_channels`` and clamp to ``_chunk_size`` frames so a
|
||||||
|
malformed push can never crash the capture thread.
|
||||||
|
"""
|
||||||
|
global _frames_received
|
||||||
|
# np.frombuffer raises if the length isn't a whole number of float32s.
|
||||||
|
# Kotlin always pushes complete blocks, but guard so a malformed buffer is
|
||||||
|
# dropped here rather than surfacing as an exception across the JNI bridge.
|
||||||
|
if len(pcm_float32) % 4 != 0:
|
||||||
|
return
|
||||||
|
samples = np.frombuffer(pcm_float32, dtype=np.float32)
|
||||||
|
|
||||||
|
# Trim to whole frames, then clamp to chunk_size frames.
|
||||||
|
frames = len(samples) // _channels
|
||||||
|
if frames <= 0:
|
||||||
|
return
|
||||||
|
frames = min(frames, _chunk_size)
|
||||||
|
usable = frames * _channels
|
||||||
|
|
||||||
|
# Copy out of the read-only frombuffer view so the queued block owns its
|
||||||
|
# memory. This lets the Kotlin side push from a reusable buffer (low GC on
|
||||||
|
# low-end TV boxes) without the not-yet-consumed queued block aliasing
|
||||||
|
# bytes Kotlin is about to overwrite. Mirrors mediaprojection_engine's
|
||||||
|
# push_frame .copy().
|
||||||
|
block = samples[:usable].copy()
|
||||||
|
|
||||||
|
_frames_received += 1
|
||||||
|
if _frames_received == 1 or _frames_received % 100 == 0:
|
||||||
|
logger.info("Android audio: received %d blocks", _frames_received)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_pcm_queue.put_nowait(block)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_pcm_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
_pcm_queue.put_nowait(block)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown() -> None:
|
||||||
|
"""Deactivate the engine. Called when the Android app stops audio."""
|
||||||
|
global _active
|
||||||
|
_active = False
|
||||||
|
logger.info("Android audio engine shut down")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CaptureStream
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AndroidAudioCaptureStream(AudioCaptureStreamBase):
|
||||||
|
"""Reads PCM blocks pushed by Kotlin from the module-level queue."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channels(self) -> int:
|
||||||
|
return _channels
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sample_rate(self) -> int:
|
||||||
|
return _sample_rate
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chunk_size(self) -> int:
|
||||||
|
return _chunk_size
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
if not _active:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Android audio engine not configured. "
|
||||||
|
"This engine is only available inside the Android app."
|
||||||
|
)
|
||||||
|
self._initialized = True
|
||||||
|
logger.info("Android audio capture stream initialized")
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
self._initialized = False
|
||||||
|
logger.info("Android audio capture stream cleaned up")
|
||||||
|
|
||||||
|
def read_chunk(self) -> np.ndarray | None:
|
||||||
|
try:
|
||||||
|
return _pcm_queue.get(timeout=0.1) # 1-D float32 interleaved
|
||||||
|
except queue.Empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CaptureEngine
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AndroidAudioEngine(AudioCaptureEngine):
|
||||||
|
"""Android playback-capture audio engine.
|
||||||
|
|
||||||
|
Only available when running inside the LedGrab Android app, which
|
||||||
|
calls :func:`configure` once audio capture is set up. Exposes a
|
||||||
|
single loopback "device" representing the system audio mix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ENGINE_TYPE = "android_playback"
|
||||||
|
ENGINE_PRIORITY = 100 # highest on a real Android device (demo only wins in demo mode)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_available(cls) -> bool:
|
||||||
|
return is_android() and _active
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default_config(cls) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sample_rate": _sample_rate,
|
||||||
|
"channels": _channels,
|
||||||
|
"chunk_size": _chunk_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
||||||
|
if not cls.is_available():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
AudioDeviceInfo(
|
||||||
|
index=0,
|
||||||
|
name="Android playback (system audio)",
|
||||||
|
is_input=True,
|
||||||
|
is_loopback=True,
|
||||||
|
channels=_channels,
|
||||||
|
default_samplerate=float(_sample_rate),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_stream(
|
||||||
|
cls,
|
||||||
|
device_index: int,
|
||||||
|
is_loopback: bool,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
) -> AndroidAudioCaptureStream:
|
||||||
|
merged = {**cls.get_default_config(), **config}
|
||||||
|
return AndroidAudioCaptureStream(device_index, is_loopback, merged)
|
||||||
@@ -26,6 +26,33 @@ from ledgrab.utils import get_logger
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Cache resolved IANA timezones (and remember invalid names) so the ~1 Hz
|
||||||
|
# automation tick neither re-parses tzdata nor log-spams on a bad name.
|
||||||
|
_TZ_CACHE: Dict[str, object] = {}
|
||||||
|
_TZ_WARNED: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_in_tz(tz_name: str) -> datetime:
|
||||||
|
"""Current local time, in ``tz_name`` (IANA) if given, else the server's."""
|
||||||
|
if not tz_name:
|
||||||
|
return datetime.now()
|
||||||
|
tz = _TZ_CACHE.get(tz_name)
|
||||||
|
if tz is None:
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
_TZ_CACHE[tz_name] = tz
|
||||||
|
except Exception:
|
||||||
|
if tz_name not in _TZ_WARNED:
|
||||||
|
_TZ_WARNED.add(tz_name)
|
||||||
|
logger.warning(
|
||||||
|
"Invalid timezone %r for time-of-day rule; using server local time",
|
||||||
|
tz_name,
|
||||||
|
)
|
||||||
|
return datetime.now()
|
||||||
|
return datetime.now(tz)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _RuleEvalContext:
|
class _RuleEvalContext:
|
||||||
@@ -519,16 +546,26 @@ class AutomationEngine:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
|
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
|
||||||
now = datetime.now()
|
now = _now_in_tz(rule.timezone)
|
||||||
current = now.hour * 60 + now.minute
|
current = now.hour * 60 + now.minute
|
||||||
parts_s = rule.start_time.split(":")
|
parts_s = rule.start_time.split(":")
|
||||||
parts_e = rule.end_time.split(":")
|
parts_e = rule.end_time.split(":")
|
||||||
start = int(parts_s[0]) * 60 + int(parts_s[1])
|
start = int(parts_s[0]) * 60 + int(parts_s[1])
|
||||||
end = int(parts_e[0]) * 60 + int(parts_e[1])
|
end = int(parts_e[0]) * 60 + int(parts_e[1])
|
||||||
|
days = rule.days_of_week
|
||||||
|
|
||||||
if start <= end:
|
if start <= end:
|
||||||
return start <= current <= end
|
if not (start <= current <= end):
|
||||||
# Overnight range (e.g. 22:00 → 06:00)
|
return False
|
||||||
return current >= start or current <= end
|
return not days or now.weekday() in days
|
||||||
|
|
||||||
|
# Overnight range (e.g. 22:00 → 06:00): the window belongs to its
|
||||||
|
# START day, so the after-midnight tail is matched against yesterday.
|
||||||
|
if current >= start: # evening portion — today's window
|
||||||
|
return not days or now.weekday() in days
|
||||||
|
if current <= end: # early-morning portion — yesterday's window
|
||||||
|
return not days or ((now.weekday() - 1) % 7) in days
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
|
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ Non-Windows: graceful degradation (returns empty results).
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from typing import Set
|
from typing import Set
|
||||||
|
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.platform import is_android
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -21,6 +23,105 @@ if _IS_WINDOWS:
|
|||||||
import ctypes.wintypes
|
import ctypes.wintypes
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Android ForegroundAppBridge interop — lazy + guarded (never at import time)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Android reports ``sys.platform == "linux"`` so ``_IS_WINDOWS`` is False there;
|
||||||
|
# the foreground app is read via the Kotlin ``ForegroundAppBridge`` (UsageStats)
|
||||||
|
# instead of Win32 ctypes. These module-level wrappers are the monkeypatch
|
||||||
|
# surface used by tests (mirrors ``android_camera_engine``) — patch the module
|
||||||
|
# function, not the live ``jclass`` object.
|
||||||
|
|
||||||
|
# Emit the "Usage Access not granted" warning only once per process so the ~1s
|
||||||
|
# automation poll loop doesn't spam the log while access is missing.
|
||||||
|
_warned_no_usage_access = False
|
||||||
|
|
||||||
|
|
||||||
|
def _foreground_bridge():
|
||||||
|
"""Return the Kotlin ``ForegroundAppBridge`` singleton, or None off-Android.
|
||||||
|
|
||||||
|
The ``from java import jclass`` import only resolves inside the Chaquopy
|
||||||
|
runtime, so it must never run at module import time (this module is imported
|
||||||
|
on desktop CI too). Mirrors ``android_camera_engine._camera_bridge()``.
|
||||||
|
"""
|
||||||
|
if not is_android():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from java import jclass # type: ignore[import-not-found]
|
||||||
|
except ImportError as exc:
|
||||||
|
logger.debug("Chaquopy java interop not available: %s", exc)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return jclass("com.ledgrab.android.ForegroundAppBridge").INSTANCE
|
||||||
|
except Exception as exc: # pragma: no cover - Android-only path
|
||||||
|
logger.debug("ForegroundAppBridge singleton unavailable: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def has_usage_access() -> bool:
|
||||||
|
"""Whether Usage Access (PACKAGE_USAGE_STATS) is granted. False off-Android."""
|
||||||
|
bridge = _foreground_bridge()
|
||||||
|
if bridge is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(bridge.hasUsageAccess())
|
||||||
|
except Exception as exc: # pragma: no cover - Android-only path
|
||||||
|
logger.debug("ForegroundAppBridge.hasUsageAccess failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_foreground_package() -> str | None:
|
||||||
|
"""Current foreground app package via the Kotlin bridge, or None.
|
||||||
|
|
||||||
|
None off-Android, when the bridge is unavailable, when Usage Access is
|
||||||
|
missing, or when no foreground event is found in the trailing window.
|
||||||
|
Monkeypatched in tests.
|
||||||
|
"""
|
||||||
|
bridge = _foreground_bridge()
|
||||||
|
if bridge is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pkg = bridge.getForegroundPackage()
|
||||||
|
except Exception as exc: # pragma: no cover - Android-only path
|
||||||
|
logger.warning("ForegroundAppBridge.getForegroundPackage failed: %s", exc)
|
||||||
|
return None
|
||||||
|
if pkg is None:
|
||||||
|
return None
|
||||||
|
s = str(pkg).strip()
|
||||||
|
return s or None
|
||||||
|
|
||||||
|
|
||||||
|
def list_installed_apps() -> list[dict]:
|
||||||
|
"""Launchable apps via the Kotlin bridge: ``[{"package": .., "label": ..}]``.
|
||||||
|
|
||||||
|
Returns ``[]`` off-Android, when the bridge is unavailable, on error, or on
|
||||||
|
invalid JSON. Sorted by label (the bridge sorts; order is preserved here).
|
||||||
|
Monkeypatched in tests.
|
||||||
|
"""
|
||||||
|
bridge = _foreground_bridge()
|
||||||
|
if bridge is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
raw = bridge.listLaunchableApps() # JSON array string
|
||||||
|
except Exception as exc: # pragma: no cover - Android-only path
|
||||||
|
logger.warning("ForegroundAppBridge.listLaunchableApps failed: %s", exc)
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(str(raw))
|
||||||
|
except (ValueError, TypeError) as exc: # pragma: no cover - Android-only path
|
||||||
|
logger.warning("ForegroundAppBridge.listLaunchableApps returned invalid JSON: %s", exc)
|
||||||
|
return []
|
||||||
|
apps: list[dict] = []
|
||||||
|
for entry in parsed if isinstance(parsed, list) else []:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
pkg = entry.get("package")
|
||||||
|
if not pkg:
|
||||||
|
continue
|
||||||
|
apps.append({"package": str(pkg), "label": str(entry.get("label") or pkg)})
|
||||||
|
return apps
|
||||||
|
|
||||||
|
|
||||||
class PlatformDetector:
|
class PlatformDetector:
|
||||||
"""Detect running processes and the foreground window's process."""
|
"""Detect running processes and the foreground window's process."""
|
||||||
|
|
||||||
@@ -215,6 +316,31 @@ class PlatformDetector:
|
|||||||
|
|
||||||
# ---- Process detection ----
|
# ---- Process detection ----
|
||||||
|
|
||||||
|
def _get_android_foreground(self) -> tuple:
|
||||||
|
"""(package_lowercased, True) for the foreground app on Android.
|
||||||
|
|
||||||
|
Returns ``(None, False)`` when Usage Access is not granted (warned once)
|
||||||
|
or no foreground app is found. ``is_fullscreen`` is reported True because
|
||||||
|
a foreground TV app effectively covers the screen — so an Android rule's
|
||||||
|
``topmost``/``topmost_fullscreen``/``fullscreen`` match types all behave
|
||||||
|
as "this app is in front". Delegates to the module-level bridge wrappers
|
||||||
|
(the monkeypatch surface used by tests).
|
||||||
|
"""
|
||||||
|
global _warned_no_usage_access
|
||||||
|
if not has_usage_access():
|
||||||
|
if not _warned_no_usage_access:
|
||||||
|
logger.warning(
|
||||||
|
"Android 'Application' automation rules need Usage Access "
|
||||||
|
"(Settings > Usage access). Foreground-app rules will not match "
|
||||||
|
"until it is granted."
|
||||||
|
)
|
||||||
|
_warned_no_usage_access = True
|
||||||
|
return (None, False)
|
||||||
|
pkg = get_foreground_package()
|
||||||
|
if not pkg:
|
||||||
|
return (None, False)
|
||||||
|
return (pkg.lower(), True)
|
||||||
|
|
||||||
def _get_running_processes_sync(self) -> Set[str]:
|
def _get_running_processes_sync(self) -> Set[str]:
|
||||||
"""Get set of lowercase process names via Win32 EnumProcesses.
|
"""Get set of lowercase process names via Win32 EnumProcesses.
|
||||||
|
|
||||||
@@ -222,7 +348,14 @@ class PlatformDetector:
|
|||||||
which is ~300x faster than WMI (~8ms vs ~3s). System services
|
which is ~300x faster than WMI (~8ms vs ~3s). System services
|
||||||
running under protected accounts are not visible, but all
|
running under protected accounts are not visible, but all
|
||||||
user-facing applications are covered.
|
user-facing applications are covered.
|
||||||
|
|
||||||
|
On Android there is no process enumeration API (getRunningTasks is
|
||||||
|
restricted); the foreground app is reported as the sole "running" entry
|
||||||
|
as a best-effort so ``match_type="running"`` rules still work.
|
||||||
"""
|
"""
|
||||||
|
if is_android():
|
||||||
|
pkg, _ = self._get_android_foreground()
|
||||||
|
return {pkg} if pkg else set()
|
||||||
if not _IS_WINDOWS:
|
if not _IS_WINDOWS:
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
@@ -276,9 +409,13 @@ class PlatformDetector:
|
|||||||
def _get_topmost_process_sync(self) -> tuple:
|
def _get_topmost_process_sync(self) -> tuple:
|
||||||
"""Get (process_name, is_fullscreen) of the foreground window.
|
"""Get (process_name, is_fullscreen) of the foreground window.
|
||||||
|
|
||||||
Returns (None, False) when detection fails.
|
On Android the "foreground window" is the foreground app package (read
|
||||||
|
via the Kotlin ForegroundAppBridge); see ``_get_android_foreground``.
|
||||||
|
Returns (None, False) when detection fails / Usage Access is missing.
|
||||||
Blocking — call via executor.
|
Blocking — call via executor.
|
||||||
"""
|
"""
|
||||||
|
if is_android():
|
||||||
|
return self._get_android_foreground()
|
||||||
if not _IS_WINDOWS:
|
if not _IS_WINDOWS:
|
||||||
return (None, False)
|
return (None, False)
|
||||||
|
|
||||||
@@ -369,7 +506,13 @@ class PlatformDetector:
|
|||||||
|
|
||||||
Enumerates all top-level windows and checks each for fullscreen.
|
Enumerates all top-level windows and checks each for fullscreen.
|
||||||
Returns process names (lowercase) whose window covers an entire monitor.
|
Returns process names (lowercase) whose window covers an entire monitor.
|
||||||
|
|
||||||
|
On Android the foreground app is treated as fullscreen, so it is the
|
||||||
|
sole entry (best-effort, mirrors ``_get_running_processes_sync``).
|
||||||
"""
|
"""
|
||||||
|
if is_android():
|
||||||
|
pkg, _ = self._get_android_foreground()
|
||||||
|
return {pkg} if pkg else set()
|
||||||
if not _IS_WINDOWS:
|
if not _IS_WINDOWS:
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,18 @@ class CalibrationConfig:
|
|||||||
skip_leds_end: int = 0
|
skip_leds_end: int = 0
|
||||||
# Border width: how many pixels from the screen edge to sample
|
# Border width: how many pixels from the screen edge to sample
|
||||||
border_width: int = 10
|
border_width: int = 10
|
||||||
|
# Region of interest (simple mode): sample only this sub-rectangle of the
|
||||||
|
# frame (fractions 0..1). Defaults to the full frame. Lets a user exclude
|
||||||
|
# HUDs/taskbars/letterboxing from the sampled border colours.
|
||||||
|
roi_x: float = 0.0
|
||||||
|
roi_y: float = 0.0
|
||||||
|
roi_width: float = 1.0
|
||||||
|
roi_height: float = 1.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_roi(self) -> bool:
|
||||||
|
"""True when the ROI is narrower than the full frame."""
|
||||||
|
return self.roi_x > 0.0 or self.roi_y > 0.0 or self.roi_width < 1.0 or self.roi_height < 1.0
|
||||||
|
|
||||||
def build_segments(self) -> List[CalibrationSegment]:
|
def build_segments(self) -> List[CalibrationSegment]:
|
||||||
"""Derive segment list from core parameters."""
|
"""Derive segment list from core parameters."""
|
||||||
@@ -656,6 +668,98 @@ def create_pixel_mapper(
|
|||||||
return PixelMapper(calibration, interpolation_mode)
|
return PixelMapper(calibration, interpolation_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def solve_calibration(
|
||||||
|
led_count: int,
|
||||||
|
start_position: str,
|
||||||
|
layout: str,
|
||||||
|
corner_indices: List[int],
|
||||||
|
offset: int = 0,
|
||||||
|
) -> "CalibrationConfig":
|
||||||
|
"""Derive a CalibrationConfig from 4 corner tap indices.
|
||||||
|
|
||||||
|
Given the LED-strip indices where the user tapped each physical corner of
|
||||||
|
the screen (in strip-walk order matching *start_position* and *layout*),
|
||||||
|
compute per-edge LED counts that are consistent with
|
||||||
|
``EDGE_ORDER``/``EDGE_REVERSE`` and round-trip through
|
||||||
|
``build_segments()``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
led_count: Total number of LEDs on the strip.
|
||||||
|
start_position: Starting corner of the strip
|
||||||
|
(``"top_left"``, ``"top_right"``, ``"bottom_left"``,
|
||||||
|
``"bottom_right"``).
|
||||||
|
layout: Winding direction (``"clockwise"`` or
|
||||||
|
``"counterclockwise"``).
|
||||||
|
corner_indices: Four strip indices, one per screen corner, in the
|
||||||
|
same order as the strip walk defined by ``EDGE_ORDER`` for the
|
||||||
|
given *(start_position, layout)* pair. Index 0 is the start
|
||||||
|
corner, index 1 is the second corner reached while walking,
|
||||||
|
etc. Indices may wrap around (i.e. the last segment may
|
||||||
|
straddle the physical end of the strip).
|
||||||
|
offset: Physical LED offset stored directly on the config (0 = none).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``CalibrationConfig`` in simple mode with per-edge counts filled in.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If *start_position*, *layout*, or the number of
|
||||||
|
corner indices is invalid.
|
||||||
|
"""
|
||||||
|
key = (start_position, layout)
|
||||||
|
if key not in EDGE_ORDER:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid start_position/layout combination: {start_position!r}/{layout!r}"
|
||||||
|
)
|
||||||
|
if len(corner_indices) != 4:
|
||||||
|
raise ValueError(f"corner_indices must have exactly 4 entries, got {len(corner_indices)}")
|
||||||
|
if led_count <= 0:
|
||||||
|
raise ValueError(f"led_count must be positive, got {led_count}")
|
||||||
|
|
||||||
|
edge_order = EDGE_ORDER[key] # 4 edges in strip-walk order
|
||||||
|
|
||||||
|
# Compute per-edge LED counts from consecutive corner indices.
|
||||||
|
# The i-th edge spans from corner_indices[i] to corner_indices[(i+1) % 4],
|
||||||
|
# wrapping around led_count if necessary.
|
||||||
|
edge_counts: dict[str, int] = {}
|
||||||
|
for i, edge in enumerate(edge_order):
|
||||||
|
start_idx = corner_indices[i] % led_count
|
||||||
|
end_idx = corner_indices[(i + 1) % 4] % led_count
|
||||||
|
if end_idx > start_idx:
|
||||||
|
count = end_idx - start_idx
|
||||||
|
elif end_idx == start_idx:
|
||||||
|
# Adjacent taps on the same index → 0-LED edge
|
||||||
|
count = 0
|
||||||
|
else:
|
||||||
|
# Wrap-around: strip crosses the physical end
|
||||||
|
count = (led_count - start_idx) + end_idx
|
||||||
|
edge_counts[edge] = count
|
||||||
|
|
||||||
|
cfg = CalibrationConfig(
|
||||||
|
mode="simple",
|
||||||
|
layout=layout,
|
||||||
|
start_position=start_position,
|
||||||
|
leds_top=edge_counts.get("top", 0),
|
||||||
|
leds_right=edge_counts.get("right", 0),
|
||||||
|
leds_bottom=edge_counts.get("bottom", 0),
|
||||||
|
leds_left=edge_counts.get("left", 0),
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"solve_calibration: start=%s layout=%s corner_indices=%s "
|
||||||
|
"-> top=%d right=%d bottom=%d left=%d offset=%d",
|
||||||
|
start_position,
|
||||||
|
layout,
|
||||||
|
corner_indices,
|
||||||
|
cfg.leds_top,
|
||||||
|
cfg.leds_right,
|
||||||
|
cfg.leds_bottom,
|
||||||
|
cfg.leds_left,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def create_default_calibration(
|
def create_default_calibration(
|
||||||
led_count: int,
|
led_count: int,
|
||||||
aspect_width: int = 16,
|
aspect_width: int = 16,
|
||||||
@@ -799,6 +903,10 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
|
|||||||
skip_leds_start=data.get("skip_leds_start", 0),
|
skip_leds_start=data.get("skip_leds_start", 0),
|
||||||
skip_leds_end=data.get("skip_leds_end", 0),
|
skip_leds_end=data.get("skip_leds_end", 0),
|
||||||
border_width=data.get("border_width", 10),
|
border_width=data.get("border_width", 10),
|
||||||
|
roi_x=data.get("roi_x", 0.0),
|
||||||
|
roi_y=data.get("roi_y", 0.0),
|
||||||
|
roi_width=data.get("roi_width", 1.0),
|
||||||
|
roi_height=data.get("roi_height", 1.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
config.validate()
|
config.validate()
|
||||||
@@ -870,4 +978,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
|||||||
result["skip_leds_end"] = config.skip_leds_end
|
result["skip_leds_end"] = config.skip_leds_end
|
||||||
if config.border_width != 10:
|
if config.border_width != 10:
|
||||||
result["border_width"] = config.border_width
|
result["border_width"] = config.border_width
|
||||||
|
# Include ROI only when it is not the full frame
|
||||||
|
if config.has_roi:
|
||||||
|
result["roi_x"] = config.roi_x
|
||||||
|
result["roi_y"] = config.roi_y
|
||||||
|
result["roi_width"] = config.roi_width
|
||||||
|
result["roi_height"] = config.roi_height
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
"""Calibration session lifecycle and per-LED chase driver.
|
||||||
|
|
||||||
|
Provides two things:
|
||||||
|
1. ``set_calibration_pixel`` — direct per-index LED write for the chase
|
||||||
|
(added beside ``set_test_mode`` on ``ProcessorManager`` via the mixin, but
|
||||||
|
kept here to avoid growing device_test_mode.py further).
|
||||||
|
2. ``CalibrationSession`` — single-active-session guard with idle timeout and
|
||||||
|
guaranteed stop/restore contract.
|
||||||
|
|
||||||
|
Stop / restore contract (required by Phase 3 UI)
|
||||||
|
-------------------------------------------------
|
||||||
|
- ``start(device_id)``:
|
||||||
|
* If a target is currently processing on *device_id*, stop it and record
|
||||||
|
its ``target_id`` as ``_prior_target_id``.
|
||||||
|
* Send the device to black (chase start state).
|
||||||
|
* Record session as active with a fresh ``last_activity`` timestamp.
|
||||||
|
* Only one active session is allowed at a time; starting a new one on any
|
||||||
|
device while another is active calls ``stop()`` on the old one first.
|
||||||
|
- ``position(index, window)``:
|
||||||
|
* Validates ``index < led_count``; raises ``ValueError`` on out-of-range.
|
||||||
|
* Sends a chase pixel (bright white centre ±window dim neighbours).
|
||||||
|
* Updates ``last_activity``.
|
||||||
|
- ``stop()`` / ``cancel()``:
|
||||||
|
* Sends all-black to clear the device.
|
||||||
|
* If ``_prior_target_id`` was recorded, calls ``start_processing`` to
|
||||||
|
restart it.
|
||||||
|
* Clears the session state.
|
||||||
|
* NEVER leaves the device dark or stuck in chase.
|
||||||
|
- Idle timeout (``IDLE_TIMEOUT_SECONDS``, default 60 s):
|
||||||
|
* A background asyncio task checks ``last_activity``; if the session has
|
||||||
|
been idle longer than the timeout, ``stop()`` is called automatically.
|
||||||
|
* The timeout task is cancelled when ``stop()`` is called explicitly.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
- ``set_calibration_pixel`` reuses ``_get_idle_client`` /
|
||||||
|
``_send_pixels_to_device`` from ``DeviceTestModeMixin``; no new connection
|
||||||
|
management is needed.
|
||||||
|
- The session holds a reference to the ``ProcessorManager`` so it can call
|
||||||
|
``stop_processing`` / ``start_processing``.
|
||||||
|
- Thread-safety: all public methods are ``async``; the idle-timeout callback
|
||||||
|
schedules itself on the running event loop via ``asyncio.ensure_future``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
IDLE_TIMEOUT_SECONDS: int = 60
|
||||||
|
"""Auto-stop a calibration session after this many seconds of inactivity."""
|
||||||
|
|
||||||
|
_CHASE_CENTER_COLOR: tuple[int, int, int] = (255, 255, 255)
|
||||||
|
"""Bright white for the chase centre pixel."""
|
||||||
|
|
||||||
|
_CHASE_WING_COLOR: tuple[int, int, int] = (60, 60, 60)
|
||||||
|
"""Dim grey for ±window neighbour pixels."""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mixin: per-index chase driver ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationChaseMixin:
|
||||||
|
"""Adds ``set_calibration_pixel`` to ``ProcessorManager``.
|
||||||
|
|
||||||
|
Requires the same host-class attributes as ``DeviceTestModeMixin``:
|
||||||
|
``_devices``, ``_processors``, ``_idle_clients``.
|
||||||
|
Inherits ``_send_pixels_to_device`` and ``_get_idle_client`` from
|
||||||
|
``DeviceTestModeMixin`` (both already on ``ProcessorManager``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def set_calibration_pixel(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
index: int,
|
||||||
|
color: tuple[int, int, int] = _CHASE_CENTER_COLOR,
|
||||||
|
window: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Light a single LED index (plus optional ±window neighbours) on a device.
|
||||||
|
|
||||||
|
Sends a full pixel array to avoid partial-frame artefacts. The centre
|
||||||
|
LED is set to *color*; the ``window`` neighbours on each side are set to
|
||||||
|
``_CHASE_WING_COLOR`` (dim grey) so the user can see which direction the
|
||||||
|
strip is wound.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: Target device ID (must be registered).
|
||||||
|
index: LED index to light (0-based). Must be < ``led_count``.
|
||||||
|
color: RGB tuple for the centre LED (default bright white).
|
||||||
|
window: Number of neighbouring LEDs to dim on each side (default 1).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If *device_id* is not registered or *index* is out of
|
||||||
|
range.
|
||||||
|
"""
|
||||||
|
if device_id not in self._devices:
|
||||||
|
raise ValueError(f"Device {device_id!r} not found")
|
||||||
|
ds = self._devices[device_id]
|
||||||
|
led_count = ds.led_count
|
||||||
|
if led_count <= 0:
|
||||||
|
raise ValueError(f"Device {device_id!r} has led_count={led_count}")
|
||||||
|
if not (0 <= index < led_count):
|
||||||
|
raise ValueError(
|
||||||
|
f"index {index} out of range for device {device_id!r} " f"(led_count={led_count})"
|
||||||
|
)
|
||||||
|
|
||||||
|
pixels: list[tuple[int, int, int]] = [(0, 0, 0)] * led_count
|
||||||
|
pixels[index] = color
|
||||||
|
for offset in range(1, window + 1):
|
||||||
|
left = (index - offset) % led_count
|
||||||
|
right = (index + offset) % led_count
|
||||||
|
pixels[left] = _CHASE_WING_COLOR
|
||||||
|
pixels[right] = _CHASE_WING_COLOR
|
||||||
|
# Re-assign center last so on tiny strips (window >= led_count) the
|
||||||
|
# center LED always shows the full color rather than a wrapped wing.
|
||||||
|
pixels[index] = color
|
||||||
|
|
||||||
|
await self._send_pixels_to_device(device_id, pixels)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"set_calibration_pixel: device=%s index=%d window=%d",
|
||||||
|
device_id,
|
||||||
|
index,
|
||||||
|
window,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSession:
|
||||||
|
"""Single-active calibration session with idle-timeout and stop/restore.
|
||||||
|
|
||||||
|
One instance is shared per application (singleton held by the API layer).
|
||||||
|
Only one session can be active at a time; starting a new session
|
||||||
|
automatically terminates the previous one.
|
||||||
|
|
||||||
|
All public methods that mutate session state acquire ``_lock`` so that
|
||||||
|
concurrent ``POST /session`` calls (or a ``stop`` racing with the idle
|
||||||
|
watchdog) cannot interleave and leave ``_prior_target_id`` stale. The
|
||||||
|
watchdog calls the internal ``_teardown_locked`` helper which must only be
|
||||||
|
invoked when the lock is already held; if the lock is already taken the
|
||||||
|
watchdog simply exits, letting the holder finish teardown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._manager: "ProcessorManager | None" = None
|
||||||
|
self._device_id: str | None = None
|
||||||
|
self._led_count: int = 0
|
||||||
|
self._prior_target_id: str | None = None
|
||||||
|
self._last_activity: datetime | None = None
|
||||||
|
self._timeout_task: asyncio.Task | None = None
|
||||||
|
self._active: bool = False
|
||||||
|
self._lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self._active
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_id(self) -> str | None:
|
||||||
|
return self._device_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def led_count(self) -> int:
|
||||||
|
return self._led_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_activity(self) -> datetime | None:
|
||||||
|
return self._last_activity
|
||||||
|
|
||||||
|
async def start(self, device_id: str, manager: "ProcessorManager") -> None:
|
||||||
|
"""Begin a calibration session on *device_id*.
|
||||||
|
|
||||||
|
If a session is already active (even on a different device), it is
|
||||||
|
stopped first. If a target is currently processing on *device_id*, it
|
||||||
|
is stopped and remembered so it can be restored when this session ends.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: The device to drive during calibration.
|
||||||
|
manager: Live ``ProcessorManager`` instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If *device_id* is not registered.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
# Validate device before touching any state or awaiting
|
||||||
|
if device_id not in manager._devices:
|
||||||
|
raise ValueError(f"Device {device_id!r} not found")
|
||||||
|
|
||||||
|
ds = manager._devices[device_id]
|
||||||
|
led_count = ds.led_count
|
||||||
|
|
||||||
|
# Capture the prior running target NOW — before any await — so the
|
||||||
|
# value cannot be mutated by a concurrent call that sneaks in after
|
||||||
|
# the lock is released between awaits.
|
||||||
|
prior_target_id = manager.get_processing_target_for_device(device_id)
|
||||||
|
|
||||||
|
# Terminate any existing session while we still hold the lock.
|
||||||
|
# Call _teardown_locked directly (we already hold the lock).
|
||||||
|
if self._active:
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.start: stopping existing session on device=%s "
|
||||||
|
"to start new one on device=%s",
|
||||||
|
self._device_id,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
await self._teardown_locked(cancelled=False)
|
||||||
|
|
||||||
|
# Stop any running target on this device and remember it for restore
|
||||||
|
if prior_target_id is not None:
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.start: stopping target %s on device %s for calibration",
|
||||||
|
prior_target_id,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
await manager.stop_processing(prior_target_id)
|
||||||
|
|
||||||
|
self._manager = manager
|
||||||
|
self._device_id = device_id
|
||||||
|
self._led_count = led_count
|
||||||
|
self._prior_target_id = prior_target_id
|
||||||
|
self._last_activity = datetime.now(timezone.utc)
|
||||||
|
self._active = True
|
||||||
|
|
||||||
|
# Clear the device to black so the chase starts from a clean state
|
||||||
|
await manager.send_clear_pixels(device_id)
|
||||||
|
|
||||||
|
# Start idle-timeout watchdog
|
||||||
|
self._timeout_task = asyncio.ensure_future(self._idle_watchdog())
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.start: session started on device=%s led_count=%d "
|
||||||
|
"prior_target=%s",
|
||||||
|
device_id,
|
||||||
|
led_count,
|
||||||
|
prior_target_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def position(self, index: int, window: int = 1) -> None:
|
||||||
|
"""Drive the chase pixel to *index* on the active device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: LED index to illuminate (0-based, must be < led_count).
|
||||||
|
window: Number of dim neighbours on each side (default 1).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If no session is active.
|
||||||
|
ValueError: If *index* is out of range.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if not self._active or self._manager is None or self._device_id is None:
|
||||||
|
raise RuntimeError("No active calibration session")
|
||||||
|
if not (0 <= index < self._led_count):
|
||||||
|
raise ValueError(f"index {index} out of range (led_count={self._led_count})")
|
||||||
|
|
||||||
|
self._last_activity = datetime.now(timezone.utc)
|
||||||
|
await self._manager.set_calibration_pixel(self._device_id, index, window=window)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"CalibrationSession.position: device=%s index=%d window=%d",
|
||||||
|
self._device_id,
|
||||||
|
index,
|
||||||
|
window,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""End the session: clear the device and restore the prior target.
|
||||||
|
|
||||||
|
Safe to call even if no session is active (no-op).
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
await self._teardown_locked(cancelled=False)
|
||||||
|
|
||||||
|
async def cancel(self) -> None:
|
||||||
|
"""Alias for ``stop()`` — ends the session without applying calibration."""
|
||||||
|
async with self._lock:
|
||||||
|
await self._teardown_locked(cancelled=True)
|
||||||
|
|
||||||
|
def get_state(self) -> dict:
|
||||||
|
"""Return a snapshot of the current session state for API responses."""
|
||||||
|
return {
|
||||||
|
"active": self._active,
|
||||||
|
"device_id": self._device_id,
|
||||||
|
"led_count": self._led_count,
|
||||||
|
"prior_target_id": self._prior_target_id,
|
||||||
|
"last_activity": (self._last_activity.isoformat() if self._last_activity else None),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Internal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _teardown_locked(self, cancelled: bool) -> None:
|
||||||
|
"""Clear the device, restore the prior target, and reset state.
|
||||||
|
|
||||||
|
MUST be called with ``self._lock`` already held by the caller.
|
||||||
|
Safe to call when already inactive (no-op).
|
||||||
|
"""
|
||||||
|
if not self._active:
|
||||||
|
return
|
||||||
|
|
||||||
|
device_id = self._device_id
|
||||||
|
manager = self._manager
|
||||||
|
prior_target_id = self._prior_target_id
|
||||||
|
|
||||||
|
# Cancel the idle watchdog — but only if we are NOT running inside it.
|
||||||
|
# Awaiting the current task would deadlock.
|
||||||
|
if (
|
||||||
|
self._timeout_task is not None
|
||||||
|
and self._timeout_task is not asyncio.current_task()
|
||||||
|
and not self._timeout_task.done()
|
||||||
|
):
|
||||||
|
self._timeout_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._timeout_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._timeout_task = None
|
||||||
|
|
||||||
|
# Reset state before side-effects so re-entrant calls are no-ops
|
||||||
|
self._active = False
|
||||||
|
self._device_id = None
|
||||||
|
self._led_count = 0
|
||||||
|
self._prior_target_id = None
|
||||||
|
self._last_activity = None
|
||||||
|
self._manager = None
|
||||||
|
|
||||||
|
if manager is None or device_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Clear the device to black
|
||||||
|
try:
|
||||||
|
await manager.send_clear_pixels(device_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"CalibrationSession._teardown: failed to clear pixels on %s: %s",
|
||||||
|
device_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Restore the prior target (if any)
|
||||||
|
if prior_target_id is not None:
|
||||||
|
try:
|
||||||
|
await manager.start_processing(prior_target_id)
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession._teardown: restored target %s on device %s",
|
||||||
|
prior_target_id,
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"CalibrationSession._teardown: failed to restore target %s on " "device %s: %s",
|
||||||
|
prior_target_id,
|
||||||
|
device_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
action = "cancel" if cancelled else "stop"
|
||||||
|
logger.info(
|
||||||
|
"CalibrationSession.%s: session ended on device=%s prior_target=%s",
|
||||||
|
action,
|
||||||
|
device_id,
|
||||||
|
prior_target_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _idle_watchdog(self) -> None:
|
||||||
|
"""Background task: auto-stop the session after IDLE_TIMEOUT_SECONDS.
|
||||||
|
|
||||||
|
Tries to acquire ``_lock`` when the timeout fires. If the lock is
|
||||||
|
already held (e.g. a concurrent ``stop()`` is in progress) the
|
||||||
|
``acquire`` will wait; once it gets the lock, ``_teardown_locked``
|
||||||
|
is a no-op if the session was already ended by the other caller.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
if not self._active or self._last_activity is None:
|
||||||
|
break
|
||||||
|
elapsed = (datetime.now(timezone.utc) - self._last_activity).total_seconds()
|
||||||
|
if elapsed >= IDLE_TIMEOUT_SECONDS:
|
||||||
|
logger.warning(
|
||||||
|
"CalibrationSession._idle_watchdog: session on device=%s "
|
||||||
|
"idle for %.0fs — auto-stopping",
|
||||||
|
self._device_id,
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
|
async with self._lock:
|
||||||
|
await self._teardown_locked(cancelled=False)
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module-level singleton ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_session: CalibrationSession = CalibrationSession()
|
||||||
|
|
||||||
|
|
||||||
|
def get_calibration_session() -> CalibrationSession:
|
||||||
|
"""Return the module-level singleton ``CalibrationSession``."""
|
||||||
|
return _session
|
||||||
@@ -159,6 +159,37 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
|
|||||||
raise RuntimeError(f"Screen capture failed: {e}")
|
raise RuntimeError(f"Screen capture failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def crop_screen_capture(
|
||||||
|
sc: ScreenCapture,
|
||||||
|
roi_x: float,
|
||||||
|
roi_y: float,
|
||||||
|
roi_width: float,
|
||||||
|
roi_height: float,
|
||||||
|
) -> ScreenCapture:
|
||||||
|
"""Crop a capture to a relative region-of-interest rectangle (fractions 0..1).
|
||||||
|
|
||||||
|
Sampling only a sub-rectangle of the frame lets a user exclude HUDs, task
|
||||||
|
bars, or letterboxing so they don't pollute the border colours. Returns the
|
||||||
|
original capture unchanged for a full-frame ROI (fast path). The cropped
|
||||||
|
image is a numpy view (no copy); out-of-range/degenerate ROIs are clamped so
|
||||||
|
at least a 1x1 region remains.
|
||||||
|
"""
|
||||||
|
if roi_x <= 0.0 and roi_y <= 0.0 and roi_width >= 1.0 and roi_height >= 1.0:
|
||||||
|
return sc
|
||||||
|
h, w = sc.image.shape[:2]
|
||||||
|
x0 = max(0, min(w - 1, int(round(roi_x * w))))
|
||||||
|
y0 = max(0, min(h - 1, int(round(roi_y * h))))
|
||||||
|
x1 = max(x0 + 1, min(w, int(round((roi_x + roi_width) * w))))
|
||||||
|
y1 = max(y0 + 1, min(h, int(round((roi_y + roi_height) * h))))
|
||||||
|
cropped = sc.image[y0:y1, x0:x1]
|
||||||
|
return ScreenCapture(
|
||||||
|
image=cropped,
|
||||||
|
width=x1 - x0,
|
||||||
|
height=y1 - y0,
|
||||||
|
display_index=sc.display_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10) -> BorderPixels:
|
def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10) -> BorderPixels:
|
||||||
"""Extract border pixels from screen capture.
|
"""Extract border pixels from screen capture.
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,18 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
_has_mediaprojection = False
|
_has_mediaprojection = False
|
||||||
|
|
||||||
|
# ── Android camera/webcam (Camera2 via Chaquopy bridge) ─────────────
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ledgrab.core.capture_engines.android_camera_engine import (
|
||||||
|
AndroidCameraEngine,
|
||||||
|
AndroidCameraCaptureStream,
|
||||||
|
)
|
||||||
|
|
||||||
|
_has_android_camera = True
|
||||||
|
except ImportError:
|
||||||
|
_has_android_camera = False
|
||||||
|
|
||||||
# ── Android root screenrecord (rooted Magisk devices) ───────────────
|
# ── Android root screenrecord (rooted Magisk devices) ───────────────
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -120,6 +132,8 @@ if _has_camera:
|
|||||||
EngineRegistry.register(CameraEngine)
|
EngineRegistry.register(CameraEngine)
|
||||||
if _has_mediaprojection:
|
if _has_mediaprojection:
|
||||||
EngineRegistry.register(MediaProjectionEngine)
|
EngineRegistry.register(MediaProjectionEngine)
|
||||||
|
if _has_android_camera:
|
||||||
|
EngineRegistry.register(AndroidCameraEngine)
|
||||||
if _has_root_screenrecord:
|
if _has_root_screenrecord:
|
||||||
EngineRegistry.register(RootScreenrecordEngine)
|
EngineRegistry.register(RootScreenrecordEngine)
|
||||||
EngineRegistry.register(DemoCaptureEngine)
|
EngineRegistry.register(DemoCaptureEngine)
|
||||||
@@ -152,5 +166,7 @@ if _has_camera:
|
|||||||
__all__ += ["CameraEngine", "CameraCaptureStream"]
|
__all__ += ["CameraEngine", "CameraCaptureStream"]
|
||||||
if _has_mediaprojection:
|
if _has_mediaprojection:
|
||||||
__all__ += ["MediaProjectionEngine", "MediaProjectionCaptureStream"]
|
__all__ += ["MediaProjectionEngine", "MediaProjectionCaptureStream"]
|
||||||
|
if _has_android_camera:
|
||||||
|
__all__ += ["AndroidCameraEngine", "AndroidCameraCaptureStream"]
|
||||||
if _has_root_screenrecord:
|
if _has_root_screenrecord:
|
||||||
__all__ += ["RootScreenrecordEngine", "RootScreenrecordCaptureStream"]
|
__all__ += ["RootScreenrecordEngine", "RootScreenrecordCaptureStream"]
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
"""Android camera (webcam) capture engine.
|
||||||
|
|
||||||
|
Receives camera frames pushed from Kotlin (via Chaquopy) through a
|
||||||
|
module-level frame queue. The Kotlin :class:`CameraBridge` opens a
|
||||||
|
camera with the Camera2 API, converts each frame to RGB, and calls
|
||||||
|
:func:`push_frame` with raw RGB bytes.
|
||||||
|
|
||||||
|
The physical camera is opened **on demand** — only while a capture
|
||||||
|
stream is active. :meth:`AndroidCameraCaptureStream.initialize` calls
|
||||||
|
:func:`start_camera` (which signals the Kotlin bridge to open the
|
||||||
|
camera) and :meth:`cleanup` calls :func:`stop_camera`. This keeps the
|
||||||
|
camera-in-use indicator and battery cost limited to actual use, unlike
|
||||||
|
the always-on screen/audio capture.
|
||||||
|
|
||||||
|
Mirrors the screen-capture bridge
|
||||||
|
(``core/capture_engines/mediaprojection_engine.py``): a module-level
|
||||||
|
queue plus push/last-frame fallback/drop-oldest, consumed through the
|
||||||
|
standard :class:`CaptureEngine` / :class:`CaptureStream` interface so
|
||||||
|
the live-stream and processing pipelines work unchanged. Cameras are
|
||||||
|
exposed as selectable "displays" exactly like the desktop OpenCV
|
||||||
|
:class:`CameraEngine`.
|
||||||
|
|
||||||
|
This engine is only available when running inside the LedGrab Android
|
||||||
|
app (``is_android()``) with at least one camera the Kotlin bridge can
|
||||||
|
enumerate. All Java interop is lazy + guarded so this module imports
|
||||||
|
cleanly on desktop CI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ledgrab.core.capture_engines.base import (
|
||||||
|
CaptureEngine,
|
||||||
|
CaptureStream,
|
||||||
|
DisplayInfo,
|
||||||
|
ScreenCapture,
|
||||||
|
)
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.platform import is_android
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Frame queue — the bridge between Kotlin and Python
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_frame_queue: "queue.Queue[ScreenCapture]" = queue.Queue(maxsize=2)
|
||||||
|
_active = False
|
||||||
|
_active_index = 0
|
||||||
|
_frames_received = 0
|
||||||
|
|
||||||
|
# Single-camera ownership. The Kotlin bridge supports exactly one open camera
|
||||||
|
# at a time (it closes any prior camera on a new open), and all streams share
|
||||||
|
# the one module-level frame queue. So the engine serializes ownership the way
|
||||||
|
# the desktop CameraEngine does with its _camera_lock/_active_cv2_indices: the
|
||||||
|
# first stream to initialize() owns the camera; a second stream on the SAME
|
||||||
|
# camera attaches (ref-counted); a second stream on a DIFFERENT camera is
|
||||||
|
# refused. Only the last owner to clean up actually stops the camera. Without
|
||||||
|
# this, two concurrent android_camera sources on different displays would make
|
||||||
|
# the second open silently steal the first's frames, and either stream's
|
||||||
|
# cleanup would drain the shared queue out from under the other.
|
||||||
|
_state_lock = threading.Lock()
|
||||||
|
_owner_index: int | None = None # display_index that currently owns the camera
|
||||||
|
_owner_refs = 0 # number of streams attached to the active camera
|
||||||
|
# Camera2 delivers frames continuously, but cache the last one so a
|
||||||
|
# brief consumer stall still has something to read (mirrors
|
||||||
|
# mediaprojection_engine's _last_frame).
|
||||||
|
_last_frame: Optional["ScreenCapture"] = None
|
||||||
|
|
||||||
|
# Enumeration cache. is_available() is polled by the engine registry,
|
||||||
|
# so the (cheap but non-free) Camera2 enumeration is cached briefly —
|
||||||
|
# matching the desktop CameraEngine's 30 s TTL.
|
||||||
|
_cam_cache: List[Dict[str, Any]] | None = None
|
||||||
|
_cam_cache_time: float = 0.0
|
||||||
|
_CAM_CACHE_TTL = 30.0 # seconds
|
||||||
|
|
||||||
|
# Resolution presets shown in the UI. Identical to the desktop
|
||||||
|
# CameraEngine set so the data-driven capture-template config UI
|
||||||
|
# (keyed by the "resolution" field name) renders the same dropdown.
|
||||||
|
# "auto" lets the Kotlin bridge pick a balanced output size.
|
||||||
|
_RESOLUTION_CHOICES: List[str] = [
|
||||||
|
"auto",
|
||||||
|
"640x480",
|
||||||
|
"1280x720",
|
||||||
|
"1920x1080",
|
||||||
|
"2560x1440",
|
||||||
|
"3840x2160",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_resolution(value: Any) -> tuple[int, int] | None:
|
||||||
|
"""Parse a 'WxH' string into (width, height). None for 'auto'/invalid."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
s = value.strip().lower()
|
||||||
|
if s in ("", "auto"):
|
||||||
|
return None
|
||||||
|
parts = s.replace("×", "x").split("x")
|
||||||
|
if len(parts) != 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
w, h = int(parts[0]), int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
return None
|
||||||
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Kotlin CameraBridge interop — lazy + guarded (never at import time)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _camera_bridge():
|
||||||
|
"""Return the Kotlin ``CameraBridge`` singleton, or None off-Android.
|
||||||
|
|
||||||
|
The ``from java import jclass`` import only resolves inside the
|
||||||
|
Chaquopy runtime, so it must never run at module import time (this
|
||||||
|
module is imported on desktop CI too). Mirrors
|
||||||
|
``core/devices/android_ble_transport.py``.
|
||||||
|
"""
|
||||||
|
if not is_android():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from java import jclass # type: ignore[import-not-found]
|
||||||
|
except ImportError as exc:
|
||||||
|
logger.debug("Chaquopy java interop not available: %s", exc)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return jclass("com.ledgrab.android.CameraBridge").INSTANCE
|
||||||
|
except Exception as exc: # pragma: no cover - Android-only path
|
||||||
|
logger.debug("CameraBridge singleton unavailable: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_cameras() -> List[Dict[str, Any]]:
|
||||||
|
"""Enumerate cameras via the Kotlin bridge.
|
||||||
|
|
||||||
|
Returns a list of ``{"index": int, "name": str, "facing": str}``
|
||||||
|
dicts in stable enumeration order, or ``[]`` off-Android / on error
|
||||||
|
/ when the device has no cameras or CAMERA enumeration fails.
|
||||||
|
Monkeypatched in tests to inject a fake list without Android.
|
||||||
|
"""
|
||||||
|
bridge = _camera_bridge()
|
||||||
|
if bridge is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
raw = bridge.listCameras() # JSON array string
|
||||||
|
except Exception as exc: # pragma: no cover - Android-only path
|
||||||
|
logger.warning("CameraBridge.listCameras failed: %s", exc)
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(str(raw))
|
||||||
|
except (ValueError, TypeError) as exc: # pragma: no cover
|
||||||
|
logger.warning("CameraBridge.listCameras returned invalid JSON: %s", exc)
|
||||||
|
return []
|
||||||
|
cameras: List[Dict[str, Any]] = []
|
||||||
|
for i, entry in enumerate(parsed if isinstance(parsed, list) else []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
cameras.append(
|
||||||
|
{
|
||||||
|
"index": int(entry.get("index", i)),
|
||||||
|
"name": str(entry.get("name") or f"Camera {i}"),
|
||||||
|
"facing": str(entry.get("facing") or "unknown"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return cameras
|
||||||
|
|
||||||
|
|
||||||
|
def _enumerate_cameras() -> List[Dict[str, Any]]:
|
||||||
|
"""Cached camera enumeration (TTL ``_CAM_CACHE_TTL``)."""
|
||||||
|
global _cam_cache, _cam_cache_time
|
||||||
|
now = time.monotonic()
|
||||||
|
if _cam_cache is not None and (now - _cam_cache_time) < _CAM_CACHE_TTL:
|
||||||
|
return _cam_cache
|
||||||
|
_cam_cache = list_cameras()
|
||||||
|
_cam_cache_time = now
|
||||||
|
return _cam_cache
|
||||||
|
|
||||||
|
|
||||||
|
def start_camera(index: int, width: int, height: int) -> bool:
|
||||||
|
"""Signal the Kotlin bridge to open camera ``index`` (on demand).
|
||||||
|
|
||||||
|
``width``/``height`` are the requested capture size (0 => let the
|
||||||
|
bridge pick a balanced default). Returns True if the camera began
|
||||||
|
streaming. False off-Android, when the bridge is unavailable, or
|
||||||
|
when the open failed (e.g. CAMERA permission denied, camera in use).
|
||||||
|
Monkeypatched in tests.
|
||||||
|
"""
|
||||||
|
bridge = _camera_bridge()
|
||||||
|
if bridge is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(bridge.startCamera(index, width, height))
|
||||||
|
except Exception as exc: # pragma: no cover - Android-only path
|
||||||
|
logger.warning("CameraBridge.startCamera(%d) failed: %s", index, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def stop_camera(index: int) -> None:
|
||||||
|
"""Signal the Kotlin bridge to close the active camera. No-op off-Android."""
|
||||||
|
bridge = _camera_bridge()
|
||||||
|
if bridge is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
bridge.stopCamera()
|
||||||
|
except Exception as exc: # pragma: no cover - Android-only path
|
||||||
|
logger.debug("CameraBridge.stopCamera failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def push_frame(rgb_bytes: bytes, width: int, height: int) -> None:
|
||||||
|
"""Push one RGB frame from Kotlin into the capture pipeline.
|
||||||
|
|
||||||
|
Called from ``CameraBridge`` on its capture thread. The byte buffer
|
||||||
|
is interpreted as tightly-packed RGB (``width * height * 3`` bytes,
|
||||||
|
3 bytes/pixel — NOT RGBA). The buffer is copied out so Kotlin may
|
||||||
|
reuse its backing array; the oldest queued frame is dropped if the
|
||||||
|
consumer is slow.
|
||||||
|
"""
|
||||||
|
global _frames_received, _last_frame
|
||||||
|
expected = width * height * 3
|
||||||
|
if expected <= 0:
|
||||||
|
return
|
||||||
|
arr = np.frombuffer(rgb_bytes, dtype=np.uint8)
|
||||||
|
if arr.size < expected:
|
||||||
|
# Short/malformed buffer — drop rather than reshape-crash.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Copy out of the read-only frombuffer view (and off any reusable
|
||||||
|
# Kotlin buffer) so the queued frame owns its memory. Mirrors
|
||||||
|
# mediaprojection_engine.push_frame's .copy().
|
||||||
|
rgb = arr[:expected].reshape((height, width, 3)).copy()
|
||||||
|
|
||||||
|
frame = ScreenCapture(
|
||||||
|
image=rgb,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
display_index=_active_index,
|
||||||
|
)
|
||||||
|
_last_frame = frame
|
||||||
|
|
||||||
|
_frames_received += 1
|
||||||
|
if _frames_received == 1 or _frames_received % 100 == 0:
|
||||||
|
logger.info("Android camera: received %d frames", _frames_received)
|
||||||
|
|
||||||
|
# Drop oldest frame if queue is full (non-blocking).
|
||||||
|
try:
|
||||||
|
_frame_queue.put_nowait(frame)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_frame_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
_frame_queue.put_nowait(frame)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown() -> None:
|
||||||
|
"""Deactivate the engine. Called when the Android app stops."""
|
||||||
|
global _active
|
||||||
|
_active = False
|
||||||
|
logger.info("Android camera engine shut down")
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_queue() -> None:
|
||||||
|
"""Discard any queued frames (stale frames from a prior session)."""
|
||||||
|
global _last_frame
|
||||||
|
while not _frame_queue.empty():
|
||||||
|
try:
|
||||||
|
_frame_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
_last_frame = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CaptureStream
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AndroidCameraCaptureStream(CaptureStream):
|
||||||
|
"""Reads camera frames pushed by Kotlin from the module-level queue.
|
||||||
|
|
||||||
|
Opening the physical camera is on demand: :meth:`initialize` asks
|
||||||
|
the Kotlin bridge to open the camera bound to ``display_index`` and
|
||||||
|
:meth:`cleanup` asks it to close.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
if not is_android():
|
||||||
|
raise RuntimeError(
|
||||||
|
"Android camera engine not available. "
|
||||||
|
"This engine is only usable inside the Android app."
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = _parse_resolution(self.config.get("resolution", "auto"))
|
||||||
|
target_w, target_h = parsed if parsed is not None else (0, 0)
|
||||||
|
|
||||||
|
global _active, _active_index, _owner_index, _owner_refs
|
||||||
|
with _state_lock:
|
||||||
|
if _owner_index is not None and _owner_index != self.display_index:
|
||||||
|
# Another camera is already streaming — the bridge can only
|
||||||
|
# drive one at a time, so refuse rather than silently stealing
|
||||||
|
# the active camera's frames (mirrors the desktop CameraEngine's
|
||||||
|
# "already in use by another stream").
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Android camera {_owner_index} is already in use by another "
|
||||||
|
f"capture; only one camera can stream at a time"
|
||||||
|
)
|
||||||
|
if _owner_index == self.display_index:
|
||||||
|
# Same camera already open — attach to it (ref-counted).
|
||||||
|
_owner_refs += 1
|
||||||
|
self._initialized = True
|
||||||
|
logger.info(
|
||||||
|
"Android camera capture stream attached (camera=%d, refs=%d)",
|
||||||
|
self.display_index,
|
||||||
|
_owner_refs,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# No camera open — open this one. Drain stale frames first so the
|
||||||
|
# first captured frame is actually current.
|
||||||
|
_drain_queue()
|
||||||
|
if not start_camera(self.display_index, target_w, target_h):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to open Android camera {self.display_index} "
|
||||||
|
f"(CAMERA permission denied, camera in use, or unavailable)"
|
||||||
|
)
|
||||||
|
_owner_index = self.display_index
|
||||||
|
_owner_refs = 1
|
||||||
|
_active = True
|
||||||
|
_active_index = self.display_index
|
||||||
|
self._initialized = True
|
||||||
|
logger.info("Android camera capture stream initialized (camera=%d)", self.display_index)
|
||||||
|
|
||||||
|
def capture_frame(self) -> ScreenCapture | None:
|
||||||
|
if not self._initialized:
|
||||||
|
self.initialize()
|
||||||
|
# Prefer a fresh frame; fall back to the last one on a brief stall.
|
||||||
|
try:
|
||||||
|
return _frame_queue.get(timeout=0.1)
|
||||||
|
except queue.Empty:
|
||||||
|
return _last_frame
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
if self._initialized:
|
||||||
|
global _active, _owner_index, _owner_refs
|
||||||
|
with _state_lock:
|
||||||
|
_owner_refs -= 1
|
||||||
|
if _owner_refs <= 0:
|
||||||
|
# Last owner released — actually stop the camera.
|
||||||
|
stop_camera(self.display_index)
|
||||||
|
_owner_index = None
|
||||||
|
_owner_refs = 0
|
||||||
|
_active = False
|
||||||
|
_drain_queue()
|
||||||
|
self._initialized = False
|
||||||
|
logger.info("Android camera capture stream cleaned up (camera=%d)", self.display_index)
|
||||||
|
else:
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CaptureEngine
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AndroidCameraEngine(CaptureEngine):
|
||||||
|
"""Android camera/webcam capture engine (Camera2 via Kotlin bridge).
|
||||||
|
|
||||||
|
Only available inside the LedGrab Android app with at least one
|
||||||
|
enumerable camera. Each camera is exposed as a selectable
|
||||||
|
"display", mirroring the desktop OpenCV :class:`CameraEngine`.
|
||||||
|
Selected explicitly via ``engine_type="android_camera"`` in a
|
||||||
|
capture template — never auto-selected (priority 0, below
|
||||||
|
MediaProjection's 100).
|
||||||
|
"""
|
||||||
|
|
||||||
|
ENGINE_TYPE = "android_camera"
|
||||||
|
ENGINE_PRIORITY = 0 # never auto-selected over MediaProjection (100); explicit only
|
||||||
|
HAS_OWN_DISPLAYS = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_available(cls) -> bool:
|
||||||
|
return is_android() and len(_enumerate_cameras()) > 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default_config(cls) -> Dict[str, Any]:
|
||||||
|
return {"resolution": "auto"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_config_choices(cls) -> Dict[str, List[str]]:
|
||||||
|
return {"resolution": list(_RESOLUTION_CHOICES)}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_available_displays(cls) -> List[DisplayInfo]:
|
||||||
|
displays: List[DisplayInfo] = []
|
||||||
|
for cam in _enumerate_cameras():
|
||||||
|
idx = cam["index"]
|
||||||
|
displays.append(
|
||||||
|
DisplayInfo(
|
||||||
|
index=idx,
|
||||||
|
name=cam["name"],
|
||||||
|
width=0,
|
||||||
|
height=0,
|
||||||
|
x=idx * 500,
|
||||||
|
y=0,
|
||||||
|
is_primary=(idx == 0),
|
||||||
|
refresh_rate=30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return displays
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_stream(
|
||||||
|
cls, display_index: int, config: Dict[str, Any]
|
||||||
|
) -> AndroidCameraCaptureStream:
|
||||||
|
merged = {**cls.get_default_config(), **config}
|
||||||
|
return AndroidCameraCaptureStream(display_index, merged)
|
||||||
@@ -40,6 +40,11 @@ _AS_IDS = {
|
|||||||
"system": "as_demo0001",
|
"system": "as_demo0001",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_VS_IDS = {
|
||||||
|
"level": "vs_demo0001",
|
||||||
|
"boost": "vs_demo0002",
|
||||||
|
}
|
||||||
|
|
||||||
_TPL_ID = "tpl_demo0001"
|
_TPL_ID = "tpl_demo0001"
|
||||||
|
|
||||||
_SCENE_ID = "scene_demo0001"
|
_SCENE_ID = "scene_demo0001"
|
||||||
@@ -86,6 +91,7 @@ def seed_demo_data(db: Database) -> None:
|
|||||||
_insert_entities(db, "picture_sources", _build_picture_sources())
|
_insert_entities(db, "picture_sources", _build_picture_sources())
|
||||||
_insert_entities(db, "color_strip_sources", _build_color_strip_sources())
|
_insert_entities(db, "color_strip_sources", _build_color_strip_sources())
|
||||||
_insert_entities(db, "audio_sources", _build_audio_sources())
|
_insert_entities(db, "audio_sources", _build_audio_sources())
|
||||||
|
_insert_entities(db, "value_sources", _build_value_sources())
|
||||||
_insert_entities(db, "scene_presets", _build_scene_presets())
|
_insert_entities(db, "scene_presets", _build_scene_presets())
|
||||||
|
|
||||||
logger.info("Demo seed data complete")
|
logger.info("Demo seed data complete")
|
||||||
@@ -334,6 +340,40 @@ def _build_audio_sources() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Value Sources ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _build_value_sources() -> dict:
|
||||||
|
"""A static float source plus a template combinator that references it,
|
||||||
|
so demo mode showcases the Jinja template value source out of the box."""
|
||||||
|
return {
|
||||||
|
_VS_IDS["level"]: {
|
||||||
|
"id": _VS_IDS["level"],
|
||||||
|
"name": "Base Level",
|
||||||
|
"source_type": "static",
|
||||||
|
"description": "A constant brightness level (demo input for the template below)",
|
||||||
|
"tags": ["demo"],
|
||||||
|
"value": 0.5,
|
||||||
|
"created_at": _NOW,
|
||||||
|
"updated_at": _NOW,
|
||||||
|
},
|
||||||
|
_VS_IDS["boost"]: {
|
||||||
|
"id": _VS_IDS["boost"],
|
||||||
|
"name": "Boosted Level (template)",
|
||||||
|
"source_type": "template",
|
||||||
|
"return_type": "float",
|
||||||
|
"description": "Jinja combinator: clamps 1.5x the Base Level into [0,1]",
|
||||||
|
"tags": ["demo"],
|
||||||
|
"template": "clamp(level * 1.5)",
|
||||||
|
"inputs": [{"name": "level", "value_source_id": _VS_IDS["level"]}],
|
||||||
|
"default_value": 0.0,
|
||||||
|
"eval_interval": None,
|
||||||
|
"created_at": _NOW,
|
||||||
|
"updated_at": _NOW,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Scene Presets ──────────────────────────────────────────────────
|
# ── Scene Presets ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ class BaseDeviceConfig:
|
|||||||
class WLEDConfig(BaseDeviceConfig):
|
class WLEDConfig(BaseDeviceConfig):
|
||||||
device_type: Literal["wled"] = "wled"
|
device_type: Literal["wled"] = "wled"
|
||||||
use_ddp: bool = False
|
use_ddp: bool = False
|
||||||
|
# WLED native realtime UDP (port 21324) — mutually exclusive with use_ddp.
|
||||||
|
# realtime_timeout = seconds WLED stays in realtime after the last packet
|
||||||
|
# before reverting to its normal effect/preset (graceful auto-revert).
|
||||||
|
use_realtime: bool = False
|
||||||
|
realtime_timeout: int = 2
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ class WLEDClient(LEDClient):
|
|||||||
retry_attempts: int = 3,
|
retry_attempts: int = 3,
|
||||||
retry_delay: int = 1,
|
retry_delay: int = 1,
|
||||||
use_ddp: bool = False,
|
use_ddp: bool = False,
|
||||||
|
use_realtime: bool = False,
|
||||||
|
realtime_timeout: int = 2,
|
||||||
):
|
):
|
||||||
"""Initialize WLED client.
|
"""Initialize WLED client.
|
||||||
|
|
||||||
@@ -95,12 +97,17 @@ class WLEDClient(LEDClient):
|
|||||||
retry_attempts: Number of retry attempts on failure
|
retry_attempts: Number of retry attempts on failure
|
||||||
retry_delay: Delay between retries in seconds
|
retry_delay: Delay between retries in seconds
|
||||||
use_ddp: Force DDP protocol (auto-enabled for >500 LEDs)
|
use_ddp: Force DDP protocol (auto-enabled for >500 LEDs)
|
||||||
|
use_realtime: Use WLED native realtime UDP (port 21324) instead of DDP
|
||||||
|
realtime_timeout: Seconds WLED stays in realtime after the last packet
|
||||||
|
before reverting to its normal effect/preset (1-255)
|
||||||
"""
|
"""
|
||||||
self.url = url.rstrip("/")
|
self.url = url.rstrip("/")
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.retry_attempts = retry_attempts
|
self.retry_attempts = retry_attempts
|
||||||
self.retry_delay = retry_delay
|
self.retry_delay = retry_delay
|
||||||
self.use_ddp = use_ddp
|
self.use_ddp = use_ddp
|
||||||
|
self.use_realtime = use_realtime
|
||||||
|
self.realtime_timeout = realtime_timeout
|
||||||
|
|
||||||
# Extract hostname/IP from URL for DDP
|
# Extract hostname/IP from URL for DDP
|
||||||
parsed = urlparse(self.url)
|
parsed = urlparse(self.url)
|
||||||
@@ -108,6 +115,7 @@ class WLEDClient(LEDClient):
|
|||||||
|
|
||||||
self._client: httpx.AsyncClient | None = None
|
self._client: httpx.AsyncClient | None = None
|
||||||
self._ddp_client: DDPClient | None = None
|
self._ddp_client: DDPClient | None = None
|
||||||
|
self._realtime_client = None # WledRealtimeClient when use_realtime
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._pre_connect_state: dict | None = None
|
self._pre_connect_state: dict | None = None
|
||||||
|
|
||||||
@@ -127,8 +135,9 @@ class WLEDClient(LEDClient):
|
|||||||
# Test connection by getting device info
|
# Test connection by getting device info
|
||||||
info = await self.get_info()
|
info = await self.get_info()
|
||||||
|
|
||||||
# Auto-enable DDP for large LED counts
|
# Auto-enable DDP for large LED counts (unless the user explicitly
|
||||||
if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp:
|
# chose native realtime UDP, which handles any size via DNRGB).
|
||||||
|
if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp and not self.use_realtime:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), "
|
f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), "
|
||||||
"auto-enabling DDP protocol"
|
"auto-enabling DDP protocol"
|
||||||
@@ -138,8 +147,30 @@ class WLEDClient(LEDClient):
|
|||||||
# Snapshot device state BEFORE any mutations (for auto-restore)
|
# Snapshot device state BEFORE any mutations (for auto-restore)
|
||||||
self._pre_connect_state = await self.snapshot_device_state()
|
self._pre_connect_state = await self.snapshot_device_state()
|
||||||
|
|
||||||
|
# Create WLED native realtime UDP client if selected
|
||||||
|
if self.use_realtime:
|
||||||
|
from ledgrab.core.devices.wled_realtime_client import WledRealtimeClient
|
||||||
|
|
||||||
|
self._realtime_client = WledRealtimeClient(
|
||||||
|
self.host, rgbw=info.rgbw, timeout_secs=self.realtime_timeout
|
||||||
|
)
|
||||||
|
await self._realtime_client.connect()
|
||||||
|
try:
|
||||||
|
await self._request(
|
||||||
|
"POST",
|
||||||
|
"/json/state",
|
||||||
|
json_data={"on": True, "lor": 0, "AudioReactive": {"on": False}},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not configure device for realtime UDP: {e}")
|
||||||
|
logger.info(
|
||||||
|
"WLED native realtime UDP enabled (port 21324, %ds timeout, %s)",
|
||||||
|
self.realtime_timeout,
|
||||||
|
"RGBW" if info.rgbw else "RGB",
|
||||||
|
)
|
||||||
|
|
||||||
# Create DDP client if needed
|
# Create DDP client if needed
|
||||||
if self.use_ddp:
|
elif self.use_ddp:
|
||||||
self._ddp_client = DDPClient(self.host, rgbw=False)
|
self._ddp_client = DDPClient(self.host, rgbw=False)
|
||||||
# Pass per-bus config so DDP client can apply per-bus color reordering
|
# Pass per-bus config so DDP client can apply per-bus color reordering
|
||||||
if info.buses:
|
if info.buses:
|
||||||
@@ -191,6 +222,9 @@ class WLEDClient(LEDClient):
|
|||||||
if self._ddp_client:
|
if self._ddp_client:
|
||||||
await self._ddp_client.close()
|
await self._ddp_client.close()
|
||||||
self._ddp_client = None
|
self._ddp_client = None
|
||||||
|
if self._realtime_client:
|
||||||
|
await self._realtime_client.close()
|
||||||
|
self._realtime_client = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
logger.debug(f"Closed connection to {self.url}")
|
logger.debug(f"Closed connection to {self.url}")
|
||||||
|
|
||||||
@@ -201,8 +235,10 @@ class WLEDClient(LEDClient):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_fast_send(self) -> bool:
|
def supports_fast_send(self) -> bool:
|
||||||
"""True when DDP is active and ready for fire-and-forget sends."""
|
"""True when DDP or native realtime UDP is active (fire-and-forget)."""
|
||||||
return self.use_ddp and self._ddp_client is not None
|
return (self.use_ddp and self._ddp_client is not None) or (
|
||||||
|
self.use_realtime and self._realtime_client is not None
|
||||||
|
)
|
||||||
|
|
||||||
async def _request(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
@@ -384,7 +420,10 @@ class WLEDClient(LEDClient):
|
|||||||
raise ValueError(f"Invalid RGB values at index {idx}: {tuple(pixel_arr[idx])}")
|
raise ValueError(f"Invalid RGB values at index {idx}: {tuple(pixel_arr[idx])}")
|
||||||
validated_pixels = pixel_arr.astype(np.uint8) if pixel_arr.dtype != np.uint8 else pixel_arr
|
validated_pixels = pixel_arr.astype(np.uint8) if pixel_arr.dtype != np.uint8 else pixel_arr
|
||||||
|
|
||||||
# Use DDP protocol if enabled
|
# Native realtime UDP takes precedence, then DDP, then HTTP
|
||||||
|
if self.use_realtime and self._realtime_client:
|
||||||
|
self._realtime_client.send_pixels_numpy(validated_pixels)
|
||||||
|
return True
|
||||||
if self.use_ddp and self._ddp_client:
|
if self.use_ddp and self._ddp_client:
|
||||||
return await self._send_pixels_ddp(validated_pixels, brightness)
|
return await self._send_pixels_ddp(validated_pixels, brightness)
|
||||||
else:
|
else:
|
||||||
@@ -485,8 +524,10 @@ class WLEDClient(LEDClient):
|
|||||||
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
|
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
|
||||||
brightness: Global brightness (0-255)
|
brightness: Global brightness (0-255)
|
||||||
"""
|
"""
|
||||||
if not self.use_ddp or not self._ddp_client:
|
if not (self.use_ddp and self._ddp_client) and not (
|
||||||
raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP")
|
self.use_realtime and self._realtime_client
|
||||||
|
):
|
||||||
|
raise RuntimeError("send_pixels_fast requires DDP or realtime UDP; use send_pixels")
|
||||||
|
|
||||||
if isinstance(pixels, np.ndarray):
|
if isinstance(pixels, np.ndarray):
|
||||||
pixel_array = pixels
|
pixel_array = pixels
|
||||||
@@ -494,7 +535,10 @@ class WLEDClient(LEDClient):
|
|||||||
pixel_array = np.array(pixels, dtype=np.uint8)
|
pixel_array = np.array(pixels, dtype=np.uint8)
|
||||||
|
|
||||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||||
self._ddp_client.send_pixels_numpy(pixel_array)
|
if self.use_realtime and self._realtime_client:
|
||||||
|
self._realtime_client.send_pixels_numpy(pixel_array)
|
||||||
|
else:
|
||||||
|
self._ddp_client.send_pixels_numpy(pixel_array)
|
||||||
|
|
||||||
# ===== LEDClient abstraction methods =====
|
# ===== LEDClient abstraction methods =====
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
|||||||
return WLEDClient(
|
return WLEDClient(
|
||||||
config.device_url,
|
config.device_url,
|
||||||
use_ddp=config.use_ddp,
|
use_ddp=config.use_ddp,
|
||||||
|
use_realtime=config.use_realtime,
|
||||||
|
realtime_timeout=config.realtime_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""WLED native realtime UDP client (port 21324).
|
||||||
|
|
||||||
|
WLED exposes a family of "realtime" UDP protocols separate from DDP. Compared to
|
||||||
|
the DDP path this gives three user-visible wins for the device LedGrab drives
|
||||||
|
most:
|
||||||
|
|
||||||
|
* **Auto-revert** — every packet carries a *timeout* byte. If LedGrab stops
|
||||||
|
streaming (host hiccup, sleep, crash), WLED returns to its normal effect /
|
||||||
|
preset after that many seconds instead of freezing on the last frame.
|
||||||
|
* **Correct RGBW whites** — the DRGBW variant carries an explicit white channel,
|
||||||
|
so RGBW strips are driven correctly instead of leaving W uncontrolled.
|
||||||
|
* **Lighter on weak Wi-Fi** — raw RGB with a 2-byte header, no DDP framing.
|
||||||
|
|
||||||
|
Unlike the DDP path, WLED applies the configured per-bus color order itself in
|
||||||
|
realtime mode, so this sender transmits plain RGB (no manual reordering) — the
|
||||||
|
user's WLED colour-order setting just works.
|
||||||
|
|
||||||
|
Packet layout (first byte selects the protocol)::
|
||||||
|
|
||||||
|
DRGB (2): [2][timeout] + R G B per LED (<= 490 LEDs)
|
||||||
|
DRGBW (3): [3][timeout] + R G B W per LED (<= 367 LEDs)
|
||||||
|
DNRGB (4): [4][timeout][start_hi][start_lo] + R G B per LED (chunked, 489/pkt)
|
||||||
|
|
||||||
|
The ``timeout`` byte is in **seconds** (1-255). DNRGB carries a 16-bit start
|
||||||
|
index so strips larger than one packet are sent as several chunks.
|
||||||
|
|
||||||
|
Ref: https://kno.wled.ge/interfaces/udp-realtime/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
REALTIME_PORT = 21324
|
||||||
|
|
||||||
|
# Protocol selector (first byte).
|
||||||
|
_DRGB = 2
|
||||||
|
_DRGBW = 3
|
||||||
|
_DNRGB = 4
|
||||||
|
|
||||||
|
# Per-protocol LED capacity (bounded by the ~1500-byte UDP payload).
|
||||||
|
_MAX_DRGB = 490 # 2 + 490*3 = 1472
|
||||||
|
_MAX_DRGBW = 367 # 2 + 367*4 = 1470
|
||||||
|
_MAX_DNRGB_CHUNK = 489 # 4 + 489*3 = 1471
|
||||||
|
|
||||||
|
# Default seconds WLED stays in realtime after the last packet before reverting.
|
||||||
|
DEFAULT_REALTIME_TIMEOUT = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_timeout(seconds: int) -> int:
|
||||||
|
"""Clamp the realtime timeout to the on-wire 1-255 range."""
|
||||||
|
return max(1, min(255, int(seconds)))
|
||||||
|
|
||||||
|
|
||||||
|
class WledRealtimeClient:
|
||||||
|
"""Fire-and-forget UDP sender for WLED native realtime protocols."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int = REALTIME_PORT,
|
||||||
|
rgbw: bool = False,
|
||||||
|
timeout_secs: int = DEFAULT_REALTIME_TIMEOUT,
|
||||||
|
) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.rgbw = rgbw
|
||||||
|
self.timeout_secs = _clamp_timeout(timeout_secs)
|
||||||
|
self._transport: asyncio.DatagramTransport | None = None
|
||||||
|
self._protocol: asyncio.DatagramProtocol | None = None
|
||||||
|
# Reusable RGBW scratch (resized on demand) so the hot path doesn't
|
||||||
|
# allocate a fresh (N, 4) array per frame.
|
||||||
|
self._rgbw_buf: np.ndarray | None = None
|
||||||
|
self._rgbw_buf_n: int = 0
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
"""Open the UDP datagram endpoint to the device."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._transport, self._protocol = await loop.create_datagram_endpoint(
|
||||||
|
asyncio.DatagramProtocol, remote_addr=(self.host, self.port)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"WLED realtime client connected to %s:%d (timeout %ds, %s)",
|
||||||
|
self.host,
|
||||||
|
self.port,
|
||||||
|
self.timeout_secs,
|
||||||
|
"RGBW" if self.rgbw else "RGB",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the datagram endpoint."""
|
||||||
|
if self._transport is not None:
|
||||||
|
self._transport.close()
|
||||||
|
self._transport = None
|
||||||
|
self._protocol = None
|
||||||
|
logger.debug("Closed WLED realtime connection to %s:%d", self.host, self.port)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._transport is not None
|
||||||
|
|
||||||
|
def _ensure_rgbw_buf(self, n: int) -> np.ndarray:
|
||||||
|
"""Return an ``(n, 4)`` uint8 RGBW buffer with the white channel zeroed."""
|
||||||
|
if self._rgbw_buf is None or self._rgbw_buf_n != n:
|
||||||
|
self._rgbw_buf = np.zeros((n, 4), dtype=np.uint8)
|
||||||
|
self._rgbw_buf_n = n
|
||||||
|
return self._rgbw_buf
|
||||||
|
|
||||||
|
def build_packets(self, pixels: np.ndarray) -> list[bytes]:
|
||||||
|
"""Build the realtime UDP packet(s) for one ``(N, 3)`` uint8 RGB frame.
|
||||||
|
|
||||||
|
Exposed (and pure) for unit testing the wire format. Picks DRGBW for
|
||||||
|
RGBW strips within range, DRGB for small RGB strips, otherwise DNRGB
|
||||||
|
chunks. The white channel is sent as 0 (colour comes from the RGB LEDs).
|
||||||
|
"""
|
||||||
|
pixels = np.ascontiguousarray(pixels, dtype=np.uint8)
|
||||||
|
n = len(pixels)
|
||||||
|
t = self.timeout_secs
|
||||||
|
if n == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if self.rgbw and n <= _MAX_DRGBW:
|
||||||
|
buf = self._ensure_rgbw_buf(n)
|
||||||
|
buf[:, 0:3] = pixels
|
||||||
|
# white channel already zeroed and left at 0
|
||||||
|
return [bytes([_DRGBW, t]) + buf.tobytes()]
|
||||||
|
|
||||||
|
if n <= _MAX_DRGB and not self.rgbw:
|
||||||
|
return [bytes([_DRGB, t]) + pixels.tobytes()]
|
||||||
|
|
||||||
|
# DNRGB: 16-bit start index, chunked. Covers >490 RGB and >367 RGBW
|
||||||
|
# (the white channel is dropped for oversized RGBW strips).
|
||||||
|
packets: list[bytes] = []
|
||||||
|
for start in range(0, n, _MAX_DNRGB_CHUNK):
|
||||||
|
end = min(start + _MAX_DNRGB_CHUNK, n)
|
||||||
|
header = bytes([_DNRGB, t, (start >> 8) & 0xFF, start & 0xFF])
|
||||||
|
packets.append(header + pixels[start:end].tobytes())
|
||||||
|
return packets
|
||||||
|
|
||||||
|
def send_pixels_numpy(self, pixels: np.ndarray) -> bool:
|
||||||
|
"""Send one frame of ``(N, 3)`` uint8 RGB pixels (fire-and-forget)."""
|
||||||
|
if self._transport is None:
|
||||||
|
return False
|
||||||
|
for packet in self.build_packets(pixels):
|
||||||
|
self._transport.sendto(packet)
|
||||||
|
return True
|
||||||
@@ -18,7 +18,7 @@ from ledgrab.core.capture.calibration import (
|
|||||||
CalibrationConfig,
|
CalibrationConfig,
|
||||||
create_pixel_mapper,
|
create_pixel_mapper,
|
||||||
)
|
)
|
||||||
from ledgrab.core.capture.screen_capture import extract_border_pixels
|
from ledgrab.core.capture.screen_capture import crop_screen_capture, extract_border_pixels
|
||||||
from ledgrab.storage.bindable import bfloat
|
from ledgrab.storage.bindable import bfloat
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
from ledgrab.utils.frame_limiter import FrameLimiter
|
from ledgrab.utils.frame_limiter import FrameLimiter
|
||||||
@@ -296,7 +296,19 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
led_colors = mapper.map_lines_to_leds(frames_dict)
|
led_colors = mapper.map_lines_to_leds(frames_dict)
|
||||||
else:
|
else:
|
||||||
border_pixels = extract_border_pixels(frame, calibration.border_width)
|
src = frame
|
||||||
|
bw = calibration.border_width
|
||||||
|
if calibration.has_roi:
|
||||||
|
src = crop_screen_capture(
|
||||||
|
frame,
|
||||||
|
calibration.roi_x,
|
||||||
|
calibration.roi_y,
|
||||||
|
calibration.roi_width,
|
||||||
|
calibration.roi_height,
|
||||||
|
)
|
||||||
|
# Border width must stay within the cropped size.
|
||||||
|
bw = max(1, min(bw, min(src.width, src.height) // 4))
|
||||||
|
border_pixels = extract_border_pixels(src, bw)
|
||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
led_colors = mapper.map_border_to_leds(border_pixels)
|
led_colors = mapper.map_border_to_leds(border_pixels)
|
||||||
t2 = time.perf_counter()
|
t2 = time.perf_counter()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import numpy as np
|
|||||||
|
|
||||||
from ledgrab.core.processing.color_strip_stream import ColorStripStream
|
from ledgrab.core.processing.color_strip_stream import ColorStripStream
|
||||||
from ledgrab.storage.bindable import bfloat
|
from ledgrab.storage.bindable import bfloat
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import clamp01, get_logger
|
||||||
from ledgrab.utils.frame_limiter import FrameLimiter
|
from ledgrab.utils.frame_limiter import FrameLimiter
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -176,7 +176,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
if i in self._brightness_streams:
|
if i in self._brightness_streams:
|
||||||
_vs_id, vs = self._brightness_streams[i]
|
_vs_id, vs = self._brightness_streams[i]
|
||||||
try:
|
try:
|
||||||
result.append(vs.get_value())
|
result.append(clamp01(vs.get_value()))
|
||||||
except Exception:
|
except Exception:
|
||||||
result.append(None)
|
result.append(None)
|
||||||
else:
|
else:
|
||||||
@@ -660,10 +660,14 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
if layer.get("reverse", False):
|
if layer.get("reverse", False):
|
||||||
colors = colors[::-1].copy()
|
colors = colors[::-1].copy()
|
||||||
|
|
||||||
# Apply per-layer brightness from value source
|
# Apply per-layer brightness from value source.
|
||||||
|
# clamp01 is finite-safe: it rejects nan/inf (which would
|
||||||
|
# crash the int() cast) and pins out-of-range values into
|
||||||
|
# [0,1] so the uint16 fixed-point multiply can't wrap on a
|
||||||
|
# negative. bri == 1.0 correctly skips the scale (no-op).
|
||||||
if i in self._brightness_streams:
|
if i in self._brightness_streams:
|
||||||
_vs_id, vs = self._brightness_streams[i]
|
_vs_id, vs = self._brightness_streams[i]
|
||||||
bri = vs.get_value()
|
bri = clamp01(vs.get_value())
|
||||||
if bri < 1.0:
|
if bri < 1.0:
|
||||||
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(
|
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(
|
||||||
np.uint8
|
np.uint8
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ Supported platforms:
|
|||||||
- **Windows**: polls toast notifications via winrt UserNotificationListener
|
- **Windows**: polls toast notifications via winrt UserNotificationListener
|
||||||
(falls back to winsdk if winrt packages are not installed)
|
(falls back to winsdk if winrt packages are not installed)
|
||||||
- **Linux**: monitors org.freedesktop.Notifications via D-Bus (dbus-next)
|
- **Linux**: monitors org.freedesktop.Notifications via D-Bus (dbus-next)
|
||||||
|
- **Android**: receives notifications pushed from a Kotlin NotificationListenerService
|
||||||
|
via Chaquopy (push-based; see push_notification() and _AndroidBackend)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -17,9 +19,10 @@ import platform
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Set
|
from typing import Callable, Dict, List, Optional, Set
|
||||||
|
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.platform import is_linux
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -30,15 +33,71 @@ _HISTORY_MAX = 50
|
|||||||
# Module-level singleton for dependency access
|
# Module-level singleton for dependency access
|
||||||
_instance: Optional["OsNotificationListener"] = None
|
_instance: Optional["OsNotificationListener"] = None
|
||||||
|
|
||||||
|
# Push target for the Android backend — set by _AndroidBackend.start(), read by
|
||||||
|
# push_notification(). None when the Android backend isn't running (desktop / server down).
|
||||||
|
_android_target: Callable[[str | None], None] | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_os_notification_listener() -> Optional["OsNotificationListener"]:
|
def get_os_notification_listener() -> Optional["OsNotificationListener"]:
|
||||||
"""Return the global OsNotificationListener instance (or None)."""
|
"""Return the global OsNotificationListener instance (or None)."""
|
||||||
return _instance
|
return _instance
|
||||||
|
|
||||||
|
|
||||||
|
def push_notification(app_name: str | None) -> None:
|
||||||
|
"""Receive an Android notification pushed from Kotlin via Chaquopy.
|
||||||
|
|
||||||
|
Called by the LedGrabNotificationListener service through
|
||||||
|
``Python.getInstance().getModule(...).callAttr("push_notification", label)``.
|
||||||
|
Routes the posting app's display label into the active listener's
|
||||||
|
``_on_new_notification`` handler. No-op when the Android backend isn't running,
|
||||||
|
so a notification arriving before the server is ready (or on desktop) is safely
|
||||||
|
ignored.
|
||||||
|
"""
|
||||||
|
# Snapshot into a local first: stop() may null _android_target concurrently, but an
|
||||||
|
# in-flight push then still completes against the prior callback. Do NOT collapse this
|
||||||
|
# into `if _android_target is not None: _android_target(...)` — that reintroduces a
|
||||||
|
# TOCTOU None-deref race.
|
||||||
|
cb = _android_target
|
||||||
|
if cb is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cb(app_name)
|
||||||
|
except Exception as exc: # never let a JNI-side call crash the bound service
|
||||||
|
logger.warning("push_notification callback error: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
# ── Platform backends ──────────────────────────────────────────────────
|
# ── Platform backends ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _AndroidBackend:
|
||||||
|
"""Push-based backend — notifications arrive from Kotlin via push_notification().
|
||||||
|
|
||||||
|
Unlike the Windows/Linux backends (which poll or eavesdrop on a thread), Android
|
||||||
|
notifications are delivered by a Kotlin NotificationListenerService across the
|
||||||
|
Chaquopy JNI boundary into the module-level push_notification() receiver, so
|
||||||
|
start()/stop() simply register/clear the receiver target.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, on_notification):
|
||||||
|
self._on_notification = on_notification
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def probe() -> bool:
|
||||||
|
"""Return True when running on Android (Chaquopy)."""
|
||||||
|
from ledgrab.utils.platform import is_android
|
||||||
|
|
||||||
|
return is_android()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
global _android_target
|
||||||
|
_android_target = self._on_notification
|
||||||
|
logger.info("OS notification listener: Android backend active")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
global _android_target
|
||||||
|
_android_target = None
|
||||||
|
|
||||||
|
|
||||||
def _import_winrt_notifications():
|
def _import_winrt_notifications():
|
||||||
"""Try to import WinRT notification APIs: winrt first, then winsdk fallback.
|
"""Try to import WinRT notification APIs: winrt first, then winsdk fallback.
|
||||||
|
|
||||||
@@ -193,7 +252,9 @@ class _LinuxBackend:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def probe() -> bool:
|
def probe() -> bool:
|
||||||
"""Return True if this backend can run on the current system."""
|
"""Return True if this backend can run on the current system."""
|
||||||
if platform.system() != "Linux":
|
# is_linux() excludes Android, which also reports platform.system() == "Linux"
|
||||||
|
# but has no D-Bus session — defense-in-depth beyond probe ordering.
|
||||||
|
if not is_linux():
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
import dbus_next # noqa: F401
|
import dbus_next # noqa: F401
|
||||||
@@ -312,8 +373,9 @@ class OsNotificationListener:
|
|||||||
global _instance
|
global _instance
|
||||||
_instance = self
|
_instance = self
|
||||||
|
|
||||||
# Try platform backends in order
|
# Try platform backends in order (Android first — it reports platform.system()
|
||||||
for backend_cls in (_WindowsBackend, _LinuxBackend):
|
# == "Linux", so probing it ahead of _LinuxBackend is the robust ordering).
|
||||||
|
for backend_cls in (_AndroidBackend, _WindowsBackend, _LinuxBackend):
|
||||||
if backend_cls.probe():
|
if backend_cls.probe():
|
||||||
self._backend = backend_cls(on_notification=self._on_new_notification)
|
self._backend = backend_cls(on_notification=self._on_new_notification)
|
||||||
self._backend.start()
|
self._backend.start()
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Automatic brightness limiting (ABL) — keep a strip within a PSU current budget.
|
||||||
|
|
||||||
|
Estimates the current an addressable LED strip would draw for a frame of
|
||||||
|
already-brightness-scaled RGB bytes and, if it exceeds the configured budget,
|
||||||
|
returns a uniform scale factor to bring it back under budget. This prevents the
|
||||||
|
classic under-spec'd-PSU failure mode: a full-white scene browning out the rail
|
||||||
|
(voltage sag -> red/orange shift, flicker, controller resets) — which reads to a
|
||||||
|
new user as "this software is broken".
|
||||||
|
|
||||||
|
Model: one addressable LED at full white ``(255, 255, 255)`` draws
|
||||||
|
``milliamps_per_led`` mA, and current scales linearly with the sum of channel
|
||||||
|
values, so a frame's draw is::
|
||||||
|
|
||||||
|
estimated_ma = sum(channel_bytes) * milliamps_per_led / (255 * 3)
|
||||||
|
|
||||||
|
(``255 * 3 = 765`` channel-units == one LED at full white.) Standby/idle current
|
||||||
|
is intentionally ignored: the limiter only needs to catch the high-draw frames
|
||||||
|
that cause brownouts, and the default 55 mA/LED already carries real-world
|
||||||
|
headroom. The same convention as WLED's "maximum current" setting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Channel units in one LED at full white (R + G + B = 255 * 3).
|
||||||
|
_FULL_WHITE_UNITS = 765.0
|
||||||
|
|
||||||
|
# Typical full-white draw of a single WS2812/SK6812-class LED, in mA.
|
||||||
|
DEFAULT_MILLIAMPS_PER_LED = 55
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_current_ma(colors: np.ndarray, milliamps_per_led: int) -> float:
|
||||||
|
"""Estimate strip draw (mA) for already-brightness-scaled RGB bytes.
|
||||||
|
|
||||||
|
``colors`` is an ``(N, 3)`` uint8 array of the values actually sent to the
|
||||||
|
strip. Full white over ``N`` LEDs returns ``N * milliamps_per_led``.
|
||||||
|
"""
|
||||||
|
if milliamps_per_led <= 0 or colors.size == 0:
|
||||||
|
return 0.0
|
||||||
|
channel_sum = float(int(colors.sum()))
|
||||||
|
return channel_sum * milliamps_per_led / _FULL_WHITE_UNITS
|
||||||
|
|
||||||
|
|
||||||
|
def power_limit_scale(colors: np.ndarray, max_milliamps: int, milliamps_per_led: int) -> float:
|
||||||
|
"""Return a scale in ``(0, 1]`` that keeps estimated draw within budget.
|
||||||
|
|
||||||
|
Returns ``1.0`` when limiting is disabled (``max_milliamps <= 0``) or the
|
||||||
|
frame is already within budget. Because current is linear in the channel
|
||||||
|
values, scaling every pixel by ``max_milliamps / estimated`` lands the frame
|
||||||
|
exactly on the budget.
|
||||||
|
"""
|
||||||
|
if max_milliamps <= 0 or milliamps_per_led <= 0:
|
||||||
|
return 1.0
|
||||||
|
estimated = estimate_current_ma(colors, milliamps_per_led)
|
||||||
|
if estimated <= max_milliamps:
|
||||||
|
return 1.0
|
||||||
|
return max_milliamps / estimated
|
||||||
@@ -44,6 +44,7 @@ from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
|||||||
from ledgrab.core.weather.weather_manager import WeatherManager
|
from ledgrab.core.weather.weather_manager import WeatherManager
|
||||||
from ledgrab.core.processing.device_health import DeviceHealthMixin
|
from ledgrab.core.processing.device_health import DeviceHealthMixin
|
||||||
from ledgrab.core.processing.device_test_mode import DeviceTestModeMixin
|
from ledgrab.core.processing.device_test_mode import DeviceTestModeMixin
|
||||||
|
from ledgrab.core.capture.calibration_session import CalibrationChaseMixin
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -106,7 +107,9 @@ class DeviceState:
|
|||||||
zone_mode: str = "combined"
|
zone_mode: str = "combined"
|
||||||
|
|
||||||
|
|
||||||
class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin):
|
class ProcessorManager(
|
||||||
|
AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin, CalibrationChaseMixin
|
||||||
|
):
|
||||||
"""Manages devices and delegates target processing to TargetProcessor instances.
|
"""Manages devices and delegates target processing to TargetProcessor instances.
|
||||||
|
|
||||||
Devices are registered for health monitoring.
|
Devices are registered for health monitoring.
|
||||||
@@ -407,6 +410,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
min_brightness_threshold: int = 0,
|
min_brightness_threshold: int = 0,
|
||||||
adaptive_fps: bool = False,
|
adaptive_fps: bool = False,
|
||||||
protocol: str = "ddp",
|
protocol: str = "ddp",
|
||||||
|
max_milliamps: int = 0,
|
||||||
|
milliamps_per_led: int = 55,
|
||||||
):
|
):
|
||||||
"""Register a WLED target processor."""
|
"""Register a WLED target processor."""
|
||||||
if target_id in self._processors:
|
if target_id in self._processors:
|
||||||
@@ -425,6 +430,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
min_brightness_threshold=min_brightness_threshold,
|
min_brightness_threshold=min_brightness_threshold,
|
||||||
adaptive_fps=adaptive_fps,
|
adaptive_fps=adaptive_fps,
|
||||||
protocol=protocol,
|
protocol=protocol,
|
||||||
|
max_milliamps=max_milliamps,
|
||||||
|
milliamps_per_led=milliamps_per_led,
|
||||||
ctx=self._build_context(),
|
ctx=self._build_context(),
|
||||||
)
|
)
|
||||||
self._processors[target_id] = proc
|
self._processors[target_id] = proc
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ def _build_ha_entity(source, d: ValueStreamDeps):
|
|||||||
min_ha_value=source.min_ha_value,
|
min_ha_value=source.min_ha_value,
|
||||||
max_ha_value=source.max_ha_value,
|
max_ha_value=source.max_ha_value,
|
||||||
smoothing=source.smoothing,
|
smoothing=source.smoothing,
|
||||||
|
normalize=source.normalize,
|
||||||
ha_manager=d.ha_manager,
|
ha_manager=d.ha_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,6 +233,7 @@ def _build_system_metrics(source, _d: ValueStreamDeps):
|
|||||||
sensor_label=source.sensor_label,
|
sensor_label=source.sensor_label,
|
||||||
poll_interval=source.poll_interval,
|
poll_interval=source.poll_interval,
|
||||||
smoothing=source.smoothing,
|
smoothing=source.smoothing,
|
||||||
|
normalize=source.normalize,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -249,6 +251,7 @@ def _build_game_event(source, d: ValueStreamDeps):
|
|||||||
smoothing=source.smoothing,
|
smoothing=source.smoothing,
|
||||||
default_value=source.default_value,
|
default_value=source.default_value,
|
||||||
timeout=source.timeout,
|
timeout=source.timeout,
|
||||||
|
normalize=source.normalize,
|
||||||
event_bus=d.event_bus,
|
event_bus=d.event_bus,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -263,10 +266,25 @@ def _build_http(source, d: ValueStreamDeps):
|
|||||||
min_value=source.min_value,
|
min_value=source.min_value,
|
||||||
max_value=source.max_value,
|
max_value=source.max_value,
|
||||||
smoothing=source.smoothing,
|
smoothing=source.smoothing,
|
||||||
|
normalize=source.normalize,
|
||||||
http_endpoint_store=d.http_endpoint_store,
|
http_endpoint_store=d.http_endpoint_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_template(source, d: ValueStreamDeps):
|
||||||
|
# References other value sources via d.value_stream_manager (recursively
|
||||||
|
# acquired in start()), exactly like _build_gradient_map.
|
||||||
|
from ledgrab.core.processing.value_stream import TemplateValueStream
|
||||||
|
|
||||||
|
return TemplateValueStream(
|
||||||
|
template=source.template,
|
||||||
|
inputs=source.inputs,
|
||||||
|
default_value=source.default_value,
|
||||||
|
eval_interval=source.eval_interval,
|
||||||
|
value_stream_manager=d.value_stream_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Registry
|
# Registry
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -290,6 +308,7 @@ STREAM_BUILDERS: dict[str, StreamBuilder] = {
|
|||||||
"system_metrics": _build_system_metrics,
|
"system_metrics": _build_system_metrics,
|
||||||
"game_event": _build_game_event,
|
"game_event": _build_game_event,
|
||||||
"http": _build_http,
|
"http": _build_http,
|
||||||
|
"template": _build_template,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ import numpy as np
|
|||||||
|
|
||||||
from ledgrab.core.processing import metric_readers as _metric_readers
|
from ledgrab.core.processing import metric_readers as _metric_readers
|
||||||
from ledgrab.storage.base_store import EntityNotFoundError
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import clamp01, get_logger
|
||||||
|
from ledgrab.utils.template_expr import (
|
||||||
|
TemplateValidationError,
|
||||||
|
compile_template,
|
||||||
|
finalize_result,
|
||||||
|
)
|
||||||
|
|
||||||
# Compiled once — used by ``_extract_simple_path`` on every poll.
|
# Compiled once — used by ``_extract_simple_path`` on every poll.
|
||||||
_NAME_HEAD_RE = re.compile(r"^([^\[]*)")
|
_NAME_HEAD_RE = re.compile(r"^([^\[]*)")
|
||||||
@@ -53,6 +58,12 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Runtime cap on recursive value-stream acquisition (referencing sources like
|
||||||
|
# template / gradient_map re-enter acquire() from start()). Higher than the
|
||||||
|
# storage-level MAX_VALUE_SOURCE_DEPTH (8) so legitimate chains never trip it;
|
||||||
|
# it only fires on a cycle that bypassed storage validation.
|
||||||
|
_MAX_ACQUIRE_DEPTH = 12
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Base class
|
# Base class
|
||||||
@@ -904,6 +915,7 @@ class HAEntityValueStream(ValueStream):
|
|||||||
min_ha_value: float = 0.0,
|
min_ha_value: float = 0.0,
|
||||||
max_ha_value: float = 100.0,
|
max_ha_value: float = 100.0,
|
||||||
smoothing: float = 0.0,
|
smoothing: float = 0.0,
|
||||||
|
normalize: bool = True,
|
||||||
ha_manager: Any | None = None,
|
ha_manager: Any | None = None,
|
||||||
):
|
):
|
||||||
self._ha_source_id = ha_source_id
|
self._ha_source_id = ha_source_id
|
||||||
@@ -912,6 +924,7 @@ class HAEntityValueStream(ValueStream):
|
|||||||
self._min_ha = min_ha_value
|
self._min_ha = min_ha_value
|
||||||
self._max_ha = max_ha_value
|
self._max_ha = max_ha_value
|
||||||
self._smoothing = smoothing
|
self._smoothing = smoothing
|
||||||
|
self._normalize_enabled = normalize
|
||||||
self._ha_manager = ha_manager
|
self._ha_manager = ha_manager
|
||||||
self._prev_value: float | None = None
|
self._prev_value: float | None = None
|
||||||
self._raw_value: float | None = None
|
self._raw_value: float | None = None
|
||||||
@@ -976,16 +989,23 @@ class HAEntityValueStream(ValueStream):
|
|||||||
|
|
||||||
self._raw_value = raw
|
self._raw_value = raw
|
||||||
|
|
||||||
# Normalize to [0, 1]
|
if self._normalize_enabled:
|
||||||
ha_range = self._max_ha - self._min_ha
|
# Normalize to [0, 1] via the configured min/max range.
|
||||||
if abs(ha_range) < 1e-9:
|
ha_range = self._max_ha - self._min_ha
|
||||||
normalized = 0.5
|
if abs(ha_range) < 1e-9:
|
||||||
|
normalized = 0.5
|
||||||
|
else:
|
||||||
|
normalized = (raw - self._min_ha) / ha_range
|
||||||
|
normalized = max(0.0, min(1.0, normalized))
|
||||||
else:
|
else:
|
||||||
normalized = (raw - self._min_ha) / ha_range
|
# Skip the rescale: treat the raw reading as already a 0–1 fraction
|
||||||
|
# (finite-safe clamp). The un-clamped magnitude stays on
|
||||||
|
# get_raw_value(); get_value() never leaves [0, 1] either way.
|
||||||
|
normalized = clamp01(raw)
|
||||||
|
|
||||||
normalized = max(0.0, min(1.0, normalized))
|
# EMA smoothing — both branches produce a [0, 1] value, so _prev_value
|
||||||
|
# is always normalized and flipping ``normalize`` live never blends a
|
||||||
# EMA smoothing
|
# raw magnitude against a fraction.
|
||||||
if self._smoothing > 0.0 and self._prev_value is not None:
|
if self._smoothing > 0.0 and self._prev_value is not None:
|
||||||
normalized = self._smoothing * self._prev_value + (1.0 - self._smoothing) * normalized
|
normalized = self._smoothing * self._prev_value + (1.0 - self._smoothing) * normalized
|
||||||
|
|
||||||
@@ -1009,6 +1029,7 @@ class HAEntityValueStream(ValueStream):
|
|||||||
self._min_ha = source.min_ha_value
|
self._min_ha = source.min_ha_value
|
||||||
self._max_ha = source.max_ha_value
|
self._max_ha = source.max_ha_value
|
||||||
self._smoothing = source.smoothing
|
self._smoothing = source.smoothing
|
||||||
|
self._normalize_enabled = source.normalize
|
||||||
|
|
||||||
# If HA source changed, swap runtime
|
# If HA source changed, swap runtime
|
||||||
if source.ha_source_id != old_ha_source and self._ha_manager:
|
if source.ha_source_id != old_ha_source and self._ha_manager:
|
||||||
@@ -1052,6 +1073,7 @@ class HTTPValueStream(ValueStream):
|
|||||||
min_value: float,
|
min_value: float,
|
||||||
max_value: float,
|
max_value: float,
|
||||||
smoothing: float,
|
smoothing: float,
|
||||||
|
normalize: bool = True,
|
||||||
http_endpoint_store: "HTTPEndpointStore" | None = None,
|
http_endpoint_store: "HTTPEndpointStore" | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._endpoint_id = endpoint_id
|
self._endpoint_id = endpoint_id
|
||||||
@@ -1060,6 +1082,7 @@ class HTTPValueStream(ValueStream):
|
|||||||
self._min_value = min_value
|
self._min_value = min_value
|
||||||
self._max_value = max_value
|
self._max_value = max_value
|
||||||
self._smoothing = smoothing
|
self._smoothing = smoothing
|
||||||
|
self._normalize_enabled = normalize
|
||||||
self._http_endpoint_store = http_endpoint_store
|
self._http_endpoint_store = http_endpoint_store
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
self._raw_value: Any = None
|
self._raw_value: Any = None
|
||||||
@@ -1099,12 +1122,19 @@ class HTTPValueStream(ValueStream):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return self._prev_normalized if self._prev_normalized is not None else 0.0
|
return self._prev_normalized if self._prev_normalized is not None else 0.0
|
||||||
|
|
||||||
rng = self._max_value - self._min_value
|
if self._normalize_enabled:
|
||||||
if abs(rng) < 1e-9:
|
rng = self._max_value - self._min_value
|
||||||
normalized = 0.5
|
if abs(rng) < 1e-9:
|
||||||
|
normalized = 0.5
|
||||||
|
else:
|
||||||
|
normalized = (numeric - self._min_value) / rng
|
||||||
|
normalized = max(0.0, min(1.0, normalized))
|
||||||
else:
|
else:
|
||||||
normalized = (numeric - self._min_value) / rng
|
# Skip the rescale: treat the extracted number as already a 0–1
|
||||||
normalized = max(0.0, min(1.0, normalized))
|
# fraction (finite-safe clamp). The verbatim extracted value (which
|
||||||
|
# may be non-numeric) stays on get_raw_value(); get_value() is always
|
||||||
|
# a float in [0, 1].
|
||||||
|
normalized = clamp01(numeric)
|
||||||
|
|
||||||
if self._smoothing > 0.0 and self._prev_normalized is not None:
|
if self._smoothing > 0.0 and self._prev_normalized is not None:
|
||||||
normalized = (
|
normalized = (
|
||||||
@@ -1128,6 +1158,7 @@ class HTTPValueStream(ValueStream):
|
|||||||
self._min_value = source.min_value
|
self._min_value = source.min_value
|
||||||
self._max_value = source.max_value
|
self._max_value = source.max_value
|
||||||
self._smoothing = source.smoothing
|
self._smoothing = source.smoothing
|
||||||
|
self._normalize_enabled = source.normalize
|
||||||
|
|
||||||
async def _poll_loop(self) -> None:
|
async def _poll_loop(self) -> None:
|
||||||
from ledgrab.utils.safe_source import safe_request_bounded
|
from ledgrab.utils.safe_source import safe_request_bounded
|
||||||
@@ -1365,6 +1396,168 @@ class GradientMapValueStream(ValueStream):
|
|||||||
self._inner_stream = None
|
self._inner_stream = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Template (Jinja expression combinator)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateValueStream(ValueStream):
|
||||||
|
"""Evaluates a hardened sandboxed-Jinja expression over the live values of
|
||||||
|
other value sources (the system's float combinator).
|
||||||
|
|
||||||
|
Acquires each referenced input stream from the manager on ``start()`` and
|
||||||
|
releases it on ``stop()`` — the same ref-counted protocol as
|
||||||
|
:class:`GradientMapValueStream`, but over a *set* of inputs. Acquisition is
|
||||||
|
tracked per unique ``value_source_id`` so two variables bound to the same
|
||||||
|
source share one ref. ``get_value()`` builds a primitives-only context
|
||||||
|
(each input's normalized ``get_value()`` plus a float-only ``raw`` dict),
|
||||||
|
evaluates the compiled expression, then coerces / NaN-guards / clamps the
|
||||||
|
result. Any error — or an uncompilable template — falls back to
|
||||||
|
``default_value``. An optional ``eval_interval`` caches the last result to
|
||||||
|
bound steady-state evaluation cost.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
template: str,
|
||||||
|
inputs: List[dict],
|
||||||
|
default_value: float = 0.0,
|
||||||
|
eval_interval: float | None = None,
|
||||||
|
value_stream_manager: "ValueStreamManager" | None = None,
|
||||||
|
):
|
||||||
|
self._template = template
|
||||||
|
self._inputs = [dict(i) for i in (inputs or [])]
|
||||||
|
self._default = max(0.0, min(1.0, float(default_value)))
|
||||||
|
self._eval_interval = float(eval_interval) if eval_interval else 0.0
|
||||||
|
self._vsm = value_stream_manager
|
||||||
|
self._streams_by_id: Dict[str, ValueStream] = {} # value_source_id -> stream
|
||||||
|
self._expr = self._compile(template)
|
||||||
|
self._last_value: float = self._default
|
||||||
|
self._last_eval: float = 0.0
|
||||||
|
self._has_value = False
|
||||||
|
self._error_logged = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compile(template: str):
|
||||||
|
"""Compile once; return ``None`` (→ always default) on invalid template.
|
||||||
|
|
||||||
|
Creation should already have rejected invalid templates via the factory;
|
||||||
|
this is defense in depth so a bad row never crashes the engine.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return compile_template(template)
|
||||||
|
except TemplateValidationError as e:
|
||||||
|
logger.warning("TemplateValueStream: invalid template, using default (%s)", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unique_ids(inputs: List[dict]) -> set:
|
||||||
|
return {i["value_source_id"] for i in inputs if i.get("value_source_id")}
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if not self._vsm:
|
||||||
|
return
|
||||||
|
for vs_id in self._unique_ids(self._inputs):
|
||||||
|
try:
|
||||||
|
self._streams_by_id[vs_id] = self._vsm.acquire(vs_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("TemplateValueStream: failed to acquire input %s: %s", vs_id, e)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self._vsm:
|
||||||
|
for vs_id in list(self._streams_by_id):
|
||||||
|
try:
|
||||||
|
self._vsm.release(vs_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("TemplateValueStream: release %s failed: %s", vs_id, e)
|
||||||
|
self._streams_by_id.clear()
|
||||||
|
self._has_value = False
|
||||||
|
|
||||||
|
def get_value(self) -> float:
|
||||||
|
if self._expr is None:
|
||||||
|
return self._default
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._eval_interval > 0.0
|
||||||
|
and self._has_value
|
||||||
|
and (time.monotonic() - self._last_eval) < self._eval_interval
|
||||||
|
):
|
||||||
|
return self._last_value
|
||||||
|
|
||||||
|
try:
|
||||||
|
ctx: Dict[str, Any] = {}
|
||||||
|
raw: Dict[str, float] = {}
|
||||||
|
for inp in self._inputs:
|
||||||
|
name = inp.get("name")
|
||||||
|
vs_id = inp.get("value_source_id")
|
||||||
|
if not name or not vs_id:
|
||||||
|
continue
|
||||||
|
stream = self._streams_by_id.get(vs_id)
|
||||||
|
if stream is None:
|
||||||
|
continue
|
||||||
|
ctx[name] = float(stream.get_value())
|
||||||
|
getter = getattr(stream, "get_raw_value", None)
|
||||||
|
if getter is not None:
|
||||||
|
rv = getter()
|
||||||
|
if rv is not None:
|
||||||
|
try:
|
||||||
|
raw[name] = float(rv)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# Non-numeric raw values never cross into the sandbox.
|
||||||
|
pass
|
||||||
|
ctx["raw"] = raw
|
||||||
|
# Globals (min/max/abs/round/clamp) resolve from SANDBOX_ENV.globals.
|
||||||
|
value = finalize_result(self._expr(**ctx), self._default)
|
||||||
|
except Exception as e:
|
||||||
|
if not self._error_logged:
|
||||||
|
logger.warning("TemplateValueStream eval error (using default): %s", e)
|
||||||
|
self._error_logged = True
|
||||||
|
value = self._default
|
||||||
|
|
||||||
|
self._last_value = value
|
||||||
|
self._last_eval = time.monotonic()
|
||||||
|
self._has_value = True
|
||||||
|
return value
|
||||||
|
|
||||||
|
def update_source(self, source: "ValueSource") -> None:
|
||||||
|
from ledgrab.storage.value_source import TemplateValueSource
|
||||||
|
|
||||||
|
if not isinstance(source, TemplateValueSource):
|
||||||
|
return
|
||||||
|
|
||||||
|
if source.template != self._template:
|
||||||
|
self._template = source.template
|
||||||
|
self._expr = self._compile(source.template)
|
||||||
|
self._error_logged = False
|
||||||
|
|
||||||
|
self._default = max(0.0, min(1.0, float(source.default_value)))
|
||||||
|
self._eval_interval = float(source.eval_interval) if source.eval_interval else 0.0
|
||||||
|
|
||||||
|
new_inputs = [dict(i) for i in (source.inputs or [])]
|
||||||
|
old_ids = set(self._streams_by_id)
|
||||||
|
new_ids = self._unique_ids(new_inputs)
|
||||||
|
|
||||||
|
if self._vsm:
|
||||||
|
# Release-before-acquire (mirrors GradientMapValueStream); safe under
|
||||||
|
# ref-counting. Unchanged ids keep their existing stream untouched.
|
||||||
|
for vs_id in old_ids - new_ids:
|
||||||
|
try:
|
||||||
|
self._vsm.release(vs_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("TemplateValueStream: release %s failed: %s", vs_id, e)
|
||||||
|
self._streams_by_id.pop(vs_id, None)
|
||||||
|
for vs_id in new_ids - old_ids:
|
||||||
|
try:
|
||||||
|
self._streams_by_id[vs_id] = self._vsm.acquire(vs_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("TemplateValueStream: acquire %s failed: %s", vs_id, e)
|
||||||
|
|
||||||
|
# Rebuild inputs (re-keys variable names on rename even when id unchanged,
|
||||||
|
# since get_value() maps name -> stream via value_source_id each tick).
|
||||||
|
self._inputs = new_inputs
|
||||||
|
self._has_value = False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CSS Extract
|
# CSS Extract
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1501,6 +1694,7 @@ class SystemMetricsValueStream(ValueStream):
|
|||||||
sensor_label: str = "",
|
sensor_label: str = "",
|
||||||
poll_interval: float = 1.0,
|
poll_interval: float = 1.0,
|
||||||
smoothing: float = 0.0,
|
smoothing: float = 0.0,
|
||||||
|
normalize: bool = True,
|
||||||
):
|
):
|
||||||
self._metric = metric
|
self._metric = metric
|
||||||
self._min_val = min_value
|
self._min_val = min_value
|
||||||
@@ -1510,6 +1704,7 @@ class SystemMetricsValueStream(ValueStream):
|
|||||||
self._sensor_label = sensor_label
|
self._sensor_label = sensor_label
|
||||||
self._poll_interval = max(0.1, poll_interval)
|
self._poll_interval = max(0.1, poll_interval)
|
||||||
self._smoothing = smoothing
|
self._smoothing = smoothing
|
||||||
|
self._normalize_enabled = normalize
|
||||||
self._prev_value: float | None = None
|
self._prev_value: float | None = None
|
||||||
self._raw_value: float | None = None
|
self._raw_value: float | None = None
|
||||||
self._last_poll: float = 0.0
|
self._last_poll: float = 0.0
|
||||||
@@ -1548,10 +1743,16 @@ class SystemMetricsValueStream(ValueStream):
|
|||||||
raw = self._read_metric()
|
raw = self._read_metric()
|
||||||
self._raw_value = raw
|
self._raw_value = raw
|
||||||
|
|
||||||
# Normalize
|
if self._normalize_enabled:
|
||||||
normalized = self._normalize(raw)
|
normalized = self._normalize(raw)
|
||||||
|
else:
|
||||||
|
# Skip the rescale: treat the raw reading as already a 0–1 fraction
|
||||||
|
# (finite-safe clamp). The un-clamped reading stays on
|
||||||
|
# get_raw_value(); get_value() never leaves [0, 1].
|
||||||
|
normalized = clamp01(raw)
|
||||||
|
|
||||||
# EMA smoothing
|
# EMA smoothing — both branches output [0, 1], so _prev_value is always
|
||||||
|
# normalized and a live ``normalize`` flip never blends raw vs fraction.
|
||||||
if self._smoothing > 0.0 and self._prev_value is not None:
|
if self._smoothing > 0.0 and self._prev_value is not None:
|
||||||
normalized = self._smoothing * self._prev_value + (1.0 - self._smoothing) * normalized
|
normalized = self._smoothing * self._prev_value + (1.0 - self._smoothing) * normalized
|
||||||
|
|
||||||
@@ -1599,6 +1800,7 @@ class SystemMetricsValueStream(ValueStream):
|
|||||||
self._sensor_label = source.sensor_label
|
self._sensor_label = source.sensor_label
|
||||||
self._poll_interval = max(0.1, source.poll_interval)
|
self._poll_interval = max(0.1, source.poll_interval)
|
||||||
self._smoothing = source.smoothing
|
self._smoothing = source.smoothing
|
||||||
|
self._normalize_enabled = source.normalize
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1644,6 +1846,10 @@ class ValueStreamManager:
|
|||||||
self._http_endpoint_store = http_endpoint_store
|
self._http_endpoint_store = http_endpoint_store
|
||||||
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
|
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
|
||||||
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
|
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
|
||||||
|
# Recursion-depth backstop for referencing sources (template / gradient
|
||||||
|
# map). A cycle that slipped past storage validation (e.g. a hand-edited
|
||||||
|
# DB or restored backup) would otherwise overflow the stack at acquire().
|
||||||
|
self._acquire_depth = 0
|
||||||
# Tracks which clock_id (if any) was acquired for each stream so we
|
# Tracks which clock_id (if any) was acquired for each stream so we
|
||||||
# can release/swap it without re-querying the store at teardown time.
|
# can release/swap it without re-querying the store at teardown time.
|
||||||
self._stream_clock_ids: Dict[str, str] = {} # vs_id → clock_id
|
self._stream_clock_ids: Dict[str, str] = {} # vs_id → clock_id
|
||||||
@@ -1659,9 +1865,29 @@ class ValueStreamManager:
|
|||||||
logger.info(f"Shared value stream {vs_id} (refs={self._ref_counts[vs_id]})")
|
logger.info(f"Shared value stream {vs_id} (refs={self._ref_counts[vs_id]})")
|
||||||
return self._streams[vs_id]
|
return self._streams[vs_id]
|
||||||
|
|
||||||
|
if self._acquire_depth >= _MAX_ACQUIRE_DEPTH:
|
||||||
|
logger.warning(
|
||||||
|
"Value source acquire depth limit (%d) reached at %s; returning "
|
||||||
|
"static fallback (possible reference cycle)",
|
||||||
|
_MAX_ACQUIRE_DEPTH,
|
||||||
|
vs_id,
|
||||||
|
)
|
||||||
|
# The intermediate referencing streams built while descending a
|
||||||
|
# cyclic chain are not stop()'d here — but this only triggers on a
|
||||||
|
# stored cycle that storage validation already rejects (e.g. a
|
||||||
|
# hand-edited DB / corrupt restore), so those transient objects are
|
||||||
|
# simply garbage-collected. Normal graphs never reach this depth.
|
||||||
|
return StaticValueStream(0.5)
|
||||||
|
|
||||||
source = self._value_source_store.get_source(vs_id)
|
source = self._value_source_store.get_source(vs_id)
|
||||||
stream = self._create_stream(source, vs_id)
|
# Increment around create+start: a referencing stream (template /
|
||||||
stream.start()
|
# gradient_map) re-enters acquire() from its own start().
|
||||||
|
self._acquire_depth += 1
|
||||||
|
try:
|
||||||
|
stream = self._create_stream(source, vs_id)
|
||||||
|
stream.start()
|
||||||
|
finally:
|
||||||
|
self._acquire_depth -= 1
|
||||||
self._streams[vs_id] = stream
|
self._streams[vs_id] = stream
|
||||||
self._ref_counts[vs_id] = 1
|
self._ref_counts[vs_id] = 1
|
||||||
logger.info(f"Acquired value stream {vs_id} (type={source.source_type})")
|
logger.info(f"Acquired value stream {vs_id} (type={source.source_type})")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from ledgrab.core.devices.led_client import (
|
|||||||
get_device_capabilities,
|
get_device_capabilities,
|
||||||
)
|
)
|
||||||
from ledgrab.core.capture.screen_capture import get_available_displays
|
from ledgrab.core.capture.screen_capture import get_available_displays
|
||||||
|
from ledgrab.core.processing.power_limit import DEFAULT_MILLIAMPS_PER_LED, power_limit_scale
|
||||||
from ledgrab.core.processing.target_processor import (
|
from ledgrab.core.processing.target_processor import (
|
||||||
ProcessingMetrics,
|
ProcessingMetrics,
|
||||||
TargetContext,
|
TargetContext,
|
||||||
@@ -62,6 +63,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
min_brightness_threshold: int = 0,
|
min_brightness_threshold: int = 0,
|
||||||
adaptive_fps: bool = False,
|
adaptive_fps: bool = False,
|
||||||
protocol: str = "ddp",
|
protocol: str = "ddp",
|
||||||
|
max_milliamps: int = 0,
|
||||||
|
milliamps_per_led: int = 55,
|
||||||
ctx: TargetContext = None,
|
ctx: TargetContext = None,
|
||||||
):
|
):
|
||||||
from ledgrab.storage.bindable import BindableFloat, bfloat
|
from ledgrab.storage.bindable import BindableFloat, bfloat
|
||||||
@@ -81,6 +84,13 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
|
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
|
||||||
self._adaptive_fps = adaptive_fps
|
self._adaptive_fps = adaptive_fps
|
||||||
self._protocol = protocol
|
self._protocol = protocol
|
||||||
|
# Automatic brightness limiting (ABL). 0 mA budget = disabled.
|
||||||
|
self._max_milliamps = max(0, int(max_milliamps or 0))
|
||||||
|
self._milliamps_per_led = max(1, int(milliamps_per_led or DEFAULT_MILLIAMPS_PER_LED))
|
||||||
|
# Reusable scratch for in-place power scaling (allocated on first use).
|
||||||
|
self._power_u16: np.ndarray | None = None
|
||||||
|
self._power_out: np.ndarray | None = None
|
||||||
|
self._power_n = 0
|
||||||
|
|
||||||
# Adaptive FPS / liveness probe runtime state
|
# Adaptive FPS / liveness probe runtime state
|
||||||
self._effective_fps: int = self._target_fps
|
self._effective_fps: int = self._target_fps
|
||||||
@@ -146,9 +156,15 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
from ledgrab.core.devices.device_config import WLEDConfig as _WLEDConfig
|
from ledgrab.core.devices.device_config import WLEDConfig as _WLEDConfig
|
||||||
|
|
||||||
config = _dev.to_config()
|
config = _dev.to_config()
|
||||||
# use_ddp is a target-derived protocol setting — override on WLEDConfig
|
# The target's protocol selects how we drive a WLED device:
|
||||||
|
# "ddp" -> DDP UDP (4048) "udp" -> WLED native realtime UDP (21324)
|
||||||
|
# "http" -> JSON API (use_ddp and use_realtime are exclusive)
|
||||||
if isinstance(config, _WLEDConfig):
|
if isinstance(config, _WLEDConfig):
|
||||||
config = _replace(config, use_ddp=(self._protocol == "ddp"))
|
config = _replace(
|
||||||
|
config,
|
||||||
|
use_ddp=(self._protocol == "ddp"),
|
||||||
|
use_realtime=(self._protocol == "udp"),
|
||||||
|
)
|
||||||
self._device_config = config
|
self._device_config = config
|
||||||
|
|
||||||
# Connect to LED device
|
# Connect to LED device
|
||||||
@@ -313,6 +329,12 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._adaptive_fps = settings["adaptive_fps"]
|
self._adaptive_fps = settings["adaptive_fps"]
|
||||||
if not self._adaptive_fps:
|
if not self._adaptive_fps:
|
||||||
self._effective_fps = self._target_fps
|
self._effective_fps = self._target_fps
|
||||||
|
if "max_milliamps" in settings:
|
||||||
|
self._max_milliamps = max(0, int(settings["max_milliamps"] or 0))
|
||||||
|
if "milliamps_per_led" in settings:
|
||||||
|
self._milliamps_per_led = max(
|
||||||
|
1, int(settings["milliamps_per_led"] or DEFAULT_MILLIAMPS_PER_LED)
|
||||||
|
)
|
||||||
logger.info(f"Updated settings for target {self._target_id}")
|
logger.info(f"Updated settings for target {self._target_id}")
|
||||||
|
|
||||||
def update_device(self, device_id: str) -> None:
|
def update_device(self, device_id: str) -> None:
|
||||||
@@ -787,8 +809,33 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
np.copyto(out, blend, casting="unsafe") # float32 → uint8
|
np.copyto(out, blend, casting="unsafe") # float32 → uint8
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def _apply_power_limit(self, colors: np.ndarray) -> np.ndarray:
|
||||||
|
"""Scale ``colors`` down to stay within the PSU current budget (ABL).
|
||||||
|
|
||||||
|
Returns ``colors`` unchanged when limiting is disabled or the frame is
|
||||||
|
already within budget; otherwise returns a scaled copy in a reusable
|
||||||
|
scratch buffer (the input is never mutated — it may be a shared frame).
|
||||||
|
"""
|
||||||
|
if self._max_milliamps <= 0:
|
||||||
|
return colors
|
||||||
|
scale = power_limit_scale(colors, self._max_milliamps, self._milliamps_per_led)
|
||||||
|
if scale >= 1.0:
|
||||||
|
return colors
|
||||||
|
factor = int(scale * 256) # 0..255 fixed-point multiplier
|
||||||
|
n = len(colors)
|
||||||
|
if self._power_u16 is None or self._power_n != n:
|
||||||
|
self._power_n = n
|
||||||
|
self._power_u16 = np.empty((n, 3), dtype=np.uint16)
|
||||||
|
self._power_out = np.empty((n, 3), dtype=np.uint8)
|
||||||
|
np.copyto(self._power_u16, colors, casting="unsafe")
|
||||||
|
self._power_u16 *= factor
|
||||||
|
self._power_u16 >>= 8
|
||||||
|
np.copyto(self._power_out, self._power_u16, casting="unsafe")
|
||||||
|
return self._power_out
|
||||||
|
|
||||||
async def _send_to_device(self, send_colors: np.ndarray) -> float:
|
async def _send_to_device(self, send_colors: np.ndarray) -> float:
|
||||||
"""Send colors to LED device and return send time in ms."""
|
"""Send colors to LED device and return send time in ms."""
|
||||||
|
send_colors = self._apply_power_limit(send_colors)
|
||||||
t_start = time.perf_counter()
|
t_start = time.perf_counter()
|
||||||
if self._led_client.supports_fast_send:
|
if self._led_client.supports_fast_send:
|
||||||
self._led_client.send_pixels_fast(send_colors)
|
self._led_client.send_pixels_fast(send_colors)
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Playlist engine — background loop that auto-cycles a scene playlist.
|
||||||
|
|
||||||
|
A playlist is an ordered, timed sequence of scene presets. The engine drives
|
||||||
|
**at most one** playlist at a time: starting a new playlist transparently stops
|
||||||
|
any currently-running one. Each cycle re-reads the playlist from the store, so
|
||||||
|
edits (and deletion) take effect at the next cycle boundary without a restart.
|
||||||
|
|
||||||
|
The actual state application reuses ``scene_activator.apply_scene_state`` — the
|
||||||
|
same code path the scene-presets API and the automation engine use — so a
|
||||||
|
playlist step behaves exactly like manually activating that preset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ledgrab.storage.scene_playlist import ScenePlaylist, clamp_duration
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlaylistRuntimeState:
|
||||||
|
"""Volatile runtime state of the (single) active playlist. Not persisted."""
|
||||||
|
|
||||||
|
playlist_id: str
|
||||||
|
playlist_name: str
|
||||||
|
current_index: int
|
||||||
|
item_count: int
|
||||||
|
current_preset_id: str | None
|
||||||
|
started_at: datetime
|
||||||
|
step_started_at: datetime
|
||||||
|
step_duration: float
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"is_running": True,
|
||||||
|
"playlist_id": self.playlist_id,
|
||||||
|
"playlist_name": self.playlist_name,
|
||||||
|
"current_index": self.current_index,
|
||||||
|
"item_count": self.item_count,
|
||||||
|
"current_preset_id": self.current_preset_id,
|
||||||
|
"started_at": self.started_at.isoformat(),
|
||||||
|
"step_started_at": self.step_started_at.isoformat(),
|
||||||
|
"step_duration": self.step_duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_IDLE_STATE = {
|
||||||
|
"is_running": False,
|
||||||
|
"playlist_id": None,
|
||||||
|
"playlist_name": None,
|
||||||
|
"current_index": 0,
|
||||||
|
"item_count": 0,
|
||||||
|
"current_preset_id": None,
|
||||||
|
"started_at": None,
|
||||||
|
"step_started_at": None,
|
||||||
|
"step_duration": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistError(Exception):
|
||||||
|
"""Raised when a playlist cannot be started (empty / not found)."""
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistEngine:
|
||||||
|
"""Cycles a scene playlist's presets on a timer, one playlist at a time."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
playlist_store,
|
||||||
|
scene_preset_store,
|
||||||
|
target_store,
|
||||||
|
processor_manager,
|
||||||
|
):
|
||||||
|
self._playlist_store = playlist_store
|
||||||
|
self._scene_preset_store = scene_preset_store
|
||||||
|
self._target_store = target_store
|
||||||
|
self._manager = processor_manager
|
||||||
|
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._state: PlaylistRuntimeState | None = None
|
||||||
|
# Serialises start/stop so overlapping API calls can't leave two
|
||||||
|
# cycling tasks alive at once.
|
||||||
|
self._lifecycle_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ===== Public control API =====
|
||||||
|
|
||||||
|
async def start_playlist(self, playlist_id: str) -> PlaylistRuntimeState:
|
||||||
|
"""Start cycling ``playlist_id``, stopping any current playlist first.
|
||||||
|
|
||||||
|
Raises ``PlaylistError`` if the playlist is unknown or has no items.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
playlist = self._playlist_store.get_playlist(playlist_id)
|
||||||
|
except Exception as exc: # EntityNotFoundError / ValueError
|
||||||
|
raise PlaylistError(f"Playlist not found: {playlist_id}") from exc
|
||||||
|
|
||||||
|
if not playlist.items:
|
||||||
|
raise PlaylistError(f"Playlist '{playlist.name}' has no items")
|
||||||
|
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
await self._cancel_task()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
first_item = playlist.items[0]
|
||||||
|
self._state = PlaylistRuntimeState(
|
||||||
|
playlist_id=playlist.id,
|
||||||
|
playlist_name=playlist.name,
|
||||||
|
current_index=0,
|
||||||
|
item_count=len(playlist.items),
|
||||||
|
current_preset_id=first_item.scene_preset_id,
|
||||||
|
started_at=now,
|
||||||
|
step_started_at=now,
|
||||||
|
step_duration=clamp_duration(first_item.duration_seconds),
|
||||||
|
)
|
||||||
|
self._task = asyncio.create_task(self._run(playlist.id))
|
||||||
|
|
||||||
|
self._fire_event("started")
|
||||||
|
logger.info("Playlist '%s' started (%d items)", playlist.name, len(playlist.items))
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the active playlist (if any). Leaves the last scene applied."""
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
was_running = self._task is not None
|
||||||
|
await self._cancel_task()
|
||||||
|
stopped_id = self._state.playlist_id if self._state else None
|
||||||
|
self._state = None
|
||||||
|
if was_running:
|
||||||
|
self._fire_event("stopped", playlist_id=stopped_id)
|
||||||
|
logger.info("Playlist stopped")
|
||||||
|
|
||||||
|
async def stop_if_running(self, playlist_id: str) -> None:
|
||||||
|
"""Stop the playlist only if ``playlist_id`` is the one running.
|
||||||
|
|
||||||
|
Used when a playlist is deleted or edited so a stale snapshot can't keep
|
||||||
|
cycling.
|
||||||
|
"""
|
||||||
|
if self._state is not None and self._state.playlist_id == playlist_id:
|
||||||
|
await self.stop()
|
||||||
|
|
||||||
|
# ===== Query API (used by routes) =====
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._task is not None and not self._task.done()
|
||||||
|
|
||||||
|
def get_running_playlist_id(self) -> str | None:
|
||||||
|
return self._state.playlist_id if self._state else None
|
||||||
|
|
||||||
|
def get_state(self) -> dict:
|
||||||
|
if self._state is not None and self.is_running():
|
||||||
|
return self._state.to_dict()
|
||||||
|
return dict(_IDLE_STATE)
|
||||||
|
|
||||||
|
# ===== Internal =====
|
||||||
|
|
||||||
|
async def _cancel_task(self) -> None:
|
||||||
|
"""Cancel and await the cycling task. Caller holds the lifecycle lock."""
|
||||||
|
task = self._task
|
||||||
|
self._task = None
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error("Playlist task raised on cancel: %s", exc, exc_info=True)
|
||||||
|
|
||||||
|
async def _run(self, playlist_id: str) -> None:
|
||||||
|
"""Cycle the playlist until cancelled, the playlist ends, or it errors."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Re-read each cycle so edits/deletes apply at the boundary.
|
||||||
|
try:
|
||||||
|
playlist = self._playlist_store.get_playlist(playlist_id)
|
||||||
|
except Exception:
|
||||||
|
logger.info("Playlist %s removed while running; stopping", playlist_id)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not playlist.items:
|
||||||
|
logger.info("Playlist '%s' has no items; stopping", playlist.name)
|
||||||
|
break
|
||||||
|
|
||||||
|
applied_any = await self._run_cycle(playlist)
|
||||||
|
|
||||||
|
if not playlist.loop:
|
||||||
|
break
|
||||||
|
if not applied_any:
|
||||||
|
# Every item referenced a missing preset — a looping
|
||||||
|
# playlist would otherwise spin with no dwell. Bail out.
|
||||||
|
logger.warning(
|
||||||
|
"Playlist '%s' applied no valid presets this cycle; stopping",
|
||||||
|
playlist.name,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Natural end (non-loop or guard). Clear state without recursing
|
||||||
|
# through stop() (which would try to cancel this very task). Guard
|
||||||
|
# against a concurrent start_playlist having already replaced us:
|
||||||
|
# only clear if we are still the engine's current task.
|
||||||
|
if self._task is asyncio.current_task():
|
||||||
|
self._task = None
|
||||||
|
ended_id = self._state.playlist_id if self._state else None
|
||||||
|
self._state = None
|
||||||
|
self._fire_event("stopped", playlist_id=ended_id)
|
||||||
|
logger.info("Playlist '%s' finished", playlist_id)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error("Playlist run loop error: %s", exc, exc_info=True)
|
||||||
|
|
||||||
|
async def _run_cycle(self, playlist: ScenePlaylist) -> bool:
|
||||||
|
"""Run one pass over the playlist's items. Returns True if any applied."""
|
||||||
|
order = self._resolve_order(playlist)
|
||||||
|
applied_any = False
|
||||||
|
|
||||||
|
for index, item in enumerate(order):
|
||||||
|
duration = clamp_duration(item.duration_seconds)
|
||||||
|
if self._state is not None:
|
||||||
|
self._state.current_index = index
|
||||||
|
self._state.current_preset_id = item.scene_preset_id
|
||||||
|
self._state.step_started_at = datetime.now(timezone.utc)
|
||||||
|
self._state.step_duration = duration
|
||||||
|
|
||||||
|
applied = await self._apply_item(item.scene_preset_id)
|
||||||
|
if applied:
|
||||||
|
applied_any = True
|
||||||
|
self._fire_event("advanced", index=index, preset_id=item.scene_preset_id)
|
||||||
|
# Only dwell on scenes we actually applied; skip missing ones
|
||||||
|
# immediately so the cycle doesn't stall on a dead reference.
|
||||||
|
await asyncio.sleep(duration)
|
||||||
|
|
||||||
|
return applied_any
|
||||||
|
|
||||||
|
def _resolve_order(self, playlist: ScenePlaylist) -> List:
|
||||||
|
if playlist.shuffle and len(playlist.items) > 1:
|
||||||
|
shuffled = list(playlist.items)
|
||||||
|
random.shuffle(shuffled) # noqa: S311 - cosmetic ordering, not security
|
||||||
|
return shuffled
|
||||||
|
return list(playlist.items)
|
||||||
|
|
||||||
|
async def _apply_item(self, preset_id: str) -> bool:
|
||||||
|
"""Apply one scene preset. Returns False if it could not be applied."""
|
||||||
|
if not self._scene_preset_store or not self._target_store or not self._manager:
|
||||||
|
logger.warning("Playlist engine missing stores; cannot apply %s", preset_id)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
preset = self._scene_preset_store.get_preset(preset_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Playlist references missing scene preset %s (skipped)", preset_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
from ledgrab.core.scenes.scene_activator import apply_scene_state
|
||||||
|
|
||||||
|
_status, errors = await apply_scene_state(preset, self._target_store, self._manager)
|
||||||
|
if errors:
|
||||||
|
logger.warning("Playlist step '%s' applied with errors: %s", preset.name, errors)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _fire_event(self, action: str, **extra) -> None:
|
||||||
|
if self._manager is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._manager.fire_event(
|
||||||
|
{
|
||||||
|
"type": "playlist_state_changed",
|
||||||
|
"action": action,
|
||||||
|
"playlist_id": extra.get("playlist_id")
|
||||||
|
or (self._state.playlist_id if self._state else None),
|
||||||
|
**{k: v for k, v in extra.items() if k != "playlist_id"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Playlist event fire failed: %s", exc, exc_info=True)
|
||||||
@@ -93,7 +93,7 @@ async def apply_scene_state(
|
|||||||
proc = processor_manager.get_processor(ts.target_id)
|
proc = processor_manager.get_processor(ts.target_id)
|
||||||
if proc and proc.is_running:
|
if proc and proc.is_running:
|
||||||
css_changed = "color_strip_source_id" in changed
|
css_changed = "color_strip_source_id" in changed
|
||||||
brightness_changed = "brightness" in changed
|
brightness_changed = "brightness_value_source_id" in changed
|
||||||
settings_changed = "fps" in changed
|
settings_changed = "fps" in changed
|
||||||
if css_changed:
|
if css_changed:
|
||||||
target.sync_with_manager(
|
target.sync_with_manager(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import time
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from ledgrab.core.processing.value_stream import ValueStream
|
from ledgrab.core.processing.value_stream import ValueStream
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import clamp01, get_logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||||
@@ -41,6 +41,7 @@ class GameEventValueStream(ValueStream):
|
|||||||
smoothing: float = 0.0,
|
smoothing: float = 0.0,
|
||||||
default_value: float = 0.5,
|
default_value: float = 0.5,
|
||||||
timeout: float = 5.0,
|
timeout: float = 5.0,
|
||||||
|
normalize: bool = True,
|
||||||
event_bus: "GameEventBus" | None = None,
|
event_bus: "GameEventBus" | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._event_type = event_type
|
self._event_type = event_type
|
||||||
@@ -49,10 +50,15 @@ class GameEventValueStream(ValueStream):
|
|||||||
self._smoothing = max(0.0, min(1.0, smoothing))
|
self._smoothing = max(0.0, min(1.0, smoothing))
|
||||||
self._default_value = max(0.0, min(1.0, default_value))
|
self._default_value = max(0.0, min(1.0, default_value))
|
||||||
self._timeout = max(0.0, timeout)
|
self._timeout = max(0.0, timeout)
|
||||||
|
# When False, skip the min/max rescale: the [0,1] output clamps the raw
|
||||||
|
# value as-is. get_value() stays in [0,1] either way; the un-clamped
|
||||||
|
# value is exposed via get_raw_value() for templates/automations.
|
||||||
|
self._normalize_enabled = normalize
|
||||||
self._event_bus = event_bus
|
self._event_bus = event_bus
|
||||||
|
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._current_value: float = self._default_value
|
self._current_value: float = self._default_value
|
||||||
|
self._current_raw: float | None = None
|
||||||
self._last_event_time: float | None = None
|
self._last_event_time: float | None = None
|
||||||
self._subscription_id: str | None = None
|
self._subscription_id: str | None = None
|
||||||
self._has_received_event: bool = False
|
self._has_received_event: bool = False
|
||||||
@@ -82,11 +88,18 @@ class GameEventValueStream(ValueStream):
|
|||||||
self._subscription_id = None
|
self._subscription_id = None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._current_value = self._default_value
|
self._current_value = self._default_value
|
||||||
|
self._current_raw = None
|
||||||
self._last_event_time = None
|
self._last_event_time = None
|
||||||
self._has_received_event = False
|
self._has_received_event = False
|
||||||
|
|
||||||
def get_value(self) -> float:
|
def get_value(self) -> float:
|
||||||
"""Return current normalized value (0.0-1.0), or default if timed out."""
|
"""Return current value in [0,1], or default_value if timed out.
|
||||||
|
|
||||||
|
Always in [0,1]: ``_current_value`` holds the smoothed output computed
|
||||||
|
at event time under the active ``normalize`` mode (rescaled, or the raw
|
||||||
|
value clamped into [0,1]). ``default_value`` is itself in [0,1], so the
|
||||||
|
timeout fallback is valid in both modes.
|
||||||
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not self._has_received_event:
|
if not self._has_received_event:
|
||||||
return self._default_value
|
return self._default_value
|
||||||
@@ -98,6 +111,15 @@ class GameEventValueStream(ValueStream):
|
|||||||
|
|
||||||
return self._current_value
|
return self._current_value
|
||||||
|
|
||||||
|
def get_raw_value(self) -> float | None:
|
||||||
|
"""Return the last raw game value before normalization.
|
||||||
|
|
||||||
|
``None`` until the first event arrives (mirrors HA/HTTP/SystemMetrics).
|
||||||
|
Exposes the un-clamped magnitude to template ``raw[]`` and automations.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
return self._current_raw
|
||||||
|
|
||||||
def get_color(self) -> tuple:
|
def get_color(self) -> tuple:
|
||||||
"""Game event value source only provides scalars, not colors."""
|
"""Game event value source only provides scalars, not colors."""
|
||||||
raise NotImplementedError("GameEventValueStream does not produce colors")
|
raise NotImplementedError("GameEventValueStream does not produce colors")
|
||||||
@@ -115,6 +137,7 @@ class GameEventValueStream(ValueStream):
|
|||||||
self._smoothing = max(0.0, min(1.0, source.smoothing))
|
self._smoothing = max(0.0, min(1.0, source.smoothing))
|
||||||
self._default_value = max(0.0, min(1.0, source.default_value))
|
self._default_value = max(0.0, min(1.0, source.default_value))
|
||||||
self._timeout = max(0.0, source.timeout)
|
self._timeout = max(0.0, source.timeout)
|
||||||
|
self._normalize_enabled = source.normalize
|
||||||
|
|
||||||
def _on_event(self, event: "GameEvent") -> None:
|
def _on_event(self, event: "GameEvent") -> None:
|
||||||
"""EventBus callback — normalize and apply smoothing.
|
"""EventBus callback — normalize and apply smoothing.
|
||||||
@@ -122,14 +145,17 @@ class GameEventValueStream(ValueStream):
|
|||||||
Called from the publisher's thread; must be thread-safe.
|
Called from the publisher's thread; must be thread-safe.
|
||||||
"""
|
"""
|
||||||
raw_value = event.value
|
raw_value = event.value
|
||||||
normalized = self._normalize(raw_value)
|
# Output is always in [0,1]: rescale via min/max, or (normalize off)
|
||||||
|
# clamp the raw value as-is. The un-clamped raw is kept for get_raw_value().
|
||||||
|
out = self._normalize(raw_value) if self._normalize_enabled else clamp01(raw_value)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._current_raw = raw_value
|
||||||
if self._smoothing > 0.0 and self._has_received_event:
|
if self._smoothing > 0.0 and self._has_received_event:
|
||||||
alpha = 1.0 - self._smoothing
|
alpha = 1.0 - self._smoothing
|
||||||
normalized = alpha * normalized + self._smoothing * self._current_value
|
out = alpha * out + self._smoothing * self._current_value
|
||||||
|
|
||||||
self._current_value = normalized
|
self._current_value = out
|
||||||
self._last_event_time = time.monotonic()
|
self._last_event_time = time.monotonic()
|
||||||
self._has_received_event = True
|
self._has_received_event = True
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import ledgrab.core.audio # noqa: F401 — trigger engine auto-registration
|
|||||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||||
from ledgrab.storage.automation_store import AutomationStore
|
from ledgrab.storage.automation_store import AutomationStore
|
||||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||||
|
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||||
from ledgrab.storage.sync_clock_store import SyncClockStore
|
from ledgrab.storage.sync_clock_store import SyncClockStore
|
||||||
from ledgrab.storage.color_strip_processing_template_store import (
|
from ledgrab.storage.color_strip_processing_template_store import (
|
||||||
ColorStripProcessingTemplateStore,
|
ColorStripProcessingTemplateStore,
|
||||||
@@ -47,6 +48,7 @@ from ledgrab.core.weather.weather_manager import WeatherManager
|
|||||||
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
||||||
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
||||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||||
|
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
|
||||||
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||||
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
|
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
|
||||||
@@ -157,6 +159,7 @@ audio_template_store = AudioTemplateStore(db)
|
|||||||
value_source_store = ValueSourceStore(db)
|
value_source_store = ValueSourceStore(db)
|
||||||
automation_store = AutomationStore(db)
|
automation_store = AutomationStore(db)
|
||||||
scene_preset_store = ScenePresetStore(db)
|
scene_preset_store = ScenePresetStore(db)
|
||||||
|
scene_playlist_store = ScenePlaylistStore(db)
|
||||||
sync_clock_store = SyncClockStore(db)
|
sync_clock_store = SyncClockStore(db)
|
||||||
cspt_store = ColorStripProcessingTemplateStore(db)
|
cspt_store = ColorStripProcessingTemplateStore(db)
|
||||||
gradient_store = GradientStore(db)
|
gradient_store = GradientStore(db)
|
||||||
@@ -278,6 +281,15 @@ async def lifespan(app: FastAPI):
|
|||||||
value_source_store=value_source_store,
|
value_source_store=value_source_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create playlist engine — auto-cycles scene presets, one playlist at a
|
||||||
|
# time. Idle (no background task) until a playlist is started via the API.
|
||||||
|
playlist_engine = PlaylistEngine(
|
||||||
|
playlist_store=scene_playlist_store,
|
||||||
|
scene_preset_store=scene_preset_store,
|
||||||
|
target_store=output_target_store,
|
||||||
|
processor_manager=processor_manager,
|
||||||
|
)
|
||||||
|
|
||||||
# Create auto-backup engine — derive paths from database location so that
|
# Create auto-backup engine — derive paths from database location so that
|
||||||
# demo mode auto-backups go to data/demo/ instead of data/.
|
# demo mode auto-backups go to data/demo/ instead of data/.
|
||||||
_data_dir = Path(config.storage.database_file).parent
|
_data_dir = Path(config.storage.database_file).parent
|
||||||
@@ -314,7 +326,9 @@ async def lifespan(app: FastAPI):
|
|||||||
value_source_store=value_source_store,
|
value_source_store=value_source_store,
|
||||||
automation_store=automation_store,
|
automation_store=automation_store,
|
||||||
scene_preset_store=scene_preset_store,
|
scene_preset_store=scene_preset_store,
|
||||||
|
scene_playlist_store=scene_playlist_store,
|
||||||
automation_engine=automation_engine,
|
automation_engine=automation_engine,
|
||||||
|
playlist_engine=playlist_engine,
|
||||||
auto_backup_engine=auto_backup_engine,
|
auto_backup_engine=auto_backup_engine,
|
||||||
sync_clock_store=sync_clock_store,
|
sync_clock_store=sync_clock_store,
|
||||||
sync_clock_manager=sync_clock_manager,
|
sync_clock_manager=sync_clock_manager,
|
||||||
@@ -436,6 +450,9 @@ async def lifespan(app: FastAPI):
|
|||||||
# would talk to processors mid-shutdown.
|
# would talk to processors mid-shutdown.
|
||||||
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5)
|
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5)
|
||||||
|
|
||||||
|
# Stop the playlist engine so its cycling task can't apply scenes mid-shutdown.
|
||||||
|
await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0)
|
||||||
|
|
||||||
# Stop discovery watcher and OS notification listener so they stop
|
# Stop discovery watcher and OS notification listener so they stop
|
||||||
# firing events into a shutting-down processor manager.
|
# firing events into a shutting-down processor manager.
|
||||||
if discovery_watcher is not None:
|
if discovery_watcher is not None:
|
||||||
|
|||||||
@@ -74,6 +74,19 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Android-only: shown in the application rule when Usage Access is missing,
|
||||||
|
so the foreground-app rule can't fire until the user grants it on the TV. */
|
||||||
|
.rule-usage-warning {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--warning-color, #ff9800);
|
||||||
|
background: color-mix(in srgb, var(--warning-color, #ff9800) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--warning-color, #ff9800) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-remove-rule {
|
.btn-remove-rule {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -139,6 +152,50 @@
|
|||||||
border-left: 1px solid var(--border-color);
|
border-left: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Weekday + timezone scheduling (time_of_day rule) */
|
||||||
|
.rule-weekday-block,
|
||||||
|
.rule-tz-block {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.rule-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.weekday-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.weekday-chip {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.weekday-chip:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.weekday-chip.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.rule-tz-block input.rule-timezone {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.time-range-label {
|
.time-range-label {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,16 @@ import {
|
|||||||
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
|
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
|
||||||
startIntegrationsTutorial,
|
startIntegrationsTutorial,
|
||||||
closeTutorial, tutorialNext, tutorialPrev,
|
closeTutorial, tutorialNext, tutorialPrev,
|
||||||
|
TOUR_KEY,
|
||||||
} from './features/tutorials.ts';
|
} from './features/tutorials.ts';
|
||||||
|
import {
|
||||||
|
openSetupWizard, closeSetupWizard,
|
||||||
|
checkAndOpenWizardIfNeeded,
|
||||||
|
wizardNext, wizardBack, wizardSkip, wizardFinish,
|
||||||
|
wizardShowManual, wizardHideManual, wizardRescan,
|
||||||
|
wizardSelectDiscovered, wizardAddManualDevice, wizardUseExistingDevice,
|
||||||
|
wizardSelectDisplay,
|
||||||
|
} from './features/setup-wizard.ts';
|
||||||
|
|
||||||
// Layer 4: devices, dashboard, streams, pattern-templates, automations
|
// Layer 4: devices, dashboard, streams, pattern-templates, automations
|
||||||
import {
|
import {
|
||||||
@@ -116,6 +125,11 @@ import {
|
|||||||
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
|
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
|
||||||
addSceneTarget,
|
addSceneTarget,
|
||||||
} from './features/scene-presets.ts';
|
} from './features/scene-presets.ts';
|
||||||
|
import {
|
||||||
|
openPlaylistEditor, editPlaylist, savePlaylist, closePlaylistEditor,
|
||||||
|
clonePlaylist, deletePlaylist, addPlaylistItem,
|
||||||
|
startScenePlaylist, stopScenePlaylist,
|
||||||
|
} from './features/scene-playlists.ts';
|
||||||
|
|
||||||
// Layer 5: device-discovery, targets
|
// Layer 5: device-discovery, targets
|
||||||
import {
|
import {
|
||||||
@@ -184,9 +198,11 @@ import {
|
|||||||
showValueSourceModal, closeValueSourceModal, saveValueSource,
|
showValueSourceModal, closeValueSourceModal, saveValueSource,
|
||||||
editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange,
|
editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange,
|
||||||
onDaylightVSRealTimeChange,
|
onDaylightVSRealTimeChange,
|
||||||
|
onValueSourceNormalizeChange,
|
||||||
addSchedulePoint,
|
addSchedulePoint,
|
||||||
addAnimatedColor, removeAnimatedColor,
|
addAnimatedColor, removeAnimatedColor,
|
||||||
addColorSchedulePoint, removeColorSchedulePoint,
|
addColorSchedulePoint, removeColorSchedulePoint,
|
||||||
|
addTemplateInput,
|
||||||
testValueSource, closeTestValueSourceModal,
|
testValueSource, closeTestValueSourceModal,
|
||||||
} from './features/value-sources.ts';
|
} from './features/value-sources.ts';
|
||||||
|
|
||||||
@@ -196,18 +212,27 @@ import {
|
|||||||
updateOffsetSkipLock, updateCalibrationPreview,
|
updateOffsetSkipLock, updateCalibrationPreview,
|
||||||
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
|
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
|
||||||
showCSSCalibration, toggleCalibrationOverlay,
|
showCSSCalibration, toggleCalibrationOverlay,
|
||||||
|
openAutoCalFromCalibration,
|
||||||
} from './features/calibration.ts';
|
} from './features/calibration.ts';
|
||||||
import {
|
import {
|
||||||
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
|
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
|
||||||
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
|
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
|
||||||
updateCalibrationLine, resetCalibrationView,
|
updateCalibrationLine, resetCalibrationView,
|
||||||
} from './features/advanced-calibration.ts';
|
} from './features/advanced-calibration.ts';
|
||||||
|
import {
|
||||||
|
showAutoCalibration, closeAutoCalModal,
|
||||||
|
autoCalSelectDevice, autoCalSetCorner, autoCalSetDirection,
|
||||||
|
autoCalBackToCorner, autoCalBackToDirection,
|
||||||
|
autoCalSweepForward, autoCalSweepBack, autoCalMarkCorner,
|
||||||
|
autoCalSolve, autoCalSave, autoCalCancel,
|
||||||
|
mountAutoCalibration, unmountAutoCalibration,
|
||||||
|
} from './features/auto-calibration.ts';
|
||||||
|
|
||||||
// Layer 5.5: graph editor
|
// Layer 5.5: graph editor
|
||||||
import {
|
import {
|
||||||
loadGraphEditor,
|
loadGraphEditor,
|
||||||
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
|
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
|
||||||
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology,
|
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology, graphDuplicateSelection,
|
||||||
graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow,
|
graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow,
|
||||||
} from './features/graph-editor.ts';
|
} from './features/graph-editor.ts';
|
||||||
|
|
||||||
@@ -313,6 +338,21 @@ Object.assign(window, {
|
|||||||
selectDisplay,
|
selectDisplay,
|
||||||
formatDisplayLabel,
|
formatDisplayLabel,
|
||||||
|
|
||||||
|
// setup wizard
|
||||||
|
openSetupWizard,
|
||||||
|
closeSetupWizard,
|
||||||
|
wizardNext,
|
||||||
|
wizardBack,
|
||||||
|
wizardSkip,
|
||||||
|
wizardFinish,
|
||||||
|
wizardShowManual,
|
||||||
|
wizardHideManual,
|
||||||
|
wizardRescan,
|
||||||
|
wizardSelectDiscovered,
|
||||||
|
wizardAddManualDevice,
|
||||||
|
wizardUseExistingDevice,
|
||||||
|
wizardSelectDisplay,
|
||||||
|
|
||||||
// tutorials
|
// tutorials
|
||||||
startCalibrationTutorial,
|
startCalibrationTutorial,
|
||||||
startDeviceTutorial,
|
startDeviceTutorial,
|
||||||
@@ -461,6 +501,17 @@ Object.assign(window, {
|
|||||||
recaptureScenePreset,
|
recaptureScenePreset,
|
||||||
addSceneTarget,
|
addSceneTarget,
|
||||||
|
|
||||||
|
// scene playlists — modal buttons + mod-card inline handlers
|
||||||
|
openPlaylistEditor,
|
||||||
|
editPlaylist,
|
||||||
|
savePlaylist,
|
||||||
|
closePlaylistEditor,
|
||||||
|
clonePlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
addPlaylistItem,
|
||||||
|
startScenePlaylist,
|
||||||
|
stopScenePlaylist,
|
||||||
|
|
||||||
// integrations
|
// integrations
|
||||||
loadIntegrations,
|
loadIntegrations,
|
||||||
switchIntegrationTab,
|
switchIntegrationTab,
|
||||||
@@ -579,11 +630,13 @@ Object.assign(window, {
|
|||||||
deleteValueSource,
|
deleteValueSource,
|
||||||
onValueSourceTypeChange,
|
onValueSourceTypeChange,
|
||||||
onDaylightVSRealTimeChange,
|
onDaylightVSRealTimeChange,
|
||||||
|
onValueSourceNormalizeChange,
|
||||||
addSchedulePoint,
|
addSchedulePoint,
|
||||||
addAnimatedColor,
|
addAnimatedColor,
|
||||||
removeAnimatedColor,
|
removeAnimatedColor,
|
||||||
addColorSchedulePoint,
|
addColorSchedulePoint,
|
||||||
removeColorSchedulePoint,
|
removeColorSchedulePoint,
|
||||||
|
addTemplateInput,
|
||||||
testValueSource,
|
testValueSource,
|
||||||
closeTestValueSourceModal,
|
closeTestValueSourceModal,
|
||||||
|
|
||||||
@@ -600,6 +653,24 @@ Object.assign(window, {
|
|||||||
toggleTestEdge,
|
toggleTestEdge,
|
||||||
showCSSCalibration,
|
showCSSCalibration,
|
||||||
toggleCalibrationOverlay,
|
toggleCalibrationOverlay,
|
||||||
|
openAutoCalFromCalibration,
|
||||||
|
|
||||||
|
// auto-calibration wizard
|
||||||
|
showAutoCalibration,
|
||||||
|
closeAutoCalModal,
|
||||||
|
autoCalSelectDevice,
|
||||||
|
autoCalSetCorner,
|
||||||
|
autoCalSetDirection,
|
||||||
|
autoCalBackToCorner,
|
||||||
|
autoCalBackToDirection,
|
||||||
|
autoCalSweepForward,
|
||||||
|
autoCalSweepBack,
|
||||||
|
autoCalMarkCorner,
|
||||||
|
autoCalSolve,
|
||||||
|
autoCalSave,
|
||||||
|
autoCalCancel,
|
||||||
|
mountAutoCalibration,
|
||||||
|
unmountAutoCalibration,
|
||||||
|
|
||||||
// advanced calibration
|
// advanced calibration
|
||||||
showAdvancedCalibration,
|
showAdvancedCalibration,
|
||||||
@@ -627,6 +698,7 @@ Object.assign(window, {
|
|||||||
graphRelayout,
|
graphRelayout,
|
||||||
graphShowIssues,
|
graphShowIssues,
|
||||||
graphExportTopology,
|
graphExportTopology,
|
||||||
|
graphDuplicateSelection,
|
||||||
graphToggleFullscreen,
|
graphToggleFullscreen,
|
||||||
graphAddEntity,
|
graphAddEntity,
|
||||||
toggleToolbarOverflow,
|
toggleToolbarOverflow,
|
||||||
@@ -903,8 +975,17 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
setProjectUrls(serverRepoUrl, serverDonateUrl);
|
setProjectUrls(serverRepoUrl, serverDonateUrl);
|
||||||
initDonationBanner();
|
initDonationBanner();
|
||||||
|
|
||||||
// Show getting-started tutorial on first visit
|
// First-run: wizard wins over the tooltip tour.
|
||||||
if (!localStorage.getItem('tour_completed')) {
|
//
|
||||||
|
// Precedence (explicit):
|
||||||
|
// 1. If backend says onboarded=false AND no output targets exist
|
||||||
|
// → open the setup wizard (suppresses tooltip tour — wizard owns
|
||||||
|
// the first-run experience; it sets localStorage TOUR_KEY on
|
||||||
|
// completion/skip so the tour never double-fires on reload).
|
||||||
|
// 2. Otherwise (already onboarded, or has targets but no wizard flag)
|
||||||
|
// → fall back to the existing tooltip tour logic unchanged.
|
||||||
|
const wizardOpened = await checkAndOpenWizardIfNeeded();
|
||||||
|
if (!wizardOpened && !localStorage.getItem(TOUR_KEY)) {
|
||||||
setTimeout(() => startGettingStartedTutorial(), 600);
|
setTimeout(() => startGettingStartedTutorial(), 600);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import {
|
import {
|
||||||
devicesCache, outputTargetsCache, colorStripSourcesCache,
|
devicesCache, outputTargetsCache, colorStripSourcesCache,
|
||||||
streamsCache, audioSourcesCache, valueSourcesCache,
|
streamsCache, audioSourcesCache, valueSourcesCache,
|
||||||
syncClocksCache, automationsCacheObj, scenePresetsCache,
|
syncClocksCache, automationsCacheObj, scenePresetsCache, scenePlaylistsCache,
|
||||||
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
|
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
|
||||||
patternTemplatesCache,
|
patternTemplatesCache,
|
||||||
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
|
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
|
||||||
@@ -26,6 +26,7 @@ const ENTITY_CACHE_MAP = {
|
|||||||
sync_clock: syncClocksCache,
|
sync_clock: syncClocksCache,
|
||||||
automation: automationsCacheObj,
|
automation: automationsCacheObj,
|
||||||
scene_preset: scenePresetsCache,
|
scene_preset: scenePresetsCache,
|
||||||
|
scene_playlist: scenePlaylistsCache,
|
||||||
capture_template: captureTemplatesCache,
|
capture_template: captureTemplatesCache,
|
||||||
audio_template: audioTemplatesCache,
|
audio_template: audioTemplatesCache,
|
||||||
pp_template: ppTemplatesCache,
|
pp_template: ppTemplatesCache,
|
||||||
@@ -51,6 +52,7 @@ const ENTITY_LOADER_MAP = {
|
|||||||
pp_template: 'loadPictureSources',
|
pp_template: 'loadPictureSources',
|
||||||
automation: 'loadAutomations',
|
automation: 'loadAutomations',
|
||||||
scene_preset: 'loadAutomations',
|
scene_preset: 'loadAutomations',
|
||||||
|
scene_playlist: 'loadAutomations',
|
||||||
weather_source: 'loadIntegrations',
|
weather_source: 'loadIntegrations',
|
||||||
home_assistant_source: 'loadIntegrations',
|
home_assistant_source: 'loadIntegrations',
|
||||||
mqtt_source: 'loadIntegrations',
|
mqtt_source: 'loadIntegrations',
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|||||||
'server_restarting',
|
'server_restarting',
|
||||||
'state_change',
|
'state_change',
|
||||||
'automation_state_changed',
|
'automation_state_changed',
|
||||||
|
'playlist_state_changed',
|
||||||
'entity_changed',
|
'entity_changed',
|
||||||
'device_health_changed',
|
'device_health_changed',
|
||||||
'update_available',
|
'update_available',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
||||||
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
||||||
} from './state.ts';
|
} from './state.ts';
|
||||||
|
import { logError } from './log.ts';
|
||||||
|
|
||||||
/** Result of the backend pre-write connection validator. */
|
/** Result of the backend pre-write connection validator. */
|
||||||
export interface ConnectionValidation {
|
export interface ConnectionValidation {
|
||||||
@@ -62,6 +63,69 @@ export async function getDependents(kind: string, id: string): Promise<GraphDepe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Schema drift guard (B4) ───────────────────────────────────── */
|
||||||
|
|
||||||
|
// Backend-declared reference fields the frontend intentionally does NOT drag-edit
|
||||||
|
// (the backend still lists them for topology/dependents completeness, so the drift
|
||||||
|
// check ignores them). Two categories:
|
||||||
|
// (a) the source kind is not a graph node — nothing to drag from.
|
||||||
|
// (b) the owning entity's PUT route is not safely partial-writable via a single
|
||||||
|
// dragged field, so it's edited through the entity editor instead.
|
||||||
|
const _DRIFT_EXCLUDE = new Set<string>([
|
||||||
|
// (a) no graph node for the source kind — nothing to drag from:
|
||||||
|
'value_source|ha_source_id',
|
||||||
|
'value_source|gradient_id',
|
||||||
|
// (b) not safely partial-PUT-able from a single dragged field:
|
||||||
|
'device|default_css_processing_template_id', // a one-field device PUT could null the URL
|
||||||
|
// (c) editable in principle but not surfaced as a graph edge yet:
|
||||||
|
'value_source|clock_id', // sync_clock → animated_colour value-source timing
|
||||||
|
]);
|
||||||
|
|
||||||
|
let _driftChecked = false;
|
||||||
|
|
||||||
|
interface SchemaConnection { target_kind: string; field: string; editable: boolean; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev safety net for the B4 finding: warn once if the frontend CONNECTION_MAP's
|
||||||
|
* editable set diverges from the backend `/graph/schema` (the drift the manual
|
||||||
|
* "10-step checklist" guards against). Read-only — never affects the graph.
|
||||||
|
* No-op against older servers without the endpoint.
|
||||||
|
*
|
||||||
|
* Note: this references `CONNECTION_MAP` (const) and `_isEditable` (fn) declared
|
||||||
|
* later in the module — safe because it is only ever invoked at runtime (from
|
||||||
|
* `loadGraphEditor`), well after module initialization.
|
||||||
|
*/
|
||||||
|
export async function checkSchemaDrift(): Promise<void> {
|
||||||
|
if (_driftChecked) return;
|
||||||
|
_driftChecked = true;
|
||||||
|
|
||||||
|
let connections: SchemaConnection[];
|
||||||
|
try {
|
||||||
|
connections = (await apiGet<{ connections: SchemaConnection[] }>('/graph/schema')).connections || [];
|
||||||
|
} catch {
|
||||||
|
return; // endpoint unavailable — nothing to compare against
|
||||||
|
}
|
||||||
|
|
||||||
|
const k = (kind: string, field: string): string => `${kind}|${field}`;
|
||||||
|
const backend = new Set<string>();
|
||||||
|
for (const c of connections) {
|
||||||
|
if (c.editable && !_DRIFT_EXCLUDE.has(k(c.target_kind, c.field))) backend.add(k(c.target_kind, c.field));
|
||||||
|
}
|
||||||
|
const frontend = new Set<string>();
|
||||||
|
for (const c of CONNECTION_MAP) {
|
||||||
|
if (_isEditable(c) && !_DRIFT_EXCLUDE.has(k(c.targetKind, c.field))) frontend.add(k(c.targetKind, c.field));
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingOnFrontend = [...backend].filter(key => !frontend.has(key));
|
||||||
|
const missingOnBackend = [...frontend].filter(key => !backend.has(key));
|
||||||
|
if (missingOnFrontend.length || missingOnBackend.length) {
|
||||||
|
logError('graph.schema_drift', new Error(
|
||||||
|
`CONNECTION_MAP drift vs /graph/schema — editable fields missing on frontend: ` +
|
||||||
|
`[${missingOnFrontend.join(', ')}]; missing on backend: [${missingOnBackend.join(', ')}]`,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Types ────────────────────────────────────────────────────── */
|
/* ── Types ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
interface ConnectionEntry {
|
interface ConnectionEntry {
|
||||||
@@ -112,17 +176,24 @@ const CONNECTION_MAP: ConnectionEntry[] = [
|
|||||||
{ targetKind: 'value_source', field: 'value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
{ targetKind: 'value_source', field: 'value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||||
{ targetKind: 'value_source', field: 'gradient_id', sourceKind: 'gradient', edgeType: 'gradient', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
{ targetKind: 'value_source', field: 'gradient_id', sourceKind: 'gradient', edgeType: 'gradient', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||||
{ targetKind: 'value_source', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
{ targetKind: 'value_source', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||||
|
// TODO: template.inputs[] drag-wiring — template value sources reference one
|
||||||
|
// inner value source per bound input (field path inputs[<name>].value_source_id).
|
||||||
|
// These render as read-only 'value' edges in graph-layout for now; a list-aware
|
||||||
|
// CONNECTION_MAP entry (with list/index/ref slot metadata) would make them
|
||||||
|
// re-wirable from the graph the way composite layers / mapped zones are.
|
||||||
|
|
||||||
// Color strip sources
|
// Color strip sources
|
||||||
{ targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
{ targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||||
{ targetKind: 'color_strip_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
{ targetKind: 'color_strip_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||||
{ targetKind: 'color_strip_source', field: 'clock_id', sourceKind: 'sync_clock', edgeType: 'clock', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
{ targetKind: 'color_strip_source', field: 'clock_id', sourceKind: 'sync_clock', edgeType: 'clock', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||||
|
// Processed strip: input source + processing template (apply_update is partial-safe)
|
||||||
|
{ targetKind: 'color_strip_source', field: 'input_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||||
|
{ targetKind: 'color_strip_source', field: 'processing_template_id', sourceKind: 'cspt', edgeType: 'template', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||||
|
|
||||||
// Output targets
|
// Output targets
|
||||||
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||||
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||||
{ targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true, bindable: true },
|
{ targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true, bindable: true },
|
||||||
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
|
||||||
|
|
||||||
// Automations
|
// Automations
|
||||||
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
||||||
@@ -206,6 +277,25 @@ export function getConnectionByField(targetKind: string, field: string): Connect
|
|||||||
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && _isEditable(c));
|
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && _isEditable(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity kinds whose per-entity PUT route validates the body against a Pydantic
|
||||||
|
* **discriminated union** (`Body(discriminator=...)`). Such a route 422s unless
|
||||||
|
* the body echoes the discriminator field, so a partial wiring write (just a
|
||||||
|
* reference field) is rejected outright. Maps the target kind → the
|
||||||
|
* discriminator's body-field name; the value is the target's *current* subtype,
|
||||||
|
* which we read back from the entity immediately before the write.
|
||||||
|
*
|
||||||
|
* Without this, drag-to-wire silently fails for nearly every source kind. Keep
|
||||||
|
* in sync with the backend `NODE_TYPE_FIELD` map in `api/graph_schema.py`.
|
||||||
|
*/
|
||||||
|
const _DISCRIMINATOR_FIELD: Readonly<Record<string, string>> = {
|
||||||
|
picture_source: 'stream_type',
|
||||||
|
audio_source: 'source_type',
|
||||||
|
value_source: 'source_type',
|
||||||
|
color_strip_source: 'source_type',
|
||||||
|
output_target: 'target_type',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a connection: set the reference field on the target entity.
|
* Update a connection: set the reference field on the target entity.
|
||||||
* @param {string} targetId - The target entity's ID
|
* @param {string} targetId - The target entity's ID
|
||||||
@@ -226,6 +316,21 @@ export async function updateConnection(targetId: string, targetKind: string, fie
|
|||||||
? { [field.split('.')[0]]: { source_id: newSourceId || '' } }
|
? { [field.split('.')[0]]: { source_id: newSourceId || '' } }
|
||||||
: { [field]: newSourceId };
|
: { [field]: newSourceId };
|
||||||
|
|
||||||
|
// Discriminated-union PUT routes reject a body without their discriminator.
|
||||||
|
// Echo the target's current subtype so a partial wiring write validates
|
||||||
|
// instead of 422-ing. Best-effort: a failed read leaves the PUT to fail as
|
||||||
|
// before — it never makes things worse.
|
||||||
|
const discrimField = _DISCRIMINATOR_FIELD[targetKind];
|
||||||
|
if (discrimField) {
|
||||||
|
try {
|
||||||
|
const current = await apiGet<Record<string, unknown>>(url);
|
||||||
|
const tag = current?.[discrimField];
|
||||||
|
if (typeof tag === 'string' && tag) body[discrimField] = tag;
|
||||||
|
} catch {
|
||||||
|
/* leave body as-is; the PUT below will surface any error */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiPut(url, body);
|
await apiPut(url, body);
|
||||||
// Invalidate the relevant cache so data refreshes
|
// Invalidate the relevant cache so data refreshes
|
||||||
@@ -243,4 +348,115 @@ export async function detachConnection(targetId: string, targetKind: string, fie
|
|||||||
return updateConnection(targetId, targetKind, field, '');
|
return updateConnection(targetId, targetKind, field, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── List-element slots (composite layers / mapped zones) ──────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Targets that hold *list* reference slots. Editing one element means
|
||||||
|
* re-PUTting the whole list, so we map the kind → its endpoint + cache.
|
||||||
|
* (Only color strip sources have list slots today: composite `layers`,
|
||||||
|
* mapped `zones`.)
|
||||||
|
*/
|
||||||
|
const _LIST_SLOT_TARGET: Readonly<Record<string, { endpoint: string; cache: { invalidate(): void } }>> = {
|
||||||
|
color_strip_source: { endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-wire a single element of a list reference slot — e.g. a composite
|
||||||
|
* `layers[index].source_id` or a mapped `zones[index].source_id`.
|
||||||
|
*
|
||||||
|
* The owning entity's PUT replaces the *entire* list, so this reads the entity
|
||||||
|
* back, copies every element verbatim, changes only `list[index][refField]`,
|
||||||
|
* and PUTs the full list (plus the discriminator). Echoing the existing element
|
||||||
|
* objects is what preserves each layer/zone's other settings (blend mode,
|
||||||
|
* opacity, LED range, per-layer brightness/template, …) — a naive partial write
|
||||||
|
* would silently drop that config.
|
||||||
|
*
|
||||||
|
* @param newSourceId New source id, or '' to clear (only valid for optional refs).
|
||||||
|
* @returns Promise<boolean> success
|
||||||
|
*/
|
||||||
|
export async function updateListSlotConnection(
|
||||||
|
targetId: string,
|
||||||
|
targetKind: string,
|
||||||
|
listField: string,
|
||||||
|
index: number,
|
||||||
|
refField: string,
|
||||||
|
newSourceId: string | null,
|
||||||
|
expectedCurrent?: string | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const target = _LIST_SLOT_TARGET[targetKind];
|
||||||
|
if (!target || !Number.isInteger(index) || index < 0) return false;
|
||||||
|
|
||||||
|
const url = target.endpoint.replace('{id}', targetId);
|
||||||
|
try {
|
||||||
|
const current = await apiGet<Record<string, unknown>>(url);
|
||||||
|
const list = current?.[listField];
|
||||||
|
if (!Array.isArray(list) || index >= list.length) return false;
|
||||||
|
|
||||||
|
// Optimistic-concurrency guard: `index` is positional, so if the list was
|
||||||
|
// reordered/edited out-of-band (e.g. via the entity editor) between render
|
||||||
|
// and write — or between an action and its undo/redo — that index now points
|
||||||
|
// at a *different* element. Refuse rather than rewrite the wrong slot.
|
||||||
|
if (expectedCurrent != null) {
|
||||||
|
const el = list[index] as Record<string, unknown>;
|
||||||
|
const actual = typeof el?.[refField] === 'string' ? (el[refField] as string) : '';
|
||||||
|
if (actual !== expectedCurrent) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy every element; change only the one ref on the targeted element.
|
||||||
|
// (`|| ''` clears the ref — only valid for *optional* refs; the graph only
|
||||||
|
// re-wires the required `source_id`, so callers always pass a real id here.)
|
||||||
|
const nextList = list.map((el, i) =>
|
||||||
|
i === index
|
||||||
|
? { ...(el as Record<string, unknown>), [refField]: newSourceId || '' }
|
||||||
|
: { ...(el as Record<string, unknown>) },
|
||||||
|
);
|
||||||
|
const body: Record<string, unknown> = { [listField]: nextList };
|
||||||
|
|
||||||
|
// Discriminated-union PUT routes need the subtype echoed (see updateConnection).
|
||||||
|
const discrimField = _DISCRIMINATOR_FIELD[targetKind];
|
||||||
|
if (discrimField) {
|
||||||
|
const tag = current[discrimField];
|
||||||
|
if (typeof tag === 'string' && tag) body[discrimField] = tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiPut(url, body);
|
||||||
|
target.cache.invalidate();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subgraph duplication (D6) ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Result of `POST /graph/duplicate` (server-side clone of a selected subgraph). */
|
||||||
|
export interface DuplicateResult {
|
||||||
|
id_map: Record<string, string>;
|
||||||
|
created: Array<{ id: string; kind: string; name: string }>;
|
||||||
|
skipped: Array<{ id: string; reason: string }>;
|
||||||
|
warnings: Array<{ id: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side duplicate of a selected subgraph: the backend deep-clones the
|
||||||
|
* value / colour-strip sources among `nodeIds` with fresh ids and rewires
|
||||||
|
* references that point *within* the selection (shared deps stay shared).
|
||||||
|
* Returns the result, or `null` on failure. Invalidates the affected caches so
|
||||||
|
* a subsequent graph reload shows the clones.
|
||||||
|
*/
|
||||||
|
export async function duplicateSubgraph(
|
||||||
|
nodeIds: string[], nameSuffix?: string,
|
||||||
|
): Promise<DuplicateResult | null> {
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = { node_ids: nodeIds };
|
||||||
|
if (nameSuffix) body.name_suffix = nameSuffix;
|
||||||
|
const res = await apiPost<DuplicateResult>('/graph/duplicate', body);
|
||||||
|
valueSourcesCache.invalidate();
|
||||||
|
colorStripSourcesCache.invalidate();
|
||||||
|
return res;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { CONNECTION_MAP };
|
export { CONNECTION_MAP };
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ interface GraphEdge {
|
|||||||
type: string;
|
type: string;
|
||||||
field?: string;
|
field?: string;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
|
/** List-element reference (composite layer / mapped zone) — exposed as
|
||||||
|
* `data-slot-*` so the editor can re-wire just this slot. */
|
||||||
|
slot?: { list: string; index: number; ref: string };
|
||||||
points?: { x: number; y: number }[] | null;
|
points?: { x: number; y: number }[] | null;
|
||||||
fromNode?: GraphNodeRect;
|
fromNode?: GraphNodeRect;
|
||||||
toNode?: GraphNodeRect;
|
toNode?: GraphNodeRect;
|
||||||
@@ -124,6 +127,12 @@ function _renderEdge(edge: GraphEdge): SVGElement {
|
|||||||
'data-to': to,
|
'data-to': to,
|
||||||
'data-field': field || '',
|
'data-field': field || '',
|
||||||
});
|
});
|
||||||
|
// List-element reference: expose the slot so the editor can re-wire it.
|
||||||
|
if (edge.slot) {
|
||||||
|
path.setAttribute('data-slot-list', edge.slot.list);
|
||||||
|
path.setAttribute('data-slot-index', String(edge.slot.index));
|
||||||
|
path.setAttribute('data-slot-ref', edge.slot.ref);
|
||||||
|
}
|
||||||
|
|
||||||
// Tooltip
|
// Tooltip
|
||||||
const title = svgEl('title');
|
const title = svgEl('title');
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ interface LayoutEdge {
|
|||||||
label: string;
|
label: string;
|
||||||
type: string;
|
type: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
/** For list-element references (composite layers / mapped zones): which list,
|
||||||
|
* which element index, and the reference field on that element. Lets the
|
||||||
|
* editor re-wire one slot without disturbing its siblings. */
|
||||||
|
slot?: { list: string; index: number; ref: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutResult {
|
interface LayoutResult {
|
||||||
@@ -236,7 +240,7 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
|||||||
nodeByIdLocal.set(id, node);
|
nodeByIdLocal.set(id, node);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addEdge(from: string, to: string, field: string, label: string = ''): void {
|
function addEdge(from: string, to: string, field: string, label: string = '', slot?: { list: string; index: number; ref: string }): void {
|
||||||
if (!from || !to) return;
|
if (!from || !to) return;
|
||||||
// The referrer (`to`) is always a current entity in these loops; if the
|
// The referrer (`to`) is always a current entity in these loops; if the
|
||||||
// referenced entity (`from`) is missing, the reference is dangling —
|
// referenced entity (`from`) is missing, the reference is dangling —
|
||||||
@@ -254,7 +258,7 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
|||||||
);
|
);
|
||||||
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
|
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
|
||||||
const editable = !field.includes('.');
|
const editable = !field.includes('.');
|
||||||
edges.push({ from, to, field, label, type, editable });
|
edges.push({ from, to, field, label, type, editable, ...(slot ? { slot } : {}) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every entity may carry a custom `icon` (+ `icon_color`); pass them through
|
// Every entity may carry a custom `icon` (+ `icon_color`); pass them through
|
||||||
@@ -348,6 +352,21 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
|||||||
for (const s of e.valueSources || []) {
|
for (const s of e.valueSources || []) {
|
||||||
if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id');
|
if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id');
|
||||||
if (s.picture_source_id) addEdge(s.picture_source_id, s.id, 'picture_source_id');
|
if (s.picture_source_id) addEdge(s.picture_source_id, s.id, 'picture_source_id');
|
||||||
|
// Derived value sources: gradient_map derives from an inner value source;
|
||||||
|
// css_extract derives from a color strip. Both are real, runtime-resolved
|
||||||
|
// references (and drag-editable) — render them so they're visible.
|
||||||
|
if (s.value_source_id) addEdge(s.value_source_id, s.id, 'value_source_id');
|
||||||
|
if (s.color_strip_source_id) addEdge(s.color_strip_source_id, s.id, 'color_strip_source_id');
|
||||||
|
// Template value sources reference one inner value source per bound input.
|
||||||
|
// Each `inputs[].value_source_id` is a real 'value' edge; the dotted field
|
||||||
|
// path marks it non-editable (drag-wiring deferred — see graph-connections).
|
||||||
|
if (s.source_type === 'template' && Array.isArray(s.inputs)) {
|
||||||
|
s.inputs.forEach((inp: any) => {
|
||||||
|
if (inp?.value_source_id) {
|
||||||
|
addEdge(inp.value_source_id, s.id, `inputs[${inp.name}].value_source_id`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color strip source edges
|
// Color strip source edges
|
||||||
@@ -360,19 +379,20 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
|||||||
if (s.input_source_id) addEdge(s.input_source_id, s.id, 'input_source_id');
|
if (s.input_source_id) addEdge(s.input_source_id, s.id, 'input_source_id');
|
||||||
if (s.processing_template_id) addEdge(s.processing_template_id, s.id, 'processing_template_id');
|
if (s.processing_template_id) addEdge(s.processing_template_id, s.id, 'processing_template_id');
|
||||||
|
|
||||||
// Composite layers
|
// Composite layers — carry the slot index so each `layer.source_id`
|
||||||
|
// edge can be re-wired individually from the graph (siblings untouched).
|
||||||
if (s.layers) {
|
if (s.layers) {
|
||||||
for (const layer of s.layers) {
|
s.layers.forEach((layer: any, i: number) => {
|
||||||
if (layer.source_id) addEdge(layer.source_id, s.id, 'layer.source_id');
|
if (layer.source_id) addEdge(layer.source_id, s.id, 'layer.source_id', '', { list: 'layers', index: i, ref: 'source_id' });
|
||||||
if (layer.brightness_source_id) addEdge(layer.brightness_source_id, s.id, 'layer.brightness_source_id');
|
if (layer.brightness_source_id) addEdge(layer.brightness_source_id, s.id, 'layer.brightness_source_id');
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mapped zones
|
// Mapped zones — carry the slot index (re-wirable from the graph).
|
||||||
if (s.zones) {
|
if (s.zones) {
|
||||||
for (const zone of s.zones) {
|
s.zones.forEach((zone: any, i: number) => {
|
||||||
if (zone.source_id) addEdge(zone.source_id, s.id, 'zone.source_id');
|
if (zone.source_id) addEdge(zone.source_id, s.id, 'zone.source_id', '', { list: 'zones', index: i, ref: 'source_id' });
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advanced picture calibration lines
|
// Advanced picture calibration lines
|
||||||
@@ -405,7 +425,6 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
|||||||
if (bvsId) addEdge(bvsId, t.id, 'brightness.source_id');
|
if (bvsId) addEdge(bvsId, t.id, 'brightness.source_id');
|
||||||
const transVsId = bindableSourceId(t.transition);
|
const transVsId = bindableSourceId(t.transition);
|
||||||
if (transVsId) addEdge(transVsId, t.id, 'transition.source_id');
|
if (transVsId) addEdge(transVsId, t.id, 'transition.source_id');
|
||||||
if (t.picture_source_id) addEdge(t.picture_source_id, t.id, 'picture_source_id');
|
|
||||||
// KC target settings
|
// KC target settings
|
||||||
if (t.settings) {
|
if (t.settings) {
|
||||||
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
|
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ const SUBTYPE_ICONS = {
|
|||||||
value_source: {
|
value_source: {
|
||||||
static: P.layoutDashboard, animated: P.refreshCw, audio: P.music,
|
static: P.layoutDashboard, animated: P.refreshCw, audio: P.music,
|
||||||
adaptive_time: P.clock, adaptive_scene: P.cloudSun, daylight: P.sun,
|
adaptive_time: P.clock, adaptive_scene: P.cloudSun, daylight: P.sun,
|
||||||
|
template: P.code,
|
||||||
},
|
},
|
||||||
audio_source: { capture: P.volume2, processed: P.slidersHorizontal },
|
audio_source: { capture: P.volume2, processed: P.slidersHorizontal },
|
||||||
output_target: { led: P.lightbulb, wled: P.lightbulb, ha_light: P.lightbulb },
|
output_target: { led: P.lightbulb, wled: P.lightbulb, ha_light: P.lightbulb },
|
||||||
@@ -656,7 +657,7 @@ export function markIssues(group: SVGGElement, issues: Map<string, string[]>): v
|
|||||||
|
|
||||||
for (const [id, msgs] of issues) {
|
for (const [id, msgs] of issues) {
|
||||||
if (!msgs.length) continue;
|
if (!msgs.length) continue;
|
||||||
const el = group.querySelector(`.graph-node[data-id="${id}"]`);
|
const el = group.querySelector(`.graph-node[data-id="${CSS.escape(id)}"]`);
|
||||||
if (!el) continue;
|
if (!el) continue;
|
||||||
el.classList.add('has-issue');
|
el.classList.add('has-issue');
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const _valueSourceTypeIcons = {
|
|||||||
system_metrics: _svg(P.cpu),
|
system_metrics: _svg(P.cpu),
|
||||||
game_event: _svg(P.gamepad2),
|
game_event: _svg(P.gamepad2),
|
||||||
http: _svg(P.globe),
|
http: _svg(P.globe),
|
||||||
|
template: _svg(P.code),
|
||||||
};
|
};
|
||||||
const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.slidersHorizontal) };
|
const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.slidersHorizontal) };
|
||||||
const _deviceTypeIcons = {
|
const _deviceTypeIcons = {
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Tiny zero-dependency Jinja-expression highlighter.
|
||||||
|
*
|
||||||
|
* A transparent <textarea> is layered over a synced <pre class="jinja-hl">.
|
||||||
|
* On every input the text is re-tokenised and painted into the <pre> so the
|
||||||
|
* caret and selection stay native while the colours live underneath. The two
|
||||||
|
* layers share identical font metrics (set in CSS via --font-mono) so the
|
||||||
|
* highlight aligns pixel-perfectly with the typed glyphs.
|
||||||
|
*
|
||||||
|
* Tokenised: strings, numbers, the sandbox globals (min|max|abs|round|clamp),
|
||||||
|
* the `raw` keyword, bound input variable names (supplied live via
|
||||||
|
* getInputNames), and operators. Everything else renders as plain text.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const ed = create({ textarea, getInputNames: () => ['audio','temp'], onChange });
|
||||||
|
* ed.refresh(); // re-paint after the input list or value changes externally
|
||||||
|
* ed.destroy();
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Globals available inside the sandboxed expression (see backend contract). */
|
||||||
|
const JINJA_GLOBALS = new Set(['min', 'max', 'abs', 'round', 'clamp']);
|
||||||
|
const JINJA_RAW = 'raw';
|
||||||
|
|
||||||
|
export interface JinjaEditorOpts {
|
||||||
|
textarea: HTMLTextAreaElement;
|
||||||
|
getInputNames: () => string[];
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JinjaEditorHandle {
|
||||||
|
/** Re-paint the highlight layer (e.g. after the bound-input list changed). */
|
||||||
|
refresh: () => void;
|
||||||
|
/** Detach listeners and remove the highlight overlay. */
|
||||||
|
destroy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenKind = 'str' | 'num' | 'fn' | 'raw' | 'var' | 'op' | 'text';
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
kind: TokenKind;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenise a Jinja expression. Deliberately small — this is presentational
|
||||||
|
* only; the backend is the source of truth for validity.
|
||||||
|
*/
|
||||||
|
function tokenize(src: string, inputNames: Set<string>): Token[] {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
const n = src.length;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
const push = (kind: TokenKind, text: string) => {
|
||||||
|
// Coalesce consecutive plain-text runs to keep the DOM tiny.
|
||||||
|
const last = tokens[tokens.length - 1];
|
||||||
|
if (kind === 'text' && last && last.kind === 'text') last.text += text;
|
||||||
|
else tokens.push({ kind, text });
|
||||||
|
};
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const ch = src[i];
|
||||||
|
|
||||||
|
// Strings — single or double quoted, with simple escape passthrough.
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
const quote = ch;
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < n && src[j] !== quote) {
|
||||||
|
if (src[j] === '\\' && j + 1 < n) j += 2;
|
||||||
|
else j += 1;
|
||||||
|
}
|
||||||
|
j = Math.min(j + 1, n); // include closing quote if present
|
||||||
|
push('str', src.slice(i, j));
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbers — integer / float.
|
||||||
|
if (ch >= '0' && ch <= '9') {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < n && /[0-9._]/.test(src[j])) j += 1;
|
||||||
|
push('num', src.slice(i, j));
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifiers — globals, the `raw` keyword, bound input names, or plain.
|
||||||
|
if (/[A-Za-z_]/.test(ch)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < n && /[A-Za-z0-9_]/.test(src[j])) j += 1;
|
||||||
|
const word = src.slice(i, j);
|
||||||
|
if (JINJA_GLOBALS.has(word)) push('fn', word);
|
||||||
|
else if (word === JINJA_RAW) push('raw', word);
|
||||||
|
else if (inputNames.has(word)) push('var', word);
|
||||||
|
else push('text', word);
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operators / punctuation.
|
||||||
|
if ('+-*/%()[]<>=!,&|?:'.includes(ch)) {
|
||||||
|
push('op', ch);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitespace and everything else.
|
||||||
|
push('text', ch);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(src: string, inputNames: Set<string>): string {
|
||||||
|
// A trailing newline is swallowed by <pre>; pad it so the highlight box
|
||||||
|
// keeps the same height as the textarea while typing a fresh line.
|
||||||
|
const padded = src.endsWith('\n') ? src + ' ' : src;
|
||||||
|
const html = tokenize(padded, inputNames)
|
||||||
|
.map(tok =>
|
||||||
|
tok.kind === 'text'
|
||||||
|
? escapeHtml(tok.text)
|
||||||
|
: `<span class="tok-${tok.kind}">${escapeHtml(tok.text)}</span>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function create({ textarea, getInputNames, onChange }: JinjaEditorOpts): JinjaEditorHandle {
|
||||||
|
// Wrap the textarea so the highlight layer can sit directly behind it.
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'jinja-editor';
|
||||||
|
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.className = 'jinja-hl';
|
||||||
|
pre.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
const parent = textarea.parentNode;
|
||||||
|
if (parent) {
|
||||||
|
parent.insertBefore(wrap, textarea);
|
||||||
|
wrap.appendChild(pre);
|
||||||
|
wrap.appendChild(textarea);
|
||||||
|
}
|
||||||
|
textarea.classList.add('jinja-input');
|
||||||
|
textarea.spellcheck = false;
|
||||||
|
textarea.setAttribute('autocomplete', 'off');
|
||||||
|
textarea.setAttribute('autocapitalize', 'off');
|
||||||
|
textarea.setAttribute('autocorrect', 'off');
|
||||||
|
textarea.setAttribute('wrap', 'off');
|
||||||
|
|
||||||
|
const paint = () => {
|
||||||
|
pre.innerHTML = render(textarea.value, new Set(getInputNames()));
|
||||||
|
// Keep the highlight scrolled in lock-step with the textarea.
|
||||||
|
pre.scrollTop = textarea.scrollTop;
|
||||||
|
pre.scrollLeft = textarea.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInput = () => {
|
||||||
|
paint();
|
||||||
|
if (onChange) onChange(textarea.value);
|
||||||
|
};
|
||||||
|
const onScroll = () => {
|
||||||
|
pre.scrollTop = textarea.scrollTop;
|
||||||
|
pre.scrollLeft = textarea.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
textarea.addEventListener('input', onInput);
|
||||||
|
textarea.addEventListener('scroll', onScroll);
|
||||||
|
|
||||||
|
paint();
|
||||||
|
|
||||||
|
return {
|
||||||
|
refresh: paint,
|
||||||
|
destroy: () => {
|
||||||
|
textarea.removeEventListener('input', onInput);
|
||||||
|
textarea.removeEventListener('scroll', onScroll);
|
||||||
|
textarea.classList.remove('jinja-input');
|
||||||
|
// Restore the textarea to its original place, drop the overlay.
|
||||||
|
if (wrap.parentNode) {
|
||||||
|
wrap.parentNode.insertBefore(textarea, wrap);
|
||||||
|
wrap.remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,13 +2,19 @@
|
|||||||
* Command-palette style name picker — reusable UI for browsing a list of
|
* Command-palette style name picker — reusable UI for browsing a list of
|
||||||
* names fetched from any API endpoint. Mirrors the EntityPalette pattern.
|
* names fetched from any API endpoint. Mirrors the EntityPalette pattern.
|
||||||
*
|
*
|
||||||
* Two concrete pickers are exported:
|
* Three concrete pickers are exported:
|
||||||
*
|
*
|
||||||
* - **ProcessPalette** — picks from running OS processes (`/system/processes`)
|
* - **ProcessPalette** — picks from running OS processes (`/system/processes`)
|
||||||
* - **NotificationAppPalette** — picks from OS notification history apps
|
* - **NotificationAppPalette** — picks from OS notification history apps
|
||||||
|
* - **AppPalette** — picks from Android launchable apps (`/system/installed-apps`),
|
||||||
|
* displaying the human label but inserting the package name
|
||||||
*
|
*
|
||||||
* Both support single-select (returns one value) and multi-select (appends to
|
* Items may be plain strings (display == stored value) or `{ value, label }`
|
||||||
* a textarea).
|
* pairs (display the label, store the value — used by AppPalette so the rule
|
||||||
|
* stores the package name while the user sees "Netflix").
|
||||||
|
*
|
||||||
|
* All support single-select (returns one value) and multi-select (appends the
|
||||||
|
* value to a textarea).
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
*
|
*
|
||||||
@@ -29,8 +35,16 @@ import { ICON_SEARCH } from './icons.ts';
|
|||||||
|
|
||||||
/* ─── types ────────────────────────────────────────────────── */
|
/* ─── types ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
interface PaletteItem {
|
/** An item with a display label distinct from its stored value. */
|
||||||
name: string;
|
interface AppItem {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw items a fetcher may return: bare strings or labelled pairs. */
|
||||||
|
type RawItem = string | AppItem;
|
||||||
|
|
||||||
|
interface PaletteEntry extends AppItem {
|
||||||
added: boolean;
|
added: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +58,9 @@ interface PickMultiOpts {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchItemsFn = () => Promise<string[]>;
|
type FetchItemsFn = () => Promise<RawItem[]>;
|
||||||
|
|
||||||
|
const DEFAULT_EMPTY_KEY = 'automations.condition.application.no_processes';
|
||||||
|
|
||||||
/* ─── generic NamePalette (shared logic) ───────────────────── */
|
/* ─── generic NamePalette (shared logic) ───────────────────── */
|
||||||
|
|
||||||
@@ -53,19 +69,21 @@ class NamePalette {
|
|||||||
private _input: HTMLInputElement;
|
private _input: HTMLInputElement;
|
||||||
private _list: HTMLDivElement;
|
private _list: HTMLDivElement;
|
||||||
private _fetchItems: FetchItemsFn;
|
private _fetchItems: FetchItemsFn;
|
||||||
|
private _emptyKey: string;
|
||||||
|
|
||||||
private _resolveSingle: ((v: string | undefined) => void) | null = null;
|
private _resolveSingle: ((v: string | undefined) => void) | null = null;
|
||||||
private _multiTextarea: HTMLTextAreaElement | null = null;
|
private _multiTextarea: HTMLTextAreaElement | null = null;
|
||||||
|
|
||||||
private _items: string[] = [];
|
private _items: AppItem[] = [];
|
||||||
private _existing: Set<string> = new Set();
|
private _existing: Set<string> = new Set();
|
||||||
private _filtered: PaletteItem[] = [];
|
private _filtered: PaletteEntry[] = [];
|
||||||
private _highlightIdx = 0;
|
private _highlightIdx = 0;
|
||||||
private _currentValue: string | undefined;
|
private _currentValue: string | undefined;
|
||||||
private _isMulti = false;
|
private _isMulti = false;
|
||||||
|
|
||||||
constructor(fetchItems: FetchItemsFn) {
|
constructor(fetchItems: FetchItemsFn, emptyKey: string = DEFAULT_EMPTY_KEY) {
|
||||||
this._fetchItems = fetchItems;
|
this._fetchItems = fetchItems;
|
||||||
|
this._emptyKey = emptyKey;
|
||||||
|
|
||||||
this._overlay = document.createElement('div');
|
this._overlay = document.createElement('div');
|
||||||
this._overlay.className = 'entity-palette-overlay process-palette-overlay';
|
this._overlay.className = 'entity-palette-overlay process-palette-overlay';
|
||||||
@@ -107,14 +125,20 @@ class NamePalette {
|
|||||||
this._isMulti = true;
|
this._isMulti = true;
|
||||||
this._multiTextarea = opts.textarea;
|
this._multiTextarea = opts.textarea;
|
||||||
this._resolveSingle = resolve as any;
|
this._resolveSingle = resolve as any;
|
||||||
this._existing = new Set(
|
this._existing = this._textareaValues(opts.textarea);
|
||||||
opts.textarea.value.split('\n').map(s => s.trim().toLowerCase()).filter(Boolean),
|
|
||||||
);
|
|
||||||
this._currentValue = undefined;
|
this._currentValue = undefined;
|
||||||
this._open(opts.placeholder);
|
this._open(opts.placeholder);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _textareaValues(ta: HTMLTextAreaElement): Set<string> {
|
||||||
|
return new Set(ta.value.split('\n').map(s => s.trim().toLowerCase()).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _normalize(raw: RawItem[]): AppItem[] {
|
||||||
|
return raw.map(r => (typeof r === 'string' ? { value: r, label: r } : r));
|
||||||
|
}
|
||||||
|
|
||||||
private async _open(placeholder?: string) {
|
private async _open(placeholder?: string) {
|
||||||
this._input.placeholder = placeholder || '';
|
this._input.placeholder = placeholder || '';
|
||||||
this._input.value = '';
|
this._input.value = '';
|
||||||
@@ -123,15 +147,13 @@ class NamePalette {
|
|||||||
requestAnimationFrame(() => this._input.focus());
|
requestAnimationFrame(() => this._input.focus());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._items = await this._fetchItems();
|
this._items = this._normalize(await this._fetchItems());
|
||||||
} catch {
|
} catch {
|
||||||
this._items = [];
|
this._items = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._isMulti) {
|
if (this._isMulti) {
|
||||||
this._existing = new Set(
|
this._existing = this._textareaValues(this._multiTextarea!);
|
||||||
this._multiTextarea!.value.split('\n').map(s => s.trim().toLowerCase()).filter(Boolean),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._filter();
|
this._filter();
|
||||||
@@ -142,14 +164,11 @@ class NamePalette {
|
|||||||
private _filter() {
|
private _filter() {
|
||||||
const q = this._input.value.toLowerCase().trim();
|
const q = this._input.value.toLowerCase().trim();
|
||||||
this._filtered = this._items
|
this._filtered = this._items
|
||||||
.filter(p => !q || p.toLowerCase().includes(q))
|
.filter(p => !q || p.label.toLowerCase().includes(q) || p.value.toLowerCase().includes(q))
|
||||||
.map(p => ({
|
.map(p => ({ ...p, added: this._existing.has(p.value.toLowerCase()) }));
|
||||||
name: p,
|
|
||||||
added: this._existing.has(p.toLowerCase()),
|
|
||||||
}));
|
|
||||||
|
|
||||||
this._highlightIdx = this._filtered.findIndex(
|
this._highlightIdx = this._filtered.findIndex(
|
||||||
i => i.name.toLowerCase() === (this._currentValue || '').toLowerCase(),
|
i => i.value.toLowerCase() === (this._currentValue || '').toLowerCase(),
|
||||||
);
|
);
|
||||||
if (this._highlightIdx === -1) this._highlightIdx = 0;
|
if (this._highlightIdx === -1) this._highlightIdx = 0;
|
||||||
this._render();
|
this._render();
|
||||||
@@ -158,9 +177,7 @@ class NamePalette {
|
|||||||
private _render() {
|
private _render() {
|
||||||
if (this._filtered.length === 0) {
|
if (this._filtered.length === 0) {
|
||||||
this._list.innerHTML = `<div class="entity-palette-empty">${
|
this._list.innerHTML = `<div class="entity-palette-empty">${
|
||||||
this._items.length === 0
|
this._items.length === 0 ? t(this._emptyKey) : '—'
|
||||||
? t('automations.condition.application.no_processes')
|
|
||||||
: '—'
|
|
||||||
}</div>`;
|
}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -170,12 +187,21 @@ class NamePalette {
|
|||||||
'entity-palette-item',
|
'entity-palette-item',
|
||||||
i === this._highlightIdx ? 'ep-highlight' : '',
|
i === this._highlightIdx ? 'ep-highlight' : '',
|
||||||
item.added ? 'ep-current' : '',
|
item.added ? 'ep-current' : '',
|
||||||
item.name.toLowerCase() === (this._currentValue || '').toLowerCase() ? 'ep-current' : '',
|
item.value.toLowerCase() === (this._currentValue || '').toLowerCase() ? 'ep-current' : '',
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
// When the label differs from the stored value (e.g. "Netflix" vs
|
||||||
|
// "com.netflix.mediaclient"), show the value as a secondary line so
|
||||||
|
// users can see exactly what gets matched. Otherwise fall back to the
|
||||||
|
// ✓ added-marker.
|
||||||
|
const showValue = item.label !== item.value;
|
||||||
|
const trailing = showValue
|
||||||
|
? `<span class="ep-item-desc">${escapeHtml(item.value)}</span>`
|
||||||
|
: (item.added ? '<span class="ep-item-desc">✓</span>' : '');
|
||||||
|
|
||||||
return `<div class="${cls}" data-idx="${i}">
|
return `<div class="${cls}" data-idx="${i}">
|
||||||
<span class="ep-item-label">${escapeHtml(item.name)}</span>
|
<span class="ep-item-label">${escapeHtml(item.label)}</span>
|
||||||
${item.added ? '<span class="ep-item-desc">\u2713</span>' : ''}
|
${trailing}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -192,19 +218,19 @@ class NamePalette {
|
|||||||
|
|
||||||
/* ── selection ──────────────────────────────────────────── */
|
/* ── selection ──────────────────────────────────────────── */
|
||||||
|
|
||||||
private _selectItem(item: PaletteItem) {
|
private _selectItem(item: PaletteEntry) {
|
||||||
if (this._isMulti) {
|
if (this._isMulti) {
|
||||||
if (!item.added) {
|
if (!item.added) {
|
||||||
const ta = this._multiTextarea!;
|
const ta = this._multiTextarea!;
|
||||||
const cur = ta.value.trim();
|
const cur = ta.value.trim();
|
||||||
ta.value = cur ? cur + '\n' + item.name : item.name;
|
ta.value = cur ? cur + '\n' + item.value : item.value;
|
||||||
this._existing.add(item.name.toLowerCase());
|
this._existing.add(item.value.toLowerCase());
|
||||||
item.added = true;
|
item.added = true;
|
||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this._overlay.classList.remove('open');
|
this._overlay.classList.remove('open');
|
||||||
if (this._resolveSingle) this._resolveSingle(item.name);
|
if (this._resolveSingle) this._resolveSingle(item.value);
|
||||||
this._resolveSingle = null;
|
this._resolveSingle = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,6 +295,17 @@ async function _fetchNotificationApps(): Promise<string[]> {
|
|||||||
return Array.from(seen.values()).sort((a, b) => a.localeCompare(b));
|
return Array.from(seen.values()).sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _fetchInstalledApps(): Promise<AppItem[]> {
|
||||||
|
try {
|
||||||
|
const data = await apiGet<{ apps?: Array<{ package: string; label: string }> }>(
|
||||||
|
'/system/installed-apps',
|
||||||
|
);
|
||||||
|
return (data.apps || []).map(a => ({ value: a.package, label: a.label || a.package }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── ProcessPalette (running processes) ───────────────────── */
|
/* ─── ProcessPalette (running processes) ───────────────────── */
|
||||||
|
|
||||||
let _processInst: NamePalette | null = null;
|
let _processInst: NamePalette | null = null;
|
||||||
@@ -301,6 +338,22 @@ export class NotificationAppPalette {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── AppPalette (Android launchable apps) ─────────────────── */
|
||||||
|
|
||||||
|
let _appInst: NamePalette | null = null;
|
||||||
|
|
||||||
|
export class AppPalette {
|
||||||
|
static pick(opts: PickOpts): Promise<string | undefined> {
|
||||||
|
if (!_appInst) _appInst = new NamePalette(_fetchInstalledApps, 'automations.rule.application.no_apps');
|
||||||
|
return _appInst.pickSingle(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
static pickMulti(opts: PickMultiOpts): Promise<void> {
|
||||||
|
if (!_appInst) _appInst = new NamePalette(_fetchInstalledApps, 'automations.rule.application.no_apps');
|
||||||
|
return _appInst.pickMulti(opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── drop-in replacement for the old attachProcessPicker ─── */
|
/* ─── drop-in replacement for the old attachProcessPicker ─── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -334,3 +387,19 @@ export function attachNotificationAppPicker(containerEl: HTMLElement, textareaEl
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire up a `.btn-browse-apps` button to open the Android launchable-app palette
|
||||||
|
* (multi-select, feeding package names into a textarea while showing labels).
|
||||||
|
*/
|
||||||
|
export function attachAppPicker(containerEl: HTMLElement, textareaEl: HTMLTextAreaElement): void {
|
||||||
|
const browseBtn = containerEl.querySelector('.btn-browse-apps');
|
||||||
|
if (!browseBtn) return;
|
||||||
|
|
||||||
|
browseBtn.addEventListener('click', () => {
|
||||||
|
AppPalette.pickMulti({
|
||||||
|
textarea: textareaEl,
|
||||||
|
placeholder: t('automations.rule.application.search_apps') || 'Filter apps…',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import { DataCache } from './cache.ts';
|
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, ScenePlaylist,
|
||||||
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
|
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||||
@@ -436,6 +436,11 @@ export const scenePresetsCache = new DataCache<ScenePreset[]>({
|
|||||||
extractData: json => json.presets || [],
|
extractData: json => json.presets || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const scenePlaylistsCache = new DataCache<ScenePlaylist[]>({
|
||||||
|
endpoint: '/scene-playlists',
|
||||||
|
extractData: json => json.playlists || [],
|
||||||
|
});
|
||||||
|
|
||||||
export interface GradientEntity {
|
export interface GradientEntity {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -0,0 +1,810 @@
|
|||||||
|
/**
|
||||||
|
* Auto-Calibration flow — guided LED-chase corner-tap wizard.
|
||||||
|
*
|
||||||
|
* Exports `mountAutoCalibration` / `unmountAutoCalibration` so Phase 4's
|
||||||
|
* wizard can embed this as a step without modification.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Device selection (EntitySelect; skipped when deviceId supplied)
|
||||||
|
* 2. Start corner — light index 0; user taps which corner is lit → start_position
|
||||||
|
* 3. Direction — advance a few indices; user identifies direction → layout
|
||||||
|
* 4. Tap-to-mark-corners — dot sweeps; user taps NEXT at each physical corner
|
||||||
|
* (first tap = corner at index 0, per Phase 1 solver contract)
|
||||||
|
* 5. Preview & Save — POST /calibration/solve → summary → PUT CSS hot-reload
|
||||||
|
*
|
||||||
|
* Session contract (Phase 1 handoff):
|
||||||
|
* POST /api/v1/calibration/session → start (stops running target)
|
||||||
|
* POST /api/v1/calibration/session/position → advance chase pixel
|
||||||
|
* POST /api/v1/calibration/session/stop → ALWAYS call on exit / error
|
||||||
|
* POST /api/v1/calibration/solve → pure solver (no persist)
|
||||||
|
* PUT /api/v1/color-strip-sources/{id} → persist + hot-reload
|
||||||
|
*
|
||||||
|
* CRITICAL: the first corner tap corresponds to LED index 0 so the solver's
|
||||||
|
* `corner_indices[0] == 0` matches `solve_calibration`'s assumption that the
|
||||||
|
* start corner is at strip index 0 (Phase 1 review finding).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiPost, apiPut } from '../core/api-client.ts';
|
||||||
|
import { colorStripSourcesCache, devicesCache } from '../core/state.ts';
|
||||||
|
import { t } from '../core/i18n.ts';
|
||||||
|
import { showToast } from '../core/ui.ts';
|
||||||
|
import { Modal } from '../core/modal.ts';
|
||||||
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
|
import { renderDeviceIcon } from '../core/device-icons.ts';
|
||||||
|
import {
|
||||||
|
ICON_DEVICE, ICON_ROTATE_CW, ICON_ROTATE_CCW,
|
||||||
|
ICON_CALIBRATION, ICON_OK,
|
||||||
|
} from '../core/icons.ts';
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type StartPosition = 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right';
|
||||||
|
type Layout = 'clockwise' | 'counterclockwise';
|
||||||
|
type AutoCalStep = 'device' | 'corner' | 'direction' | 'corners' | 'preview';
|
||||||
|
|
||||||
|
interface CalibrationSessionState {
|
||||||
|
active: boolean;
|
||||||
|
device_id: string | null;
|
||||||
|
led_count: number;
|
||||||
|
prior_target_id: string | null;
|
||||||
|
last_activity: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SolvedCalibration {
|
||||||
|
mode: 'simple';
|
||||||
|
layout: string;
|
||||||
|
start_position: string;
|
||||||
|
leds_top: number;
|
||||||
|
leds_right: number;
|
||||||
|
leds_bottom: number;
|
||||||
|
leds_left: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoCalState {
|
||||||
|
step: AutoCalStep;
|
||||||
|
cssId: string;
|
||||||
|
cssSourceType: string;
|
||||||
|
deviceId: string;
|
||||||
|
ledCount: number;
|
||||||
|
startPosition: StartPosition | null;
|
||||||
|
layout: Layout | null;
|
||||||
|
/** Strip indices of the 4 physical corners, in strip-walk order.
|
||||||
|
* cornerIndices[0] is ALWAYS 0 (start corner = LED index 0). */
|
||||||
|
cornerIndices: number[];
|
||||||
|
currentIndex: number;
|
||||||
|
sessionActive: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
solved: SolvedCalibration | null;
|
||||||
|
errorMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `mountAutoCalibration()`. */
|
||||||
|
export interface AutoCalOptions {
|
||||||
|
/** DOM container element to render wizard steps into. */
|
||||||
|
container: HTMLElement;
|
||||||
|
/** Color-strip source ID being calibrated. */
|
||||||
|
cssId: string;
|
||||||
|
/** Pre-selected device ID; if supplied the device-picker step is skipped. */
|
||||||
|
deviceId?: string;
|
||||||
|
/** Called after successful save. */
|
||||||
|
onComplete?: () => void;
|
||||||
|
/** Called after user cancels (session already stopped before this fires). */
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Module-level singleton ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _state: AutoCalState | null = null;
|
||||||
|
let _opts: AutoCalOptions | null = null;
|
||||||
|
let _deviceEntitySelect: EntitySelect | null = null;
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount the auto-calibration flow into the given container.
|
||||||
|
*
|
||||||
|
* Phase 4 usage:
|
||||||
|
* ```ts
|
||||||
|
* await mountAutoCalibration({
|
||||||
|
* container: document.getElementById('wizard-body')!,
|
||||||
|
* cssId: sourceId,
|
||||||
|
* deviceId: inferredDeviceId, // optional
|
||||||
|
* onComplete: () => wizard.next(),
|
||||||
|
* onCancel: () => wizard.close(),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
* Call `unmountAutoCalibration()` when the containing modal closes to guarantee
|
||||||
|
* the calibration session is stopped.
|
||||||
|
*/
|
||||||
|
export async function mountAutoCalibration(opts: AutoCalOptions): Promise<void> {
|
||||||
|
await unmountAutoCalibration();
|
||||||
|
_opts = opts;
|
||||||
|
|
||||||
|
let cssSourceType = 'picture';
|
||||||
|
try {
|
||||||
|
const sources = await colorStripSourcesCache.fetch() as { id: string; source_type?: string }[];
|
||||||
|
const src = sources.find(s => s.id === opts.cssId);
|
||||||
|
if (src) cssSourceType = src.source_type || 'picture';
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
|
||||||
|
_state = {
|
||||||
|
step: opts.deviceId ? 'corner' : 'device',
|
||||||
|
cssId: opts.cssId,
|
||||||
|
cssSourceType,
|
||||||
|
deviceId: opts.deviceId || '',
|
||||||
|
ledCount: 0,
|
||||||
|
startPosition: null,
|
||||||
|
layout: null,
|
||||||
|
cornerIndices: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
sessionActive: false,
|
||||||
|
busy: false,
|
||||||
|
solved: null,
|
||||||
|
errorMsg: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
_render();
|
||||||
|
|
||||||
|
if (opts.deviceId) {
|
||||||
|
_state.deviceId = opts.deviceId;
|
||||||
|
await _startSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmount: stop any active session, destroy widgets, clear container.
|
||||||
|
* Safe to call when nothing is mounted.
|
||||||
|
*/
|
||||||
|
export async function unmountAutoCalibration(): Promise<void> {
|
||||||
|
if (_deviceEntitySelect) { _deviceEntitySelect.destroy(); _deviceEntitySelect = null; }
|
||||||
|
if (_state?.sessionActive) {
|
||||||
|
await _stopSession().catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
if (_opts?.container) _opts.container.innerHTML = '';
|
||||||
|
_state = null;
|
||||||
|
_opts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal render ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _render(): void {
|
||||||
|
if (!_opts || !_state) return;
|
||||||
|
switch (_state.step) {
|
||||||
|
case 'device': _renderDevice(); break;
|
||||||
|
case 'corner': _renderCorner(); break;
|
||||||
|
case 'direction': _renderDirection(); break;
|
||||||
|
case 'corners': _renderCorners(); break;
|
||||||
|
case 'preview': _renderPreview(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: Device picker ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderDevice(): void {
|
||||||
|
if (!_opts) return;
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="device">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_DEVICE}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.device.title'))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.device.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:16px;">
|
||||||
|
<label for="autocal-device-select">${_esc(t('autocal.device.label'))}</label>
|
||||||
|
<select id="autocal-device-select"></select>
|
||||||
|
</div>
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-primary" onclick="autoCalSelectDevice()">${_esc(t('autocal.btn.next'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_populateDeviceSelect();
|
||||||
|
_showError(_state?.errorMsg || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _populateDeviceSelect(): Promise<void> {
|
||||||
|
const sel = document.getElementById('autocal-device-select') as HTMLSelectElement | null;
|
||||||
|
if (!sel) return;
|
||||||
|
let devices: { id: string; name: string; led_count: number; icon?: string }[] = [];
|
||||||
|
try { devices = await devicesCache.fetch() as typeof devices; } catch { /* empty */ }
|
||||||
|
|
||||||
|
sel.innerHTML = '';
|
||||||
|
devices.forEach(d => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = d.id;
|
||||||
|
opt.textContent = d.name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_deviceEntitySelect) { _deviceEntitySelect.destroy(); _deviceEntitySelect = null; }
|
||||||
|
if (devices.length > 0) {
|
||||||
|
_deviceEntitySelect = new EntitySelect({
|
||||||
|
target: sel,
|
||||||
|
getItems: () => devices.map(d => ({
|
||||||
|
value: d.id,
|
||||||
|
label: d.name,
|
||||||
|
icon: renderDeviceIcon(d.icon) || ICON_DEVICE,
|
||||||
|
desc: d.led_count ? `${d.led_count} LEDs` : '',
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
} as ConstructorParameters<typeof EntitySelect>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select LED-count-matched device
|
||||||
|
if (devices.length > 0 && _state) {
|
||||||
|
try {
|
||||||
|
const sources = await colorStripSourcesCache.fetch() as { id: string; led_count?: number }[];
|
||||||
|
const src = sources.find(s => s.id === _state!.cssId);
|
||||||
|
if (src?.led_count) {
|
||||||
|
const match = devices.find(d => d.led_count === src.led_count);
|
||||||
|
if (match) {
|
||||||
|
sel.value = match.id;
|
||||||
|
if (_deviceEntitySelect) _deviceEntitySelect.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSelectDevice(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const sel = document.getElementById('autocal-device-select') as HTMLSelectElement | null;
|
||||||
|
if (!sel?.value) { _setError(t('autocal.error.no_device')); return; }
|
||||||
|
_state.deviceId = sel.value;
|
||||||
|
_state.step = 'corner';
|
||||||
|
_render();
|
||||||
|
await _startSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: Start corner ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderCorner(): void {
|
||||||
|
if (!_opts) return;
|
||||||
|
const busy = _state?.busy ?? false;
|
||||||
|
const s = _state!;
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="corner">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_CALIBRATION}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.corner.title'))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.corner.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-led-indicator">
|
||||||
|
<span class="autocal-led-dot ${busy ? '' : 'autocal-led-dot--active'}" aria-hidden="true"></span>
|
||||||
|
<span class="autocal-led-index">${_esc(t('autocal.corner.led_index', { index: '0' }))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-corner-grid" ${busy ? 'aria-busy="true"' : ''}>
|
||||||
|
${(['top_left', 'top_right', 'bottom_left', 'bottom_right'] as StartPosition[]).map(pos =>
|
||||||
|
`<button class="autocal-corner-btn autocal-corner-btn--${pos.replace('_', '-')}"
|
||||||
|
onclick="autoCalSetCorner('${pos}')"
|
||||||
|
${busy ? 'disabled' : ''}
|
||||||
|
aria-label="${_esc(t(`autocal.position.${pos}`))}">
|
||||||
|
<span class="autocal-corner-glyph" aria-hidden="true"></span>
|
||||||
|
<span>${_esc(t(`autocal.position.${pos}`))}</span>
|
||||||
|
</button>`
|
||||||
|
).join('')}
|
||||||
|
</div>
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(s.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSetCorner(position: StartPosition): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.startPosition = position;
|
||||||
|
_state.step = 'direction';
|
||||||
|
_state.busy = true;
|
||||||
|
_render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// LED is at index 0; advance to ~5% to show movement direction
|
||||||
|
await _setPosition(0);
|
||||||
|
await _delay(350);
|
||||||
|
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
|
||||||
|
await _setPosition(advance);
|
||||||
|
_state.busy = false;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
_state.step = 'corner'; // revert on error
|
||||||
|
}
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: Direction ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderDirection(): void {
|
||||||
|
if (!_opts || !_state) return;
|
||||||
|
const busy = _state.busy;
|
||||||
|
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="direction">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_ROTATE_CW}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.direction.title', { step: String(advance) }))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.direction.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-direction-grid">
|
||||||
|
<button class="autocal-direction-btn" onclick="autoCalSetDirection('clockwise')" ${busy ? 'disabled' : ''}>
|
||||||
|
${ICON_ROTATE_CW}
|
||||||
|
<span>${_esc(t('calibration.direction.clockwise'))}</span>
|
||||||
|
</button>
|
||||||
|
<button class="autocal-direction-btn" onclick="autoCalSetDirection('counterclockwise')" ${busy ? 'disabled' : ''}>
|
||||||
|
${ICON_ROTATE_CCW}
|
||||||
|
<span>${_esc(t('calibration.direction.counterclockwise'))}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-ghost" onclick="autoCalBackToCorner()">${_esc(t('autocal.btn.back'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSetDirection(layout: Layout): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.layout = layout;
|
||||||
|
// corner_indices[0] MUST be 0 (Phase 1 solver contract: start corner = index 0)
|
||||||
|
_state.cornerIndices = [0];
|
||||||
|
_state.currentIndex = 0;
|
||||||
|
_state.step = 'corners';
|
||||||
|
_render();
|
||||||
|
await _setPosition(0).catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalBackToCorner(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.step = 'corner';
|
||||||
|
_state.startPosition = null;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
await _setPosition(0).catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: Tap-to-mark corners ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderCorners(): void {
|
||||||
|
if (!_opts || !_state) return;
|
||||||
|
const { cornerIndices, currentIndex, ledCount, busy } = _state;
|
||||||
|
const collected = cornerIndices.length; // starts at 1 (index 0 already in)
|
||||||
|
const isComplete = collected >= 4;
|
||||||
|
const cornerLabels = _cornerLabels(_state.startPosition!, _state.layout!);
|
||||||
|
|
||||||
|
const pips = [0, 1, 2, 3].map(i => {
|
||||||
|
const done = i < collected;
|
||||||
|
const active = i === collected - 1;
|
||||||
|
return `<span class="autocal-pip ${done ? 'autocal-pip--done' : ''} ${active ? 'autocal-pip--active' : ''}"
|
||||||
|
aria-label="${cornerLabels[i]}">${i + 1}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const activeCornerLabel = isComplete ? '' : cornerLabels[collected - 1];
|
||||||
|
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="corners">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon">${ICON_CALIBRATION}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(isComplete ? t('autocal.corners.title', { remaining: '0' }) : t('autocal.corners.title', { remaining: String(4 - collected) }))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(
|
||||||
|
isComplete
|
||||||
|
? t('autocal.corners.desc_complete')
|
||||||
|
: t('autocal.corners.desc', { corner: activeCornerLabel })
|
||||||
|
)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="autocal-corners-progress">
|
||||||
|
<div class="autocal-pips">${pips}</div>
|
||||||
|
<div class="autocal-index-badge">
|
||||||
|
<span class="autocal-index-label">${_esc(t('autocal.corners.index_label'))}</span>
|
||||||
|
<span class="autocal-index-value">${currentIndex}</span>
|
||||||
|
<span class="autocal-index-total">/ ${ledCount - 1}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="autocal-sweep-row">
|
||||||
|
<button class="btn btn-ghost btn-sm autocal-sweep-btn" onclick="autoCalSweepBack()" ${busy || isComplete || currentIndex <= 0 ? 'disabled' : ''}
|
||||||
|
aria-label="${_esc(t('autocal.btn.step_back'))}">←</button>
|
||||||
|
<div class="autocal-led-track">
|
||||||
|
<div class="autocal-led-track-fill" style="width:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
|
||||||
|
<div class="autocal-led-cursor" style="left:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm autocal-sweep-btn" onclick="autoCalSweepForward()" ${busy || isComplete || currentIndex >= ledCount - 1 ? 'disabled' : ''}
|
||||||
|
aria-label="${_esc(t('autocal.btn.step_fwd'))}">→</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${isComplete ? '' : `
|
||||||
|
<button class="btn btn-primary autocal-mark-btn" onclick="autoCalMarkCorner()" ${busy ? 'disabled' : ''}>
|
||||||
|
${_esc(t('autocal.btn.mark_corner', { n: String(collected), label: activeCornerLabel }))}
|
||||||
|
</button>`}
|
||||||
|
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-ghost" onclick="autoCalBackToDirection()">${_esc(t('autocal.btn.back'))}</button>
|
||||||
|
${isComplete ? `<button class="btn btn-primary" onclick="autoCalSolve()">${_esc(t('autocal.btn.solve'))}</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cornerLabels(startPos: StartPosition, layout: Layout): string[] {
|
||||||
|
const all: StartPosition[] = ['top_left', 'top_right', 'bottom_right', 'bottom_left'];
|
||||||
|
const si = all.indexOf(startPos);
|
||||||
|
let ordered: StartPosition[];
|
||||||
|
if (layout === 'clockwise') {
|
||||||
|
ordered = [all[si % 4], all[(si + 1) % 4], all[(si + 2) % 4], all[(si + 3) % 4]];
|
||||||
|
} else {
|
||||||
|
ordered = [all[si % 4], all[(si + 3) % 4], all[(si + 2) % 4], all[(si + 1) % 4]];
|
||||||
|
}
|
||||||
|
return ordered.map(c => t(`autocal.position.${c}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSweepForward(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||||
|
const next = _state.currentIndex + 1;
|
||||||
|
if (next >= _state.ledCount) return;
|
||||||
|
_state.busy = true;
|
||||||
|
try {
|
||||||
|
await _setPosition(next);
|
||||||
|
_state.currentIndex = next;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSweepBack(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||||
|
const prev = _state.currentIndex - 1;
|
||||||
|
// Clamp to one past the last marked corner index to preserve monotonic ordering.
|
||||||
|
const lastMarked = _state.cornerIndices.length > 0
|
||||||
|
? _state.cornerIndices[_state.cornerIndices.length - 1]
|
||||||
|
: -1;
|
||||||
|
if (prev < 0 || prev <= lastMarked) return;
|
||||||
|
_state.busy = true;
|
||||||
|
try {
|
||||||
|
await _setPosition(prev);
|
||||||
|
_state.currentIndex = prev;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalMarkCorner(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
|
||||||
|
_state.cornerIndices.push(_state.currentIndex);
|
||||||
|
if (_state.cornerIndices.length < 4) {
|
||||||
|
// Nudge forward so user can see the dot isn't stuck
|
||||||
|
const next = Math.min(_state.currentIndex + 1, _state.ledCount - 1);
|
||||||
|
_state.busy = true;
|
||||||
|
try {
|
||||||
|
await _setPosition(next);
|
||||||
|
_state.currentIndex = next;
|
||||||
|
} catch { /* best effort */ } finally {
|
||||||
|
_state.busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalBackToDirection(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.step = 'direction';
|
||||||
|
_state.layout = null;
|
||||||
|
_state.cornerIndices = [];
|
||||||
|
_state.currentIndex = 0;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
await _setPosition(0).catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSolve(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || _state.cornerIndices.length !== 4) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const solved = await apiPost<SolvedCalibration>('/calibration/solve', {
|
||||||
|
device_id: _state.deviceId,
|
||||||
|
start_position: _state.startPosition,
|
||||||
|
layout: _state.layout,
|
||||||
|
corner_indices: _state.cornerIndices,
|
||||||
|
offset: 0,
|
||||||
|
}, { errorMessage: t('autocal.error.solve_failed') });
|
||||||
|
|
||||||
|
_state.solved = solved;
|
||||||
|
// Stop the chase session — device restored to prior target
|
||||||
|
await _stopSession();
|
||||||
|
_state.step = 'preview';
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.busy = false;
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5: Preview & Save ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderPreview(): void {
|
||||||
|
if (!_opts || !_state?.solved) return;
|
||||||
|
const s = _state.solved;
|
||||||
|
const busy = _state.busy;
|
||||||
|
|
||||||
|
const dirLabel = s.layout === 'clockwise'
|
||||||
|
? t('calibration.direction.clockwise')
|
||||||
|
: t('calibration.direction.counterclockwise');
|
||||||
|
|
||||||
|
const dirIcon = s.layout === 'clockwise' ? ICON_ROTATE_CW : ICON_ROTATE_CCW;
|
||||||
|
|
||||||
|
_opts.container.innerHTML = `
|
||||||
|
<div class="autocal-step" data-step="preview">
|
||||||
|
<div class="autocal-step-header">
|
||||||
|
<span class="autocal-step-icon autocal-step-icon--ok">${ICON_OK}</span>
|
||||||
|
<div>
|
||||||
|
<div class="autocal-step-title">${_esc(t('autocal.preview.title'))}</div>
|
||||||
|
<div class="autocal-step-desc">${_esc(t('autocal.preview.desc'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="autocal-solved-grid">
|
||||||
|
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.start'))}</span>
|
||||||
|
<span class="autocal-solved-val">${_esc(t(`autocal.position.${s.start_position}`))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('calibration.direction'))}</span>
|
||||||
|
<span class="autocal-solved-val">${dirIcon} ${_esc(dirLabel)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.top'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_top}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.right'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_right}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.bottom'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_bottom}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.left'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_left}</span>
|
||||||
|
</div>
|
||||||
|
<div class="autocal-solved-item autocal-solved-item--wide">
|
||||||
|
<span class="autocal-solved-key">${_esc(t('autocal.preview.total'))}</span>
|
||||||
|
<span class="autocal-solved-val autocal-led-count">${s.leds_top + s.leds_right + s.leds_bottom + s.leds_left}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="autocal-error" class="autocal-error" style="display:none"></div>
|
||||||
|
<div class="autocal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
|
||||||
|
<button class="btn btn-primary" id="autocal-save-btn" onclick="autoCalSave()" ${busy ? 'disabled' : ''}>
|
||||||
|
${_esc(t('autocal.btn.save'))}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCalSave(): Promise<void> {
|
||||||
|
if (!_state || _state.busy || !_state.solved) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
const btn = document.getElementById('autocal-save-btn');
|
||||||
|
if (btn) btn.setAttribute('disabled', 'true');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s = _state.solved;
|
||||||
|
await apiPut(`/color-strip-sources/${_state.cssId}`, {
|
||||||
|
source_type: _state.cssSourceType,
|
||||||
|
calibration: {
|
||||||
|
mode: 'simple',
|
||||||
|
layout: s.layout,
|
||||||
|
start_position: s.start_position,
|
||||||
|
leds_top: s.leds_top,
|
||||||
|
leds_right: s.leds_right,
|
||||||
|
leds_bottom: s.leds_bottom,
|
||||||
|
leds_left: s.leds_left,
|
||||||
|
offset: s.offset,
|
||||||
|
span_top_start: 0, span_top_end: 1,
|
||||||
|
span_right_start: 0, span_right_end: 1,
|
||||||
|
span_bottom_start: 0, span_bottom_end: 1,
|
||||||
|
span_left_start: 0, span_left_end: 1,
|
||||||
|
skip_leds_start: 0,
|
||||||
|
skip_leds_end: 0,
|
||||||
|
border_width: 10,
|
||||||
|
roi_x: 0, roi_y: 0, roi_width: 1, roi_height: 1,
|
||||||
|
},
|
||||||
|
}, { errorMessage: t('autocal.error.save_failed') });
|
||||||
|
|
||||||
|
colorStripSourcesCache.invalidate();
|
||||||
|
showToast(t('autocal.saved'), 'success');
|
||||||
|
|
||||||
|
const onComplete = _opts?.onComplete;
|
||||||
|
await unmountAutoCalibration();
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
if (btn) btn.removeAttribute('disabled');
|
||||||
|
_showError(_state.errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cancel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function autoCalCancel(): Promise<void> {
|
||||||
|
const onCancel = _opts?.onCancel;
|
||||||
|
await unmountAutoCalibration();
|
||||||
|
if (onCancel) onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session lifecycle ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _startSession(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_render();
|
||||||
|
try {
|
||||||
|
const state = await apiPost<CalibrationSessionState>('/calibration/session', {
|
||||||
|
device_id: _state.deviceId,
|
||||||
|
}, { errorMessage: t('autocal.error.session_start_failed') });
|
||||||
|
_state.sessionActive = true;
|
||||||
|
_state.ledCount = state.led_count;
|
||||||
|
_state.busy = false;
|
||||||
|
await _setPosition(0);
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_render();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Session may already be live (POST /calibration/session succeeded before _setPosition threw),
|
||||||
|
// so call _stopSession() to let the backend tear down cleanly instead of flipping the flag directly.
|
||||||
|
await _stopSession().catch(() => { /* best effort */ });
|
||||||
|
_state.busy = false;
|
||||||
|
_state.errorMsg = _errMsg(err);
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _stopSession(): Promise<void> {
|
||||||
|
if (!_state?.sessionActive) return;
|
||||||
|
try {
|
||||||
|
await apiPost<CalibrationSessionState>('/calibration/session/stop', undefined, {
|
||||||
|
errorMessage: t('autocal.error.session_stop_failed'),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (_state) _state.sessionActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _setPosition(index: number): Promise<void> {
|
||||||
|
if (!_state?.sessionActive) return;
|
||||||
|
await apiPost<CalibrationSessionState>('/calibration/session/position', {
|
||||||
|
index,
|
||||||
|
window: 1,
|
||||||
|
}, { errorMessage: t('autocal.error.position_failed') });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _errMsg(err: unknown): string {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showError(msg: string): void {
|
||||||
|
const el = document.getElementById('autocal-error');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.display = msg ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setError(msg: string): void {
|
||||||
|
if (_state) _state.errorMsg = msg;
|
||||||
|
_showError(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Standalone modal management ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The standalone modal is the Phase 3 surface: opened from the calibration
|
||||||
|
// modal's "Auto-calibrate" button. Phase 4 wizard uses mountAutoCalibration()
|
||||||
|
// directly (no modal wrapper needed — the wizard is itself a modal).
|
||||||
|
|
||||||
|
class AutoCalModal extends Modal {
|
||||||
|
constructor() { super('auto-calibration-modal'); }
|
||||||
|
|
||||||
|
snapshotValues(): Record<string, string> {
|
||||||
|
// No dirty-check needed for a wizard flow; always allow close.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
onForceClose(): void {
|
||||||
|
// Unmount the flow asynchronously (session stop is async)
|
||||||
|
unmountAutoCalibration().catch(() => { /* best effort */ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _autoCalModal = new AutoCalModal();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the auto-calibration wizard for a color-strip source.
|
||||||
|
*
|
||||||
|
* Called from calibration.ts "Auto-calibrate" button.
|
||||||
|
*
|
||||||
|
* @param cssId The color-strip source ID to calibrate.
|
||||||
|
* @param deviceId Optional pre-selected device; if omitted, the device picker
|
||||||
|
* step is shown.
|
||||||
|
*/
|
||||||
|
export async function showAutoCalibration(cssId: string, deviceId?: string): Promise<void> {
|
||||||
|
const container = document.getElementById('autocal-step-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Store context on the hidden inputs for reference
|
||||||
|
const cssIdInput = document.getElementById('autocal-modal-css-id') as HTMLInputElement | null;
|
||||||
|
const deviceIdInput = document.getElementById('autocal-modal-device-id') as HTMLInputElement | null;
|
||||||
|
if (cssIdInput) cssIdInput.value = cssId;
|
||||||
|
if (deviceIdInput) deviceIdInput.value = deviceId || '';
|
||||||
|
|
||||||
|
_autoCalModal.open();
|
||||||
|
_autoCalModal.snapshot();
|
||||||
|
|
||||||
|
await mountAutoCalibration({
|
||||||
|
container,
|
||||||
|
cssId,
|
||||||
|
deviceId,
|
||||||
|
onComplete: () => {
|
||||||
|
_autoCalModal.forceClose();
|
||||||
|
// Reload calibration view if open
|
||||||
|
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
_autoCalModal.forceClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the auto-calibration modal (stops session). */
|
||||||
|
export async function closeAutoCalModal(): Promise<void> {
|
||||||
|
await _autoCalModal.close();
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
|
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
|
||||||
scenePresetsCache, _cachedHASources, haSourcesCache,
|
scenePresetsCache, scenePlaylistsCache, _cachedHASources, haSourcesCache,
|
||||||
_cachedValueSources, valueSourcesCache,
|
_cachedValueSources, valueSourcesCache,
|
||||||
getHAEntityFriendlyName, setHAEntityNames,
|
getHAEntityFriendlyName, setHAEntityNames,
|
||||||
} from '../core/state.ts';
|
} from '../core/state.ts';
|
||||||
@@ -18,7 +18,7 @@ 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 { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
||||||
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE } from '../core/icons.ts';
|
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE, ICON_LIST_CHECKS } 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 type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
||||||
@@ -29,9 +29,10 @@ import { getBaseOrigin } from './settings.ts';
|
|||||||
import { IconSelect } from '../core/icon-select.ts';
|
import { IconSelect } from '../core/icon-select.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import { enhanceMiniSelects } from '../core/mini-select.ts';
|
import { enhanceMiniSelects } from '../core/mini-select.ts';
|
||||||
import { attachProcessPicker } from '../core/process-picker.ts';
|
import { attachProcessPicker, attachAppPicker } from '../core/process-picker.ts';
|
||||||
import { TreeNav } from '../core/tree-nav.ts';
|
import { TreeNav } from '../core/tree-nav.ts';
|
||||||
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
||||||
|
import { csPlaylists, createPlaylistCard, initPlaylistDelegation } from './scene-playlists.ts';
|
||||||
import type { Automation, RuleType } from '../types.ts';
|
import type { Automation, RuleType } from '../types.ts';
|
||||||
|
|
||||||
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
|
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
|
||||||
@@ -215,6 +216,28 @@ document.addEventListener('server:automation_state_changed', () => {
|
|||||||
if (apiKey && isActiveTab('automations')) loadAutomations();
|
if (apiKey && isActiveTab('automations')) loadAutomations();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Platform capability signal from `/system/info` — drives the application-rule
|
||||||
|
* editor (process picker + match types on desktop vs. app picker + foreground-only
|
||||||
|
* on Android) and the Usage-Access banner. Fetched once and cached. */
|
||||||
|
interface PlatformInfo {
|
||||||
|
is_android: boolean;
|
||||||
|
app_match_kind: 'process' | 'package';
|
||||||
|
usage_access_granted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _platformInfo: PlatformInfo | null = null;
|
||||||
|
|
||||||
|
async function ensurePlatformInfo(): Promise<PlatformInfo> {
|
||||||
|
if (_platformInfo) return _platformInfo;
|
||||||
|
try {
|
||||||
|
_platformInfo = await apiGet<PlatformInfo>('/system/info');
|
||||||
|
} catch {
|
||||||
|
// Default to desktop semantics if the signal can't be fetched.
|
||||||
|
_platformInfo = { is_android: false, app_match_kind: 'process', usage_access_granted: true };
|
||||||
|
}
|
||||||
|
return _platformInfo;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadAutomations() {
|
export async function loadAutomations() {
|
||||||
if (_automationsLoading) return;
|
if (_automationsLoading) return;
|
||||||
set_automationsLoading(true);
|
set_automationsLoading(true);
|
||||||
@@ -222,10 +245,15 @@ export async function loadAutomations() {
|
|||||||
if (!container) { set_automationsLoading(false); return; }
|
if (!container) { set_automationsLoading(false); return; }
|
||||||
if (!csAutomations.isMounted()) setTabRefreshing('automations-content', true);
|
if (!csAutomations.isMounted()) setTabRefreshing('automations-content', true);
|
||||||
|
|
||||||
|
// Prime the platform signal so the editor renders the right app source +
|
||||||
|
// match semantics without an async hop when a rule row is expanded.
|
||||||
|
void ensurePlatformInfo();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [automations, scenes] = await Promise.all([
|
const [automations, scenes] = await Promise.all([
|
||||||
automationsCacheObj.fetch(),
|
automationsCacheObj.fetch(),
|
||||||
scenePresetsCache.fetch(),
|
scenePresetsCache.fetch(),
|
||||||
|
scenePlaylistsCache.fetch(),
|
||||||
haSourcesCache.fetch(),
|
haSourcesCache.fetch(),
|
||||||
valueSourcesCache.fetch(),
|
valueSourcesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
@@ -265,38 +293,45 @@ function renderAutomations(automations: any, sceneMap: any) {
|
|||||||
|
|
||||||
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
|
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
|
||||||
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
|
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
|
||||||
|
const playlistItems = csPlaylists.applySortOrder(scenePlaylistsCache.data.map(p => ({ key: p.id, html: createPlaylistCard(p) })));
|
||||||
|
|
||||||
const activeTab = getActiveSubTab('automations')!;
|
const activeTab = getActiveSubTab('automations')!;
|
||||||
|
|
||||||
const treeItems = [
|
const treeItems = [
|
||||||
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
|
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
|
||||||
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length },
|
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length },
|
||||||
|
{ key: 'playlists', icon: ICON_LIST_CHECKS, titleKey: 'playlists.title', count: scenePlaylistsCache.data.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (csAutomations.isMounted()) {
|
if (csAutomations.isMounted()) {
|
||||||
_automationsTree.updateCounts({
|
_automationsTree.updateCounts({
|
||||||
automations: automations.length,
|
automations: automations.length,
|
||||||
scenes: scenePresetsCache.data.length,
|
scenes: scenePresetsCache.data.length,
|
||||||
|
playlists: scenePlaylistsCache.data.length,
|
||||||
});
|
});
|
||||||
csAutomations.reconcile(autoItems);
|
csAutomations.reconcile(autoItems);
|
||||||
csScenes.reconcile(sceneItems);
|
csScenes.reconcile(sceneItems);
|
||||||
|
csPlaylists.reconcile(playlistItems);
|
||||||
} else {
|
} else {
|
||||||
const panels = [
|
const panels = [
|
||||||
{ key: 'automations', html: csAutomations.render(autoItems) },
|
{ key: 'automations', html: csAutomations.render(autoItems) },
|
||||||
{ key: 'scenes', html: csScenes.render(sceneItems) },
|
{ key: 'scenes', html: csScenes.render(sceneItems) },
|
||||||
|
{ key: 'playlists', html: csPlaylists.render(playlistItems) },
|
||||||
].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join('');
|
].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join('');
|
||||||
|
|
||||||
container!.innerHTML = panels;
|
container!.innerHTML = panels;
|
||||||
CardSection.bindAll([csAutomations, csScenes]);
|
CardSection.bindAll([csAutomations, csScenes, csPlaylists]);
|
||||||
|
|
||||||
// Event delegation for scene preset card actions
|
// Event delegation for scene preset + playlist card actions
|
||||||
initScenePresetDelegation(container!);
|
initScenePresetDelegation(container!);
|
||||||
|
initPlaylistDelegation(container!);
|
||||||
|
|
||||||
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||||
_automationsTree.update(treeItems, activeTab);
|
_automationsTree.update(treeItems, activeTab);
|
||||||
_automationsTree.observeSections('automations-content', {
|
_automationsTree.observeSections('automations-content', {
|
||||||
'automations': 'automations',
|
'automations': 'automations',
|
||||||
'scenes': 'scenes',
|
'scenes': 'scenes',
|
||||||
|
'playlists': 'playlists',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,11 +349,15 @@ const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
|
|||||||
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
|
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
|
||||||
return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
|
return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
|
||||||
},
|
},
|
||||||
time_of_day: (c) => ({
|
time_of_day: (c) => {
|
||||||
icon: ICON_CLOCK,
|
const days: number[] = Array.isArray(c.days_of_week) ? c.days_of_week : [];
|
||||||
text: `${c.start_time || '00:00'} – ${c.end_time || '23:59'}`,
|
let text = `${c.start_time || '00:00'} – ${c.end_time || '23:59'}`;
|
||||||
title: t('automations.rule.time_of_day'),
|
if (days.length && days.length < 7) {
|
||||||
}),
|
text += ` · ${[...days].sort((a, b) => a - b).map((d) => t('weekday.short.' + d)).join(' ')}`;
|
||||||
|
}
|
||||||
|
if (c.timezone) text += ` · ${c.timezone}`;
|
||||||
|
return { icon: ICON_CLOCK, text, title: t('automations.rule.time_of_day') };
|
||||||
|
},
|
||||||
system_idle: (c) => {
|
system_idle: (c) => {
|
||||||
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
|
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
|
||||||
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
|
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
|
||||||
@@ -559,6 +598,11 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
|
|||||||
errorEl.style.display = 'none';
|
errorEl.style.display = 'none';
|
||||||
ruleList!.innerHTML = '';
|
ruleList!.innerHTML = '';
|
||||||
|
|
||||||
|
// Ensure the platform signal is loaded before rendering rule rows so the
|
||||||
|
// application rule picks the right app source + match semantics. The
|
||||||
|
// automations tab primes this, but the graph editor opens this directly.
|
||||||
|
await ensurePlatformInfo();
|
||||||
|
|
||||||
_ensureRuleLogicIconSelect();
|
_ensureRuleLogicIconSelect();
|
||||||
_ensureDeactivationModeIconSelect();
|
_ensureDeactivationModeIconSelect();
|
||||||
|
|
||||||
@@ -847,6 +891,11 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
|
|||||||
const [sh, sm] = startTime.split(':').map(Number);
|
const [sh, sm] = startTime.split(':').map(Number);
|
||||||
const [eh, em] = endTime.split(':').map(Number);
|
const [eh, em] = endTime.split(':').map(Number);
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const days: number[] = Array.isArray(data.days_of_week) ? data.days_of_week : [];
|
||||||
|
const tz: string = data.timezone || '';
|
||||||
|
const dayChips = [0, 1, 2, 3, 4, 5, 6]
|
||||||
|
.map((d) => `<button type="button" class="weekday-chip${days.includes(d) ? ' active' : ''}" data-day="${d}">${t('weekday.short.' + d)}</button>`)
|
||||||
|
.join('');
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="rule-fields">
|
<div class="rule-fields">
|
||||||
<input type="hidden" class="rule-start-time" value="${startTime}">
|
<input type="hidden" class="rule-start-time" value="${startTime}">
|
||||||
@@ -870,9 +919,21 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rule-weekday-block">
|
||||||
|
<span class="rule-field-label">${t('automations.rule.time_of_day.days')}</span>
|
||||||
|
<div class="weekday-chips">${dayChips}</div>
|
||||||
|
<small class="rule-hint-desc">${t('automations.rule.time_of_day.days_hint')}</small>
|
||||||
|
</div>
|
||||||
|
<div class="rule-tz-block">
|
||||||
|
<label class="rule-field-label">${t('automations.rule.time_of_day.timezone')}</label>
|
||||||
|
<input type="text" class="rule-timezone" placeholder="${t('automations.rule.time_of_day.timezone.placeholder')}" value="${tz}">
|
||||||
|
</div>
|
||||||
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
|
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
|
||||||
</div>`;
|
</div>`;
|
||||||
_wireTimeRangePicker(container);
|
_wireTimeRangePicker(container);
|
||||||
|
container.querySelectorAll('.weekday-chip').forEach((chip) => {
|
||||||
|
chip.addEventListener('click', () => chip.classList.toggle('active'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderSystemIdleFields(container: HTMLElement, data: any): void {
|
function _renderSystemIdleFields(container: HTMLElement, data: any): void {
|
||||||
@@ -1129,6 +1190,33 @@ function _renderWebhookFields(container: HTMLElement, data: any): void {
|
|||||||
|
|
||||||
function _renderApplicationFields(container: HTMLElement, data: any): void {
|
function _renderApplicationFields(container: HTMLElement, data: any): void {
|
||||||
const appsValue = (data.apps || []).join('\n');
|
const appsValue = (data.apps || []).join('\n');
|
||||||
|
|
||||||
|
// On Android there is exactly one obtainable signal — the foreground app —
|
||||||
|
// so the match-type selector is hidden (match_type is forced to "topmost" by
|
||||||
|
// the collector) and the app list comes from launchable apps (package names)
|
||||||
|
// rather than running processes (process names).
|
||||||
|
if (_platformInfo?.is_android) {
|
||||||
|
const banner = _platformInfo.usage_access_granted
|
||||||
|
? ''
|
||||||
|
: `<div class="rule-usage-warning">${t('automations.rule.application.usage_access_required')}</div>`;
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="rule-fields">
|
||||||
|
${banner}
|
||||||
|
<div class="rule-field">
|
||||||
|
<div class="rule-apps-header">
|
||||||
|
<label>${t('automations.rule.application.apps')}</label>
|
||||||
|
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.rule.application.browse')}">${ICON_SEARCH}</button>
|
||||||
|
</div>
|
||||||
|
<textarea class="rule-apps" rows="3" placeholder="com.netflix.mediaclient com.android.chrome">${escapeHtml(appsValue)}</textarea>
|
||||||
|
<small class="rule-hint-desc">${t('automations.rule.application.apps.hint_android')}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const textarea = container.querySelector('.rule-apps') as HTMLTextAreaElement;
|
||||||
|
attachAppPicker(container, textarea);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const matchType = data.match_type || 'running';
|
const matchType = data.match_type || 'running';
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="rule-fields">
|
<div class="rule-fields">
|
||||||
@@ -1256,6 +1344,9 @@ const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
|
|||||||
rule_type: 'time_of_day',
|
rule_type: 'time_of_day',
|
||||||
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
|
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
|
||||||
end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59',
|
end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59',
|
||||||
|
days_of_week: Array.from(row.querySelectorAll('.weekday-chip.active'))
|
||||||
|
.map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)),
|
||||||
|
timezone: ((row.querySelector('.rule-timezone') as HTMLInputElement)?.value || '').trim(),
|
||||||
}),
|
}),
|
||||||
system_idle: (row) => ({
|
system_idle: (row) => ({
|
||||||
rule_type: 'system_idle',
|
rule_type: 'system_idle',
|
||||||
@@ -1299,7 +1390,10 @@ const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
|
|||||||
return r;
|
return r;
|
||||||
},
|
},
|
||||||
application: (row) => {
|
application: (row) => {
|
||||||
const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value;
|
// On Android the match-type selector is hidden (only the foreground app is
|
||||||
|
// detectable), so default to "topmost" when the select isn't present.
|
||||||
|
const matchSel = row.querySelector('.rule-match-type') as HTMLSelectElement | null;
|
||||||
|
const matchType = matchSel ? matchSel.value : 'topmost';
|
||||||
const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim();
|
const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim();
|
||||||
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
|
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
|
||||||
return { rule_type: 'application', apps, match_type: matchType };
|
return { rule_type: 'application', apps, match_type: matchType };
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW, ICON_DEVICE } from '../c
|
|||||||
import { renderDeviceIcon } from '../core/device-icons.ts';
|
import { renderDeviceIcon } from '../core/device-icons.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import type { Calibration } from '../types.ts';
|
import type { Calibration } from '../types.ts';
|
||||||
|
import { showAutoCalibration } from './auto-calibration.ts';
|
||||||
|
|
||||||
let _calTestDeviceEntitySelect: EntitySelect | null = null;
|
let _calTestDeviceEntitySelect: EntitySelect | null = null;
|
||||||
let _calTestDeviceList: any[] = [];
|
let _calTestDeviceList: any[] = [];
|
||||||
@@ -41,6 +42,10 @@ class CalibrationModal extends Modal {
|
|||||||
skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
|
skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
|
||||||
skip_end: (this.$('cal-skip-end') as HTMLInputElement).value,
|
skip_end: (this.$('cal-skip-end') as HTMLInputElement).value,
|
||||||
border_width: (this.$('cal-border-width') as HTMLInputElement).value,
|
border_width: (this.$('cal-border-width') as HTMLInputElement).value,
|
||||||
|
roi_x: (this.$('cal-roi-x') as HTMLInputElement)?.value,
|
||||||
|
roi_y: (this.$('cal-roi-y') as HTMLInputElement)?.value,
|
||||||
|
roi_width: (this.$('cal-roi-width') as HTMLInputElement)?.value,
|
||||||
|
roi_height: (this.$('cal-roi-height') as HTMLInputElement)?.value,
|
||||||
led_count: (this.$('cal-css-led-count') as HTMLInputElement).value,
|
led_count: (this.$('cal-css-led-count') as HTMLInputElement).value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -173,6 +178,7 @@ export async function showCalibration(deviceId: any) {
|
|||||||
updateOffsetSkipLock();
|
updateOffsetSkipLock();
|
||||||
|
|
||||||
(document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
|
(document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
|
||||||
|
_populateRoiInputs(calibration);
|
||||||
|
|
||||||
window.edgeSpans = {
|
window.edgeSpans = {
|
||||||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||||||
@@ -228,6 +234,33 @@ export async function closeCalibrationModal() {
|
|||||||
calibModal.close();
|
calibModal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the auto-calibration wizard for the currently-open calibration modal.
|
||||||
|
*
|
||||||
|
* Reads the CSS ID or device ID from the active calibration modal context,
|
||||||
|
* then launches the auto-cal modal. In CSS mode the test device (if selected)
|
||||||
|
* is offered as the default device; in device mode the device is known.
|
||||||
|
*/
|
||||||
|
export async function openAutoCalFromCalibration(): Promise<void> {
|
||||||
|
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value || '';
|
||||||
|
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement)?.value || '';
|
||||||
|
|
||||||
|
if (cssId) {
|
||||||
|
// CSS calibration mode: try the already-selected test device as default
|
||||||
|
const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement | null;
|
||||||
|
const testDevice = testDeviceSelect?.value || undefined;
|
||||||
|
// Close the calibration modal so the auto-cal modal has focus
|
||||||
|
calibModal.forceClose();
|
||||||
|
await showAutoCalibration(cssId, testDevice);
|
||||||
|
} else if (deviceId) {
|
||||||
|
// Device calibration mode: not directly supported by auto-cal (which
|
||||||
|
// writes to a CSS), so show a toast explaining the constraint.
|
||||||
|
showToast(t('autocal.error.css_required'), 'error');
|
||||||
|
} else {
|
||||||
|
showToast(t('calibration.error.load_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── CSS Calibration support ──────────────────────────────────── */
|
/* ── CSS Calibration support ──────────────────────────────────── */
|
||||||
|
|
||||||
export async function showCSSCalibration(cssId: any) {
|
export async function showCSSCalibration(cssId: any) {
|
||||||
@@ -319,6 +352,7 @@ export async function showCSSCalibration(cssId: any) {
|
|||||||
updateOffsetSkipLock();
|
updateOffsetSkipLock();
|
||||||
|
|
||||||
(document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
|
(document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
|
||||||
|
_populateRoiInputs(calibration);
|
||||||
|
|
||||||
window.edgeSpans = {
|
window.edgeSpans = {
|
||||||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||||||
@@ -882,6 +916,20 @@ async function clearTestMode(deviceId: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Populate the ROI percentage inputs from a calibration object (fractions 0..1). */
|
||||||
|
function _populateRoiInputs(calibration: any): void {
|
||||||
|
const pct = (v: number | undefined, fallback: number) =>
|
||||||
|
String(Math.round((v ?? fallback) * 100));
|
||||||
|
const set = (id: string, v: string) => {
|
||||||
|
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||||
|
if (el) el.value = v;
|
||||||
|
};
|
||||||
|
set('cal-roi-x', pct(calibration.roi_x, 0));
|
||||||
|
set('cal-roi-y', pct(calibration.roi_y, 0));
|
||||||
|
set('cal-roi-width', pct(calibration.roi_width, 1));
|
||||||
|
set('cal-roi-height', pct(calibration.roi_height, 1));
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveCalibration() {
|
export async function saveCalibration() {
|
||||||
const cssMode = _isCSS();
|
const cssMode = _isCSS();
|
||||||
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
|
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
|
||||||
@@ -936,6 +984,10 @@ export async function saveCalibration() {
|
|||||||
skip_leds_start: parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'),
|
skip_leds_start: parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'),
|
||||||
skip_leds_end: parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0'),
|
skip_leds_end: parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0'),
|
||||||
border_width: parseInt((document.getElementById('cal-border-width') as HTMLInputElement).value) || 10,
|
border_width: parseInt((document.getElementById('cal-border-width') as HTMLInputElement).value) || 10,
|
||||||
|
roi_x: (parseFloat((document.getElementById('cal-roi-x') as HTMLInputElement).value) || 0) / 100,
|
||||||
|
roi_y: (parseFloat((document.getElementById('cal-roi-y') as HTMLInputElement).value) || 0) / 100,
|
||||||
|
roi_width: (parseFloat((document.getElementById('cal-roi-width') as HTMLInputElement).value) || 100) / 100,
|
||||||
|
roi_height: (parseFloat((document.getElementById('cal-roi-height') as HTMLInputElement).value) || 100) / 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const REORDERABLE_SECTIONS: readonly string[] = [
|
|||||||
'integrations',
|
'integrations',
|
||||||
'automations',
|
'automations',
|
||||||
'scenes',
|
'scenes',
|
||||||
|
'playlists',
|
||||||
'sync-clocks',
|
'sync-clocks',
|
||||||
'targets',
|
'targets',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -69,6 +70,7 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
|
|||||||
integrations: 'dashboard.section.integrations',
|
integrations: 'dashboard.section.integrations',
|
||||||
automations: 'dashboard.section.automations',
|
automations: 'dashboard.section.automations',
|
||||||
scenes: 'dashboard.section.scenes',
|
scenes: 'dashboard.section.scenes',
|
||||||
|
playlists: 'dashboard.section.playlists',
|
||||||
'sync-clocks': 'dashboard.section.sync_clocks',
|
'sync-clocks': 'dashboard.section.sync_clocks',
|
||||||
targets: 'dashboard.section.targets',
|
targets: 'dashboard.section.targets',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type SectionKey =
|
|||||||
| 'integrations'
|
| 'integrations'
|
||||||
| 'automations'
|
| 'automations'
|
||||||
| 'scenes'
|
| 'scenes'
|
||||||
|
| 'playlists'
|
||||||
| 'sync-clocks'
|
| 'sync-clocks'
|
||||||
| 'targets'
|
| 'targets'
|
||||||
// Reserved registry keys for v1.1+ (so saved layouts forward-compat).
|
// Reserved registry keys for v1.1+ (so saved layouts forward-compat).
|
||||||
@@ -151,6 +152,7 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
|
|||||||
_defaultSection('integrations'),
|
_defaultSection('integrations'),
|
||||||
_defaultSection('automations'),
|
_defaultSection('automations'),
|
||||||
_defaultSection('scenes'),
|
_defaultSection('scenes'),
|
||||||
|
_defaultSection('playlists'),
|
||||||
_defaultSection('sync-clocks'),
|
_defaultSection('sync-clocks'),
|
||||||
_defaultSection('targets'),
|
_defaultSection('targets'),
|
||||||
],
|
],
|
||||||
@@ -192,7 +194,7 @@ export const PRESETS: Record<string, () => DashboardLayoutV1> = {
|
|||||||
|
|
||||||
operator: () => {
|
operator: () => {
|
||||||
const l = _clone(DEFAULT_LAYOUT, 'operator');
|
const l = _clone(DEFAULT_LAYOUT, 'operator');
|
||||||
const hide = new Set(['integrations', 'scenes', 'sync-clocks']);
|
const hide = new Set(['integrations', 'scenes', 'playlists', 'sync-clocks']);
|
||||||
l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s);
|
l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s);
|
||||||
l.sections = l.sections.map(s => ({ ...s, density: 'compact' }));
|
l.sections = l.sections.map(s => ({ ...s, density: 'compact' }));
|
||||||
return l;
|
return l;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS,
|
ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS,
|
||||||
} from '../core/icons.ts';
|
} from '../core/icons.ts';
|
||||||
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
|
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
|
||||||
|
import { loadPlaylists } from './scene-playlists.ts';
|
||||||
import { cardColorStyle } from '../core/card-colors.ts';
|
import { cardColorStyle } from '../core/card-colors.ts';
|
||||||
import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
||||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||||
@@ -55,7 +56,7 @@ function _mountDashboardCardModeToggles(): void {
|
|||||||
_dashboardModeTeardowns.set(surface, teardown);
|
_dashboardModeTeardowns.set(surface, teardown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';
|
import type { Device, OutputTarget, ColorStripSource, ScenePreset, ScenePlaylist, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';
|
||||||
|
|
||||||
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
||||||
const MAX_FPS_SAMPLES = 120;
|
const MAX_FPS_SAMPLES = 120;
|
||||||
@@ -529,6 +530,49 @@ function renderDashboardSyncClock(clock: SyncClock): string {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compact dashboard card for a scene playlist. Mirrors the sync-clock card:
|
||||||
|
* running state drives the LED / patch indicator and the Start↔Stop toggle.
|
||||||
|
* Only one playlist cycles at a time, so the running one is sorted to the
|
||||||
|
* front by the caller. Uses the window-exposed start/stop handlers (same as
|
||||||
|
* the Automations-tab cards), so no extra delegation wiring is needed. */
|
||||||
|
function renderDashboardPlaylist(playlist: ScenePlaylist): string {
|
||||||
|
const running = playlist.is_running === true;
|
||||||
|
const itemCount = (playlist.items || []).length;
|
||||||
|
const metaParts = [
|
||||||
|
itemCount > 0 ? `${itemCount} ${t('playlists.scenes_count')}` : null,
|
||||||
|
playlist.description ? escapeHtml(playlist.description) : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
const short = (playlist.id || '').replace(/^playlist_/i, '').slice(-2).toUpperCase() || 'PL';
|
||||||
|
const ledCls = running ? 'led on blink' : 'led';
|
||||||
|
const patchLabel = running ? t('playlists.status.playing') : t('playlists.status.stopped');
|
||||||
|
const patchLive = running ? ' is-live' : '';
|
||||||
|
const btnCls = running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
|
||||||
|
const btnLabel = running ? (t('playlists.action.stop') || 'Stop') : (t('playlists.action.start') || 'Start');
|
||||||
|
const btnTitle = running ? t('playlists.stop') : t('playlists.start');
|
||||||
|
const toggleAction = running ? 'stopScenePlaylist()' : `startScenePlaylist('${playlist.id}')`;
|
||||||
|
|
||||||
|
const plStyle = cardColorStyle(playlist.id);
|
||||||
|
const iconPlate = _dashboardIconPlate(playlist as any);
|
||||||
|
const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
|
||||||
|
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${running ? 'is-running' : ''}" data-playlist-id="${playlist.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations','playlists','playlists','data-playlist-id','${playlist.id}')}"${plStyle ? ` style="${plStyle}"` : ''}>
|
||||||
|
<div class="${headCls}">
|
||||||
|
${iconPlate}
|
||||||
|
<div class="mod-id">
|
||||||
|
<span class="mod-badge">PL · ${escapeHtml(short)}</span>
|
||||||
|
<div class="mod-name"><span>${escapeHtml(playlist.name)}</span></div>
|
||||||
|
${metaParts.length ? `<div class="mod-meta">${metaParts.join(' · ')}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="mod-leds" aria-hidden="true">
|
||||||
|
<span class="${ledCls}"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mod-foot">
|
||||||
|
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
|
||||||
|
<button class="${btnCls}" onclick="event.stopPropagation(); ${toggleAction}" title="${btnTitle}">${running ? ICON_PAUSE : ICON_START} <span>${btnLabel}</span></button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
|
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||||
/** Called from the transport-bar poll cycler (and any legacy callers
|
/** Called from the transport-bar poll cycler (and any legacy callers
|
||||||
* that might still reference `window.changeDashboardPollInterval`). */
|
* that might still reference `window.changeDashboardPollInterval`). */
|
||||||
@@ -644,7 +688,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Fire all requests in a single batch to avoid sequential RTTs
|
// Fire all requests in a single batch to avoid sequential RTTs
|
||||||
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([
|
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, playlists, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([
|
||||||
outputTargetsCache.fetch().catch((): any[] => []),
|
outputTargetsCache.fetch().catch((): any[] => []),
|
||||||
fetchWithAuth('/automations').catch(() => null),
|
fetchWithAuth('/automations').catch(() => null),
|
||||||
devicesCache.fetch().catch((): any[] => []),
|
devicesCache.fetch().catch((): any[] => []),
|
||||||
@@ -652,6 +696,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
||||||
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
||||||
loadScenePresets(),
|
loadScenePresets(),
|
||||||
|
loadPlaylists().catch((): ScenePlaylist[] => []),
|
||||||
fetchWithAuth('/sync-clocks').catch(() => null),
|
fetchWithAuth('/sync-clocks').catch(() => null),
|
||||||
fetchWithAuth('/home-assistant/status').catch(() => null),
|
fetchWithAuth('/home-assistant/status').catch(() => null),
|
||||||
fetchWithAuth('/mqtt/status').catch(() => null),
|
fetchWithAuth('/mqtt/status').catch(() => null),
|
||||||
@@ -717,7 +762,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
// Build dynamic HTML (targets, automations)
|
// Build dynamic HTML (targets, automations)
|
||||||
let dynamicHtml = '';
|
let dynamicHtml = '';
|
||||||
let runningIds: any[] = [];
|
let runningIds: any[] = [];
|
||||||
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) {
|
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && playlists.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) {
|
||||||
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
const enriched = targets.map(target => ({
|
const enriched = targets.map(target => ({
|
||||||
@@ -906,6 +951,19 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scene Playlists section — running playlist (if any) sorts first.
|
||||||
|
if (playlists.length > 0) {
|
||||||
|
const ordered = [...playlists].sort(
|
||||||
|
(a, b) => Number(b.is_running === true) - Number(a.is_running === true),
|
||||||
|
);
|
||||||
|
const playlistCards = ordered.map(p => renderDashboardPlaylist(p)).join('');
|
||||||
|
const playlistGrid = `<div class="dashboard-autostart-grid">${playlistCards}</div>`;
|
||||||
|
sectionFragments['playlists'] = `<div class="dashboard-section" data-section="playlists">
|
||||||
|
${_sectionHeader('playlists', t('dashboard.section.playlists'), playlists.length, '', 'dashboard-playlists')}
|
||||||
|
${_sectionContent('playlists', playlistGrid)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Sync Clocks section
|
// Sync Clocks section
|
||||||
if (syncClocks.length > 0) {
|
if (syncClocks.length > 0) {
|
||||||
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
|
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { apiGet } from '../core/api-client.ts';
|
|||||||
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
|
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
|
||||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge, validateConnection, getDependents } from '../core/graph-connections.ts';
|
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, updateListSlotConnection, duplicateSubgraph, isEditableEdge, validateConnection, getDependents, checkSchemaDrift } from '../core/graph-connections.ts';
|
||||||
import { showTypePicker } from '../core/icon-select.ts';
|
import { showTypePicker } from '../core/icon-select.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.ts';
|
import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.ts';
|
||||||
@@ -358,6 +358,10 @@ export async function loadGraphEditor(): Promise<void> {
|
|||||||
if (gc) gc.appendChild(overlay);
|
if (gc) gc.appendChild(overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dev safety net: warn once if the frontend connection map drifts from the
|
||||||
|
// backend schema (fire-and-forget; never blocks the graph).
|
||||||
|
void checkSchemaDrift();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entities = await _fetchAllEntities();
|
const entities = await _fetchAllEntities();
|
||||||
// Index raw entities by id for subtype-safe bindable-slot resolution.
|
// Index raw entities by id for subtype-safe bindable-slot resolution.
|
||||||
@@ -805,6 +809,40 @@ export async function graphExportTopology(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate the current node selection server-side. The backend clones the
|
||||||
|
* value / colour-strip sources in the selection (full config preserved, no
|
||||||
|
* secrets crossing the wire), rewires references that point *within* the
|
||||||
|
* selection, and shares everything else. The graph then reloads with the new
|
||||||
|
* cluster selected.
|
||||||
|
*/
|
||||||
|
export async function graphDuplicateSelection(): Promise<void> {
|
||||||
|
const ids = [..._selectedIds];
|
||||||
|
if (ids.length === 0) {
|
||||||
|
showToast(t('graph.duplicate_none'), 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await duplicateSubgraph(ids);
|
||||||
|
if (!res) {
|
||||||
|
showToast(t('graph.duplicate_failed'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newIds = Object.values(res.id_map || {});
|
||||||
|
if (newIds.length === 0) {
|
||||||
|
showToast(t('graph.duplicate_none_eligible'), 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadGraphEditor();
|
||||||
|
_selectedIds = new Set(newIds);
|
||||||
|
const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null;
|
||||||
|
if (nodeGroup) updateSelection(nodeGroup, _selectedIds);
|
||||||
|
const hasWarn = !!res.warnings?.length;
|
||||||
|
showToast(
|
||||||
|
t(hasWarn ? 'graph.duplicate_done_warn' : 'graph.duplicate_done', { count: newIds.length }),
|
||||||
|
hasWarn ? 'info' : 'success',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Frame and highlight all nodes flagged with configuration issues. */
|
/** Frame and highlight all nodes flagged with configuration issues. */
|
||||||
export function graphShowIssues(): void {
|
export function graphShowIssues(): void {
|
||||||
if (_issueIds.size === 0 || !_nodeMap || !_canvas) {
|
if (_issueIds.size === 0 || !_nodeMap || !_canvas) {
|
||||||
@@ -1353,6 +1391,10 @@ function _graphHTML(): string {
|
|||||||
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
<span>${t('graph.export')}</span>
|
<span>${t('graph.export')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="graphDuplicateSelection(); closeToolbarOverflow()">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
<span>${t('graph.duplicate')}</span>
|
||||||
|
</button>
|
||||||
<div class="graph-overflow-sep"></div>
|
<div class="graph-overflow-sep"></div>
|
||||||
<button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()">
|
<button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()">
|
||||||
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
||||||
@@ -2628,16 +2670,22 @@ function _onConnectPointerUp(e: PointerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep only the bindable slots the target entity actually exposes (subtype-safe)
|
* Keep only the slots the target entity actually exposes (subtype-safe) — a
|
||||||
* — e.g. an "effect" strip offers `intensity`/`scale`, a "picture" strip offers
|
* field is offered iff its first path segment is a key on the serialized entity.
|
||||||
* `smoothing`. Non-bindable matches always pass through.
|
* E.g. an "effect" strip offers `intensity`/`scale`, a "picture" strip offers
|
||||||
|
* `smoothing`; a processed strip offers `input_source_id`. Applies to ALL
|
||||||
|
* matches (bindable and top-level alike); reference fields are always emitted by
|
||||||
|
* `to_dict` so empty slots stay wireable.
|
||||||
*/
|
*/
|
||||||
function _availableMatches<T extends { field: string; bindable?: boolean }>(matches: T[], targetId: string): T[] {
|
function _availableMatches<T extends { field: string }>(matches: T[], targetId: string): T[] {
|
||||||
const ent = _entitiesById.get(targetId);
|
const ent = _entitiesById.get(targetId);
|
||||||
return matches.filter(m => {
|
if (!ent) return matches; // no data (e.g. freshly created) → don't over-filter
|
||||||
if (!m.bindable || !ent) return true;
|
// Offer a field only if the target entity actually exposes its slot (its
|
||||||
return m.field.split('.')[0] in ent;
|
// first path segment is a key on the serialized entity). Reference fields
|
||||||
});
|
// are always emitted by `to_dict` even when empty, so empty slots stay
|
||||||
|
// wireable (B2); subtype-specific fields (e.g. processed-strip
|
||||||
|
// `input_source_id`) are correctly hidden on subtypes that lack them.
|
||||||
|
return matches.filter(m => m.field.split('.')[0] in ent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ask the user which field to wire when a source maps to multiple target fields. */
|
/** Ask the user which field to wire when a source maps to multiple target fields. */
|
||||||
@@ -2841,6 +2889,13 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
|
|||||||
_dismissEdgeContextMenu();
|
_dismissEdgeContextMenu();
|
||||||
|
|
||||||
const field = edgePath.getAttribute('data-field') || '';
|
const field = edgePath.getAttribute('data-field') || '';
|
||||||
|
// List-slot edges (composite layers / mapped zones) aren't editable through
|
||||||
|
// the flat (to, field) path, but each carries its slot index so it can be
|
||||||
|
// re-wired individually.
|
||||||
|
if (edgePath.getAttribute('data-slot-list')) {
|
||||||
|
_showListSlotRewireMenu(edgePath, e, container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
|
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
|
||||||
|
|
||||||
const toId = edgePath.getAttribute('data-to') ?? '';
|
const toId = edgePath.getAttribute('data-to') ?? '';
|
||||||
@@ -2877,6 +2932,73 @@ function _dismissEdgeContextMenu(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-wire menu for a list-slot edge (a composite layer or mapped zone source).
|
||||||
|
* Each such edge carries `data-slot-*`, so we can replace just that one
|
||||||
|
* element's reference and leave its siblings (and its own other settings)
|
||||||
|
* untouched. The picker lists every node of the source's kind; the backend
|
||||||
|
* rejects self-reference / cycles.
|
||||||
|
*/
|
||||||
|
function _showListSlotRewireMenu(edgePath: Element, e: MouseEvent, container: HTMLElement): void {
|
||||||
|
const toId = edgePath.getAttribute('data-to') ?? ''; // composite/mapped owner
|
||||||
|
const fromId = edgePath.getAttribute('data-from') ?? ''; // current source occupant
|
||||||
|
const listField = edgePath.getAttribute('data-slot-list') ?? '';
|
||||||
|
const index = parseInt(edgePath.getAttribute('data-slot-index') ?? '', 10);
|
||||||
|
const refField = edgePath.getAttribute('data-slot-ref') ?? '';
|
||||||
|
const toNode = _nodeMap?.get(toId);
|
||||||
|
if (!toNode || Number.isNaN(index)) return;
|
||||||
|
|
||||||
|
const sourceKind = _nodeMap?.get(fromId)?.kind ?? 'color_strip_source';
|
||||||
|
const candidates = [...(_nodeMap?.values() ?? [])]
|
||||||
|
.filter((n: any) => n.kind === sourceKind && n.id !== toId && n.id !== fromId)
|
||||||
|
.sort((a: any, b: any) => (a.name || a.id).localeCompare(b.name || b.id));
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'graph-edge-menu';
|
||||||
|
menu.style.left = `${e.clientX - container.getBoundingClientRect().left}px`;
|
||||||
|
menu.style.top = `${e.clientY - container.getBoundingClientRect().top}px`;
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'graph-edge-menu-item';
|
||||||
|
btn.textContent = t('graph.rewire');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
_dismissEdgeContextMenu();
|
||||||
|
if (candidates.length === 0) { showToast(t('graph.no_compatible_connection'), 'info'); return; }
|
||||||
|
showTypePicker({
|
||||||
|
title: t('graph.rewire_choose_source'),
|
||||||
|
items: candidates.map((n: any) => ({ value: n.id, icon: _ico(P.link), label: n.name || n.id })),
|
||||||
|
onPick: (newSourceId: string) => { void _doListSlotRewire(toId, toNode.kind, listField, index, refField, newSourceId, fromId); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
menu.appendChild(btn);
|
||||||
|
|
||||||
|
container.querySelector('.graph-container')!.appendChild(menu);
|
||||||
|
_edgeContextMenu = menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-wire one list slot (composite layer / mapped zone) and record an undoable action. */
|
||||||
|
async function _doListSlotRewire(
|
||||||
|
targetId: string, targetKind: string, listField: string, index: number,
|
||||||
|
refField: string, newSourceId: string, prevSourceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (newSourceId === prevSourceId) return;
|
||||||
|
// Pass the expected current occupant so the write refuses if the slot was
|
||||||
|
// reordered/edited out-of-band (positional `index` could otherwise hit the
|
||||||
|
// wrong element). Each step expects the slot to still hold what it replaces.
|
||||||
|
const ok = await updateListSlotConnection(targetId, targetKind, listField, index, refField, newSourceId, prevSourceId);
|
||||||
|
if (ok) {
|
||||||
|
pushUndoAction({
|
||||||
|
label: t('graph.action.rewire'),
|
||||||
|
undo: async () => { if (!(await updateListSlotConnection(targetId, targetKind, listField, index, refField, prevSourceId, newSourceId))) throw new Error(t('graph.connection_failed')); },
|
||||||
|
redo: async () => { if (!(await updateListSlotConnection(targetId, targetKind, listField, index, refField, newSourceId, prevSourceId))) throw new Error(t('graph.connection_failed')); },
|
||||||
|
});
|
||||||
|
showToast(t('graph.connection_updated'), 'success');
|
||||||
|
await loadGraphEditor();
|
||||||
|
} else {
|
||||||
|
showToast(t('graph.connection_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function _detachSelectedEdge(): Promise<void> {
|
async function _detachSelectedEdge(): Promise<void> {
|
||||||
if (!_selectedEdge) return;
|
if (!_selectedEdge) return;
|
||||||
const { from, to, field, targetKind } = _selectedEdge;
|
const { from, to, field, targetKind } = _selectedEdge;
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
/**
|
||||||
|
* Scene Playlists — ordered, timed sequences of scene presets that auto-cycle.
|
||||||
|
* Rendered as a CardSection inside the Automations tab (third sub-tab).
|
||||||
|
*
|
||||||
|
* A playlist activates each referenced scene preset in turn and holds it for
|
||||||
|
* that item's dwell duration, then advances. Only one playlist cycles at a
|
||||||
|
* time; starting one stops any other.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeHtml } from '../core/api.ts';
|
||||||
|
import { apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
||||||
|
import { t } from '../core/i18n.ts';
|
||||||
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
|
import { Modal } from '../core/modal.ts';
|
||||||
|
import { CardSection } from '../core/card-sections.ts';
|
||||||
|
import {
|
||||||
|
ICON_START, ICON_PAUSE, ICON_EDIT, ICON_TRASH, ICON_LINK, ICON_REFRESH, ICON_CLOCK,
|
||||||
|
} from '../core/icons.ts';
|
||||||
|
import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
||||||
|
import { scenePlaylistsCache, scenePresetsCache } from '../core/state.ts';
|
||||||
|
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||||
|
import { wrapCard } from '../core/card-colors.ts';
|
||||||
|
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
|
||||||
|
import { EntityPalette } from '../core/entity-palette.ts';
|
||||||
|
import { navigateToCard } from '../core/navigation.ts';
|
||||||
|
import { isActiveTab } from '../core/tab-registry.ts';
|
||||||
|
import { makeCardIconFields } from '../core/card-icon.ts';
|
||||||
|
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
|
||||||
|
import type { ScenePlaylist, ScenePreset } from '../types.ts';
|
||||||
|
|
||||||
|
const DEFAULT_ITEM_DURATION = 30;
|
||||||
|
const MIN_ITEM_DURATION = 1;
|
||||||
|
const SCENE_DOT = '<span class="scene-color-dot" style="background:#4fc3f7"></span>';
|
||||||
|
|
||||||
|
registerIconEntityType('scene_playlist', makeSimpleIconAdapter<ScenePlaylist>({
|
||||||
|
cache: scenePlaylistsCache,
|
||||||
|
endpointPrefix: '/scene-playlists',
|
||||||
|
reload: async () => {
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
if (typeof window.loadAutomations === 'function') {
|
||||||
|
await window.loadAutomations();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
typeLabelKey: 'device.icon.entity.scene_playlist',
|
||||||
|
typeLabelFallback: 'Playlist',
|
||||||
|
cardSelectors: (id) => [`[data-playlist-id="${CSS.escape(id)}"]`],
|
||||||
|
}));
|
||||||
|
|
||||||
|
let _editingId: string | null = null;
|
||||||
|
let _playlistTagsInput: TagInput | null = null;
|
||||||
|
let _presetMap: Record<string, ScenePreset> = {};
|
||||||
|
|
||||||
|
// ── Scene-preset lookup helpers ──
|
||||||
|
|
||||||
|
async function _primePresets(): Promise<void> {
|
||||||
|
const presets = await scenePresetsCache.fetch().catch((): ScenePreset[] => []);
|
||||||
|
_presetMap = {};
|
||||||
|
for (const p of presets) _presetMap[p.id] = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _presetName(presetId: string): string {
|
||||||
|
return _presetMap[presetId]?.name || presetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _presetIconSvg(preset: ScenePreset | undefined): string {
|
||||||
|
const svg = preset?.icon ? renderDeviceIconSvg(preset.icon, { size: 18 }) : '';
|
||||||
|
return svg || SCENE_DOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Item row rendering (editor) ──
|
||||||
|
|
||||||
|
function _renderItemRowHtml(presetId: string, duration: number): string {
|
||||||
|
const preset = _presetMap[presetId];
|
||||||
|
const removeLabel = t('common.remove') || 'Remove';
|
||||||
|
const upLabel = t('playlists.item.move_up') || 'Move up';
|
||||||
|
const downLabel = t('playlists.item.move_down') || 'Move down';
|
||||||
|
const missing = preset ? '' : ' playlist-item--missing';
|
||||||
|
return `
|
||||||
|
<div class="playlist-item-icon" aria-hidden="true">${_presetIconSvg(preset)}</div>
|
||||||
|
<div class="playlist-item-id">
|
||||||
|
<span class="playlist-item-name">${escapeHtml(_presetName(presetId))}</span>
|
||||||
|
<span class="playlist-item-type${missing}">${preset ? 'SCN' : (t('playlists.item.missing') || 'MISSING')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-item-duration-wrap">
|
||||||
|
${ICON_CLOCK}
|
||||||
|
<input type="number" class="playlist-item-duration" min="${MIN_ITEM_DURATION}" step="1"
|
||||||
|
value="${Math.max(MIN_ITEM_DURATION, Math.round(duration))}"
|
||||||
|
aria-label="${escapeHtml(t('playlists.item.duration') || 'Seconds')}">
|
||||||
|
<span class="playlist-item-unit">s</span>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-item-actions">
|
||||||
|
<button type="button" class="playlist-item-btn" data-action="playlist-item-up" title="${escapeHtml(upLabel)}" aria-label="${escapeHtml(upLabel)}">↑</button>
|
||||||
|
<button type="button" class="playlist-item-btn" data-action="playlist-item-down" title="${escapeHtml(downLabel)}" aria-label="${escapeHtml(downLabel)}">↓</button>
|
||||||
|
<button type="button" class="playlist-item-btn playlist-item-remove" data-action="playlist-item-remove" title="${escapeHtml(removeLabel)}" aria-label="${escapeHtml(removeLabel)}">${ICON_TRASH}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _appendItemRow(presetId: string, duration: number, listEl: HTMLElement): void {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'playlist-item';
|
||||||
|
item.dataset.presetId = presetId;
|
||||||
|
item.dataset.duration = String(duration);
|
||||||
|
item.innerHTML = _renderItemRowHtml(presetId, duration);
|
||||||
|
listEl.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _readEditorItems(): Array<{ scene_preset_id: string; duration_seconds: number }> {
|
||||||
|
return [...document.querySelectorAll('#playlist-item-list .playlist-item')].map(el => {
|
||||||
|
const row = el as HTMLElement;
|
||||||
|
const input = row.querySelector('.playlist-item-duration') as HTMLInputElement | null;
|
||||||
|
const raw = input ? parseFloat(input.value) : DEFAULT_ITEM_DURATION;
|
||||||
|
const duration = Number.isFinite(raw) && raw >= MIN_ITEM_DURATION ? raw : MIN_ITEM_DURATION;
|
||||||
|
return { scene_preset_id: row.dataset.presetId || '', duration_seconds: duration };
|
||||||
|
}).filter(i => i.scene_preset_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setItemListEmptyHint(listEl: HTMLElement): void {
|
||||||
|
listEl.dataset.empty = t('playlists.items.empty') || 'No scenes yet — add some below';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-name ──
|
||||||
|
|
||||||
|
let _plNameManuallyEdited = false;
|
||||||
|
|
||||||
|
function _autoGeneratePlaylistName(): void {
|
||||||
|
if (_plNameManuallyEdited) return;
|
||||||
|
if ((document.getElementById('playlist-editor-id') as HTMLInputElement).value) return;
|
||||||
|
const count = document.querySelectorAll('#playlist-item-list .playlist-item').length;
|
||||||
|
const label = count > 0
|
||||||
|
? `${t('playlists.title')} · ${count} ${count === 1 ? (t('playlists.scene_one') || 'scene') : (t('playlists.scene_many') || 'scenes')}`
|
||||||
|
: t('playlists.title');
|
||||||
|
(document.getElementById('playlist-editor-name') as HTMLInputElement).value = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlaylistEditorModal extends Modal {
|
||||||
|
constructor() { super('playlist-editor-modal'); }
|
||||||
|
onForceClose() {
|
||||||
|
if (_playlistTagsInput) { _playlistTagsInput.destroy(); _playlistTagsInput = null; }
|
||||||
|
}
|
||||||
|
snapshotValues() {
|
||||||
|
const items = _readEditorItems().map(i => `${i.scene_preset_id}:${i.duration_seconds}`).join(',');
|
||||||
|
return {
|
||||||
|
name: (document.getElementById('playlist-editor-name') as HTMLInputElement).value,
|
||||||
|
description: (document.getElementById('playlist-editor-description') as HTMLInputElement).value,
|
||||||
|
loop: (document.getElementById('playlist-editor-loop') as HTMLInputElement).checked.toString(),
|
||||||
|
shuffle: (document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked.toString(),
|
||||||
|
items,
|
||||||
|
tags: JSON.stringify(_playlistTagsInput ? _playlistTagsInput.getValue() : []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const playlistModal = new PlaylistEditorModal();
|
||||||
|
|
||||||
|
export const csPlaylists = new CardSection('playlists', {
|
||||||
|
titleKey: 'playlists.title',
|
||||||
|
gridClass: 'devices-grid',
|
||||||
|
addCardOnclick: 'openPlaylistEditor()',
|
||||||
|
keyAttr: 'data-playlist-id',
|
||||||
|
emptyKey: 'section.empty.playlists',
|
||||||
|
bulkActions: [{
|
||||||
|
key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete',
|
||||||
|
handler: async (ids) => {
|
||||||
|
const results = await Promise.allSettled(ids.map(id => apiDelete(`/scene-playlists/${id}`)));
|
||||||
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
|
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
|
||||||
|
else showToast(t('playlists.deleted'), 'success');
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
if (window.loadAutomations) window.loadAutomations();
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createPlaylistCard(playlist: ScenePlaylist): string {
|
||||||
|
const itemCount = (playlist.items || []).length;
|
||||||
|
const running = playlist.is_running === true;
|
||||||
|
const updated = playlist.updated_at ? new Date(playlist.updated_at).toLocaleString() : '';
|
||||||
|
const shortId = (playlist.id || '').replace(/^playlist_/i, '').slice(-2).toUpperCase() || 'NA';
|
||||||
|
|
||||||
|
const metaParts: string[] = [];
|
||||||
|
if (itemCount > 0) metaParts.push(`${itemCount} ${t('playlists.scenes_count')}`);
|
||||||
|
if (updated) metaParts.push(updated);
|
||||||
|
const metaHtml = metaParts.length ? metaParts.map(escapeHtml).join(' · ') : undefined;
|
||||||
|
|
||||||
|
const chips: ModChipOpts[] = [];
|
||||||
|
if (playlist.loop) chips.push({ icon: ICON_REFRESH, text: t('playlists.chip.loop'), variant: 'tag' });
|
||||||
|
if (playlist.shuffle) chips.push({ icon: ICON_LINK, text: t('playlists.chip.shuffle'), variant: 'tag' });
|
||||||
|
|
||||||
|
const leds: LedState[] = [running ? 'on' : 'off'];
|
||||||
|
|
||||||
|
const primaryAction = running
|
||||||
|
? {
|
||||||
|
label: t('playlists.action.stop'),
|
||||||
|
icon: ICON_PAUSE,
|
||||||
|
onclick: `stopScenePlaylist()`,
|
||||||
|
title: t('playlists.stop'),
|
||||||
|
variant: 'stop' as const,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: t('playlists.action.start'),
|
||||||
|
icon: ICON_START,
|
||||||
|
onclick: `startScenePlaylist('${playlist.id}')`,
|
||||||
|
title: t('playlists.start'),
|
||||||
|
variant: 'go' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mod: ModCardOpts = {
|
||||||
|
head: {
|
||||||
|
badge: { text: `PL · ${shortId}` },
|
||||||
|
name: playlist.name,
|
||||||
|
metaHtml,
|
||||||
|
leds,
|
||||||
|
...makeCardIconFields('scene_playlist', playlist.id, playlist),
|
||||||
|
menu: {
|
||||||
|
duplicateOnclick: `clonePlaylist('${playlist.id}')`,
|
||||||
|
hideOnclick: `toggleCardHidden('playlists','${playlist.id}')`,
|
||||||
|
deleteOnclick: `deletePlaylist('${playlist.id}')`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
desc: playlist.description || undefined,
|
||||||
|
chips: chips.length ? chips : undefined,
|
||||||
|
},
|
||||||
|
foot: {
|
||||||
|
patchState: running ? 'live' : 'idle',
|
||||||
|
patchLabel: running ? t('playlists.status.playing') : t('playlists.status.stopped'),
|
||||||
|
primaryAction,
|
||||||
|
iconActions: [{
|
||||||
|
icon: ICON_EDIT,
|
||||||
|
onclick: `editPlaylist('${playlist.id}')`,
|
||||||
|
title: t('playlists.edit'),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardHtml = wrapCard({ dataAttr: 'data-playlist-id', id: playlist.id, mod });
|
||||||
|
const tagsHtml = renderTagChips(playlist.tags);
|
||||||
|
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPlaylists(): Promise<ScenePlaylist[]> {
|
||||||
|
return scenePlaylistsCache.fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Create / Edit / Clone =====
|
||||||
|
|
||||||
|
function _resetEditorChrome(titleKey: string): void {
|
||||||
|
(document.getElementById('playlist-editor-error') as HTMLElement).style.display = 'none';
|
||||||
|
const titleEl = document.querySelector('#playlist-editor-title span[data-i18n]');
|
||||||
|
if (titleEl) { titleEl.setAttribute('data-i18n', titleKey); titleEl.textContent = t(titleKey); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wireAutoName(): void {
|
||||||
|
_plNameManuallyEdited = false;
|
||||||
|
(document.getElementById('playlist-editor-name') as HTMLElement).oninput = () => { _plNameManuallyEdited = true; };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _initTags(values: string[]): void {
|
||||||
|
if (_playlistTagsInput) { _playlistTagsInput.destroy(); _playlistTagsInput = null; }
|
||||||
|
_playlistTagsInput = new TagInput(document.getElementById('playlist-tags-container'), { placeholder: t('tags.placeholder') });
|
||||||
|
_playlistTagsInput.setValue(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _openEditorWith(opts: {
|
||||||
|
editingId: string | null;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
loop: boolean;
|
||||||
|
shuffle: boolean;
|
||||||
|
items: Array<{ scene_preset_id: string; duration_seconds: number }>;
|
||||||
|
tags: string[];
|
||||||
|
titleKey: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
_editingId = opts.editingId;
|
||||||
|
(document.getElementById('playlist-editor-id') as HTMLInputElement).value = opts.editingId || '';
|
||||||
|
(document.getElementById('playlist-editor-name') as HTMLInputElement).value = opts.name;
|
||||||
|
(document.getElementById('playlist-editor-description') as HTMLInputElement).value = opts.description;
|
||||||
|
(document.getElementById('playlist-editor-loop') as HTMLInputElement).checked = opts.loop;
|
||||||
|
(document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked = opts.shuffle;
|
||||||
|
_resetEditorChrome(opts.titleKey);
|
||||||
|
|
||||||
|
const list = document.getElementById('playlist-item-list');
|
||||||
|
if (list) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
_setItemListEmptyHint(list);
|
||||||
|
await _primePresets();
|
||||||
|
for (const it of opts.items) _appendItemRow(it.scene_preset_id, it.duration_seconds, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
_initTags(opts.tags);
|
||||||
|
_wireAutoName();
|
||||||
|
if (!opts.editingId) _autoGeneratePlaylistName();
|
||||||
|
|
||||||
|
playlistModal.open();
|
||||||
|
playlistModal.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPlaylistEditor(): Promise<void> {
|
||||||
|
await _openEditorWith({
|
||||||
|
editingId: null, name: '', description: '', loop: true, shuffle: false,
|
||||||
|
items: [], tags: [], titleKey: 'playlists.add',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editPlaylist(playlistId: string): Promise<void> {
|
||||||
|
const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId);
|
||||||
|
if (!playlist) return;
|
||||||
|
await _openEditorWith({
|
||||||
|
editingId: playlistId,
|
||||||
|
name: playlist.name,
|
||||||
|
description: playlist.description || '',
|
||||||
|
loop: playlist.loop !== false,
|
||||||
|
shuffle: playlist.shuffle === true,
|
||||||
|
items: (playlist.items || []).map(i => ({ scene_preset_id: i.scene_preset_id, duration_seconds: i.duration_seconds })),
|
||||||
|
tags: playlist.tags || [],
|
||||||
|
titleKey: 'playlists.edit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clonePlaylist(playlistId: string): Promise<void> {
|
||||||
|
const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId);
|
||||||
|
if (!playlist) return;
|
||||||
|
await _openEditorWith({
|
||||||
|
editingId: null,
|
||||||
|
name: `${playlist.name || ''} (Copy)`,
|
||||||
|
description: playlist.description || '',
|
||||||
|
loop: playlist.loop !== false,
|
||||||
|
shuffle: playlist.shuffle === true,
|
||||||
|
items: (playlist.items || []).map(i => ({ scene_preset_id: i.scene_preset_id, duration_seconds: i.duration_seconds })),
|
||||||
|
tags: playlist.tags || [],
|
||||||
|
titleKey: 'playlists.add',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePlaylist(): Promise<void> {
|
||||||
|
if (playlistModal.closeIfPristine(_editingId)) return;
|
||||||
|
|
||||||
|
const name = (document.getElementById('playlist-editor-name') as HTMLInputElement).value.trim();
|
||||||
|
const description = (document.getElementById('playlist-editor-description') as HTMLInputElement).value.trim();
|
||||||
|
const loop = (document.getElementById('playlist-editor-loop') as HTMLInputElement).checked;
|
||||||
|
const shuffle = (document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked;
|
||||||
|
const errorEl = document.getElementById('playlist-editor-error')!;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
errorEl.textContent = t('playlists.error.name_required');
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = _readEditorItems();
|
||||||
|
const tags = _playlistTagsInput ? _playlistTagsInput.getValue() : [];
|
||||||
|
const body = { name, description, loop, shuffle, items, tags };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_editingId) {
|
||||||
|
await apiPut(`/scene-playlists/${_editingId}`, body, { errorMessage: t('playlists.error.save_failed') });
|
||||||
|
} else {
|
||||||
|
await apiPost('/scene-playlists', body, { errorMessage: t('playlists.error.save_failed') });
|
||||||
|
}
|
||||||
|
playlistModal.forceClose();
|
||||||
|
showToast(_editingId ? t('playlists.updated') : t('playlists.created'), 'success');
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
_reloadPlaylistsTab();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
errorEl.textContent = error.message || t('playlists.error.save_failed');
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePlaylistEditor(): Promise<void> {
|
||||||
|
await playlistModal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Item selector =====
|
||||||
|
|
||||||
|
export async function addPlaylistItem(): Promise<void> {
|
||||||
|
await _primePresets();
|
||||||
|
const presets = Object.values(_presetMap);
|
||||||
|
if (presets.length === 0) {
|
||||||
|
showToast(t('playlists.error.no_presets') || 'Create a scene preset first', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = presets.map(p => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.name,
|
||||||
|
icon: _presetIconSvg(p),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const picked = await EntityPalette.pick({
|
||||||
|
items,
|
||||||
|
placeholder: t('playlists.items.search_placeholder'),
|
||||||
|
});
|
||||||
|
if (!picked) return;
|
||||||
|
|
||||||
|
const list = document.getElementById('playlist-item-list');
|
||||||
|
if (list) {
|
||||||
|
_appendItemRow(String(picked), DEFAULT_ITEM_DURATION, list);
|
||||||
|
_autoGeneratePlaylistName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Start / Stop =====
|
||||||
|
|
||||||
|
export async function startScenePlaylist(playlistId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await apiPost(`/scene-playlists/${playlistId}/start`, undefined, { errorMessage: t('playlists.error.start_failed') });
|
||||||
|
showToast(t('playlists.started'), 'success');
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
_reloadPlaylistsTab();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
showToast(error.message || t('playlists.error.start_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopScenePlaylist(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await apiPost('/scene-playlists/stop', undefined, { errorMessage: t('playlists.error.stop_failed') });
|
||||||
|
showToast(t('playlists.stopped'), 'success');
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
_reloadPlaylistsTab();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
showToast(error.message || t('playlists.error.stop_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Delete =====
|
||||||
|
|
||||||
|
export async function deletePlaylist(playlistId: string): Promise<void> {
|
||||||
|
const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId);
|
||||||
|
const name = playlist ? playlist.name : playlistId;
|
||||||
|
const confirmed = await showConfirm(t('playlists.delete_confirm', { name }));
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiDelete(`/scene-playlists/${playlistId}`, { errorMessage: t('playlists.error.delete_failed') });
|
||||||
|
showToast(t('playlists.deleted'), 'success');
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
_reloadPlaylistsTab();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.isAuth) return;
|
||||||
|
showToast(error.message || t('playlists.error.delete_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Event delegation =====
|
||||||
|
|
||||||
|
const _playlistCardActions: Record<string, (id: string) => void> = {
|
||||||
|
'delete-playlist': deletePlaylist,
|
||||||
|
'clone-playlist': clonePlaylist,
|
||||||
|
'edit-playlist': editPlaylist,
|
||||||
|
'start-playlist': startScenePlaylist,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function initPlaylistDelegation(container: HTMLElement): void {
|
||||||
|
container.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
if (action === 'add-playlist') {
|
||||||
|
e.stopPropagation();
|
||||||
|
openPlaylistEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'stop-playlist') {
|
||||||
|
e.stopPropagation();
|
||||||
|
stopScenePlaylist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'navigate-playlist') {
|
||||||
|
if ((e.target as HTMLElement).closest('button')) return;
|
||||||
|
navigateToCard('automations', 'playlists', 'playlists', 'data-playlist-id', id!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) return;
|
||||||
|
const handler = _playlistCardActions[action];
|
||||||
|
if (handler) {
|
||||||
|
e.stopPropagation();
|
||||||
|
handler(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
|
||||||
|
function _reloadPlaylistsTab(): void {
|
||||||
|
if (isActiveTab('automations') && typeof window.loadAutomations === 'function') {
|
||||||
|
window.loadAutomations();
|
||||||
|
}
|
||||||
|
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Editor modal item-list delegation (reorder / remove / duration) =====
|
||||||
|
|
||||||
|
const _playlistEditorModal = document.getElementById('playlist-editor-modal');
|
||||||
|
if (_playlistEditorModal) {
|
||||||
|
_playlistEditorModal.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const row = btn.closest('.playlist-item') as HTMLElement | null;
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
if (action === 'playlist-item-remove') {
|
||||||
|
row.remove();
|
||||||
|
_autoGeneratePlaylistName();
|
||||||
|
} else if (action === 'playlist-item-up') {
|
||||||
|
const prev = row.previousElementSibling;
|
||||||
|
if (prev) row.parentElement!.insertBefore(row, prev);
|
||||||
|
} else if (action === 'playlist-item-down') {
|
||||||
|
const next = row.nextElementSibling;
|
||||||
|
if (next) row.parentElement!.insertBefore(next, row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-refresh playlist cards when the engine reports a state change
|
||||||
|
// (start / advance / natural-completion stop) so the running indicator and
|
||||||
|
// Start/Stop button stay accurate across clients and after a playlist ends.
|
||||||
|
document.addEventListener('server:playlist_state_changed', () => {
|
||||||
|
scenePlaylistsCache.invalidate();
|
||||||
|
_reloadPlaylistsTab();
|
||||||
|
});
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
/**
|
||||||
|
* Setup Wizard — multi-step first-run flow.
|
||||||
|
*
|
||||||
|
* Guides a brand-new user from zero to a running, calibrated LED strip in
|
||||||
|
* roughly seven steps:
|
||||||
|
* 1. Welcome
|
||||||
|
* 2. Find device — discovery scan + manual add fallback
|
||||||
|
* 3. Pick screen — GET /api/v1/config/displays
|
||||||
|
* 4. Scaffold — POST /api/v1/setup/scaffold → entity ids
|
||||||
|
* 5. Calibrate — embed mountAutoCalibration (Phase 3 component)
|
||||||
|
* 6. Start output — POST /api/v1/output-targets/{id}/start
|
||||||
|
* 7. Done
|
||||||
|
*
|
||||||
|
* First-run precedence (explicit):
|
||||||
|
* - app.ts checks GET /preferences/onboarding
|
||||||
|
* - if onboarded=false AND no output targets → open wizard, suppress tour
|
||||||
|
* - wizard completion/skip → PUT /preferences/onboarding {onboarded:true}
|
||||||
|
* + localStorage 'tour_completed' = '1' so the tour never double-fires
|
||||||
|
* - if onboarded=true → existing tour logic runs unchanged
|
||||||
|
*
|
||||||
|
* Re-entrant: openSetupWizard() is exported so a toolbar button can reopen it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
|
||||||
|
import { devicesCache, outputTargetsCache, displaysCache } from '../core/state.ts';
|
||||||
|
import { t } from '../core/i18n.ts';
|
||||||
|
import { showToast } from '../core/ui.ts';
|
||||||
|
import { Modal } from '../core/modal.ts';
|
||||||
|
import { mountAutoCalibration, unmountAutoCalibration } from './auto-calibration.ts';
|
||||||
|
import { suppressGettingStartedTour } from './tutorials.ts';
|
||||||
|
import {
|
||||||
|
ICON_MONITOR, ICON_SPARKLES, ICON_DEVICE, ICON_OK, ICON_CHECK, ICON_ROCKET_ICON,
|
||||||
|
ICON_CALIBRATION, ICON_START, ICON_SEARCH, ICON_PLUS,
|
||||||
|
} from '../core/icons.ts';
|
||||||
|
import { getDeviceTypeIcon } from '../core/icons.ts';
|
||||||
|
import type { Device } from '../types.ts';
|
||||||
|
import type { Display } from '../types.ts';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type WizardStep = 'welcome' | 'device' | 'display' | 'scaffold' | 'calibrate' | 'start' | 'done';
|
||||||
|
|
||||||
|
interface DiscoveredDevice {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
device_type: string;
|
||||||
|
led_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScaffoldResult {
|
||||||
|
device_id: string;
|
||||||
|
capture_template_id: string;
|
||||||
|
picture_source_id: string;
|
||||||
|
color_strip_source_id: string;
|
||||||
|
output_target_id: string;
|
||||||
|
capture_template_reused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WizardState {
|
||||||
|
step: WizardStep;
|
||||||
|
/** Persisted device id after creation. */
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
displayIndex: number;
|
||||||
|
displayName: string;
|
||||||
|
scaffoldResult: ScaffoldResult | null;
|
||||||
|
/** Populated by step 2 discovery scan. */
|
||||||
|
discoveredDevices: DiscoveredDevice[];
|
||||||
|
/** Manual-entry mode in step 2. */
|
||||||
|
manualMode: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
errorMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Module singleton ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _state: WizardState | null = null;
|
||||||
|
let _modal: SetupWizardModal | null = null;
|
||||||
|
|
||||||
|
const STEPS: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start', 'done'];
|
||||||
|
|
||||||
|
// ── Modal class ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SetupWizardModal extends Modal {
|
||||||
|
constructor() {
|
||||||
|
super('setup-wizard-modal');
|
||||||
|
}
|
||||||
|
onForceClose(): void {
|
||||||
|
_handleWizardClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Open the wizard (first-run or on-demand). */
|
||||||
|
export function openSetupWizard(): void {
|
||||||
|
if (!_modal) _modal = new SetupWizardModal();
|
||||||
|
_state = {
|
||||||
|
step: 'welcome',
|
||||||
|
deviceId: '',
|
||||||
|
deviceName: '',
|
||||||
|
displayIndex: 0,
|
||||||
|
displayName: '',
|
||||||
|
scaffoldResult: null,
|
||||||
|
discoveredDevices: [],
|
||||||
|
manualMode: false,
|
||||||
|
busy: false,
|
||||||
|
errorMsg: '',
|
||||||
|
};
|
||||||
|
_modal.open();
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the wizard and mark as complete / skipped. */
|
||||||
|
export function closeSetupWizard(): void {
|
||||||
|
if (!_modal) return;
|
||||||
|
void unmountAutoCalibration();
|
||||||
|
_modal.forceClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// First-run check (called from app.ts after auth passes)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check onboarding state and open the wizard on true first run.
|
||||||
|
*
|
||||||
|
* Returns `true` if the wizard was opened (caller should suppress the tour).
|
||||||
|
* Returns `false` if already onboarded (caller should proceed with tour logic).
|
||||||
|
*/
|
||||||
|
export async function checkAndOpenWizardIfNeeded(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const [onboardingResp, targetsResp] = await Promise.all([
|
||||||
|
apiGet<{ onboarded: boolean; completed_at: string | null }>('/preferences/onboarding'),
|
||||||
|
outputTargetsCache.fetch().catch((): unknown[] => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (onboardingResp.onboarded) {
|
||||||
|
// Already onboarded — let tour run normally
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = Array.isArray(targetsResp) ? targetsResp : [];
|
||||||
|
if (targets.length > 0) {
|
||||||
|
// Has output targets but never completed onboarding wizard.
|
||||||
|
// Power user or migrated setup — mark done and skip wizard.
|
||||||
|
await _markOnboarded();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True first run: no targets, not onboarded
|
||||||
|
openSetupWizard();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// If the check itself fails (server offline, 404 on new backend, etc.)
|
||||||
|
// fall through to existing tour logic — don't block the UI.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Onboarding flag helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _markOnboarded(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await apiPut('/preferences/onboarding', { onboarded: true });
|
||||||
|
// Suppress tooltip tour too — wizard owns the first-run experience
|
||||||
|
suppressGettingStartedTour();
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: UI already moved on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Wizard step navigation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _stepIndex(step: WizardStep): number {
|
||||||
|
return STEPS.indexOf(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wizardNext(): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const step = _state.step;
|
||||||
|
|
||||||
|
if (step === 'welcome') {
|
||||||
|
_state.step = 'device';
|
||||||
|
_renderStep();
|
||||||
|
_startDiscovery();
|
||||||
|
} else if (step === 'device') {
|
||||||
|
if (!_state.deviceId) {
|
||||||
|
_setError(t('wizard.error.no_device'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_state.step = 'display';
|
||||||
|
_renderStep();
|
||||||
|
await _loadDisplays();
|
||||||
|
} else if (step === 'display') {
|
||||||
|
_state.step = 'scaffold';
|
||||||
|
_renderStep();
|
||||||
|
await _runScaffold();
|
||||||
|
} else if (step === 'calibrate') {
|
||||||
|
// "Skip calibration" path — move to start
|
||||||
|
void unmountAutoCalibration();
|
||||||
|
_state.step = 'start';
|
||||||
|
_renderStep();
|
||||||
|
await _startOutput();
|
||||||
|
} else if (step === 'start') {
|
||||||
|
_state.step = 'done';
|
||||||
|
_renderStep();
|
||||||
|
} else if (step === 'done') {
|
||||||
|
void closeSetupWizard();
|
||||||
|
await _markOnboarded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardBack(): void {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const idx = _stepIndex(_state.step);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
// Back from calibrate: unmount the autocal component
|
||||||
|
if (_state.step === 'calibrate') {
|
||||||
|
void unmountAutoCalibration();
|
||||||
|
}
|
||||||
|
_state.step = STEPS[idx - 1];
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardSkip(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
void closeSetupWizard();
|
||||||
|
void _markOnboarded();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: device discovery
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _startDiscovery(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.discoveredDevices = [];
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
// Omit device_type so the backend scans every provider (WLED, Adalight,
|
||||||
|
// DDP, OpenRGB, BLE, …) in parallel — not just WLED.
|
||||||
|
const data = await apiGet<{ devices?: DiscoveredDevice[] }>('/devices/discover?timeout=3');
|
||||||
|
_state.discoveredDevices = data.devices || [];
|
||||||
|
} catch {
|
||||||
|
_state.discoveredDevices = [];
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch device step to manual-entry mode. */
|
||||||
|
export function wizardShowManual(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.manualMode = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardHideManual(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.manualMode = false;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User clicked a discovered device — create it via POST /devices. */
|
||||||
|
export async function wizardSelectDiscovered(url: string, name: string, device_type: string): Promise<void> {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name,
|
||||||
|
device_type,
|
||||||
|
url,
|
||||||
|
led_count: 60,
|
||||||
|
};
|
||||||
|
const device = await apiPost<Device>('/devices', body,
|
||||||
|
{ errorMessage: t('wizard.error.device_create_failed') });
|
||||||
|
_state.deviceId = device.id;
|
||||||
|
_state.deviceName = device.name;
|
||||||
|
devicesCache.invalidate();
|
||||||
|
_state.step = 'display';
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
await _loadDisplays();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_setError(err instanceof Error ? err.message : t('wizard.error.device_create_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual device form submit. */
|
||||||
|
export async function wizardAddManualDevice(event: Event): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
const nameEl = document.getElementById('wizard-device-name') as HTMLInputElement | null;
|
||||||
|
const urlEl = document.getElementById('wizard-device-url') as HTMLInputElement | null;
|
||||||
|
const ledEl = document.getElementById('wizard-device-led-count') as HTMLInputElement | null;
|
||||||
|
const name = nameEl?.value.trim() || '';
|
||||||
|
const url = urlEl?.value.trim() || '';
|
||||||
|
const ledCount = parseInt(ledEl?.value || '60', 10) || 60;
|
||||||
|
|
||||||
|
if (!name) { _setError(t('wizard.error.device_name_required')); return; }
|
||||||
|
if (!url) { _setError(t('wizard.error.device_url_required')); return; }
|
||||||
|
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
const device = await apiPost<Device>('/devices', {
|
||||||
|
name, url, device_type: 'wled', led_count: ledCount,
|
||||||
|
}, { errorMessage: t('wizard.error.device_create_failed') });
|
||||||
|
_state.deviceId = device.id;
|
||||||
|
_state.deviceName = device.name;
|
||||||
|
devicesCache.invalidate();
|
||||||
|
_state.step = 'display';
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
await _loadDisplays();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_setError(err instanceof Error ? err.message : t('wizard.error.device_create_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User selected an already-existing device from the cache. */
|
||||||
|
export function wizardUseExistingDevice(deviceId: string, deviceName: string): void {
|
||||||
|
if (!_state || _state.busy) return;
|
||||||
|
_state.deviceId = deviceId;
|
||||||
|
_state.deviceName = deviceName;
|
||||||
|
_state.step = 'display';
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
void _loadDisplays();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: display selection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _loadDisplays(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
await displaysCache.fetch();
|
||||||
|
} catch {
|
||||||
|
// Fall through — render will show a fallback
|
||||||
|
} finally {
|
||||||
|
_state.busy = false;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wizardSelectDisplay(index: number, displayName: string): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.displayIndex = index;
|
||||||
|
_state.displayName = displayName;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: scaffold
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _runScaffold(): Promise<void> {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
const result = await apiPost<ScaffoldResult>('/setup/scaffold', {
|
||||||
|
device_id: _state.deviceId,
|
||||||
|
display_index: _state.displayIndex,
|
||||||
|
calibration: null,
|
||||||
|
}, { errorMessage: t('wizard.error.scaffold_failed') });
|
||||||
|
_state.scaffoldResult = result;
|
||||||
|
_state.busy = false;
|
||||||
|
_state.step = 'calibrate';
|
||||||
|
_renderStep();
|
||||||
|
// Mount the auto-calibration component inside the calibrate step container
|
||||||
|
const container = document.getElementById('wizard-calibrate-container');
|
||||||
|
if (container) {
|
||||||
|
await mountAutoCalibration({
|
||||||
|
container,
|
||||||
|
cssId: result.color_strip_source_id,
|
||||||
|
deviceId: _state.deviceId,
|
||||||
|
onComplete: () => {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.step = 'start';
|
||||||
|
_renderStep();
|
||||||
|
void _startOutput();
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.step = 'start';
|
||||||
|
_renderStep();
|
||||||
|
void _startOutput();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
_setError(err instanceof Error ? err.message : t('wizard.error.scaffold_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step: start output
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _startOutput(): Promise<void> {
|
||||||
|
if (!_state?.scaffoldResult) return;
|
||||||
|
_state.busy = true;
|
||||||
|
_state.errorMsg = '';
|
||||||
|
_renderStep();
|
||||||
|
try {
|
||||||
|
await apiPost<unknown>(`/output-targets/${_state.scaffoldResult.output_target_id}/start`, {},
|
||||||
|
{ errorMessage: t('wizard.error.start_failed') });
|
||||||
|
outputTargetsCache.invalidate();
|
||||||
|
_state.busy = false;
|
||||||
|
_state.step = 'done';
|
||||||
|
_renderStep();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
_state.busy = false;
|
||||||
|
// Non-fatal: still show done step but surface the error
|
||||||
|
showToast(err instanceof Error ? err.message : t('wizard.error.start_failed'), 'warning');
|
||||||
|
_state.step = 'done';
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Internal helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _setError(msg: string): void {
|
||||||
|
if (!_state) return;
|
||||||
|
_state.errorMsg = msg;
|
||||||
|
_renderStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleWizardClose(): void {
|
||||||
|
void unmountAutoCalibration();
|
||||||
|
_state = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Rendering
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderStep(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
const container = document.getElementById('wizard-step-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
_renderProgressBar();
|
||||||
|
|
||||||
|
const html = _buildStepHtml(_state);
|
||||||
|
container.innerHTML = html;
|
||||||
|
_attachStepListeners(_state.step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderProgressBar(): void {
|
||||||
|
if (!_state) return;
|
||||||
|
const bar = document.getElementById('wizard-progress-bar');
|
||||||
|
const labels = document.getElementById('wizard-progress-labels');
|
||||||
|
if (!bar || !labels) return;
|
||||||
|
|
||||||
|
const currentIdx = _stepIndex(_state.step);
|
||||||
|
// Progress bar shows steps 1-6 (skip 'done' which is the finish state)
|
||||||
|
const visibleSteps: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start'];
|
||||||
|
const total = visibleSteps.length;
|
||||||
|
const activeIdx = visibleSteps.indexOf(_state.step);
|
||||||
|
const pct = activeIdx < 0 ? 100 : Math.round(((activeIdx) / (total - 1)) * 100);
|
||||||
|
|
||||||
|
bar.innerHTML = `
|
||||||
|
<div class="wizard-progress-track">
|
||||||
|
<div class="wizard-progress-fill" style="width:${pct}%"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const stepLabels = visibleSteps.map((s, i) => {
|
||||||
|
const done = currentIdx > STEPS.indexOf(s);
|
||||||
|
const active = s === _state!.step;
|
||||||
|
const cls = done ? 'wizard-pip wizard-pip--done' : active ? 'wizard-pip wizard-pip--active' : 'wizard-pip';
|
||||||
|
return `<span class="${cls}" title="${t(`wizard.step.${s}`)}">${done ? ICON_CHECK : String(i + 1)}</span>`;
|
||||||
|
}).join('');
|
||||||
|
labels.innerHTML = stepLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildStepHtml(state: WizardState): string {
|
||||||
|
switch (state.step) {
|
||||||
|
case 'welcome': return _buildWelcomeStep();
|
||||||
|
case 'device': return _buildDeviceStep(state);
|
||||||
|
case 'display': return _buildDisplayStep(state);
|
||||||
|
case 'scaffold': return _buildScaffoldStep(state);
|
||||||
|
case 'calibrate':return _buildCalibrateStep(state);
|
||||||
|
case 'start': return _buildStartStep(state);
|
||||||
|
case 'done': return _buildDoneStep(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _errorBanner(msg: string): string {
|
||||||
|
if (!msg) return '';
|
||||||
|
return `<div class="wizard-error">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
|
||||||
|
<span>${msg}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildWelcomeStep(): string {
|
||||||
|
return `<div class="wizard-step wizard-step--welcome">
|
||||||
|
<div class="wizard-welcome-icon">${ICON_SPARKLES}</div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.welcome.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.welcome.desc')}</p>
|
||||||
|
<ul class="wizard-welcome-list">
|
||||||
|
<li>${ICON_DEVICE}<span>${t('wizard.welcome.item1')}</span></li>
|
||||||
|
<li>${ICON_MONITOR}<span>${t('wizard.welcome.item2')}</span></li>
|
||||||
|
<li>${ICON_CALIBRATION}<span>${t('wizard.welcome.item3')}</span></li>
|
||||||
|
<li>${ICON_START}<span>${t('wizard.welcome.item4')}</span></li>
|
||||||
|
</ul>
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardSkip()">${t('wizard.skip')}</button>
|
||||||
|
<button class="btn btn-primary" onclick="wizardNext()">${t('wizard.start')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildDeviceStep(state: WizardState): string {
|
||||||
|
const existingDevices: Device[] = devicesCache.data || [];
|
||||||
|
|
||||||
|
let discoveryHtml = '';
|
||||||
|
if (state.busy && state.discoveredDevices.length === 0) {
|
||||||
|
discoveryHtml = `<div class="wizard-discovery-scanning">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>${t('wizard.device.scanning')}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (state.discoveredDevices.length > 0) {
|
||||||
|
discoveryHtml = `<div class="wizard-discovery-list">` +
|
||||||
|
state.discoveredDevices.map(d => `
|
||||||
|
<button class="wizard-discovery-item" onclick="wizardSelectDiscovered('${_esc(d.url)}','${_esc(d.name)}','${_esc(d.device_type)}')">
|
||||||
|
<span class="wizard-discovery-icon">${getDeviceTypeIcon(d.device_type)}</span>
|
||||||
|
<span class="wizard-discovery-details">
|
||||||
|
<span class="wizard-discovery-name">${_esc(d.name)}</span>
|
||||||
|
<span class="wizard-discovery-url">${_esc(d.url)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="wizard-discovery-badge">${_esc(d.device_type.toUpperCase())}</span>
|
||||||
|
</button>`).join('') +
|
||||||
|
`</div>`;
|
||||||
|
} else {
|
||||||
|
discoveryHtml = `<div class="wizard-discovery-empty">
|
||||||
|
<span>${t('wizard.device.none_found')}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingHtml = '';
|
||||||
|
if (existingDevices.length > 0) {
|
||||||
|
existingHtml = `<div class="wizard-section-label">${t('wizard.device.existing')}</div>
|
||||||
|
<div class="wizard-discovery-list">` +
|
||||||
|
existingDevices.map(d => `
|
||||||
|
<button class="wizard-discovery-item" onclick="wizardUseExistingDevice('${_esc(d.id)}','${_esc(d.name)}')">
|
||||||
|
<span class="wizard-discovery-icon">${getDeviceTypeIcon(d.device_type)}</span>
|
||||||
|
<span class="wizard-discovery-details">
|
||||||
|
<span class="wizard-discovery-name">${_esc(d.name)}</span>
|
||||||
|
<span class="wizard-discovery-url">${_esc(d.url)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="wizard-discovery-badge">${_esc(d.device_type.toUpperCase())}</span>
|
||||||
|
</button>`).join('') +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manualHtml = '';
|
||||||
|
if (state.manualMode) {
|
||||||
|
manualHtml = `<form id="wizard-manual-form" onsubmit="wizardAddManualDevice(event)">
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.device.manual.name')}</label>
|
||||||
|
<input id="wizard-device-name" class="form-input" type="text" placeholder="${t('wizard.device.manual.name_placeholder')}" required>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.device.manual.url')}</label>
|
||||||
|
<input id="wizard-device-url" class="form-input" type="text" placeholder="http://192.168.1.x" required>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.device.manual.led_count')}</label>
|
||||||
|
<input id="wizard-device-led-count" class="form-input" type="number" min="1" max="1000" value="60">
|
||||||
|
</div>
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick="wizardHideManual()">${t('common.back')}</button>
|
||||||
|
<button type="submit" class="btn btn-primary"${state.busy ? ' disabled' : ''}>
|
||||||
|
${state.busy ? `<div class="btn-spinner"></div>` : ''}${t('wizard.device.manual.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
} else {
|
||||||
|
manualHtml = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="wizard-step">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon">${ICON_DEVICE}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.device.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.device.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${!state.manualMode ? `
|
||||||
|
<div class="wizard-discovery-section">
|
||||||
|
<div class="wizard-section-label wizard-section-label--scan">
|
||||||
|
${t('wizard.device.discovered')}
|
||||||
|
<button class="wizard-scan-btn" onclick="wizardRescan()"${state.busy ? ' disabled' : ''}>
|
||||||
|
${ICON_SEARCH} ${t('wizard.device.rescan')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${discoveryHtml}
|
||||||
|
</div>
|
||||||
|
${existingHtml}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardSkip()">${t('wizard.skip')}</button>
|
||||||
|
<button class="btn btn-secondary" onclick="wizardShowManual()">
|
||||||
|
${ICON_PLUS} ${t('wizard.device.manual.title')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : manualHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildDisplayStep(state: WizardState): string {
|
||||||
|
const displays: Display[] = displaysCache.data ?? [];
|
||||||
|
|
||||||
|
let listHtml = '';
|
||||||
|
if (state.busy && displays.length === 0) {
|
||||||
|
listHtml = `<div class="wizard-discovery-scanning">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>${t('wizard.display.loading')}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (displays.length === 0) {
|
||||||
|
// Fallback: offer a manual index input
|
||||||
|
listHtml = `<div class="wizard-display-fallback">
|
||||||
|
<p class="wizard-step-desc">${t('wizard.display.no_displays')}</p>
|
||||||
|
<div class="wizard-form-row">
|
||||||
|
<label class="wizard-form-label">${t('wizard.display.manual_index')}</label>
|
||||||
|
<input id="wizard-display-index-manual" class="form-input" type="number"
|
||||||
|
min="0" max="63" value="${state.displayIndex}"
|
||||||
|
oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value)">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
listHtml = `<div class="wizard-display-list">` +
|
||||||
|
displays.map(d => {
|
||||||
|
const active = d.index === state.displayIndex;
|
||||||
|
return `<button class="wizard-display-item${active ? ' wizard-display-item--active' : ''}"
|
||||||
|
onclick="wizardSelectDisplay(${d.index}, '${_esc(d.name)}')">
|
||||||
|
<span class="wizard-display-icon">${ICON_MONITOR}</span>
|
||||||
|
<span class="wizard-display-details">
|
||||||
|
<span class="wizard-display-name">${_esc(d.name)}</span>
|
||||||
|
<span class="wizard-display-dims">${d.width} × ${d.height}${d.is_primary ? ' · ' + t('wizard.display.primary') : ''}</span>
|
||||||
|
</span>
|
||||||
|
${active ? `<span class="wizard-display-check">${ICON_CHECK}</span>` : ''}
|
||||||
|
</button>`;
|
||||||
|
}).join('') +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="wizard-step">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon">${ICON_MONITOR}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.display.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.display.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${listHtml}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardBack()">${t('common.back')}</button>
|
||||||
|
<button class="btn btn-primary" onclick="wizardNext()"${state.busy ? ' disabled' : ''}>
|
||||||
|
${t('wizard.display.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildScaffoldStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--scaffold">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon${state.scaffoldResult ? ' wizard-step-icon--ok' : ''}">${state.scaffoldResult ? ICON_OK : ICON_SPARKLES}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.scaffold.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${state.busy ? t('wizard.scaffold.building') : state.scaffoldResult ? t('wizard.scaffold.done') : t('wizard.scaffold.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${state.busy ? `<div class="wizard-scaffold-progress">
|
||||||
|
<div class="wizard-scaffold-spinner"><div class="loading-spinner"></div></div>
|
||||||
|
<span class="wizard-scaffold-label">${t('wizard.scaffold.building')}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildCalibrateStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--calibrate">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon">${ICON_CALIBRATION}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.calibrate.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.calibrate.desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- auto-calibration.ts mounts here -->
|
||||||
|
<div id="wizard-calibrate-container" class="wizard-calibrate-container"></div>
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="btn btn-ghost" onclick="wizardNext()">${t('wizard.calibrate.skip')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildStartStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--start">
|
||||||
|
<div class="wizard-step-header">
|
||||||
|
<div class="wizard-step-icon${!state.busy && !state.errorMsg ? ' wizard-step-icon--ok' : ''}">${START_STEP_ICON(state)}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.start.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${state.busy ? t('wizard.start.starting') : state.errorMsg ? t('wizard.start.failed') : t('wizard.start.done')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${state.busy ? `<div class="wizard-scaffold-progress">
|
||||||
|
<div class="wizard-scaffold-spinner"><div class="loading-spinner"></div></div>
|
||||||
|
<span class="wizard-scaffold-label">${t('wizard.start.starting')}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${_errorBanner(state.errorMsg)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function START_STEP_ICON(state: WizardState): string {
|
||||||
|
if (state.busy) return ICON_START;
|
||||||
|
if (state.errorMsg) return ICON_START;
|
||||||
|
return ICON_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildDoneStep(state: WizardState): string {
|
||||||
|
return `<div class="wizard-step wizard-step--done">
|
||||||
|
<div class="wizard-done-icon">${ICON_ROCKET_ICON}</div>
|
||||||
|
<h3 class="wizard-step-title">${t('wizard.done.title')}</h3>
|
||||||
|
<p class="wizard-step-desc">${t('wizard.done.desc')}</p>
|
||||||
|
${state.scaffoldResult ? `<div class="wizard-done-summary">
|
||||||
|
<div class="wizard-done-item">
|
||||||
|
<span class="wizard-done-label">${t('wizard.done.device')}</span>
|
||||||
|
<span class="wizard-done-value">${_esc(state.deviceName)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-done-item">
|
||||||
|
<span class="wizard-done-label">${t('wizard.done.display')}</span>
|
||||||
|
<span class="wizard-done-value">${_esc(state.displayName || (t('wizard.display.index_prefix') + ' ' + String(state.displayIndex)))}</span>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="wizard-footer wizard-footer--done">
|
||||||
|
<button class="btn btn-primary" onclick="wizardFinish()">${t('wizard.done.finish')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _attachStepListeners(_step: WizardStep): void {
|
||||||
|
// The manual device form uses onsubmit="wizardAddManualDevice(event)" inline —
|
||||||
|
// no duplicate addEventListener needed here.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Re-scan
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function wizardRescan(): void {
|
||||||
|
if (!_state || _state.step !== 'device') return;
|
||||||
|
_startDiscovery();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Finish
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function wizardFinish(): void {
|
||||||
|
void closeSetupWizard();
|
||||||
|
void _markOnboarded();
|
||||||
|
// Reload targets tab so the new target appears immediately
|
||||||
|
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Utility
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _esc(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
@@ -171,6 +171,8 @@ class TargetEditorModal extends Modal {
|
|||||||
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
|
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
|
||||||
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
|
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
|
||||||
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
|
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
|
||||||
|
max_milliamps: (document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value,
|
||||||
|
milliamps_per_led: (document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value,
|
||||||
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
|
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -181,8 +183,13 @@ const targetEditorModal = new TargetEditorModal();
|
|||||||
function _protocolBadge(device: any, target: any) {
|
function _protocolBadge(device: any, target: any) {
|
||||||
const dt = device?.device_type;
|
const dt = device?.device_type;
|
||||||
if (!dt || dt === 'wled') {
|
if (!dt || dt === 'wled') {
|
||||||
const proto = target.protocol === 'http' ? 'HTTP' : 'DDP';
|
const wledMap: Record<string, [string, string]> = {
|
||||||
return `${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${proto}`;
|
http: [ICON_GLOBE, 'HTTP'],
|
||||||
|
udp: [ICON_RADIO, 'WLED UDP'],
|
||||||
|
ddp: [ICON_RADIO, 'DDP'],
|
||||||
|
};
|
||||||
|
const [icon, label] = wledMap[target.protocol] || wledMap.ddp;
|
||||||
|
return `${icon} ${label}`;
|
||||||
}
|
}
|
||||||
const map = {
|
const map = {
|
||||||
openrgb: [ICON_PALETTE, 'OpenRGB SDK'],
|
openrgb: [ICON_PALETTE, 'OpenRGB SDK'],
|
||||||
@@ -311,10 +318,11 @@ function _ensureProtocolIconSelect() {
|
|||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const items = [
|
const items = [
|
||||||
{ value: 'ddp', icon: _pIcon(P.radio), label: t('targets.protocol.ddp'), desc: t('targets.protocol.ddp.desc') },
|
{ value: 'ddp', icon: _pIcon(P.radio), label: t('targets.protocol.ddp'), desc: t('targets.protocol.ddp.desc') },
|
||||||
|
{ value: 'udp', icon: _pIcon(P.radio), label: t('targets.protocol.udp'), desc: t('targets.protocol.udp.desc') },
|
||||||
{ value: 'http', icon: _pIcon(P.globe), label: t('targets.protocol.http'), desc: t('targets.protocol.http.desc') },
|
{ value: 'http', icon: _pIcon(P.globe), label: t('targets.protocol.http'), desc: t('targets.protocol.http.desc') },
|
||||||
];
|
];
|
||||||
if (_protocolIconSelect) { _protocolIconSelect.updateItems(items); return; }
|
if (_protocolIconSelect) { _protocolIconSelect.updateItems(items); return; }
|
||||||
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
|
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 3 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function _ensureBrightnessWidget(): BindableScalarWidget {
|
function _ensureBrightnessWidget(): BindableScalarWidget {
|
||||||
@@ -401,6 +409,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
|||||||
|
|
||||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
|
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
|
||||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
|
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
|
||||||
|
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = String(target.max_milliamps ?? 0);
|
||||||
|
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = String(target.milliamps_per_led ?? 55);
|
||||||
|
|
||||||
_populateCssDropdown(target.color_strip_source_id || '');
|
_populateCssDropdown(target.color_strip_source_id || '');
|
||||||
_ensureBrightnessWidget().setValue(target.brightness ?? 1.0);
|
_ensureBrightnessWidget().setValue(target.brightness ?? 1.0);
|
||||||
@@ -419,6 +429,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
|||||||
|
|
||||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
|
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
|
||||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
|
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
|
||||||
|
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = String(cloneData.max_milliamps ?? 0);
|
||||||
|
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = String(cloneData.milliamps_per_led ?? 55);
|
||||||
|
|
||||||
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
||||||
_ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0);
|
_ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0);
|
||||||
@@ -435,6 +447,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
|||||||
|
|
||||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
|
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
|
||||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
|
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
|
||||||
|
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = '0';
|
||||||
|
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = '55';
|
||||||
|
|
||||||
_populateCssDropdown('');
|
_populateCssDropdown('');
|
||||||
_ensureBrightnessWidget().setValue(1.0);
|
_ensureBrightnessWidget().setValue(1.0);
|
||||||
@@ -515,6 +529,8 @@ export async function saveTargetEditor() {
|
|||||||
|
|
||||||
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
|
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
|
||||||
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
|
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
|
||||||
|
const maxMilliamps = Math.max(0, Math.round(Number((document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value) || 0));
|
||||||
|
const milliampsPerLed = Math.max(1, Math.round(Number((document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value) || 55));
|
||||||
|
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
name,
|
name,
|
||||||
@@ -526,6 +542,8 @@ export async function saveTargetEditor() {
|
|||||||
keepalive_interval: standbyInterval,
|
keepalive_interval: standbyInterval,
|
||||||
adaptive_fps: adaptiveFps,
|
adaptive_fps: adaptiveFps,
|
||||||
protocol,
|
protocol,
|
||||||
|
max_milliamps: maxMilliamps,
|
||||||
|
milliamps_per_led: milliampsPerLed,
|
||||||
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
|
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user