Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 838c95484d | |||
| 14822fb6a0 | |||
| 0c096db639 | |||
| c1eeefcf06 | |||
| 39b0554444 | |||
| 6745e25b20 | |||
| 126d8f2449 | |||
| e584235676 | |||
| b43f821046 | |||
| 077c99c7d1 | |||
| ae74cca132 | |||
| 77284e8e7b | |||
| ff1ff06cb5 | |||
| 3dd1ac3f0d | |||
| 6e1dd2111d | |||
| 9a0137fa4c | |||
| 4a0927521a | |||
| 25c613c5cb | |||
| 726f39e2ba | |||
| 1ac4a0f66d | |||
| 1afe7d6fcc | |||
| 17dd2e02ba | |||
| 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 | |||
| 6aeda935f1 | |||
| a5effba553 |
@@ -354,6 +354,58 @@ jobs:
|
|||||||
docker push "$REGISTRY:latest"
|
docker push "$REGISTRY:latest"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Best-effort arm64 (Raspberry Pi / arm64 HAOS hosts). Runs AFTER the
|
||||||
|
# amd64 push so the amd64 image always ships even if this fails.
|
||||||
|
# Deliberately avoids `docker buildx` (its docker-container driver needs
|
||||||
|
# nested networking the TrueNAS runners lack — see contexts/ci-cd.md):
|
||||||
|
# instead it cross-builds a single arm64 image via QEMU binfmt and folds
|
||||||
|
# amd64 + arm64 into multi-arch manifest lists under the existing tags.
|
||||||
|
# `continue-on-error` keeps a runner that can't emulate arm64 from
|
||||||
|
# failing the release; the plain amd64 tags pushed above remain valid.
|
||||||
|
- name: Build + publish arm64 (multi-arch manifest, best-effort)
|
||||||
|
if: github.event_name == 'push' && steps.docker-login.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||||
|
TAG="${{ gitea.ref_name }}"
|
||||||
|
REGISTRY="${{ steps.meta.outputs.registry }}"
|
||||||
|
VERSION="${{ steps.meta.outputs.version }}"
|
||||||
|
|
||||||
|
# Register arm64 emulation. If the runner forbids privileged
|
||||||
|
# containers this fails and the whole step is skipped.
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||||
|
|
||||||
|
# Cross-build the arm64 image (QEMU-emulated — slow but uses arm64
|
||||||
|
# manylinux wheels, so no source compilation). Stays in the local
|
||||||
|
# daemon alongside the amd64 image from the previous build step.
|
||||||
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--build-arg APP_VERSION="$VERSION" \
|
||||||
|
--label "org.opencontainers.image.version=$VERSION" \
|
||||||
|
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
|
||||||
|
-t "$REGISTRY:$VERSION-arm64" \
|
||||||
|
./server
|
||||||
|
|
||||||
|
# Fold amd64 + arm64 into a multi-arch manifest list under each
|
||||||
|
# user-facing tag. The arch-suffixed tags remain pullable directly.
|
||||||
|
publish_manifest() {
|
||||||
|
local t="$1"
|
||||||
|
docker tag "$REGISTRY:$t" "$REGISTRY:$t-amd64"
|
||||||
|
docker push "$REGISTRY:$t-amd64"
|
||||||
|
docker tag "$REGISTRY:$VERSION-arm64" "$REGISTRY:$t-arm64"
|
||||||
|
docker push "$REGISTRY:$t-arm64"
|
||||||
|
docker manifest create --amend "$REGISTRY:$t" \
|
||||||
|
"$REGISTRY:$t-amd64" "$REGISTRY:$t-arm64"
|
||||||
|
docker manifest push "$REGISTRY:$t"
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_manifest "$TAG"
|
||||||
|
publish_manifest "$VERSION"
|
||||||
|
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
||||||
|
publish_manifest "latest"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Publish the release (flip draft=false) ─────────────────
|
# ── Publish the release (flip draft=false) ─────────────────
|
||||||
# Runs only after every build job succeeded so users never see a
|
# Runs only after every build job succeeded so users never see a
|
||||||
# release that's missing artifacts or sha256 sidecars (the in-app
|
# release that's missing artifacts or sha256 sidecars (the in-app
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ semantic = true
|
|||||||
# Automatically run `vex update` before search if the index is stale
|
# Automatically run `vex update` before search if the index is stale
|
||||||
auto_update = true
|
auto_update = true
|
||||||
|
|
||||||
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
|
# Embedder used for semantic indexing. IDs: minilm-l6-v2 (default, CPU-fast),
|
||||||
|
# jina-code (code-specialized, GPU-worthy), bge-base-en-v1.5, bge-large-en-v1.5.
|
||||||
# Changing the embedder requires a full reindex.
|
# Changing the embedder requires a full reindex.
|
||||||
# embedder = "minilm-l6-v2"
|
embedder = "jina-code"
|
||||||
|
|||||||
@@ -2,9 +2,40 @@
|
|||||||
|
|
||||||
## Code Search
|
## Code Search
|
||||||
|
|
||||||
**If `ast-index` is available, use it as the PRIMARY code search tool.** It is significantly faster than grep and returns structured, accurate results. Fall back to grep/Glob only when ast-index is not installed, returns empty results, or when searching regex patterns/string literals/comments.
|
**Priority order: `vex` (PRIMARY) → `ast-index` (fallback) → Grep/Glob (last resort).** This repo has a fully-featured `.vex.toml` index. Use vex first for any symbol/definition/usage/call-graph lookup. Fall back to ast-index only when vex legitimately can't help, and to Grep/Glob only for regex patterns, string literals, comments, config files, or unparsed languages.
|
||||||
|
|
||||||
**IMPORTANT for subagents:** When spawning Agent subagents (Plan, Explore, general-purpose, etc.), always instruct them to use `ast-index` via Bash for code search instead of grep/Glob. Example: include "Use `ast-index search`, `ast-index class`, `ast-index usages` etc. via Bash for code search" in the agent prompt.
|
**IMPORTANT — use ALL vex indexing features.** The index is built with every capability enabled, and queries must take advantage of them. Keep them ON and exploit them:
|
||||||
|
|
||||||
|
| Capability | Status | Powers |
|
||||||
|
| ---------- | ------ | ------ |
|
||||||
|
| Semantic embeddings (`jina-code`, 768-dim) | ON | `vex search` (semantic channel), `similar`, `find_similar`, `duplicates` |
|
||||||
|
| Call graph | ON | `vex callers`, `callees`, `paths`, `reachable`, `bundle --mode pr-impact` |
|
||||||
|
| BM25 | ON | hybrid RRF text channel in `vex search` |
|
||||||
|
| Pattern index | ON | `vex pattern` AST-shape matching |
|
||||||
|
| C++ includes | ON | include-graph resolution |
|
||||||
|
| Body tokens (incremental HNSW) | ON | fast incremental reindex |
|
||||||
|
| History | ON | `vex history`, `vex diff <rev>` blame/evolution queries |
|
||||||
|
|
||||||
|
**In-session, use the `mcp__vex__*` MCP tools** (`search`, `show`, `usages`, `callers`, `callees`, `bundle`, `outline`, `implementations`, `similar`, `grep`, `status`, etc.) — MCP output is far cheaper in tokens than `Bash("vex …")`. Drop to Bash `vex` only for CLI-only features (`pattern`, `diff`, `paths`, `reachable`, `bundle`, `history`, `--strict`/`--why` flags), for subagent prompts, or for shell composition.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vex search "query" --semantic # Hybrid semantic + BM25 search
|
||||||
|
vex show <Symbol> # Definition body (prefer over Read)
|
||||||
|
vex usages <Symbol> --strict # Reference sites (AST-precise on T1 langs)
|
||||||
|
vex callers <Function> # Call sites (function-scoped)
|
||||||
|
vex callees <Function> # Outgoing calls
|
||||||
|
vex paths --from <A> --to <B> # Multi-hop call-graph path
|
||||||
|
vex bundle --mode pr-impact --base master # Changed symbols + callers + reachable tests
|
||||||
|
vex pattern '$X async fn returning Response' # AST-shape (metavariables)
|
||||||
|
vex diff master # Symbol-level branch diff
|
||||||
|
vex history <Symbol> # Commit evolution of a symbol
|
||||||
|
```
|
||||||
|
|
||||||
|
**Maintenance:** the index has `auto_update = true`, so it refreshes on stale queries. After a `vex self-update`, rerun `vex index --history --semantic --embedder jina-code --device cuda` so newly-added extractors populate and all features stay enabled. Verify with `vex status` — every capability line should read `yes`.
|
||||||
|
|
||||||
|
**IMPORTANT for subagents:** Subagents don't inherit MCP. When spawning Agent subagents (Plan, Explore, general-purpose, etc.), instruct them to use `vex` via Bash for code search (e.g. include "Use `vex search`, `vex show`, `vex usages`, `vex callers` via Bash for code search; ast-index is the fallback"). Don't tell them to default to grep/Glob.
|
||||||
|
|
||||||
|
**Fallback — `ast-index`** (use only when vex is unavailable):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ast-index search "Query" # Universal search
|
ast-index search "Query" # Universal search
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -57,34 +82,72 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
|||||||
- Real-time FPS, latency, and uptime charts
|
- Real-time FPS, latency, and uptime charts
|
||||||
- Localized in English, Russian, and Chinese
|
- Localized in English, Russian, and Chinese
|
||||||
|
|
||||||
|
### Activity Log
|
||||||
|
|
||||||
|
The **Activity** tab is a persistent, queryable audit log of everything LedGrab has done — entity changes, auth events, device connections, and system actions.
|
||||||
|
|
||||||
|
- Filter by category (auth, device, entity, capture, system), severity, actor, entity type, date range, or free text
|
||||||
|
- Live-append of new events as they happen
|
||||||
|
- Export as CSV or JSON (authentication required)
|
||||||
|
- Entity crosslinks navigate directly to the relevant card
|
||||||
|
- **Retention settings** (Settings → Activity Log): configure max age, max entry count, and toggle recording on/off
|
||||||
|
- **Clear log** (Settings → Activity Log, requires authentication) — audited: a system entry records who cleared the log and when
|
||||||
|
|
||||||
|
> **Note:** The Activity Log is distinct from the **debug Log Viewer** (Settings → General → Open Log Viewer). The Log Viewer is an ephemeral real-time tail of the server's Python log stream (WARNING/ERROR lines, resets on disconnect). The Activity Log is a structured, persistent SQLite-backed record of semantic application events.
|
||||||
|
|
||||||
### 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 +178,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 +196,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 +209,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 +225,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 +277,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
|
|
||||||
|
|||||||
+68
-49
@@ -1,72 +1,91 @@
|
|||||||
## v0.8.0 (2026-05-28)
|
## v0.9.0 (2026-06-23)
|
||||||
|
|
||||||
### User-facing changes
|
A large feature release: a full activity/audit log, two roadmap batches of
|
||||||
|
capture and smart-light improvements, per-pixel control for LIFX/Hue/Nanoleaf,
|
||||||
|
and new outbound integrations (webhooks + Home Assistant MQTT discovery).
|
||||||
|
|
||||||
#### Features
|
### Features
|
||||||
|
|
||||||
##### Android TV — production-readiness pass
|
#### Activity Log
|
||||||
|
- Persistent activity/audit log: storage model with migration, recorder with
|
||||||
|
actor context and retention, and event instrumentation across four
|
||||||
|
categories ([1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f), [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e), [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c))
|
||||||
|
- REST API for list / export / settings / clear ([4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275))
|
||||||
|
- Activity tab with smart filtering, live updates, and export, plus a
|
||||||
|
dashboard widget and settings panel ([9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f), [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21))
|
||||||
|
|
||||||
- **Security:** per-install random API key (persisted, threaded into the embedded server via env, embedded in the pairing QR as a URL-fragment so it never reaches HTTP logs); root-shell injection eliminated via POSIX-quoted `runAsRoot(argv)` overload; broadcast receivers locked to the app package; release builds refuse to silently sign with the debug keystore; crash log retention capped at 10 entries
|
#### Per-pixel smart lights
|
||||||
- **Performance:** single reusable RGBA buffer in `ScreenCapture` / `RootScreenrecord` (eliminates ~15 MB/s GC churn at 30 fps); frame pacer switched to `elapsedRealtimeNanos` with catch-up accumulator (fixes ~30.3 fps drift); capture dimensions derived from source aspect ratio so non-16:9 displays aren't squashed; QR bitmap cached by URL
|
- LIFX multizone (SetExtendedColorZones) and Tile per-pixel streaming,
|
||||||
- **Compatibility:** compileSdk/targetSdk → 35 (Play Store requirement); armeabi-v7a build path; foreground service type declared as `mediaProjection|specialUse` with proper `ServiceCompat.startForeground` promotion; Ethernet > Wi-Fi > VPN > cellular selection in `NetworkUtils`; Android 15 predictive-back via `enableOnBackInvokedCallback`; splash screen API hides Chaquopy cold-start delay
|
auto-detected on connect with single-colour fallback ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||||
- **UI/UX:** all hardcoded English strings localised across en/ru/zh; monochrome notification icon; 320×180 TV banner; ViewStub-based running panel; pulse animator on Running dot; "Starting…" button while probing root; autostart checkbox hidden on unrooted devices
|
- Philips Hue gradient-lightstrip mapping: Entertainment v2 frames keyed by
|
||||||
- **Lifecycle hardening:** `processLock` serialises EOF respawn vs `stop()` to prevent orphaned screenrecord; publish-before-start under `@Synchronized` in `CaptureService.restartRootPipeline` closes the orphan window during watchdog restarts; watchdog give-up bound corrected ([ef1f9ea](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ef1f9ea))
|
channel id, with a `hue_gradient_mode` toggle ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||||
|
- Nanoleaf extControl v2 per-panel UDP streaming (`per_panel` mode) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||||
|
|
||||||
##### Backup format — bundled DB + assets ZIP
|
#### Capture & effects
|
||||||
|
- Linear-light blending and spatio-temporal dithering, opt-in per calibration ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||||
|
- Audio-reactive palette modulation across all 12 procedural effects ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||||
|
- Color-harmony gradient generator (complementary / analogous / triadic / …) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||||
|
|
||||||
- Auto-backups now produce a `.zip` containing `ledgrab.db` plus every file from the assets directory under `assets/` — matching the manual `GET /api/v1/system/backup` download. Restore accepts both `.zip` and legacy `.db` interchangeably
|
#### Automations & integrations
|
||||||
- Partial-write hardening: writes stage to `<name>.partial` then `os.replace` into place — a crash mid-write never leaves a corrupt backup masquerading as valid. Stale `.partial` files from prior crashes are swept on the next run
|
- Solar sunrise/sunset automation trigger (new `utils/solar.py`) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||||
- Symlinks inside the assets directory are skipped (security guard against link targets outside the dir)
|
- Outbound webhook automation action (Discord / IFTTT / Zapier / Node-RED),
|
||||||
- Backups over 500 MB log a warning so operators notice unbounded asset growth before disk fills up
|
SSRF-gated at save and fire time ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||||
- `restart.py` redirects spawned restart script stdout/stderr to `restart.log` and bails out early if the script is missing — silent failures used to vanish into a detached child ([85da2e5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/85da2e5))
|
- Home Assistant MQTT auto-discovery: read-only binary sensors per automation,
|
||||||
|
availability via birth/will, with cleanup on disable/delete ([39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554))
|
||||||
|
- League of Legends poller wired via a `LoLPollManager` + shared runtime state ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||||
|
- `auth.expose_docs` flag (default off) to view `/docs`, `/redoc`, and
|
||||||
|
`/openapi.json` without a token; all real endpoints stay protected ([126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2))
|
||||||
|
|
||||||
##### Spectrum-aperture icon set
|
### Bug Fixes
|
||||||
|
- Pre-release review hardening: solar timezone crash, webhook header CRLF,
|
||||||
- Regenerated icon family from a single Pillow script: rounded-square aperture traced by a continuous RGB color-wheel stroke over a vignette canvas with chromatic bloom. 4× supersampled then downsampled per output for crispness
|
MQTT topic-prefix injection, thread-safe `get_stats` copy, MQTT discovery
|
||||||
- New 256 px transparent-background **tray variant** — taskbar icon reads cleanly against light themes instead of showing a dark tile
|
lock, `reactive_mode` Literal, and calibration-modal accessibility ([0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db))
|
||||||
- `icon.ico` now embeds 16/24/32/48/64/128/256 frames sourced from the transparent master (fixes the dark-square halo on light Windows themes)
|
- Comprehensive review fixes across security, concurrency, performance,
|
||||||
- Maskable 512 variant safe-area padded for PWA round-crops ([3645216](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3645216))
|
Android, and UI ([17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0))
|
||||||
|
- Activity Log polish: accessible export menu, i18n placeholders, dashboard
|
||||||
#### Bug Fixes
|
section reconciliation, column alignment, ticking time, and no spinner
|
||||||
|
flash on instant filtering ([3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3), [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06), [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8), [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca), [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c))
|
||||||
- **Notification sound dropdowns:** both the per-app override list and the main row now always render the EntitySelect (was silently inert before any sound assets were registered) and offer "no sound" as a first-class option via `allowNone` ([1f95993](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1f95993))
|
|
||||||
- **CSS editor:** `notification_sound` and `notification_volume` are now persisted on save — they were silently dropped from the payload before ([66b85b0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/66b85b0))
|
|
||||||
- **Python 3.13 ctypes:** Win32 message-pump prototypes (`GetMessageW` / `TranslateMessage` / `DispatchMessageW`) now share a single `LPMSG = POINTER(wintypes.MSG)` class across `WindowsShutdownGuard` and `PlatformDetector` — fixes the `expected LP_MSG instance instead of pointer to _MSG` error and the resulting shutdown-guard / display-power-monitor failure on 3.13 ([e4d24a0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4d24a0), [0d840ad](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0d840ad))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Development / Internal
|
### Development / Internal
|
||||||
|
|
||||||
#### CI/Build
|
#### CI/Build
|
||||||
|
- Best-effort arm64 multi-arch Docker manifest via QEMU + `docker manifest`
|
||||||
|
(amd64 path untouched) ([6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25))
|
||||||
|
|
||||||
- `release.yml` now creates the Gitea release as a **draft** and only flips `draft=false` once every build job (Windows, Linux, Docker) has uploaded its artifacts and sha256 sidecars — users never see a release page that's missing assets, which would have broken the in-app updater ([bc42604](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bc42604))
|
#### Chores
|
||||||
|
- Activity Log feature plan/subplan scaffold, post-merge cleanup, and context
|
||||||
|
graduated into CLAUDE.md ([1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6), [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235))
|
||||||
|
|
||||||
#### Refactoring
|
> Tests: ~180 new unit tests added across the activity log, roadmap features,
|
||||||
|
> and integrations. Release gate green: ruff + tsc + build clean,
|
||||||
- **Shared API client + automations registry (audit M7, H8):** new `core/api-client.ts` wraps `fetchWithAuth` with typed `apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete`; 35 feature/core files migrated. FastAPI validation-array detail unwrap hardened. Automations editor's two hand-rolled `RuleType` dispatch ladders converted to `Record<RuleType, ...>` registries with an import-time exhaustiveness check ([bb3a316](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bb3a316))
|
> **pytest 2739 passed / 2 skipped**.
|
||||||
- **types.ts split (audit H6):** 1140 LOC `types.ts` split into 18 per-entity files under `types/`, original file kept as a pure re-export barrel — 102 type exports preserved with no import sites changed ([49c35a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49c35a2))
|
|
||||||
|
|
||||||
#### Documentation
|
|
||||||
|
|
||||||
- `REVIEW_RECONCILE_NOTES.md` — design doc for the dashboard innerHTML reconciliation work: bug-class analysis, latent-site inventory, decision ladder (helper / hand-rolled cells / Lit), and recommendation to migrate polling-heavy modules to Lit with `entity-events.ts` tab reconciliation sequenced first ([10eb24b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/10eb24b))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>All Commits (11)</summary>
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
| Hash | Message | Author |
|
||||||
| ---- | ------- | ------ |
|
|------|---------|--------|
|
||||||
| [0d840ad](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0d840ad) | fix(ctypes): share wintypes.MSG with platform_detector to avoid argtype races | alexei.dolgolyov |
|
| [0c096db](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0c096db) | fix: address pre-release review findings (2026-06-23) | alexei.dolgolyov |
|
||||||
| [1f95993](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1f95993) | fix(notification): allow clearing the sound on per-app overrides and main row | alexei.dolgolyov |
|
| [39b0554](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/39b0554) | feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations | alexei.dolgolyov |
|
||||||
| [10eb24b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/10eb24b) | docs: dashboard innerHTML reconciliation review notes | alexei.dolgolyov |
|
| [6745e25](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6745e25) | feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations | alexei.dolgolyov |
|
||||||
| [66b85b0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/66b85b0) | fix(css-editor): persist notification_sound + notification_volume | alexei.dolgolyov |
|
| [126d8f2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/126d8f2) | feat(auth): add auth.expose_docs flag to view API docs without a token | alexei.dolgolyov |
|
||||||
| [bc42604](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bc42604) | ci(release): publish release only after every build job uploads assets | alexei.dolgolyov |
|
| [e584235](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e584235) | chore(activity-log): post-merge cleanup + graduate context to CLAUDE.md | alexei.dolgolyov |
|
||||||
| [3645216](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3645216) | feat(icons): spectrum aperture icon set + dedicated tray variant | alexei.dolgolyov |
|
| [077c99c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/077c99c) | fix(activity-log): no spinner flash on instant filtering | alexei.dolgolyov |
|
||||||
| [85da2e5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/85da2e5) | feat(backup): bundle assets in ZIP + partial-write hardening + restart log | alexei.dolgolyov |
|
| [ae74cca](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ae74cca) | fix(activity-log): UI polish - accessible export menu, i18n placeholders, zero-result spinner fix | alexei.dolgolyov |
|
||||||
| [e4d24a0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e4d24a0) | fix(ctypes): pin LPMSG across MSG-pump prototypes for Python 3.13 | alexei.dolgolyov |
|
| [77284e8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/77284e8) | fix(activity-log): dashboard section reconciliation + activity column alignment | alexei.dolgolyov |
|
||||||
| [bb3a316](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/bb3a316) | refactor(frontend): shared API client + automations registry (audit M7, H8) | alexei.dolgolyov |
|
| [ff1ff06](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ff1ff06) | fix(activity-log): post-test polish - localize descriptions, dashboard widget, ticking time | alexei.dolgolyov |
|
||||||
| [49c35a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/49c35a2) | refactor(frontend): split types.ts into 18 per-entity files (audit H6) | alexei.dolgolyov |
|
| [3dd1ac3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3dd1ac3) | fix(activity-log): final-review fixes - crosslink keys + sanitize parity | alexei.dolgolyov |
|
||||||
| [ef1f9ea](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ef1f9ea) | feat(android): production-readiness pass — security, perf, compat, UI/UX | alexei.dolgolyov |
|
| [6e1dd21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e1dd21) | feat(activity-log): phase 6 - dashboard widget + settings panel + docs | alexei.dolgolyov |
|
||||||
|
| [9a0137f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9a0137f) | feat(activity-log): phase 5 - Activity tab (smart filtering, live updates, export) | alexei.dolgolyov |
|
||||||
|
| [4a09275](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4a09275) | feat(activity-log): phase 4 - REST API (list/export/settings/clear) | alexei.dolgolyov |
|
||||||
|
| [25c613c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/25c613c) | feat(activity-log): phase 3 - event instrumentation (4 categories) | alexei.dolgolyov |
|
||||||
|
| [726f39e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/726f39e) | feat(activity-log): phase 2 - recorder, actor context, retention, lifecycle | alexei.dolgolyov |
|
||||||
|
| [1ac4a0f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ac4a0f) | feat(activity-log): phase 1 - storage model, migration, repository | alexei.dolgolyov |
|
||||||
|
| [1afe7d6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1afe7d6) | chore(activity-log): scaffold feature plan and phase subplans | alexei.dolgolyov |
|
||||||
|
| [17dd2e0](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17dd2e0) | fix: resolve comprehensive review findings (security, concurrency, perf, Android, UI) | alexei.dolgolyov |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -201,9 +201,19 @@ caller off the legacy path, then delete it.
|
|||||||
- [x] Field on `device_config.MQTTConfig`
|
- [x] Field on `device_config.MQTTConfig`
|
||||||
- [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()`
|
- [x] `MQTTLEDClient` acquires runtime in `connect()`, releases in `close()`
|
||||||
- [x] Provider threads `mqtt_manager` via `ProviderDeps`
|
- [x] Provider threads `mqtt_manager` via `ProviderDeps`
|
||||||
- [ ] Device editor: MQTT source picker shown for `device_type=mqtt` *(UI still
|
- [x] Device editor: MQTT source picker shown for `device_type=mqtt`. Turned
|
||||||
pending — backend accepts the field, but the device-create form doesn't
|
out the API layer was *also* missing it (the TODO's "backend accepts the
|
||||||
expose it yet)*
|
field" was wrong — `mqtt_source_id` lived in `device_store` +
|
||||||
|
`device_config.MQTTConfig` but was dropped by `DeviceCreate/Update/Response`
|
||||||
|
and the routes). Added: schema fields + route threading + referenced-source
|
||||||
|
validation (`_validate_mqtt_source_exists`, mirrors output_targets) +
|
||||||
|
`except HTTPException: raise` guard in `update_device` (it was masking its
|
||||||
|
own 4xx as 500). Frontend: broker `EntitySelect` (reusing `mqttSourcesCache`)
|
||||||
|
in both the add-device (`device-discovery.ts`) and settings
|
||||||
|
(`devices.ts`) modals — shown for `device_type=mqtt`, wired into
|
||||||
|
load/save/validate/dirty-check/clone. Empty = "first available broker".
|
||||||
|
4 regression tests in `test_devices_routes.py::TestMqttSourceId`; full
|
||||||
|
suite 1567 passing; en/ru/zh keys added.
|
||||||
|
|
||||||
### Phase 5 — `AutomationEngine`
|
### Phase 5 — `AutomationEngine`
|
||||||
|
|
||||||
@@ -213,8 +223,11 @@ caller off the legacy path, then delete it.
|
|||||||
### Phase 6 — `api/routes/system.py`
|
### Phase 6 — `api/routes/system.py`
|
||||||
|
|
||||||
- [x] Replace integration status with `mqtt_manager.get_all_sources_status()`
|
- [x] Replace integration status with `mqtt_manager.get_all_sources_status()`
|
||||||
- [ ] Update frontend dashboard payload (MQTT widget now expects a list of
|
- [x] Update frontend dashboard payload (MQTT widget now expects a list of
|
||||||
sources instead of a single `enabled`/`connected` pair — surface in UI)
|
sources instead of a single `enabled`/`connected` pair — surface in UI).
|
||||||
|
Done: `dashboard.ts` `_renderMQTTIntegrationCard` renders one card per
|
||||||
|
`mqttStatus.connections` entry; `_updateIntegrationsInPlace` iterates the
|
||||||
|
list.
|
||||||
|
|
||||||
### Phase 7 — Startup migration
|
### Phase 7 — Startup migration
|
||||||
|
|
||||||
@@ -980,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.0"
|
versionName = "0.9.0"
|
||||||
|
|
||||||
// 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 —
|
||||||
@@ -210,6 +210,10 @@ dependencies {
|
|||||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
// QR code generation for displaying server URL on TV
|
// QR code generation for displaying server URL on TV
|
||||||
implementation("com.google.zxing:core:3.5.3")
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
// EncryptedSharedPreferences (Android Keystore-backed) for the per-install
|
||||||
|
// server API key (see ApiKeyManager). Falls back to plain SharedPreferences
|
||||||
|
// when the keystore is unavailable.
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
|
||||||
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
|
||||||
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
android:usesPermissionFlags="neverForLocation"
|
android:usesPermissionFlags="neverForLocation"
|
||||||
tools:targetApi="s" />
|
tools:targetApi="s" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
|
||||||
|
tools:targetApi="s" />
|
||||||
|
|
||||||
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
@@ -35,10 +36,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 +102,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 +143,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
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.ledgrab.android
|
package com.ledgrab.android
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,8 +26,23 @@ import java.security.SecureRandom
|
|||||||
*/
|
*/
|
||||||
class ApiKeyManager(context: Context) {
|
class ApiKeyManager(context: Context) {
|
||||||
|
|
||||||
private val prefs = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
|
// Prefer Android-Keystore-backed EncryptedSharedPreferences for the API
|
||||||
|
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
|
||||||
|
// or absent keystore, or a key got corrupted), creation throws — fall back
|
||||||
|
// to plain SharedPreferences so a keystore failure NEVER bricks the local
|
||||||
|
// API key (which would 401 every LAN client).
|
||||||
|
private val prefs: SharedPreferences
|
||||||
|
|
||||||
|
init {
|
||||||
|
val (store, isEncrypted) = buildPrefs(appContext)
|
||||||
|
prefs = store
|
||||||
|
// Only run the plain→encrypted migration when the encrypted store is
|
||||||
|
// actually available; on the degraded plain path there is nothing to
|
||||||
|
// migrate INTO (and recoverLegacyKey reads the backup directly).
|
||||||
|
if (isEncrypted) migrateLegacyKeyIfPresent()
|
||||||
|
}
|
||||||
|
|
||||||
// Once we've materialised a key in this process, cache it so
|
// Once we've materialised a key in this process, cache it so
|
||||||
// subsequent reads don't hit prefs and don't risk re-checking
|
// subsequent reads don't hit prefs and don't risk re-checking
|
||||||
@@ -60,6 +78,20 @@ class ApiKeyManager(context: Context) {
|
|||||||
cached = existing
|
cached = existing
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
// Before minting a fresh key, fall back to any key still in the
|
||||||
|
// legacy plain store (covers a failed/partial encrypted migration:
|
||||||
|
// commit() can return false WITHOUT throwing, so migration may have
|
||||||
|
// left the live key only in the legacy file). Rotating the
|
||||||
|
// per-install key would 401 every already-paired client, so we
|
||||||
|
// generate a brand-new key ONLY when no key exists anywhere.
|
||||||
|
recoverLegacyKey()?.let { recovered ->
|
||||||
|
// Best-effort persist into the encrypted store; cache regardless
|
||||||
|
// so we still return the recovered key if the write keeps failing.
|
||||||
|
runCatching { prefs.edit().putString(KEY_API_KEY, recovered).commit() }
|
||||||
|
cached = recovered
|
||||||
|
Log.i(TAG, "Recovered existing API key from legacy storage")
|
||||||
|
return recovered
|
||||||
|
}
|
||||||
val generated = generateKey()
|
val generated = generateKey()
|
||||||
// commit() (synchronous disk write) on the FIRST write so
|
// commit() (synchronous disk write) on the FIRST write so
|
||||||
// the key is durable before MainActivity encodes it into a
|
// the key is durable before MainActivity encodes it into a
|
||||||
@@ -74,6 +106,115 @@ class ApiKeyManager(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the backing store, preferring EncryptedSharedPreferences. Returns
|
||||||
|
* (store, isEncrypted). Any keystore failure falls back to the plain prefs
|
||||||
|
* file so the local API key is never lost on a broken-keystore device.
|
||||||
|
*/
|
||||||
|
private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> {
|
||||||
|
return try {
|
||||||
|
createEncrypted(context) to true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// The keystore can become invalidated (OS upgrade, device restore,
|
||||||
|
// OEM keystore bug), after which create() throws on EVERY launch and
|
||||||
|
// the corrupt encrypted file is never cleaned up — degrading to plain
|
||||||
|
// prefs forever and (because the live key was only in the encrypted
|
||||||
|
// store) rotating the per-install key on the next mint, 401-ing every
|
||||||
|
// paired client. Self-heal once: delete the corrupt store + master key
|
||||||
|
// alias and retry create() before degrading.
|
||||||
|
Log.w(TAG, "EncryptedSharedPreferences unavailable, attempting one-time reset: ${e.message}")
|
||||||
|
runCatching {
|
||||||
|
context.deleteSharedPreferences(ENCRYPTED_PREFS_NAME)
|
||||||
|
runCatching {
|
||||||
|
val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
if (ks.containsAlias(MasterKey.DEFAULT_MASTER_KEY_ALIAS)) {
|
||||||
|
ks.deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Master-key alias cleanup failed: ${it.message}") }
|
||||||
|
createEncrypted(context) to true
|
||||||
|
}.getOrElse {
|
||||||
|
// Still failing after reset — degrade to plain prefs rather than
|
||||||
|
// crashing. Worst case the key is stored unencrypted on a
|
||||||
|
// single-user TV box, which is the pre-existing behaviour.
|
||||||
|
Log.w(TAG, "EncryptedSharedPreferences still unavailable after reset, using plain prefs: ${it.message}")
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createEncrypted(context: Context): SharedPreferences {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
return EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
ENCRYPTED_PREFS_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration: if a key exists in the legacy plain-text prefs file
|
||||||
|
* (from before encrypted storage), copy it into the encrypted store and
|
||||||
|
* remove the plain copy. Preserves the existing key so already-scanned QR
|
||||||
|
* clients keep working — generating a fresh key here would silently 401
|
||||||
|
* every LAN client (see the Data Migration Policy in CLAUDE.md).
|
||||||
|
*/
|
||||||
|
private fun migrateLegacyKeyIfPresent() {
|
||||||
|
// Don't migrate if the encrypted store already holds a key.
|
||||||
|
if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return
|
||||||
|
runCatching {
|
||||||
|
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val legacyKey = legacy.getString(KEY_API_KEY, null)
|
||||||
|
if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) {
|
||||||
|
// commit() returns false on write failure WITHOUT throwing, so the
|
||||||
|
// runCatching wrapper alone does NOT protect this path. Verify the
|
||||||
|
// encrypted store both committed AND reads back the identical value
|
||||||
|
// before touching the legacy copy — otherwise a silent write
|
||||||
|
// failure could delete the only surviving copy of the key and
|
||||||
|
// rotate it on next launch (401s every paired client — the exact
|
||||||
|
// silent-data-loss the Data Migration Policy forbids).
|
||||||
|
val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit()
|
||||||
|
if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) {
|
||||||
|
// Keep the value as a .migrated backup (don't hard-delete) per
|
||||||
|
// the migration policy; remove only the live legacy key so the
|
||||||
|
// plaintext copy no longer answers reads.
|
||||||
|
legacy.edit()
|
||||||
|
.putString(KEY_API_KEY_MIGRATED, legacyKey)
|
||||||
|
.remove(KEY_API_KEY)
|
||||||
|
.apply()
|
||||||
|
Log.i(TAG, "Migrated API key from plain to encrypted storage")
|
||||||
|
} else {
|
||||||
|
// Leave the legacy key untouched; getOrCreateKey() will recover
|
||||||
|
// it via recoverLegacyKey() rather than minting a fresh one.
|
||||||
|
Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover a still-present key from the legacy plain store — either the live
|
||||||
|
* key (failed/never-run migration) or the `.migrated` backup. Returns null
|
||||||
|
* only when no valid key survives.
|
||||||
|
*
|
||||||
|
* This MUST run on the degraded plain-prefs path too (not just the encrypted
|
||||||
|
* path): after a successful migration the live key is moved to the
|
||||||
|
* `.migrated` backup in this same plain file, so when the keystore later
|
||||||
|
* fails and we degrade to plain prefs, the backup is the only surviving
|
||||||
|
* copy. Returning null here (the previous `if (!encrypted) return null`
|
||||||
|
* guard) would mint a fresh key and rotate the per-install key, 401-ing every
|
||||||
|
* paired client.
|
||||||
|
*/
|
||||||
|
private fun recoverLegacyKey(): String? {
|
||||||
|
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val candidate = legacy.getString(KEY_API_KEY, null)
|
||||||
|
?: legacy.getString(KEY_API_KEY_MIGRATED, null)
|
||||||
|
return candidate?.takeIf { it.length >= MIN_KEY_LENGTH }
|
||||||
|
}
|
||||||
|
|
||||||
private fun generateKey(): String {
|
private fun generateKey(): String {
|
||||||
val bytes = ByteArray(KEY_BYTES)
|
val bytes = ByteArray(KEY_BYTES)
|
||||||
SecureRandom().nextBytes(bytes)
|
SecureRandom().nextBytes(bytes)
|
||||||
@@ -88,7 +229,11 @@ class ApiKeyManager(context: Context) {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ApiKeyManager"
|
private const val TAG = "ApiKeyManager"
|
||||||
private const val PREFS_NAME = "ledgrab_auth"
|
private const val PREFS_NAME = "ledgrab_auth"
|
||||||
|
private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc"
|
||||||
private const val KEY_API_KEY = "api_key"
|
private const val KEY_API_KEY = "api_key"
|
||||||
|
// Backup of a migrated legacy key, kept in the plain store per the
|
||||||
|
// Data Migration Policy (never hard-delete user data on rename/move).
|
||||||
|
private const val KEY_API_KEY_MIGRATED = "api_key_migrated"
|
||||||
private const val KEY_BYTES = 32
|
private const val KEY_BYTES = 32
|
||||||
private const val MIN_KEY_LENGTH = 32
|
private const val MIN_KEY_LENGTH = 32
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,12 +103,32 @@ object BleBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bleHandler.post { scanner.startScan(callback) }
|
// startScan runs on the BLE handler thread; a denied
|
||||||
|
// BLUETOOTH_SCAN throws SecurityException there, which would
|
||||||
|
// crash the whole process (an uncaught exception on a handler
|
||||||
|
// thread is fatal). Catch it inside the posted body and report.
|
||||||
|
bleHandler.post {
|
||||||
|
try {
|
||||||
|
scanner.startScan(callback)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "BLUETOOTH_SCAN permission denied — scan skipped", e)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "BLE startScan failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
Thread.sleep(timeoutMs)
|
Thread.sleep(timeoutMs)
|
||||||
} catch (_: InterruptedException) {
|
} catch (_: InterruptedException) {
|
||||||
Thread.currentThread().interrupt()
|
Thread.currentThread().interrupt()
|
||||||
} finally {
|
} finally {
|
||||||
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
|
bleHandler.post {
|
||||||
|
try {
|
||||||
|
scanner.stopScan(callback)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "BLUETOOTH_SCAN permission denied — stopScan skipped", e)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "BLE stopScan failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return seen.values.toList()
|
return seen.values.toList()
|
||||||
}
|
}
|
||||||
@@ -136,7 +156,18 @@ object BleBridge {
|
|||||||
newState == BluetoothProfile.STATE_CONNECTED
|
newState == BluetoothProfile.STATE_CONNECTED
|
||||||
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
||||||
Log.d(TAG, "GATT connected to $address, discovering services")
|
Log.d(TAG, "GATT connected to $address, discovering services")
|
||||||
|
// Runs on the BLE handler thread; a denied
|
||||||
|
// BLUETOOTH_CONNECT throws SecurityException here, which
|
||||||
|
// would crash the process. Catch and fail the connect.
|
||||||
|
try {
|
||||||
gatt.discoverServices()
|
gatt.discoverServices()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "BLUETOOTH_CONNECT denied during discoverServices", e)
|
||||||
|
readyDeferred.complete(false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "discoverServices failed: ${e.message}")
|
||||||
|
readyDeferred.complete(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -102,19 +105,69 @@ class CaptureService : Service() {
|
|||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
|
||||||
|
|
||||||
// CRITICAL: startForeground must be called IMMEDIATELY — before
|
// CRITICAL (Android 14+): for the MediaProjection path, validate the
|
||||||
// any other work, especially before getMediaProjection(). The
|
// projection token BEFORE promoting to a foreground service with the
|
||||||
// service type must match the work; pass it explicitly via
|
// mediaProjection FGS type. On service recreation (system redelivery
|
||||||
// ServiceCompat so we stay compatible back to API 24.
|
// or a stale relaunch) the consent token is gone — promoting first and
|
||||||
|
// then discovering the dead token causes a spurious foreground-service
|
||||||
|
// start + immediate stop, which on strict OEMs flickers the
|
||||||
|
// notification or trips a stopSelf loop. Bail out cleanly here, before
|
||||||
|
// startForeground, when the MediaProjection consent data is missing.
|
||||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
|
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
|
||||||
val url = "http://$localIp:$SERVER_PORT"
|
val url = "http://$localIp:$SERVER_PORT"
|
||||||
|
|
||||||
|
val mediaProjectionResultData: Intent? =
|
||||||
|
if (!useRoot) extractProjectionResultData(intent) else null
|
||||||
|
if (!useRoot && (intent == null || mediaProjectionResultData == null)) {
|
||||||
|
// MediaProjection mode can't recover from a redelivery —
|
||||||
|
// the consent token in the original intent is single-use.
|
||||||
|
//
|
||||||
|
// We were launched via startForegroundService(), so the OS REQUIRES
|
||||||
|
// a startForeground() within ~5s even on this immediate-stop path,
|
||||||
|
// or it raises the fatal ForegroundServiceDidNotStartInTimeException.
|
||||||
|
// Promote with a benign SPECIAL_USE type (NOT mediaProjection — we
|
||||||
|
// have no valid consent token, and requesting that type without an
|
||||||
|
// active projection is exactly what we're avoiding) just long enough
|
||||||
|
// to satisfy the contract, then stop.
|
||||||
|
Log.w(TAG, "MediaProjection start without a valid consent token — stopping")
|
||||||
|
runCatching {
|
||||||
|
val bailType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(url), bailType)
|
||||||
|
}.onFailure { Log.w(TAG, "Bail-path startForeground failed: ${it.message}") }
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
// startForeground must be called IMMEDIATELY after the token check —
|
||||||
|
// before any heavier work like getMediaProjection(). The service type
|
||||||
|
// must match the work; pass it explicitly via ServiceCompat so we stay
|
||||||
|
// compatible back to API 24. The MEDIA_PROJECTION type is only used
|
||||||
|
// here once resultData is confirmed non-null (checked above).
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -135,20 +188,13 @@ class CaptureService : Service() {
|
|||||||
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
// otherwise `isRunning=true` sticks forever when startForeground throws.
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
if (intent == null && !useRoot) {
|
|
||||||
// MediaProjection mode can't recover from a redelivery —
|
|
||||||
// the consent token in the original intent is single-use.
|
|
||||||
Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
|
|
||||||
isRunning = false
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (useRoot) {
|
if (useRoot) {
|
||||||
startRootCapture(url)
|
startRootCapture(url)
|
||||||
} else {
|
} else {
|
||||||
startMediaProjectionCapture(intent!!, url)
|
// mediaProjectionResultData is guaranteed non-null here — the
|
||||||
|
// token was validated before startForeground above.
|
||||||
|
startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start capture", e)
|
Log.e(TAG, "Failed to start capture", e)
|
||||||
@@ -277,21 +323,25 @@ class CaptureService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startMediaProjectionCapture(intent: Intent, url: String) {
|
/**
|
||||||
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
* Extract the single-use MediaProjection consent token from the start
|
||||||
|
* intent, or null if the intent is missing/redelivered without it.
|
||||||
|
* Called BEFORE startForeground so the mediaProjection FGS type is only
|
||||||
|
* ever requested when a valid token is present (see onStartCommand).
|
||||||
|
*/
|
||||||
|
private fun extractProjectionResultData(intent: Intent?): Intent? {
|
||||||
|
if (intent == null) return null
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
||||||
} else {
|
} else {
|
||||||
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
intent.getParcelableExtra(EXTRA_RESULT_DATA)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resultData == null) {
|
|
||||||
Log.e(TAG, "No MediaProjection result data")
|
|
||||||
stopSelf()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) {
|
||||||
|
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||||
|
|
||||||
val projectionManager =
|
val projectionManager =
|
||||||
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||||
val projection = projectionManager.getMediaProjection(resultCode, resultData)
|
val projection = projectionManager.getMediaProjection(resultCode, resultData)
|
||||||
@@ -338,6 +388,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 +420,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,178 @@
|
|||||||
|
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.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.RejectedExecutionException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
//
|
||||||
|
// Tied to the listener-connection lifecycle (onListenerConnected /
|
||||||
|
// onListenerDisconnected), NOT onDestroy: this is a system-rebindable
|
||||||
|
// service, so it can be connected/disconnected multiple times across a
|
||||||
|
// single onCreate..onDestroy span. Managing the executor here — combined
|
||||||
|
// with the runCatching guard at the submit site — keeps a notification
|
||||||
|
// that races teardown from triggering RejectedExecutionException on a
|
||||||
|
// shut-down executor. @Volatile so the connect/disconnect callbacks (which
|
||||||
|
// may run on a different thread than onNotificationPosted) publish safely.
|
||||||
|
@Volatile private var pushExecutor: ExecutorService? = null
|
||||||
|
|
||||||
|
// Guards executor creation so the lazy submit-site fallback and
|
||||||
|
// onListenerConnected can't race two executors into existence.
|
||||||
|
private val executorLock = Any()
|
||||||
|
|
||||||
|
// Tracks whether the listener is currently connected. ensureExecutor() only
|
||||||
|
// CREATES a new executor while connected — otherwise a notification racing
|
||||||
|
// onListenerDisconnected (which nulls pushExecutor) would spin up a fresh
|
||||||
|
// executor that nothing reaps until the next disconnect cycle (a thread leak).
|
||||||
|
@Volatile private var connected: Boolean = false
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// Obtain (creating if needed) the executor. onListenerConnected normally
|
||||||
|
// creates it, but that callback is not reliably invoked on every
|
||||||
|
// OEM/version (re)bind, and a notification can arrive before it fires —
|
||||||
|
// lazily creating here keeps a missing/late onListenerConnected from
|
||||||
|
// permanently disabling notification forwarding. A late submit onto an
|
||||||
|
// executor that onListenerDisconnected is shutting down throws
|
||||||
|
// RejectedExecutionException — guard with runCatching so a notification
|
||||||
|
// racing teardown can never crash this system-bound service.
|
||||||
|
val executor = ensureExecutor() ?: run {
|
||||||
|
Log.d(TAG, "no executor (listener disconnected) — skipping push")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
executor.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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
if (e is RejectedExecutionException) {
|
||||||
|
Log.d(TAG, "push rejected — listener disconnecting")
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 }
|
||||||
|
// Only cache SUCCESSFUL resolutions. Caching the package-name fallback
|
||||||
|
// would permanently pin a wrong label if the PackageManager lookup
|
||||||
|
// failed transiently (e.g. the app was mid-install / still updating).
|
||||||
|
val resolved = runCatching {
|
||||||
|
val info = packageManager.getApplicationInfo(pkg, 0)
|
||||||
|
packageManager.getApplicationLabel(info).toString()
|
||||||
|
}.getOrNull()
|
||||||
|
if (resolved != null) {
|
||||||
|
labelCache[pkg] = resolved
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the push executor, creating it under [executorLock] if absent AND
|
||||||
|
* the listener is connected. Returns null when disconnected so a notification
|
||||||
|
* racing teardown neither submits onto a shutting-down executor nor spins up
|
||||||
|
* a stray one. Safe against a concurrent onListenerConnected/onNotificationPosted
|
||||||
|
* race (single executor) and against a missing onListenerConnected callback.
|
||||||
|
*/
|
||||||
|
private fun ensureExecutor(): ExecutorService? {
|
||||||
|
pushExecutor?.let { return it }
|
||||||
|
synchronized(executorLock) {
|
||||||
|
if (!connected) return null
|
||||||
|
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onListenerConnected() {
|
||||||
|
Log.i(TAG, "Notification listener connected")
|
||||||
|
// Spin up the push executor on connect. The system can disconnect and
|
||||||
|
// later reconnect this service without destroying it, so own the
|
||||||
|
// executor here rather than in onCreate/onDestroy. onNotificationPosted
|
||||||
|
// also lazily creates it (via ensureExecutor) in case this callback is
|
||||||
|
// late or skipped on some ROMs.
|
||||||
|
connected = true
|
||||||
|
ensureExecutor()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onListenerDisconnected() {
|
||||||
|
Log.i(TAG, "Notification listener disconnected")
|
||||||
|
// Mark disconnected BEFORE nulling the executor so a racing ensureExecutor
|
||||||
|
// sees !connected and skips creating a replacement. Tear the executor
|
||||||
|
// down; a fresh one is created on the next onListenerConnected.
|
||||||
|
connected = false
|
||||||
|
pushExecutor?.let { exec ->
|
||||||
|
pushExecutor = null
|
||||||
|
exec.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
// Defensive: onListenerDisconnected normally clears this first, but
|
||||||
|
// shut down here too in case onDestroy fires without a prior disconnect.
|
||||||
|
connected = false
|
||||||
|
pushExecutor?.shutdown()
|
||||||
|
pushExecutor = null
|
||||||
|
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
|
||||||
@@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +55,12 @@ 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 REQUEST_BLUETOOTH = 1005
|
||||||
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 +70,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 +115,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 +137,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 +162,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +191,13 @@ class MainActivity : Activity() {
|
|||||||
toggleButton.text = getString(R.string.btn_starting)
|
toggleButton.text = getString(R.string.btn_starting)
|
||||||
statusText.text = getString(R.string.status_checking_root)
|
statusText.text = getString(R.string.status_checking_root)
|
||||||
uiScope.launch(Dispatchers.IO) {
|
uiScope.launch(Dispatchers.IO) {
|
||||||
val rooted = Root.requestGrant()
|
// runInterruptible so a config change (rotation) during the
|
||||||
|
// up-to-10s `su` probe cancels the coroutine AND interrupts the
|
||||||
|
// blocking probe thread — Root.requestGrant honours the interrupt,
|
||||||
|
// destroys the su child, and rethrows, so we don't leak the
|
||||||
|
// process + drain thread. Without this, IO-dispatcher cancellation
|
||||||
|
// would not interrupt the blocking waitFor().
|
||||||
|
val rooted = runInterruptible { Root.requestGrant() }
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
toggleButton.isEnabled = true
|
toggleButton.isEnabled = true
|
||||||
toggleButton.text = originalText
|
toggleButton.text = originalText
|
||||||
@@ -196,6 +220,9 @@ class MainActivity : Activity() {
|
|||||||
|
|
||||||
private fun startRootCaptureService() {
|
private fun startRootCaptureService() {
|
||||||
ensureNotificationPermission()
|
ensureNotificationPermission()
|
||||||
|
ensureNotificationListenerAccess()
|
||||||
|
ensureCameraPermission()
|
||||||
|
ensureBluetoothPermissions()
|
||||||
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
|
||||||
updateUI()
|
updateUI()
|
||||||
}
|
}
|
||||||
@@ -215,6 +242,10 @@ class MainActivity : Activity() {
|
|||||||
|
|
||||||
private fun startCaptureService(resultCode: Int, resultData: Intent) {
|
private fun startCaptureService(resultCode: Int, resultData: Intent) {
|
||||||
ensureNotificationPermission()
|
ensureNotificationPermission()
|
||||||
|
ensureNotificationListenerAccess()
|
||||||
|
ensureAudioPermission()
|
||||||
|
ensureCameraPermission()
|
||||||
|
ensureBluetoothPermissions()
|
||||||
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 +502,152 @@ 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request BLUETOOTH_SCAN + BLUETOOTH_CONNECT (API 31+) so the embedded
|
||||||
|
* server can discover and drive BLE LED controllers (SP110E / Triones /
|
||||||
|
* Zengge). On API < 31 these are install-time legacy permissions
|
||||||
|
* (BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION, maxSdk=30) and
|
||||||
|
* need no runtime grant — so this is a no-op there. Fire-and-forget,
|
||||||
|
* like [ensureAudioPermission]: screen capture works without BLE, and
|
||||||
|
* BleBridge degrades gracefully (empty scan / failed connect) when the
|
||||||
|
* grant is denied, so we don't block on the result. If first granted
|
||||||
|
* here, BLE devices become reachable on the next scan/connect.
|
||||||
|
*/
|
||||||
|
private fun ensureBluetoothPermissions() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||||
|
val needed = listOf(
|
||||||
|
Manifest.permission.BLUETOOTH_SCAN,
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT,
|
||||||
|
).filter {
|
||||||
|
checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (needed.isEmpty()) return
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
requestPermissions(needed.toTypedArray(), REQUEST_BLUETOOTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit
|
|||||||
object Root {
|
object Root {
|
||||||
private const val TAG = "Root"
|
private const val TAG = "Root"
|
||||||
|
|
||||||
|
// Slice length for the cancellation-aware su probe wait loop. Short
|
||||||
|
// enough that coroutine cancellation is honoured promptly, long enough
|
||||||
|
// to avoid busy-spinning while Magisk's grant dialog is up.
|
||||||
|
private const val POLL_SLICE_MS = 100L
|
||||||
|
|
||||||
private val SU_PATHS = listOf(
|
private val SU_PATHS = listOf(
|
||||||
"/system/bin/su",
|
"/system/bin/su",
|
||||||
"/system/xbin/su",
|
"/system/xbin/su",
|
||||||
@@ -49,17 +54,19 @@ object Root {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var process: Process? = null
|
||||||
val granted = try {
|
val granted = try {
|
||||||
// redirectErrorStream merges stderr into stdout so a single
|
// redirectErrorStream merges stderr into stdout so a single
|
||||||
// drain thread is enough — avoids the classic pipe-buffer
|
// drain thread is enough — avoids the classic pipe-buffer
|
||||||
// deadlock where waitFor() blocks because stderr filled up.
|
// deadlock where waitFor() blocks because stderr filled up.
|
||||||
val process = ProcessBuilder("su", "-c", "id")
|
val proc = ProcessBuilder("su", "-c", "id")
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.start()
|
.start()
|
||||||
|
process = proc
|
||||||
val outputBuilder = StringBuilder()
|
val outputBuilder = StringBuilder()
|
||||||
val drain = Thread({
|
val drain = Thread({
|
||||||
try {
|
try {
|
||||||
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
|
BufferedReader(InputStreamReader(proc.inputStream)).use { r ->
|
||||||
val buf = CharArray(512)
|
val buf = CharArray(512)
|
||||||
while (true) {
|
while (true) {
|
||||||
val n = r.read(buf)
|
val n = r.read(buf)
|
||||||
@@ -72,17 +79,35 @@ object Root {
|
|||||||
}
|
}
|
||||||
}, "Root-su-drain").apply { isDaemon = true; start() }
|
}, "Root-su-drain").apply { isDaemon = true; start() }
|
||||||
|
|
||||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
// Cancellation-aware wait: callers run this on a coroutine
|
||||||
|
// (MainActivity wraps it in runInterruptible), so a config change
|
||||||
|
// mid-probe cancels the coroutine and interrupts this thread.
|
||||||
|
// Poll waitFor() in short slices and honour interruption so we
|
||||||
|
// don't leak the `su` child + its drain thread for up to 10s.
|
||||||
|
// The catch(InterruptedException) below destroys the process; we
|
||||||
|
// re-arm the interrupt and rethrow so coroutine cancellation
|
||||||
|
// propagates cleanly.
|
||||||
|
val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
|
||||||
|
var finished = false
|
||||||
|
while (System.nanoTime() < deadlineNanos) {
|
||||||
|
if (proc.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) {
|
||||||
|
finished = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Throws InterruptedException if the thread was interrupted
|
||||||
|
// by coroutine cancellation — handled below to tear down.
|
||||||
|
if (Thread.interrupted()) throw InterruptedException("su probe cancelled")
|
||||||
|
}
|
||||||
if (!finished) {
|
if (!finished) {
|
||||||
process.destroyForcibly()
|
proc.destroyForcibly()
|
||||||
drain.join(500)
|
drain.join(500)
|
||||||
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
drain.join(500)
|
drain.join(500)
|
||||||
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
val output = synchronized(outputBuilder) { outputBuilder.toString() }
|
||||||
if (process.exitValue() != 0) {
|
if (proc.exitValue() != 0) {
|
||||||
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
|
Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'")
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
val rooted = output.contains("uid=0")
|
val rooted = output.contains("uid=0")
|
||||||
@@ -90,8 +115,17 @@ object Root {
|
|||||||
rooted
|
rooted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
// Coroutine cancelled mid-probe (e.g. config change). Kill the
|
||||||
|
// su child so it doesn't outlive the cancelled work, re-arm the
|
||||||
|
// interrupt flag, and rethrow so the coroutine cancels cleanly.
|
||||||
|
// Do NOT cache a result — the probe never completed.
|
||||||
|
runCatching { process?.destroyForcibly() }
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "su invocation failed: ${e.message}")
|
Log.w(TAG, "su invocation failed: ${e.message}")
|
||||||
|
runCatching { process?.destroyForcibly() }
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,14 +89,15 @@ class RootScreenrecord(
|
|||||||
running = true
|
running = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
imageReader = buildImageReader()
|
val reader = buildImageReader().also { imageReader = it }
|
||||||
decoder = buildDecoder(imageReader!!)
|
val codec = buildDecoder(reader).also { decoder = it }
|
||||||
process = spawnScreenrecord() ?: run {
|
val proc = spawnScreenrecord() ?: run {
|
||||||
stop()
|
stop()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
startInputPump(process!!.inputStream, decoder!!)
|
process = proc
|
||||||
startOutputDrain(decoder!!)
|
startInputPump(proc.inputStream, codec)
|
||||||
|
startOutputDrain(codec)
|
||||||
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
|
||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -178,6 +179,14 @@ class RootScreenrecord(
|
|||||||
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
buffer.get(frameBuffer, row * rowBytes, rowBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||||
|
// reader callback — no copy here). Safety depends on the Python
|
||||||
|
// receiver copying the bytes before this callback returns and
|
||||||
|
// overwrites the buffer for the next frame. It does:
|
||||||
|
// PythonBridge.pushRootFrame → root_screenrecord_engine.push_frame
|
||||||
|
// (server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py)
|
||||||
|
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||||
|
// pixels independently of this buffer. Do NOT remove that copy.
|
||||||
bridge.pushRootFrame(frameBuffer, width, height)
|
bridge.pushRootFrame(frameBuffer, width, height)
|
||||||
framesDeliveredCounter.incrementAndGet()
|
framesDeliveredCounter.incrementAndGet()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -147,6 +147,14 @@ class ScreenCapture(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
|
||||||
|
// capture handler — no copy here). Safety depends on the Python
|
||||||
|
// receiver copying the bytes before this callback returns and
|
||||||
|
// overwrites the buffer for the next frame. It does:
|
||||||
|
// PythonBridge.pushFrame → mediaprojection_engine.push_frame
|
||||||
|
// (server/src/ledgrab/core/capture_engines/mediaprojection_engine.py)
|
||||||
|
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
|
||||||
|
// pixels independently of this buffer. Do NOT remove that copy.
|
||||||
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
|
||||||
|
|
||||||
// Advance the pacing accumulator. If we fell badly behind
|
// Advance the pacing accumulator. If we fell badly behind
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+7
-1
@@ -31,9 +31,15 @@ Creates the Gitea release with a description table listing all artifacts. **The
|
|||||||
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
|
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
|
||||||
|
|
||||||
### 4. `build-docker`
|
### 4. `build-docker`
|
||||||
- Plain `docker build` + `docker push` (no Buildx — TrueNAS runners lack nested networking)
|
- Plain `docker build` + `docker push` for **amd64** (no Buildx — TrueNAS runners lack nested networking)
|
||||||
- Registry: `{gitea_host}/{repo}:{tag}`
|
- Registry: `{gitea_host}/{repo}:{tag}`
|
||||||
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
|
- Tags: `v0.x.x`, `0.x.x`, and `latest` (stable only, not alpha/beta/rc)
|
||||||
|
- **arm64 is best-effort** (Raspberry Pi / arm64 HAOS): a `continue-on-error` step
|
||||||
|
cross-builds arm64 via **QEMU binfmt** (`tonistiigi/binfmt`) + `docker manifest`
|
||||||
|
(NOT buildx — sidesteps the docker-container-driver networking limit) and folds
|
||||||
|
amd64 + arm64 into multi-arch manifest lists under the same tags, plus
|
||||||
|
`:{tag}-amd64` / `:{tag}-arm64` arch-suffixed tags. If the runner can't run
|
||||||
|
privileged binfmt the step is skipped and the amd64 tags above remain valid.
|
||||||
|
|
||||||
## Build Scripts
|
## Build Scripts
|
||||||
|
|
||||||
|
|||||||
+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 |
+24
-10
@@ -6,33 +6,47 @@
|
|||||||
- `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).
|
||||||
|
|
||||||
|
## Activity / Audit Log
|
||||||
|
|
||||||
|
Persistent, queryable audit log of meaningful actions (auth, device, entity CRUD, capture, system), surfaced in the WebUI (Activity tab + Dashboard widget + Settings retention panel).
|
||||||
|
|
||||||
|
- **Storage is NOT a `BaseSqliteStore`.** `storage/activity_log_repository.py` is a purpose-built repository over a dedicated indexed `activity_log` table (migration `002_add_activity_log`) — query-on-demand with **keyset pagination** (`seq` cursor), never load-all-into-memory. Don't route it through the entity-store pattern.
|
||||||
|
- **Recording.** `core/activity_log/recorder.py` (`ActivityRecorder`) is best-effort (never raises into the audited action) and **thread-safe** (inline on the event loop; `loop.call_soon_threadsafe` from non-loop threads, e.g. zeroconf discovery). It persists the entry **and** fires an `activity_logged` realtime event. Actor comes from the `current_actor` `ContextVar` (set in `verify_api_key`), default `"system"`.
|
||||||
|
- **Entity CRUD is auto-audited** via the `fire_entity_event()` choke point in `api/dependencies.py` — every create/update/delete already calls it. **Delete handlers must pass `entity_name`** (the entity is gone by record time). Non-entity events use explicit `recorder.record(...)` (get it via `get_activity_recorder()` DI or `get_module_recorder()` for engine/thread sites).
|
||||||
|
- **Never log secrets.** API-key tokens are never recorded. Wrap any untrusted/attacker-controllable string (mDNS names, headers, user-authored names) with `sanitize_display()` (`core/activity_log/sanitize.py`) before it enters a `message`/`metadata` field. Per-IP throttle bounds auth-failure audit writes.
|
||||||
|
- **Adding a new audited event:** pick a dotted `action` (e.g. `"thing.created"`), call the recorder; for it to render localized in the UI, add `activity_log.msg.<action>` to all three `static/locales/*.json` (the frontend `localizeMessage()` maps action→template; falls back to the server `message`). Entity-type labels live under `activity_log.entity_type.<type>`.
|
||||||
|
- **Adding a new realtime event type** (`pm.fire_event({"type": ...})`): add it to `_ALLOWED_SERVER_EVENT_TYPES` in `static/js/core/events-ws.ts` AND keep `tests/test_events_ws_parity.py` green.
|
||||||
|
- **Retention + API.** `core/activity_log/retention.py` prunes by `max_days` + `max_entries` (settings persisted via `db.set_setting("activity_log")`); the recorder's `enabled` flag is rehydrated from those settings on startup. REST in `api/routes/activity_log.py`: `GET /activity-log` (list, `AuthRequired`), `GET /export` (CSV/JSON stream — `require_authenticated`; chunked keyset so it never holds the DB lock across the stream; CSV formula-injection guarded), `GET|PUT /settings` (PUT is `require_authenticated`), `DELETE` (clear — `require_authenticated`, self-audited). The table is covered by the existing whole-DB backup (no `STORE_MAP` change needed).
|
||||||
|
|
||||||
## 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,20 @@ 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"
|
||||||
|
|
||||||
|
# Expose the interactive API docs (/docs, /redoc, /openapi.json) WITHOUT a
|
||||||
|
# Bearer token so they can be opened directly in a browser. When true, this
|
||||||
|
# applies to loopback AND LAN clients. Only the API *surface* (route paths +
|
||||||
|
# parameter schemas) is exposed — calling an endpoint from Swagger still
|
||||||
|
# requires the token via its "Authorize" button, and every other route stays
|
||||||
|
# protected. Leave false unless you want browsable docs on your network.
|
||||||
|
expose_docs: false
|
||||||
|
|
||||||
# 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.0"
|
version = "0.9.0"
|
||||||
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"}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
# In dev (running from source without `pip install -e .`) and on Android
|
# In dev (running from source without `pip install -e .`) and on Android
|
||||||
# (Chaquopy embeds the source directly with no dist-info), we additionally
|
# (Chaquopy embeds the source directly with no dist-info), we additionally
|
||||||
# read pyproject.toml so the version is always correct without manual sync.
|
# read pyproject.toml so the version is always correct without manual sync.
|
||||||
_FALLBACK_VERSION = "0.8.0"
|
_FALLBACK_VERSION = "0.8.1"
|
||||||
|
|
||||||
|
|
||||||
def _read_pyproject_version() -> str | None:
|
def _read_pyproject_version() -> str | None:
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ _fix_embedded_tcl_paths()
|
|||||||
|
|
||||||
import uvicorn # noqa: E402
|
import uvicorn # noqa: E402
|
||||||
|
|
||||||
from ledgrab.config import get_config # noqa: E402
|
from ledgrab.config import Config, get_config # noqa: E402
|
||||||
from ledgrab.server_ref import set_server, set_tray # noqa: E402
|
from ledgrab.server_ref import set_server, set_tray # noqa: E402
|
||||||
|
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT # noqa: E402
|
||||||
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
|
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
|
||||||
from ledgrab.utils import setup_logging, get_logger # noqa: E402
|
from ledgrab.utils import setup_logging, get_logger # noqa: E402
|
||||||
from ledgrab.utils.platform import is_windows # noqa: E402
|
from ledgrab.utils.platform import is_windows # noqa: E402
|
||||||
@@ -108,17 +109,28 @@ def _check_port(host: str, port: int) -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def _build_server(config: Config) -> uvicorn.Server:
|
||||||
config = get_config()
|
"""Construct the uvicorn Server with a bounded graceful-shutdown timeout.
|
||||||
_check_port(config.server.host, config.server.port)
|
|
||||||
|
|
||||||
|
Extracted so the graceful-shutdown bound is unit-testable — leaving it
|
||||||
|
unset (the uvicorn default of ``None``) is the regression that strands
|
||||||
|
LED targets and prevents the process from exiting.
|
||||||
|
"""
|
||||||
uv_config = uvicorn.Config(
|
uv_config = uvicorn.Config(
|
||||||
"ledgrab.main:app",
|
"ledgrab.main:app",
|
||||||
host=config.server.host,
|
host=config.server.host,
|
||||||
port=config.server.port,
|
port=config.server.port,
|
||||||
log_level=config.server.log_level.lower(),
|
log_level=config.server.log_level.lower(),
|
||||||
|
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
|
||||||
)
|
)
|
||||||
server = uvicorn.Server(uv_config)
|
return uvicorn.Server(uv_config)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
config = get_config()
|
||||||
|
_check_port(config.server.host, config.server.port)
|
||||||
|
|
||||||
|
server = _build_server(config)
|
||||||
set_server(server)
|
set_server(server)
|
||||||
|
|
||||||
# Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals
|
# Wire the OS-shutdown safety net. The lifespan in ``ledgrab.main`` signals
|
||||||
@@ -165,9 +177,11 @@ def main() -> None:
|
|||||||
tray.run()
|
tray.run()
|
||||||
|
|
||||||
# Tray exited — wait for server to finish its graceful shutdown.
|
# Tray exited — wait for server to finish its graceful shutdown.
|
||||||
# Use a longer join than the lifespan's own ~18 s budget so we don't
|
# Budget: the graceful-shutdown wait (GRACEFUL_SHUTDOWN_TIMEOUT) runs
|
||||||
# cut the DB checkpoint short on a slow disk.
|
# first, then the lifespan's own ~16 s shutdown (target restore + DB
|
||||||
server_thread.join(timeout=20)
|
# checkpoint). Join longer than their sum so a slow disk doesn't get
|
||||||
|
# the DB checkpoint cut short.
|
||||||
|
server_thread.join(timeout=25)
|
||||||
if guard is not None:
|
if guard is not None:
|
||||||
guard.stop()
|
guard.stop()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) ->
|
|||||||
"LEDGRAB_AUTH__API_KEYS."
|
"LEDGRAB_AUTH__API_KEYS."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ledgrab.shutdown_state import GRACEFUL_SHUTDOWN_TIMEOUT
|
||||||
|
|
||||||
uv_config = uvicorn.Config(
|
uv_config = uvicorn.Config(
|
||||||
"ledgrab.main:app",
|
"ledgrab.main:app",
|
||||||
host=config.server.host,
|
host=config.server.host,
|
||||||
@@ -91,6 +93,9 @@ def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) ->
|
|||||||
log_level=config.server.log_level.lower(),
|
log_level=config.server.log_level.lower(),
|
||||||
# No uvloop/httptools on Android — use pure-Python asyncio
|
# No uvloop/httptools on Android — use pure-Python asyncio
|
||||||
loop="asyncio",
|
loop="asyncio",
|
||||||
|
# Bound the graceful-shutdown wait so stop_server() can't hang forever
|
||||||
|
# on a lingering WebView events WebSocket — see shutdown_state for why.
|
||||||
|
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
global _server, _loop
|
global _server, _loop
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -33,6 +34,11 @@ from .routes.audio_processing_templates import router as audio_processing_templa
|
|||||||
from .routes.audio_filters import router as audio_filters_router
|
from .routes.audio_filters import router as audio_filters_router
|
||||||
from .routes.pattern_templates import router as pattern_templates_router
|
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.graph import router as graph_router
|
||||||
|
from .routes.calibration import router as calibration_router
|
||||||
|
from .routes.setup import router as setup_router
|
||||||
|
from .routes.activity_log import router as activity_log_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -51,6 +57,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)
|
||||||
@@ -66,5 +73,10 @@ router.include_router(audio_processing_templates_router)
|
|||||||
router.include_router(audio_filters_router)
|
router.include_router(audio_filters_router)
|
||||||
router.include_router(pattern_templates_router)
|
router.include_router(pattern_templates_router)
|
||||||
router.include_router(preferences_router)
|
router.include_router(preferences_router)
|
||||||
|
router.include_router(snapshot_router)
|
||||||
|
router.include_router(graph_router)
|
||||||
|
router.include_router(calibration_router)
|
||||||
|
router.include_router(setup_router)
|
||||||
|
router.include_router(activity_log_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -3,18 +3,137 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, Security, status
|
from fastapi import Depends, HTTPException, Request, Security, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from ledgrab.config import get_config
|
from ledgrab.config import get_config
|
||||||
|
from ledgrab.core.activity_log.context import current_actor
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
|
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# ── Auth-failure audit throttle (H3) ───────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Unauthenticated callers can hammer any auth path; without a recording
|
||||||
|
# throttle each attempt would write one SQLite row AND broadcast one WS event,
|
||||||
|
# providing a cheap disk/broadcast amplification vector.
|
||||||
|
#
|
||||||
|
# Mitigation: record at most one ``auth.rejected`` audit entry per client IP
|
||||||
|
# per _AUTH_RECORD_WINDOW seconds. The auth decision (401) is NEVER
|
||||||
|
# suppressed — only the *audit recording* is de-duplicated.
|
||||||
|
#
|
||||||
|
# Memory safety: the throttle dict is capped at _AUTH_THROTTLE_HARD_CAP
|
||||||
|
# entries. When the cap is exceeded the oldest-inserted IP is evicted in O(1)
|
||||||
|
# so the dict stays bounded regardless of the number of distinct source IPs an
|
||||||
|
# attacker can forge.
|
||||||
|
#
|
||||||
|
# Thread safety: the throttle dict is guarded by ``_auth_record_lock`` (mirrors
|
||||||
|
# ``_auth_fail_lock`` in routes/game_integration) so the compound
|
||||||
|
# read/evict/insert is atomic. The HTTP auth dependency runs on the event loop
|
||||||
|
# (``verify_api_key`` is async), but ``_record_auth_failure`` is reached from
|
||||||
|
# both the HTTP and WebSocket auth paths and must remain safe if ever called
|
||||||
|
# from a background thread — the lock is uncontended on the loop, so it costs
|
||||||
|
# nothing while preventing a KeyError / "dict changed size" from ever turning
|
||||||
|
# an intended 401 into a 500.
|
||||||
|
|
||||||
|
_AUTH_RECORD_WINDOW: float = 10.0 # seconds — one record per IP per window
|
||||||
|
_AUTH_THROTTLE_HARD_CAP: int = 512 # max IPs tracked simultaneously
|
||||||
|
|
||||||
|
# ip -> monotonic timestamp of last *recorded* auth.rejected entry.
|
||||||
|
# OrderedDict so the oldest insertion can be evicted in O(1) via popitem.
|
||||||
|
_auth_record_last: "OrderedDict[str, float]" = OrderedDict()
|
||||||
|
_auth_record_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _should_record_auth_failure(client_ip: str) -> bool:
|
||||||
|
"""Return True when an ``auth.rejected`` record should be written for *client_ip*.
|
||||||
|
|
||||||
|
Suppresses duplicates within _AUTH_RECORD_WINDOW seconds. Evicts the
|
||||||
|
oldest entry when the dict exceeds _AUTH_THROTTLE_HARD_CAP to prevent
|
||||||
|
unbounded memory growth under IP-spray attacks.
|
||||||
|
|
||||||
|
Thread-safe: the entire read/evict/insert is performed under
|
||||||
|
``_auth_record_lock`` so concurrent threadpool workers cannot corrupt the
|
||||||
|
dict or raise mid-mutation.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
with _auth_record_lock:
|
||||||
|
last = _auth_record_last.get(client_ip)
|
||||||
|
if last is not None and (now - last) < _AUTH_RECORD_WINDOW:
|
||||||
|
return False # suppress: within the de-dup window
|
||||||
|
|
||||||
|
# Enforce hard cap before inserting: evict the oldest entry in O(1).
|
||||||
|
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
|
||||||
|
_auth_record_last.popitem(last=False)
|
||||||
|
|
||||||
|
# Refresh recency: move/insert this IP to the most-recent end so the
|
||||||
|
# popitem(last=False) above always drops a genuinely old entry.
|
||||||
|
_auth_record_last[client_ip] = now
|
||||||
|
_auth_record_last.move_to_end(client_ip)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _record_auth_failure(reason: str, client_host: str | None) -> None:
|
||||||
|
"""Best-effort: record an auth failure audit entry (never raises).
|
||||||
|
|
||||||
|
SECURITY: the attempted token is NEVER passed here; only the reason and
|
||||||
|
the caller's IP/label are recorded.
|
||||||
|
|
||||||
|
THROTTLE: at most one ``auth.rejected`` record is written per client IP
|
||||||
|
per _AUTH_RECORD_WINDOW seconds to prevent disk/WS-broadcast amplification
|
||||||
|
DoS. The 401 response is always returned regardless.
|
||||||
|
|
||||||
|
The whole body is wrapped so an audit-path failure can never convert an
|
||||||
|
intended 401 into a 500 (honors the "never raises" contract).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not _should_record_auth_failure(client_host or "unknown"):
|
||||||
|
return # throttled — drop duplicate recording for this IP/window
|
||||||
|
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is None:
|
||||||
|
return
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.AUTH,
|
||||||
|
action="auth.rejected",
|
||||||
|
severity=ActivitySeverity.WARNING,
|
||||||
|
actor="anonymous",
|
||||||
|
message=f"Authentication failed: {reason}",
|
||||||
|
metadata={"reason": reason, "client": client_host or "unknown"},
|
||||||
|
)
|
||||||
|
except Exception as exc: # never raise into the auth path
|
||||||
|
logger.warning("auth-failure audit recording failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_ws_auth_success(label: str, client_host: str | None) -> None:
|
||||||
|
"""Best-effort: record a successful WebSocket session establishment."""
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is None:
|
||||||
|
return
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.AUTH,
|
||||||
|
action="auth.ws_connected",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
actor=label,
|
||||||
|
message=f"WebSocket session established by '{label}'",
|
||||||
|
metadata={"client": client_host or "unknown"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Security scheme for Bearer token
|
# Security scheme for Bearer token
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
@@ -49,7 +168,7 @@ def _is_loopback(host: str | None) -> bool:
|
|||||||
return _classify_is_loopback(host)
|
return _classify_is_loopback(host)
|
||||||
|
|
||||||
|
|
||||||
def verify_api_key(
|
async def verify_api_key(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -63,6 +182,13 @@ def verify_api_key(
|
|||||||
LAN access requires an API key).
|
LAN access requires an API key).
|
||||||
- When API keys ARE configured, valid Bearer credentials are required.
|
- When API keys ARE configured, valid Bearer credentials are required.
|
||||||
|
|
||||||
|
This is an ``async`` dependency on purpose: token comparison is CPU-trivial,
|
||||||
|
and an async dependency runs in the SAME task/context as the route handler,
|
||||||
|
so ``current_actor.set(...)`` below is visible to ``ActivityRecorder`` when
|
||||||
|
the handler later records an entity event. A sync dependency would run in a
|
||||||
|
throwaway threadpool context and the actor mutation would be discarded,
|
||||||
|
attributing every audited action to "system".
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: incoming request (used to read client host)
|
request: incoming request (used to read client host)
|
||||||
credentials: HTTP authorization credentials
|
credentials: HTTP authorization credentials
|
||||||
@@ -80,10 +206,13 @@ def verify_api_key(
|
|||||||
if not config.auth.api_keys:
|
if not config.auth.api_keys:
|
||||||
# No keys configured — allow loopback only.
|
# No keys configured — allow loopback only.
|
||||||
if _is_loopback(client_host):
|
if _is_loopback(client_host):
|
||||||
|
request.state.auth_label = "anonymous"
|
||||||
|
current_actor.set("anonymous")
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
# Allow caller to authenticate explicitly even without configured keys?
|
# Allow caller to authenticate explicitly even without configured keys?
|
||||||
# No — there are no keys to compare against. Reject.
|
# No — there are no keys to compare against. Reject.
|
||||||
logger.warning("Rejected LAN request from %s: no API key configured", client_host)
|
logger.warning("Rejected LAN request from %s: no API key configured", client_host)
|
||||||
|
_record_auth_failure("LAN access rejected: no API key configured", client_host)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail=(
|
detail=(
|
||||||
@@ -96,24 +225,32 @@ def verify_api_key(
|
|||||||
# Check if credentials are provided
|
# Check if credentials are provided
|
||||||
if not credentials:
|
if not credentials:
|
||||||
logger.warning("Request missing Authorization header")
|
logger.warning("Request missing Authorization header")
|
||||||
|
_record_auth_failure("missing Bearer token", client_host)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Missing API key - authentication is required",
|
detail="Missing API key - authentication is required",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract token
|
# Extract token — NEVER log or record the token value itself.
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
|
||||||
# Find matching key and return its label using constant-time comparison
|
# Find matching key and return its label using constant-time comparison.
|
||||||
|
# Compare UTF-8 byte encodings: secrets.compare_digest raises TypeError on
|
||||||
|
# non-ASCII str (an attacker can put 0x80-0xFF in the Authorization header,
|
||||||
|
# which Starlette latin-1-decodes to a non-ASCII str). Byte comparison is
|
||||||
|
# well-defined for any input and preserves constant-time behavior, so a
|
||||||
|
# bad/non-ASCII token cleanly falls through to the 401 below instead of 500.
|
||||||
|
token_b = (token or "").encode("utf-8")
|
||||||
authenticated_as = None
|
authenticated_as = None
|
||||||
for label, api_key in config.auth.api_keys.items():
|
for label, api_key in config.auth.api_keys.items():
|
||||||
if secrets.compare_digest(token, api_key):
|
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
|
||||||
authenticated_as = label
|
authenticated_as = label
|
||||||
break
|
break
|
||||||
|
|
||||||
if not authenticated_as:
|
if not authenticated_as:
|
||||||
logger.warning("Invalid API key attempt")
|
logger.warning("Invalid API key attempt")
|
||||||
|
_record_auth_failure("invalid Bearer token", client_host)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid API key",
|
detail="Invalid API key",
|
||||||
@@ -123,6 +260,12 @@ def verify_api_key(
|
|||||||
# Log successful authentication
|
# Log successful authentication
|
||||||
logger.debug(f"Authenticated as: {authenticated_as}")
|
logger.debug(f"Authenticated as: {authenticated_as}")
|
||||||
|
|
||||||
|
# Stash the friendly label so the access-log middleware can attribute the
|
||||||
|
# request to a client without re-running the token comparison.
|
||||||
|
request.state.auth_label = authenticated_as
|
||||||
|
# Set the actor ContextVar so ActivityRecorder can resolve it without
|
||||||
|
# threading it through every call site.
|
||||||
|
current_actor.set(authenticated_as)
|
||||||
return authenticated_as
|
return authenticated_as
|
||||||
|
|
||||||
|
|
||||||
@@ -131,6 +274,31 @@ def verify_api_key(
|
|||||||
AuthRequired = Annotated[str, Depends(verify_api_key)]
|
AuthRequired = Annotated[str, Depends(verify_api_key)]
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_docs_access(
|
||||||
|
request: Request,
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)],
|
||||||
|
) -> str:
|
||||||
|
"""Auth gate for the OpenAPI docs routes (/docs, /redoc, /openapi.json).
|
||||||
|
|
||||||
|
When ``auth.expose_docs`` is True, the docs pages load anonymously from any
|
||||||
|
client (loopback and LAN) so they can be viewed in a browser without a
|
||||||
|
Bearer token. Only the API *surface* is exposed this way — every other
|
||||||
|
endpoint still goes through :func:`verify_api_key`.
|
||||||
|
|
||||||
|
When ``auth.expose_docs`` is False (default), this delegates to
|
||||||
|
:func:`verify_api_key`, so docs require a token exactly like the rest of
|
||||||
|
the API.
|
||||||
|
"""
|
||||||
|
if get_config().auth.expose_docs:
|
||||||
|
request.state.auth_label = "anonymous-docs"
|
||||||
|
return "anonymous-docs"
|
||||||
|
return await verify_api_key(request, credentials)
|
||||||
|
|
||||||
|
|
||||||
|
# Dependency for the OpenAPI docs routes — relaxed when auth.expose_docs is set
|
||||||
|
DocsAccess = Annotated[str, Depends(verify_docs_access)]
|
||||||
|
|
||||||
|
|
||||||
def require_authenticated(label: str) -> None:
|
def require_authenticated(label: str) -> None:
|
||||||
"""Reject the anonymous (loopback) auth label.
|
"""Reject the anonymous (loopback) auth label.
|
||||||
|
|
||||||
@@ -186,12 +354,30 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
|||||||
# a strong signal even before the token check. Non-browser clients
|
# a strong signal even before the token check. Non-browser clients
|
||||||
# legitimately omit Origin; those fall through to the auth handshake.
|
# legitimately omit Origin; those fall through to the auth handshake.
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
client_host = websocket.client.host if websocket.client else None
|
||||||
origin = websocket.headers.get("origin")
|
origin = websocket.headers.get("origin")
|
||||||
if not _is_origin_allowed(origin, config.server.cors_origins):
|
if not _is_origin_allowed(origin, config.server.cors_origins):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Rejected WebSocket from origin %r (not in cors_origins)",
|
"Rejected WebSocket from origin %r (not in cors_origins)",
|
||||||
origin,
|
origin,
|
||||||
)
|
)
|
||||||
|
# Sanitize first so urlparse does not choke on control chars / ANSI / NUL
|
||||||
|
# embedded by an attacker in the Origin header (e.g. \n triggers IPv6 parse
|
||||||
|
# error in Python's urlsplit on malformed netloc).
|
||||||
|
_safe_origin_raw = sanitize_display(origin) if origin else ""
|
||||||
|
try:
|
||||||
|
_netloc = urlparse(_safe_origin_raw).netloc if _safe_origin_raw else ""
|
||||||
|
except ValueError:
|
||||||
|
# Malformed IPv6 addresses (e.g. "http://[::1" without closing "]")
|
||||||
|
# cause urlparse to raise ValueError. Fall back to "unknown" — do NOT
|
||||||
|
# fall back to the raw origin string, which could carry query params
|
||||||
|
# or path components containing secrets.
|
||||||
|
_netloc = ""
|
||||||
|
_safe_origin = sanitize_display(_netloc or "unknown")
|
||||||
|
_record_auth_failure(
|
||||||
|
f"WebSocket origin rejected: {_safe_origin!r}",
|
||||||
|
client_host,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
|
||||||
except _WS_SEND_BENIGN_EXC:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
@@ -206,6 +392,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
|||||||
except _WS_SEND_BENIGN_EXC:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
_record_ws_auth_success(label, client_host)
|
||||||
return label
|
return label
|
||||||
|
|
||||||
|
|
||||||
@@ -213,12 +400,19 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
|
|||||||
|
|
||||||
|
|
||||||
def _match_api_key(token: str) -> str | None:
|
def _match_api_key(token: str) -> str | None:
|
||||||
"""Return the label matching *token* using constant-time comparison, or None."""
|
"""Return the label matching *token* using constant-time comparison, or None.
|
||||||
|
|
||||||
|
Compares UTF-8 byte encodings so a non-ASCII token (a JSON string in the WS
|
||||||
|
auth message trivially carries non-ASCII) cannot raise TypeError out of
|
||||||
|
``secrets.compare_digest`` — it simply fails to match and yields a clean
|
||||||
|
``auth_error`` instead of crashing the handler.
|
||||||
|
"""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
token_b = token.encode("utf-8")
|
||||||
for label, api_key in config.auth.api_keys.items():
|
for label, api_key in config.auth.api_keys.items():
|
||||||
if secrets.compare_digest(token, api_key):
|
if secrets.compare_digest(token_b, api_key.encode("utf-8")):
|
||||||
return label
|
return label
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -271,6 +465,7 @@ async def verify_ws_auth(
|
|||||||
return None
|
return None
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
|
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
|
||||||
|
_record_auth_failure("WebSocket auth timeout", client_host)
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
|
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
|
||||||
except _WS_SEND_BENIGN_EXC:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
@@ -328,6 +523,7 @@ async def verify_ws_auth(
|
|||||||
await websocket.send_json({"type": "auth_ok"})
|
await websocket.send_json({"type": "auth_ok"})
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host)
|
logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host)
|
||||||
|
_record_auth_failure("LAN WebSocket rejected: no API key configured", client_host)
|
||||||
try:
|
try:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
@@ -339,10 +535,11 @@ async def verify_ws_auth(
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Keys configured: require a matching token.
|
# Keys configured: require a matching token. NEVER log the token value.
|
||||||
label = _match_api_key(token or "")
|
label = _match_api_key(token or "")
|
||||||
if not label:
|
if not label:
|
||||||
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
|
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
|
||||||
|
_record_auth_failure("invalid WebSocket token", client_host)
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
|
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
|
||||||
except _WS_SEND_BENIGN_EXC:
|
except _WS_SEND_BENIGN_EXC:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -35,11 +37,17 @@ 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.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
|
||||||
|
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
|
||||||
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||||
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
|
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
|
||||||
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
|
||||||
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
|
||||||
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
from ledgrab.storage.pattern_template_store import PatternTemplateStore
|
||||||
|
from ledgrab.core.activity_log.recorder import ActivityRecorder, get_module_recorder
|
||||||
|
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
|
from ledgrab.storage.activity_log_repository import ActivityLogRepository
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -110,6 +118,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")
|
||||||
|
|
||||||
@@ -158,6 +174,15 @@ def get_game_event_bus() -> GameEventBus:
|
|||||||
return _get("game_event_bus", "Game event bus")
|
return _get("game_event_bus", "Game event bus")
|
||||||
|
|
||||||
|
|
||||||
|
def get_lol_poll_manager() -> LoLPollManager | None:
|
||||||
|
"""LoL poll manager, or None if not wired (e.g. minimal test harnesses).
|
||||||
|
|
||||||
|
Polling is a best-effort background feature, so callers guard on None
|
||||||
|
rather than 500-ing a CRUD request when the manager is absent.
|
||||||
|
"""
|
||||||
|
return _deps.get("lol_poll_manager")
|
||||||
|
|
||||||
|
|
||||||
def get_mqtt_store() -> MQTTSourceStore:
|
def get_mqtt_store() -> MQTTSourceStore:
|
||||||
return _get("mqtt_store", "MQTT source store")
|
return _get("mqtt_store", "MQTT source store")
|
||||||
|
|
||||||
@@ -186,16 +211,87 @@ def get_update_service() -> UpdateService:
|
|||||||
return _get("update_service", "Update service")
|
return _get("update_service", "Update service")
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_recorder() -> ActivityRecorder:
|
||||||
|
return _get("activity_recorder", "Activity recorder")
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_log_repo() -> ActivityLogRepository:
|
||||||
|
return _get("activity_log_repo", "Activity log repository")
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_log_retention_engine() -> ActivityLogRetentionEngine:
|
||||||
|
return _get("activity_log_retention_engine", "Activity log retention engine")
|
||||||
|
|
||||||
|
|
||||||
# ── Event helper ────────────────────────────────────────────────────────
|
# ── Event helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
# entity_type → (_deps key, store method name) for human-name resolution.
|
||||||
"""Fire an entity_changed event via the ProcessorManager event bus.
|
# Module-level constant: built once at import rather than per audited mutation
|
||||||
|
# (``_resolve_entity_name`` is the create/update audit choke point).
|
||||||
|
_STORE_LOOKUP: dict[str, tuple[str, str]] = {
|
||||||
|
"output_target": ("output_target_store", "get_target"),
|
||||||
|
"device": ("device_store", "get_device"),
|
||||||
|
"picture_source": ("picture_source_store", "get_source"),
|
||||||
|
"audio_source": ("audio_source_store", "get_source"),
|
||||||
|
"color_strip_source": ("color_strip_store", "get_source"),
|
||||||
|
"template": ("template_store", "get_template"),
|
||||||
|
"capture_template": ("template_store", "get_template"),
|
||||||
|
"pp_template": ("pp_template_store", "get_template"),
|
||||||
|
"automation": ("automation_store", "get_automation"),
|
||||||
|
"scene_preset": ("scene_preset_store", "get_preset"),
|
||||||
|
"scene_playlist": ("scene_playlist_store", "get_playlist"),
|
||||||
|
"sync_clock": ("sync_clock_store", "get_clock"),
|
||||||
|
"gradient": ("gradient_store", "get_gradient"),
|
||||||
|
"audio_template": ("audio_template_store", "get_template"),
|
||||||
|
"value_source": ("value_source_store", "get_source"),
|
||||||
|
"cspt": ("cspt_store", "get_template"),
|
||||||
|
"audio_processing_template": ("audio_processing_template_store", "get_template"),
|
||||||
|
"pattern_template": ("pattern_template_store", "get_template"),
|
||||||
|
"home_assistant_source": ("ha_store", "get_source"),
|
||||||
|
"mqtt_source": ("mqtt_store", "get_source"),
|
||||||
|
"http_endpoint": ("http_endpoint_store", "get_endpoint"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_entity_name(entity_type: str, entity_id: str) -> str | None:
|
||||||
|
"""Best-effort: look up a human name for *entity_id* from the matching store.
|
||||||
|
|
||||||
|
Returns ``None`` when the store is missing, the entity is gone, or any
|
||||||
|
exception occurs (e.g. during delete the entity may have just been removed).
|
||||||
|
"""
|
||||||
|
entry = _STORE_LOOKUP.get(entity_type)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
store_key, method_name = entry
|
||||||
|
store = _deps.get(store_key)
|
||||||
|
if store is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
obj = getattr(store, method_name)(entity_id)
|
||||||
|
if obj is not None:
|
||||||
|
return getattr(obj, "name", None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fire_entity_event(
|
||||||
|
entity_type: str,
|
||||||
|
action: str,
|
||||||
|
entity_id: str,
|
||||||
|
entity_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fire an entity_changed event via the ProcessorManager event bus and
|
||||||
|
record an audit entry.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entity_type: e.g. "device", "output_target", "color_strip_source"
|
entity_type: e.g. "device", "output_target", "color_strip_source"
|
||||||
action: "created", "updated", or "deleted"
|
action: "created", "updated", or "deleted"
|
||||||
entity_id: The entity's unique ID
|
entity_id: The entity's unique ID
|
||||||
|
entity_name: Human-readable name. For deletes: **must** be passed
|
||||||
|
explicitly (entity is already gone when we get here).
|
||||||
|
For create/update: resolved from the store when not supplied.
|
||||||
"""
|
"""
|
||||||
pm = _deps.get("processor_manager")
|
pm = _deps.get("processor_manager")
|
||||||
if pm is not None:
|
if pm is not None:
|
||||||
@@ -208,6 +304,38 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Audit record (best-effort) ──────────────────────────────────────────
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve name when not explicitly provided (create / update paths).
|
||||||
|
# For deleted: entity already gone — rely on the explicitly passed name.
|
||||||
|
resolved_name = entity_name
|
||||||
|
if resolved_name is None and action != "deleted":
|
||||||
|
resolved_name = _resolve_entity_name(entity_type, entity_id)
|
||||||
|
|
||||||
|
# Build a concise human message.
|
||||||
|
# Sanitize the display name before interpolating into the free-text message
|
||||||
|
# (user-authored names hit the CSV/export trust surface).
|
||||||
|
safe_display_name = sanitize_display(resolved_name) if resolved_name else None
|
||||||
|
display_name = f"'{safe_display_name}'" if safe_display_name else entity_id
|
||||||
|
action_word = {"created": "created", "updated": "updated", "deleted": "deleted"}.get(
|
||||||
|
action, action
|
||||||
|
)
|
||||||
|
entity_label = entity_type.replace("_", " ")
|
||||||
|
message = f"{entity_label.capitalize()} {display_name} {action_word}"
|
||||||
|
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.ENTITY,
|
||||||
|
action=f"entity.{action}",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
entity_name=sanitize_display(resolved_name) if resolved_name else None,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Initialization ──────────────────────────────────────────────────────
|
# ── Initialization ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -226,7 +354,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,
|
||||||
@@ -240,11 +370,15 @@ def init_dependencies(
|
|||||||
ha_manager: HomeAssistantManager | None = None,
|
ha_manager: HomeAssistantManager | None = None,
|
||||||
game_integration_store: GameIntegrationStore | None = None,
|
game_integration_store: GameIntegrationStore | None = None,
|
||||||
game_event_bus: GameEventBus | None = None,
|
game_event_bus: GameEventBus | None = None,
|
||||||
|
lol_poll_manager: LoLPollManager | None = None,
|
||||||
mqtt_store: MQTTSourceStore | None = None,
|
mqtt_store: MQTTSourceStore | None = None,
|
||||||
mqtt_manager: MQTTManager | None = None,
|
mqtt_manager: MQTTManager | None = None,
|
||||||
http_endpoint_store: HTTPEndpointStore | None = None,
|
http_endpoint_store: HTTPEndpointStore | None = None,
|
||||||
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
|
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
|
||||||
pattern_template_store: PatternTemplateStore | None = None,
|
pattern_template_store: PatternTemplateStore | None = None,
|
||||||
|
activity_recorder: ActivityRecorder | None = None,
|
||||||
|
activity_log_repo: ActivityLogRepository | None = None,
|
||||||
|
activity_log_retention_engine: ActivityLogRetentionEngine | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
_deps.update(
|
_deps.update(
|
||||||
@@ -262,7 +396,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,
|
||||||
@@ -276,10 +412,14 @@ def init_dependencies(
|
|||||||
"ha_manager": ha_manager,
|
"ha_manager": ha_manager,
|
||||||
"game_integration_store": game_integration_store,
|
"game_integration_store": game_integration_store,
|
||||||
"game_event_bus": game_event_bus,
|
"game_event_bus": game_event_bus,
|
||||||
|
"lol_poll_manager": lol_poll_manager,
|
||||||
"mqtt_store": mqtt_store,
|
"mqtt_store": mqtt_store,
|
||||||
"mqtt_manager": mqtt_manager,
|
"mqtt_manager": mqtt_manager,
|
||||||
"http_endpoint_store": http_endpoint_store,
|
"http_endpoint_store": http_endpoint_store,
|
||||||
"audio_processing_template_store": audio_processing_template_store,
|
"audio_processing_template_store": audio_processing_template_store,
|
||||||
"pattern_template_store": pattern_template_store,
|
"pattern_template_store": pattern_template_store,
|
||||||
|
"activity_recorder": activity_recorder,
|
||||||
|
"activity_log_repo": activity_log_repo,
|
||||||
|
"activity_log_retention_engine": activity_log_retention_engine,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,609 @@
|
|||||||
|
"""Authoritative wiring-graph schema and topology engine.
|
||||||
|
|
||||||
|
This module is the single source of truth for **which reference fields connect
|
||||||
|
which entity kinds**. The frontend graph editor historically hard-coded the same
|
||||||
|
information in two places (``graph-connections.ts`` ``CONNECTION_MAP`` and
|
||||||
|
``graph-layout.ts`` ``buildGraph``); the ``GET /api/v1/graph/schema`` endpoint
|
||||||
|
now serves this registry so the client can render ports and edges generically
|
||||||
|
and the two never drift.
|
||||||
|
|
||||||
|
This registry is a *superset* of the current frontend ``buildGraph``: it also
|
||||||
|
declares real references that ``buildGraph`` does not yet draw (e.g.
|
||||||
|
``value_source.value_source_id`` chaining and ``value_source.color_strip_source_id``).
|
||||||
|
The backend is authoritative; the client is expected to converge on it.
|
||||||
|
|
||||||
|
Everything in this module is pure (operates on plain dicts), so the topology
|
||||||
|
build, dependency lookup, cycle and dangling-reference detection are all unit
|
||||||
|
testable without booting the app or any store.
|
||||||
|
|
||||||
|
Field-path grammar (the ``field`` of a :class:`ConnectionField`):
|
||||||
|
|
||||||
|
* ``"device_id"`` — a top-level string id.
|
||||||
|
* ``"brightness.source_id"`` — a nested object; ``brightness`` may be a
|
||||||
|
plain number (unbound :class:`BindableFloat`) or ``{"value", "source_id"}``.
|
||||||
|
* ``"settings.pattern_template_id"`` — arbitrarily deep object access.
|
||||||
|
* ``"layers[].source_id"`` — ``layers`` is a list; read ``source_id``
|
||||||
|
from every element.
|
||||||
|
* ``"calibration.lines[].picture_source_id"`` — object → list → field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import asdict, dataclass, is_dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConnectionField:
|
||||||
|
"""One connectable reference: ``target_kind.field`` points at ``source_kind``."""
|
||||||
|
|
||||||
|
target_kind: str
|
||||||
|
"""Entity kind that *holds* the reference (the consumer / referrer)."""
|
||||||
|
field: str
|
||||||
|
"""Dot-path to the reference value (see module docstring grammar)."""
|
||||||
|
source_kind: str
|
||||||
|
"""Entity kind being referenced (the producer / source)."""
|
||||||
|
edge_type: str
|
||||||
|
"""Edge category, used by the client for colour and port grouping."""
|
||||||
|
bindable: bool = False
|
||||||
|
"""True when the slot is a :class:`BindableFloat`/``BindableColor`` value binding."""
|
||||||
|
nested: bool = False
|
||||||
|
"""True when the field lives inside a nested object/list (dotted path)."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_list(self) -> bool:
|
||||||
|
"""True when any path segment iterates a list (``foo[]``)."""
|
||||||
|
return "[]" in self.field
|
||||||
|
|
||||||
|
|
||||||
|
# ── Entity kinds & their human "type" attribute ────────────────────────────
|
||||||
|
# Mirrors the frontend buildGraph(): kind → the serialized field that carries
|
||||||
|
# the entity's subtype (used only for the node label / icon).
|
||||||
|
NODE_TYPE_FIELD: dict[str, str] = {
|
||||||
|
"device": "device_type",
|
||||||
|
"capture_template": "engine_type",
|
||||||
|
"pp_template": "",
|
||||||
|
"audio_template": "engine_type",
|
||||||
|
"pattern_template": "",
|
||||||
|
"picture_source": "stream_type",
|
||||||
|
"audio_source": "source_type",
|
||||||
|
"value_source": "source_type",
|
||||||
|
"color_strip_source": "source_type",
|
||||||
|
"sync_clock": "",
|
||||||
|
"output_target": "target_type",
|
||||||
|
"scene_preset": "",
|
||||||
|
"automation": "",
|
||||||
|
"cspt": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
ENTITY_KINDS: tuple[str, ...] = tuple(NODE_TYPE_FIELD.keys())
|
||||||
|
|
||||||
|
|
||||||
|
# ── The registry ───────────────────────────────────────────────────────────
|
||||||
|
# NOTE: ``gradient`` and ``ha_source`` reference fields are intentionally
|
||||||
|
# omitted — they are not first-class graph node kinds, so wiring them would
|
||||||
|
# only ever produce dangling-reference noise.
|
||||||
|
CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
||||||
|
# ── Picture sources ──
|
||||||
|
ConnectionField("picture_source", "capture_template_id", "capture_template", "template"),
|
||||||
|
ConnectionField("picture_source", "source_stream_id", "picture_source", "picture"),
|
||||||
|
ConnectionField("picture_source", "postprocessing_template_id", "pp_template", "template"),
|
||||||
|
# ── Audio sources ──
|
||||||
|
ConnectionField("audio_source", "audio_template_id", "audio_template", "audio"),
|
||||||
|
ConnectionField("audio_source", "audio_source_id", "audio_source", "audio"),
|
||||||
|
# ── Value sources ──
|
||||||
|
ConnectionField("value_source", "audio_source_id", "audio_source", "audio"),
|
||||||
|
ConnectionField("value_source", "picture_source_id", "picture_source", "picture"),
|
||||||
|
ConnectionField("value_source", "value_source_id", "value_source", "value"),
|
||||||
|
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) ──
|
||||||
|
ConnectionField("color_strip_source", "picture_source_id", "picture_source", "picture"),
|
||||||
|
ConnectionField("color_strip_source", "audio_source_id", "audio_source", "audio"),
|
||||||
|
ConnectionField("color_strip_source", "clock_id", "sync_clock", "clock"),
|
||||||
|
ConnectionField("color_strip_source", "input_source_id", "color_strip_source", "colorstrip"),
|
||||||
|
ConnectionField("color_strip_source", "processing_template_id", "cspt", "template"),
|
||||||
|
# ── Color strip sources (BindableFloat value bindings) ──
|
||||||
|
*(
|
||||||
|
ConnectionField(
|
||||||
|
"color_strip_source",
|
||||||
|
f"{prop}.source_id",
|
||||||
|
"value_source",
|
||||||
|
"value",
|
||||||
|
bindable=True,
|
||||||
|
nested=True,
|
||||||
|
)
|
||||||
|
for prop in (
|
||||||
|
"smoothing",
|
||||||
|
"sensitivity",
|
||||||
|
"intensity",
|
||||||
|
"scale",
|
||||||
|
"speed",
|
||||||
|
"wind_strength",
|
||||||
|
"temperature_influence",
|
||||||
|
"sound_volume",
|
||||||
|
"timeout",
|
||||||
|
"brightness",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
# ── 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(
|
||||||
|
"color_strip_source",
|
||||||
|
f"{prop}.source_id",
|
||||||
|
"value_source",
|
||||||
|
"value",
|
||||||
|
bindable=True,
|
||||||
|
nested=True,
|
||||||
|
)
|
||||||
|
for prop in ("color", "color_peak", "fallback_color", "default_color")
|
||||||
|
),
|
||||||
|
# ── Color strip sources (composite layers / mapped zones / calibration) ──
|
||||||
|
ConnectionField(
|
||||||
|
"color_strip_source", "layers[].source_id", "color_strip_source", "colorstrip", nested=True
|
||||||
|
),
|
||||||
|
ConnectionField(
|
||||||
|
"color_strip_source",
|
||||||
|
"layers[].brightness_source_id",
|
||||||
|
"value_source",
|
||||||
|
"value",
|
||||||
|
bindable=True,
|
||||||
|
nested=True,
|
||||||
|
),
|
||||||
|
ConnectionField(
|
||||||
|
"color_strip_source", "layers[].processing_template_id", "cspt", "template", nested=True
|
||||||
|
),
|
||||||
|
ConnectionField(
|
||||||
|
"color_strip_source", "zones[].source_id", "color_strip_source", "colorstrip", nested=True
|
||||||
|
),
|
||||||
|
ConnectionField(
|
||||||
|
"color_strip_source",
|
||||||
|
"calibration.lines[].picture_source_id",
|
||||||
|
"picture_source",
|
||||||
|
"picture",
|
||||||
|
nested=True,
|
||||||
|
),
|
||||||
|
# ── Output targets ──
|
||||||
|
ConnectionField("output_target", "device_id", "device", "device"),
|
||||||
|
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
||||||
|
ConnectionField(
|
||||||
|
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
|
||||||
|
),
|
||||||
|
ConnectionField(
|
||||||
|
"output_target", "transition.source_id", "value_source", "value", bindable=True, nested=True
|
||||||
|
),
|
||||||
|
ConnectionField(
|
||||||
|
"output_target", "settings.pattern_template_id", "pattern_template", "template", nested=True
|
||||||
|
),
|
||||||
|
ConnectionField(
|
||||||
|
"output_target",
|
||||||
|
"settings.brightness.source_id",
|
||||||
|
"value_source",
|
||||||
|
"value",
|
||||||
|
bindable=True,
|
||||||
|
nested=True,
|
||||||
|
),
|
||||||
|
# ── Scene presets ──
|
||||||
|
ConnectionField("scene_preset", "targets[].target_id", "output_target", "scene", nested=True),
|
||||||
|
# ── Automations ──
|
||||||
|
ConnectionField("automation", "scene_preset_id", "scene_preset", "scene"),
|
||||||
|
ConnectionField("automation", "deactivation_scene_preset_id", "scene_preset", "scene"),
|
||||||
|
# ── Devices ──
|
||||||
|
ConnectionField("device", "default_css_processing_template_id", "cspt", "template"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_for_kind(kind: str) -> list[ConnectionField]:
|
||||||
|
"""Every connectable field whose *referrer* is ``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]]:
|
||||||
|
"""Serialize the registry for the ``/graph/schema`` endpoint."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"target_kind": c.target_kind,
|
||||||
|
"field": c.field,
|
||||||
|
"source_kind": c.source_kind,
|
||||||
|
"edge_type": c.edge_type,
|
||||||
|
"bindable": c.bindable,
|
||||||
|
"nested": c.nested,
|
||||||
|
"is_list": c.is_list,
|
||||||
|
"editable": is_editable(c),
|
||||||
|
}
|
||||||
|
for c in CONNECTION_SCHEMA
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Reference extraction ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def extract_refs(entity: dict[str, Any], field_path: str) -> list[str]:
|
||||||
|
"""Resolve a (possibly nested/list) ``field_path`` to its referenced ids.
|
||||||
|
|
||||||
|
Returns only non-empty string ids. Tolerant of missing keys, ``None``
|
||||||
|
values and unbound bindables (a plain number where an object was expected).
|
||||||
|
"""
|
||||||
|
current: list[Any] = [entity]
|
||||||
|
for segment in field_path.split("."):
|
||||||
|
is_list = segment.endswith("[]")
|
||||||
|
key = segment[:-2] if is_list else segment
|
||||||
|
nxt: list[Any] = []
|
||||||
|
for obj in current:
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
continue
|
||||||
|
val = obj.get(key)
|
||||||
|
if is_list:
|
||||||
|
if isinstance(val, list):
|
||||||
|
nxt.extend(val)
|
||||||
|
elif val is not None:
|
||||||
|
nxt.append(val)
|
||||||
|
current = nxt
|
||||||
|
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]:
|
||||||
|
"""Best-effort serialize a storage model to a plain dict for graph use.
|
||||||
|
|
||||||
|
Prefers ``dataclasses.asdict`` (pure structural, recurses bindables/lists,
|
||||||
|
invokes no managers), falling back to ``to_dict()`` then ``{}``.
|
||||||
|
"""
|
||||||
|
if is_dataclass(model) and not isinstance(model, type):
|
||||||
|
try:
|
||||||
|
return asdict(model)
|
||||||
|
except Exception as exc: # noqa: BLE001 — defensive: never let one model break the graph
|
||||||
|
logger.debug("graph: asdict failed for %r: %s", type(model).__name__, exc)
|
||||||
|
to_dict = getattr(model, "to_dict", None)
|
||||||
|
if callable(to_dict):
|
||||||
|
try:
|
||||||
|
result = to_dict()
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return result
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.debug("graph: to_dict failed for %r: %s", type(model).__name__, exc)
|
||||||
|
logger.warning(
|
||||||
|
"graph: could not serialize model %r; excluding from graph", type(model).__name__
|
||||||
|
)
|
||||||
|
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 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _node_from(kind: str, entity: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
eid = entity.get("id")
|
||||||
|
if not isinstance(eid, str) or not eid:
|
||||||
|
return None
|
||||||
|
type_field = NODE_TYPE_FIELD.get(kind, "")
|
||||||
|
subtype = entity.get(type_field, "") if type_field else ""
|
||||||
|
return {
|
||||||
|
"id": eid,
|
||||||
|
"kind": kind,
|
||||||
|
"name": entity.get("name") or eid,
|
||||||
|
"type": subtype if isinstance(subtype, str) else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_topology(entities_by_kind: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
|
||||||
|
"""Build the full wiring graph + a validation report.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entities_by_kind: ``{kind: [serialized_entity_dict, ...]}``.
|
||||||
|
|
||||||
|
Returns a dict with ``nodes``, ``edges`` and ``issues`` (``orphans``,
|
||||||
|
``broken_refs``, ``cycles``).
|
||||||
|
"""
|
||||||
|
nodes: list[dict[str, Any]] = []
|
||||||
|
node_ids: set[str] = set()
|
||||||
|
for kind in ENTITY_KINDS:
|
||||||
|
for entity in entities_by_kind.get(kind, []):
|
||||||
|
node = _node_from(kind, entity)
|
||||||
|
if node and node["id"] not in node_ids:
|
||||||
|
node_ids.add(node["id"])
|
||||||
|
nodes.append(node)
|
||||||
|
|
||||||
|
edges: list[dict[str, Any]] = []
|
||||||
|
broken_refs: list[dict[str, str]] = []
|
||||||
|
for cf in CONNECTION_SCHEMA:
|
||||||
|
for entity in entities_by_kind.get(cf.target_kind, []):
|
||||||
|
referrer = entity.get("id")
|
||||||
|
if not isinstance(referrer, str) or not referrer:
|
||||||
|
continue
|
||||||
|
for ref in extract_refs(entity, cf.field):
|
||||||
|
if ref not in node_ids:
|
||||||
|
broken_refs.append({"ref": ref, "by": referrer, "field": cf.field})
|
||||||
|
continue
|
||||||
|
edges.append(
|
||||||
|
{
|
||||||
|
"from": ref,
|
||||||
|
"to": referrer,
|
||||||
|
"field": cf.field,
|
||||||
|
"edge_type": cf.edge_type,
|
||||||
|
"nested": cf.nested,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
connected: set[str] = set()
|
||||||
|
for e in edges:
|
||||||
|
connected.add(e["from"])
|
||||||
|
connected.add(e["to"])
|
||||||
|
orphans = sorted(nid for nid in node_ids if nid not in connected)
|
||||||
|
cycles = sorted(detect_cycles(edges))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"nodes": nodes,
|
||||||
|
"edges": edges,
|
||||||
|
"issues": {
|
||||||
|
"orphans": orphans,
|
||||||
|
"broken_refs": broken_refs,
|
||||||
|
"cycles": cycles,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_dependents(
|
||||||
|
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Return every entity that references ``(kind, entity_id)``.
|
||||||
|
|
||||||
|
``kind`` is the kind of the *referenced* entity; matching schema entries are
|
||||||
|
those whose ``source_kind == kind``.
|
||||||
|
"""
|
||||||
|
name_by_id: dict[str, str] = {}
|
||||||
|
for k in ENTITY_KINDS:
|
||||||
|
for entity in entities_by_kind.get(k, []):
|
||||||
|
eid = entity.get("id")
|
||||||
|
if isinstance(eid, str):
|
||||||
|
name_by_id[eid] = entity.get("name") or eid
|
||||||
|
|
||||||
|
dependents: list[dict[str, str]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for cf in CONNECTION_SCHEMA:
|
||||||
|
if cf.source_kind != kind:
|
||||||
|
continue
|
||||||
|
for entity in entities_by_kind.get(cf.target_kind, []):
|
||||||
|
referrer = entity.get("id")
|
||||||
|
if not isinstance(referrer, str):
|
||||||
|
continue
|
||||||
|
if entity_id in extract_refs(entity, cf.field):
|
||||||
|
key = (referrer, cf.field)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
dependents.append(
|
||||||
|
{
|
||||||
|
"id": referrer,
|
||||||
|
"kind": cf.target_kind,
|
||||||
|
"name": name_by_id.get(referrer, referrer),
|
||||||
|
"field": cf.field,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return dependents
|
||||||
|
|
||||||
|
|
||||||
|
def detect_cycles(edges: list[dict[str, Any]]) -> set[str]:
|
||||||
|
"""Return every node id that participates in a directed cycle (from→to)."""
|
||||||
|
adj: dict[str, list[str]] = {}
|
||||||
|
for e in edges:
|
||||||
|
adj.setdefault(e["from"], []).append(e["to"])
|
||||||
|
|
||||||
|
WHITE, GRAY, BLACK = 0, 1, 2
|
||||||
|
color: dict[str, int] = {}
|
||||||
|
in_cycle: set[str] = set()
|
||||||
|
|
||||||
|
for start in list(adj.keys()):
|
||||||
|
if color.get(start, WHITE) != WHITE:
|
||||||
|
continue
|
||||||
|
stack: list[tuple[str, int]] = [(start, 0)]
|
||||||
|
path: list[str] = [start]
|
||||||
|
color[start] = GRAY
|
||||||
|
while stack:
|
||||||
|
node, idx = stack[-1]
|
||||||
|
neighbors = adj.get(node, [])
|
||||||
|
if idx < len(neighbors):
|
||||||
|
stack[-1] = (node, idx + 1)
|
||||||
|
nxt = neighbors[idx]
|
||||||
|
c = color.get(nxt, WHITE)
|
||||||
|
if c == GRAY:
|
||||||
|
if nxt in path:
|
||||||
|
i = path.index(nxt)
|
||||||
|
in_cycle.update(path[i:])
|
||||||
|
elif c == WHITE:
|
||||||
|
color[nxt] = GRAY
|
||||||
|
path.append(nxt)
|
||||||
|
stack.append((nxt, 0))
|
||||||
|
else:
|
||||||
|
color[node] = BLACK
|
||||||
|
if path and path[-1] == node:
|
||||||
|
path.pop()
|
||||||
|
stack.pop()
|
||||||
|
return in_cycle
|
||||||
|
|
||||||
|
|
||||||
|
def _reachable(edges: list[dict[str, Any]], start: str, goal: str) -> bool:
|
||||||
|
"""True if ``goal`` is reachable from ``start`` following from→to edges."""
|
||||||
|
if start == goal:
|
||||||
|
return True
|
||||||
|
adj: dict[str, list[str]] = {}
|
||||||
|
for e in edges:
|
||||||
|
adj.setdefault(e["from"], []).append(e["to"])
|
||||||
|
seen = {start}
|
||||||
|
queue = [start]
|
||||||
|
while queue:
|
||||||
|
cur = queue.pop()
|
||||||
|
for nxt in adj.get(cur, []):
|
||||||
|
if nxt == goal:
|
||||||
|
return True
|
||||||
|
if nxt not in seen:
|
||||||
|
seen.add(nxt)
|
||||||
|
queue.append(nxt)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def would_create_cycle(edges: list[dict[str, Any]], source_id: str, target_id: str) -> bool:
|
||||||
|
"""Would wiring ``source_id`` into ``target_id`` (edge source→target) loop?
|
||||||
|
|
||||||
|
A cycle forms if ``source_id`` is already reachable from ``target_id`` via
|
||||||
|
the existing data-flow edges (so the new edge would close the loop), or the
|
||||||
|
two are the same node.
|
||||||
|
"""
|
||||||
|
if source_id == target_id:
|
||||||
|
return True
|
||||||
|
return _reachable(edges, target_id, source_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_exists(
|
||||||
|
entities_by_kind: dict[str, list[dict[str, Any]]], kind: str, entity_id: str
|
||||||
|
) -> bool:
|
||||||
|
return any(e.get("id") == entity_id for e in entities_by_kind.get(kind, []))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_connection(
|
||||||
|
entities_by_kind: dict[str, list[dict[str, Any]]],
|
||||||
|
target_kind: str,
|
||||||
|
target_id: str,
|
||||||
|
field: str,
|
||||||
|
source_id: str,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Validate a proposed wiring edit before it is persisted.
|
||||||
|
|
||||||
|
Checks, in order: the field is a known connectable reference; the target
|
||||||
|
exists; (when not detaching) the source exists and is of the registry's
|
||||||
|
expected kind; and the edit would not create a dependency cycle. Returns
|
||||||
|
``(ok, error_message)``. Detaching (empty ``source_id``) is always allowed.
|
||||||
|
"""
|
||||||
|
cf = next(
|
||||||
|
(c for c in CONNECTION_SCHEMA if c.target_kind == target_kind and c.field == field),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if cf is None:
|
||||||
|
return False, f"Unknown connection field: {target_kind}.{field}"
|
||||||
|
if not is_editable(cf):
|
||||||
|
# List slots (need an element index), double-nested fields, and dead
|
||||||
|
# colour bindings can't be wired from the graph — edit via the entity
|
||||||
|
# editor instead.
|
||||||
|
return False, f"Field '{field}' is not editable via the graph"
|
||||||
|
if not _entity_exists(entities_by_kind, target_kind, target_id):
|
||||||
|
return False, f"Target entity not found: {target_id}"
|
||||||
|
if not source_id:
|
||||||
|
return True, None # detaching a slot is always valid
|
||||||
|
if not _entity_exists(entities_by_kind, cf.source_kind, source_id):
|
||||||
|
return False, f"Source {cf.source_kind} not found: {source_id}"
|
||||||
|
# Cycle check: ignore the edge currently occupying this slot, since the
|
||||||
|
# write replaces it.
|
||||||
|
topo = build_topology(entities_by_kind)
|
||||||
|
edges = [e for e in topo["edges"] if not (e["to"] == target_id and e["field"] == field)]
|
||||||
|
if would_create_cycle(edges, source_id, target_id):
|
||||||
|
return False, "Connection would create a dependency cycle"
|
||||||
|
return True, None
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Shared MQTT-source validation for route handlers.
|
||||||
|
|
||||||
|
Both the device routes and the output-target routes accept an
|
||||||
|
``mqtt_source_id`` that must reference an existing ``MQTTSource``. This module
|
||||||
|
is the single source of truth for that check so the two callers cannot drift.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
|
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str | None) -> None:
|
||||||
|
"""Ensure a referenced MQTT source exists.
|
||||||
|
|
||||||
|
Empty / ``None`` is allowed (unconfigured = "first available broker").
|
||||||
|
Raises ``HTTPException(422)`` if a non-empty id does not resolve.
|
||||||
|
"""
|
||||||
|
if not mqtt_source_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
mqtt_store.get(mqtt_source_id)
|
||||||
|
except (ValueError, EntityNotFoundError):
|
||||||
|
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
"""Activity-log REST API — query / filter / export / settings / clear.
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
---------
|
||||||
|
GET /api/v1/activity-log List (filterable, keyset-paginated)
|
||||||
|
GET /api/v1/activity-log/export Streaming CSV or JSON export
|
||||||
|
GET /api/v1/activity-log/settings Retention settings
|
||||||
|
PUT /api/v1/activity-log/settings Update retention settings (requires non-anonymous auth)
|
||||||
|
DELETE /api/v1/activity-log Clear all entries (requires non-anonymous auth)
|
||||||
|
|
||||||
|
Auth posture
|
||||||
|
------------
|
||||||
|
- List + read settings (``GET``): ``AuthRequired`` (loopback-anonymous is fine).
|
||||||
|
- Export, update settings (``PUT``), and clear: ``require_authenticated()``
|
||||||
|
(loopback-anonymous is rejected; mirrors the backup download / secret-reveal
|
||||||
|
pattern from ``backup.py``). Updating settings can disable auditing or prune
|
||||||
|
the trail, so it is gated like the destructive clear.
|
||||||
|
|
||||||
|
CSV injection
|
||||||
|
-------------
|
||||||
|
Cells that begin with =, +, -, @, TAB, or CR can trigger formula execution in
|
||||||
|
spreadsheet apps (OWASP Formula Injection). ``_csv_safe`` prefixes any such cell
|
||||||
|
with a single quote so formulas are inert. Fields already go through
|
||||||
|
``sanitize_display`` in Phase 3 instrumentation, but the CSV writer applies its
|
||||||
|
own guard as defence-in-depth.
|
||||||
|
|
||||||
|
Export generator + lock
|
||||||
|
-----------------------
|
||||||
|
``repo.iter_export()`` fetches rows in bounded batches, holding the DB ``_lock``
|
||||||
|
only around each batch fetch and releasing it before yielding — so a slow or
|
||||||
|
stalled client never blocks other DB operations. The ``StreamingResponse``
|
||||||
|
generator is wrapped in a ``try/finally`` block so the batch generator is closed
|
||||||
|
even when the client disconnects mid-stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Annotated, Iterator
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from ledgrab.api.auth import AuthRequired, require_authenticated
|
||||||
|
from ledgrab.api.dependencies import (
|
||||||
|
get_activity_log_repo,
|
||||||
|
get_activity_log_retention_engine,
|
||||||
|
get_activity_recorder,
|
||||||
|
)
|
||||||
|
from ledgrab.api.schemas.activity_log import (
|
||||||
|
ActivityLogPageResponse,
|
||||||
|
ActivityLogSettingsResponse,
|
||||||
|
UpdateActivityLogSettingsRequest,
|
||||||
|
)
|
||||||
|
from ledgrab.core.activity_log.recorder import ActivityRecorder, entry_to_dict
|
||||||
|
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogFilters, ActivitySeverity
|
||||||
|
from ledgrab.storage.activity_log_repository import ActivityLogRepository
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/activity-log", tags=["Activity Log"])
|
||||||
|
|
||||||
|
# Hard cap on the per-request limit to prevent runaway queries.
|
||||||
|
_MAX_LIMIT = 200
|
||||||
|
_DEFAULT_LIMIT = 50
|
||||||
|
|
||||||
|
# Bounds on the text filter params so a multi-KB ``q`` / actor / entity filter
|
||||||
|
# can't enlarge the LIKE pattern and bound params per page (FastAPI returns 422
|
||||||
|
# on overflow). The free-text ``q`` gets a larger budget than the id filters.
|
||||||
|
_MAX_TEXT_FILTER = 256
|
||||||
|
_MAX_ID_FILTER = 128
|
||||||
|
|
||||||
|
# CSV export columns (matches entry_to_dict key order)
|
||||||
|
_CSV_COLUMNS = [
|
||||||
|
"id",
|
||||||
|
"ts",
|
||||||
|
"category",
|
||||||
|
"action",
|
||||||
|
"severity",
|
||||||
|
"actor",
|
||||||
|
"entity_type",
|
||||||
|
"entity_id",
|
||||||
|
"entity_name",
|
||||||
|
"message",
|
||||||
|
"metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Characters that trigger formula injection in spreadsheet apps (OWASP).
|
||||||
|
# Leading TAB and CR are also recognised triggers by Excel / Google Sheets.
|
||||||
|
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
||||||
|
|
||||||
|
# Cap for export-cell sanitization. Effectively no truncation (a single audit
|
||||||
|
# field never approaches this) — we reuse sanitize_display only to strip
|
||||||
|
# NUL/control/ANSI from CSV cells, not to shorten them.
|
||||||
|
_EXPORT_CELL_MAXLEN = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_safe(value: str) -> str:
|
||||||
|
"""Prefix formula-injection triggers with a literal single-quote.
|
||||||
|
|
||||||
|
A cell starting with =, +, -, or @ can execute as a formula in Excel /
|
||||||
|
Google Sheets. OWASP recommends prepending a single quote to neutralise it.
|
||||||
|
"""
|
||||||
|
if value and value[0] in _FORMULA_PREFIXES:
|
||||||
|
return "'" + value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_for_anon(entry_dict: dict, auth_label: str) -> dict:
|
||||||
|
"""Redact the source-IP metadata for anonymous (loopback) callers.
|
||||||
|
|
||||||
|
The streaming export is gated by ``require_authenticated`` precisely because
|
||||||
|
the log can contain client IPs (e.g. ``auth.rejected`` / ``auth.ws_connected``
|
||||||
|
store ``metadata.client``). The list endpoint allows loopback-anonymous
|
||||||
|
callers, so to keep the posture consistent we mask that one field for the
|
||||||
|
``"anonymous"`` label rather than handing it back what export withholds.
|
||||||
|
"""
|
||||||
|
if auth_label != "anonymous":
|
||||||
|
return entry_dict
|
||||||
|
meta = entry_dict.get("metadata")
|
||||||
|
if isinstance(meta, dict) and "client" in meta:
|
||||||
|
return {**entry_dict, "metadata": {**meta, "client": "[redacted]"}}
|
||||||
|
return entry_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _build_filters(
|
||||||
|
categories: list[str] | None,
|
||||||
|
severities: list[str] | None,
|
||||||
|
actor: str | None,
|
||||||
|
entity_type: str | None,
|
||||||
|
entity_id: str | None,
|
||||||
|
since: datetime | None,
|
||||||
|
until: datetime | None,
|
||||||
|
q: str | None,
|
||||||
|
) -> ActivityLogFilters:
|
||||||
|
"""Assemble an ``ActivityLogFilters`` dataclass from query parameters."""
|
||||||
|
return ActivityLogFilters(
|
||||||
|
categories=categories or None,
|
||||||
|
severities=severities or None,
|
||||||
|
actor=actor or None,
|
||||||
|
entity_type=entity_type or None,
|
||||||
|
entity_id=entity_id or None,
|
||||||
|
since=since,
|
||||||
|
until=until,
|
||||||
|
message_like=q or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/v1/activity-log — list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ActivityLogPageResponse, summary="List activity-log entries")
|
||||||
|
def list_activity_log(
|
||||||
|
auth: AuthRequired,
|
||||||
|
repo: ActivityLogRepository = Depends(get_activity_log_repo),
|
||||||
|
# ── Filters ────────────────────────────────────────────────────────────
|
||||||
|
categories: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Query(
|
||||||
|
description=(
|
||||||
|
"Filter by category (repeatable or comma-separated). "
|
||||||
|
"Values: auth, device, entity, capture, system"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
severities: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Query(description="Filter by severity (repeatable). Values: info, warning, error"),
|
||||||
|
] = None,
|
||||||
|
actor: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(max_length=_MAX_ID_FILTER, description="Filter by actor label (exact match)"),
|
||||||
|
] = None,
|
||||||
|
entity_type: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(max_length=_MAX_ID_FILTER, description="Filter by entity type (exact match)"),
|
||||||
|
] = None,
|
||||||
|
entity_id: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(max_length=_MAX_ID_FILTER, description="Filter by entity id (exact match)"),
|
||||||
|
] = None,
|
||||||
|
since: Annotated[
|
||||||
|
datetime | None,
|
||||||
|
Query(description="Return entries at or after this ISO-8601 datetime"),
|
||||||
|
] = None,
|
||||||
|
until: Annotated[
|
||||||
|
datetime | None,
|
||||||
|
Query(description="Return entries at or before this ISO-8601 datetime"),
|
||||||
|
] = None,
|
||||||
|
q: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(
|
||||||
|
max_length=_MAX_TEXT_FILTER,
|
||||||
|
description="Free-text search in the message field (substring)",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
# ── Pagination ─────────────────────────────────────────────────────────
|
||||||
|
before_seq: Annotated[
|
||||||
|
int | None,
|
||||||
|
Query(
|
||||||
|
description=(
|
||||||
|
"Keyset cursor: pass the 'next_before_seq' from the previous page "
|
||||||
|
"to get the following (older) page. Omit for the first (newest) page."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
limit: Annotated[
|
||||||
|
int,
|
||||||
|
Query(
|
||||||
|
ge=1,
|
||||||
|
le=_MAX_LIMIT,
|
||||||
|
description=f"Max entries per page (default {_DEFAULT_LIMIT}, max {_MAX_LIMIT})",
|
||||||
|
),
|
||||||
|
] = _DEFAULT_LIMIT,
|
||||||
|
) -> ActivityLogPageResponse:
|
||||||
|
"""Return the newest matching entries, oldest-first within the page.
|
||||||
|
|
||||||
|
Keyset pagination: the response includes ``next_before_seq`` — pass it
|
||||||
|
as ``before_seq`` in the next request to get the next (older) page.
|
||||||
|
The ``total`` field is the count of all entries matching the current
|
||||||
|
filters across all pages.
|
||||||
|
"""
|
||||||
|
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
|
||||||
|
|
||||||
|
# Fetch limit+1 rows to detect whether an older page exists.
|
||||||
|
#
|
||||||
|
# query() fetches DESC internally (newest-first) then reverses to ascending.
|
||||||
|
# With limit+1, the result is ascending: [oldest_probe, ..., newest].
|
||||||
|
# When we got exactly limit+1 rows, has_more is True and the probe row
|
||||||
|
# (index 0 — the oldest) is the extra one. We keep the newest `limit` rows
|
||||||
|
# by slicing [1:], which is the actual page content for the client.
|
||||||
|
# When we got <= limit rows, this is the last page and all rows are included.
|
||||||
|
effective_limit = min(limit, _MAX_LIMIT)
|
||||||
|
# query_with_seq returns (seq, entry) ascending (oldest-first within page),
|
||||||
|
# so the seq is already in hand — no extra get_seq_for_id round-trip.
|
||||||
|
rows_plus = repo.query_with_seq(filters, before_seq=before_seq, limit=effective_limit + 1)
|
||||||
|
has_more = len(rows_plus) > effective_limit
|
||||||
|
# When over-fetched, drop the oldest probe row (index 0) and keep the newest.
|
||||||
|
rows = rows_plus[1:] if has_more else rows_plus
|
||||||
|
|
||||||
|
total = repo.count(filters)
|
||||||
|
|
||||||
|
# next_before_seq: the seq of the oldest entry on this page (rows[0]).
|
||||||
|
# The next request passes before_seq=X to get entries with seq < X.
|
||||||
|
next_before_seq: int | None = rows[0][0] if (has_more and rows) else None
|
||||||
|
|
||||||
|
return ActivityLogPageResponse(
|
||||||
|
entries=[_redact_for_anon(entry_to_dict(e), auth) for _seq, e in rows], # type: ignore[arg-type]
|
||||||
|
next_before_seq=next_before_seq,
|
||||||
|
has_more=has_more,
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/v1/activity-log/export — streaming export (CSV or JSON)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _export_csv_generator(
|
||||||
|
repo: ActivityLogRepository,
|
||||||
|
filters: ActivityLogFilters,
|
||||||
|
) -> Iterator[bytes]:
|
||||||
|
"""Yield UTF-8-encoded CSV chunks one row at a time.
|
||||||
|
|
||||||
|
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
|
||||||
|
lock is released even on early client disconnect (which triggers
|
||||||
|
``GeneratorExit``).
|
||||||
|
"""
|
||||||
|
gen = repo.iter_export(filters)
|
||||||
|
try:
|
||||||
|
# Header
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(_CSV_COLUMNS)
|
||||||
|
yield buf.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
for entry in gen:
|
||||||
|
d = entry_to_dict(entry)
|
||||||
|
row = []
|
||||||
|
for col in _CSV_COLUMNS:
|
||||||
|
if col == "metadata":
|
||||||
|
# json.dumps escapes control chars (<0x20) as \uXXXX, so the
|
||||||
|
# metadata cell can't carry raw NUL/CR/ANSI into the file.
|
||||||
|
cell = json.dumps(d.get(col) or {})
|
||||||
|
else:
|
||||||
|
# Defense-in-depth: strip NUL/control/ANSI from string cells
|
||||||
|
# at the export boundary so a (current or future) un-sanitized
|
||||||
|
# call site can't leak control chars into the CSV. csv.writer
|
||||||
|
# quotes embedded newlines but does not strip control chars.
|
||||||
|
cell = sanitize_display(str(d.get(col, "") or ""), maxlen=_EXPORT_CELL_MAXLEN)
|
||||||
|
row.append(_csv_safe(cell))
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(row)
|
||||||
|
yield buf.getvalue().encode("utf-8")
|
||||||
|
finally:
|
||||||
|
gen.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _export_json_generator(
|
||||||
|
repo: ActivityLogRepository,
|
||||||
|
filters: ActivityLogFilters,
|
||||||
|
) -> Iterator[bytes]:
|
||||||
|
"""Yield a streamed JSON array, one entry per chunk.
|
||||||
|
|
||||||
|
Format: ``[\\n{entry},\\n{entry},\\n...]\\n``
|
||||||
|
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
|
||||||
|
lock is released even on early client disconnect.
|
||||||
|
"""
|
||||||
|
gen = repo.iter_export(filters)
|
||||||
|
try:
|
||||||
|
first = True
|
||||||
|
yield b"[\n"
|
||||||
|
for entry in gen:
|
||||||
|
d = entry_to_dict(entry)
|
||||||
|
chunk = json.dumps(d, ensure_ascii=False, default=str)
|
||||||
|
if first:
|
||||||
|
yield chunk.encode("utf-8")
|
||||||
|
first = False
|
||||||
|
else:
|
||||||
|
yield b",\n" + chunk.encode("utf-8")
|
||||||
|
yield b"\n]\n"
|
||||||
|
finally:
|
||||||
|
gen.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export", summary="Export activity-log entries (streaming CSV or JSON)")
|
||||||
|
def export_activity_log(
|
||||||
|
auth: AuthRequired,
|
||||||
|
repo: ActivityLogRepository = Depends(get_activity_log_repo),
|
||||||
|
# ── Format ────────────────────────────────────────────────────────────
|
||||||
|
format: Annotated[
|
||||||
|
str,
|
||||||
|
Query(description="Export format: 'csv' or 'json'"),
|
||||||
|
] = "csv",
|
||||||
|
# ── Same filters as list ───────────────────────────────────────────────
|
||||||
|
categories: Annotated[list[str] | None, Query()] = None,
|
||||||
|
severities: Annotated[list[str] | None, Query()] = None,
|
||||||
|
actor: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
|
||||||
|
entity_type: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
|
||||||
|
entity_id: Annotated[str | None, Query(max_length=_MAX_ID_FILTER)] = None,
|
||||||
|
since: Annotated[datetime | None, Query()] = None,
|
||||||
|
until: Annotated[datetime | None, Query()] = None,
|
||||||
|
q: Annotated[str | None, Query(max_length=_MAX_TEXT_FILTER)] = None,
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Stream all matching entries as CSV or JSON.
|
||||||
|
|
||||||
|
Requires a non-anonymous API key (loopback-anonymous access is rejected
|
||||||
|
because the log may contain IP addresses and entity names).
|
||||||
|
"""
|
||||||
|
require_authenticated(auth)
|
||||||
|
|
||||||
|
if format not in ("csv", "json"):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="'format' must be 'csv' or 'json'",
|
||||||
|
)
|
||||||
|
|
||||||
|
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
|
||||||
|
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||||
|
|
||||||
|
if format == "csv":
|
||||||
|
filename = f"activity-log-{timestamp}.csv"
|
||||||
|
media_type = "text/csv; charset=utf-8"
|
||||||
|
generator = _export_csv_generator(repo, filters)
|
||||||
|
else:
|
||||||
|
filename = f"activity-log-{timestamp}.json"
|
||||||
|
media_type = "application/json"
|
||||||
|
generator = _export_json_generator(repo, filters)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generator,
|
||||||
|
media_type=media_type,
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/v1/activity-log/settings
|
||||||
|
# PUT /api/v1/activity-log/settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/settings",
|
||||||
|
response_model=ActivityLogSettingsResponse,
|
||||||
|
summary="Get activity-log retention settings",
|
||||||
|
)
|
||||||
|
def get_activity_log_settings(
|
||||||
|
_: AuthRequired,
|
||||||
|
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
|
||||||
|
) -> ActivityLogSettingsResponse:
|
||||||
|
"""Return the current activity-log retention settings."""
|
||||||
|
return ActivityLogSettingsResponse(**engine.get_settings())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/settings",
|
||||||
|
response_model=ActivityLogSettingsResponse,
|
||||||
|
summary="Update activity-log retention settings",
|
||||||
|
)
|
||||||
|
async def update_activity_log_settings(
|
||||||
|
auth: AuthRequired,
|
||||||
|
body: UpdateActivityLogSettingsRequest,
|
||||||
|
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
|
||||||
|
) -> ActivityLogSettingsResponse:
|
||||||
|
"""Update the activity-log retention settings (applied immediately).
|
||||||
|
|
||||||
|
Requires a non-anonymous API key (loopback-anonymous access is rejected)
|
||||||
|
because disabling the log or pruning retention is equivalent in impact to
|
||||||
|
clearing the audit trail.
|
||||||
|
|
||||||
|
Setting ``enabled=false`` records an audit entry BEFORE the flag takes
|
||||||
|
effect so the last entry in the log shows who disabled recording.
|
||||||
|
"""
|
||||||
|
require_authenticated(auth)
|
||||||
|
result = await engine.update_settings(
|
||||||
|
enabled=body.enabled,
|
||||||
|
max_days=body.max_days,
|
||||||
|
max_entries=body.max_entries,
|
||||||
|
)
|
||||||
|
return ActivityLogSettingsResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /api/v1/activity-log — clear
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("", summary="Clear all activity-log entries")
|
||||||
|
def clear_activity_log(
|
||||||
|
auth: AuthRequired,
|
||||||
|
repo: ActivityLogRepository = Depends(get_activity_log_repo),
|
||||||
|
recorder: ActivityRecorder = Depends(get_activity_recorder),
|
||||||
|
) -> dict:
|
||||||
|
"""Delete all activity-log entries.
|
||||||
|
|
||||||
|
Requires a non-anonymous API key (loopback-anonymous access is rejected).
|
||||||
|
The clear operation itself is audited — a ``system/activity_log_cleared``
|
||||||
|
entry is recorded AFTER the wipe, so the log shows who cleared it and how
|
||||||
|
many rows were removed.
|
||||||
|
|
||||||
|
Returns ``{"deleted": <count>}``.
|
||||||
|
"""
|
||||||
|
require_authenticated(auth)
|
||||||
|
|
||||||
|
deleted = repo.clear()
|
||||||
|
|
||||||
|
# Record the clear action (best-effort — recorder never raises).
|
||||||
|
recorder.record(
|
||||||
|
category=ActivityCategory.SYSTEM,
|
||||||
|
action="activity_log.cleared",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
actor=auth,
|
||||||
|
message=f"Activity log cleared ({deleted} entries removed)",
|
||||||
|
metadata={"deleted_count": deleted},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"deleted": deleted}
|
||||||
@@ -182,6 +182,12 @@ async def delete_audio_source(
|
|||||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
):
|
):
|
||||||
"""Delete an audio source."""
|
"""Delete an audio source."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_source(source_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if any CSS entities reference this audio source
|
# Check if any CSS entities reference this audio source
|
||||||
from ledgrab.storage.color_strip_source import AudioColorStripSource
|
from ledgrab.storage.color_strip_source import AudioColorStripSource
|
||||||
@@ -194,7 +200,7 @@ async def delete_audio_source(
|
|||||||
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
|
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
|
||||||
|
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
fire_entity_event("audio_source", "deleted", source_id)
|
fire_entity_event("audio_source", "deleted", source_id, entity_name=_entity_name)
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -12,28 +12,35 @@ from ledgrab.api.dependencies import (
|
|||||||
get_scene_preset_store,
|
get_scene_preset_store,
|
||||||
)
|
)
|
||||||
from ledgrab.api.schemas.automations import (
|
from ledgrab.api.schemas.automations import (
|
||||||
|
ActionSchema,
|
||||||
AutomationCreate,
|
AutomationCreate,
|
||||||
AutomationListResponse,
|
AutomationListResponse,
|
||||||
AutomationResponse,
|
AutomationResponse,
|
||||||
|
AutomationTriggerResponse,
|
||||||
AutomationUpdate,
|
AutomationUpdate,
|
||||||
RuleSchema,
|
RuleSchema,
|
||||||
)
|
)
|
||||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||||
from ledgrab.storage.automation import (
|
from ledgrab.storage.automation import (
|
||||||
|
Action,
|
||||||
ApplicationRule,
|
ApplicationRule,
|
||||||
DisplayStateRule,
|
DisplayStateRule,
|
||||||
HomeAssistantRule,
|
HomeAssistantRule,
|
||||||
HTTPPollRule,
|
HTTPPollRule,
|
||||||
|
ManualTriggerRule,
|
||||||
MQTTRule,
|
MQTTRule,
|
||||||
Rule,
|
Rule,
|
||||||
|
SolarRule,
|
||||||
StartupRule,
|
StartupRule,
|
||||||
SystemIdleRule,
|
SystemIdleRule,
|
||||||
TimeOfDayRule,
|
TimeOfDayRule,
|
||||||
|
WebhookAction,
|
||||||
WebhookRule,
|
WebhookRule,
|
||||||
)
|
)
|
||||||
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.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.safe_source import validate_polling_url
|
||||||
from ledgrab.storage.base_store import EntityNotFoundError
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -52,6 +59,22 @@ 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 "",
|
||||||
|
),
|
||||||
|
# SolarRule.from_dict validates events, clamps offsets/coords, and
|
||||||
|
# filters weekdays — route the raw schema values through it.
|
||||||
|
"solar": lambda: SolarRule.from_dict(
|
||||||
|
{
|
||||||
|
"start_event": s.start_event,
|
||||||
|
"start_offset_minutes": s.start_offset_minutes,
|
||||||
|
"end_event": s.end_event,
|
||||||
|
"end_offset_minutes": s.end_offset_minutes,
|
||||||
|
"latitude": s.latitude,
|
||||||
|
"longitude": s.longitude,
|
||||||
|
"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,
|
||||||
@@ -70,6 +93,7 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
|
|||||||
token=s.token or secrets.token_hex(16),
|
token=s.token or secrets.token_hex(16),
|
||||||
),
|
),
|
||||||
"startup": lambda: StartupRule(),
|
"startup": lambda: StartupRule(),
|
||||||
|
"manual_trigger": lambda: ManualTriggerRule(),
|
||||||
"home_assistant": lambda: HomeAssistantRule(
|
"home_assistant": lambda: HomeAssistantRule(
|
||||||
ha_source_id=s.ha_source_id or "",
|
ha_source_id=s.ha_source_id or "",
|
||||||
entity_id=s.entity_id or "",
|
entity_id=s.entity_id or "",
|
||||||
@@ -93,6 +117,43 @@ def _rule_to_schema(r: Rule) -> RuleSchema:
|
|||||||
return RuleSchema(**d)
|
return RuleSchema(**d)
|
||||||
|
|
||||||
|
|
||||||
|
def _action_from_schema(s: ActionSchema) -> Action:
|
||||||
|
"""Build a domain Action from its request schema, validating the webhook URL.
|
||||||
|
|
||||||
|
The SSRF gate runs here (save time) AND again at fire time, closing the
|
||||||
|
DNS-rebinding window. A bad/blocked URL rejects the whole save with 400.
|
||||||
|
"""
|
||||||
|
if s.action_type != "webhook":
|
||||||
|
raise ValueError(f"Unknown action type: {s.action_type}")
|
||||||
|
url = (s.webhook_url or "").strip()
|
||||||
|
if not url:
|
||||||
|
raise ValueError("webhook action requires a webhook_url")
|
||||||
|
method = (s.method or "POST").upper()
|
||||||
|
if method not in ("POST", "PUT", "GET"):
|
||||||
|
raise ValueError(f"Invalid webhook method: {method}. Must be POST, PUT or GET.")
|
||||||
|
fire_on = s.fire_on or "activate"
|
||||||
|
if fire_on not in ("activate", "deactivate", "both"):
|
||||||
|
raise ValueError(f"Invalid fire_on: {fire_on}. Must be activate, deactivate or both.")
|
||||||
|
# content_type is emitted verbatim as the outbound Content-Type header — reject
|
||||||
|
# control chars (CR/LF) so it can't be used to inject additional HTTP headers.
|
||||||
|
content_type = (s.content_type or "application/json").strip()
|
||||||
|
if len(content_type) > 128 or any(ord(c) < 0x20 or ord(c) > 0x7E for c in content_type):
|
||||||
|
raise ValueError("Invalid content_type: control or non-ASCII characters are not allowed.")
|
||||||
|
# Raises HTTPException(400) on a blocked/loopback/metadata target.
|
||||||
|
validate_polling_url(url)
|
||||||
|
return WebhookAction(
|
||||||
|
webhook_url=url,
|
||||||
|
method=method,
|
||||||
|
body_template=s.body_template or "",
|
||||||
|
content_type=content_type,
|
||||||
|
fire_on=fire_on,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _action_to_schema(a: Action) -> ActionSchema:
|
||||||
|
return ActionSchema(**a.to_dict())
|
||||||
|
|
||||||
|
|
||||||
def _automation_to_response(
|
def _automation_to_response(
|
||||||
automation, engine: AutomationEngine, request: Request = None
|
automation, engine: AutomationEngine, request: Request = None
|
||||||
) -> AutomationResponse:
|
) -> AutomationResponse:
|
||||||
@@ -128,6 +189,7 @@ def _automation_to_response(
|
|||||||
last_activated_at=state.get("last_activated_at"),
|
last_activated_at=state.get("last_activated_at"),
|
||||||
last_deactivated_at=state.get("last_deactivated_at"),
|
last_deactivated_at=state.get("last_deactivated_at"),
|
||||||
tags=automation.tags,
|
tags=automation.tags,
|
||||||
|
actions=[_action_to_schema(a) for a in getattr(automation, "actions", [])],
|
||||||
icon=getattr(automation, "icon", "") or "",
|
icon=getattr(automation, "icon", "") or "",
|
||||||
icon_color=getattr(automation, "icon_color", "") or "",
|
icon_color=getattr(automation, "icon_color", "") or "",
|
||||||
created_at=automation.created_at,
|
created_at=automation.created_at,
|
||||||
@@ -184,6 +246,7 @@ async def create_automation(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
rules = [_rule_from_schema(r) for r in data.rules]
|
rules = [_rule_from_schema(r) for r in data.rules]
|
||||||
|
actions = [_action_from_schema(a) for a in data.actions]
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
@@ -199,6 +262,7 @@ async def create_automation(
|
|||||||
deactivation_mode=data.deactivation_mode,
|
deactivation_mode=data.deactivation_mode,
|
||||||
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
|
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
|
actions=actions,
|
||||||
icon=data.icon,
|
icon=data.icon,
|
||||||
icon_color=data.icon_color,
|
icon_color=data.icon_color,
|
||||||
)
|
)
|
||||||
@@ -281,6 +345,13 @@ async def update_automation(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
actions = None
|
||||||
|
if data.actions is not None:
|
||||||
|
try:
|
||||||
|
actions = [_action_from_schema(a) for a in data.actions]
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# If disabling, deactivate first
|
# If disabling, deactivate first
|
||||||
if data.enabled is False:
|
if data.enabled is False:
|
||||||
@@ -295,6 +366,7 @@ async def update_automation(
|
|||||||
rules=rules,
|
rules=rules,
|
||||||
deactivation_mode=data.deactivation_mode,
|
deactivation_mode=data.deactivation_mode,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
|
actions=actions,
|
||||||
icon=data.icon,
|
icon=data.icon,
|
||||||
icon_color=data.icon_color,
|
icon_color=data.icon_color,
|
||||||
)
|
)
|
||||||
@@ -327,6 +399,12 @@ async def delete_automation(
|
|||||||
engine: AutomationEngine = Depends(get_automation_engine),
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
):
|
):
|
||||||
"""Delete an automation."""
|
"""Delete an automation."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_automation(automation_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Deactivate first
|
# Deactivate first
|
||||||
await engine.deactivate_if_active(automation_id)
|
await engine.deactivate_if_active(automation_id)
|
||||||
|
|
||||||
@@ -335,7 +413,7 @@ async def delete_automation(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
fire_entity_event("automation", "deleted", automation_id)
|
fire_entity_event("automation", "deleted", automation_id, entity_name=_entity_name)
|
||||||
|
|
||||||
|
|
||||||
# ===== Enable/Disable =====
|
# ===== Enable/Disable =====
|
||||||
@@ -386,3 +464,37 @@ async def disable_automation(
|
|||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
return _automation_to_response(automation, engine, request)
|
return _automation_to_response(automation, engine, request)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Manual trigger =====
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/automations/{automation_id}/trigger",
|
||||||
|
response_model=AutomationTriggerResponse,
|
||||||
|
tags=["Automations"],
|
||||||
|
)
|
||||||
|
async def trigger_automation(
|
||||||
|
automation_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: AutomationStore = Depends(get_automation_store),
|
||||||
|
engine: AutomationEngine = Depends(get_automation_engine),
|
||||||
|
):
|
||||||
|
"""Manually fire an automation.
|
||||||
|
|
||||||
|
Evaluates the automation's rules with its manual trigger satisfied — so it
|
||||||
|
"still checks all of the rules" under the automation's ``rule_logic`` — and,
|
||||||
|
if it should activate, applies its scene once. Independent of the ``enabled``
|
||||||
|
flag (that gates only the background evaluation loop).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
automation = store.get_automation(automation_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, errors = await engine.fire_manual_trigger(automation)
|
||||||
|
except Exception as e: # noqa: BLE001 — surface a structured error, never a bare 500
|
||||||
|
logger.error("Manual trigger failed for automation %s: %s", automation_id, e)
|
||||||
|
return AutomationTriggerResponse(status="error", errors=[str(e)])
|
||||||
|
return AutomationTriggerResponse(status=status, errors=errors)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from ledgrab.api.schemas.system import (
|
|||||||
)
|
)
|
||||||
from ledgrab.config import get_config
|
from ledgrab.config import get_config
|
||||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
from ledgrab.storage.asset_store import AssetStore
|
from ledgrab.storage.asset_store import AssetStore
|
||||||
from ledgrab.storage.database import Database, freeze_writes
|
from ledgrab.storage.database import Database, freeze_writes
|
||||||
from ledgrab.utils import get_logger, read_upload_capped
|
from ledgrab.utils import get_logger, read_upload_capped
|
||||||
@@ -35,6 +36,22 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _record_system(action: str, message: str, metadata: dict | None = None) -> None:
|
||||||
|
"""Best-effort audit record for a system-level event."""
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.SYSTEM,
|
||||||
|
action=action,
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
message=message,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
||||||
|
|
||||||
|
|
||||||
@@ -143,6 +160,8 @@ def backup_config(
|
|||||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||||
filename = f"ledgrab-backup-{timestamp}.zip"
|
filename = f"ledgrab-backup-{timestamp}.zip"
|
||||||
|
|
||||||
|
_record_system("backup.created", f"Backup downloaded: {filename}", {"filename": filename})
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
zip_buffer,
|
zip_buffer,
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
@@ -243,6 +262,7 @@ async def restore_config(
|
|||||||
|
|
||||||
freeze_writes()
|
freeze_writes()
|
||||||
logger.info("Database restored from uploaded backup. Scheduling restart...")
|
logger.info("Database restored from uploaded backup. Scheduling restart...")
|
||||||
|
_record_system("backup.restored", "Database restored from uploaded backup")
|
||||||
_schedule_restart()
|
_schedule_restart()
|
||||||
|
|
||||||
return RestoreResponse(
|
return RestoreResponse(
|
||||||
@@ -257,6 +277,7 @@ def restart_server(_: AuthRequired):
|
|||||||
"""Schedule a server restart and return immediately."""
|
"""Schedule a server restart and return immediately."""
|
||||||
from ledgrab.server_ref import _broadcast_restarting
|
from ledgrab.server_ref import _broadcast_restarting
|
||||||
|
|
||||||
|
_record_system("server.restarting", "Server restart requested by user")
|
||||||
_broadcast_restarting()
|
_broadcast_restarting()
|
||||||
_schedule_restart()
|
_schedule_restart()
|
||||||
return {"status": "restarting"}
|
return {"status": "restarting"}
|
||||||
@@ -267,6 +288,7 @@ def shutdown_server(_: AuthRequired):
|
|||||||
"""Gracefully shut down the server."""
|
"""Gracefully shut down the server."""
|
||||||
from ledgrab.server_ref import request_shutdown
|
from ledgrab.server_ref import request_shutdown
|
||||||
|
|
||||||
|
_record_system("server.shutdown_requested", "Server shutdown requested by user")
|
||||||
request_shutdown()
|
request_shutdown()
|
||||||
return {"status": "shutting_down"}
|
return {"status": "shutting_down"}
|
||||||
|
|
||||||
@@ -300,11 +322,17 @@ async def update_auto_backup_settings(
|
|||||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||||
):
|
):
|
||||||
"""Update auto-backup settings (enable/disable, interval, max backups)."""
|
"""Update auto-backup settings (enable/disable, interval, max backups)."""
|
||||||
return await engine.update_settings(
|
result = await engine.update_settings(
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
interval_hours=body.interval_hours,
|
interval_hours=body.interval_hours,
|
||||||
max_backups=body.max_backups,
|
max_backups=body.max_backups,
|
||||||
)
|
)
|
||||||
|
_record_system(
|
||||||
|
"settings.changed",
|
||||||
|
f"Auto-backup settings updated (enabled={body.enabled})",
|
||||||
|
{"setting_key": "auto_backup", "enabled": body.enabled},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
|
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
|
||||||
@@ -365,4 +393,5 @@ async def delete_saved_backup(
|
|||||||
engine.delete_backup(filename)
|
engine.delete_backup(filename)
|
||||||
except (ValueError, FileNotFoundError) as e:
|
except (ValueError, FileNotFoundError) as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
_record_system("backup.deleted", f"Saved backup deleted: {filename}", {"filename": filename})
|
||||||
return {"status": "deleted", "filename": filename}
|
return {"status": "deleted", "filename": filename}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""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.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
|
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")
|
||||||
|
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.SYSTEM,
|
||||||
|
action="calibration.started",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
entity_type="device",
|
||||||
|
entity_id=body.device_id,
|
||||||
|
message=f"Calibration session started for device '{body.device_id}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.SYSTEM,
|
||||||
|
action="calibration.stopped",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
message="Calibration session stopped",
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.SYSTEM,
|
||||||
|
action="calibration.cancelled",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
message="Calibration session cancelled",
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -167,6 +167,12 @@ async def delete_color_strip_source(
|
|||||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
):
|
):
|
||||||
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
|
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_source(source_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target_names = target_store.get_targets_referencing_css(source_id)
|
target_names = target_store.get_targets_referencing_css(source_id)
|
||||||
if target_names:
|
if target_names:
|
||||||
@@ -201,7 +207,7 @@ async def delete_color_strip_source(
|
|||||||
"Delete or reassign the processed source(s) first.",
|
"Delete or reassign the processed source(s) first.",
|
||||||
)
|
)
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
fire_entity_event("color_strip_source", "deleted", source_id)
|
fire_entity_event("color_strip_source", "deleted", source_id, entity_name=_entity_name)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from ledgrab.core.devices.led_client import (
|
|||||||
from ledgrab.api.dependencies import (
|
from ledgrab.api.dependencies import (
|
||||||
fire_entity_event,
|
fire_entity_event,
|
||||||
get_device_store,
|
get_device_store,
|
||||||
|
get_mqtt_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
)
|
)
|
||||||
@@ -33,10 +34,13 @@ from ledgrab.api.schemas.devices import (
|
|||||||
)
|
)
|
||||||
from ledgrab.core.processing.processor_manager import ProcessorManager
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||||
from ledgrab.storage import DeviceStore
|
from ledgrab.storage import DeviceStore
|
||||||
|
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
|
||||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
from ledgrab.utils.url_scheme import infer_http_scheme
|
from ledgrab.utils.url_scheme import infer_http_scheme
|
||||||
|
|
||||||
|
from ._mqtt_validation import validate_mqtt_source_exists
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -92,19 +96,23 @@ def _device_to_response(device) -> DeviceResponse:
|
|||||||
espnow_channel=device.espnow_channel,
|
espnow_channel=device.espnow_channel,
|
||||||
hue_paired=bool(device.hue_username and device.hue_client_key),
|
hue_paired=bool(device.hue_username and device.hue_client_key),
|
||||||
hue_entertainment_group_id=device.hue_entertainment_group_id,
|
hue_entertainment_group_id=device.hue_entertainment_group_id,
|
||||||
|
hue_gradient_mode=device.hue_gradient_mode,
|
||||||
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
|
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
|
||||||
wiz_min_interval_ms=device.wiz_min_interval_ms,
|
wiz_min_interval_ms=device.wiz_min_interval_ms,
|
||||||
lifx_min_interval_ms=device.lifx_min_interval_ms,
|
lifx_min_interval_ms=device.lifx_min_interval_ms,
|
||||||
|
lifx_per_zone=device.lifx_per_zone,
|
||||||
govee_min_interval_ms=device.govee_min_interval_ms,
|
govee_min_interval_ms=device.govee_min_interval_ms,
|
||||||
opc_channel=device.opc_channel,
|
opc_channel=device.opc_channel,
|
||||||
nanoleaf_paired=bool(device.nanoleaf_token),
|
nanoleaf_paired=bool(device.nanoleaf_token),
|
||||||
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
|
nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms,
|
||||||
|
nanoleaf_per_panel=device.nanoleaf_per_panel,
|
||||||
spi_speed_hz=device.spi_speed_hz,
|
spi_speed_hz=device.spi_speed_hz,
|
||||||
spi_led_type=device.spi_led_type,
|
spi_led_type=device.spi_led_type,
|
||||||
chroma_device_type=device.chroma_device_type,
|
chroma_device_type=device.chroma_device_type,
|
||||||
gamesense_device_type=device.gamesense_device_type,
|
gamesense_device_type=device.gamesense_device_type,
|
||||||
ble_family=device.ble_family,
|
ble_family=device.ble_family,
|
||||||
ble_govee_key=device.ble_govee_key,
|
ble_govee_key=device.ble_govee_key,
|
||||||
|
mqtt_source_id=getattr(device, "mqtt_source_id", "") or "",
|
||||||
default_css_processing_template_id=device.default_css_processing_template_id,
|
default_css_processing_template_id=device.default_css_processing_template_id,
|
||||||
group_device_ids=device.group_device_ids,
|
group_device_ids=device.group_device_ids,
|
||||||
group_mode=device.group_mode,
|
group_mode=device.group_mode,
|
||||||
@@ -124,11 +132,13 @@ async def create_device(
|
|||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||||
):
|
):
|
||||||
"""Create and attach a new LED device."""
|
"""Create and attach a new LED device."""
|
||||||
try:
|
try:
|
||||||
device_type = device_data.device_type
|
device_type = device_data.device_type
|
||||||
logger.info(f"Creating {device_type} device: {device_data.name}")
|
logger.info(f"Creating {device_type} device: {device_data.name}")
|
||||||
|
validate_mqtt_source_exists(mqtt_store, device_data.mqtt_source_id)
|
||||||
|
|
||||||
# ── Group device: validate children + compute LED count ──
|
# ── Group device: validate children + compute LED count ──
|
||||||
if device_type == "group":
|
if device_type == "group":
|
||||||
@@ -254,6 +264,9 @@ async def create_device(
|
|||||||
hue_username=device_data.hue_username or "",
|
hue_username=device_data.hue_username or "",
|
||||||
hue_client_key=device_data.hue_client_key or "",
|
hue_client_key=device_data.hue_client_key or "",
|
||||||
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
|
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
|
||||||
|
hue_gradient_mode=(
|
||||||
|
device_data.hue_gradient_mode if device_data.hue_gradient_mode is not None else True
|
||||||
|
),
|
||||||
yeelight_min_interval_ms=(
|
yeelight_min_interval_ms=(
|
||||||
device_data.yeelight_min_interval_ms
|
device_data.yeelight_min_interval_ms
|
||||||
if device_data.yeelight_min_interval_ms is not None
|
if device_data.yeelight_min_interval_ms is not None
|
||||||
@@ -269,6 +282,7 @@ async def create_device(
|
|||||||
if device_data.lifx_min_interval_ms is not None
|
if device_data.lifx_min_interval_ms is not None
|
||||||
else 50
|
else 50
|
||||||
),
|
),
|
||||||
|
lifx_per_zone=bool(device_data.lifx_per_zone),
|
||||||
govee_min_interval_ms=(
|
govee_min_interval_ms=(
|
||||||
device_data.govee_min_interval_ms
|
device_data.govee_min_interval_ms
|
||||||
if device_data.govee_min_interval_ms is not None
|
if device_data.govee_min_interval_ms is not None
|
||||||
@@ -281,12 +295,14 @@ async def create_device(
|
|||||||
if device_data.nanoleaf_min_interval_ms is not None
|
if device_data.nanoleaf_min_interval_ms is not None
|
||||||
else 100
|
else 100
|
||||||
),
|
),
|
||||||
|
nanoleaf_per_panel=bool(device_data.nanoleaf_per_panel),
|
||||||
spi_speed_hz=device_data.spi_speed_hz or 800000,
|
spi_speed_hz=device_data.spi_speed_hz or 800000,
|
||||||
spi_led_type=device_data.spi_led_type or "WS2812B",
|
spi_led_type=device_data.spi_led_type or "WS2812B",
|
||||||
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
||||||
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
|
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
|
||||||
ble_family=device_data.ble_family or "",
|
ble_family=device_data.ble_family or "",
|
||||||
ble_govee_key=device_data.ble_govee_key or "",
|
ble_govee_key=device_data.ble_govee_key or "",
|
||||||
|
mqtt_source_id=device_data.mqtt_source_id or "",
|
||||||
group_device_ids=group_device_ids,
|
group_device_ids=group_device_ids,
|
||||||
group_mode=group_mode,
|
group_mode=group_mode,
|
||||||
)
|
)
|
||||||
@@ -543,12 +559,14 @@ async def update_device(
|
|||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
|
mqtt_store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||||
):
|
):
|
||||||
"""Update device information."""
|
"""Update device information."""
|
||||||
try:
|
try:
|
||||||
# Group-specific validation before applying update
|
# Group-specific validation before applying update
|
||||||
existing = store.get_device(device_id)
|
existing = store.get_device(device_id)
|
||||||
is_group = existing.device_type == "group"
|
is_group = existing.device_type == "group"
|
||||||
|
validate_mqtt_source_exists(mqtt_store, update_data.mqtt_source_id)
|
||||||
|
|
||||||
# Normalize URL the same way we do on create:
|
# Normalize URL the same way we do on create:
|
||||||
# * always rstrip trailing slashes (so PUT-with-trailing-/ matches
|
# * always rstrip trailing slashes (so PUT-with-trailing-/ matches
|
||||||
@@ -621,19 +639,23 @@ async def update_device(
|
|||||||
hue_username=update_data.hue_username,
|
hue_username=update_data.hue_username,
|
||||||
hue_client_key=update_data.hue_client_key,
|
hue_client_key=update_data.hue_client_key,
|
||||||
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
|
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
|
||||||
|
hue_gradient_mode=update_data.hue_gradient_mode,
|
||||||
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
|
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
|
||||||
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
|
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
|
||||||
lifx_min_interval_ms=update_data.lifx_min_interval_ms,
|
lifx_min_interval_ms=update_data.lifx_min_interval_ms,
|
||||||
|
lifx_per_zone=update_data.lifx_per_zone,
|
||||||
govee_min_interval_ms=update_data.govee_min_interval_ms,
|
govee_min_interval_ms=update_data.govee_min_interval_ms,
|
||||||
opc_channel=update_data.opc_channel,
|
opc_channel=update_data.opc_channel,
|
||||||
nanoleaf_token=update_data.nanoleaf_token,
|
nanoleaf_token=update_data.nanoleaf_token,
|
||||||
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
|
nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms,
|
||||||
|
nanoleaf_per_panel=update_data.nanoleaf_per_panel,
|
||||||
spi_speed_hz=update_data.spi_speed_hz,
|
spi_speed_hz=update_data.spi_speed_hz,
|
||||||
spi_led_type=update_data.spi_led_type,
|
spi_led_type=update_data.spi_led_type,
|
||||||
chroma_device_type=update_data.chroma_device_type,
|
chroma_device_type=update_data.chroma_device_type,
|
||||||
gamesense_device_type=update_data.gamesense_device_type,
|
gamesense_device_type=update_data.gamesense_device_type,
|
||||||
ble_family=update_data.ble_family,
|
ble_family=update_data.ble_family,
|
||||||
ble_govee_key=update_data.ble_govee_key,
|
ble_govee_key=update_data.ble_govee_key,
|
||||||
|
mqtt_source_id=update_data.mqtt_source_id,
|
||||||
group_device_ids=update_data.group_device_ids,
|
group_device_ids=update_data.group_device_ids,
|
||||||
group_mode=update_data.group_mode,
|
group_mode=update_data.group_mode,
|
||||||
icon=update_data.icon,
|
icon=update_data.icon,
|
||||||
@@ -669,6 +691,10 @@ async def update_device(
|
|||||||
fire_entity_event("device", "updated", device_id)
|
fire_entity_event("device", "updated", device_id)
|
||||||
return _device_to_response(device)
|
return _device_to_response(device)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
# Intentional 4xx (e.g. unknown mqtt_source_id, group validation)
|
||||||
|
# must propagate unchanged — not be masked as a 500.
|
||||||
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -686,6 +712,13 @@ async def delete_device(
|
|||||||
):
|
):
|
||||||
"""Delete/detach a device. Returns 409 if referenced by a target."""
|
"""Delete/detach a device. Returns 409 if referenced by a target."""
|
||||||
try:
|
try:
|
||||||
|
# Resolve name before deletion for the audit record.
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_device(device_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Check if any target references this device
|
# Check if any target references this device
|
||||||
refs = target_store.get_targets_for_device(device_id)
|
refs = target_store.get_targets_for_device(device_id)
|
||||||
if refs:
|
if refs:
|
||||||
@@ -713,7 +746,7 @@ async def delete_device(
|
|||||||
# Delete from storage
|
# Delete from storage
|
||||||
store.delete_device(device_id)
|
store.delete_device(device_id)
|
||||||
|
|
||||||
fire_entity_event("device", "deleted", device_id)
|
fire_entity_event("device", "deleted", device_id, entity_name=_entity_name)
|
||||||
logger.info(f"Deleted device {device_id}")
|
logger.info(f"Deleted device {device_id}")
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -777,6 +810,32 @@ async def ping_device(
|
|||||||
# ===== WLED BRIGHTNESS ENDPOINTS =====
|
# ===== WLED BRIGHTNESS ENDPOINTS =====
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_device_brightness(device, manager: ProcessorManager) -> int | None:
|
||||||
|
"""Resolve a device's current brightness for aggregate/batch reads.
|
||||||
|
|
||||||
|
Mirrors GET /brightness but degrades to ``None`` instead of raising, so one
|
||||||
|
unreachable device can't fail a whole snapshot. Reads the server-side cache
|
||||||
|
first and only touches hardware when the cache is cold, then populates it so
|
||||||
|
subsequent reads are I/O-free.
|
||||||
|
"""
|
||||||
|
if "brightness_control" not in get_device_capabilities(device.device_type):
|
||||||
|
return None
|
||||||
|
ds = manager.find_device_state(device.id)
|
||||||
|
if ds and ds.hardware_brightness is not None:
|
||||||
|
return ds.hardware_brightness
|
||||||
|
try:
|
||||||
|
provider = get_provider(device.device_type)
|
||||||
|
bri = await provider.get_brightness(device.url)
|
||||||
|
if ds:
|
||||||
|
ds.hardware_brightness = bri
|
||||||
|
return bri
|
||||||
|
except NotImplementedError:
|
||||||
|
return device.software_brightness
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to resolve brightness for device %s: %s", device.id, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
||||||
async def get_device_brightness(
|
async def get_device_brightness(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ adapter metadata, and diagnostics.
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -16,6 +17,7 @@ from ledgrab.api.dependencies import (
|
|||||||
get_database,
|
get_database,
|
||||||
get_game_integration_store,
|
get_game_integration_store,
|
||||||
get_game_event_bus,
|
get_game_event_bus,
|
||||||
|
get_lol_poll_manager,
|
||||||
)
|
)
|
||||||
from ledgrab.api.schemas.game_integration import (
|
from ledgrab.api.schemas.game_integration import (
|
||||||
AdapterInfoResponse,
|
AdapterInfoResponse,
|
||||||
@@ -36,9 +38,16 @@ from ledgrab.api.schemas.game_integration import (
|
|||||||
)
|
)
|
||||||
from ledgrab.core.game_integration.adapter_registry import AdapterRegistry
|
from ledgrab.core.game_integration.adapter_registry import AdapterRegistry
|
||||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||||
from ledgrab.core.game_integration.events import GameEvent
|
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
|
||||||
|
from ledgrab.core.game_integration.runtime_state import (
|
||||||
|
cleanup_state as _cleanup_state,
|
||||||
|
get_prev_state as _get_prev_state,
|
||||||
|
get_stats as _get_stats,
|
||||||
|
record_events as _record_events,
|
||||||
|
set_prev_state as _set_prev_state,
|
||||||
|
)
|
||||||
from ledgrab.storage.base_store import EntityNotFoundError
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
from ledgrab.storage.game_integration import EventMapping
|
from ledgrab.storage.game_integration import _SECRET_CONFIG_KEYS, EventMapping
|
||||||
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
@@ -46,15 +55,77 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# ── Per-integration runtime state (in-memory, not persisted) ──────────────
|
# Per-integration runtime state (prev-state + stats + payload processing) lives
|
||||||
|
# in ``core/game_integration/runtime_state.py`` and is imported above under the
|
||||||
|
# legacy ``_get_prev_state`` / ``_record_events`` / … names so both this route
|
||||||
|
# and the LoL poll manager share one set of counters.
|
||||||
|
|
||||||
_integration_state_lock = threading.Lock()
|
|
||||||
|
|
||||||
# integration_id -> prev_state dict for diff-based trigger detection
|
# ── Failed-auth rate limiter (brute-force defence on the ingest route) ─────
|
||||||
_prev_states: dict[str, dict[str, Any]] = {}
|
#
|
||||||
|
# The ingest route is high-frequency (games push at 16-64 Hz), so we do NOT
|
||||||
|
# rate-limit every event — that would throttle legitimate gameplay traffic.
|
||||||
|
# Instead we throttle only FAILED-auth attempts per source IP (the only thing
|
||||||
|
# an attacker without the token can produce). This mirrors the IP-based
|
||||||
|
# limiter in routes/webhooks.py (~30/min) but scopes it to failures so a
|
||||||
|
# brute-forcer is locked out after _AUTH_FAIL_LIMIT bad tokens per minute
|
||||||
|
# while authenticated high-rate ingestion is completely unaffected.
|
||||||
|
_AUTH_FAIL_LIMIT = 30
|
||||||
|
_AUTH_FAIL_WINDOW = 60.0 # seconds
|
||||||
|
_AUTH_FAIL_HITS_HARD_CAP = 1024
|
||||||
|
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||||
|
_auth_fail_hits: dict[str, list[float]] = defaultdict(list)
|
||||||
|
_auth_fail_lock = threading.Lock()
|
||||||
|
|
||||||
# integration_id -> runtime stats
|
|
||||||
_integration_stats: dict[str, dict[str, Any]] = {}
|
def _rate_limit_key(request: Request) -> str:
|
||||||
|
"""Pick a stable client identifier for rate-limiting.
|
||||||
|
|
||||||
|
When the immediate peer is loopback (assumed reverse-proxy), use the
|
||||||
|
first ``X-Forwarded-For`` entry; otherwise use the peer's IP.
|
||||||
|
"""
|
||||||
|
peer = request.client.host if request.client else "unknown"
|
||||||
|
if peer in _LOOPBACK_HOSTS:
|
||||||
|
xff = request.headers.get("x-forwarded-for", "")
|
||||||
|
if xff:
|
||||||
|
return xff.split(",", 1)[0].strip() or peer
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
|
def _check_auth_fail_rate_limit(client_ip: str) -> None:
|
||||||
|
"""Raise 429 if *client_ip* exceeded the failed-auth attempt limit."""
|
||||||
|
now = time.time()
|
||||||
|
window_start = now - _AUTH_FAIL_WINDOW
|
||||||
|
with _auth_fail_lock:
|
||||||
|
timestamps = [t for t in _auth_fail_hits[client_ip] if t > window_start]
|
||||||
|
_auth_fail_hits[client_ip] = timestamps
|
||||||
|
if len(timestamps) >= _AUTH_FAIL_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many failed authentication attempts. Try again later.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_auth_failure(client_ip: str) -> None:
|
||||||
|
"""Record a failed-auth attempt for *client_ip* (bounded memory)."""
|
||||||
|
now = time.time()
|
||||||
|
window_start = now - _AUTH_FAIL_WINDOW
|
||||||
|
with _auth_fail_lock:
|
||||||
|
_auth_fail_hits[client_ip].append(now)
|
||||||
|
# Periodic cleanup of stale IPs to prevent unbounded growth.
|
||||||
|
if len(_auth_fail_hits) > 100:
|
||||||
|
stale = [ip for ip, ts in _auth_fail_hits.items() if not ts or ts[-1] < window_start]
|
||||||
|
for ip in stale:
|
||||||
|
del _auth_fail_hits[ip]
|
||||||
|
# Hard cap against an attacker spraying many distinct X-Forwarded-For
|
||||||
|
# values; drop the oldest-touched IPs.
|
||||||
|
if len(_auth_fail_hits) > _AUTH_FAIL_HITS_HARD_CAP:
|
||||||
|
ordered = sorted(
|
||||||
|
_auth_fail_hits.items(),
|
||||||
|
key=lambda kv: kv[1][-1] if kv[1] else 0.0,
|
||||||
|
)
|
||||||
|
for ip, _ in ordered[: len(ordered) - _AUTH_FAIL_HITS_HARD_CAP]:
|
||||||
|
_auth_fail_hits.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
|
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
@@ -82,59 +153,47 @@ def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
def _get_prev_state(integration_id: str) -> dict[str, Any]:
|
|
||||||
"""Get or create the prev_state dict for an integration."""
|
|
||||||
with _integration_state_lock:
|
|
||||||
if integration_id not in _prev_states:
|
|
||||||
_prev_states[integration_id] = {}
|
|
||||||
return _prev_states[integration_id]
|
|
||||||
|
|
||||||
|
|
||||||
def _set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
|
|
||||||
"""Update the prev_state dict for an integration."""
|
|
||||||
with _integration_state_lock:
|
|
||||||
_prev_states[integration_id] = state
|
|
||||||
|
|
||||||
|
|
||||||
def _record_events(integration_id: str, events: list[GameEvent]) -> None:
|
|
||||||
"""Record event stats for an integration."""
|
|
||||||
with _integration_state_lock:
|
|
||||||
if integration_id not in _integration_stats:
|
|
||||||
_integration_stats[integration_id] = {
|
|
||||||
"event_count": 0,
|
|
||||||
"event_counts_by_type": {},
|
|
||||||
"last_event_time": None,
|
|
||||||
}
|
|
||||||
stats = _integration_stats[integration_id]
|
|
||||||
for event in events:
|
|
||||||
stats["event_count"] += 1
|
|
||||||
stats["event_counts_by_type"][event.event_type] = (
|
|
||||||
stats["event_counts_by_type"].get(event.event_type, 0) + 1
|
|
||||||
)
|
|
||||||
stats["last_event_time"] = event.timestamp
|
|
||||||
|
|
||||||
|
|
||||||
def _get_stats(integration_id: str) -> dict[str, Any]:
|
|
||||||
"""Get runtime stats for an integration."""
|
|
||||||
with _integration_state_lock:
|
|
||||||
return _integration_stats.get(
|
|
||||||
integration_id,
|
|
||||||
{"event_count": 0, "event_counts_by_type": {}, "last_event_time": None},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_state(integration_id: str) -> None:
|
|
||||||
"""Remove runtime state for a deleted integration."""
|
|
||||||
with _integration_state_lock:
|
|
||||||
_prev_states.pop(integration_id, None)
|
|
||||||
_integration_stats.pop(integration_id, None)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helper: convert config to response ────────────────────────────────────
|
# ── Helper: convert config to response ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_secrets(adapter_config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return a copy of *adapter_config* with secret values masked.
|
||||||
|
|
||||||
|
The adapter ``auth_token`` is a live shared secret (it authenticates the
|
||||||
|
ingest endpoint). It is encrypted at rest, but the response builder echoes
|
||||||
|
the in-memory *decrypted* config, so without masking any API caller
|
||||||
|
(loopback-anonymous by default) could read the cleartext token. We never
|
||||||
|
return the secret over the API — the edit form submits a blank value to
|
||||||
|
keep the existing secret (see ``_merge_preserved_secrets``).
|
||||||
|
"""
|
||||||
|
cfg = dict(adapter_config)
|
||||||
|
for key in _SECRET_CONFIG_KEYS:
|
||||||
|
if cfg.get(key):
|
||||||
|
cfg[key] = "" # mask — never echo the secret to the client
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_preserved_secrets(
|
||||||
|
incoming: dict[str, Any] | None, existing: Any
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Preserve a stored secret when an update submits a blank/absent one.
|
||||||
|
|
||||||
|
Because the API masks secrets in responses, the edit form re-submits a
|
||||||
|
blank value for an unchanged secret. Without this merge that blank would
|
||||||
|
overwrite (and destroy) the stored token. A non-empty incoming value is a
|
||||||
|
deliberate change and is kept as-is.
|
||||||
|
"""
|
||||||
|
if incoming is None:
|
||||||
|
return None
|
||||||
|
merged = dict(incoming)
|
||||||
|
for key in _SECRET_CONFIG_KEYS:
|
||||||
|
if not merged.get(key) and existing.adapter_config.get(key):
|
||||||
|
merged[key] = existing.adapter_config[key]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _config_to_response(config: Any) -> GameIntegrationResponse:
|
def _config_to_response(config: Any) -> GameIntegrationResponse:
|
||||||
"""Convert a GameIntegrationConfig to its API response."""
|
"""Convert a GameIntegrationConfig to its API response (secrets redacted)."""
|
||||||
from ledgrab.api.schemas.game_integration import EventMappingSchema
|
from ledgrab.api.schemas.game_integration import EventMappingSchema
|
||||||
|
|
||||||
return GameIntegrationResponse(
|
return GameIntegrationResponse(
|
||||||
@@ -142,7 +201,7 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
|
|||||||
name=config.name,
|
name=config.name,
|
||||||
adapter_type=config.adapter_type,
|
adapter_type=config.adapter_type,
|
||||||
enabled=config.enabled,
|
enabled=config.enabled,
|
||||||
adapter_config=config.adapter_config,
|
adapter_config=_redact_secrets(config.adapter_config),
|
||||||
event_mappings=[
|
event_mappings=[
|
||||||
EventMappingSchema(
|
EventMappingSchema(
|
||||||
event_type=m.event_type,
|
event_type=m.event_type,
|
||||||
@@ -234,6 +293,7 @@ async def create_integration(
|
|||||||
data: GameIntegrationCreate,
|
data: GameIntegrationCreate,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||||
|
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
|
||||||
):
|
):
|
||||||
"""Create a new game integration config."""
|
"""Create a new game integration config."""
|
||||||
try:
|
try:
|
||||||
@@ -262,6 +322,8 @@ async def create_integration(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fire_entity_event("game_integration", "created", config.id)
|
fire_entity_event("game_integration", "created", config.id)
|
||||||
|
if lol_mgr is not None:
|
||||||
|
lol_mgr.sync(store.get_all_integrations())
|
||||||
return _config_to_response(config)
|
return _config_to_response(config)
|
||||||
|
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
@@ -301,6 +363,7 @@ async def update_integration(
|
|||||||
data: GameIntegrationUpdate,
|
data: GameIntegrationUpdate,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||||
|
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
|
||||||
):
|
):
|
||||||
"""Update a game integration config."""
|
"""Update a game integration config."""
|
||||||
try:
|
try:
|
||||||
@@ -318,12 +381,20 @@ async def update_integration(
|
|||||||
for m in data.event_mappings
|
for m in data.event_mappings
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Preserve a stored secret when the update submits a blank token
|
||||||
|
# (the API masks secrets, so the edit form re-sends a blank value
|
||||||
|
# for an unchanged secret — see _merge_preserved_secrets).
|
||||||
|
adapter_config = data.adapter_config
|
||||||
|
if adapter_config is not None:
|
||||||
|
existing = store.get_integration(integration_id)
|
||||||
|
adapter_config = _merge_preserved_secrets(adapter_config, existing)
|
||||||
|
|
||||||
config = store.update_integration(
|
config = store.update_integration(
|
||||||
integration_id=integration_id,
|
integration_id=integration_id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
adapter_type=data.adapter_type,
|
adapter_type=data.adapter_type,
|
||||||
enabled=data.enabled,
|
enabled=data.enabled,
|
||||||
adapter_config=data.adapter_config,
|
adapter_config=adapter_config,
|
||||||
event_mappings=mappings,
|
event_mappings=mappings,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
@@ -332,6 +403,8 @@ async def update_integration(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fire_entity_event("game_integration", "updated", integration_id)
|
fire_entity_event("game_integration", "updated", integration_id)
|
||||||
|
if lol_mgr is not None:
|
||||||
|
lol_mgr.sync(store.get_all_integrations())
|
||||||
return _config_to_response(config)
|
return _config_to_response(config)
|
||||||
|
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
@@ -352,11 +425,14 @@ async def delete_integration(
|
|||||||
integration_id: str,
|
integration_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||||
|
lol_mgr: LoLPollManager | None = Depends(get_lol_poll_manager),
|
||||||
):
|
):
|
||||||
"""Delete a game integration config."""
|
"""Delete a game integration config."""
|
||||||
try:
|
try:
|
||||||
store.delete_integration(integration_id)
|
store.delete_integration(integration_id)
|
||||||
_cleanup_state(integration_id)
|
_cleanup_state(integration_id)
|
||||||
|
if lol_mgr is not None:
|
||||||
|
lol_mgr.sync(store.get_all_integrations())
|
||||||
fire_entity_event("game_integration", "deleted", integration_id)
|
fire_entity_event("game_integration", "deleted", integration_id)
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
@@ -387,7 +463,16 @@ async def ingest_event(
|
|||||||
called before standard API auth.
|
called before standard API auth.
|
||||||
|
|
||||||
No AuthRequired dependency — adapter-level auth is used instead.
|
No AuthRequired dependency — adapter-level auth is used instead.
|
||||||
|
|
||||||
|
Rate limiting is scoped to FAILED-auth attempts per source IP (see
|
||||||
|
``_check_auth_fail_rate_limit``) so legitimate high-rate ingestion is
|
||||||
|
never throttled, but a brute-forcer is locked out after the threshold.
|
||||||
"""
|
"""
|
||||||
|
client_ip = _rate_limit_key(request)
|
||||||
|
# Block IPs that have already burned through the failed-auth budget,
|
||||||
|
# before doing any work (cheap brute-force lockout).
|
||||||
|
_check_auth_fail_rate_limit(client_ip)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = store.get_integration(integration_id)
|
config = store.get_integration(integration_id)
|
||||||
except EntityNotFoundError:
|
except EntityNotFoundError:
|
||||||
@@ -402,9 +487,18 @@ async def ingest_event(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
# Adapter-level auth check
|
# Adapter-level auth check. Treat ANY exception from validate_auth as an
|
||||||
|
# auth failure (rate-limited + 403), never a 500 — a malformed/attacker-
|
||||||
|
# controlled token must not crash the handler nor bypass the brute-force
|
||||||
|
# lockout counter.
|
||||||
headers = dict(request.headers)
|
headers = dict(request.headers)
|
||||||
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
|
try:
|
||||||
|
authed = adapter_cls.validate_auth(headers, payload.data, config.adapter_config)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("validate_auth raised for %s: %s", integration_id, exc)
|
||||||
|
authed = False
|
||||||
|
if not authed:
|
||||||
|
_record_auth_failure(client_ip)
|
||||||
raise HTTPException(status_code=403, detail="Adapter authentication failed")
|
raise HTTPException(status_code=403, detail="Adapter authentication failed")
|
||||||
|
|
||||||
# Parse payload through adapter
|
# Parse payload through adapter
|
||||||
|
|||||||
@@ -152,13 +152,19 @@ async def delete_gradient(
|
|||||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
):
|
):
|
||||||
"""Delete a gradient (fails if built-in or referenced by sources)."""
|
"""Delete a gradient (fails if built-in or referenced by sources)."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_gradient(gradient_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check references
|
# Check references
|
||||||
for source in css_store.get_all_sources():
|
for source in css_store.get_all_sources():
|
||||||
if getattr(source, "gradient_id", None) == gradient_id:
|
if getattr(source, "gradient_id", None) == gradient_id:
|
||||||
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
|
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
|
||||||
store.delete_gradient(gradient_id)
|
store.delete_gradient(gradient_id)
|
||||||
fire_entity_event("gradient", "deleted", gradient_id)
|
fire_entity_event("gradient", "deleted", gradient_id, entity_name=_entity_name)
|
||||||
except (ValueError, EntityNotFoundError) as e:
|
except (ValueError, EntityNotFoundError) as e:
|
||||||
status = 404 if "not found" in str(e).lower() else 400
|
status = 404 if "not found" in str(e).lower() else 400
|
||||||
raise HTTPException(status_code=status, detail=str(e))
|
raise HTTPException(status_code=status, detail=str(e))
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""Wiring-graph endpoints: schema registry, full topology, and dependents.
|
||||||
|
|
||||||
|
These power the visual graph editor (and any other client) with a single
|
||||||
|
authoritative view of how entities are wired together:
|
||||||
|
|
||||||
|
* ``GET /api/v1/graph/schema`` — the connectable-field registry.
|
||||||
|
* ``GET /api/v1/graph`` — nodes + edges + validation.
|
||||||
|
* ``GET /api/v1/graph/dependents/{kind}/{id}`` — what references an entity.
|
||||||
|
|
||||||
|
All heavy logic lives in :mod:`ledgrab.api.graph_schema` (pure, unit-tested);
|
||||||
|
this layer only gathers serialized entities from the stores and delegates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ledgrab.api import dependencies as deps
|
||||||
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.api.graph_schema import (
|
||||||
|
ENTITY_KINDS,
|
||||||
|
NODE_TYPE_FIELD,
|
||||||
|
build_topology,
|
||||||
|
extract_refs,
|
||||||
|
find_dependents,
|
||||||
|
remap_refs,
|
||||||
|
schema_as_dicts,
|
||||||
|
schema_for_kind,
|
||||||
|
serialize_entity,
|
||||||
|
serialize_entity_for_graph,
|
||||||
|
validate_connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionValidationRequest(BaseModel):
|
||||||
|
"""A proposed wiring edit: set ``target_kind.field`` to ``source_id``."""
|
||||||
|
|
||||||
|
target_kind: str
|
||||||
|
target_id: str
|
||||||
|
field: str
|
||||||
|
source_id: str = Field(default="", description="Empty string detaches the slot.")
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# kind → dependency getter for the store that owns that entity kind.
|
||||||
|
_KIND_STORES: dict[str, Callable[[], Any]] = {
|
||||||
|
"device": deps.get_device_store,
|
||||||
|
"capture_template": deps.get_template_store,
|
||||||
|
"pp_template": deps.get_pp_template_store,
|
||||||
|
"audio_template": deps.get_audio_template_store,
|
||||||
|
"pattern_template": deps.get_pattern_template_store,
|
||||||
|
"picture_source": deps.get_picture_source_store,
|
||||||
|
"audio_source": deps.get_audio_source_store,
|
||||||
|
"value_source": deps.get_value_source_store,
|
||||||
|
"color_strip_source": deps.get_color_strip_store,
|
||||||
|
"sync_clock": deps.get_sync_clock_store,
|
||||||
|
"output_target": deps.get_output_target_store,
|
||||||
|
"scene_preset": deps.get_scene_preset_store,
|
||||||
|
"automation": deps.get_automation_store,
|
||||||
|
"cspt": deps.get_cspt_store,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gather_entities() -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""Serialize every entity, keyed by kind. Missing stores yield ``[]``."""
|
||||||
|
out: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for kind, getter in _KIND_STORES.items():
|
||||||
|
try:
|
||||||
|
store = getter()
|
||||||
|
models = store.get_all()
|
||||||
|
except (
|
||||||
|
Exception
|
||||||
|
) as exc: # noqa: BLE001 — an uninitialized/failing store must not 500 the graph
|
||||||
|
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
|
||||||
|
out[kind] = []
|
||||||
|
continue
|
||||||
|
out[kind] = [serialize_entity_for_graph(kind, m) for m in models]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/graph/schema", tags=["Graph"])
|
||||||
|
async def get_graph_schema(_auth: AuthRequired) -> dict[str, Any]:
|
||||||
|
"""Return the authoritative registry of connectable reference fields."""
|
||||||
|
return {
|
||||||
|
"kinds": list(ENTITY_KINDS),
|
||||||
|
"node_type_field": NODE_TYPE_FIELD,
|
||||||
|
"connections": schema_as_dicts(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/graph", tags=["Graph"])
|
||||||
|
async def get_graph(_auth: AuthRequired) -> dict[str, Any]:
|
||||||
|
"""Return the full wiring topology (nodes + edges) and a validation report."""
|
||||||
|
entities = await run_in_threadpool(_gather_entities)
|
||||||
|
return build_topology(entities)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/graph/dependents/{kind}/{entity_id}", tags=["Graph"])
|
||||||
|
async def get_graph_dependents(kind: str, entity_id: str, _auth: AuthRequired) -> dict[str, Any]:
|
||||||
|
"""Return every entity that references ``(kind, entity_id)``."""
|
||||||
|
if kind not in ENTITY_KINDS:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown entity kind: {kind}")
|
||||||
|
entities = await run_in_threadpool(_gather_entities)
|
||||||
|
return {"dependents": find_dependents(entities, kind, entity_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/graph/validate-connection", tags=["Graph"])
|
||||||
|
async def validate_graph_connection(
|
||||||
|
body: ConnectionValidationRequest, _auth: AuthRequired
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate a proposed wiring edit (existence + source kind + no cycle).
|
||||||
|
|
||||||
|
The graph editor calls this before persisting a drag-connect so it can
|
||||||
|
refuse edits that would dangle a reference or create a dependency loop.
|
||||||
|
"""
|
||||||
|
entities = await run_in_threadpool(_gather_entities)
|
||||||
|
ok, error = validate_connection(
|
||||||
|
entities, body.target_kind, body.target_id, body.field, body.source_id
|
||||||
|
)
|
||||||
|
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)
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ from ledgrab.storage.base_store import EntityNotFoundError
|
|||||||
from ledgrab.storage.home_assistant_source import HomeAssistantSource
|
from ledgrab.storage.home_assistant_source import HomeAssistantSource
|
||||||
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.net_classify import validate_lan_host
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -37,6 +39,23 @@ router = APIRouter()
|
|||||||
_REDACTED_TOKEN = "***"
|
_REDACTED_TOKEN = "***"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ha_host(host: str | None) -> None:
|
||||||
|
"""Reject literal public/link-local/metadata IPs for a HA source host.
|
||||||
|
|
||||||
|
HA sources are LAN-by-design (loopback + private ranges allowed), so we
|
||||||
|
gate the user-supplied ``host`` with the same shared classifier the LED
|
||||||
|
device providers use (``validate_lan_host``). The HA host is stored as
|
||||||
|
``host:port`` (e.g. ``192.168.1.100:8123``), so strip the port first via
|
||||||
|
``urlparse`` — which also handles bracketed IPv6 literals. Hostnames /
|
||||||
|
mDNS labels pass through (classified UNPARSEABLE). Raises ``ValueError``
|
||||||
|
on a literal public IP, which the callers translate to HTTP 400.
|
||||||
|
"""
|
||||||
|
if not host:
|
||||||
|
return
|
||||||
|
bare_host = urlparse(f"//{host.strip()}").hostname or host.strip()
|
||||||
|
validate_lan_host(bare_host)
|
||||||
|
|
||||||
|
|
||||||
def _to_response(
|
def _to_response(
|
||||||
source: HomeAssistantSource,
|
source: HomeAssistantSource,
|
||||||
manager: HomeAssistantManager,
|
manager: HomeAssistantManager,
|
||||||
@@ -99,6 +118,7 @@ async def create_ha_source(
|
|||||||
manager: HomeAssistantManager = Depends(get_ha_manager),
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
_validate_ha_host(data.host)
|
||||||
source = store.create_source(
|
source = store.create_source(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
host=data.host,
|
host=data.host,
|
||||||
@@ -153,6 +173,7 @@ async def update_ha_source(
|
|||||||
manager: HomeAssistantManager = Depends(get_ha_manager),
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
_validate_ha_host(data.host)
|
||||||
source = store.update_source(
|
source = store.update_source(
|
||||||
source_id,
|
source_id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse
|
|||||||
password_set=bool(source.password),
|
password_set=bool(source.password),
|
||||||
client_id=source.client_id,
|
client_id=source.client_id,
|
||||||
base_topic=source.base_topic,
|
base_topic=source.base_topic,
|
||||||
|
publish_ha_discovery=getattr(source, "publish_ha_discovery", False),
|
||||||
|
discovery_prefix=getattr(source, "discovery_prefix", "homeassistant"),
|
||||||
connected=runtime.is_connected if runtime else False,
|
connected=runtime.is_connected if runtime else False,
|
||||||
description=source.description,
|
description=source.description,
|
||||||
tags=source.tags,
|
tags=source.tags,
|
||||||
@@ -90,6 +92,8 @@ async def create_mqtt_source(
|
|||||||
password=data.password,
|
password=data.password,
|
||||||
client_id=data.client_id,
|
client_id=data.client_id,
|
||||||
base_topic=data.base_topic,
|
base_topic=data.base_topic,
|
||||||
|
publish_ha_discovery=data.publish_ha_discovery,
|
||||||
|
discovery_prefix=data.discovery_prefix,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
icon=data.icon,
|
icon=data.icon,
|
||||||
@@ -97,6 +101,8 @@ async def create_mqtt_source(
|
|||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# Publish HA discovery if the new source opted in.
|
||||||
|
await manager.sync_discovery(source.id)
|
||||||
fire_entity_event("mqtt_source", "created", source.id)
|
fire_entity_event("mqtt_source", "created", source.id)
|
||||||
return _to_response(source, manager)
|
return _to_response(source, manager)
|
||||||
|
|
||||||
@@ -141,6 +147,8 @@ async def update_mqtt_source(
|
|||||||
password=data.password,
|
password=data.password,
|
||||||
client_id=data.client_id,
|
client_id=data.client_id,
|
||||||
base_topic=data.base_topic,
|
base_topic=data.base_topic,
|
||||||
|
publish_ha_discovery=data.publish_ha_discovery,
|
||||||
|
discovery_prefix=data.discovery_prefix,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
tags=data.tags,
|
tags=data.tags,
|
||||||
icon=data.icon,
|
icon=data.icon,
|
||||||
@@ -151,6 +159,8 @@ async def update_mqtt_source(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
await manager.update_source(source_id)
|
await manager.update_source(source_id)
|
||||||
|
# Reconcile HA discovery (publish if enabled, clear if turned off).
|
||||||
|
await manager.sync_discovery(source_id)
|
||||||
fire_entity_event("mqtt_source", "updated", source.id)
|
fire_entity_event("mqtt_source", "updated", source.id)
|
||||||
return _to_response(source, manager)
|
return _to_response(source, manager)
|
||||||
|
|
||||||
@@ -162,6 +172,9 @@ async def delete_mqtt_source(
|
|||||||
store: MQTTSourceStore = Depends(get_mqtt_store),
|
store: MQTTSourceStore = Depends(get_mqtt_store),
|
||||||
manager: MQTTManager = Depends(get_mqtt_manager),
|
manager: MQTTManager = Depends(get_mqtt_manager),
|
||||||
):
|
):
|
||||||
|
# Clear any HA discovery configs (needs the source still present to build
|
||||||
|
# the exact retained topics) before deleting the row.
|
||||||
|
await manager.disable_discovery(source_id)
|
||||||
try:
|
try:
|
||||||
store.delete_source(source_id)
|
store.delete_source(source_id)
|
||||||
except EntityNotFoundError:
|
except EntityNotFoundError:
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ from ledgrab.storage.value_source_store import ValueSourceStore
|
|||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
from ledgrab.storage.base_store import EntityNotFoundError
|
from ledgrab.storage.base_store import EntityNotFoundError
|
||||||
|
|
||||||
|
from ._mqtt_validation import validate_mqtt_source_exists
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -68,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 "",
|
||||||
@@ -270,16 +274,6 @@ def _validate_device_exists(device_store: DeviceStore, device_id: str) -> None:
|
|||||||
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
raise HTTPException(status_code=422, detail=f"Device {device_id} not found")
|
||||||
|
|
||||||
|
|
||||||
def _validate_mqtt_source_exists(mqtt_store: MQTTSourceStore, mqtt_source_id: str) -> None:
|
|
||||||
"""Ensure the referenced MQTT source exists. Empty id is allowed (unconfigured)."""
|
|
||||||
if not mqtt_source_id:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
mqtt_store.get(mqtt_source_id)
|
|
||||||
except (ValueError, EntityNotFoundError):
|
|
||||||
raise HTTPException(status_code=422, detail=f"MQTT source {mqtt_source_id} not found")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
|
"/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201
|
||||||
)
|
)
|
||||||
@@ -310,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":
|
||||||
@@ -333,7 +329,7 @@ async def create_target(
|
|||||||
case Z2MLightOutputTargetCreate():
|
case Z2MLightOutputTargetCreate():
|
||||||
if data.source_kind == "color_vs":
|
if data.source_kind == "color_vs":
|
||||||
_validate_color_value_source(value_source_store, data.color_value_source_id)
|
_validate_color_value_source(value_source_store, data.color_value_source_id)
|
||||||
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
||||||
target = target_store.create_z2m_light_target(
|
target = target_store.create_z2m_light_target(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -472,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
|
||||||
@@ -484,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
|
||||||
@@ -540,7 +540,7 @@ async def update_target(
|
|||||||
)
|
)
|
||||||
_validate_color_value_source(value_source_store, effective_id)
|
_validate_color_value_source(value_source_store, effective_id)
|
||||||
if data.mqtt_source_id:
|
if data.mqtt_source_id:
|
||||||
_validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
validate_mqtt_source_exists(mqtt_store, data.mqtt_source_id)
|
||||||
target = target_store.update_z2m_light_target(
|
target = target_store.update_z2m_light_target(
|
||||||
target_id,
|
target_id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
@@ -624,6 +624,13 @@ async def delete_target(
|
|||||||
):
|
):
|
||||||
"""Delete a output target. Stops processing first if active."""
|
"""Delete a output target. Stops processing first if active."""
|
||||||
try:
|
try:
|
||||||
|
# Resolve name before deletion for the audit record.
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = target_store.get_target(target_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Stop processing if running
|
# Stop processing if running
|
||||||
try:
|
try:
|
||||||
await manager.stop_processing(target_id)
|
await manager.stop_processing(target_id)
|
||||||
@@ -641,7 +648,7 @@ async def delete_target(
|
|||||||
# Delete from store
|
# Delete from store
|
||||||
target_store.delete_target(target_id)
|
target_store.delete_target(target_id)
|
||||||
|
|
||||||
fire_entity_event("output_target", "deleted", target_id)
|
fire_entity_event("output_target", "deleted", target_id, entity_name=_entity_name)
|
||||||
logger.info(f"Deleted target {target_id}")
|
logger.info(f"Deleted target {target_id}")
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ledgrab.api.dependencies import (
|
|||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
get_processor_manager,
|
get_processor_manager,
|
||||||
)
|
)
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
from ledgrab.api.schemas.output_targets import (
|
from ledgrab.api.schemas.output_targets import (
|
||||||
BulkTargetRequest,
|
BulkTargetRequest,
|
||||||
BulkTargetResponse,
|
BulkTargetResponse,
|
||||||
@@ -28,6 +29,7 @@ from ledgrab.storage.color_strip_source import (
|
|||||||
from ledgrab.storage.picture_source_store import PictureSourceStore
|
from ledgrab.storage.picture_source_store import PictureSourceStore
|
||||||
from ledgrab.storage.wled_output_target import WledOutputTarget
|
from ledgrab.storage.wled_output_target import WledOutputTarget
|
||||||
from ledgrab.storage.output_target_store import OutputTargetStore
|
from ledgrab.storage.output_target_store import OutputTargetStore
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -35,6 +37,23 @@ logger = get_logger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _record_capture(action: str, target_id: str, target_name: str | None, message: str) -> None:
|
||||||
|
"""Best-effort audit record for a capture start/stop action."""
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.CAPTURE,
|
||||||
|
action=action,
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
entity_type="output_target",
|
||||||
|
entity_id=target_id,
|
||||||
|
entity_name=sanitize_display(target_name) if target_name else None,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
|
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
|
||||||
|
|
||||||
|
|
||||||
@@ -53,10 +72,18 @@ async def bulk_start_processing(
|
|||||||
|
|
||||||
for target_id in body.ids:
|
for target_id in body.ids:
|
||||||
try:
|
try:
|
||||||
target_store.get_target(target_id)
|
_tgt = target_store.get_target(target_id)
|
||||||
await manager.start_processing(target_id)
|
await manager.start_processing(target_id)
|
||||||
started.append(target_id)
|
started.append(target_id)
|
||||||
logger.info(f"Bulk start: started processing for target {target_id}")
|
logger.info(f"Bulk start: started processing for target {target_id}")
|
||||||
|
_tgt_name_raw = getattr(_tgt, "name", None)
|
||||||
|
_tgt_safe = sanitize_display(_tgt_name_raw) if _tgt_name_raw else None
|
||||||
|
_record_capture(
|
||||||
|
"capture.started",
|
||||||
|
target_id,
|
||||||
|
_tgt_safe,
|
||||||
|
f"Capture started for target '{_tgt_safe or target_id}' (bulk)",
|
||||||
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
errors[target_id] = str(e)
|
errors[target_id] = str(e)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
@@ -78,6 +105,7 @@ async def bulk_start_processing(
|
|||||||
async def bulk_stop_processing(
|
async def bulk_stop_processing(
|
||||||
body: BulkTargetRequest,
|
body: BulkTargetRequest,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
|
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
|
||||||
@@ -89,6 +117,18 @@ async def bulk_stop_processing(
|
|||||||
await manager.stop_processing(target_id)
|
await manager.stop_processing(target_id)
|
||||||
stopped.append(target_id)
|
stopped.append(target_id)
|
||||||
logger.info(f"Bulk stop: stopped processing for target {target_id}")
|
logger.info(f"Bulk stop: stopped processing for target {target_id}")
|
||||||
|
_tgt_name: str | None = None
|
||||||
|
try:
|
||||||
|
_tgt_name = target_store.get_target(target_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_tgt_name_safe = sanitize_display(_tgt_name) if _tgt_name else None
|
||||||
|
_record_capture(
|
||||||
|
"capture.stopped",
|
||||||
|
target_id,
|
||||||
|
_tgt_name_safe,
|
||||||
|
f"Capture stopped for target '{_tgt_name_safe or target_id}' (bulk)",
|
||||||
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
errors[target_id] = str(e)
|
errors[target_id] = str(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -112,11 +152,19 @@ async def start_processing(
|
|||||||
logger.info("Start processing requested for target %s", target_id)
|
logger.info("Start processing requested for target %s", target_id)
|
||||||
try:
|
try:
|
||||||
# Verify target exists in store
|
# Verify target exists in store
|
||||||
target_store.get_target(target_id)
|
target = target_store.get_target(target_id)
|
||||||
|
|
||||||
await manager.start_processing(target_id)
|
await manager.start_processing(target_id)
|
||||||
|
|
||||||
logger.info(f"Started processing for target {target_id}")
|
logger.info(f"Started processing for target {target_id}")
|
||||||
|
_tgt_name_raw2 = getattr(target, "name", None)
|
||||||
|
_tgt_safe2 = sanitize_display(_tgt_name_raw2) if _tgt_name_raw2 else None
|
||||||
|
_record_capture(
|
||||||
|
"capture.started",
|
||||||
|
target_id,
|
||||||
|
_tgt_safe2,
|
||||||
|
f"Capture started for target '{_tgt_safe2 or target_id}'",
|
||||||
|
)
|
||||||
return {"status": "started", "target_id": target_id}
|
return {"status": "started", "target_id": target_id}
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -137,6 +185,7 @@ async def start_processing(
|
|||||||
async def stop_processing(
|
async def stop_processing(
|
||||||
target_id: str,
|
target_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||||
manager: ProcessorManager = Depends(get_processor_manager),
|
manager: ProcessorManager = Depends(get_processor_manager),
|
||||||
):
|
):
|
||||||
"""Stop processing for a output target."""
|
"""Stop processing for a output target."""
|
||||||
@@ -144,6 +193,18 @@ async def stop_processing(
|
|||||||
await manager.stop_processing(target_id)
|
await manager.stop_processing(target_id)
|
||||||
|
|
||||||
logger.info(f"Stopped processing for target {target_id}")
|
logger.info(f"Stopped processing for target {target_id}")
|
||||||
|
_target_name: str | None = None
|
||||||
|
try:
|
||||||
|
_target_name = target_store.get_target(target_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_target_name_safe = sanitize_display(_target_name) if _target_name else None
|
||||||
|
_record_capture(
|
||||||
|
"capture.stopped",
|
||||||
|
target_id,
|
||||||
|
_target_name_safe,
|
||||||
|
f"Capture stopped for target '{_target_name_safe or target_id}'",
|
||||||
|
)
|
||||||
return {"status": "stopped", "target_id": target_id}
|
return {"status": "stopped", "target_id": target_id}
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -374,6 +374,12 @@ async def delete_picture_source(
|
|||||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||||
):
|
):
|
||||||
"""Delete a picture source."""
|
"""Delete a picture source."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_stream(stream_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if any target transitively references this stream via a CSS
|
# Check if any target transitively references this stream via a CSS
|
||||||
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
|
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
|
||||||
@@ -395,7 +401,7 @@ async def delete_picture_source(
|
|||||||
f"{css_names}. Please reassign or delete those first.",
|
f"{css_names}. Please reassign or delete those first.",
|
||||||
)
|
)
|
||||||
store.delete_stream(stream_id)
|
store.delete_stream(stream_id)
|
||||||
fire_entity_event("picture_source", "deleted", stream_id)
|
fire_entity_event("picture_source", "deleted", stream_id, entity_name=_entity_name)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
|
|||||||
@@ -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,328 @@
|
|||||||
|
"""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.core.activity_log.sanitize import sanitize_display
|
||||||
|
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)."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_playlist(playlist_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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, entity_name=_entity_name)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 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)
|
||||||
|
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
_pl_name: str | None = None
|
||||||
|
try:
|
||||||
|
_pl_name = store.get_playlist(playlist_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_safe_pl_name = sanitize_display(_pl_name) if _pl_name else None
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.CAPTURE,
|
||||||
|
action="playlist.started",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
entity_type="scene_playlist",
|
||||||
|
entity_id=playlist_id,
|
||||||
|
entity_name=_safe_pl_name,
|
||||||
|
message=f"Playlist '{_safe_pl_name or playlist_id}' started",
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
|
||||||
|
engine: PlaylistEngine = Depends(get_playlist_engine),
|
||||||
|
):
|
||||||
|
"""Stop the active playlist (leaves the last applied scene in place)."""
|
||||||
|
stopped_id = engine.get_running_playlist_id()
|
||||||
|
_stopped_name: str | None = None
|
||||||
|
if stopped_id:
|
||||||
|
try:
|
||||||
|
_stopped_name = store.get_playlist(stopped_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await engine.stop()
|
||||||
|
if stopped_id:
|
||||||
|
fire_entity_event("scene_playlist", "updated", stopped_id)
|
||||||
|
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
_safe_stopped_name = sanitize_display(_stopped_name) if _stopped_name else None
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.CAPTURE,
|
||||||
|
action="playlist.stopped",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
entity_type="scene_playlist",
|
||||||
|
entity_id=stopped_id,
|
||||||
|
entity_name=_safe_stopped_name,
|
||||||
|
message=f"Playlist '{_safe_stopped_name or stopped_id}' stopped",
|
||||||
|
)
|
||||||
|
|
||||||
|
return PlaylistRuntimeStateSchema(**engine.get_state())
|
||||||
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from ledgrab.api.auth import AuthRequired
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
from ledgrab.api.dependencies import (
|
from ledgrab.api.dependencies import (
|
||||||
fire_entity_event,
|
fire_entity_event,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
@@ -208,12 +209,18 @@ async def delete_scene_preset(
|
|||||||
store: ScenePresetStore = Depends(get_scene_preset_store),
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||||
):
|
):
|
||||||
"""Delete a scene preset."""
|
"""Delete a scene preset."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_preset(preset_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
store.delete_preset(preset_id)
|
store.delete_preset(preset_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
fire_entity_event("scene_preset", "deleted", preset_id)
|
fire_entity_event("scene_preset", "deleted", preset_id, entity_name=_entity_name)
|
||||||
|
|
||||||
|
|
||||||
# ===== Recapture =====
|
# ===== Recapture =====
|
||||||
@@ -282,4 +289,21 @@ async def activate_scene_preset(
|
|||||||
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
||||||
|
|
||||||
fire_entity_event("scene_preset", "updated", preset_id)
|
fire_entity_event("scene_preset", "updated", preset_id)
|
||||||
|
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
_safe_preset_name = sanitize_display(preset.name) if preset.name else None
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.CAPTURE,
|
||||||
|
action="scene.activated",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
entity_type="scene_preset",
|
||||||
|
entity_id=preset_id,
|
||||||
|
entity_name=_safe_preset_name,
|
||||||
|
message=f"Scene preset '{_safe_preset_name or preset_id}' activated",
|
||||||
|
)
|
||||||
|
|
||||||
return ActivateResponse(status=status, errors=errors)
|
return ActivateResponse(status=status, errors=errors)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Aggregated snapshot endpoint for low-overhead polling clients.
|
||||||
|
|
||||||
|
Returns, in a single response, everything the Home Assistant integration's
|
||||||
|
coordinator needs per poll: all output targets with processing state + metrics,
|
||||||
|
all devices with brightness, the color-strip / value-source / scene-preset /
|
||||||
|
sync-clock lists, and the system block (performance, health, update).
|
||||||
|
|
||||||
|
This collapses the integration's previous ~2N+M request fan-out (per-target
|
||||||
|
``/state`` + ``/metrics`` and per-device ``/brightness``) into one round trip.
|
||||||
|
|
||||||
|
The handler delegates to the existing list/batch route handlers so the response
|
||||||
|
sub-shapes stay byte-identical to the individual endpoints — no shaping logic is
|
||||||
|
duplicated here.
|
||||||
|
|
||||||
|
Callers that don't need the whole payload can pass ``?include=`` with a
|
||||||
|
comma-separated subset of section names (the response keys). Omitting it returns
|
||||||
|
every section. Gating is per section, so an excluded section also skips its
|
||||||
|
server-side work — dropping ``device_brightness`` avoids cold-cache hardware
|
||||||
|
probes, and dropping ``system`` skips the (blocking) NVML performance query.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from ledgrab.api.auth import AuthRequired
|
||||||
|
from ledgrab.api.dependencies import (
|
||||||
|
get_color_strip_store,
|
||||||
|
get_device_store,
|
||||||
|
get_output_target_store,
|
||||||
|
get_playlist_engine,
|
||||||
|
get_processor_manager,
|
||||||
|
get_scene_playlist_store,
|
||||||
|
get_scene_preset_store,
|
||||||
|
get_sync_clock_manager,
|
||||||
|
get_sync_clock_store,
|
||||||
|
get_update_service,
|
||||||
|
get_value_source_store,
|
||||||
|
)
|
||||||
|
from ledgrab.api.schemas.update import UpdateStatusResponse
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
from .color_strip_sources.crud import list_color_strip_sources
|
||||||
|
from .devices import list_devices, resolve_device_brightness
|
||||||
|
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 .sync_clocks import list_sync_clocks
|
||||||
|
from .system import get_system_performance, health_check
|
||||||
|
from .update import get_update_status
|
||||||
|
from .value_sources import list_value_sources
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# 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 = (
|
||||||
|
"targets",
|
||||||
|
"target_states",
|
||||||
|
"target_metrics",
|
||||||
|
"devices",
|
||||||
|
"device_brightness",
|
||||||
|
"css_sources",
|
||||||
|
"value_sources",
|
||||||
|
"scene_presets",
|
||||||
|
"scene_playlists",
|
||||||
|
"sync_clocks",
|
||||||
|
"system",
|
||||||
|
)
|
||||||
|
_SECTION_SET = frozenset(SNAPSHOT_SECTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_sections(include: str | None) -> frozenset[str]:
|
||||||
|
"""Validate the ``include`` query param into the set of sections to emit.
|
||||||
|
|
||||||
|
``None``/empty → every section. Unknown names are rejected with 422 so a
|
||||||
|
typo fails loudly instead of silently returning a smaller payload.
|
||||||
|
"""
|
||||||
|
if not include:
|
||||||
|
return _SECTION_SET
|
||||||
|
requested = {part.strip() for part in include.split(",") if part.strip()}
|
||||||
|
unknown = requested - _SECTION_SET
|
||||||
|
if unknown:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=(
|
||||||
|
f"Unknown snapshot section(s): {', '.join(sorted(unknown))}. "
|
||||||
|
f"Valid sections: {', '.join(SNAPSHOT_SECTIONS)}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return frozenset(requested)
|
||||||
|
|
||||||
|
|
||||||
|
async def _safe_section(awaitable, label: str):
|
||||||
|
"""Await a section, degrading to ``None`` on failure instead of 500-ing.
|
||||||
|
|
||||||
|
The snapshot is a resilience-oriented poll surface: one failing section
|
||||||
|
(e.g. NVML performance probing) must not fail the whole response. This
|
||||||
|
preserves the per-section fault isolation the HA coordinator relied on
|
||||||
|
before these calls were merged into one request — the coordinator already
|
||||||
|
tolerates a ``None`` section.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await awaitable
|
||||||
|
except Exception:
|
||||||
|
logger.warning("snapshot: section %r failed, returning null", label, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_status_model(_auth, update_service) -> UpdateStatusResponse:
|
||||||
|
"""Fetch update status and coerce it through the response model.
|
||||||
|
|
||||||
|
The standalone ``/system/update/status`` endpoint declares
|
||||||
|
``response_model=UpdateStatusResponse``; coercing here keeps the snapshot's
|
||||||
|
``system.update`` field identical to that endpoint rather than emitting the
|
||||||
|
service's raw dict unfiltered.
|
||||||
|
"""
|
||||||
|
raw = await get_update_status(_auth, update_service)
|
||||||
|
return UpdateStatusResponse.model_validate(raw)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/snapshot", tags=["Snapshot"])
|
||||||
|
async def get_snapshot(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
include: str | None = Query(
|
||||||
|
None,
|
||||||
|
description=(
|
||||||
|
"Comma-separated subset of sections to include. Omit for all. "
|
||||||
|
"Valid: " + ", ".join(SNAPSHOT_SECTIONS)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
manager=Depends(get_processor_manager),
|
||||||
|
target_store=Depends(get_output_target_store),
|
||||||
|
device_store=Depends(get_device_store),
|
||||||
|
css_store=Depends(get_color_strip_store),
|
||||||
|
value_store=Depends(get_value_source_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_manager=Depends(get_sync_clock_manager),
|
||||||
|
update_service=Depends(get_update_service),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return the full poll payload (or a requested subset) in one response.
|
||||||
|
|
||||||
|
Shape (a key is present only when its section is requested)::
|
||||||
|
|
||||||
|
{
|
||||||
|
"targets": [<OutputTargetResponse>, ...],
|
||||||
|
"target_states": {target_id: <state>, ...},
|
||||||
|
"target_metrics": {target_id: <metrics>, ...},
|
||||||
|
"devices": [<DeviceResponse>, ...],
|
||||||
|
"device_brightness": {device_id: int | null, ...},
|
||||||
|
"css_sources": [...],
|
||||||
|
"value_sources": [...],
|
||||||
|
"scene_presets": [...],
|
||||||
|
"scene_playlists": [...],
|
||||||
|
"playlist_state": {...}, # companion to scene_playlists
|
||||||
|
"sync_clocks": [...],
|
||||||
|
"system": {"performance": {...}, "health": {...}, "update": {...}}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
sections = _resolve_sections(include)
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if "targets" in sections:
|
||||||
|
result["targets"] = (await list_targets(_auth, target_store)).targets
|
||||||
|
if "target_states" in sections:
|
||||||
|
result["target_states"] = (await batch_target_states(_auth, manager))["states"]
|
||||||
|
if "target_metrics" in sections:
|
||||||
|
result["target_metrics"] = (await batch_target_metrics(_auth, manager))["metrics"]
|
||||||
|
if "devices" in sections:
|
||||||
|
result["devices"] = (await list_devices(_auth, device_store)).devices
|
||||||
|
if "device_brightness" in sections:
|
||||||
|
device_models = device_store.get_all_devices()
|
||||||
|
brightness_values = await asyncio.gather(
|
||||||
|
*(resolve_device_brightness(d, manager) for d in device_models),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
result["device_brightness"] = {
|
||||||
|
model.id: (None if isinstance(value, BaseException) else value)
|
||||||
|
for model, value in zip(device_models, brightness_values)
|
||||||
|
}
|
||||||
|
if "css_sources" in sections:
|
||||||
|
css = await list_color_strip_sources(_auth, css_store, manager)
|
||||||
|
result["css_sources"] = css.sources
|
||||||
|
if "value_sources" in sections:
|
||||||
|
result["value_sources"] = (await list_value_sources(_auth, None, value_store)).sources
|
||||||
|
if "scene_presets" in sections:
|
||||||
|
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:
|
||||||
|
clocks = await list_sync_clocks(_auth, clock_store, clock_manager)
|
||||||
|
result["sync_clocks"] = clocks.clocks
|
||||||
|
if "system" in sections:
|
||||||
|
result["system"] = {
|
||||||
|
"performance": await _safe_section(
|
||||||
|
run_in_threadpool(get_system_performance, _auth), "system.performance"
|
||||||
|
),
|
||||||
|
"health": await _safe_section(health_check(request), "system.health"),
|
||||||
|
"update": await _safe_section(
|
||||||
|
_update_status_model(_auth, update_service), "system.update"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -149,6 +149,12 @@ async def delete_sync_clock(
|
|||||||
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||||
):
|
):
|
||||||
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
|
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = store.get_clock(clock_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check references
|
# Check references
|
||||||
for source in css_store.get_all_sources():
|
for source in css_store.get_all_sources():
|
||||||
@@ -159,7 +165,7 @@ async def delete_sync_clock(
|
|||||||
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
|
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
|
||||||
manager.release_all_for(clock_id)
|
manager.release_all_for(clock_id)
|
||||||
store.delete_clock(clock_id)
|
store.delete_clock(clock_id)
|
||||||
fire_entity_event("sync_clock", "deleted", clock_id)
|
fire_entity_event("sync_clock", "deleted", clock_id, entity_name=_entity_name)
|
||||||
except EntityNotFoundError as e:
|
except EntityNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -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,100 +17,36 @@ 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.core.activity_log.sanitize import sanitize_display
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
from ledgrab.storage.database import Database
|
from ledgrab.storage.database import Database
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_setting(action: str, key: str, message: str) -> None:
|
||||||
|
"""Best-effort audit record for a high-value settings change."""
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.SYSTEM,
|
||||||
|
action=action,
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
message=message,
|
||||||
|
metadata={"setting_key": key},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -199,6 +135,11 @@ async def update_shutdown_action(
|
|||||||
"""Set what happens to LED targets when the server shuts down."""
|
"""Set what happens to LED targets when the server shuts down."""
|
||||||
db.set_setting("shutdown_action", {"action": body.action})
|
db.set_setting("shutdown_action", {"action": body.action})
|
||||||
logger.info("Shutdown action updated: %s", body.action)
|
logger.info("Shutdown action updated: %s", body.action)
|
||||||
|
_record_setting(
|
||||||
|
"settings.changed",
|
||||||
|
"shutdown_action",
|
||||||
|
f"Shutdown action set to '{body.action}'",
|
||||||
|
)
|
||||||
return ShutdownActionResponse(action=body.action)
|
return ShutdownActionResponse(action=body.action)
|
||||||
|
|
||||||
|
|
||||||
@@ -328,6 +269,17 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
|
|||||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||||
output = (stdout.decode() + stderr.decode()).strip()
|
output = (stdout.decode() + stderr.decode()).strip()
|
||||||
if "connected" in output.lower():
|
if "connected" in output.lower():
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.DEVICE,
|
||||||
|
action="device.adb_connected",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
message=f"ADB device connected: {sanitize_display(address)}",
|
||||||
|
metadata={"address": address},
|
||||||
|
)
|
||||||
return {"status": "connected", "address": address, "message": output}
|
return {"status": "connected", "address": address, "message": output}
|
||||||
raise HTTPException(status_code=400, detail=output or "Connection failed")
|
raise HTTPException(status_code=400, detail=output or "Connection failed")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -358,6 +310,17 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
|||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.DEVICE,
|
||||||
|
action="device.adb_disconnected",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
message=f"ADB device disconnected: {sanitize_display(address)}",
|
||||||
|
metadata={"address": address},
|
||||||
|
)
|
||||||
return {"status": "disconnected", "message": stdout.decode().strip()}
|
return {"status": "disconnected", "message": stdout.decode().strip()}
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ async def delete_template(
|
|||||||
|
|
||||||
Validates that no streams are currently using this template before deletion.
|
Validates that no streams are currently using this template before deletion.
|
||||||
"""
|
"""
|
||||||
|
_entity_name: str | None = None
|
||||||
|
try:
|
||||||
|
_entity_name = template_store.get_template(template_id).name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if any streams are using this template
|
# Check if any streams are using this template
|
||||||
streams_using_template = []
|
streams_using_template = []
|
||||||
@@ -203,7 +209,7 @@ async def delete_template(
|
|||||||
|
|
||||||
# Proceed with deletion
|
# Proceed with deletion
|
||||||
template_store.delete_template(template_id)
|
template_store.delete_template(template_id)
|
||||||
fire_entity_event("capture_template", "deleted", template_id)
|
fire_entity_event("capture_template", "deleted", template_id, entity_name=_entity_name)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise # Re-raise HTTP exceptions as-is
|
raise # Re-raise HTTP exceptions as-is
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ledgrab.api.schemas.update import (
|
|||||||
UpdateStatusResponse,
|
UpdateStatusResponse,
|
||||||
)
|
)
|
||||||
from ledgrab.core.update.update_service import UpdateService
|
from ledgrab.core.update.update_service import UpdateService
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -42,6 +43,17 @@ async def dismiss_update(
|
|||||||
service: UpdateService = Depends(get_update_service),
|
service: UpdateService = Depends(get_update_service),
|
||||||
):
|
):
|
||||||
service.dismiss(body.version)
|
service.dismiss(body.version)
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.SYSTEM,
|
||||||
|
action="update.dismissed",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
message=f"Update dismissed: {body.version}",
|
||||||
|
metadata={"version": body.version},
|
||||||
|
)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +75,18 @@ async def apply_update(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await service.apply_update()
|
await service.apply_update()
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
version = status.get("available_version", "unknown")
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.SYSTEM,
|
||||||
|
action="update.applied",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
message=f"Update applied: {version}",
|
||||||
|
metadata={"version": version},
|
||||||
|
)
|
||||||
return {"ok": True, "message": "Update applied, server shutting down"}
|
return {"ok": True, "message": "Update applied, server shutting down"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to apply update: %s", exc, exc_info=True)
|
logger.error("Failed to apply update: %s", exc, exc_info=True)
|
||||||
@@ -83,8 +107,20 @@ async def update_update_settings(
|
|||||||
body: UpdateSettingsRequest,
|
body: UpdateSettingsRequest,
|
||||||
service: UpdateService = Depends(get_update_service),
|
service: UpdateService = Depends(get_update_service),
|
||||||
):
|
):
|
||||||
return await service.update_settings(
|
result = await service.update_settings(
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
check_interval_hours=body.check_interval_hours,
|
check_interval_hours=body.check_interval_hours,
|
||||||
include_prerelease=body.include_prerelease,
|
include_prerelease=body.include_prerelease,
|
||||||
)
|
)
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.SYSTEM,
|
||||||
|
action="settings.changed",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
message=f"Update settings changed (enabled={body.enabled})",
|
||||||
|
metadata={"setting_key": "update", "enabled": body.enabled},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Pydantic schemas for the activity-log API (Phase 4)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry + page response
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityLogEntryResponse(BaseModel):
|
||||||
|
"""Single audit-log entry.
|
||||||
|
|
||||||
|
Shape matches ``entry_to_dict()`` from
|
||||||
|
``ledgrab.core.activity_log.recorder`` exactly — that function is the
|
||||||
|
single source of truth for serialisation; this schema documents the wire
|
||||||
|
format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = Field(description="Entry id — 'al_<8-hex>'")
|
||||||
|
ts: str = Field(description="ISO-8601 UTC timestamp")
|
||||||
|
category: str = Field(description="Broad bucket (auth, device, entity, capture, system)")
|
||||||
|
action: str = Field(description="Verb-object label, e.g. 'entity.created'")
|
||||||
|
severity: str = Field(description="info | warning | error")
|
||||||
|
actor: str = Field(description="API-key label or 'system' / 'anonymous'")
|
||||||
|
entity_type: str | None = Field(default=None, description="Affected entity type, if applicable")
|
||||||
|
entity_id: str | None = Field(default=None, description="Affected entity id, if applicable")
|
||||||
|
entity_name: str | None = Field(
|
||||||
|
default=None, description="Entity name at time of event, if applicable"
|
||||||
|
)
|
||||||
|
message: str = Field(description="Human-readable description")
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict, description="Extra structured context")
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityLogPageResponse(BaseModel):
|
||||||
|
"""Paginated list of audit-log entries (keyset cursor)."""
|
||||||
|
|
||||||
|
entries: list[ActivityLogEntryResponse] = Field(description="Entries on this page")
|
||||||
|
next_before_seq: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Pass as 'before_seq' in the next request to get the following page. "
|
||||||
|
"None when this is the last page."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
has_more: bool = Field(
|
||||||
|
description="True when there are more entries before the first entry on this page"
|
||||||
|
)
|
||||||
|
total: int = Field(description="Total entries matching the current filters (all pages)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MAX_DAYS_CAP = 3650 # 10 years — sanity upper bound
|
||||||
|
_MAX_ENTRIES_CAP = 10_000_000 # 10 M rows — sanity upper bound
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityLogSettingsResponse(BaseModel):
|
||||||
|
"""Current activity-log retention settings."""
|
||||||
|
|
||||||
|
enabled: bool = Field(description="Whether the activity log is recording")
|
||||||
|
max_days: int = Field(
|
||||||
|
ge=0,
|
||||||
|
le=_MAX_DAYS_CAP,
|
||||||
|
description="Retain entries for at most this many days (0 = no age-based pruning)",
|
||||||
|
)
|
||||||
|
max_entries: int = Field(
|
||||||
|
ge=0,
|
||||||
|
le=_MAX_ENTRIES_CAP,
|
||||||
|
description="Keep at most this many entries (0 = no count-based pruning)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateActivityLogSettingsRequest(BaseModel):
|
||||||
|
"""Request body for PUT /settings."""
|
||||||
|
|
||||||
|
enabled: bool = Field(description="Enable or disable activity-log recording")
|
||||||
|
max_days: int = Field(
|
||||||
|
ge=0,
|
||||||
|
le=_MAX_DAYS_CAP,
|
||||||
|
description="Retain entries for at most this many days (0 = no age-based pruning)",
|
||||||
|
)
|
||||||
|
max_entries: int = Field(
|
||||||
|
ge=0,
|
||||||
|
le=_MAX_ENTRIES_CAP,
|
||||||
|
description="Keep at most this many entries (0 = no count-based pruning)",
|
||||||
|
)
|
||||||
@@ -11,13 +11,55 @@ 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 / solar rules (e.g. 'Europe/Berlin'). "
|
||||||
|
"Empty = server local."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Solar rule fields (days_of_week / timezone above are shared with time_of_day)
|
||||||
|
start_event: str | None = Field(
|
||||||
|
None, description="'sunrise' or 'sunset' — window start anchor (for solar rule)"
|
||||||
|
)
|
||||||
|
start_offset_minutes: int | None = Field(
|
||||||
|
None, description="Minutes added to the start event, ±1439 (for solar rule)"
|
||||||
|
)
|
||||||
|
end_event: str | None = Field(
|
||||||
|
None, description="'sunrise' or 'sunset' — window end anchor (for solar rule)"
|
||||||
|
)
|
||||||
|
end_offset_minutes: int | None = Field(
|
||||||
|
None, description="Minutes added to the end event, ±1439 (for solar rule)"
|
||||||
|
)
|
||||||
|
latitude: float | None = Field(
|
||||||
|
None, description="Latitude for solar timing, -90..90 (for solar rule)"
|
||||||
|
)
|
||||||
|
longitude: float | None = Field(
|
||||||
|
None, description="Longitude for solar timing, -180..180 (for solar rule)"
|
||||||
|
)
|
||||||
# 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)"
|
||||||
@@ -66,6 +108,37 @@ class RuleSchema(BaseModel):
|
|||||||
ConditionSchema = RuleSchema
|
ConditionSchema = RuleSchema
|
||||||
|
|
||||||
|
|
||||||
|
class ActionSchema(BaseModel):
|
||||||
|
"""A single outbound action fired alongside scene activation/deactivation."""
|
||||||
|
|
||||||
|
action_type: str = Field(
|
||||||
|
max_length=32, description="Action type discriminator (e.g. 'webhook')"
|
||||||
|
)
|
||||||
|
# Webhook action fields
|
||||||
|
webhook_url: str | None = Field(
|
||||||
|
None, max_length=2048, description="Target URL for the webhook action"
|
||||||
|
)
|
||||||
|
method: str | None = Field(
|
||||||
|
None, max_length=8, description="'POST', 'PUT', or 'GET' (for webhook action)"
|
||||||
|
)
|
||||||
|
body_template: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=8192,
|
||||||
|
description=(
|
||||||
|
"Request body template (for webhook action). Tokens: {{automation_name}}, "
|
||||||
|
"{{automation_id}}, {{event}}, {{timestamp}}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
content_type: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=128,
|
||||||
|
description="Content-Type header for the webhook body (default application/json)",
|
||||||
|
)
|
||||||
|
fire_on: str | None = Field(
|
||||||
|
None, max_length=16, description="'activate', 'deactivate', or 'both' (for webhook action)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AutomationCreate(BaseModel):
|
class AutomationCreate(BaseModel):
|
||||||
"""Request to create an automation."""
|
"""Request to create an automation."""
|
||||||
|
|
||||||
@@ -81,6 +154,9 @@ class AutomationCreate(BaseModel):
|
|||||||
None, description="Scene preset for fallback deactivation"
|
None, description="Scene preset for fallback deactivation"
|
||||||
)
|
)
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
actions: List[ActionSchema] = Field(
|
||||||
|
default_factory=list, description="Outbound actions (e.g. webhooks)"
|
||||||
|
)
|
||||||
icon: str | None = Field(
|
icon: str | None = Field(
|
||||||
None,
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
@@ -106,6 +182,7 @@ class AutomationUpdate(BaseModel):
|
|||||||
None, description="Scene preset for fallback deactivation"
|
None, description="Scene preset for fallback deactivation"
|
||||||
)
|
)
|
||||||
tags: List[str] | None = None
|
tags: List[str] | None = None
|
||||||
|
actions: List[ActionSchema] | None = Field(None, description="Outbound actions (e.g. webhooks)")
|
||||||
icon: str | None = Field(
|
icon: str | None = Field(
|
||||||
None,
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
@@ -130,6 +207,9 @@ class AutomationResponse(BaseModel):
|
|||||||
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
||||||
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
|
deactivation_scene_preset_id: str | None = Field(None, description="Fallback scene preset")
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
actions: List[ActionSchema] = Field(
|
||||||
|
default_factory=list, description="Outbound actions (e.g. webhooks)"
|
||||||
|
)
|
||||||
webhook_url: str | None = Field(
|
webhook_url: str | None = Field(
|
||||||
None, description="Webhook URL for the first webhook rule (if any)"
|
None, description="Webhook URL for the first webhook rule (if any)"
|
||||||
)
|
)
|
||||||
@@ -159,3 +239,15 @@ class AutomationListResponse(BaseModel):
|
|||||||
|
|
||||||
automations: List[AutomationResponse] = Field(description="List of automations")
|
automations: List[AutomationResponse] = Field(description="List of automations")
|
||||||
count: int = Field(description="Number of automations")
|
count: int = Field(description="Number of automations")
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationTriggerResponse(BaseModel):
|
||||||
|
"""Result of manually triggering an automation."""
|
||||||
|
|
||||||
|
status: str = Field(
|
||||||
|
description="'triggered' (scene applied / nothing to apply), 'partial' "
|
||||||
|
"(applied with errors), 'skipped' (rules not satisfied), or 'error'."
|
||||||
|
)
|
||||||
|
errors: List[str] = Field(
|
||||||
|
default_factory=list, description="Per-target error messages, if any."
|
||||||
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -145,6 +145,10 @@ class EffectCSSResponse(_CSSResponseBase):
|
|||||||
scale: Any = Field(description="Spatial scale")
|
scale: Any = Field(description="Spatial scale")
|
||||||
mirror: bool = Field(description="Mirror/bounce mode")
|
mirror: bool = Field(description="Mirror/bounce mode")
|
||||||
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
|
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
|
||||||
|
audio_reactive: bool = Field(False, description="Modulate output by live audio loudness")
|
||||||
|
reactive_audio_source_id: str = Field("", description="AudioSource id driving reactivity")
|
||||||
|
reactive_mode: str = Field("brightness", description="brightness | saturation | both")
|
||||||
|
reactive_intensity: Any = Field(None, description="Reactive modulation strength (0-1)")
|
||||||
|
|
||||||
|
|
||||||
class CompositeCSSResponse(_CSSResponseBase):
|
class CompositeCSSResponse(_CSSResponseBase):
|
||||||
@@ -332,6 +336,12 @@ class EffectCSSCreate(_CSSCreateBase):
|
|||||||
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
|
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
|
||||||
mirror: bool | None = Field(None, description="Mirror/bounce mode")
|
mirror: bool | None = Field(None, description="Mirror/bounce mode")
|
||||||
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
|
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
|
||||||
|
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
|
||||||
|
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
|
||||||
|
reactive_mode: Literal["brightness", "saturation", "both"] | None = Field(
|
||||||
|
None, description="brightness | saturation | both"
|
||||||
|
)
|
||||||
|
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
|
||||||
|
|
||||||
|
|
||||||
class CompositeCSSCreate(_CSSCreateBase):
|
class CompositeCSSCreate(_CSSCreateBase):
|
||||||
@@ -532,6 +542,12 @@ class EffectCSSUpdate(_CSSUpdateBase):
|
|||||||
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
|
scale: Any = Field(default=None, description="Spatial scale (0.5-5.0)")
|
||||||
mirror: bool | None = Field(None, description="Mirror/bounce mode")
|
mirror: bool | None = Field(None, description="Mirror/bounce mode")
|
||||||
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
|
custom_palette: List[List[float]] | None = Field(None, description="Custom palette stops")
|
||||||
|
audio_reactive: bool | None = Field(None, description="Modulate output by live audio loudness")
|
||||||
|
reactive_audio_source_id: str | None = Field(None, description="AudioSource id for reactivity")
|
||||||
|
reactive_mode: Literal["brightness", "saturation", "both"] | None = Field(
|
||||||
|
None, description="brightness | saturation | both"
|
||||||
|
)
|
||||||
|
reactive_intensity: Any = Field(default=None, description="Reactive modulation strength (0-1)")
|
||||||
|
|
||||||
|
|
||||||
class CompositeCSSUpdate(_CSSUpdateBase):
|
class CompositeCSSUpdate(_CSSUpdateBase):
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class DeviceCreate(BaseModel):
|
|||||||
hue_entertainment_group_id: str | None = Field(
|
hue_entertainment_group_id: str | None = Field(
|
||||||
None, description="Hue entertainment group/zone ID"
|
None, description="Hue entertainment group/zone ID"
|
||||||
)
|
)
|
||||||
|
hue_gradient_mode: bool | None = Field(
|
||||||
|
None,
|
||||||
|
description="Map the strip across gradient-lightstrip channels vs one record per light",
|
||||||
|
)
|
||||||
# Yeelight fields
|
# Yeelight fields
|
||||||
yeelight_min_interval_ms: int | None = Field(
|
yeelight_min_interval_ms: int | None = Field(
|
||||||
None,
|
None,
|
||||||
@@ -80,6 +84,10 @@ class DeviceCreate(BaseModel):
|
|||||||
le=10000,
|
le=10000,
|
||||||
description="LIFX client-side rate limit between commands in ms (default 50)",
|
description="LIFX client-side rate limit between commands in ms (default 50)",
|
||||||
)
|
)
|
||||||
|
lifx_per_zone: bool | None = Field(
|
||||||
|
None,
|
||||||
|
description="Stream individual zones/tiles (multizone Z/Beam, Tile/Canvas) vs single colour",
|
||||||
|
)
|
||||||
# Govee fields
|
# Govee fields
|
||||||
govee_min_interval_ms: int | None = Field(
|
govee_min_interval_ms: int | None = Field(
|
||||||
None,
|
None,
|
||||||
@@ -106,6 +114,9 @@ class DeviceCreate(BaseModel):
|
|||||||
le=10000,
|
le=10000,
|
||||||
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
|
description="Nanoleaf client-side rate limit between commands in ms (default 100)",
|
||||||
)
|
)
|
||||||
|
nanoleaf_per_panel: bool | None = Field(
|
||||||
|
None, description="Stream each panel individually via extControl UDP (vs single colour)"
|
||||||
|
)
|
||||||
# SPI Direct fields
|
# SPI Direct fields
|
||||||
spi_speed_hz: int | None = Field(
|
spi_speed_hz: int | None = Field(
|
||||||
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
|
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
|
||||||
@@ -131,6 +142,11 @@ class DeviceCreate(BaseModel):
|
|||||||
None,
|
None,
|
||||||
description="Govee AES key (hex) — required for encrypted Govee firmware",
|
description="Govee AES key (hex) — required for encrypted Govee firmware",
|
||||||
)
|
)
|
||||||
|
# MQTT (multi-broker) field
|
||||||
|
mqtt_source_id: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="MQTT source (broker) ID for device_type=mqtt. Empty = first available broker.",
|
||||||
|
)
|
||||||
default_css_processing_template_id: str | None = Field(
|
default_css_processing_template_id: str | None = Field(
|
||||||
None, description="Default color strip processing template ID"
|
None, description="Default color strip processing template ID"
|
||||||
)
|
)
|
||||||
@@ -190,6 +206,10 @@ class DeviceUpdate(BaseModel):
|
|||||||
hue_username: str | None = Field(None, description="Hue bridge username")
|
hue_username: str | None = Field(None, description="Hue bridge username")
|
||||||
hue_client_key: str | None = Field(None, description="Hue entertainment client key")
|
hue_client_key: str | None = Field(None, description="Hue entertainment client key")
|
||||||
hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID")
|
hue_entertainment_group_id: str | None = Field(None, description="Hue entertainment group ID")
|
||||||
|
hue_gradient_mode: bool | None = Field(
|
||||||
|
None,
|
||||||
|
description="Map the strip across gradient-lightstrip channels vs one record per light",
|
||||||
|
)
|
||||||
yeelight_min_interval_ms: int | None = Field(
|
yeelight_min_interval_ms: int | None = Field(
|
||||||
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
|
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
|
||||||
)
|
)
|
||||||
@@ -199,6 +219,9 @@ class DeviceUpdate(BaseModel):
|
|||||||
lifx_min_interval_ms: int | None = Field(
|
lifx_min_interval_ms: int | None = Field(
|
||||||
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
|
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
|
||||||
)
|
)
|
||||||
|
lifx_per_zone: bool | None = Field(
|
||||||
|
None, description="Stream individual zones/tiles (multizone/matrix) vs single colour"
|
||||||
|
)
|
||||||
govee_min_interval_ms: int | None = Field(
|
govee_min_interval_ms: int | None = Field(
|
||||||
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
|
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
|
||||||
)
|
)
|
||||||
@@ -207,6 +230,9 @@ class DeviceUpdate(BaseModel):
|
|||||||
nanoleaf_min_interval_ms: int | None = Field(
|
nanoleaf_min_interval_ms: int | None = Field(
|
||||||
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
|
None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms"
|
||||||
)
|
)
|
||||||
|
nanoleaf_per_panel: bool | None = Field(
|
||||||
|
None, description="Stream each panel individually via extControl UDP"
|
||||||
|
)
|
||||||
spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed")
|
spi_speed_hz: int | None = Field(None, ge=100000, le=4000000, description="SPI clock speed")
|
||||||
spi_led_type: str | None = Field(None, description="LED chipset type")
|
spi_led_type: str | None = Field(None, description="LED chipset type")
|
||||||
chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
|
chroma_device_type: str | None = Field(None, description="Chroma peripheral type")
|
||||||
@@ -217,6 +243,9 @@ class DeviceUpdate(BaseModel):
|
|||||||
ble_govee_key: str | None = Field(
|
ble_govee_key: str | None = Field(
|
||||||
None, description="Govee AES key (hex) — required for encrypted Govee firmware"
|
None, description="Govee AES key (hex) — required for encrypted Govee firmware"
|
||||||
)
|
)
|
||||||
|
mqtt_source_id: str | None = Field(
|
||||||
|
None, description="MQTT source (broker) ID for device_type=mqtt"
|
||||||
|
)
|
||||||
default_css_processing_template_id: str | None = Field(
|
default_css_processing_template_id: str | None = Field(
|
||||||
None, description="Default color strip processing template ID"
|
None, description="Default color strip processing template ID"
|
||||||
)
|
)
|
||||||
@@ -336,6 +365,26 @@ 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)"
|
||||||
|
)
|
||||||
|
linear_blend: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Blend border pixels in linear light instead of sRGB (perceptually correct)",
|
||||||
|
)
|
||||||
|
dither: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Spatio-temporally dither the final 8-bit output to reduce gradient banding",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalibrationTestModeRequest(BaseModel):
|
class CalibrationTestModeRequest(BaseModel):
|
||||||
@@ -408,11 +457,19 @@ class DeviceResponse(BaseModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
|
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
|
||||||
|
hue_gradient_mode: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Map the strip across gradient-lightstrip channels vs one record per light",
|
||||||
|
)
|
||||||
yeelight_min_interval_ms: int = Field(
|
yeelight_min_interval_ms: int = Field(
|
||||||
default=500, description="Yeelight client-side rate limit in ms"
|
default=500, description="Yeelight client-side rate limit in ms"
|
||||||
)
|
)
|
||||||
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
|
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
|
||||||
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
|
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
|
||||||
|
lifx_per_zone: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Stream individual zones/tiles (multizone/matrix) vs single colour",
|
||||||
|
)
|
||||||
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
|
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
|
||||||
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
|
opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)")
|
||||||
nanoleaf_paired: bool = Field(
|
nanoleaf_paired: bool = Field(
|
||||||
@@ -426,6 +483,9 @@ class DeviceResponse(BaseModel):
|
|||||||
nanoleaf_min_interval_ms: int = Field(
|
nanoleaf_min_interval_ms: int = Field(
|
||||||
default=100, description="Nanoleaf client-side rate limit in ms"
|
default=100, description="Nanoleaf client-side rate limit in ms"
|
||||||
)
|
)
|
||||||
|
nanoleaf_per_panel: bool = Field(
|
||||||
|
default=False, description="Stream each panel individually via extControl UDP"
|
||||||
|
)
|
||||||
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
|
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
|
||||||
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
||||||
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
||||||
@@ -436,6 +496,9 @@ class DeviceResponse(BaseModel):
|
|||||||
ble_govee_key: str = Field(
|
ble_govee_key: str = Field(
|
||||||
default="", description="Govee AES key (hex) — required for encrypted Govee firmware"
|
default="", description="Govee AES key (hex) — required for encrypted Govee firmware"
|
||||||
)
|
)
|
||||||
|
mqtt_source_id: str = Field(
|
||||||
|
default="", description="MQTT source (broker) ID for device_type=mqtt"
|
||||||
|
)
|
||||||
default_css_processing_template_id: str = Field(
|
default_css_processing_template_id: str = Field(
|
||||||
default="", description="Default color strip processing template ID"
|
default="", description="Default color strip processing template ID"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ class MQTTSourceCreate(BaseModel):
|
|||||||
password: str = Field(default="", description="Broker password (optional)")
|
password: str = Field(default="", description="Broker password (optional)")
|
||||||
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
||||||
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
||||||
|
publish_ha_discovery: bool = Field(
|
||||||
|
default=False, description="Publish Home Assistant MQTT auto-discovery configs"
|
||||||
|
)
|
||||||
|
discovery_prefix: str = Field(
|
||||||
|
default="homeassistant",
|
||||||
|
max_length=64,
|
||||||
|
pattern=r"^[A-Za-z0-9_\-/]+$",
|
||||||
|
description="HA MQTT discovery prefix (default 'homeassistant')",
|
||||||
|
)
|
||||||
description: str | None = Field(None, description="Optional description", max_length=500)
|
description: str | None = Field(None, description="Optional description", max_length=500)
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
icon: str | None = Field(
|
icon: str | None = Field(
|
||||||
@@ -40,6 +49,15 @@ class MQTTSourceUpdate(BaseModel):
|
|||||||
password: str | None = Field(None, description="Broker password")
|
password: str | None = Field(None, description="Broker password")
|
||||||
client_id: str | None = Field(None, description="MQTT client ID")
|
client_id: str | None = Field(None, description="MQTT client ID")
|
||||||
base_topic: str | None = Field(None, description="Base topic prefix")
|
base_topic: str | None = Field(None, description="Base topic prefix")
|
||||||
|
publish_ha_discovery: bool | None = Field(
|
||||||
|
None, description="Publish Home Assistant MQTT auto-discovery configs"
|
||||||
|
)
|
||||||
|
discovery_prefix: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=64,
|
||||||
|
pattern=r"^[A-Za-z0-9_\-/]+$",
|
||||||
|
description="HA MQTT discovery prefix",
|
||||||
|
)
|
||||||
description: str | None = Field(None, description="Optional description", max_length=500)
|
description: str | None = Field(None, description="Optional description", max_length=500)
|
||||||
tags: List[str] | None = None
|
tags: List[str] | None = None
|
||||||
icon: str | None = Field(
|
icon: str | None = Field(
|
||||||
@@ -65,6 +83,8 @@ class MQTTSourceResponse(BaseModel):
|
|||||||
password_set: bool = Field(default=False, description="Whether a password is configured")
|
password_set: bool = Field(default=False, description="Whether a password is configured")
|
||||||
client_id: str = Field(description="MQTT client ID")
|
client_id: str = Field(description="MQTT client ID")
|
||||||
base_topic: str = Field(description="Base topic prefix")
|
base_topic: str = Field(description="Base topic prefix")
|
||||||
|
publish_ha_discovery: bool = Field(default=False, description="HA MQTT discovery enabled")
|
||||||
|
discovery_prefix: str = Field(default="homeassistant", description="HA MQTT discovery prefix")
|
||||||
connected: bool = Field(default=False, description="Whether the broker connection is active")
|
connected: bool = Field(default=False, description="Whether the broker connection is active")
|
||||||
description: str | None = Field(None, description="Description")
|
description: str | None = Field(None, description="Description")
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ class AuthConfig(BaseSettings):
|
|||||||
"""Authentication configuration."""
|
"""Authentication configuration."""
|
||||||
|
|
||||||
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
|
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
|
||||||
|
# When True, the OpenAPI docs routes (/docs, /redoc, /openapi.json) load
|
||||||
|
# WITHOUT a Bearer token from any client (loopback and LAN). This exposes
|
||||||
|
# the API *surface* (route paths + parameter schemas), not data — actually
|
||||||
|
# invoking an endpoint from Swagger still requires the token via its
|
||||||
|
# "Authorize" button. All other endpoints stay protected. Default off.
|
||||||
|
expose_docs: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AssetsConfig(BaseSettings):
|
class AssetsConfig(BaseSettings):
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Activity / audit log core — recorder, retention engine, and actor context.
|
||||||
|
|
||||||
|
Public surface
|
||||||
|
--------------
|
||||||
|
``ActivityRecorder`` — thread-safe facade; persists entries and fires live events.
|
||||||
|
``ActivityLogRetentionEngine`` — background pruning engine (mirrors AutoBackupEngine).
|
||||||
|
``current_actor`` — ``ContextVar[str]`` set by the auth layer per request.
|
||||||
|
|
||||||
|
Quick start
|
||||||
|
-----------
|
||||||
|
Wired in ``main.py`` lifespan; injected via ``api/dependencies.py`` getters.
|
||||||
|
Phase 3 adds the instrumentation call sites.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ledgrab.core.activity_log.context import current_actor
|
||||||
|
from ledgrab.core.activity_log.recorder import ActivityRecorder
|
||||||
|
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ActivityRecorder",
|
||||||
|
"ActivityLogRetentionEngine",
|
||||||
|
"current_actor",
|
||||||
|
"sanitize_display",
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Actor context variable for the activity log.
|
||||||
|
|
||||||
|
``current_actor`` is set by ``api/auth.py:verify_api_key`` so that
|
||||||
|
``ActivityRecorder.record(...)`` can resolve the actor without requiring every
|
||||||
|
call site to pass it explicitly.
|
||||||
|
|
||||||
|
Default value is ``"system"`` — used by background engines and any code path
|
||||||
|
that runs outside a request context (e.g. lifespan startup/shutdown, zeroconf
|
||||||
|
discovery thread).
|
||||||
|
|
||||||
|
Per-request isolation is provided by ASGI/anyio ContextVar copy semantics:
|
||||||
|
Starlette dispatches each request in its own task whose context is a copy of
|
||||||
|
the parent, so a ``current_actor.set(...)`` in one request is never visible to
|
||||||
|
another request, and each request starts from the ``"system"`` default.
|
||||||
|
|
||||||
|
The auth layer only *sets* (never resets) the actor: ``verify_api_key`` calls
|
||||||
|
``current_actor.set(...)`` on the authenticated path and on the loopback-
|
||||||
|
anonymous path. It is an ``async`` dependency on purpose — an async dependency
|
||||||
|
runs in the same task/context as the route handler, so the ``set`` is visible
|
||||||
|
to ``record(...)`` (a sync dependency would set it in a throwaway threadpool
|
||||||
|
context that the handler never sees). Routes without the ``verify_api_key``
|
||||||
|
dependency (e.g. the unauthenticated ``POST /api/v1/webhooks/{token}``) never
|
||||||
|
set it and therefore record as ``"system"``.
|
||||||
|
|
||||||
|
There is intentionally no explicit per-request reset — do not rely on one. If
|
||||||
|
you run a recorder call in a worker thread that inherited a parent request's
|
||||||
|
context, pass an explicit ``actor=`` to ``record(...)`` rather than trusting
|
||||||
|
the ContextVar default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
#: The actor label for the current request — API-key label or ``"system"``.
|
||||||
|
current_actor: ContextVar[str] = ContextVar("current_actor", default="system")
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""Thread-safe ActivityRecorder facade.
|
||||||
|
|
||||||
|
Responsibilities
|
||||||
|
----------------
|
||||||
|
1. Build an ``ActivityLogEntry`` from the caller-supplied fields.
|
||||||
|
2. Resolve the ``actor`` from the ``current_actor`` ContextVar when not given.
|
||||||
|
3. Persist the entry via ``ActivityLogRepository.record()`` on the event-loop
|
||||||
|
thread — inline if already on that thread, via
|
||||||
|
``loop.call_soon_threadsafe`` if called from another thread (e.g. the
|
||||||
|
zeroconf discovery thread that fires ``device_discovered/lost`` events).
|
||||||
|
4. Push a live ``activity_logged`` event via
|
||||||
|
``ProcessorManager.fire_event({"type": "activity_logged", "entry": {...}})``.
|
||||||
|
5. Never raise into the caller — audit recording is best-effort. Failures are
|
||||||
|
logged at ``WARNING`` level so operators can diagnose without breaking the
|
||||||
|
audited action.
|
||||||
|
|
||||||
|
Thread-marshal pattern mirrors ``utils/log_broadcaster.py`` (``ensure_loop`` /
|
||||||
|
``call_soon_threadsafe``).
|
||||||
|
|
||||||
|
Module accessor
|
||||||
|
---------------
|
||||||
|
A module-level singleton ``_recorder`` is populated by
|
||||||
|
``set_module_recorder()`` during ``main.py`` lifespan startup and exposed via
|
||||||
|
``get_module_recorder()``. Background engines and other non-DI sites that need
|
||||||
|
to call ``record()`` without FastAPI DI can use this accessor. Phase 3
|
||||||
|
instrumentation uses it at the ``fire_entity_event`` choke-point.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ledgrab.core.activity_log.context import current_actor
|
||||||
|
from ledgrab.storage.activity_log import ActivityLogEntry, ActivitySeverity
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ledgrab.core.processing.processor_manager import ProcessorManager
|
||||||
|
from ledgrab.storage.activity_log_repository import ActivityLogRepository
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _new_id() -> str:
|
||||||
|
"""Generate an activity-log entry id: ``al_<32-hex-chars>``.
|
||||||
|
|
||||||
|
Uses the full 128-bit uuid4 hex. The ``id`` column is ``UNIQUE`` and a
|
||||||
|
collision is silently dropped (best-effort recorder), so the entropy must
|
||||||
|
be high enough that a collision is astronomically unlikely even against the
|
||||||
|
full retention window (default 20k live rows).
|
||||||
|
"""
|
||||||
|
return "al_" + uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def entry_to_dict(entry: ActivityLogEntry) -> dict:
|
||||||
|
"""Serialise an ``ActivityLogEntry`` to the canonical API/event payload dict.
|
||||||
|
|
||||||
|
Reused by Phase 4 (API response serialisation) and Phase 5 (frontend).
|
||||||
|
The shape is identical to the flat row codec minus the DB-only ``seq``
|
||||||
|
field, but with ``ts`` kept as an ISO-8601 string and ``metadata`` as a
|
||||||
|
real ``dict`` (not a JSON string).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": entry.id,
|
||||||
|
"ts": entry.ts.isoformat(),
|
||||||
|
"category": entry.category,
|
||||||
|
"action": entry.action,
|
||||||
|
"severity": entry.severity,
|
||||||
|
"actor": entry.actor,
|
||||||
|
"entity_type": entry.entity_type,
|
||||||
|
"entity_id": entry.entity_id,
|
||||||
|
"entity_name": entry.entity_name,
|
||||||
|
"message": entry.message,
|
||||||
|
"metadata": entry.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityRecorder:
|
||||||
|
"""Thread-safe facade for persisting audit log entries.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
repo:
|
||||||
|
``ActivityLogRepository`` used to persist entries.
|
||||||
|
processor_manager:
|
||||||
|
``ProcessorManager`` whose ``fire_event`` dispatches the live
|
||||||
|
``activity_logged`` event to WebSocket subscribers.
|
||||||
|
loop:
|
||||||
|
Optional: the running asyncio event loop. If ``None``, it is
|
||||||
|
captured lazily on the first call that originates from an async
|
||||||
|
context (mirroring ``LogBroadcaster.ensure_loop``). Pass it
|
||||||
|
explicitly in tests to avoid depending on a real running loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repo: "ActivityLogRepository",
|
||||||
|
processor_manager: "ProcessorManager",
|
||||||
|
*,
|
||||||
|
loop: asyncio.AbstractEventLoop | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._repo = repo
|
||||||
|
self._pm = processor_manager
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = loop
|
||||||
|
self._enabled: bool = True
|
||||||
|
|
||||||
|
# ── Loop capture (mirrors LogBroadcaster.ensure_loop) ──────────────────
|
||||||
|
|
||||||
|
def ensure_loop(self) -> None:
|
||||||
|
"""Capture the running event loop if not already stored.
|
||||||
|
|
||||||
|
Call from an async context (e.g. lifespan startup) so that
|
||||||
|
``call_soon_threadsafe`` works when ``record()`` is later called from
|
||||||
|
non-async threads.
|
||||||
|
"""
|
||||||
|
if self._loop is None:
|
||||||
|
try:
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Public API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
"""Whether recording is currently active."""
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
@enabled.setter
|
||||||
|
def enabled(self, value: bool) -> None:
|
||||||
|
self._enabled = value
|
||||||
|
|
||||||
|
def record(
|
||||||
|
self,
|
||||||
|
category: str,
|
||||||
|
action: str,
|
||||||
|
*,
|
||||||
|
severity: str = ActivitySeverity.INFO,
|
||||||
|
actor: str | None = None,
|
||||||
|
entity_type: str | None = None,
|
||||||
|
entity_id: str | None = None,
|
||||||
|
entity_name: str | None = None,
|
||||||
|
message: str,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
_bypass_enabled: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Append one audit entry — best-effort, never raises.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
category:
|
||||||
|
Broad bucket — one of :class:`~ledgrab.storage.activity_log.ActivityCategory`.
|
||||||
|
action:
|
||||||
|
Verb-object label, e.g. ``"entity.created"`` or ``"server.shutting_down"``.
|
||||||
|
severity:
|
||||||
|
One of :class:`~ledgrab.storage.activity_log.ActivitySeverity`. Defaults
|
||||||
|
to ``"info"``.
|
||||||
|
actor:
|
||||||
|
Who triggered the action. When ``None`` (the common case), the
|
||||||
|
value is resolved from :data:`~ledgrab.core.activity_log.context.current_actor`
|
||||||
|
with a default of ``"system"``.
|
||||||
|
entity_type / entity_id / entity_name:
|
||||||
|
Optional entity context for entity-domain events.
|
||||||
|
message:
|
||||||
|
Human-readable description suitable for display.
|
||||||
|
metadata:
|
||||||
|
Small JSON-serialisable dict with extra context. Defaults to ``{}``.
|
||||||
|
_bypass_enabled:
|
||||||
|
Internal flag used by the retention engine to record the
|
||||||
|
"audit log disabled" event even when ``enabled`` is ``False``.
|
||||||
|
"""
|
||||||
|
if not self._enabled and not _bypass_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve actor from ContextVar when not explicitly supplied.
|
||||||
|
resolved_actor = actor if actor is not None else current_actor.get()
|
||||||
|
|
||||||
|
entry = ActivityLogEntry(
|
||||||
|
id=_new_id(),
|
||||||
|
ts=datetime.now(timezone.utc),
|
||||||
|
category=category,
|
||||||
|
action=action,
|
||||||
|
severity=severity,
|
||||||
|
actor=resolved_actor,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
entity_name=entity_name,
|
||||||
|
message=message,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine whether we are on the event-loop thread or not.
|
||||||
|
loop = self._loop
|
||||||
|
if loop is None:
|
||||||
|
# Lazy capture — may fail if called before the loop is running.
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._loop = loop
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if loop is not None and loop.is_running():
|
||||||
|
try:
|
||||||
|
current = asyncio.get_event_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
current = None
|
||||||
|
|
||||||
|
# If the current thread IS the event-loop thread, write inline.
|
||||||
|
if current is loop:
|
||||||
|
self._write_and_emit(entry)
|
||||||
|
else:
|
||||||
|
# Called from a non-loop thread (e.g. zeroconf discovery) —
|
||||||
|
# marshal onto the event-loop thread.
|
||||||
|
try:
|
||||||
|
loop.call_soon_threadsafe(self._write_and_emit, entry)
|
||||||
|
except RuntimeError:
|
||||||
|
# Loop has been closed (rare; happens during tests)
|
||||||
|
logger.warning(
|
||||||
|
"ActivityRecorder: event loop closed, dropping entry %s", entry.id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No running loop — fall back to a direct synchronous write.
|
||||||
|
# This path hits in synchronous unit tests that do not start a loop.
|
||||||
|
self._write_and_emit(entry)
|
||||||
|
|
||||||
|
def _write_and_emit(self, entry: ActivityLogEntry) -> None:
|
||||||
|
"""Persist *entry* and fire the live event — called on the loop thread."""
|
||||||
|
try:
|
||||||
|
self._repo.record(entry)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ActivityRecorder: failed to persist entry %s: %s", entry.id, exc)
|
||||||
|
return # don't emit an event for an entry that failed to persist
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._pm.fire_event(
|
||||||
|
{
|
||||||
|
"type": "activity_logged",
|
||||||
|
"entry": entry_to_dict(entry),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ActivityRecorder: failed to fire live event for %s: %s", entry.id, exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module-level singleton accessor ────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Background engines and non-DI call sites (Phase 3's fire_entity_event hook,
|
||||||
|
# device discovery thread) need ``record()`` without going through FastAPI DI.
|
||||||
|
# ``set_module_recorder`` is called from ``main.py`` lifespan immediately after
|
||||||
|
# the recorder is wired into ``init_dependencies``.
|
||||||
|
|
||||||
|
_recorder: ActivityRecorder | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_module_recorder(recorder: ActivityRecorder) -> None:
|
||||||
|
"""Store the application-level recorder in the module singleton.
|
||||||
|
|
||||||
|
Called once from ``main.py`` lifespan startup.
|
||||||
|
"""
|
||||||
|
global _recorder
|
||||||
|
_recorder = recorder
|
||||||
|
|
||||||
|
|
||||||
|
def get_module_recorder() -> ActivityRecorder | None:
|
||||||
|
"""Return the module-level recorder, or ``None`` if not yet initialised.
|
||||||
|
|
||||||
|
Callers must guard against ``None`` — this returns ``None`` during module
|
||||||
|
import and early startup before ``main.py`` lifespan has run.
|
||||||
|
"""
|
||||||
|
return _recorder
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Activity log retention engine.
|
||||||
|
|
||||||
|
Mirrors ``core/backup/auto_backup.py``:
|
||||||
|
- Settings persisted via ``db.get_setting("activity_log")`` /
|
||||||
|
``db.set_setting("activity_log", {...})``.
|
||||||
|
- ``start()`` / ``stop()`` lifecycle following the engine convention used
|
||||||
|
throughout the codebase.
|
||||||
|
- Hourly background loop calling ``repo.prune(before_ts=..., max_entries=...)``.
|
||||||
|
- ``get_settings()`` / ``async update_settings(...)`` for the Settings API
|
||||||
|
(Phase 4).
|
||||||
|
|
||||||
|
Changing ``enabled`` to ``False`` records an ``"audit_log.disabled"`` event via
|
||||||
|
the recorder BEFORE the flag takes effect — so the last action in the log is a
|
||||||
|
record of the intentional disable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ledgrab.core.activity_log.recorder import ActivityRecorder
|
||||||
|
from ledgrab.storage.activity_log_repository import ActivityLogRepository
|
||||||
|
from ledgrab.storage.database import Database
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS: dict = {
|
||||||
|
"enabled": True,
|
||||||
|
"max_days": 90,
|
||||||
|
"max_entries": 20000,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prune loop interval — run roughly once an hour.
|
||||||
|
_PRUNE_INTERVAL_SECS = 3600
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityLogRetentionEngine:
|
||||||
|
"""Background engine that prunes old activity log entries.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
repo:
|
||||||
|
The ``ActivityLogRepository`` used to prune entries.
|
||||||
|
db:
|
||||||
|
The shared ``Database`` singleton for settings persistence.
|
||||||
|
recorder:
|
||||||
|
The ``ActivityRecorder`` used to log the "audit log disabled" event
|
||||||
|
before disabling takes effect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repo: "ActivityLogRepository",
|
||||||
|
db: "Database",
|
||||||
|
recorder: "ActivityRecorder",
|
||||||
|
) -> None:
|
||||||
|
self._repo = repo
|
||||||
|
self._db = db
|
||||||
|
self._recorder = recorder
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._settings = self._load_settings()
|
||||||
|
# Rehydrate the recorder's enabled flag from persisted settings so a
|
||||||
|
# previously-disabled log stays disabled across restarts.
|
||||||
|
self._recorder.enabled = self._settings["enabled"]
|
||||||
|
|
||||||
|
# ── Settings persistence ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_settings(self) -> dict:
|
||||||
|
data = self._db.get_setting("activity_log")
|
||||||
|
if data:
|
||||||
|
return {**DEFAULT_SETTINGS, **data}
|
||||||
|
return dict(DEFAULT_SETTINGS)
|
||||||
|
|
||||||
|
def _save_settings(self) -> None:
|
||||||
|
self._db.set_setting(
|
||||||
|
"activity_log",
|
||||||
|
{
|
||||||
|
"enabled": self._settings["enabled"],
|
||||||
|
"max_days": self._settings["max_days"],
|
||||||
|
"max_entries": self._settings["max_entries"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Lifecycle ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start the retention loop if enabled."""
|
||||||
|
if self._settings["enabled"]:
|
||||||
|
self._start_loop()
|
||||||
|
logger.info(
|
||||||
|
"Activity log retention engine started " "(max_days=%d, max_entries=%d)",
|
||||||
|
self._settings["max_days"],
|
||||||
|
self._settings["max_entries"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Activity log retention engine initialized (disabled)")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Cancel the retention loop."""
|
||||||
|
self._cancel_loop()
|
||||||
|
logger.info("Activity log retention engine stopped")
|
||||||
|
|
||||||
|
def _start_loop(self) -> None:
|
||||||
|
self._cancel_loop()
|
||||||
|
self._task = asyncio.create_task(self._retention_loop())
|
||||||
|
|
||||||
|
def _cancel_loop(self) -> None:
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
# ── Prune loop ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _retention_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(_PRUNE_INTERVAL_SECS)
|
||||||
|
try:
|
||||||
|
self._prune()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Activity log retention prune failed: %s", exc, exc_info=True)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("Activity log retention loop cancelled")
|
||||||
|
|
||||||
|
def _prune(self) -> None:
|
||||||
|
"""Execute one prune pass based on current settings."""
|
||||||
|
settings = self._settings
|
||||||
|
if not settings["enabled"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
max_days: int = settings["max_days"]
|
||||||
|
max_entries: int = settings["max_entries"]
|
||||||
|
|
||||||
|
before_ts: datetime | None = None
|
||||||
|
if max_days and max_days > 0:
|
||||||
|
before_ts = datetime.now(timezone.utc) - timedelta(days=max_days)
|
||||||
|
|
||||||
|
max_entries_val: int | None = max_entries if max_entries and max_entries > 0 else None
|
||||||
|
|
||||||
|
deleted = self._repo.prune(before_ts=before_ts, max_entries=max_entries_val)
|
||||||
|
if deleted:
|
||||||
|
logger.info(
|
||||||
|
"Activity log pruned %d rows (max_days=%d, max_entries=%d)",
|
||||||
|
deleted,
|
||||||
|
max_days,
|
||||||
|
max_entries,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Public API ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_settings(self) -> dict:
|
||||||
|
"""Return the current retention settings dict."""
|
||||||
|
return {
|
||||||
|
"enabled": self._settings["enabled"],
|
||||||
|
"max_days": self._settings["max_days"],
|
||||||
|
"max_entries": self._settings["max_entries"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def update_settings(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
max_days: int,
|
||||||
|
max_entries: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Persist new settings and apply them immediately.
|
||||||
|
|
||||||
|
If ``enabled`` is changing to ``False``, the disable event is recorded
|
||||||
|
BEFORE the flag takes effect so there is a final log entry.
|
||||||
|
|
||||||
|
Returns the new settings dict (same as ``get_settings()``).
|
||||||
|
"""
|
||||||
|
was_enabled = self._settings["enabled"]
|
||||||
|
|
||||||
|
# Record the disable event before the recorder stops accepting entries.
|
||||||
|
if was_enabled and not enabled:
|
||||||
|
self._recorder.record(
|
||||||
|
category=ActivityCategory.SYSTEM,
|
||||||
|
action="audit_log.disabled",
|
||||||
|
severity=ActivitySeverity.WARNING,
|
||||||
|
actor="system",
|
||||||
|
message="Activity log recording disabled via settings",
|
||||||
|
_bypass_enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._settings["enabled"] = enabled
|
||||||
|
self._settings["max_days"] = max_days
|
||||||
|
self._settings["max_entries"] = max_entries
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
# Propagate enabled flag to the recorder.
|
||||||
|
self._recorder.enabled = enabled
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
self._start_loop()
|
||||||
|
logger.info(
|
||||||
|
"Activity log retention enabled (max_days=%d, max_entries=%d)",
|
||||||
|
max_days,
|
||||||
|
max_entries,
|
||||||
|
)
|
||||||
|
# Run an immediate prune pass when re-enabling.
|
||||||
|
try:
|
||||||
|
self._prune()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Activity log immediate prune failed: %s", exc)
|
||||||
|
else:
|
||||||
|
self._cancel_loop()
|
||||||
|
logger.info("Activity log retention disabled")
|
||||||
|
|
||||||
|
return self.get_settings()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Log-injection sanitizer for audit-log message and display strings.
|
||||||
|
|
||||||
|
Provides :func:`sanitize_display` — a dependency-free helper that strips
|
||||||
|
characters that should not appear in a recorded ``message`` or display
|
||||||
|
string before it is persisted to SQLite, broadcast over WebSocket, or
|
||||||
|
exported to CSV.
|
||||||
|
|
||||||
|
Design constraints
|
||||||
|
------------------
|
||||||
|
- **Dependency-free**: uses only the Python standard library so it can be
|
||||||
|
imported from any module without adding transitive weight.
|
||||||
|
- **Conservative**: keeps printable ASCII/Unicode and normal spaces; drops
|
||||||
|
everything else including control chars (NUL, BEL, BS, VT, FF, ESC,
|
||||||
|
DEL), ANSI/CSI escape sequences (``\\x1b[...``), and carriage returns /
|
||||||
|
newlines / tabs which are the classic log-injection primitives.
|
||||||
|
- **Length-capped**: truncates to *maxlen* characters and appends ``"…"``
|
||||||
|
so callers can rely on a bounded string without adding their own guards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Matches ANSI/VT100 escape sequences: ESC [ ... m (CSI) and shorter forms.
|
||||||
|
# We strip these before the printable-char filter so the bracket/letters that
|
||||||
|
# follow the ESC don't survive stripping the ESC alone.
|
||||||
|
_ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||||
|
|
||||||
|
# Characters we explicitly want to remove even if str.isprintable() would
|
||||||
|
# let them through in some edge-case: NUL is the canonical SQL/log null-byte
|
||||||
|
# injection; the others are kept out by the printable check but listed here
|
||||||
|
# for documentation clarity.
|
||||||
|
_EXPLICIT_DROP = frozenset("\x00\r\n\t")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_display(value: str | None, *, maxlen: int = 120) -> str:
|
||||||
|
"""Return a sanitized, length-capped version of *value* safe for log messages.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
value:
|
||||||
|
The raw, potentially attacker-controlled string. ``None`` or empty
|
||||||
|
returns ``""``.
|
||||||
|
maxlen:
|
||||||
|
Maximum length of the returned string (default: 120). If the input
|
||||||
|
exceeds this length after sanitization, the string is truncated and
|
||||||
|
``"…"`` is appended (the ellipsis counts toward *maxlen*).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str
|
||||||
|
A string that:
|
||||||
|
- contains no NUL bytes (``\\x00``),
|
||||||
|
- contains no ANSI/CSI escape sequences,
|
||||||
|
- contains no carriage returns, newlines, or tab characters,
|
||||||
|
- contains only characters for which ``str.isprintable()`` is ``True``
|
||||||
|
plus the regular ASCII space (``\\x20``),
|
||||||
|
- is at most *maxlen* characters long.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 1. Strip ANSI escape sequences first so their bracket/letter tails don't
|
||||||
|
# survive as stray printable characters.
|
||||||
|
cleaned = _ANSI_RE.sub("", value)
|
||||||
|
|
||||||
|
# 2. Drop each character that is neither printable nor a plain space.
|
||||||
|
# str.isprintable() returns False for all control chars (including NUL,
|
||||||
|
# BEL, BS, TAB, LF, VT, FF, CR, ESC, DEL) and True for normal letters,
|
||||||
|
# digits, punctuation, and the space character.
|
||||||
|
cleaned = "".join(ch for ch in cleaned if ch.isprintable() or ch == " ")
|
||||||
|
|
||||||
|
# 3. Final belt-and-suspenders pass for the explicit drop set (catches NUL
|
||||||
|
# that may survive if isprintable ever changes in a future Python version).
|
||||||
|
cleaned = "".join(ch for ch in cleaned if ch not in _EXPLICIT_DROP)
|
||||||
|
|
||||||
|
# 4. Cap length. Guard the degenerate maxlen cases: ``cleaned[: maxlen - 1]``
|
||||||
|
# with maxlen <= 0 would slice from the END (keeping all-but-last char or
|
||||||
|
# a negative-index tail), violating the bounded-length contract.
|
||||||
|
if maxlen <= 0:
|
||||||
|
return ""
|
||||||
|
if len(cleaned) > maxlen:
|
||||||
|
if maxlen == 1:
|
||||||
|
# No room for content + ellipsis; emit the ellipsis alone.
|
||||||
|
cleaned = "…"
|
||||||
|
else:
|
||||||
|
# Reserve one character for the ellipsis so total length == maxlen.
|
||||||
|
cleaned = cleaned[: maxlen - 1] + "…"
|
||||||
|
|
||||||
|
return cleaned
|
||||||
@@ -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)
|
||||||
@@ -13,8 +13,10 @@ from ledgrab.storage.automation import (
|
|||||||
DisplayStateRule,
|
DisplayStateRule,
|
||||||
HomeAssistantRule,
|
HomeAssistantRule,
|
||||||
HTTPPollRule,
|
HTTPPollRule,
|
||||||
|
ManualTriggerRule,
|
||||||
MQTTRule,
|
MQTTRule,
|
||||||
Rule,
|
Rule,
|
||||||
|
SolarRule,
|
||||||
StartupRule,
|
StartupRule,
|
||||||
SystemIdleRule,
|
SystemIdleRule,
|
||||||
TimeOfDayRule,
|
TimeOfDayRule,
|
||||||
@@ -23,9 +25,37 @@ from ledgrab.storage.automation import (
|
|||||||
from ledgrab.storage.automation_store import AutomationStore
|
from ledgrab.storage.automation_store import AutomationStore
|
||||||
from ledgrab.storage.scene_preset import ScenePreset
|
from ledgrab.storage.scene_preset import ScenePreset
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.solar import compute_solar_times, utc_offset_hours_for
|
||||||
|
|
||||||
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:
|
||||||
@@ -114,6 +144,11 @@ class AutomationEngine:
|
|||||||
self._last_deactivated: Dict[str, datetime] = {}
|
self._last_deactivated: Dict[str, datetime] = {}
|
||||||
# webhook_token → bool (volatile state set by webhook calls)
|
# webhook_token → bool (volatile state set by webhook calls)
|
||||||
self._webhook_states: Dict[str, bool] = {}
|
self._webhook_states: Dict[str, bool] = {}
|
||||||
|
# True only while a single automation is being manually fired
|
||||||
|
# (fire_manual_trigger). The background tick never sets it, so a
|
||||||
|
# ManualTriggerRule reads False during normal evaluation and a
|
||||||
|
# manual-trigger automation never activates on its own.
|
||||||
|
self._manual_fire_active: bool = False
|
||||||
# HA source IDs currently acquired by the engine
|
# HA source IDs currently acquired by the engine
|
||||||
self._ha_acquired: Set[str] = set()
|
self._ha_acquired: Set[str] = set()
|
||||||
# MQTT source IDs currently acquired by the engine
|
# MQTT source IDs currently acquired by the engine
|
||||||
@@ -342,6 +377,32 @@ class AutomationEngine:
|
|||||||
display_state,
|
display_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _detection_needs(rules) -> tuple[bool, bool, bool, bool, bool]:
|
||||||
|
"""Which platform-detection probes a set of rules requires.
|
||||||
|
|
||||||
|
Returns ``(needs_running, needs_topmost, needs_fullscreen, needs_idle,
|
||||||
|
needs_display_state)``. Shared by the background evaluation tick and the
|
||||||
|
one-shot manual-trigger path so both request the same detection set.
|
||||||
|
"""
|
||||||
|
match_types_used: set = set()
|
||||||
|
needs_idle = False
|
||||||
|
needs_display_state = False
|
||||||
|
for r in rules:
|
||||||
|
if isinstance(r, ApplicationRule):
|
||||||
|
match_types_used.add(r.match_type)
|
||||||
|
elif isinstance(r, SystemIdleRule):
|
||||||
|
needs_idle = True
|
||||||
|
elif isinstance(r, DisplayStateRule):
|
||||||
|
needs_display_state = True
|
||||||
|
return (
|
||||||
|
"running" in match_types_used,
|
||||||
|
bool(match_types_used & {"topmost", "topmost_fullscreen"}),
|
||||||
|
"fullscreen" in match_types_used,
|
||||||
|
needs_idle,
|
||||||
|
needs_display_state,
|
||||||
|
)
|
||||||
|
|
||||||
async def _evaluate_all_locked(self) -> None:
|
async def _evaluate_all_locked(self) -> None:
|
||||||
automations = self._store.get_all_automations()
|
automations = self._store.get_all_automations()
|
||||||
if not automations:
|
if not automations:
|
||||||
@@ -350,23 +411,15 @@ class AutomationEngine:
|
|||||||
await self._deactivate_automation(aid)
|
await self._deactivate_automation(aid)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine which detection methods are actually needed
|
# Determine which detection methods are actually needed (across the
|
||||||
match_types_used: set = set()
|
# rules of every *enabled* automation — disabled ones are skipped below).
|
||||||
needs_idle = False
|
(
|
||||||
needs_display_state = False
|
needs_running,
|
||||||
for a in automations:
|
needs_topmost,
|
||||||
if a.enabled:
|
needs_fullscreen,
|
||||||
for r in a.rules:
|
needs_idle,
|
||||||
if isinstance(r, ApplicationRule):
|
needs_display_state,
|
||||||
match_types_used.add(r.match_type)
|
) = self._detection_needs([r for a in automations if a.enabled for r in a.rules])
|
||||||
elif isinstance(r, SystemIdleRule):
|
|
||||||
needs_idle = True
|
|
||||||
elif isinstance(r, DisplayStateRule):
|
|
||||||
needs_display_state = True
|
|
||||||
|
|
||||||
needs_running = "running" in match_types_used
|
|
||||||
needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
|
|
||||||
needs_fullscreen = "fullscreen" in match_types_used
|
|
||||||
|
|
||||||
# Single executor call for all platform detection
|
# Single executor call for all platform detection
|
||||||
(
|
(
|
||||||
@@ -499,6 +552,9 @@ class AutomationEngine:
|
|||||||
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
|
def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
|
||||||
return self._evaluate_time_of_day(rule)
|
return self._evaluate_time_of_day(rule)
|
||||||
|
|
||||||
|
def _handle_solar(self, rule: SolarRule, ctx: _RuleEvalContext) -> bool:
|
||||||
|
return self._evaluate_solar(rule)
|
||||||
|
|
||||||
def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
|
def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
|
||||||
return self._evaluate_idle(rule, ctx.idle_seconds)
|
return self._evaluate_idle(rule, ctx.idle_seconds)
|
||||||
|
|
||||||
@@ -511,24 +567,76 @@ class AutomationEngine:
|
|||||||
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
|
def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
|
||||||
return self._webhook_states.get(rule.token, False)
|
return self._webhook_states.get(rule.token, False)
|
||||||
|
|
||||||
|
def _handle_manual(self, rule: ManualTriggerRule, ctx: _RuleEvalContext) -> bool:
|
||||||
|
# True only while fire_manual_trigger is evaluating this one automation
|
||||||
|
# under the eval lock; always False during the background tick.
|
||||||
|
return self._manual_fire_active
|
||||||
|
|
||||||
def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool:
|
def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool:
|
||||||
return self._evaluate_home_assistant(rule)
|
return self._evaluate_home_assistant(rule)
|
||||||
|
|
||||||
def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
|
def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
|
||||||
return self._evaluate_http_poll(rule)
|
return self._evaluate_http_poll(rule)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _weekday_window_active(
|
||||||
|
current: int, start: int, end: int, weekday: int, days: list
|
||||||
|
) -> bool:
|
||||||
|
"""Is ``current`` (minutes-of-day) inside the [start, end] window?
|
||||||
|
|
||||||
|
Handles the overnight wrap (start > end): the after-midnight tail
|
||||||
|
belongs to the window's START day, so it's matched against the
|
||||||
|
previous weekday. ``days`` empty = every day of the week.
|
||||||
|
"""
|
||||||
|
if start <= end:
|
||||||
|
if not (start <= current <= end):
|
||||||
|
return False
|
||||||
|
return not days or 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 weekday in days
|
||||||
|
if current <= end: # early-morning portion — yesterday's window
|
||||||
|
return not days or ((weekday - 1) % 7) in days
|
||||||
|
return False
|
||||||
|
|
||||||
@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])
|
||||||
if start <= end:
|
return AutomationEngine._weekday_window_active(
|
||||||
return start <= current <= end
|
current, start, end, now.weekday(), rule.days_of_week
|
||||||
# Overnight range (e.g. 22:00 → 06:00)
|
)
|
||||||
return current >= start or current <= end
|
|
||||||
|
@staticmethod
|
||||||
|
def _evaluate_solar(rule: SolarRule) -> bool:
|
||||||
|
# One ``now`` drives every read: day-of-year, the UTC offset for the
|
||||||
|
# solar math, the current-minute compare, and the weekday.
|
||||||
|
now = _now_in_tz(rule.timezone)
|
||||||
|
day_of_year = now.timetuple().tm_yday
|
||||||
|
utc_offset = utc_offset_hours_for(rule.timezone, now)
|
||||||
|
sunrise_h, sunset_h = compute_solar_times(
|
||||||
|
rule.latitude, rule.longitude, day_of_year, utc_offset
|
||||||
|
)
|
||||||
|
|
||||||
|
def _event_minutes(event: str) -> int:
|
||||||
|
hour = sunset_h if event == "sunset" else sunrise_h
|
||||||
|
return int(round(hour * 60))
|
||||||
|
|
||||||
|
# compute_solar_times clamps sunrise < sunset, so the only way to wrap
|
||||||
|
# past midnight is via the offsets — which ``_weekday_window_active``
|
||||||
|
# handles the same way it does an overnight time-of-day window.
|
||||||
|
start = (_event_minutes(rule.start_event) + rule.start_offset_minutes) % 1440
|
||||||
|
end = (_event_minutes(rule.end_event) + rule.end_offset_minutes) % 1440
|
||||||
|
current = now.hour * 60 + now.minute
|
||||||
|
return AutomationEngine._weekday_window_active(
|
||||||
|
current, start, end, now.weekday(), rule.days_of_week
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
|
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
|
||||||
@@ -638,6 +746,62 @@ class AutomationEngine:
|
|||||||
# Default: "running"
|
# Default: "running"
|
||||||
return any(app in running_procs for app in apps_lower)
|
return any(app in running_procs for app in apps_lower)
|
||||||
|
|
||||||
|
def _audit_activation(self, automation: Automation) -> None:
|
||||||
|
"""Best-effort audit record for any successful automation activation.
|
||||||
|
|
||||||
|
Shared by both the normal scene path and the no-scene branch so an
|
||||||
|
activation is recorded uniformly regardless of whether a scene was
|
||||||
|
applied (mirrors the uniform recording on the deactivation side).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
_safe_name = sanitize_display(automation.name) if automation.name else None
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.CAPTURE,
|
||||||
|
action="automation.activated",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
actor="system",
|
||||||
|
entity_type="automation",
|
||||||
|
entity_id=automation.id,
|
||||||
|
entity_name=_safe_name,
|
||||||
|
message=f"Automation '{_safe_name or automation.id}' activated",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _audit_manual_trigger(self, automation: Automation) -> None:
|
||||||
|
"""Best-effort audit record for a manual trigger.
|
||||||
|
|
||||||
|
Unlike :meth:`_audit_activation` this does NOT force ``actor='system'``
|
||||||
|
— the recorder resolves ``actor`` from the ``current_actor`` ContextVar
|
||||||
|
(set in ``verify_api_key``), so the run is attributed to the user who
|
||||||
|
pressed the button.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
_safe_name = sanitize_display(automation.name) if automation.name else None
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.CAPTURE,
|
||||||
|
action="automation.triggered",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
entity_type="automation",
|
||||||
|
entity_id=automation.id,
|
||||||
|
entity_name=_safe_name,
|
||||||
|
message=f"Automation '{_safe_name or automation.id}' manually triggered",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def _activate_automation(self, automation: Automation) -> None:
|
async def _activate_automation(self, automation: Automation) -> None:
|
||||||
if not automation.scene_preset_id:
|
if not automation.scene_preset_id:
|
||||||
# No scene configured — just mark active (rules matched but nothing to do)
|
# No scene configured — just mark active (rules matched but nothing to do)
|
||||||
@@ -645,6 +809,11 @@ class AutomationEngine:
|
|||||||
self._last_activated[automation.id] = datetime.now(timezone.utc)
|
self._last_activated[automation.id] = datetime.now(timezone.utc)
|
||||||
self._fire_event(automation.id, "activated")
|
self._fire_event(automation.id, "activated")
|
||||||
logger.info(f"Automation '{automation.name}' activated (no scene configured)")
|
logger.info(f"Automation '{automation.name}' activated (no scene configured)")
|
||||||
|
# Record the activation too — a no-scene activation is still a
|
||||||
|
# successful activation and must appear in the audit log.
|
||||||
|
self._audit_activation(automation)
|
||||||
|
await self._fire_actions(automation, "activate")
|
||||||
|
await self._publish_mqtt_state(automation.id, True)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self._scene_preset_store or not self._target_store or not self._device_store:
|
if not self._scene_preset_store or not self._target_store or not self._device_store:
|
||||||
@@ -689,6 +858,141 @@ class AutomationEngine:
|
|||||||
else:
|
else:
|
||||||
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
|
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
|
||||||
|
|
||||||
|
# Audit record — best-effort (shared helper, also used by no-scene path).
|
||||||
|
self._audit_activation(automation)
|
||||||
|
await self._fire_actions(automation, "activate")
|
||||||
|
await self._publish_mqtt_state(automation.id, True)
|
||||||
|
|
||||||
|
async def _fire_actions(self, automation: Automation, event: str) -> None:
|
||||||
|
"""Fire any outbound actions (e.g. webhooks) for this transition.
|
||||||
|
|
||||||
|
Best-effort and never raises into the activation path: a hung or
|
||||||
|
failing endpoint is logged/audited but must not stall the evaluation
|
||||||
|
loop or abort scene activation.
|
||||||
|
"""
|
||||||
|
actions = getattr(automation, "actions", None)
|
||||||
|
if not actions:
|
||||||
|
return
|
||||||
|
from ledgrab.storage.automation import WebhookAction
|
||||||
|
from ledgrab.core.automations.webhook_action import fire_webhook_action, should_fire
|
||||||
|
|
||||||
|
for action in actions:
|
||||||
|
if not isinstance(action, WebhookAction) or not should_fire(action, event):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ok, err = await fire_webhook_action(action, automation, event)
|
||||||
|
except Exception as exc: # noqa: BLE001 — defensive; fire is already best-effort
|
||||||
|
logger.warning(
|
||||||
|
"Action fire raised for '%s': %s", automation.name, type(exc).__name__
|
||||||
|
)
|
||||||
|
ok, err = False, type(exc).__name__
|
||||||
|
self._audit_webhook(automation, event, ok, err)
|
||||||
|
|
||||||
|
def _audit_webhook(self, automation: Automation, event: str, ok: bool, err: str | None) -> None:
|
||||||
|
"""Best-effort audit entry for a webhook fire (success or failure)."""
|
||||||
|
try:
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is None:
|
||||||
|
return
|
||||||
|
safe_name = sanitize_display(automation.name) if automation.name else automation.id
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.CAPTURE,
|
||||||
|
action="automation.webhook_fired",
|
||||||
|
severity=ActivitySeverity.INFO if ok else ActivitySeverity.WARNING,
|
||||||
|
actor="system",
|
||||||
|
entity_type="automation",
|
||||||
|
entity_id=automation.id,
|
||||||
|
entity_name=safe_name,
|
||||||
|
message=(
|
||||||
|
f"Webhook for '{safe_name}' {'fired' if ok else 'failed'} on {event}"
|
||||||
|
+ ("" if ok else f" ({sanitize_display(err) if err else 'error'})")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _apply_manual_scene(self, automation: Automation) -> tuple[str, list[str]]:
|
||||||
|
"""Apply the automation's scene once for a manual trigger.
|
||||||
|
|
||||||
|
Mirrors the scene-application core of :meth:`_activate_automation` but
|
||||||
|
does NOT enter the sticky ``_active_automations`` state or capture a
|
||||||
|
revert snapshot — a manual trigger is a one-shot apply, so the
|
||||||
|
background tick has nothing to reconcile away. Returns
|
||||||
|
``(status, errors)`` where ``status`` is ``"triggered"`` (applied, or no
|
||||||
|
scene configured), ``"partial"`` (applied with errors), or ``"error"``
|
||||||
|
(scene stores unavailable / preset missing).
|
||||||
|
"""
|
||||||
|
if not automation.scene_preset_id:
|
||||||
|
return ("triggered", [])
|
||||||
|
|
||||||
|
if not self._scene_preset_store or not self._target_store or not self._device_store:
|
||||||
|
logger.warning(
|
||||||
|
f"Automation '{automation.name}' triggered but scene stores not available"
|
||||||
|
)
|
||||||
|
return ("error", ["scene stores not available"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found"
|
||||||
|
)
|
||||||
|
return ("error", [f"scene preset {automation.scene_preset_id} not found"])
|
||||||
|
|
||||||
|
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(
|
||||||
|
f"Automation '{automation.name}' manually triggered with errors: {errors}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Automation '{automation.name}' manually triggered (scene '{preset.name}' applied)"
|
||||||
|
)
|
||||||
|
# apply_scene_state returns "activated"/"partial"; surface "triggered"
|
||||||
|
# for the happy path so the API status reads naturally.
|
||||||
|
return ("triggered" if status == "activated" else status, errors)
|
||||||
|
|
||||||
|
async def fire_manual_trigger(self, automation: Automation) -> tuple[str, list[str]]:
|
||||||
|
"""Manually fire an automation: evaluate its rules with the manual
|
||||||
|
trigger satisfied and, if it should activate, apply its scene once.
|
||||||
|
|
||||||
|
"Checks all of the rules": the automation's full rule set is evaluated
|
||||||
|
under its ``rule_logic`` with the ManualTriggerRule treated as True. The
|
||||||
|
``enabled`` flag is intentionally ignored — it gates only the background
|
||||||
|
tick; a manual trigger is an explicit user action. Returns
|
||||||
|
``(status, errors)``: ``"skipped"`` when the rules are not satisfied,
|
||||||
|
otherwise the result of :meth:`_apply_manual_scene`.
|
||||||
|
"""
|
||||||
|
async with self._eval_lock:
|
||||||
|
detection = await asyncio.to_thread(
|
||||||
|
self._detect_all_sync, *self._detection_needs(automation.rules)
|
||||||
|
)
|
||||||
|
# Force the manual term True for this one evaluation, then clear it
|
||||||
|
# before releasing the lock so the background tick never sees it.
|
||||||
|
self._manual_fire_active = True
|
||||||
|
try:
|
||||||
|
should_fire = (not automation.rules) or self._evaluate_rules(automation, *detection)
|
||||||
|
finally:
|
||||||
|
self._manual_fire_active = False
|
||||||
|
|
||||||
|
if not should_fire:
|
||||||
|
logger.info(
|
||||||
|
f"Automation '{automation.name}' manual trigger skipped (rules not satisfied)"
|
||||||
|
)
|
||||||
|
return ("skipped", [])
|
||||||
|
|
||||||
|
status, errors = await self._apply_manual_scene(automation)
|
||||||
|
self._last_activated[automation.id] = datetime.now(timezone.utc)
|
||||||
|
self._fire_event(automation.id, "triggered")
|
||||||
|
self._audit_manual_trigger(automation)
|
||||||
|
return (status, errors)
|
||||||
|
|
||||||
async def _deactivate_automation(self, automation_id: str) -> None:
|
async def _deactivate_automation(self, automation_id: str) -> None:
|
||||||
was_active = self._active_automations.pop(automation_id, False)
|
was_active = self._active_automations.pop(automation_id, False)
|
||||||
if not was_active:
|
if not was_active:
|
||||||
@@ -714,6 +1018,47 @@ class AutomationEngine:
|
|||||||
# Clean up any leftover snapshot
|
# Clean up any leftover snapshot
|
||||||
self._pre_activation_snapshots.pop(automation_id, None)
|
self._pre_activation_snapshots.pop(automation_id, None)
|
||||||
|
|
||||||
|
# Audit record — best-effort.
|
||||||
|
try:
|
||||||
|
from ledgrab.core.activity_log.recorder import get_module_recorder
|
||||||
|
from ledgrab.core.activity_log.sanitize import sanitize_display
|
||||||
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
||||||
|
|
||||||
|
rec = get_module_recorder()
|
||||||
|
if rec is not None:
|
||||||
|
# Reuse the automation already fetched above (no second store
|
||||||
|
# read); degrades to None if it was since-deleted (== None).
|
||||||
|
_auto_name = automation.name if automation else None
|
||||||
|
_safe_deact_name = sanitize_display(_auto_name) if _auto_name else None
|
||||||
|
rec.record(
|
||||||
|
category=ActivityCategory.CAPTURE,
|
||||||
|
action="automation.deactivated",
|
||||||
|
severity=ActivitySeverity.INFO,
|
||||||
|
actor="system",
|
||||||
|
entity_type="automation",
|
||||||
|
entity_id=automation_id,
|
||||||
|
entity_name=_safe_deact_name,
|
||||||
|
message=f"Automation '{_safe_deact_name or automation_id}' deactivated",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fire any outbound deactivate actions (best-effort). Skipped when the
|
||||||
|
# automation was since-deleted (no actions to read).
|
||||||
|
if automation is not None:
|
||||||
|
await self._fire_actions(automation, "deactivate")
|
||||||
|
await self._publish_mqtt_state(automation_id, False)
|
||||||
|
|
||||||
|
async def _publish_mqtt_state(self, automation_id: str, active: bool) -> None:
|
||||||
|
"""Best-effort publish of the automation's active state to HA discovery."""
|
||||||
|
mgr = self._mqtt_manager
|
||||||
|
if mgr is None or not hasattr(mgr, "publish_automation_state_all"):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await mgr.publish_automation_state_all(automation_id, active)
|
||||||
|
except Exception: # noqa: BLE001 — never raise into the engine
|
||||||
|
pass
|
||||||
|
|
||||||
async def _deactivate_revert(self, automation_id: str) -> None:
|
async def _deactivate_revert(self, automation_id: str) -> None:
|
||||||
"""Revert to pre-activation snapshot."""
|
"""Revert to pre-activation snapshot."""
|
||||||
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
||||||
@@ -818,10 +1163,12 @@ AutomationEngine._RULE_HANDLERS = {
|
|||||||
StartupRule: AutomationEngine._handle_startup,
|
StartupRule: AutomationEngine._handle_startup,
|
||||||
ApplicationRule: AutomationEngine._handle_application,
|
ApplicationRule: AutomationEngine._handle_application,
|
||||||
TimeOfDayRule: AutomationEngine._handle_time_of_day,
|
TimeOfDayRule: AutomationEngine._handle_time_of_day,
|
||||||
|
SolarRule: AutomationEngine._handle_solar,
|
||||||
SystemIdleRule: AutomationEngine._handle_system_idle,
|
SystemIdleRule: AutomationEngine._handle_system_idle,
|
||||||
DisplayStateRule: AutomationEngine._handle_display_state,
|
DisplayStateRule: AutomationEngine._handle_display_state,
|
||||||
MQTTRule: AutomationEngine._handle_mqtt,
|
MQTTRule: AutomationEngine._handle_mqtt,
|
||||||
WebhookRule: AutomationEngine._handle_webhook,
|
WebhookRule: AutomationEngine._handle_webhook,
|
||||||
|
ManualTriggerRule: AutomationEngine._handle_manual,
|
||||||
HomeAssistantRule: AutomationEngine._handle_home_assistant,
|
HomeAssistantRule: AutomationEngine._handle_home_assistant,
|
||||||
HTTPPollRule: AutomationEngine._handle_http_poll,
|
HTTPPollRule: AutomationEngine._handle_http_poll,
|
||||||
}
|
}
|
||||||
@@ -839,10 +1186,12 @@ def _assert_rule_handler_coverage() -> None:
|
|||||||
StartupRule,
|
StartupRule,
|
||||||
ApplicationRule,
|
ApplicationRule,
|
||||||
TimeOfDayRule,
|
TimeOfDayRule,
|
||||||
|
SolarRule,
|
||||||
SystemIdleRule,
|
SystemIdleRule,
|
||||||
DisplayStateRule,
|
DisplayStateRule,
|
||||||
MQTTRule,
|
MQTTRule,
|
||||||
WebhookRule,
|
WebhookRule,
|
||||||
|
ManualTriggerRule,
|
||||||
HomeAssistantRule,
|
HomeAssistantRule,
|
||||||
HTTPPollRule,
|
HTTPPollRule,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Outbound webhook action firing for the automation engine.
|
||||||
|
|
||||||
|
When an automation activates or deactivates, any attached
|
||||||
|
:class:`~ledgrab.storage.automation.WebhookAction` performs a best-effort
|
||||||
|
outbound HTTP request (Discord / IFTTT / Zapier / Node-RED / Home Assistant
|
||||||
|
webhooks). Firing is fire-and-forget: a hung or failing endpoint is logged and
|
||||||
|
audited but never raises into the activation path.
|
||||||
|
|
||||||
|
Security: the target URL is SSRF-gated via :func:`validate_polling_url` (LAN
|
||||||
|
allowed so users can hit Node-RED / HA on their own network; loopback,
|
||||||
|
link-local / cloud-metadata, multicast and reserved ranges blocked) at **both**
|
||||||
|
save time (in the route) and fire time (here) — re-validating at fire time
|
||||||
|
closes the DNS-rebinding window. Redirects are not followed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from ledgrab.storage.automation import Automation, WebhookAction
|
||||||
|
from ledgrab.utils import get_logger
|
||||||
|
from ledgrab.utils.safe_source import validate_polling_url
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# A webhook must never stall the ~1 Hz evaluation loop.
|
||||||
|
_WEBHOOK_TIMEOUT_S = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(template: str, automation: Automation, event: str) -> str:
|
||||||
|
"""Substitute the supported ``{{token}}`` placeholders in *template*.
|
||||||
|
|
||||||
|
Tokens: ``{{automation_name}}``, ``{{automation_id}}``, ``{{event}}``
|
||||||
|
(``activate``/``deactivate``), ``{{timestamp}}`` (ISO-8601 UTC). Unknown
|
||||||
|
tokens are left untouched.
|
||||||
|
"""
|
||||||
|
replacements = {
|
||||||
|
"{{automation_name}}": automation.name,
|
||||||
|
"{{automation_id}}": automation.id,
|
||||||
|
"{{event}}": event,
|
||||||
|
"{{timestamp}}": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
out = template
|
||||||
|
for token, value in replacements.items():
|
||||||
|
out = out.replace(token, value)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def should_fire(action: WebhookAction, event: str) -> bool:
|
||||||
|
"""Whether *action* fires for this transition (``activate``/``deactivate``)."""
|
||||||
|
return action.fire_on == event or action.fire_on == "both"
|
||||||
|
|
||||||
|
|
||||||
|
async def fire_webhook_action(
|
||||||
|
action: WebhookAction,
|
||||||
|
automation: Automation,
|
||||||
|
event: str,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Fire a single webhook action. Best-effort: never raises.
|
||||||
|
|
||||||
|
Returns ``(ok, error)`` where ``ok`` is True on a 2xx response and
|
||||||
|
``error`` is a short, secret-free reason on failure.
|
||||||
|
"""
|
||||||
|
url = action.webhook_url.strip()
|
||||||
|
if not url:
|
||||||
|
return False, "no URL configured"
|
||||||
|
|
||||||
|
# Re-validate at fire time (DNS-rebinding window). HTTPException carries a
|
||||||
|
# 4xx detail; surface a short reason rather than raising into the engine.
|
||||||
|
try:
|
||||||
|
validate_polling_url(url)
|
||||||
|
except HTTPException as exc:
|
||||||
|
logger.warning("Webhook for '%s' blocked by SSRF policy: %s", automation.name, exc.detail)
|
||||||
|
return False, "blocked by SSRF policy"
|
||||||
|
|
||||||
|
body = render_template(action.body_template, automation, event)
|
||||||
|
headers = {"Content-Type": action.content_type or "application/json"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_WEBHOOK_TIMEOUT_S, follow_redirects=False) as client:
|
||||||
|
kwargs: dict = {"headers": headers}
|
||||||
|
# Only attach a body for write methods with content to send.
|
||||||
|
if action.method in ("POST", "PUT") and body:
|
||||||
|
kwargs["content"] = body.encode("utf-8")
|
||||||
|
response = await client.request(action.method, url, **kwargs)
|
||||||
|
except Exception as exc: # noqa: BLE001 — never propagate into activation
|
||||||
|
# Never log the rendered body or the exception repr (may carry the URL
|
||||||
|
# with embedded secrets) — the type name is enough to diagnose.
|
||||||
|
logger.warning("Webhook for '%s' failed: %s", automation.name, type(exc).__name__)
|
||||||
|
return False, f"request failed: {type(exc).__name__}"
|
||||||
|
|
||||||
|
ok = 200 <= response.status_code < 300
|
||||||
|
if not ok:
|
||||||
|
logger.warning("Webhook for '%s' returned HTTP %d", automation.name, response.status_code)
|
||||||
|
return False, f"HTTP {response.status_code}"
|
||||||
|
logger.info(
|
||||||
|
"Webhook for '%s' fired on %s (HTTP %d)", automation.name, event, response.status_code
|
||||||
|
)
|
||||||
|
return True, None
|
||||||
@@ -113,6 +113,23 @@ 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
|
||||||
|
# Blend border pixels in linear light (perceptually correct averaging)
|
||||||
|
# instead of gamma-encoded sRGB. Off by default = unchanged behaviour.
|
||||||
|
linear_blend: bool = False
|
||||||
|
# Spatio-temporal dither the final 8-bit quantization to reduce banding.
|
||||||
|
dither: bool = False
|
||||||
|
|
||||||
|
@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."""
|
||||||
@@ -337,6 +354,8 @@ class PixelMapper:
|
|||||||
"""
|
"""
|
||||||
self.calibration = calibration
|
self.calibration = calibration
|
||||||
self.interpolation_mode = interpolation_mode
|
self.interpolation_mode = interpolation_mode
|
||||||
|
# Per-frame counter driving the temporal dither phase.
|
||||||
|
self._dither_frame = 0
|
||||||
|
|
||||||
# Validate calibration
|
# Validate calibration
|
||||||
self.calibration.validate()
|
self.calibration.validate()
|
||||||
@@ -418,7 +437,16 @@ class PixelMapper:
|
|||||||
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
|
Scratch buffers are cached on ``self._edge_cache`` keyed by edge name;
|
||||||
the shared kernel handles all allocations on first use.
|
the shared kernel handles all allocations on first use.
|
||||||
"""
|
"""
|
||||||
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, edge_name)
|
return average_edge_to_leds(
|
||||||
|
edge_pixels,
|
||||||
|
edge_name,
|
||||||
|
led_count,
|
||||||
|
self._edge_cache,
|
||||||
|
edge_name,
|
||||||
|
linear=self.calibration.linear_blend,
|
||||||
|
dither=self.calibration.dither,
|
||||||
|
frame_index=self._dither_frame,
|
||||||
|
)
|
||||||
|
|
||||||
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
|
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
|
||||||
"""Map screen border pixels to LED colors.
|
"""Map screen border pixels to LED colors.
|
||||||
@@ -437,6 +465,7 @@ class PixelMapper:
|
|||||||
"""
|
"""
|
||||||
led_array = self._led_buf
|
led_array = self._led_buf
|
||||||
led_array[:] = 0
|
led_array[:] = 0
|
||||||
|
self._dither_frame += 1
|
||||||
|
|
||||||
# Phase 1+2: Map edges and place at offset-adjusted positions (no np.roll)
|
# Phase 1+2: Map edges and place at offset-adjusted positions (no np.roll)
|
||||||
for i, segment in enumerate(self.calibration.segments):
|
for i, segment in enumerate(self.calibration.segments):
|
||||||
@@ -502,6 +531,7 @@ class AdvancedPixelMapper:
|
|||||||
):
|
):
|
||||||
self.calibration = calibration
|
self.calibration = calibration
|
||||||
self.interpolation_mode = interpolation_mode
|
self.interpolation_mode = interpolation_mode
|
||||||
|
self._dither_frame = 0
|
||||||
calibration.validate()
|
calibration.validate()
|
||||||
|
|
||||||
if interpolation_mode == "average":
|
if interpolation_mode == "average":
|
||||||
@@ -588,7 +618,16 @@ class AdvancedPixelMapper:
|
|||||||
``cache_key`` is an integer (e.g. line index) so multiple per-line
|
``cache_key`` is an integer (e.g. line index) so multiple per-line
|
||||||
edges can share the same ``self._edge_cache`` dict without colliding.
|
edges can share the same ``self._edge_cache`` dict without colliding.
|
||||||
"""
|
"""
|
||||||
return average_edge_to_leds(edge_pixels, edge_name, led_count, self._edge_cache, cache_key)
|
return average_edge_to_leds(
|
||||||
|
edge_pixels,
|
||||||
|
edge_name,
|
||||||
|
led_count,
|
||||||
|
self._edge_cache,
|
||||||
|
cache_key,
|
||||||
|
linear=self.calibration.linear_blend,
|
||||||
|
dither=self.calibration.dither,
|
||||||
|
frame_index=self._dither_frame,
|
||||||
|
)
|
||||||
|
|
||||||
def _map_edge_fallback(
|
def _map_edge_fallback(
|
||||||
self,
|
self,
|
||||||
@@ -610,6 +649,7 @@ class AdvancedPixelMapper:
|
|||||||
"""
|
"""
|
||||||
led_array = self._led_buf
|
led_array = self._led_buf
|
||||||
led_array[:] = 0
|
led_array[:] = 0
|
||||||
|
self._dither_frame += 1
|
||||||
|
|
||||||
for i, line in enumerate(self.calibration.lines):
|
for i, line in enumerate(self.calibration.lines):
|
||||||
frame = frames.get(line.picture_source_id)
|
frame = frames.get(line.picture_source_id)
|
||||||
@@ -656,6 +696,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,
|
||||||
@@ -720,6 +852,30 @@ def create_default_calibration(
|
|||||||
right_count = max(1, right_count)
|
right_count = max(1, right_count)
|
||||||
left_count = max(1, left_count)
|
left_count = max(1, left_count)
|
||||||
|
|
||||||
|
# The max(1, ...) floors above can push the total above led_count for
|
||||||
|
# small counts (e.g. led_count=5 -> top=2,right=1,bottom=2,left=1 = 6).
|
||||||
|
# Trim the largest edge that stays >= 1 until the total matches exactly.
|
||||||
|
edge_order = ["bottom", "top", "right", "left"]
|
||||||
|
counts = {
|
||||||
|
"bottom": bottom_count,
|
||||||
|
"top": top_count,
|
||||||
|
"right": right_count,
|
||||||
|
"left": left_count,
|
||||||
|
}
|
||||||
|
overshoot = sum(counts.values()) - led_count
|
||||||
|
while overshoot > 0:
|
||||||
|
# Pick the largest edge that can still be reduced (stays >= 1).
|
||||||
|
trimmable = [e for e in edge_order if counts[e] > 1]
|
||||||
|
if not trimmable:
|
||||||
|
break
|
||||||
|
target_edge = max(trimmable, key=lambda e: counts[e])
|
||||||
|
counts[target_edge] -= 1
|
||||||
|
overshoot -= 1
|
||||||
|
bottom_count = counts["bottom"]
|
||||||
|
top_count = counts["top"]
|
||||||
|
right_count = counts["right"]
|
||||||
|
left_count = counts["left"]
|
||||||
|
|
||||||
config = CalibrationConfig(
|
config = CalibrationConfig(
|
||||||
layout="clockwise",
|
layout="clockwise",
|
||||||
start_position="bottom_left",
|
start_position="bottom_left",
|
||||||
@@ -774,6 +930,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
|
|||||||
offset=data.get("offset", 0),
|
offset=data.get("offset", 0),
|
||||||
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),
|
||||||
|
linear_blend=bool(data.get("linear_blend", False)),
|
||||||
|
dither=bool(data.get("dither", False)),
|
||||||
)
|
)
|
||||||
config.validate()
|
config.validate()
|
||||||
return config
|
return config
|
||||||
@@ -799,6 +957,12 @@ 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),
|
||||||
|
linear_blend=bool(data.get("linear_blend", False)),
|
||||||
|
dither=bool(data.get("dither", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
config.validate()
|
config.validate()
|
||||||
@@ -843,6 +1007,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
|||||||
result["skip_leds_start"] = config.skip_leds_start
|
result["skip_leds_start"] = config.skip_leds_start
|
||||||
if config.skip_leds_end > 0:
|
if config.skip_leds_end > 0:
|
||||||
result["skip_leds_end"] = config.skip_leds_end
|
result["skip_leds_end"] = config.skip_leds_end
|
||||||
|
if config.linear_blend:
|
||||||
|
result["linear_blend"] = True
|
||||||
|
if config.dither:
|
||||||
|
result["dither"] = True
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Simple mode
|
# Simple mode
|
||||||
@@ -870,4 +1038,14 @@ 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
|
||||||
|
if config.linear_blend:
|
||||||
|
result["linear_blend"] = True
|
||||||
|
if config.dither:
|
||||||
|
result["dither"] = True
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""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.
|
||||||
|
# send_clear_pixels re-raises on a double send failure; a transient
|
||||||
|
# failure here must NOT strand the session with _active=True and no
|
||||||
|
# watchdog — log and continue so the idle-timeout watchdog still gets
|
||||||
|
# armed (mirrors the guarded clear in _teardown_locked).
|
||||||
|
try:
|
||||||
|
await manager.send_clear_pixels(device_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"CalibrationSession.start: failed to clear pixels on %s "
|
||||||
|
"before chase (continuing): %s",
|
||||||
|
device_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -23,6 +23,9 @@ from typing import Any, Callable, Dict, Hashable, Tuple
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from ledgrab.utils.dither import ordered_dither_quantize
|
||||||
|
from ledgrab.utils.linear_light import linear_to_srgb_float, linear_to_srgb_uint8, srgb_to_linear
|
||||||
|
|
||||||
# Cache value layout — kept as a tuple for the small per-frame cost of
|
# Cache value layout — kept as a tuple for the small per-frame cost of
|
||||||
# tuple unpacking vs the readability of a dataclass. The first two entries
|
# tuple unpacking vs the readability of a dataclass. The first two entries
|
||||||
# are the (edge_len, led_count) signature used to detect a re-build.
|
# are the (edge_len, led_count) signature used to detect a re-build.
|
||||||
@@ -75,6 +78,9 @@ def average_edge_to_leds(
|
|||||||
led_count: int,
|
led_count: int,
|
||||||
cache: Dict[Hashable, _CacheEntry],
|
cache: Dict[Hashable, _CacheEntry],
|
||||||
cache_key: Hashable,
|
cache_key: Hashable,
|
||||||
|
linear: bool = False,
|
||||||
|
dither: bool = False,
|
||||||
|
frame_index: int = 0,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""Vectorised average colour per LED segment.
|
"""Vectorised average colour per LED segment.
|
||||||
|
|
||||||
@@ -82,6 +88,14 @@ def average_edge_to_leds(
|
|||||||
over axis=0 (collapsing rows), then segment along the width; for
|
over axis=0 (collapsing rows), then segment along the width; for
|
||||||
left/right edges we average over axis=1 then segment along the height.
|
left/right edges we average over axis=1 then segment along the height.
|
||||||
|
|
||||||
|
When ``linear`` is True the pixels are decoded to linear light before
|
||||||
|
averaging and re-encoded to sRGB at the end — perceptually correct
|
||||||
|
blending at a small extra cost (a LUT decode of the input + an analytic
|
||||||
|
encode of the per-LED result).
|
||||||
|
|
||||||
|
When ``dither`` is True the final 8-bit quantization is spatio-temporally
|
||||||
|
dithered (using ``frame_index``) to suppress gradient banding.
|
||||||
|
|
||||||
Returns a view into the caller-owned cache's ``out_uint8`` buffer —
|
Returns a view into the caller-owned cache's ``out_uint8`` buffer —
|
||||||
do NOT retain the result across calls without copying.
|
do NOT retain the result across calls without copying.
|
||||||
"""
|
"""
|
||||||
@@ -110,8 +124,13 @@ def average_edge_to_leds(
|
|||||||
out_uint8,
|
out_uint8,
|
||||||
) = entry
|
) = entry
|
||||||
|
|
||||||
|
# Decode to linear light first so both the row/column collapse and the
|
||||||
|
# per-segment mean happen in physically-linear space. ``src`` is float32
|
||||||
|
# in [0, 1] (linear) or the raw uint8 sRGB pixels otherwise.
|
||||||
|
src = srgb_to_linear(edge_pixels) if linear else edge_pixels
|
||||||
|
|
||||||
# Mean into pre-allocated buffer (no intermediate float64 array)
|
# Mean into pre-allocated buffer (no intermediate float64 array)
|
||||||
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
np.mean(src, axis=axis, out=edge_1d_buf)
|
||||||
|
|
||||||
# Cumulative sum so each LED segment's sum is two array lookups apart.
|
# Cumulative sum so each LED segment's sum is two array lookups apart.
|
||||||
cumsum_buf[0] = 0
|
cumsum_buf[0] = 0
|
||||||
@@ -122,6 +141,14 @@ def average_edge_to_leds(
|
|||||||
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
|
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
|
||||||
np.subtract(sums_buf, starts_buf, out=sums_buf)
|
np.subtract(sums_buf, starts_buf, out=sums_buf)
|
||||||
np.divide(sums_buf, lengths, out=sums_buf)
|
np.divide(sums_buf, lengths, out=sums_buf)
|
||||||
|
if dither:
|
||||||
|
# sums_buf is linear [0,1] or sRGB [0,255]; quantize with dithering.
|
||||||
|
srgb_f = linear_to_srgb_float(sums_buf) if linear else sums_buf
|
||||||
|
np.copyto(out_uint8, ordered_dither_quantize(srgb_f, frame_index))
|
||||||
|
elif linear:
|
||||||
|
# sums_buf holds linear [0, 1] averages — re-encode to sRGB uint8.
|
||||||
|
np.copyto(out_uint8, linear_to_srgb_uint8(sums_buf))
|
||||||
|
else:
|
||||||
np.clip(sums_buf, 0, 255, out=sums_buf)
|
np.clip(sums_buf, 0, 255, out=sums_buf)
|
||||||
np.copyto(out_uint8, sums_buf, casting="unsafe")
|
np.copyto(out_uint8, sums_buf, casting="unsafe")
|
||||||
return out_uint8
|
return out_uint8
|
||||||
|
|||||||
@@ -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)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user