Compare commits
11 Commits
v0.8.1
...
4b2e8fc5ec
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b2e8fc5ec | |||
| 487259a96d | |||
| fd62db1720 | |||
| 669ae20824 | |||
| 6de61b965e | |||
| 12b40e6071 | |||
| 498854f04d | |||
| 15cfb821d3 | |||
| 2e51f46dfd | |||
| 05cf121666 | |||
| d505388f0e |
@@ -0,0 +1,308 @@
|
||||
# Plan: Android on-device audio capture
|
||||
|
||||
> Status: proposed plan (not yet approved). No code changes. Last updated 2026-06-01.
|
||||
|
||||
## Context
|
||||
|
||||
LedGrab's audio-reactive features (music analyzer, audio value sources, band filters)
|
||||
depend on capturing an audio stream and running it through `AudioAnalyzer`
|
||||
(`server/src/ledgrab/core/audio/analysis.py`). On desktop this is fed by **WASAPI**
|
||||
(Windows) or **Sounddevice/PortAudio** (cross-platform). On the **experimental
|
||||
Android-TV build** neither is available — `sounddevice` has no Chaquopy wheel and PortAudio
|
||||
isn't bundled — so `core/audio/__init__.py` registers only `DemoAudioEngine`, and
|
||||
audio-reactive lighting is effectively dead on Android.
|
||||
|
||||
Android does not need PortAudio: the platform exposes **`AudioPlaybackCapture`** (API 29+),
|
||||
which captures system playback audio and **takes a `MediaProjection` token — the very token
|
||||
the app already obtains for screen capture** (`ScreenCapture(projection, …)`). This plan adds
|
||||
a push-based Android audio engine so the TV box can drive sound-reactive lighting from its own
|
||||
media playback, at parity with how desktop audio feeds the analyzer.
|
||||
|
||||
The design mirrors the working screen-capture bridge
|
||||
(`mediaprojection_engine.py` ↔ `ScreenCapture.kt` ↔ `PythonBridge`) and the existing audio
|
||||
engine abstraction (`AudioCaptureEngine` / `AudioCaptureStreamBase` /
|
||||
`AudioEngineRegistry`). **No new Python dependencies** (`numpy` is already bundled) → no
|
||||
Chaquopy / `build.gradle.kts` `pip {}` changes.
|
||||
|
||||
---
|
||||
|
||||
## Approach
|
||||
|
||||
A new **push-based** audio engine registered in the existing `AudioEngineRegistry`:
|
||||
|
||||
- **Python:** `AndroidAudioEngine` + `AndroidAudioCaptureStream` mirroring `SounddeviceEngine`,
|
||||
but `read_chunk()` pops PCM from a module-level queue that **Kotlin fills** (mirror of
|
||||
`mediaprojection_engine.push_frame`). High `ENGINE_PRIORITY` so
|
||||
`AudioEngineRegistry.get_best_available_engine()` selects it on Android. The existing
|
||||
`ManagedAudioStream` capture loop and `AudioAnalyzer` consume `read_chunk()` unchanged.
|
||||
- **Android:** an `AudioCapture` helper using `AudioRecord` + `AudioPlaybackCaptureConfiguration`
|
||||
(reusing `CaptureService`'s `MediaProjection`), pushing float32 PCM to Python. Mic
|
||||
(`AudioSource.MIC`) fallback. Wired into `CaptureService` next to `ScreenCapture`.
|
||||
|
||||
```
|
||||
[media playback] → AudioRecord (AudioPlaybackCapture, reuses MediaProjection)
|
||||
→ AudioCapture.kt → PythonBridge.pushAudio(pcmFloat32, frames, channels)
|
||||
→ android_audio_engine.push_samples() [module-level queue]
|
||||
→ AndroidAudioCaptureStream.read_chunk() → ManagedAudioStream → AudioAnalyzer [unchanged]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part A — Python (server)
|
||||
|
||||
**New file: `server/src/ledgrab/core/audio/android_audio_engine.py`** — mirror
|
||||
`mediaprojection_engine.py` (queue + configure + push) and `sounddevice_engine.py` (engine/stream shape):
|
||||
|
||||
```python
|
||||
import queue
|
||||
import numpy as np
|
||||
from typing import Any, Dict, List
|
||||
from ledgrab.core.audio.base import AudioCaptureEngine, AudioCaptureStreamBase, AudioDeviceInfo
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_pcm_queue: "queue.Queue[np.ndarray]" = queue.Queue(maxsize=8)
|
||||
_sample_rate = 48000
|
||||
_channels = 2
|
||||
_chunk_size = 1024
|
||||
_active = False
|
||||
|
||||
def configure(sample_rate: int, channels: int, chunk_size: int) -> None:
|
||||
"""Called from Kotlin before audio frames start flowing. Drains stale PCM."""
|
||||
global _sample_rate, _channels, _chunk_size, _active
|
||||
while not _pcm_queue.empty():
|
||||
try: _pcm_queue.get_nowait()
|
||||
except queue.Empty: break
|
||||
_sample_rate, _channels, _chunk_size = sample_rate, channels, chunk_size
|
||||
_active = True
|
||||
|
||||
def push_samples(pcm_float32: bytes) -> None:
|
||||
"""Push one interleaved float32 PCM chunk from Kotlin. Drops oldest if full."""
|
||||
samples = np.frombuffer(pcm_float32, dtype=np.float32)
|
||||
try:
|
||||
_pcm_queue.put_nowait(samples)
|
||||
except queue.Full:
|
||||
try: _pcm_queue.get_nowait()
|
||||
except queue.Empty: pass
|
||||
try: _pcm_queue.put_nowait(samples)
|
||||
except queue.Full: pass
|
||||
|
||||
def shutdown() -> None:
|
||||
global _active
|
||||
_active = False
|
||||
|
||||
|
||||
class AndroidAudioCaptureStream(AudioCaptureStreamBase):
|
||||
@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 not _active:
|
||||
raise RuntimeError("Android audio engine not configured (only valid in-app).")
|
||||
self._initialized = True
|
||||
def cleanup(self) -> None:
|
||||
self._initialized = False
|
||||
def read_chunk(self) -> np.ndarray | None:
|
||||
try:
|
||||
return _pcm_queue.get(timeout=0.1) # 1-D float32 interleaved
|
||||
except queue.Empty:
|
||||
return None
|
||||
|
||||
|
||||
class AndroidAudioEngine(AudioCaptureEngine):
|
||||
ENGINE_TYPE = "android_playback"
|
||||
ENGINE_PRIORITY = 100 # highest on Android (demo is lower)
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
from ledgrab.utils.platform import is_android
|
||||
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, is_loopback, config) -> AndroidAudioCaptureStream:
|
||||
return AndroidAudioCaptureStream(device_index, is_loopback, {**cls.get_default_config(), **config})
|
||||
```
|
||||
|
||||
**Modify `server/src/ledgrab/core/audio/__init__.py`** — register behind a guarded import,
|
||||
matching the existing `_has_wasapi` / `_has_sounddevice` pattern:
|
||||
|
||||
```python
|
||||
try:
|
||||
from ledgrab.core.audio.android_audio_engine import AndroidAudioEngine
|
||||
_has_android_audio = True
|
||||
except ImportError:
|
||||
_has_android_audio = False
|
||||
...
|
||||
if _has_android_audio:
|
||||
AudioEngineRegistry.register(AndroidAudioEngine)
|
||||
```
|
||||
|
||||
**Reused, unchanged:** `AudioEngineRegistry.get_best_available_engine()` (picks by priority),
|
||||
`ManagedAudioStream._capture_loop()` (`audio_capture.py`), `AudioAnalyzer`, the audio value
|
||||
sources, and the device-enumeration endpoints. The Android engine appears as one loopback
|
||||
device named "Android playback (system audio)".
|
||||
|
||||
---
|
||||
|
||||
## Part B — Android (Kotlin + manifest)
|
||||
|
||||
**New file: `android/app/src/main/java/com/ledgrab/android/AudioCapture.kt`**
|
||||
|
||||
Mirrors `ScreenCapture.kt`, taking the same `MediaProjection`:
|
||||
|
||||
```kotlin
|
||||
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,
|
||||
)
|
||||
```
|
||||
|
||||
- `start()` (API 29+, MediaProjection mode):
|
||||
- Build `AudioPlaybackCaptureConfiguration(projection)` adding usages
|
||||
`USAGE_MEDIA`, `USAGE_GAME`, `USAGE_UNKNOWN` (the capturable set).
|
||||
- `AudioRecord.Builder().setAudioPlaybackCaptureConfig(cfg)` with
|
||||
`AudioFormat(ENCODING_PCM_FLOAT, sampleRate, CHANNEL_IN_STEREO)`.
|
||||
- On a dedicated `HandlerThread`, loop `audioRecord.read(floatBuf, …, READ_BLOCKING)` →
|
||||
wrap into a little-endian float32 `ByteArray` (reusable buffer, like `ScreenCapture`'s
|
||||
`frameBuffer`) → `bridge.pushAudio(bytes, framesRead, channels)`.
|
||||
- `stop()`: stop/release `AudioRecord`, quit the thread.
|
||||
- **Mic fallback** (`startMic()`): `AudioSource.MIC` for root mode (no MediaProjection) or
|
||||
API < 29. Used only when playback capture is unavailable.
|
||||
|
||||
**Modify `android/app/src/main/java/com/ledgrab/android/PythonBridge.kt`** — add the audio
|
||||
push path (same shape as `pushFrame`, with a cached PyObject handle):
|
||||
|
||||
```kotlin
|
||||
@Volatile private var androidAudioEngine: PyObject? = null
|
||||
|
||||
fun configureAudio(sampleRate: Int, channels: Int, chunkFrames: Int) {
|
||||
val engine = Python.getInstance().getModule("ledgrab.core.audio.android_audio_engine")
|
||||
engine.callAttr("configure", sampleRate, channels, chunkFrames)
|
||||
androidAudioEngine = engine
|
||||
}
|
||||
fun pushAudio(pcmFloat32: ByteArray, frames: Int, channels: Int) {
|
||||
if (!running) return
|
||||
androidAudioEngine?.let {
|
||||
try { it.callAttr("push_samples", pcmFloat32) }
|
||||
catch (e: Exception) { Log.w(TAG, "pushAudio failed: ${e.message}") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Modify `android/app/src/main/java/com/ledgrab/android/CaptureService.kt`** — in the
|
||||
MediaProjection start path (where `ScreenCapture` is created with the projection), if
|
||||
`RECORD_AUDIO` is granted and API ≥ 29, also `bridge.configureAudio(...)` and start an
|
||||
`AudioCapture(projection, bridge)`. Stop/release it in `onDestroy` alongside `ScreenCapture`.
|
||||
Root path → optional mic fallback (or skip; see Risks).
|
||||
|
||||
**Modify `android/app/src/main/AndroidManifest.xml`:**
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- For mic-mode foreground capture on API 34+ (playback capture is covered by the
|
||||
existing mediaProjection FGS type): -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
```
|
||||
The existing `CaptureService` already declares `foregroundServiceType="mediaProjection|specialUse"`
|
||||
and holds `FOREGROUND_SERVICE_MEDIA_PROJECTION`; add `microphone` to the type only if mic
|
||||
fallback is implemented.
|
||||
|
||||
**Modify `MainActivity.kt`** — request `RECORD_AUDIO` at runtime alongside the existing
|
||||
`ensureNotificationPermission()` (POST_NOTIFICATIONS) flow, before starting capture. Capture
|
||||
proceeds without audio if denied (graceful degradation).
|
||||
|
||||
---
|
||||
|
||||
## Orchestration decision (the main trade-off)
|
||||
|
||||
Desktop starts audio capture **on demand** when an audio-reactive source is acquired
|
||||
(`AudioCaptureManager.acquire`). On Android, PCM only flows if Kotlin has set up `AudioRecord`.
|
||||
|
||||
- **MVP (recommended):** start `AudioCapture` when `CaptureService` starts (if `RECORD_AUDIO`
|
||||
granted + MediaProjection mode + API ≥ 29) and push continuously; the bounded queue drops
|
||||
frames when no audio source consumes them. Simplest; modest extra CPU.
|
||||
- **Future optimization:** on-demand start/stop signaled Python→Kotlin (Chaquopy can call
|
||||
Kotlin, as `BleBridge`/`UsbSerialBridge` show) so `AudioRecord` runs only while an
|
||||
audio-reactive source is active. Defer unless CPU/battery on low-end boxes warrants it.
|
||||
|
||||
---
|
||||
|
||||
## What does NOT change
|
||||
|
||||
- **Frontend / API** — audio engine + device selection, the music analyzer UI, and audio value
|
||||
sources are engine-agnostic; the Android engine shows up via the existing device enumeration.
|
||||
- **`build.gradle.kts` / Chaquopy pip block** — no new Python packages.
|
||||
- **Audio analysis pipeline** — `AudioAnalyzer`, band filters, `ManagedAudioStream` untouched.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
**Create**
|
||||
- `server/src/ledgrab/core/audio/android_audio_engine.py`
|
||||
- `android/app/src/main/java/com/ledgrab/android/AudioCapture.kt`
|
||||
- `server/tests/core/audio/test_android_audio_engine.py`
|
||||
|
||||
**Modify**
|
||||
- `server/src/ledgrab/core/audio/__init__.py` — guarded import + registry registration.
|
||||
- `android/app/src/main/java/com/ledgrab/android/PythonBridge.kt` — `configureAudio` + `pushAudio`.
|
||||
- `android/app/src/main/java/com/ledgrab/android/CaptureService.kt` — start/stop `AudioCapture`.
|
||||
- `android/app/src/main/java/com/ledgrab/android/MainActivity.kt` — request `RECORD_AUDIO`.
|
||||
- `android/app/src/main/AndroidManifest.xml` — `RECORD_AUDIO` (+ mic FGS if mic fallback).
|
||||
|
||||
---
|
||||
|
||||
## Tests (Python — run on desktop CI, no Android device needed)
|
||||
|
||||
New `server/tests/core/audio/test_android_audio_engine.py`:
|
||||
|
||||
- `configure()` then `push_samples()` → `read_chunk()` returns the same float32 samples;
|
||||
queue drops oldest when full (push > maxsize).
|
||||
- `AndroidAudioEngine.is_available()` is `False` until `configure()` and only on Android
|
||||
(monkeypatch `ledgrab.utils.platform.is_android`); `True` after.
|
||||
- `enumerate_devices()` returns exactly one loopback device when active, `[]` otherwise.
|
||||
- Integration: with `is_android()` patched true + `configure()`, `get_best_available_engine()`
|
||||
returns `"android_playback"` (priority beats demo), and a stream created via
|
||||
`AudioEngineRegistry.create_stream("android_playback", 0, True, {})` yields pushed chunks.
|
||||
- Registry isolation: use `AudioEngineRegistry.clear_registry()` / re-register in fixtures so
|
||||
desktop engines aren't disturbed.
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Python:** `py -3.13 -m pytest tests/core/audio/test_android_audio_engine.py --no-cov -q`
|
||||
(from `server/`), then the full suite.
|
||||
2. **Lint:** `ruff check src/ tests/ --fix` (from `server/`).
|
||||
3. **Android build:** `./gradlew :app:assembleDebug` (from `android/`).
|
||||
4. **On device/emulator (manual):** install APK → grant `RECORD_AUDIO` + screen-capture consent
|
||||
→ start capture → play non-DRM media (e.g. a local video / YouTube web) → create an
|
||||
audio-reactive value source bound to a strip → confirm the LEDs react to the audio, and the
|
||||
Android playback device appears in audio device enumeration.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- **DRM opt-out:** Netflix/Disney+/etc. set audio as non-capturable; `AudioPlaybackCapture`
|
||||
yields silence for them. Works for non-DRM media and the device's own audio. Document in UI.
|
||||
- **API 29 minimum** for playback capture (minSdk is 24). API 24–28 and root mode (no
|
||||
MediaProjection) → mic fallback only, or audio unsupported. Gate cleanly + log.
|
||||
- **`RECORD_AUDIO`** is a runtime "dangerous" permission — must be requested; capture must
|
||||
degrade gracefully when denied.
|
||||
- **Format:** request `ENCODING_PCM_FLOAT` so Kotlin pushes float32 matching
|
||||
`read_chunk()`'s contract (1-D interleaved float32, length = frames × channels). If a device
|
||||
rejects float, capture 16-bit PCM and convert (`/32768.0`) before pushing.
|
||||
- **Latency/CPU:** small `chunkFrames` (e.g. 1024 @ 48 kHz ≈ 21 ms) keeps reactivity tight;
|
||||
continuous capture (MVP) adds modest CPU on low-end boxes — see the orchestration trade-off.
|
||||
- **R8/ProGuard:** minify is disabled and the Python module is resolved by string from Kotlin;
|
||||
no new keep-rules needed.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Android (TV) — Missing Functionality Assessment
|
||||
|
||||
> Status: review/feasibility document. No code changes. Last updated 2026-06-01.
|
||||
|
||||
## Context
|
||||
|
||||
LedGrab ships an **experimental on-device Android-TV build**: a Kotlin shell that
|
||||
embeds the Python FastAPI server via **Chaquopy**, with Kotlin↔Python **bridges**
|
||||
(`PythonBridge`, `BleBridge`, `UsbSerialBridge`). Several desktop features are
|
||||
unavailable on this build because their Python backends rely on native libraries
|
||||
that have no Android/Chaquopy wheels (`mss`, `dxcam`, `sounddevice`/PortAudio,
|
||||
`opencv`, `nvidia-ml-py`, `winrt`, `dbus-next`), or on OS facilities Android
|
||||
sandboxes differently.
|
||||
|
||||
The README "Feature support by OS" table now carries an Android column reflecting
|
||||
this. This document assesses **whether each missing feature can be added**, how, and
|
||||
whether it's worth it.
|
||||
|
||||
### The enabling pattern (why most of this is feasible)
|
||||
|
||||
Every desktop capability that's "missing" on Android is missing only because of a
|
||||
*native dependency*, not because the capability is impossible. Android exposes the
|
||||
same capability through a platform API, and the codebase already has the bridge
|
||||
shape to plug it in:
|
||||
|
||||
> **Bridge pattern:** a Kotlin component captures an event/buffer → pushes it across
|
||||
> the Chaquopy JNI boundary into a **module-level receiver** in a small Python engine
|
||||
> → an existing engine/stream consumes it unchanged.
|
||||
|
||||
Reference implementation: `server/src/ledgrab/core/capture_engines/mediaprojection_engine.py`
|
||||
(`configure()` + `push_frame()` + a bounded `queue.Queue`) ↔
|
||||
`android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt` ↔
|
||||
`PythonBridge.pushFrame()`. Screen capture already works on Android this exact way.
|
||||
|
||||
So for most missing features the work is: **add a Kotlin capture source + a thin
|
||||
Python receiver engine mirroring that pattern.**
|
||||
|
||||
---
|
||||
|
||||
## Current Android capability matrix
|
||||
|
||||
| Feature | Desktop | Android (TV) today | Missing? |
|
||||
| ------- | ------- | ------------------ | -------- |
|
||||
| Screen capture | DXCam/WGC/MSS | ✅ MediaProjection + root `screenrecord` | No |
|
||||
| LED transports (network/USB-serial/BLE) | ✅ | ✅ (USB via Android driver, BLE via Android bridge) | No |
|
||||
| System metrics | psutil | ✅ CPU/RAM/battery/thermal via `/proc`, `/sys` (`AndroidMetricsProvider`) | No |
|
||||
| **Audio capture** | WASAPI / Sounddevice | ❌ no PortAudio | **Yes** |
|
||||
| **Notification capture** | WinRT / D-Bus | ❌ listener only Win/Linux | **Yes** |
|
||||
| Webcam capture | OpenCV | ❌ no OpenCV wheel | Yes (niche) |
|
||||
| GPU monitoring | NVML | ❌ no NVIDIA GPU | Marginal |
|
||||
| Capture from *another* Android phone | scrcpy/ADB | ❌ | Skip (redundant) |
|
||||
| Automation: window/process conditions | Windows ctypes | ❌ sandboxed | Partial |
|
||||
| Monitor names / multi-display | WMI / generic | Single built-in display | Low value |
|
||||
|
||||
---
|
||||
|
||||
## Per-feature feasibility
|
||||
|
||||
### 🔊 Audio capture — **FEASIBLE, HIGH VALUE** ⭐ (detailed plan exists)
|
||||
|
||||
- **Blocker:** only `sounddevice`/PortAudio is missing — not the capability.
|
||||
- **Android path:** `AudioPlaybackCapture` (API 29+) captures system playback audio and
|
||||
**takes a `MediaProjection` token — which the app already obtains for screen capture.**
|
||||
Kotlin `AudioRecord` → push PCM (float32) → a new push-based `AndroidAudioEngine`
|
||||
mirroring `mediaprojection_engine.py`, registered in `core/audio/__init__.py`, feeding
|
||||
the existing `AudioAnalyzer` unchanged. Mic (`AudioSource.MIC`) is the fallback.
|
||||
- **Effort:** moderate. **Value:** high — music/sound-reactive lighting is a flagship use
|
||||
on a TV box. **No new Python deps.**
|
||||
- ⚠️ DRM-protected apps (Netflix etc.) opt out of playback capture; works for non-DRM
|
||||
media and the device's own audio. Root mode (no MediaProjection) → mic-only.
|
||||
- 📄 **See `android-audio-capture-plan.md`** for the full implementation plan.
|
||||
|
||||
### 🔔 Notification capture — **FEASIBLE, HIGH VALUE** ⭐ (planned)
|
||||
|
||||
- **Android is the *best* platform for this:** `NotificationListenerService` is the native,
|
||||
event-push mechanism (no polling).
|
||||
- **Path:** a `NotificationListenerService` resolves the posting app's display label and
|
||||
pushes it via a module-level `push_notification()` into the existing
|
||||
`os_notification_listener.py` pipeline (a new push-based `_AndroidBackend` alongside
|
||||
`_WindowsBackend`/`_LinuxBackend`). Existing `NotificationColorStripSource` filters,
|
||||
per-app colors/sounds, and the history endpoint all work unchanged. **No new Python deps.**
|
||||
- **Permission:** user enables "Notification access" in Settings (`ACTION_NOTIFICATION_LISTENER_SETTINGS`);
|
||||
no runtime-permission popup.
|
||||
- **Effort:** moderate. **Value:** high.
|
||||
- 📄 **Plan approved & detailed** — see `C:\Users\Alexei\.claude\plans\deep-enchanting-muffin.md`
|
||||
(app-name parity; prompt-once permission UX).
|
||||
|
||||
### 📷 Webcam capture — **FEASIBLE, LOW VALUE**
|
||||
|
||||
- **Blocker** is `opencv-python-headless` (no Chaquopy cp311 wheel) — but capture doesn't
|
||||
*need* OpenCV. Use **CameraX / Camera2** + `ImageReader` in Kotlin and push frames through
|
||||
the same bridge as MediaProjection into a new `CameraBridgeEngine`.
|
||||
- **Effort:** moderate. **Value:** low — TVs rarely have cameras; USB-UVC webcams need extra
|
||||
device handling. Recommend deferring unless a concrete use case appears.
|
||||
|
||||
### 🎮 GPU monitoring — **MARGINAL, SKIP FOR NOW**
|
||||
|
||||
- NVML is desktop-NVIDIA only. Android GPU load lives in **vendor-specific sysfs**
|
||||
(Adreno `/sys/class/kgsl/kgsl-3d0/gpubusy`, Mali `/sys/class/devfreq/*.mali/...`),
|
||||
inconsistent and often root-only.
|
||||
- CPU/RAM/battery/thermal are **already** covered by `AndroidMetricsProvider`. A best-effort
|
||||
GPU-load reader could be added to that provider, but reliability is poor and value is low.
|
||||
|
||||
### 🪟 Automation: window/process conditions — **PARTIAL**
|
||||
|
||||
- Android forbids full window/process enumeration (`getRunningTasks` restricted since API 21+).
|
||||
- **Obtainable:** the *current foreground app package* via `UsageStatsManager` (needs the
|
||||
`PACKAGE_USAGE_STATS` special access) or an `AccessibilityService`.
|
||||
- So "when <app> is in the foreground → scene X" is feasible (mirrors
|
||||
`automations/platform_detector.py`, which currently returns empty off-Windows); full
|
||||
window-title matching is **not**. **Effort:** moderate. **Value:** moderate (per-app scenes
|
||||
on a TV box).
|
||||
|
||||
### 📱 Capture from *another* Android phone (scrcpy/ADB) — **SKIP**
|
||||
|
||||
- Impractical and redundant: no `adb` binary in Chaquopy, TV boxes can't reliably host an
|
||||
adb server, and the device already captures its **own** screen via MediaProjection.
|
||||
|
||||
### 🖥️ Monitor names / multi-display — **LOW VALUE**
|
||||
|
||||
- `DisplayManager` can report a better display name and enumerate secondary (HDMI) displays,
|
||||
but MediaProjection captures the default display; capturing a secondary display is more
|
||||
involved and rarely useful on a single-screen box.
|
||||
|
||||
---
|
||||
|
||||
## Prioritization
|
||||
|
||||
| Priority | Feature | Effort | Value | New Python deps | Status |
|
||||
| -------- | ------- | ------ | ----- | --------------- | ------ |
|
||||
| 1 | Notification capture | Moderate | High | None | **Plan approved** |
|
||||
| 2 | Audio capture | Moderate | High | None | **Plan written** (this folder) |
|
||||
| 3 | Automation: foreground-app condition | Moderate | Moderate | None | Idea |
|
||||
| 4 | Webcam capture (CameraX) | Moderate | Low | None | Idea |
|
||||
| — | GPU load (vendor sysfs) | Low–Med | Low | None | Not recommended |
|
||||
| — | Capture from another phone | — | — | — | Won't do |
|
||||
| — | Multi-display / monitor names | Low | Low | None | Not recommended |
|
||||
|
||||
**Recommended order:** ship notifications → ship audio → reassess. Both reuse existing
|
||||
infrastructure (bridge pattern, the MediaProjection consent token, the audio/notification
|
||||
pipelines) and add **zero** Python dependencies, so neither risks the Chaquopy
|
||||
`--no-deps` build constraint documented in `CLAUDE.md`.
|
||||
|
||||
## Cross-cutting notes
|
||||
|
||||
- **No `build.gradle.kts` / Chaquopy pip impact** for notifications or audio — both use Android
|
||||
platform APIs (Kotlin) + stdlib/`numpy` (already bundled) on the Python side.
|
||||
- **Per-instance `PythonBridge`:** `PythonBridge` is created per `CaptureService` instance, so
|
||||
system-bound services (e.g. a `NotificationListenerService`) call Python via the
|
||||
process-global `Python.getInstance()` rather than borrowing that bridge.
|
||||
- **Permissions are the recurring friction**, not the capture: audio needs `RECORD_AUDIO` +
|
||||
(for playback capture) a MediaProjection token; notifications need the "Notification access"
|
||||
settings toggle; foreground-app automation needs `PACKAGE_USAGE_STATS`.
|
||||
@@ -1,36 +1,58 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
### Screen Capture
|
||||
|
||||
- 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
|
||||
- Capture templates for reusable configurations
|
||||
- Reusable capture templates
|
||||
|
||||
### LED Device Support
|
||||
|
||||
- WLED (HTTP/UDP) with mDNS auto-discovery
|
||||
- Adalight (serial) — Arduino-compatible LED controllers
|
||||
- AmbileD (serial)
|
||||
- DDP (Distributed Display Protocol, UDP)
|
||||
- OpenRGB — PC peripherals (keyboard, mouse, RAM, fans, LED strips)
|
||||
- Serial port auto-detection and baud rate configuration
|
||||
LedGrab speaks many protocols, so a single setup can drive everything from a DIY strip to off-the-shelf smart bulbs:
|
||||
|
||||

|
||||
|
||||
- **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 / 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
|
||||
|
||||
- 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
|
||||
- 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
|
||||
|
||||
### 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)
|
||||
- WASAPI engine on Windows, Sounddevice (PortAudio) engine on Linux/macOS
|
||||
- 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
|
||||
|
||||
- Profile engine with condition-based switching (time of day, active window, etc.)
|
||||
- Dynamic brightness value sources (schedule-based, scene-aware)
|
||||
- Key Colors (KC) targets with live WebSocket color streaming
|
||||
- Automations engine with condition-based rules — switch targets, scenes, or brightness by time of day, active window/process, MQTT, webhooks, or game events
|
||||
- Scene presets for one-click lighting changes
|
||||
- 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
|
||||
|
||||
- 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
|
||||
- Responsive mobile layout with bottom tab navigation
|
||||
- Device management with auto-discovery wizard
|
||||
@@ -59,32 +84,56 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
||||
|
||||
### Home Assistant Integration
|
||||
|
||||
- HACS-compatible custom component
|
||||
- HACS-compatible custom component (separate repository)
|
||||
- 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
|
||||
|
||||
## Requirements
|
||||
## Platforms
|
||||
|
||||
- Python 3.11+ (or Docker)
|
||||
- A supported LED device on the local network or connected via USB
|
||||
- Windows, Linux, or macOS — all core features work cross-platform
|
||||
LedGrab runs as a desktop / server application:
|
||||
|
||||
### Platform Notes
|
||||
| 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 |
|
||||
| ------- | ------- | ------------- |
|
||||
| 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) |
|
||||
| GPU monitoring | NVIDIA (nvidia-ml-py) | NVIDIA (nvidia-ml-py) |
|
||||
| Capture from Android phone | scrcpy (ADB) | scrcpy (ADB) |
|
||||
| Notification capture | WinRT | dbus (Linux) |
|
||||
| Monitor names | Friendly names (WMI) | Generic ("Display 0") |
|
||||
| Profile conditions | Process/window detection | Not yet implemented |
|
||||
| Automation: window/process conditions | Supported | Partial |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+ (or Docker)
|
||||
- A supported LED device on the local network, connected via USB/serial, or reachable over Bluetooth
|
||||
- Windows, Linux, or macOS
|
||||
|
||||
## 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
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
|
||||
@@ -115,11 +164,11 @@ export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
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
|
||||
|
||||
@@ -133,50 +182,9 @@ docker compose run -e LEDGRAB_DEMO=true server
|
||||
|
||||
# Python
|
||||
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.
|
||||
|
||||
## 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
|
||||
```
|
||||
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.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -187,14 +195,15 @@ server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
log_level: "INFO"
|
||||
cors_origins:
|
||||
- "http://localhost:8080"
|
||||
|
||||
auth:
|
||||
api_keys:
|
||||
dev: "development-key-change-in-production"
|
||||
|
||||
storage:
|
||||
devices_file: "data/devices.json"
|
||||
templates_file: "data/capture_templates.json"
|
||||
# Empty (default) → loopback-only anonymous access; LAN requests are rejected.
|
||||
# Add a key to enable LAN/remote access (generate one with: openssl rand -hex 32).
|
||||
api_keys: {}
|
||||
# api_keys:
|
||||
# dev: "your-secret-key-here"
|
||||
|
||||
logging:
|
||||
format: "json"
|
||||
@@ -202,25 +211,26 @@ logging:
|
||||
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
|
||||
|
||||
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
|
||||
- **Capture Templates** — Screen capture configurations
|
||||
- **Picture Sources** — Screen capture stream definitions
|
||||
- **Picture Targets** — LED target management, start/stop processing
|
||||
- **Post-Processing Templates** — Filter pipeline configurations
|
||||
- **Color Strip Sources** — Audio, pattern, composite, mapped sources
|
||||
- **Audio Sources** — Multichannel and mono audio device configuration
|
||||
- **Pattern Templates** — Effect pattern definitions
|
||||
- **Value Sources** — Dynamic brightness/value providers
|
||||
- **Key Colors Targets** — KC targets with WebSocket live color stream
|
||||
- **Profiles** — Condition-based automation profiles
|
||||
- **Capture Templates** & **Picture Sources** — screen capture configuration and stream definitions
|
||||
- **Output Targets** — LED target management, start/stop processing, live color stream
|
||||
- **Post-Processing Templates** — filter pipeline configurations
|
||||
- **Color Strip Sources**, **Pattern Templates**, **Gradients** — color generation
|
||||
- **Audio Sources / Templates / Filters** — audio capture and reactive processing
|
||||
- **Value Sources**, **Weather Sources**, **Scene Presets** — dynamic parameters and presets
|
||||
- **Automations**, **Webhooks**, **HTTP Endpoints**, **Game Integration** — triggers and rules
|
||||
- **MQTT** & **Home Assistant** — broker sources and HA integration
|
||||
|
||||
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.
|
||||
|
||||
@@ -253,16 +263,16 @@ ruff check src/ tests/
|
||||
Optional extras:
|
||||
|
||||
```bash
|
||||
pip install -e ".[perf]" # High-performance capture engines (Windows)
|
||||
pip install -e ".[camera]" # Webcam capture via OpenCV
|
||||
pip install -e ".[perf]" # High-performance capture engines (Windows: DXCam, BetterCam, WGC)
|
||||
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
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
## 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
|
||||
MIT — see [LICENSE](LICENSE). Free and open source.
|
||||
|
||||
@@ -993,3 +993,123 @@ After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen f
|
||||
- LOW: Nanoleaf `.port` property added; pair-then-create E2E test
|
||||
added.
|
||||
- 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.
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
|
||||
<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" />
|
||||
|
||||
<!-- Autostart on boot — BootReceiver spawns CaptureService in root
|
||||
mode so capture resumes without the user touching the remote. -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.Manifest
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
@@ -85,6 +87,7 @@ class CaptureService : Service() {
|
||||
private var bridge: PythonBridge? = null
|
||||
private var screenCapture: ScreenCapture? = null
|
||||
private var rootCapture: RootScreenrecord? = null
|
||||
private var audioCapture: AudioCapture? = null
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
|
||||
// Service-scoped coroutine scope for the root-capture watchdog.
|
||||
@@ -338,6 +341,25 @@ class CaptureService : Service() {
|
||||
onProjectionStopped = { stopSelf() },
|
||||
).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")
|
||||
}
|
||||
|
||||
@@ -351,6 +373,10 @@ class CaptureService : Service() {
|
||||
screenCapture?.stop()
|
||||
screenCapture = null
|
||||
|
||||
// Stop audio before the server: stop() calls bridge.shutdownAudio().
|
||||
audioCapture?.stop()
|
||||
audioCapture = null
|
||||
|
||||
rootCapture?.stop()
|
||||
rootCapture = null
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ class MainActivity : Activity() {
|
||||
private const val SERVER_PORT = 8080
|
||||
private const val REQUEST_MEDIA_PROJECTION = 1001
|
||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
||||
private const val REQUEST_RECORD_AUDIO = 1003
|
||||
private const val QR_SIZE_PX = 560
|
||||
}
|
||||
|
||||
@@ -215,6 +216,7 @@ class MainActivity : Activity() {
|
||||
|
||||
private fun startCaptureService(resultCode: Int, resultData: Intent) {
|
||||
ensureNotificationPermission()
|
||||
ensureAudioPermission()
|
||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||
ContextCompat.startForegroundService(this, intent)
|
||||
updateUI()
|
||||
@@ -471,4 +473,24 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ class PythonBridge(private val context: Context) {
|
||||
// single-writer/single-reader pattern we have here.
|
||||
@Volatile private var mediaProjectionEngine: PyObject? = null
|
||||
@Volatile private var rootEngine: PyObject? = null
|
||||
@Volatile private var androidAudioEngine: PyObject? = null
|
||||
|
||||
/**
|
||||
* 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}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
||||
+3
-2
@@ -56,9 +56,10 @@ SetCompressor /SOLID lzma
|
||||
; ── Functions ─────────────────────────────────────────────
|
||||
|
||||
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}"'
|
||||
Sleep 2000
|
||||
ExecShell "open" "http://localhost:8080/"
|
||||
FunctionEnd
|
||||
|
||||
; Detect running instance before install (file lock check on python.exe)
|
||||
|
||||
+605
-289
@@ -1,335 +1,651 @@
|
||||
# LedGrab API Documentation
|
||||
# LedGrab API Reference
|
||||
|
||||
Complete REST API reference for the LedGrab server.
|
||||
Complete REST + WebSocket API reference for the LedGrab server.
|
||||
|
||||
**Base URL:** `http://localhost:8080`
|
||||
**API Version:** v1
|
||||
- **Base URL:** `http://localhost:8080`
|
||||
- **API version:** `v1` (all REST paths are under `/api/v1`, except `/health`)
|
||||
- **Interactive docs:** Swagger UI at [`/docs`](http://localhost:8080/docs), ReDoc at [`/redoc`](http://localhost:8080/redoc), raw schema at [`/openapi.json`](http://localhost:8080/openapi.json). The interactive docs are always the authoritative, up-to-date source for request/response schemas — this file is a hand-maintained overview.
|
||||
|
||||
> The application version is reported by `GET /api/v1/version`; this document is version-agnostic.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Health & Info](#health--info)
|
||||
- [Device Management](#device-management)
|
||||
- [Processing Control](#processing-control)
|
||||
- [Settings Management](#settings-management)
|
||||
- [Calibration](#calibration)
|
||||
- [Metrics](#metrics)
|
||||
- [Authentication](#authentication)
|
||||
- [Conventions](#conventions)
|
||||
- [WebSocket protocol](#websocket-protocol)
|
||||
- [Worked examples](#worked-examples)
|
||||
- **Endpoint reference**
|
||||
- [Health & system info](#health--system-info)
|
||||
- [System settings](#system-settings)
|
||||
- [User preferences](#user-preferences)
|
||||
- [Backup, restore & server control](#backup-restore--server-control)
|
||||
- [Updates](#updates)
|
||||
- [Snapshot](#snapshot)
|
||||
- [Devices](#devices)
|
||||
- [Capture templates, engines & filters](#capture-templates-engines--filters)
|
||||
- [Picture sources](#picture-sources)
|
||||
- [Post-processing templates](#post-processing-templates)
|
||||
- [Output targets](#output-targets)
|
||||
- [Output target control & live preview](#output-target-control--live-preview)
|
||||
- [Color strip sources](#color-strip-sources)
|
||||
- [Color strip processing templates](#color-strip-processing-templates)
|
||||
- [Pattern templates](#pattern-templates)
|
||||
- [Gradients](#gradients)
|
||||
- [Audio devices](#audio-devices)
|
||||
- [Audio sources](#audio-sources)
|
||||
- [Audio templates & engines](#audio-templates--engines)
|
||||
- [Audio processing templates](#audio-processing-templates)
|
||||
- [Audio filters](#audio-filters)
|
||||
- [Value sources](#value-sources)
|
||||
- [Weather sources](#weather-sources)
|
||||
- [Automations](#automations)
|
||||
- [Scene presets](#scene-presets)
|
||||
- [Sync clocks](#sync-clocks)
|
||||
- [Webhooks](#webhooks)
|
||||
- [HTTP endpoints](#http-endpoints)
|
||||
- [Game integration](#game-integration)
|
||||
- [Home Assistant](#home-assistant)
|
||||
- [MQTT sources](#mqtt-sources)
|
||||
- [Assets](#assets)
|
||||
- [Graph wiring](#graph-wiring)
|
||||
- [Web UI & PWA](#web-ui--pwa)
|
||||
|
||||
---
|
||||
|
||||
## Health & Info
|
||||
## Authentication
|
||||
|
||||
### GET /health
|
||||
LedGrab uses API-key authentication. The behavior depends on whether any keys are configured under `auth.api_keys` (see [INSTALLATION.md](../INSTALLATION.md)):
|
||||
|
||||
Health check endpoint.
|
||||
| Situation | Loopback (`127.0.0.1` / `::1` / `localhost`) | LAN / remote |
|
||||
| --------- | -------------------------------------------- | ------------ |
|
||||
| **No keys configured** (default) | Allowed anonymously | **Rejected with `401`** |
|
||||
| **Keys configured** | Valid Bearer token required | Valid Bearer token required |
|
||||
|
||||
Pass the key as a Bearer token:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <your-api-key>
|
||||
```
|
||||
|
||||
A few **sensitive endpoints require a real API key even from localhost** (they reject the loopback-anonymous identity): the backup download/restore endpoints, and any endpoint that reveals stored secrets (e.g. `GET /api/v1/home-assistant/sources?include_secrets=true`). Configure a key to use those.
|
||||
|
||||
WebSocket endpoints authenticate with a [first-message handshake](#websocket-protocol) rather than the `Authorization` header.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Content type:** request and response bodies are JSON (`application/json`) unless noted (file uploads use `multipart/form-data`; some endpoints stream binary or file responses).
|
||||
- **Errors:** failures return the standard FastAPI shape with an HTTP status code and a body of `{"detail": "<message>"}`. Validation errors return `422` with a structured `detail` array.
|
||||
- **IDs:** entities are addressed by string IDs (e.g. `dev_…`, `ot_…`, `css_…`) generated on creation.
|
||||
- **Common create/update fields:** most configurable entities accept `name`, `description`, `tags` (string array), and UI styling fields `icon` and `icon_color`.
|
||||
- **Referential integrity:** deleting an entity that is still referenced (e.g. a device used by an output target) returns `409 Conflict`.
|
||||
- **Timestamps:** ISO-8601 UTC strings.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
All WebSocket endpoints share the same auth handshake:
|
||||
|
||||
1. The client connects. The server accepts the socket.
|
||||
2. The client sends a JSON auth message as the **first** message, within ~3 seconds: `{"type": "auth", "token": "<your-api-key>"}`. On loopback with no keys configured, `token` may be `null` or the message omitted.
|
||||
3. The server replies `{"type": "auth_ok"}` on success, or `{"type": "auth_error", "reason": "..."}` then closes (close code `4401`) on failure. A cross-site `Origin` is rejected with close code `4403`.
|
||||
|
||||
Browser clients must connect from an allowed `cors_origins` origin. After `auth_ok`, the stream payload depends on the endpoint (JSON event objects, JSON spectrum/metric frames, or binary RGB frames — see each endpoint's description).
|
||||
|
||||
The WebSocket endpoints are listed within their resource sections below (method `WS`).
|
||||
|
||||
---
|
||||
|
||||
## Worked examples
|
||||
|
||||
> Example values are illustrative.
|
||||
|
||||
**Health check** — `GET /health` (no auth on loopback):
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2026-02-06T12:00:00Z",
|
||||
"version": "0.1.0"
|
||||
"timestamp": "2026-05-29T12:00:00Z",
|
||||
"version": "0.8.1",
|
||||
"demo_mode": false,
|
||||
"auth_required": false,
|
||||
"setup_required": false,
|
||||
"uptime_seconds": 3600
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/version
|
||||
**Create a WLED device** — `POST /api/v1/devices`:
|
||||
|
||||
Get version information.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"python_version": "3.11.0",
|
||||
"api_version": "v1"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/config/displays
|
||||
|
||||
List available displays for screen capture.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"displays": [
|
||||
{
|
||||
"index": 0,
|
||||
"name": "Display 1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"is_primary": true
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Device Management
|
||||
|
||||
### POST /api/v1/devices
|
||||
|
||||
Create and attach a new WLED device.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "Living Room TV",
|
||||
"url": "http://192.168.1.100",
|
||||
"device_type": "wled",
|
||||
"led_count": 150
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created`
|
||||
Response `201 Created` returns the stored device, including its generated `id`. (For Adalight, send `device_type: "adalight"`, the serial `url` like `COM3` or `/dev/ttyUSB0`, `led_count`, and `baud_rate`. Each device type accepts its own fields — see `/docs`.)
|
||||
|
||||
**Start / stop a target** — `POST /api/v1/output-targets/{target_id}/start`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "device_abc123",
|
||||
"name": "Living Room TV",
|
||||
"url": "http://192.168.1.100",
|
||||
"led_count": 150,
|
||||
"enabled": true,
|
||||
"status": "disconnected",
|
||||
"settings": {
|
||||
"display_index": 0,
|
||||
"fps": 30,
|
||||
"border_width": 10
|
||||
},
|
||||
"calibration": {
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [...]
|
||||
},
|
||||
"created_at": "2026-02-06T12:00:00Z",
|
||||
"updated_at": "2026-02-06T12:00:00Z"
|
||||
}
|
||||
{ "status": "started", "target_id": "ot_abc123" }
|
||||
```
|
||||
|
||||
### GET /api/v1/devices
|
||||
**Authenticated request with a configured key:**
|
||||
|
||||
List all attached devices.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"devices": [...],
|
||||
"count": 2
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/devices/{device_id}
|
||||
|
||||
Get device details.
|
||||
|
||||
**Response:** Same as POST response
|
||||
|
||||
### PUT /api/v1/devices/{device_id}
|
||||
|
||||
Update device information.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /api/v1/devices/{device_id}
|
||||
|
||||
Delete/detach a device.
|
||||
|
||||
**Response:** `204 No Content`
|
||||
|
||||
---
|
||||
|
||||
## Processing Control
|
||||
|
||||
### POST /api/v1/devices/{device_id}/start
|
||||
|
||||
Start screen processing for a device.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "started",
|
||||
"device_id": "device_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/v1/devices/{device_id}/stop
|
||||
|
||||
Stop screen processing.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "stopped",
|
||||
"device_id": "device_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/devices/{device_id}/state
|
||||
|
||||
Get current processing state.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"device_id": "device_abc123",
|
||||
"processing": true,
|
||||
"fps_actual": 29.8,
|
||||
"fps_target": 30,
|
||||
"display_index": 0,
|
||||
"last_update": "2026-02-06T12:00:00Z",
|
||||
"errors": []
|
||||
}
|
||||
```bash
|
||||
curl -H "Authorization: Bearer your-api-key" \
|
||||
http://localhost:8080/api/v1/devices
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings Management
|
||||
## Endpoint reference
|
||||
|
||||
### GET /api/v1/devices/{device_id}/settings
|
||||
## Health & system info
|
||||
|
||||
Get processing settings.
|
||||
Health checks, version information, displays, system metrics, and integration status.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"display_index": 0,
|
||||
"fps": 30,
|
||||
"brightness": 1.0,
|
||||
"smoothing": 0.3,
|
||||
"interpolation_mode": "average",
|
||||
"standby_interval": 1.0,
|
||||
"state_check_interval": 30
|
||||
}
|
||||
```
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/health` | Service health: status, version, uptime, and whether auth/setup is required. |
|
||||
| GET | `/api/v1/version` | Application version, Python version, and API version. |
|
||||
| GET | `/api/v1/tags` | All tags used across every entity in the system. |
|
||||
| GET | `/api/v1/config/displays` | Available displays/monitors for screen capture (optional `engine_type` query, e.g. `scrcpy`). |
|
||||
| GET | `/api/v1/system/processes` | Running process names, for use in automation conditions. |
|
||||
| GET | `/api/v1/system/performance` | Current CPU, RAM, and GPU utilization metrics. |
|
||||
| GET | `/api/v1/system/metrics-history` | Last ~2 minutes of system and per-target metrics for dashboard charts. |
|
||||
| GET | `/api/v1/system/api-keys` | API-key labels with masked values (read-only; keys live in YAML config). |
|
||||
| GET | `/api/v1/system/integrations-status` | Connection status for MQTT and Home Assistant integrations. |
|
||||
|
||||
### PUT /api/v1/devices/{device_id}/settings
|
||||
## System settings
|
||||
|
||||
Update processing settings.
|
||||
Server configuration: MQTT broker, external URL, shutdown action, log level, ADB connection, and live log streaming.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"display_index": 1,
|
||||
"fps": 60,
|
||||
"brightness": 0.8
|
||||
}
|
||||
```
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/system/mqtt/settings` | Current MQTT broker settings (password masked). |
|
||||
| PUT | `/api/v1/system/mqtt/settings` | Update MQTT broker settings (empty password preserves existing). |
|
||||
| GET | `/api/v1/system/external-url` | Configured external base URL. |
|
||||
| PUT | `/api/v1/system/external-url` | Set the external base URL for webhooks and user-visible links. |
|
||||
| GET | `/api/v1/system/shutdown-action` | Configured server shutdown action (`stop_targets` or `nothing`). |
|
||||
| PUT | `/api/v1/system/shutdown-action` | Set what happens to targets when the server shuts down. |
|
||||
| WS | `/api/v1/system/logs/ws` | Live server log stream with a buffered backlog. |
|
||||
| POST | `/api/v1/adb/connect` | Connect to a Wi-Fi ADB device by IP (auto-appends `:5555`). |
|
||||
| POST | `/api/v1/adb/disconnect` | Disconnect a Wi-Fi ADB device. |
|
||||
| GET | `/api/v1/system/log-level` | Current root logger level. |
|
||||
| PUT | `/api/v1/system/log-level` | Change the log level at runtime without restart. |
|
||||
|
||||
## User preferences
|
||||
|
||||
Dashboard layout, notification settings, card display modes, and the global daylight timezone.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/preferences/dashboard-layout` | Read the saved dashboard layout (empty when unset). |
|
||||
| PUT | `/api/v1/preferences/dashboard-layout` | Save the dashboard layout (opaque versioned JSON blob). |
|
||||
| DELETE | `/api/v1/preferences/dashboard-layout` | Delete the saved layout; revert to default. |
|
||||
| GET | `/api/v1/preferences/notifications` | Read notification preferences (server defaults when unset). |
|
||||
| PUT | `/api/v1/preferences/notifications` | Persist notification preferences (channels, discovery, grace/debounce). |
|
||||
| GET | `/api/v1/preferences/card-modes` | Read per-surface card-mode preferences. |
|
||||
| PUT | `/api/v1/preferences/card-modes` | Save per-surface card modes (comfortable/compact/dense/row). |
|
||||
| DELETE | `/api/v1/preferences/card-modes` | Delete card-mode preferences; revert to defaults. |
|
||||
| GET | `/api/v1/preferences/daylight-timezone` | Read the global IANA timezone for daylight cycles. |
|
||||
| PUT | `/api/v1/preferences/daylight-timezone` | Persist the daylight-cycle timezone (empty = server local). |
|
||||
|
||||
## Backup, restore & server control
|
||||
|
||||
Database backup/restore, server restart/shutdown, and auto-backup management.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/system/backup` | Download a full backup `.zip` (database + assets). 🔒 requires a key. |
|
||||
| POST | `/api/v1/system/restore` | Upload a `.db`/`.zip` backup to restore config and trigger a restart. 🔒 requires a key. |
|
||||
| POST | `/api/v1/system/restart` | Schedule a server restart and return immediately. |
|
||||
| POST | `/api/v1/system/shutdown` | Gracefully shut down the server. |
|
||||
| GET | `/api/v1/system/auto-backup/settings` | Auto-backup settings and status (enabled, interval, retention, last/next). |
|
||||
| PUT | `/api/v1/system/auto-backup/settings` | Update auto-backup settings. |
|
||||
| POST | `/api/v1/system/auto-backup/trigger` | Trigger a backup now and return its metadata. |
|
||||
| GET | `/api/v1/system/backups` | List saved auto-backup files. |
|
||||
| GET | `/api/v1/system/backups/{filename}` | Download a specific saved backup file. |
|
||||
| DELETE | `/api/v1/system/backups/{filename}` | Delete a specific saved backup file. |
|
||||
|
||||
> 🔒 = requires a real API key even from localhost (rejects loopback-anonymous access).
|
||||
|
||||
## Updates
|
||||
|
||||
Auto-update management: check, apply, dismiss, and configure.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/system/update/status` | Current update status (available version, install type, capability). |
|
||||
| POST | `/api/v1/system/update/check` | Trigger an immediate update check. |
|
||||
| POST | `/api/v1/system/update/dismiss` | Dismiss the notification for a specific version. |
|
||||
| POST | `/api/v1/system/update/apply` | Download and apply the available update, then shut down. |
|
||||
| GET | `/api/v1/system/update/settings` | Update settings (enabled, interval, include prereleases). |
|
||||
| PUT | `/api/v1/system/update/settings` | Change auto-update settings. |
|
||||
|
||||
## Snapshot
|
||||
|
||||
A single aggregated poll endpoint for low-overhead clients.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/snapshot` | Full poll payload (targets, states, metrics, devices, brightness, color/value sources, scene presets, sync clocks, system) in one response. Use `?include=` to request a subset; per-section fault isolation. |
|
||||
|
||||
## Devices
|
||||
|
||||
LED device CRUD, pairing, discovery, health checks, brightness/power control, and the WS pixel stream.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| POST | `/api/v1/devices` | Create/attach a new LED device (validates connectivity). |
|
||||
| POST | `/api/v1/devices/pair` | Run a pairing handshake before creating a device. |
|
||||
| GET | `/api/v1/devices` | List all attached devices. |
|
||||
| GET | `/api/v1/devices/discover` | Scan the network for devices (optional `timeout`, `device_type`). |
|
||||
| GET | `/api/v1/devices/openrgb-zones` | List zones on an OpenRGB device (`url` query). |
|
||||
| GET | `/api/v1/devices/batch/states` | Health/connection state for all devices at once. |
|
||||
| GET | `/api/v1/devices/{device_id}` | Get a device by ID. |
|
||||
| PUT | `/api/v1/devices/{device_id}` | Update device configuration. |
|
||||
| DELETE | `/api/v1/devices/{device_id}` | Delete/detach a device (`409` if referenced). |
|
||||
| GET | `/api/v1/devices/{device_id}/state` | Get device health/connection state. |
|
||||
| POST | `/api/v1/devices/{device_id}/ping` | Force an immediate health check. |
|
||||
| GET | `/api/v1/devices/{device_id}/brightness` | Get current (cached) brightness. |
|
||||
| PUT | `/api/v1/devices/{device_id}/brightness` | Set brightness (`0–255`). |
|
||||
| GET | `/api/v1/devices/{device_id}/power` | Get current power state. |
|
||||
| PUT | `/api/v1/devices/{device_id}/power` | Turn the device on or off. |
|
||||
| WS | `/api/v1/devices/{device_id}/ws` | Pixel stream for `ws` device type (`[brightness][R G B …]`). |
|
||||
|
||||
## Capture templates, engines & filters
|
||||
|
||||
Capture template CRUD/testing, capture engine discovery, and post-processing filter discovery.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/capture-templates` | List all capture templates. |
|
||||
| POST | `/api/v1/capture-templates` | Create a capture template. |
|
||||
| GET | `/api/v1/capture-templates/{template_id}` | Get a capture template by ID. |
|
||||
| PUT | `/api/v1/capture-templates/{template_id}` | Update a capture template (partial). |
|
||||
| DELETE | `/api/v1/capture-templates/{template_id}` | Delete a template (`409` if used by streams). |
|
||||
| GET | `/api/v1/capture-engines` | List capture engines with platform availability. |
|
||||
| POST | `/api/v1/capture-templates/test` | Test a capture config; returns FPS metrics + preview. |
|
||||
| WS | `/api/v1/capture-templates/test/ws` | Real-time capture test with intermediate frame previews. |
|
||||
| GET | `/api/v1/filters` | List post-processing filter types and option schemas. |
|
||||
| GET | `/api/v1/strip-filters` | List filter types that support 1D LED-strip processing. |
|
||||
|
||||
## Picture sources
|
||||
|
||||
Screen captures, static images, video files, and processed streams used for color extraction.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/picture-sources` | List all picture sources. |
|
||||
| POST | `/api/v1/picture-sources/validate-image` | Validate an image source and return a preview thumbnail. |
|
||||
| GET | `/api/v1/picture-sources/full-image` | Serve a full-resolution image for lightbox preview (`source` query). |
|
||||
| POST | `/api/v1/picture-sources` | Create a picture source (`raw`/`processed`/`static`/`video`). |
|
||||
| GET | `/api/v1/picture-sources/{stream_id}` | Get a picture source by ID. |
|
||||
| PUT | `/api/v1/picture-sources/{stream_id}` | Update a picture source. |
|
||||
| DELETE | `/api/v1/picture-sources/{stream_id}` | Delete a picture source (`409` if referenced). |
|
||||
| GET | `/api/v1/picture-sources/{stream_id}/thumbnail` | Thumbnail (first frame) for a video source. |
|
||||
| POST | `/api/v1/picture-sources/{stream_id}/test` | Resolve the chain and run a capture test. |
|
||||
| WS | `/api/v1/picture-sources/{stream_id}/test/ws` | Test stream with intermediate frame previews. |
|
||||
|
||||
## Post-processing templates
|
||||
|
||||
Reusable filter chains applied to picture sources.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/postprocessing-templates` | List all post-processing templates. |
|
||||
| POST | `/api/v1/postprocessing-templates` | Create a template (name + filter list). |
|
||||
| GET | `/api/v1/postprocessing-templates/{template_id}` | Get a template by ID. |
|
||||
| PUT | `/api/v1/postprocessing-templates/{template_id}` | Update a template (partial). |
|
||||
| DELETE | `/api/v1/postprocessing-templates/{template_id}` | Delete a template (`409` if referenced). |
|
||||
| POST | `/api/v1/postprocessing-templates/{template_id}/test` | Capture from a source and apply the filters. |
|
||||
| WS | `/api/v1/postprocessing-templates/{template_id}/test/ws` | Real-time test with intermediate frame previews. |
|
||||
|
||||
## Output targets
|
||||
|
||||
LED strips, Home Assistant light groups, and Zigbee2MQTT bulb groups.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| POST | `/api/v1/output-targets` | Create a target (`led` / `ha_light` / `z2m_light`). |
|
||||
| GET | `/api/v1/output-targets` | List all output targets. |
|
||||
| GET | `/api/v1/output-targets/batch/states` | Processing state for all targets at once. |
|
||||
| GET | `/api/v1/output-targets/batch/metrics` | Metrics for all targets at once. |
|
||||
| GET | `/api/v1/output-targets/{target_id}` | Get a single target. |
|
||||
| PUT | `/api/v1/output-targets/{target_id}` | Update a target (partial, per type). |
|
||||
| DELETE | `/api/v1/output-targets/{target_id}` | Delete a target (stops processing first). |
|
||||
|
||||
## Output target control & live preview
|
||||
|
||||
Start/stop processing, state & metrics, the calibration overlay, the global event stream, and live color/LED preview WebSockets.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| POST | `/api/v1/output-targets/bulk/start` | Start processing for multiple targets. |
|
||||
| POST | `/api/v1/output-targets/bulk/stop` | Stop processing for multiple targets. |
|
||||
| POST | `/api/v1/output-targets/{target_id}/start` | Start processing for one target. |
|
||||
| POST | `/api/v1/output-targets/{target_id}/stop` | Stop processing for one target. |
|
||||
| GET | `/api/v1/output-targets/{target_id}/state` | Current processing state (FPS, timing, device, errors). |
|
||||
| GET | `/api/v1/output-targets/{target_id}/metrics` | Processing metrics (uptime, frames, error count). |
|
||||
| WS | `/api/v1/events/ws` | Real-time state-change events across all targets. |
|
||||
| POST | `/api/v1/output-targets/{target_id}/overlay/start` | Start the on-screen sampling/LED overlay. |
|
||||
| POST | `/api/v1/output-targets/{target_id}/overlay/stop` | Stop the overlay. |
|
||||
| GET | `/api/v1/output-targets/{target_id}/overlay/status` | Whether the overlay is active. |
|
||||
| POST | `/api/v1/output-targets/{target_id}/ha-light/turn-off` | Turn off all HA light entities for the target. |
|
||||
| WS | `/api/v1/output-targets/{target_id}/ha-light/ws` | Live HA light color preview. |
|
||||
| POST | `/api/v1/output-targets/{target_id}/z2m-light/turn-off` | Publish OFF to all Zigbee2MQTT bulbs for the target. |
|
||||
| WS | `/api/v1/output-targets/{target_id}/z2m-light/ws` | Live Zigbee2MQTT bulb color preview. |
|
||||
| WS | `/api/v1/output-targets/{target_id}/led-preview/ws` | Live LED-strip preview (binary RGB frames). |
|
||||
|
||||
## Color strip sources
|
||||
|
||||
CRUD, calibration, raw color push, notifications, and preview streaming for color strip sources.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/color-strip-sources` | List all color strip sources. |
|
||||
| POST | `/api/v1/color-strip-sources` | Create a color strip source (by `source_type`). |
|
||||
| GET | `/api/v1/color-strip-sources/{source_id}` | Get a color strip source by ID. |
|
||||
| PUT | `/api/v1/color-strip-sources/{source_id}` | Update a source; hot-reloads running streams. |
|
||||
| DELETE | `/api/v1/color-strip-sources/{source_id}` | Delete a source (`409` if referenced). |
|
||||
| POST | `/api/v1/color-strip-sources/{source_id}/overlay/start` | Start the screen overlay (picture-type, calibrated). |
|
||||
| POST | `/api/v1/color-strip-sources/{source_id}/overlay/stop` | Stop the screen overlay. |
|
||||
| GET | `/api/v1/color-strip-sources/{source_id}/overlay/status` | Whether the overlay is active. |
|
||||
| POST | `/api/v1/color-strip-sources/{source_id}/colors` | Push raw LED colors to an `api_input` source. |
|
||||
| POST | `/api/v1/color-strip-sources/{source_id}/notify` | Trigger a one-shot notification effect. |
|
||||
| GET | `/api/v1/color-strip-sources/os-notifications/history` | Recent OS-notification capture history. |
|
||||
| PUT | `/api/v1/color-strip-sources/{source_id}/calibration/test` | Light up LED edges to verify calibration. |
|
||||
| POST | `/api/v1/color-strip-sources/{source_id}/key-colors/test` | Test a `key_colors` source (extract colors from rectangles). |
|
||||
| WS | `/api/v1/color-strip-sources/{source_id}/key-colors/test/ws` | Real-time key-colors test preview. |
|
||||
| WS | `/api/v1/color-strip-sources/preview/ws` | Transient ad-hoc source preview stream. |
|
||||
| WS | `/api/v1/color-strip-sources/{source_id}/ws` | Push raw colors to an `api_input` source over WS. |
|
||||
| WS | `/api/v1/color-strip-sources/{source_id}/test/ws` | Real-time source preview (binary RGB, optional JPEG). |
|
||||
|
||||
## Color strip processing templates
|
||||
|
||||
Reusable filter chains applied to color strips (1D LED data).
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/color-strip-processing-templates` | List all color-strip processing templates. |
|
||||
| POST | `/api/v1/color-strip-processing-templates` | Create a template (name + filter list). |
|
||||
| GET | `/api/v1/color-strip-processing-templates/{template_id}` | Get a template by ID. |
|
||||
| PUT | `/api/v1/color-strip-processing-templates/{template_id}` | Update a template. |
|
||||
| DELETE | `/api/v1/color-strip-processing-templates/{template_id}` | Delete a template (`409` if referenced). |
|
||||
| WS | `/api/v1/color-strip-processing-templates/{template_id}/test/ws` | Real-time preview: apply the filter chain to an input source. |
|
||||
|
||||
## Pattern templates
|
||||
|
||||
Layout templates of named rectangles for LED device configuration.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/pattern-templates` | List all pattern templates. |
|
||||
| POST | `/api/v1/pattern-templates` | Create a pattern template (named rectangles). |
|
||||
| GET | `/api/v1/pattern-templates/{template_id}` | Get a pattern template by ID. |
|
||||
| PUT | `/api/v1/pattern-templates/{template_id}` | Update a pattern template. |
|
||||
| DELETE | `/api/v1/pattern-templates/{template_id}` | Delete a template (`409` if referenced by targets). |
|
||||
|
||||
## Gradients
|
||||
|
||||
Reusable gradient definitions (color stops). Built-in gradients are read-only but clonable.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/gradients` | List all gradients (built-in and user-created). |
|
||||
| POST | `/api/v1/gradients` | Create a user-defined gradient. |
|
||||
| GET | `/api/v1/gradients/{gradient_id}` | Get a gradient by ID. |
|
||||
| PUT | `/api/v1/gradients/{gradient_id}` | Update a gradient (built-ins are read-only). |
|
||||
| POST | `/api/v1/gradients/{gradient_id}/clone` | Clone a gradient into a customizable copy. |
|
||||
| DELETE | `/api/v1/gradients/{gradient_id}` | Delete a gradient (`400` if built-in or referenced). |
|
||||
|
||||
## Audio devices
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/audio-devices` | List audio input/output devices (flat list + per-engine grouping). |
|
||||
|
||||
## Audio sources
|
||||
|
||||
Audio capture and processing sources for audio-reactive effects.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/audio-sources` | List all audio sources (optional `source_type`). |
|
||||
| POST | `/api/v1/audio-sources` | Create an audio source (`capture` or `processed`). |
|
||||
| GET | `/api/v1/audio-sources/{source_id}` | Get an audio source by ID. |
|
||||
| PUT | `/api/v1/audio-sources/{source_id}` | Update an audio source (partial). |
|
||||
| DELETE | `/api/v1/audio-sources/{source_id}` | Delete an audio source (`409` if referenced). |
|
||||
| WS | `/api/v1/audio-sources/{source_id}/test/ws` | Real-time spectrum/RMS/peak/beat analysis (~20 Hz). |
|
||||
|
||||
## Audio templates & engines
|
||||
|
||||
Audio capture templates and engine discovery.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/audio-templates` | List all audio capture templates. |
|
||||
| POST | `/api/v1/audio-templates` | Create an audio capture template. |
|
||||
| GET | `/api/v1/audio-templates/{template_id}` | Get an audio template by ID. |
|
||||
| PUT | `/api/v1/audio-templates/{template_id}` | Update an audio template. |
|
||||
| DELETE | `/api/v1/audio-templates/{template_id}` | Delete a template (cascades to audio sources). |
|
||||
| GET | `/api/v1/audio-engines` | List audio capture engines and availability. |
|
||||
| WS | `/api/v1/audio-templates/{template_id}/test/ws` | Real-time spectrum test for a template + device. |
|
||||
|
||||
## Audio processing templates
|
||||
|
||||
Reusable audio filter chains.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/audio-processing-templates` | List all audio processing templates. |
|
||||
| POST | `/api/v1/audio-processing-templates` | Create a template (name + filter list). |
|
||||
| GET | `/api/v1/audio-processing-templates/{template_id}` | Get a template by ID. |
|
||||
| PUT | `/api/v1/audio-processing-templates/{template_id}` | Update a template (hot-updates running streams). |
|
||||
| DELETE | `/api/v1/audio-processing-templates/{template_id}` | Delete a template (`409` if referenced). |
|
||||
|
||||
## Audio filters
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/audio-filters` | List audio filter types and their option schemas. |
|
||||
|
||||
## Value sources
|
||||
|
||||
Dynamic data inputs (brightness and other parameters): static, animated, audio, adaptive, color, sensor, HTTP, Home Assistant, and `template` — a sandboxed-Jinja **combinator** that evaluates an expression over the live values of other value sources.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/value-sources` | List all value sources (optional `source_type`). |
|
||||
| POST | `/api/v1/value-sources` | Create a value source (discriminated by `source_type`). |
|
||||
| POST | `/api/v1/value-sources/validate-template` | Validate a template expression + inputs (advisory; always `200` with `{valid, error, errors, warnings, variables}`). |
|
||||
| GET | `/api/v1/value-sources/{source_id}` | Get a value source by ID. |
|
||||
| PUT | `/api/v1/value-sources/{source_id}` | Update a value source; hot-reloads running streams. |
|
||||
| DELETE | `/api/v1/value-sources/{source_id}` | Delete a value source (`400` if referenced by a target or another value source). |
|
||||
| WS | `/api/v1/value-sources/{source_id}/test/ws` | Real-time value output stream (~20 Hz). |
|
||||
|
||||
### Template value source (`source_type: "template"`)
|
||||
|
||||
A `float` combinator. Fields: `template` (a Jinja *expression*), `inputs` (`[{name, value_source_id}]` bindings to other value sources), `default_value` (fallback in `[0,1]` on any error), and `eval_interval` (optional re-eval throttle in seconds; `0`/null = every poll). At runtime each input is exposed by its `name` (the source's normalized `0..1` value) plus `raw[name]` (its un-normalized value, where available). Globals: `min`, `max`, `abs`, `round`, `clamp(x, lo=0, hi=1)`. The expression runs in a hardened `ImmutableSandboxedEnvironment` (no statements/blocks, filters, attribute access, `**`, or string repetition); results are coerced, NaN/inf-rejected, and clamped to `[0,1]`. Reference cycles and over-deep nesting are rejected at save time. For time-of-day logic, bind an `adaptive_time` or `daylight` source as an input.
|
||||
|
||||
## Weather sources
|
||||
|
||||
Weather data providers feeding weather-driven value sources.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/weather-sources` | List all weather sources. |
|
||||
| POST | `/api/v1/weather-sources` | Create a weather source (provider, lat/lon, interval). |
|
||||
| GET | `/api/v1/weather-sources/{source_id}` | Get a weather source by ID. |
|
||||
| PUT | `/api/v1/weather-sources/{source_id}` | Update a weather source. |
|
||||
| DELETE | `/api/v1/weather-sources/{source_id}` | Delete a weather source. |
|
||||
| POST | `/api/v1/weather-sources/{source_id}/test` | Force-fetch current weather and return it. |
|
||||
|
||||
## Automations
|
||||
|
||||
Rules that trigger scene presets (time, display state, MQTT, webhooks, Home Assistant, HTTP polling, active window).
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| POST | `/api/v1/automations` | Create an automation (rules + scene preset + deactivation). |
|
||||
| GET | `/api/v1/automations` | List automations with current activity state. |
|
||||
| GET | `/api/v1/automations/{automation_id}` | Get an automation by ID (includes webhook URL if any). |
|
||||
| PUT | `/api/v1/automations/{automation_id}` | Update an automation (partial); re-evaluates if enabled. |
|
||||
| DELETE | `/api/v1/automations/{automation_id}` | Delete and deactivate an automation. |
|
||||
| POST | `/api/v1/automations/{automation_id}/enable` | Enable and immediately evaluate rules. |
|
||||
| POST | `/api/v1/automations/{automation_id}/disable` | Disable and deactivate. |
|
||||
|
||||
## Scene presets
|
||||
|
||||
Captured snapshots of target state that can be restored.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| POST | `/api/v1/scene-presets` | Create a preset by capturing current target state. |
|
||||
| GET | `/api/v1/scene-presets` | List all scene presets. |
|
||||
| GET | `/api/v1/scene-presets/{preset_id}` | Get a scene preset by ID. |
|
||||
| PUT | `/api/v1/scene-presets/{preset_id}` | Update metadata and optionally change targets. |
|
||||
| DELETE | `/api/v1/scene-presets/{preset_id}` | Delete a scene preset. |
|
||||
| POST | `/api/v1/scene-presets/{preset_id}/recapture` | Re-capture current state into the preset. |
|
||||
| POST | `/api/v1/scene-presets/{preset_id}/activate` | Activate the preset (restore captured state). |
|
||||
|
||||
## Sync clocks
|
||||
|
||||
Shared clocks that drive linked animations with configurable speed.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/sync-clocks` | List all synchronization clocks. |
|
||||
| POST | `/api/v1/sync-clocks` | Create a sync clock. |
|
||||
| GET | `/api/v1/sync-clocks/{clock_id}` | Get a sync clock by ID. |
|
||||
| PUT | `/api/v1/sync-clocks/{clock_id}` | Update a clock (speed changes hot-applied). |
|
||||
| DELETE | `/api/v1/sync-clocks/{clock_id}` | Delete a clock (`409` if referenced). |
|
||||
| POST | `/api/v1/sync-clocks/{clock_id}/pause` | Pause the clock (freeze linked animations). |
|
||||
| POST | `/api/v1/sync-clocks/{clock_id}/resume` | Resume a paused clock. |
|
||||
| POST | `/api/v1/sync-clocks/{clock_id}/reset` | Reset the clock to `t=0`. |
|
||||
|
||||
## Webhooks
|
||||
|
||||
Inbound trigger endpoint for external services.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| POST | `/api/v1/webhooks/{token}` | Trigger an automation by secret token (`activate`/`deactivate`; rate-limited 30/min/IP). |
|
||||
|
||||
## HTTP endpoints
|
||||
|
||||
Outbound HTTP polling endpoints for integrations. 🔒 These require a real API key even on loopback.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/http/endpoints` | List all HTTP polling endpoints. |
|
||||
| POST | `/api/v1/http/endpoints` | Create an endpoint (URL, method, auth token, headers). |
|
||||
| GET | `/api/v1/http/endpoints/{endpoint_id}` | Get an endpoint by ID. |
|
||||
| PUT | `/api/v1/http/endpoints/{endpoint_id}` | Update an endpoint. |
|
||||
| DELETE | `/api/v1/http/endpoints/{endpoint_id}` | Delete an endpoint. |
|
||||
| POST | `/api/v1/http/endpoints/test` | One-shot test fetch to validate a config before saving. |
|
||||
| POST | `/api/v1/http/endpoints/{endpoint_id}/test` | Test a stored endpoint without re-entering its token. |
|
||||
|
||||
## Game integration
|
||||
|
||||
Game event ingestion, adapter metadata, presets, and diagnostics.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/game-integrations/presets` | List built-in effect presets. |
|
||||
| GET | `/api/v1/game-integrations` | List all game integration configs. |
|
||||
| POST | `/api/v1/game-integrations` | Create a game integration config. |
|
||||
| GET | `/api/v1/game-integrations/{integration_id}` | Get a config by ID. |
|
||||
| PUT | `/api/v1/game-integrations/{integration_id}` | Update a config. |
|
||||
| DELETE | `/api/v1/game-integrations/{integration_id}` | Delete a config. |
|
||||
| POST | `/api/v1/game-integrations/{integration_id}/event` | Ingest a game event (adapter-level auth; 16–64 Hz). |
|
||||
| GET | `/api/v1/game-integrations/{integration_id}/status` | Runtime status (connected state, event counts). |
|
||||
| GET | `/api/v1/game-integrations/{integration_id}/events` | Recent events for debugging (`limit`). |
|
||||
| GET | `/api/v1/game-adapters` | List adapter types and supported events. |
|
||||
| POST | `/api/v1/game-integrations/{integration_id}/apply-preset` | Apply a built-in preset (optionally replacing mappings). |
|
||||
| POST | `/api/v1/game-integrations/{integration_id}/auto-setup` | Write game config files and generate an auth token. |
|
||||
|
||||
## Home Assistant
|
||||
|
||||
Home Assistant WebSocket sources, entity discovery, and live status.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/home-assistant/sources` | List HA sources with status and entity counts (`?include_secrets=true` 🔒). |
|
||||
| POST | `/api/v1/home-assistant/sources` | Create an HA source (host, long-lived token, filters). |
|
||||
| GET | `/api/v1/home-assistant/sources/{source_id}` | Get an HA source (`?include_secrets=true` 🔒). |
|
||||
| PUT | `/api/v1/home-assistant/sources/{source_id}` | Update an HA source; refreshes the connection. |
|
||||
| DELETE | `/api/v1/home-assistant/sources/{source_id}` | Delete an HA source and release its runtime. |
|
||||
| GET | `/api/v1/home-assistant/sources/{source_id}/entities` | List available HA entities (live + cache fallback). |
|
||||
| POST | `/api/v1/home-assistant/sources/{source_id}/test` | Test connection/auth and report HA version. |
|
||||
| GET | `/api/v1/home-assistant/status` | Overall HA integration status per source. |
|
||||
|
||||
## MQTT sources
|
||||
|
||||
MQTT broker connections (sources) and status monitoring.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/mqtt/sources` | List MQTT sources with connection status. |
|
||||
| POST | `/api/v1/mqtt/sources` | Create an MQTT source (broker connection). |
|
||||
| GET | `/api/v1/mqtt/sources/{source_id}` | Get an MQTT source by ID. |
|
||||
| PUT | `/api/v1/mqtt/sources/{source_id}` | Update a source; restarts the broker runtime. |
|
||||
| DELETE | `/api/v1/mqtt/sources/{source_id}` | Delete a source and release its runtime. |
|
||||
| POST | `/api/v1/mqtt/sources/{source_id}/test` | Test connection to the broker (10s timeout). |
|
||||
| GET | `/api/v1/mqtt/status` | Overall MQTT integration status per source. |
|
||||
|
||||
## Assets
|
||||
|
||||
Media files (sounds, images, videos) used by effects and notifications.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/assets` | List assets (optional `asset_type` filter). |
|
||||
| GET | `/api/v1/assets/{asset_id}` | Get asset metadata by ID. |
|
||||
| POST | `/api/v1/assets` | Upload a new asset file (`multipart/form-data`). |
|
||||
| PUT | `/api/v1/assets/{asset_id}` | Update asset metadata. |
|
||||
| DELETE | `/api/v1/assets/{asset_id}` | Delete an asset (prebuilt assets are soft-deleted/restorable). |
|
||||
| GET | `/api/v1/assets/{asset_id}/file` | Serve the asset file (download). |
|
||||
| POST | `/api/v1/assets/restore-prebuilt` | Re-import any deleted prebuilt assets. |
|
||||
|
||||
## Graph wiring
|
||||
|
||||
The wiring-graph: schema registry, topology, dependents, validation, and subgraph duplication.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/api/v1/graph/schema` | Registry of connectable reference fields. |
|
||||
| GET | `/api/v1/graph` | Full wiring topology (nodes + edges) and validation report. |
|
||||
| GET | `/api/v1/graph/dependents/{kind}/{entity_id}` | Every entity that references `(kind, entity_id)`. |
|
||||
| POST | `/api/v1/graph/validate-connection` | Validate a proposed wiring edit (existence, kind, no cycle). |
|
||||
| POST | `/api/v1/graph/duplicate` | Deep-clone selected value/color-strip sources with remapped wiring. |
|
||||
|
||||
## Web UI & PWA
|
||||
|
||||
App-level routes served by FastAPI (not under `/api/v1`).
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `/` | The web dashboard UI. |
|
||||
| GET | `/manifest.json` | PWA manifest (root scope). |
|
||||
| GET | `/sw.js` | Service worker (root scope). |
|
||||
| GET | `/openapi.json` | OpenAPI schema. |
|
||||
| GET | `/docs` | Swagger UI (interactive API docs). |
|
||||
| GET | `/redoc` | ReDoc API reference. |
|
||||
|
||||
---
|
||||
|
||||
## Calibration
|
||||
## Next steps
|
||||
|
||||
### GET /api/v1/devices/{device_id}/calibration
|
||||
|
||||
Get calibration configuration.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"segments": [
|
||||
{
|
||||
"edge": "bottom",
|
||||
"led_start": 0,
|
||||
"led_count": 40,
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"edge": "right",
|
||||
"led_start": 40,
|
||||
"led_count": 30,
|
||||
"reverse": false
|
||||
},
|
||||
{
|
||||
"edge": "top",
|
||||
"led_start": 70,
|
||||
"led_count": 40,
|
||||
"reverse": true
|
||||
},
|
||||
{
|
||||
"edge": "left",
|
||||
"led_start": 110,
|
||||
"led_count": 40,
|
||||
"reverse": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /api/v1/devices/{device_id}/calibration
|
||||
|
||||
Update calibration.
|
||||
|
||||
**Request:** Same as GET response
|
||||
|
||||
### POST /api/v1/devices/{device_id}/calibration/test
|
||||
|
||||
Test calibration by lighting up specific edge.
|
||||
|
||||
**Query Parameters:**
|
||||
- `edge`: Edge to test (top, right, bottom, left)
|
||||
- `color`: RGB color array (e.g., [255, 0, 0])
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
### GET /api/v1/devices/{device_id}/metrics
|
||||
|
||||
Get detailed processing metrics.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"device_id": "device_abc123",
|
||||
"processing": true,
|
||||
"fps_actual": 29.8,
|
||||
"fps_target": 30,
|
||||
"uptime_seconds": 3600.5,
|
||||
"frames_processed": 107415,
|
||||
"errors_count": 2,
|
||||
"last_error": null,
|
||||
"last_update": "2026-02-06T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints may return error responses in this format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "ErrorType",
|
||||
"message": "Human-readable error message",
|
||||
"detail": {...},
|
||||
"timestamp": "2026-02-06T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Common HTTP Status Codes:**
|
||||
- `200 OK` - Success
|
||||
- `201 Created` - Resource created
|
||||
- `204 No Content` - Success with no response body
|
||||
- `400 Bad Request` - Invalid request
|
||||
- `404 Not Found` - Resource not found
|
||||
- `500 Internal Server Error` - Server error
|
||||
|
||||
---
|
||||
|
||||
## Interactive Documentation
|
||||
|
||||
The server provides interactive API documentation:
|
||||
|
||||
- **Swagger UI:** http://localhost:8080/docs
|
||||
- **ReDoc:** http://localhost:8080/redoc
|
||||
- **OpenAPI JSON:** http://localhost:8080/openapi.json
|
||||
- [Installation Guide](../INSTALLATION.md)
|
||||
- [Calibration Guide](CALIBRATION.md)
|
||||
- Interactive, always-current schemas: [`/docs`](http://localhost:8080/docs)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 358 KiB |
+12
-10
@@ -6,33 +6,35 @@
|
||||
- `src/ledgrab/api/routes/` — REST API endpoints (one file per entity)
|
||||
- `src/ledgrab/api/schemas/` — Pydantic request/response models (one file per entity)
|
||||
- `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/static/` — Frontend files (TypeScript, CSS, locales)
|
||||
- `src/ledgrab/templates/` — Jinja2 HTML templates
|
||||
- `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
|
||||
|
||||
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
|
||||
|
||||
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`
|
||||
- Env var: `LEDGRAB_AUTH__API_KEYS`
|
||||
- When `api_keys` is empty (default), auth is disabled — all endpoints are open
|
||||
- To enable auth, add key entries (e.g. `dev: "your-secret-key"`)
|
||||
- Config: `config/default_config.yaml` under `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 **set**: a valid Bearer token is required from every client (loopback included).
|
||||
- `require_authenticated()` rejects even loopback-anonymous callers on sensitive endpoints (e.g. backup download, secret reveal).
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### 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/`
|
||||
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
|
||||
5. Test via `/docs` (Swagger UI)
|
||||
|
||||
|
||||
@@ -99,6 +99,8 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
||||
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"),
|
||||
@@ -129,6 +131,11 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
||||
)
|
||||
),
|
||||
# ── 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",
|
||||
@@ -168,7 +175,6 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
||||
# ── Output targets ──
|
||||
ConnectionField("output_target", "device_id", "device", "device"),
|
||||
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
||||
ConnectionField("output_target", "picture_source_id", "picture_source", "picture"),
|
||||
ConnectionField(
|
||||
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
|
||||
),
|
||||
@@ -201,6 +207,34 @@ def schema_for_kind(kind: str) -> list[ConnectionField]:
|
||||
return [c for c in CONNECTION_SCHEMA if c.target_kind == kind]
|
||||
|
||||
|
||||
# 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 [
|
||||
@@ -212,6 +246,7 @@ def schema_as_dicts() -> list[dict[str, Any]]:
|
||||
"bindable": c.bindable,
|
||||
"nested": c.nested,
|
||||
"is_list": c.is_list,
|
||||
"editable": is_editable(c),
|
||||
}
|
||||
for c in CONNECTION_SCHEMA
|
||||
]
|
||||
@@ -244,6 +279,54 @@ def extract_refs(entity: dict[str, Any], field_path: str) -> list[str]:
|
||||
return [v for v in current if isinstance(v, str) and v]
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -269,6 +352,32 @@ def serialize_entity(model: Any) -> dict[str, Any]:
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -480,12 +589,11 @@ def validate_connection(
|
||||
)
|
||||
if cf is None:
|
||||
return False, f"Unknown connection field: {target_kind}.{field}"
|
||||
if cf.is_list:
|
||||
# List slots (layers/zones/scene targets) hold many edges sharing the
|
||||
# same (to, field); without an element index this endpoint can't model
|
||||
# which one is being replaced for the cycle check. Edit those via the
|
||||
# entity editor.
|
||||
return False, f"List connection '{field}' must be edited via the entity editor"
|
||||
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:
|
||||
|
||||
@@ -26,9 +26,13 @@ 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,
|
||||
)
|
||||
|
||||
@@ -78,7 +82,7 @@ def _gather_entities() -> dict[str, list[dict[str, Any]]]:
|
||||
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
|
||||
out[kind] = []
|
||||
continue
|
||||
out[kind] = [serialize_entity(m) for m in models]
|
||||
out[kind] = [serialize_entity_for_graph(kind, m) for m in models]
|
||||
return out
|
||||
|
||||
|
||||
@@ -122,3 +126,124 @@ async def validate_graph_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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ledgrab.api.auth import AuthRequired
|
||||
from ledgrab.api.dependencies import (
|
||||
@@ -27,6 +28,8 @@ from ledgrab.api.schemas.value_sources import (
|
||||
StaticColorValueSourceResponse,
|
||||
StaticValueSourceResponse,
|
||||
SystemMetricsValueSourceResponse,
|
||||
TemplateInput,
|
||||
TemplateValueSourceResponse,
|
||||
ValueSourceCreate,
|
||||
ValueSourceListResponse,
|
||||
ValueSourceResponse,
|
||||
@@ -46,6 +49,7 @@ from ledgrab.storage.value_source import (
|
||||
StaticColorValueSource,
|
||||
StaticValueSource,
|
||||
SystemMetricsValueSource,
|
||||
TemplateValueSource,
|
||||
ValueSource,
|
||||
)
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
@@ -170,6 +174,7 @@ _RESPONSE_MAP = {
|
||||
min_ha_value=s.min_ha_value,
|
||||
max_ha_value=s.max_ha_value,
|
||||
smoothing=s.smoothing,
|
||||
normalize=s.normalize,
|
||||
),
|
||||
GradientMapValueSource: lambda s: GradientMapValueSourceResponse(
|
||||
id=s.id,
|
||||
@@ -214,6 +219,7 @@ _RESPONSE_MAP = {
|
||||
sensor_label=s.sensor_label,
|
||||
poll_interval=s.poll_interval,
|
||||
smoothing=s.smoothing,
|
||||
normalize=s.normalize,
|
||||
),
|
||||
HTTPValueSource: lambda s: HTTPValueSourceResponse(
|
||||
id=s.id,
|
||||
@@ -230,6 +236,23 @@ _RESPONSE_MAP = {
|
||||
min_value=s.min_value,
|
||||
max_value=s.max_value,
|
||||
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:
|
||||
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)
|
||||
fire_entity_event("value_source", "deleted", source_id)
|
||||
except EntityNotFoundError as e:
|
||||
@@ -404,6 +434,121 @@ async def delete_value_source(
|
||||
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 =====
|
||||
|
||||
|
||||
@@ -467,10 +612,22 @@ async def test_value_source_ws(
|
||||
msg["input_value"] = round(stream.get_input_value(), 4)
|
||||
if hasattr(stream, "get_raw_value"):
|
||||
raw = stream.get_raw_value()
|
||||
if raw is not None:
|
||||
msg["raw_value"] = round(raw, 4)
|
||||
if hasattr(stream, "_min_ha"):
|
||||
msg["raw_range"] = [stream._min_ha, stream._max_ha]
|
||||
if isinstance(raw, bool):
|
||||
# bool is a subclass of int — send as-is (don't coerce/round).
|
||||
msg["raw_value"] = raw
|
||||
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 asyncio.sleep(0.05)
|
||||
except WebSocketDisconnect:
|
||||
|
||||
@@ -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):
|
||||
"""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")
|
||||
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)")
|
||||
normalize: bool = Field(
|
||||
description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
|
||||
)
|
||||
|
||||
|
||||
class GradientMapValueSourceResponse(_ValueSourceResponseBase):
|
||||
@@ -149,6 +163,9 @@ class SystemMetricsValueSourceResponse(_ValueSourceResponseBase):
|
||||
sensor_label: str = Field(description="Sensor label for cpu_temp/fan_speed")
|
||||
poll_interval: float = Field(description="Seconds between reads")
|
||||
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):
|
||||
@@ -160,6 +177,22 @@ class HTTPValueSourceResponse(_ValueSourceResponseBase):
|
||||
min_value: float = Field(description="Raw value mapped to output 0.0")
|
||||
max_value: float = Field(description="Raw value mapped to output 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[
|
||||
@@ -176,7 +209,8 @@ ValueSourceResponse = Annotated[
|
||||
| Annotated[GradientMapValueSourceResponse, Tag("gradient_map")]
|
||||
| Annotated[CSSExtractValueSourceResponse, Tag("css_extract")]
|
||||
| Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")]
|
||||
| Annotated[HTTPValueSourceResponse, Tag("http")],
|
||||
| Annotated[HTTPValueSourceResponse, Tag("http")]
|
||||
| Annotated[TemplateValueSourceResponse, Tag("template")],
|
||||
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")
|
||||
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)
|
||||
normalize: bool = Field(
|
||||
True, description="Rescale raw value to [0,1] via min/max; false clamps the raw value as-is"
|
||||
)
|
||||
|
||||
|
||||
class GradientMapValueSourceCreate(_ValueSourceCreateBase):
|
||||
@@ -318,6 +355,9 @@ class SystemMetricsValueSourceCreate(_ValueSourceCreateBase):
|
||||
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)
|
||||
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):
|
||||
@@ -328,6 +368,30 @@ class HTTPValueSourceCreate(_ValueSourceCreateBase):
|
||||
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")
|
||||
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[
|
||||
@@ -344,7 +408,8 @@ ValueSourceCreate = Annotated[
|
||||
| Annotated[GradientMapValueSourceCreate, Tag("gradient_map")]
|
||||
| Annotated[CSSExtractValueSourceCreate, Tag("css_extract")]
|
||||
| Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")]
|
||||
| Annotated[HTTPValueSourceCreate, Tag("http")],
|
||||
| Annotated[HTTPValueSourceCreate, Tag("http")]
|
||||
| Annotated[TemplateValueSourceCreate, Tag("template")],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
@@ -452,6 +517,9 @@ class HAEntityValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
min_ha_value: float | None = Field(None, description="Min 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)
|
||||
normalize: bool | None = Field(
|
||||
None, description="Rescale raw via min/max (false = clamp as-is)"
|
||||
)
|
||||
|
||||
|
||||
class GradientMapValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
@@ -478,6 +546,9 @@ class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
sensor_label: str | None = Field(None, description="Sensor label")
|
||||
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)
|
||||
normalize: bool | None = Field(
|
||||
None, description="Rescale raw via min/max (false = clamp as-is)"
|
||||
)
|
||||
|
||||
|
||||
class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
@@ -488,6 +559,23 @@ class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
|
||||
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")
|
||||
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[
|
||||
@@ -504,7 +592,8 @@ ValueSourceUpdate = Annotated[
|
||||
| Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")]
|
||||
| Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")]
|
||||
| Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")]
|
||||
| Annotated[HTTPValueSourceUpdate, Tag("http")],
|
||||
| Annotated[HTTPValueSourceUpdate, Tag("http")]
|
||||
| Annotated[TemplateValueSourceUpdate, Tag("template")],
|
||||
Discriminator("source_type"),
|
||||
]
|
||||
|
||||
|
||||
@@ -38,6 +38,19 @@ try:
|
||||
except ImportError:
|
||||
_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
|
||||
|
||||
# Auto-register available engines
|
||||
@@ -45,6 +58,8 @@ if _has_wasapi:
|
||||
AudioEngineRegistry.register(WasapiEngine)
|
||||
if _has_sounddevice:
|
||||
AudioEngineRegistry.register(SounddeviceEngine)
|
||||
if _has_android_audio:
|
||||
AudioEngineRegistry.register(AndroidAudioEngine)
|
||||
AudioEngineRegistry.register(DemoAudioEngine)
|
||||
|
||||
__all__ = [
|
||||
@@ -65,3 +80,5 @@ if _has_wasapi:
|
||||
__all__ += ["WasapiEngine", "WasapiCaptureStream"]
|
||||
if _has_sounddevice:
|
||||
__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)
|
||||
@@ -40,6 +40,11 @@ _AS_IDS = {
|
||||
"system": "as_demo0001",
|
||||
}
|
||||
|
||||
_VS_IDS = {
|
||||
"level": "vs_demo0001",
|
||||
"boost": "vs_demo0002",
|
||||
}
|
||||
|
||||
_TPL_ID = "tpl_demo0001"
|
||||
|
||||
_SCENE_ID = "scene_demo0001"
|
||||
@@ -86,6 +91,7 @@ def seed_demo_data(db: Database) -> None:
|
||||
_insert_entities(db, "picture_sources", _build_picture_sources())
|
||||
_insert_entities(db, "color_strip_sources", _build_color_strip_sources())
|
||||
_insert_entities(db, "audio_sources", _build_audio_sources())
|
||||
_insert_entities(db, "value_sources", _build_value_sources())
|
||||
_insert_entities(db, "scene_presets", _build_scene_presets())
|
||||
|
||||
logger.info("Demo seed data complete")
|
||||
@@ -334,6 +340,40 @@ def _build_audio_sources() -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── Value Sources ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_value_sources() -> dict:
|
||||
"""A static float source plus a template combinator that references it,
|
||||
so demo mode showcases the Jinja template value source out of the box."""
|
||||
return {
|
||||
_VS_IDS["level"]: {
|
||||
"id": _VS_IDS["level"],
|
||||
"name": "Base Level",
|
||||
"source_type": "static",
|
||||
"description": "A constant brightness level (demo input for the template below)",
|
||||
"tags": ["demo"],
|
||||
"value": 0.5,
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
_VS_IDS["boost"]: {
|
||||
"id": _VS_IDS["boost"],
|
||||
"name": "Boosted Level (template)",
|
||||
"source_type": "template",
|
||||
"return_type": "float",
|
||||
"description": "Jinja combinator: clamps 1.5x the Base Level into [0,1]",
|
||||
"tags": ["demo"],
|
||||
"template": "clamp(level * 1.5)",
|
||||
"inputs": [{"name": "level", "value_source_id": _VS_IDS["level"]}],
|
||||
"default_value": 0.0,
|
||||
"eval_interval": None,
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Scene Presets ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import numpy as np
|
||||
|
||||
from ledgrab.core.processing.color_strip_stream import ColorStripStream
|
||||
from ledgrab.storage.bindable import bfloat
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils import clamp01, get_logger
|
||||
from ledgrab.utils.frame_limiter import FrameLimiter
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -176,7 +176,7 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
if i in self._brightness_streams:
|
||||
_vs_id, vs = self._brightness_streams[i]
|
||||
try:
|
||||
result.append(vs.get_value())
|
||||
result.append(clamp01(vs.get_value()))
|
||||
except Exception:
|
||||
result.append(None)
|
||||
else:
|
||||
@@ -660,10 +660,14 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
if layer.get("reverse", False):
|
||||
colors = colors[::-1].copy()
|
||||
|
||||
# Apply per-layer brightness from value source
|
||||
# Apply per-layer brightness from value source.
|
||||
# clamp01 is finite-safe: it rejects nan/inf (which would
|
||||
# crash the int() cast) and pins out-of-range values into
|
||||
# [0,1] so the uint16 fixed-point multiply can't wrap on a
|
||||
# negative. bri == 1.0 correctly skips the scale (no-op).
|
||||
if i in self._brightness_streams:
|
||||
_vs_id, vs = self._brightness_streams[i]
|
||||
bri = vs.get_value()
|
||||
bri = clamp01(vs.get_value())
|
||||
if bri < 1.0:
|
||||
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(
|
||||
np.uint8
|
||||
|
||||
@@ -193,6 +193,7 @@ def _build_ha_entity(source, d: ValueStreamDeps):
|
||||
min_ha_value=source.min_ha_value,
|
||||
max_ha_value=source.max_ha_value,
|
||||
smoothing=source.smoothing,
|
||||
normalize=source.normalize,
|
||||
ha_manager=d.ha_manager,
|
||||
)
|
||||
|
||||
@@ -232,6 +233,7 @@ def _build_system_metrics(source, _d: ValueStreamDeps):
|
||||
sensor_label=source.sensor_label,
|
||||
poll_interval=source.poll_interval,
|
||||
smoothing=source.smoothing,
|
||||
normalize=source.normalize,
|
||||
)
|
||||
|
||||
|
||||
@@ -249,6 +251,7 @@ def _build_game_event(source, d: ValueStreamDeps):
|
||||
smoothing=source.smoothing,
|
||||
default_value=source.default_value,
|
||||
timeout=source.timeout,
|
||||
normalize=source.normalize,
|
||||
event_bus=d.event_bus,
|
||||
)
|
||||
|
||||
@@ -263,10 +266,25 @@ def _build_http(source, d: ValueStreamDeps):
|
||||
min_value=source.min_value,
|
||||
max_value=source.max_value,
|
||||
smoothing=source.smoothing,
|
||||
normalize=source.normalize,
|
||||
http_endpoint_store=d.http_endpoint_store,
|
||||
)
|
||||
|
||||
|
||||
def _build_template(source, d: ValueStreamDeps):
|
||||
# References other value sources via d.value_stream_manager (recursively
|
||||
# acquired in start()), exactly like _build_gradient_map.
|
||||
from ledgrab.core.processing.value_stream import TemplateValueStream
|
||||
|
||||
return TemplateValueStream(
|
||||
template=source.template,
|
||||
inputs=source.inputs,
|
||||
default_value=source.default_value,
|
||||
eval_interval=source.eval_interval,
|
||||
value_stream_manager=d.value_stream_manager,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -290,6 +308,7 @@ STREAM_BUILDERS: dict[str, StreamBuilder] = {
|
||||
"system_metrics": _build_system_metrics,
|
||||
"game_event": _build_game_event,
|
||||
"http": _build_http,
|
||||
"template": _build_template,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,12 @@ import numpy as np
|
||||
|
||||
from ledgrab.core.processing import metric_readers as _metric_readers
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils import clamp01, get_logger
|
||||
from ledgrab.utils.template_expr import (
|
||||
TemplateValidationError,
|
||||
compile_template,
|
||||
finalize_result,
|
||||
)
|
||||
|
||||
# Compiled once — used by ``_extract_simple_path`` on every poll.
|
||||
_NAME_HEAD_RE = re.compile(r"^([^\[]*)")
|
||||
@@ -53,6 +58,12 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Runtime cap on recursive value-stream acquisition (referencing sources like
|
||||
# template / gradient_map re-enter acquire() from start()). Higher than the
|
||||
# storage-level MAX_VALUE_SOURCE_DEPTH (8) so legitimate chains never trip it;
|
||||
# it only fires on a cycle that bypassed storage validation.
|
||||
_MAX_ACQUIRE_DEPTH = 12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base class
|
||||
@@ -904,6 +915,7 @@ class HAEntityValueStream(ValueStream):
|
||||
min_ha_value: float = 0.0,
|
||||
max_ha_value: float = 100.0,
|
||||
smoothing: float = 0.0,
|
||||
normalize: bool = True,
|
||||
ha_manager: Any | None = None,
|
||||
):
|
||||
self._ha_source_id = ha_source_id
|
||||
@@ -912,6 +924,7 @@ class HAEntityValueStream(ValueStream):
|
||||
self._min_ha = min_ha_value
|
||||
self._max_ha = max_ha_value
|
||||
self._smoothing = smoothing
|
||||
self._normalize_enabled = normalize
|
||||
self._ha_manager = ha_manager
|
||||
self._prev_value: float | None = None
|
||||
self._raw_value: float | None = None
|
||||
@@ -976,16 +989,23 @@ class HAEntityValueStream(ValueStream):
|
||||
|
||||
self._raw_value = raw
|
||||
|
||||
# Normalize to [0, 1]
|
||||
ha_range = self._max_ha - self._min_ha
|
||||
if abs(ha_range) < 1e-9:
|
||||
normalized = 0.5
|
||||
if self._normalize_enabled:
|
||||
# Normalize to [0, 1] via the configured min/max range.
|
||||
ha_range = self._max_ha - self._min_ha
|
||||
if abs(ha_range) < 1e-9:
|
||||
normalized = 0.5
|
||||
else:
|
||||
normalized = (raw - self._min_ha) / ha_range
|
||||
normalized = max(0.0, min(1.0, normalized))
|
||||
else:
|
||||
normalized = (raw - self._min_ha) / ha_range
|
||||
# Skip the rescale: treat the raw reading as already a 0–1 fraction
|
||||
# (finite-safe clamp). The un-clamped magnitude stays on
|
||||
# get_raw_value(); get_value() never leaves [0, 1] either way.
|
||||
normalized = clamp01(raw)
|
||||
|
||||
normalized = max(0.0, min(1.0, normalized))
|
||||
|
||||
# EMA smoothing
|
||||
# EMA smoothing — both branches produce a [0, 1] value, so _prev_value
|
||||
# is always normalized and flipping ``normalize`` live never blends a
|
||||
# raw magnitude against a fraction.
|
||||
if self._smoothing > 0.0 and self._prev_value is not None:
|
||||
normalized = self._smoothing * self._prev_value + (1.0 - self._smoothing) * normalized
|
||||
|
||||
@@ -1009,6 +1029,7 @@ class HAEntityValueStream(ValueStream):
|
||||
self._min_ha = source.min_ha_value
|
||||
self._max_ha = source.max_ha_value
|
||||
self._smoothing = source.smoothing
|
||||
self._normalize_enabled = source.normalize
|
||||
|
||||
# If HA source changed, swap runtime
|
||||
if source.ha_source_id != old_ha_source and self._ha_manager:
|
||||
@@ -1052,6 +1073,7 @@ class HTTPValueStream(ValueStream):
|
||||
min_value: float,
|
||||
max_value: float,
|
||||
smoothing: float,
|
||||
normalize: bool = True,
|
||||
http_endpoint_store: "HTTPEndpointStore" | None = None,
|
||||
) -> None:
|
||||
self._endpoint_id = endpoint_id
|
||||
@@ -1060,6 +1082,7 @@ class HTTPValueStream(ValueStream):
|
||||
self._min_value = min_value
|
||||
self._max_value = max_value
|
||||
self._smoothing = smoothing
|
||||
self._normalize_enabled = normalize
|
||||
self._http_endpoint_store = http_endpoint_store
|
||||
self._task: asyncio.Task | None = None
|
||||
self._raw_value: Any = None
|
||||
@@ -1099,12 +1122,19 @@ class HTTPValueStream(ValueStream):
|
||||
except (TypeError, ValueError):
|
||||
return self._prev_normalized if self._prev_normalized is not None else 0.0
|
||||
|
||||
rng = self._max_value - self._min_value
|
||||
if abs(rng) < 1e-9:
|
||||
normalized = 0.5
|
||||
if self._normalize_enabled:
|
||||
rng = self._max_value - self._min_value
|
||||
if abs(rng) < 1e-9:
|
||||
normalized = 0.5
|
||||
else:
|
||||
normalized = (numeric - self._min_value) / rng
|
||||
normalized = max(0.0, min(1.0, normalized))
|
||||
else:
|
||||
normalized = (numeric - self._min_value) / rng
|
||||
normalized = max(0.0, min(1.0, normalized))
|
||||
# Skip the rescale: treat the extracted number as already a 0–1
|
||||
# fraction (finite-safe clamp). The verbatim extracted value (which
|
||||
# may be non-numeric) stays on get_raw_value(); get_value() is always
|
||||
# a float in [0, 1].
|
||||
normalized = clamp01(numeric)
|
||||
|
||||
if self._smoothing > 0.0 and self._prev_normalized is not None:
|
||||
normalized = (
|
||||
@@ -1128,6 +1158,7 @@ class HTTPValueStream(ValueStream):
|
||||
self._min_value = source.min_value
|
||||
self._max_value = source.max_value
|
||||
self._smoothing = source.smoothing
|
||||
self._normalize_enabled = source.normalize
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
from ledgrab.utils.safe_source import safe_request_bounded
|
||||
@@ -1365,6 +1396,168 @@ class GradientMapValueStream(ValueStream):
|
||||
self._inner_stream = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template (Jinja expression combinator)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TemplateValueStream(ValueStream):
|
||||
"""Evaluates a hardened sandboxed-Jinja expression over the live values of
|
||||
other value sources (the system's float combinator).
|
||||
|
||||
Acquires each referenced input stream from the manager on ``start()`` and
|
||||
releases it on ``stop()`` — the same ref-counted protocol as
|
||||
:class:`GradientMapValueStream`, but over a *set* of inputs. Acquisition is
|
||||
tracked per unique ``value_source_id`` so two variables bound to the same
|
||||
source share one ref. ``get_value()`` builds a primitives-only context
|
||||
(each input's normalized ``get_value()`` plus a float-only ``raw`` dict),
|
||||
evaluates the compiled expression, then coerces / NaN-guards / clamps the
|
||||
result. Any error — or an uncompilable template — falls back to
|
||||
``default_value``. An optional ``eval_interval`` caches the last result to
|
||||
bound steady-state evaluation cost.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template: str,
|
||||
inputs: List[dict],
|
||||
default_value: float = 0.0,
|
||||
eval_interval: float | None = None,
|
||||
value_stream_manager: "ValueStreamManager" | None = None,
|
||||
):
|
||||
self._template = template
|
||||
self._inputs = [dict(i) for i in (inputs or [])]
|
||||
self._default = max(0.0, min(1.0, float(default_value)))
|
||||
self._eval_interval = float(eval_interval) if eval_interval else 0.0
|
||||
self._vsm = value_stream_manager
|
||||
self._streams_by_id: Dict[str, ValueStream] = {} # value_source_id -> stream
|
||||
self._expr = self._compile(template)
|
||||
self._last_value: float = self._default
|
||||
self._last_eval: float = 0.0
|
||||
self._has_value = False
|
||||
self._error_logged = False
|
||||
|
||||
@staticmethod
|
||||
def _compile(template: str):
|
||||
"""Compile once; return ``None`` (→ always default) on invalid template.
|
||||
|
||||
Creation should already have rejected invalid templates via the factory;
|
||||
this is defense in depth so a bad row never crashes the engine.
|
||||
"""
|
||||
try:
|
||||
return compile_template(template)
|
||||
except TemplateValidationError as e:
|
||||
logger.warning("TemplateValueStream: invalid template, using default (%s)", e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _unique_ids(inputs: List[dict]) -> set:
|
||||
return {i["value_source_id"] for i in inputs if i.get("value_source_id")}
|
||||
|
||||
def start(self) -> None:
|
||||
if not self._vsm:
|
||||
return
|
||||
for vs_id in self._unique_ids(self._inputs):
|
||||
try:
|
||||
self._streams_by_id[vs_id] = self._vsm.acquire(vs_id)
|
||||
except Exception as e:
|
||||
logger.warning("TemplateValueStream: failed to acquire input %s: %s", vs_id, e)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._vsm:
|
||||
for vs_id in list(self._streams_by_id):
|
||||
try:
|
||||
self._vsm.release(vs_id)
|
||||
except Exception as e:
|
||||
logger.debug("TemplateValueStream: release %s failed: %s", vs_id, e)
|
||||
self._streams_by_id.clear()
|
||||
self._has_value = False
|
||||
|
||||
def get_value(self) -> float:
|
||||
if self._expr is None:
|
||||
return self._default
|
||||
|
||||
if (
|
||||
self._eval_interval > 0.0
|
||||
and self._has_value
|
||||
and (time.monotonic() - self._last_eval) < self._eval_interval
|
||||
):
|
||||
return self._last_value
|
||||
|
||||
try:
|
||||
ctx: Dict[str, Any] = {}
|
||||
raw: Dict[str, float] = {}
|
||||
for inp in self._inputs:
|
||||
name = inp.get("name")
|
||||
vs_id = inp.get("value_source_id")
|
||||
if not name or not vs_id:
|
||||
continue
|
||||
stream = self._streams_by_id.get(vs_id)
|
||||
if stream is None:
|
||||
continue
|
||||
ctx[name] = float(stream.get_value())
|
||||
getter = getattr(stream, "get_raw_value", None)
|
||||
if getter is not None:
|
||||
rv = getter()
|
||||
if rv is not None:
|
||||
try:
|
||||
raw[name] = float(rv)
|
||||
except (TypeError, ValueError):
|
||||
# Non-numeric raw values never cross into the sandbox.
|
||||
pass
|
||||
ctx["raw"] = raw
|
||||
# Globals (min/max/abs/round/clamp) resolve from SANDBOX_ENV.globals.
|
||||
value = finalize_result(self._expr(**ctx), self._default)
|
||||
except Exception as e:
|
||||
if not self._error_logged:
|
||||
logger.warning("TemplateValueStream eval error (using default): %s", e)
|
||||
self._error_logged = True
|
||||
value = self._default
|
||||
|
||||
self._last_value = value
|
||||
self._last_eval = time.monotonic()
|
||||
self._has_value = True
|
||||
return value
|
||||
|
||||
def update_source(self, source: "ValueSource") -> None:
|
||||
from ledgrab.storage.value_source import TemplateValueSource
|
||||
|
||||
if not isinstance(source, TemplateValueSource):
|
||||
return
|
||||
|
||||
if source.template != self._template:
|
||||
self._template = source.template
|
||||
self._expr = self._compile(source.template)
|
||||
self._error_logged = False
|
||||
|
||||
self._default = max(0.0, min(1.0, float(source.default_value)))
|
||||
self._eval_interval = float(source.eval_interval) if source.eval_interval else 0.0
|
||||
|
||||
new_inputs = [dict(i) for i in (source.inputs or [])]
|
||||
old_ids = set(self._streams_by_id)
|
||||
new_ids = self._unique_ids(new_inputs)
|
||||
|
||||
if self._vsm:
|
||||
# Release-before-acquire (mirrors GradientMapValueStream); safe under
|
||||
# ref-counting. Unchanged ids keep their existing stream untouched.
|
||||
for vs_id in old_ids - new_ids:
|
||||
try:
|
||||
self._vsm.release(vs_id)
|
||||
except Exception as e:
|
||||
logger.debug("TemplateValueStream: release %s failed: %s", vs_id, e)
|
||||
self._streams_by_id.pop(vs_id, None)
|
||||
for vs_id in new_ids - old_ids:
|
||||
try:
|
||||
self._streams_by_id[vs_id] = self._vsm.acquire(vs_id)
|
||||
except Exception as e:
|
||||
logger.warning("TemplateValueStream: acquire %s failed: %s", vs_id, e)
|
||||
|
||||
# Rebuild inputs (re-keys variable names on rename even when id unchanged,
|
||||
# since get_value() maps name -> stream via value_source_id each tick).
|
||||
self._inputs = new_inputs
|
||||
self._has_value = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS Extract
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1501,6 +1694,7 @@ class SystemMetricsValueStream(ValueStream):
|
||||
sensor_label: str = "",
|
||||
poll_interval: float = 1.0,
|
||||
smoothing: float = 0.0,
|
||||
normalize: bool = True,
|
||||
):
|
||||
self._metric = metric
|
||||
self._min_val = min_value
|
||||
@@ -1510,6 +1704,7 @@ class SystemMetricsValueStream(ValueStream):
|
||||
self._sensor_label = sensor_label
|
||||
self._poll_interval = max(0.1, poll_interval)
|
||||
self._smoothing = smoothing
|
||||
self._normalize_enabled = normalize
|
||||
self._prev_value: float | None = None
|
||||
self._raw_value: float | None = None
|
||||
self._last_poll: float = 0.0
|
||||
@@ -1548,10 +1743,16 @@ class SystemMetricsValueStream(ValueStream):
|
||||
raw = self._read_metric()
|
||||
self._raw_value = raw
|
||||
|
||||
# Normalize
|
||||
normalized = self._normalize(raw)
|
||||
if self._normalize_enabled:
|
||||
normalized = self._normalize(raw)
|
||||
else:
|
||||
# Skip the rescale: treat the raw reading as already a 0–1 fraction
|
||||
# (finite-safe clamp). The un-clamped reading stays on
|
||||
# get_raw_value(); get_value() never leaves [0, 1].
|
||||
normalized = clamp01(raw)
|
||||
|
||||
# EMA smoothing
|
||||
# EMA smoothing — both branches output [0, 1], so _prev_value is always
|
||||
# normalized and a live ``normalize`` flip never blends raw vs fraction.
|
||||
if self._smoothing > 0.0 and self._prev_value is not None:
|
||||
normalized = self._smoothing * self._prev_value + (1.0 - self._smoothing) * normalized
|
||||
|
||||
@@ -1599,6 +1800,7 @@ class SystemMetricsValueStream(ValueStream):
|
||||
self._sensor_label = source.sensor_label
|
||||
self._poll_interval = max(0.1, source.poll_interval)
|
||||
self._smoothing = source.smoothing
|
||||
self._normalize_enabled = source.normalize
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1644,6 +1846,10 @@ class ValueStreamManager:
|
||||
self._http_endpoint_store = http_endpoint_store
|
||||
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
|
||||
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
|
||||
# Recursion-depth backstop for referencing sources (template / gradient
|
||||
# map). A cycle that slipped past storage validation (e.g. a hand-edited
|
||||
# DB or restored backup) would otherwise overflow the stack at acquire().
|
||||
self._acquire_depth = 0
|
||||
# Tracks which clock_id (if any) was acquired for each stream so we
|
||||
# can release/swap it without re-querying the store at teardown time.
|
||||
self._stream_clock_ids: Dict[str, str] = {} # vs_id → clock_id
|
||||
@@ -1659,9 +1865,29 @@ class ValueStreamManager:
|
||||
logger.info(f"Shared value stream {vs_id} (refs={self._ref_counts[vs_id]})")
|
||||
return self._streams[vs_id]
|
||||
|
||||
if self._acquire_depth >= _MAX_ACQUIRE_DEPTH:
|
||||
logger.warning(
|
||||
"Value source acquire depth limit (%d) reached at %s; returning "
|
||||
"static fallback (possible reference cycle)",
|
||||
_MAX_ACQUIRE_DEPTH,
|
||||
vs_id,
|
||||
)
|
||||
# The intermediate referencing streams built while descending a
|
||||
# cyclic chain are not stop()'d here — but this only triggers on a
|
||||
# stored cycle that storage validation already rejects (e.g. a
|
||||
# hand-edited DB / corrupt restore), so those transient objects are
|
||||
# simply garbage-collected. Normal graphs never reach this depth.
|
||||
return StaticValueStream(0.5)
|
||||
|
||||
source = self._value_source_store.get_source(vs_id)
|
||||
stream = self._create_stream(source, vs_id)
|
||||
stream.start()
|
||||
# Increment around create+start: a referencing stream (template /
|
||||
# gradient_map) re-enters acquire() from its own start().
|
||||
self._acquire_depth += 1
|
||||
try:
|
||||
stream = self._create_stream(source, vs_id)
|
||||
stream.start()
|
||||
finally:
|
||||
self._acquire_depth -= 1
|
||||
self._streams[vs_id] = stream
|
||||
self._ref_counts[vs_id] = 1
|
||||
logger.info(f"Acquired value stream {vs_id} (type={source.source_type})")
|
||||
|
||||
@@ -15,7 +15,7 @@ import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ledgrab.core.processing.value_stream import ValueStream
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils import clamp01, get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||
@@ -41,6 +41,7 @@ class GameEventValueStream(ValueStream):
|
||||
smoothing: float = 0.0,
|
||||
default_value: float = 0.5,
|
||||
timeout: float = 5.0,
|
||||
normalize: bool = True,
|
||||
event_bus: "GameEventBus" | None = None,
|
||||
) -> None:
|
||||
self._event_type = event_type
|
||||
@@ -49,10 +50,15 @@ class GameEventValueStream(ValueStream):
|
||||
self._smoothing = max(0.0, min(1.0, smoothing))
|
||||
self._default_value = max(0.0, min(1.0, default_value))
|
||||
self._timeout = max(0.0, timeout)
|
||||
# When False, skip the min/max rescale: the [0,1] output clamps the raw
|
||||
# value as-is. get_value() stays in [0,1] either way; the un-clamped
|
||||
# value is exposed via get_raw_value() for templates/automations.
|
||||
self._normalize_enabled = normalize
|
||||
self._event_bus = event_bus
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._current_value: float = self._default_value
|
||||
self._current_raw: float | None = None
|
||||
self._last_event_time: float | None = None
|
||||
self._subscription_id: str | None = None
|
||||
self._has_received_event: bool = False
|
||||
@@ -82,11 +88,18 @@ class GameEventValueStream(ValueStream):
|
||||
self._subscription_id = None
|
||||
with self._lock:
|
||||
self._current_value = self._default_value
|
||||
self._current_raw = None
|
||||
self._last_event_time = None
|
||||
self._has_received_event = False
|
||||
|
||||
def get_value(self) -> float:
|
||||
"""Return current normalized value (0.0-1.0), or default if timed out."""
|
||||
"""Return current value in [0,1], or default_value if timed out.
|
||||
|
||||
Always in [0,1]: ``_current_value`` holds the smoothed output computed
|
||||
at event time under the active ``normalize`` mode (rescaled, or the raw
|
||||
value clamped into [0,1]). ``default_value`` is itself in [0,1], so the
|
||||
timeout fallback is valid in both modes.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._has_received_event:
|
||||
return self._default_value
|
||||
@@ -98,6 +111,15 @@ class GameEventValueStream(ValueStream):
|
||||
|
||||
return self._current_value
|
||||
|
||||
def get_raw_value(self) -> float | None:
|
||||
"""Return the last raw game value before normalization.
|
||||
|
||||
``None`` until the first event arrives (mirrors HA/HTTP/SystemMetrics).
|
||||
Exposes the un-clamped magnitude to template ``raw[]`` and automations.
|
||||
"""
|
||||
with self._lock:
|
||||
return self._current_raw
|
||||
|
||||
def get_color(self) -> tuple:
|
||||
"""Game event value source only provides scalars, not colors."""
|
||||
raise NotImplementedError("GameEventValueStream does not produce colors")
|
||||
@@ -115,6 +137,7 @@ class GameEventValueStream(ValueStream):
|
||||
self._smoothing = max(0.0, min(1.0, source.smoothing))
|
||||
self._default_value = max(0.0, min(1.0, source.default_value))
|
||||
self._timeout = max(0.0, source.timeout)
|
||||
self._normalize_enabled = source.normalize
|
||||
|
||||
def _on_event(self, event: "GameEvent") -> None:
|
||||
"""EventBus callback — normalize and apply smoothing.
|
||||
@@ -122,14 +145,17 @@ class GameEventValueStream(ValueStream):
|
||||
Called from the publisher's thread; must be thread-safe.
|
||||
"""
|
||||
raw_value = event.value
|
||||
normalized = self._normalize(raw_value)
|
||||
# Output is always in [0,1]: rescale via min/max, or (normalize off)
|
||||
# clamp the raw value as-is. The un-clamped raw is kept for get_raw_value().
|
||||
out = self._normalize(raw_value) if self._normalize_enabled else clamp01(raw_value)
|
||||
|
||||
with self._lock:
|
||||
self._current_raw = raw_value
|
||||
if self._smoothing > 0.0 and self._has_received_event:
|
||||
alpha = 1.0 - self._smoothing
|
||||
normalized = alpha * normalized + self._smoothing * self._current_value
|
||||
out = alpha * out + self._smoothing * self._current_value
|
||||
|
||||
self._current_value = normalized
|
||||
self._current_value = out
|
||||
self._last_event_time = time.monotonic()
|
||||
self._has_received_event = True
|
||||
|
||||
|
||||
@@ -298,6 +298,214 @@ select.field-invalid {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.field-ok-msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--success-color);
|
||||
font-size: 0.78rem;
|
||||
margin-top: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.field-ok-msg .icon { width: 14px; height: 14px; }
|
||||
.field-error-msg .icon { width: 14px; height: 14px; vertical-align: -2px; margin-right: 3px; }
|
||||
|
||||
.field-warn-msg {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
margin-top: 4px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* ── Jinja expression editor ─────────────────────────────────────
|
||||
A transparent <textarea> layered over a synced highlight <pre>.
|
||||
Both share identical type metrics so the colour layer aligns with
|
||||
the typed glyphs. The shared box rules below MUST stay in sync. */
|
||||
.jinja-editor {
|
||||
position: relative;
|
||||
display: block;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2, color-mix(in srgb, var(--text-color) 6%, var(--bg-color)));
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.jinja-editor:focus-within {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 18%, transparent);
|
||||
}
|
||||
|
||||
.jinja-editor.field-invalid {
|
||||
border-color: var(--danger-color);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger-color) 15%, transparent);
|
||||
}
|
||||
|
||||
/* Shared metrics — applied identically to both layers. */
|
||||
.jinja-hl,
|
||||
.jinja-input {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.55;
|
||||
letter-spacing: 0;
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.jinja-hl {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
color: var(--text-color);
|
||||
overflow: hidden; /* scroll is mirrored from the textarea */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.jinja-input {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 4.6em; /* ~3 lines */
|
||||
resize: vertical;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: transparent; /* glyphs are painted by .jinja-hl underneath */
|
||||
caret-color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The global `textarea:focus` rule (higher specificity than `.jinja-input`)
|
||||
sets an opaque background; on this overlay editor that would cover the
|
||||
`.jinja-hl` highlight layer and hide the transparent glyphs on focus. Keep
|
||||
the textarea fully transparent — the focus ring is drawn by the wrapper's
|
||||
`.jinja-editor:focus-within`. */
|
||||
.jinja-input:focus {
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.jinja-input::selection { background: color-mix(in srgb, var(--primary-color) 30%, transparent); }
|
||||
|
||||
/* Token palette — restrained, three accents plus muted operators. */
|
||||
.jinja-hl .tok-str { color: var(--success-color); }
|
||||
.jinja-hl .tok-num { color: #d19a66; }
|
||||
.jinja-hl .tok-fn { color: var(--primary-color); font-weight: 600; }
|
||||
.jinja-hl .tok-raw { color: #c678dd; font-style: italic; }
|
||||
.jinja-hl .tok-var { color: #61afef; }
|
||||
.jinja-hl .tok-op { color: var(--text-muted); }
|
||||
|
||||
/* ── Template-input rows ─────────────────────────────────────── */
|
||||
.template-inputs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.template-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(96px, 0.42fr) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-input-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.template-input-row .entity-select-trigger { width: 100%; }
|
||||
|
||||
.template-inputs-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
/* ── Expression hints panel ──────────────────────────────────── */
|
||||
.jinja-hints {
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--text-color) 3%, transparent);
|
||||
}
|
||||
|
||||
.jinja-hints > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.jinja-hints > summary::-webkit-details-marker { display: none; }
|
||||
.jinja-hints > summary::before {
|
||||
content: '›';
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
transition: transform 0.15s ease;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.jinja-hints[open] > summary::before { transform: rotate(90deg); }
|
||||
|
||||
.jinja-hints-body {
|
||||
padding: 4px 14px 12px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.jinja-hints-body code,
|
||||
.jinja-hints-body .jinja-hints-vars code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--text-color) 8%, transparent);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.jinja-hints-section { margin-top: 8px; }
|
||||
.jinja-hints-section-title {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.jinja-hints-vars {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.jinja-hints-vars .tok-var-chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.76rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, #61afef 16%, transparent);
|
||||
color: #61afef;
|
||||
}
|
||||
|
||||
.jinja-hints-examples { margin: 4px 0 0; padding-left: 0; list-style: none; }
|
||||
.jinja-hints-examples li { margin: 3px 0; }
|
||||
|
||||
.jinja-hints-empty { color: var(--text-muted); font-size: 0.76rem; font-style: italic; }
|
||||
.jinja-hints-time { color: var(--text-muted); font-size: 0.76rem; }
|
||||
|
||||
/* Remove browser autofill styling */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
|
||||
@@ -184,9 +184,11 @@ import {
|
||||
showValueSourceModal, closeValueSourceModal, saveValueSource,
|
||||
editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange,
|
||||
onDaylightVSRealTimeChange,
|
||||
onValueSourceNormalizeChange,
|
||||
addSchedulePoint,
|
||||
addAnimatedColor, removeAnimatedColor,
|
||||
addColorSchedulePoint, removeColorSchedulePoint,
|
||||
addTemplateInput,
|
||||
testValueSource, closeTestValueSourceModal,
|
||||
} from './features/value-sources.ts';
|
||||
|
||||
@@ -207,7 +209,7 @@ import {
|
||||
import {
|
||||
loadGraphEditor,
|
||||
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes, toggleGraphHelp, graphUndo, graphRedo,
|
||||
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology,
|
||||
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, graphShowIssues, graphExportTopology, graphDuplicateSelection,
|
||||
graphToggleFullscreen, graphAddEntity, toggleToolbarOverflow, closeToolbarOverflow,
|
||||
} from './features/graph-editor.ts';
|
||||
|
||||
@@ -579,11 +581,13 @@ Object.assign(window, {
|
||||
deleteValueSource,
|
||||
onValueSourceTypeChange,
|
||||
onDaylightVSRealTimeChange,
|
||||
onValueSourceNormalizeChange,
|
||||
addSchedulePoint,
|
||||
addAnimatedColor,
|
||||
removeAnimatedColor,
|
||||
addColorSchedulePoint,
|
||||
removeColorSchedulePoint,
|
||||
addTemplateInput,
|
||||
testValueSource,
|
||||
closeTestValueSourceModal,
|
||||
|
||||
@@ -627,6 +631,7 @@ Object.assign(window, {
|
||||
graphRelayout,
|
||||
graphShowIssues,
|
||||
graphExportTopology,
|
||||
graphDuplicateSelection,
|
||||
graphToggleFullscreen,
|
||||
graphAddEntity,
|
||||
toggleToolbarOverflow,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
||||
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
||||
} from './state.ts';
|
||||
import { logError } from './log.ts';
|
||||
|
||||
/** Result of the backend pre-write connection validator. */
|
||||
export interface ConnectionValidation {
|
||||
@@ -62,6 +63,69 @@ export async function getDependents(kind: string, id: string): Promise<GraphDepe
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Schema drift guard (B4) ───────────────────────────────────── */
|
||||
|
||||
// Backend-declared reference fields the frontend intentionally does NOT drag-edit
|
||||
// (the backend still lists them for topology/dependents completeness, so the drift
|
||||
// check ignores them). Two categories:
|
||||
// (a) the source kind is not a graph node — nothing to drag from.
|
||||
// (b) the owning entity's PUT route is not safely partial-writable via a single
|
||||
// dragged field, so it's edited through the entity editor instead.
|
||||
const _DRIFT_EXCLUDE = new Set<string>([
|
||||
// (a) no graph node for the source kind — nothing to drag from:
|
||||
'value_source|ha_source_id',
|
||||
'value_source|gradient_id',
|
||||
// (b) not safely partial-PUT-able from a single dragged field:
|
||||
'device|default_css_processing_template_id', // a one-field device PUT could null the URL
|
||||
// (c) editable in principle but not surfaced as a graph edge yet:
|
||||
'value_source|clock_id', // sync_clock → animated_colour value-source timing
|
||||
]);
|
||||
|
||||
let _driftChecked = false;
|
||||
|
||||
interface SchemaConnection { target_kind: string; field: string; editable: boolean; }
|
||||
|
||||
/**
|
||||
* Dev safety net for the B4 finding: warn once if the frontend CONNECTION_MAP's
|
||||
* editable set diverges from the backend `/graph/schema` (the drift the manual
|
||||
* "10-step checklist" guards against). Read-only — never affects the graph.
|
||||
* No-op against older servers without the endpoint.
|
||||
*
|
||||
* Note: this references `CONNECTION_MAP` (const) and `_isEditable` (fn) declared
|
||||
* later in the module — safe because it is only ever invoked at runtime (from
|
||||
* `loadGraphEditor`), well after module initialization.
|
||||
*/
|
||||
export async function checkSchemaDrift(): Promise<void> {
|
||||
if (_driftChecked) return;
|
||||
_driftChecked = true;
|
||||
|
||||
let connections: SchemaConnection[];
|
||||
try {
|
||||
connections = (await apiGet<{ connections: SchemaConnection[] }>('/graph/schema')).connections || [];
|
||||
} catch {
|
||||
return; // endpoint unavailable — nothing to compare against
|
||||
}
|
||||
|
||||
const k = (kind: string, field: string): string => `${kind}|${field}`;
|
||||
const backend = new Set<string>();
|
||||
for (const c of connections) {
|
||||
if (c.editable && !_DRIFT_EXCLUDE.has(k(c.target_kind, c.field))) backend.add(k(c.target_kind, c.field));
|
||||
}
|
||||
const frontend = new Set<string>();
|
||||
for (const c of CONNECTION_MAP) {
|
||||
if (_isEditable(c) && !_DRIFT_EXCLUDE.has(k(c.targetKind, c.field))) frontend.add(k(c.targetKind, c.field));
|
||||
}
|
||||
|
||||
const missingOnFrontend = [...backend].filter(key => !frontend.has(key));
|
||||
const missingOnBackend = [...frontend].filter(key => !backend.has(key));
|
||||
if (missingOnFrontend.length || missingOnBackend.length) {
|
||||
logError('graph.schema_drift', new Error(
|
||||
`CONNECTION_MAP drift vs /graph/schema — editable fields missing on frontend: ` +
|
||||
`[${missingOnFrontend.join(', ')}]; missing on backend: [${missingOnBackend.join(', ')}]`,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────────── */
|
||||
|
||||
interface ConnectionEntry {
|
||||
@@ -112,17 +176,24 @@ const CONNECTION_MAP: ConnectionEntry[] = [
|
||||
{ targetKind: 'value_source', field: 'value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||
{ targetKind: 'value_source', field: 'gradient_id', sourceKind: 'gradient', edgeType: 'gradient', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||
{ targetKind: 'value_source', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
|
||||
// TODO: template.inputs[] drag-wiring — template value sources reference one
|
||||
// inner value source per bound input (field path inputs[<name>].value_source_id).
|
||||
// These render as read-only 'value' edges in graph-layout for now; a list-aware
|
||||
// CONNECTION_MAP entry (with list/index/ref slot metadata) would make them
|
||||
// re-wirable from the graph the way composite layers / mapped zones are.
|
||||
|
||||
// Color strip sources
|
||||
{ targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
{ targetKind: 'color_strip_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
{ targetKind: 'color_strip_source', field: 'clock_id', sourceKind: 'sync_clock', edgeType: 'clock', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
// Processed strip: input source + processing template (apply_update is partial-safe)
|
||||
{ targetKind: 'color_strip_source', field: 'input_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
{ targetKind: 'color_strip_source', field: 'processing_template_id', sourceKind: 'cspt', edgeType: 'template', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
|
||||
// Output targets
|
||||
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
{ targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true, bindable: true },
|
||||
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
|
||||
// Automations
|
||||
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
||||
@@ -206,6 +277,25 @@ export function getConnectionByField(targetKind: string, field: string): Connect
|
||||
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && _isEditable(c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity kinds whose per-entity PUT route validates the body against a Pydantic
|
||||
* **discriminated union** (`Body(discriminator=...)`). Such a route 422s unless
|
||||
* the body echoes the discriminator field, so a partial wiring write (just a
|
||||
* reference field) is rejected outright. Maps the target kind → the
|
||||
* discriminator's body-field name; the value is the target's *current* subtype,
|
||||
* which we read back from the entity immediately before the write.
|
||||
*
|
||||
* Without this, drag-to-wire silently fails for nearly every source kind. Keep
|
||||
* in sync with the backend `NODE_TYPE_FIELD` map in `api/graph_schema.py`.
|
||||
*/
|
||||
const _DISCRIMINATOR_FIELD: Readonly<Record<string, string>> = {
|
||||
picture_source: 'stream_type',
|
||||
audio_source: 'source_type',
|
||||
value_source: 'source_type',
|
||||
color_strip_source: 'source_type',
|
||||
output_target: 'target_type',
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a connection: set the reference field on the target entity.
|
||||
* @param {string} targetId - The target entity's ID
|
||||
@@ -226,6 +316,21 @@ export async function updateConnection(targetId: string, targetKind: string, fie
|
||||
? { [field.split('.')[0]]: { source_id: newSourceId || '' } }
|
||||
: { [field]: newSourceId };
|
||||
|
||||
// Discriminated-union PUT routes reject a body without their discriminator.
|
||||
// Echo the target's current subtype so a partial wiring write validates
|
||||
// instead of 422-ing. Best-effort: a failed read leaves the PUT to fail as
|
||||
// before — it never makes things worse.
|
||||
const discrimField = _DISCRIMINATOR_FIELD[targetKind];
|
||||
if (discrimField) {
|
||||
try {
|
||||
const current = await apiGet<Record<string, unknown>>(url);
|
||||
const tag = current?.[discrimField];
|
||||
if (typeof tag === 'string' && tag) body[discrimField] = tag;
|
||||
} catch {
|
||||
/* leave body as-is; the PUT below will surface any error */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await apiPut(url, body);
|
||||
// Invalidate the relevant cache so data refreshes
|
||||
@@ -243,4 +348,115 @@ export async function detachConnection(targetId: string, targetKind: string, fie
|
||||
return updateConnection(targetId, targetKind, field, '');
|
||||
}
|
||||
|
||||
/* ── List-element slots (composite layers / mapped zones) ──────────── */
|
||||
|
||||
/**
|
||||
* Targets that hold *list* reference slots. Editing one element means
|
||||
* re-PUTting the whole list, so we map the kind → its endpoint + cache.
|
||||
* (Only color strip sources have list slots today: composite `layers`,
|
||||
* mapped `zones`.)
|
||||
*/
|
||||
const _LIST_SLOT_TARGET: Readonly<Record<string, { endpoint: string; cache: { invalidate(): void } }>> = {
|
||||
color_strip_source: { endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-wire a single element of a list reference slot — e.g. a composite
|
||||
* `layers[index].source_id` or a mapped `zones[index].source_id`.
|
||||
*
|
||||
* The owning entity's PUT replaces the *entire* list, so this reads the entity
|
||||
* back, copies every element verbatim, changes only `list[index][refField]`,
|
||||
* and PUTs the full list (plus the discriminator). Echoing the existing element
|
||||
* objects is what preserves each layer/zone's other settings (blend mode,
|
||||
* opacity, LED range, per-layer brightness/template, …) — a naive partial write
|
||||
* would silently drop that config.
|
||||
*
|
||||
* @param newSourceId New source id, or '' to clear (only valid for optional refs).
|
||||
* @returns Promise<boolean> success
|
||||
*/
|
||||
export async function updateListSlotConnection(
|
||||
targetId: string,
|
||||
targetKind: string,
|
||||
listField: string,
|
||||
index: number,
|
||||
refField: string,
|
||||
newSourceId: string | null,
|
||||
expectedCurrent?: string | null,
|
||||
): Promise<boolean> {
|
||||
const target = _LIST_SLOT_TARGET[targetKind];
|
||||
if (!target || !Number.isInteger(index) || index < 0) return false;
|
||||
|
||||
const url = target.endpoint.replace('{id}', targetId);
|
||||
try {
|
||||
const current = await apiGet<Record<string, unknown>>(url);
|
||||
const list = current?.[listField];
|
||||
if (!Array.isArray(list) || index >= list.length) return false;
|
||||
|
||||
// Optimistic-concurrency guard: `index` is positional, so if the list was
|
||||
// reordered/edited out-of-band (e.g. via the entity editor) between render
|
||||
// and write — or between an action and its undo/redo — that index now points
|
||||
// at a *different* element. Refuse rather than rewrite the wrong slot.
|
||||
if (expectedCurrent != null) {
|
||||
const el = list[index] as Record<string, unknown>;
|
||||
const actual = typeof el?.[refField] === 'string' ? (el[refField] as string) : '';
|
||||
if (actual !== expectedCurrent) return false;
|
||||
}
|
||||
|
||||
// Copy every element; change only the one ref on the targeted element.
|
||||
// (`|| ''` clears the ref — only valid for *optional* refs; the graph only
|
||||
// re-wires the required `source_id`, so callers always pass a real id here.)
|
||||
const nextList = list.map((el, i) =>
|
||||
i === index
|
||||
? { ...(el as Record<string, unknown>), [refField]: newSourceId || '' }
|
||||
: { ...(el as Record<string, unknown>) },
|
||||
);
|
||||
const body: Record<string, unknown> = { [listField]: nextList };
|
||||
|
||||
// Discriminated-union PUT routes need the subtype echoed (see updateConnection).
|
||||
const discrimField = _DISCRIMINATOR_FIELD[targetKind];
|
||||
if (discrimField) {
|
||||
const tag = current[discrimField];
|
||||
if (typeof tag === 'string' && tag) body[discrimField] = tag;
|
||||
}
|
||||
|
||||
await apiPut(url, body);
|
||||
target.cache.invalidate();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Subgraph duplication (D6) ─────────────────────────────────────── */
|
||||
|
||||
/** Result of `POST /graph/duplicate` (server-side clone of a selected subgraph). */
|
||||
export interface DuplicateResult {
|
||||
id_map: Record<string, string>;
|
||||
created: Array<{ id: string; kind: string; name: string }>;
|
||||
skipped: Array<{ id: string; reason: string }>;
|
||||
warnings: Array<{ id: string; reason: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side duplicate of a selected subgraph: the backend deep-clones the
|
||||
* value / colour-strip sources among `nodeIds` with fresh ids and rewires
|
||||
* references that point *within* the selection (shared deps stay shared).
|
||||
* Returns the result, or `null` on failure. Invalidates the affected caches so
|
||||
* a subsequent graph reload shows the clones.
|
||||
*/
|
||||
export async function duplicateSubgraph(
|
||||
nodeIds: string[], nameSuffix?: string,
|
||||
): Promise<DuplicateResult | null> {
|
||||
try {
|
||||
const body: Record<string, unknown> = { node_ids: nodeIds };
|
||||
if (nameSuffix) body.name_suffix = nameSuffix;
|
||||
const res = await apiPost<DuplicateResult>('/graph/duplicate', body);
|
||||
valueSourcesCache.invalidate();
|
||||
colorStripSourcesCache.invalidate();
|
||||
return res;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { CONNECTION_MAP };
|
||||
|
||||
@@ -19,6 +19,9 @@ interface GraphEdge {
|
||||
type: string;
|
||||
field?: string;
|
||||
editable?: boolean;
|
||||
/** List-element reference (composite layer / mapped zone) — exposed as
|
||||
* `data-slot-*` so the editor can re-wire just this slot. */
|
||||
slot?: { list: string; index: number; ref: string };
|
||||
points?: { x: number; y: number }[] | null;
|
||||
fromNode?: GraphNodeRect;
|
||||
toNode?: GraphNodeRect;
|
||||
@@ -124,6 +127,12 @@ function _renderEdge(edge: GraphEdge): SVGElement {
|
||||
'data-to': to,
|
||||
'data-field': field || '',
|
||||
});
|
||||
// List-element reference: expose the slot so the editor can re-wire it.
|
||||
if (edge.slot) {
|
||||
path.setAttribute('data-slot-list', edge.slot.list);
|
||||
path.setAttribute('data-slot-index', String(edge.slot.index));
|
||||
path.setAttribute('data-slot-ref', edge.slot.ref);
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
const title = svgEl('title');
|
||||
|
||||
@@ -29,6 +29,10 @@ interface LayoutEdge {
|
||||
label: string;
|
||||
type: string;
|
||||
editable: boolean;
|
||||
/** For list-element references (composite layers / mapped zones): which list,
|
||||
* which element index, and the reference field on that element. Lets the
|
||||
* editor re-wire one slot without disturbing its siblings. */
|
||||
slot?: { list: string; index: number; ref: string };
|
||||
}
|
||||
|
||||
interface LayoutResult {
|
||||
@@ -236,7 +240,7 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
nodeByIdLocal.set(id, node);
|
||||
}
|
||||
|
||||
function addEdge(from: string, to: string, field: string, label: string = ''): void {
|
||||
function addEdge(from: string, to: string, field: string, label: string = '', slot?: { list: string; index: number; ref: string }): void {
|
||||
if (!from || !to) return;
|
||||
// The referrer (`to`) is always a current entity in these loops; if the
|
||||
// referenced entity (`from`) is missing, the reference is dangling —
|
||||
@@ -254,7 +258,7 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
);
|
||||
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
|
||||
const editable = !field.includes('.');
|
||||
edges.push({ from, to, field, label, type, editable });
|
||||
edges.push({ from, to, field, label, type, editable, ...(slot ? { slot } : {}) });
|
||||
}
|
||||
|
||||
// Every entity may carry a custom `icon` (+ `icon_color`); pass them through
|
||||
@@ -348,6 +352,21 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
for (const s of e.valueSources || []) {
|
||||
if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id');
|
||||
if (s.picture_source_id) addEdge(s.picture_source_id, s.id, 'picture_source_id');
|
||||
// Derived value sources: gradient_map derives from an inner value source;
|
||||
// css_extract derives from a color strip. Both are real, runtime-resolved
|
||||
// references (and drag-editable) — render them so they're visible.
|
||||
if (s.value_source_id) addEdge(s.value_source_id, s.id, 'value_source_id');
|
||||
if (s.color_strip_source_id) addEdge(s.color_strip_source_id, s.id, 'color_strip_source_id');
|
||||
// Template value sources reference one inner value source per bound input.
|
||||
// Each `inputs[].value_source_id` is a real 'value' edge; the dotted field
|
||||
// path marks it non-editable (drag-wiring deferred — see graph-connections).
|
||||
if (s.source_type === 'template' && Array.isArray(s.inputs)) {
|
||||
s.inputs.forEach((inp: any) => {
|
||||
if (inp?.value_source_id) {
|
||||
addEdge(inp.value_source_id, s.id, `inputs[${inp.name}].value_source_id`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Color strip source edges
|
||||
@@ -360,19 +379,20 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
if (s.input_source_id) addEdge(s.input_source_id, s.id, 'input_source_id');
|
||||
if (s.processing_template_id) addEdge(s.processing_template_id, s.id, 'processing_template_id');
|
||||
|
||||
// Composite layers
|
||||
// Composite layers — carry the slot index so each `layer.source_id`
|
||||
// edge can be re-wired individually from the graph (siblings untouched).
|
||||
if (s.layers) {
|
||||
for (const layer of s.layers) {
|
||||
if (layer.source_id) addEdge(layer.source_id, s.id, 'layer.source_id');
|
||||
s.layers.forEach((layer: any, i: number) => {
|
||||
if (layer.source_id) addEdge(layer.source_id, s.id, 'layer.source_id', '', { list: 'layers', index: i, ref: 'source_id' });
|
||||
if (layer.brightness_source_id) addEdge(layer.brightness_source_id, s.id, 'layer.brightness_source_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mapped zones
|
||||
// Mapped zones — carry the slot index (re-wirable from the graph).
|
||||
if (s.zones) {
|
||||
for (const zone of s.zones) {
|
||||
if (zone.source_id) addEdge(zone.source_id, s.id, 'zone.source_id');
|
||||
}
|
||||
s.zones.forEach((zone: any, i: number) => {
|
||||
if (zone.source_id) addEdge(zone.source_id, s.id, 'zone.source_id', '', { list: 'zones', index: i, ref: 'source_id' });
|
||||
});
|
||||
}
|
||||
|
||||
// Advanced picture calibration lines
|
||||
@@ -405,7 +425,6 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
if (bvsId) addEdge(bvsId, t.id, 'brightness.source_id');
|
||||
const transVsId = bindableSourceId(t.transition);
|
||||
if (transVsId) addEdge(transVsId, t.id, 'transition.source_id');
|
||||
if (t.picture_source_id) addEdge(t.picture_source_id, t.id, 'picture_source_id');
|
||||
// KC target settings
|
||||
if (t.settings) {
|
||||
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
|
||||
|
||||
@@ -106,6 +106,7 @@ const SUBTYPE_ICONS = {
|
||||
value_source: {
|
||||
static: P.layoutDashboard, animated: P.refreshCw, audio: P.music,
|
||||
adaptive_time: P.clock, adaptive_scene: P.cloudSun, daylight: P.sun,
|
||||
template: P.code,
|
||||
},
|
||||
audio_source: { capture: P.volume2, processed: P.slidersHorizontal },
|
||||
output_target: { led: P.lightbulb, wled: P.lightbulb, ha_light: P.lightbulb },
|
||||
@@ -656,7 +657,7 @@ export function markIssues(group: SVGGElement, issues: Map<string, string[]>): v
|
||||
|
||||
for (const [id, msgs] of issues) {
|
||||
if (!msgs.length) continue;
|
||||
const el = group.querySelector(`.graph-node[data-id="${id}"]`);
|
||||
const el = group.querySelector(`.graph-node[data-id="${CSS.escape(id)}"]`);
|
||||
if (!el) continue;
|
||||
el.classList.add('has-issue');
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ const _valueSourceTypeIcons = {
|
||||
system_metrics: _svg(P.cpu),
|
||||
game_event: _svg(P.gamepad2),
|
||||
http: _svg(P.globe),
|
||||
template: _svg(P.code),
|
||||
};
|
||||
const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.slidersHorizontal) };
|
||||
const _deviceTypeIcons = {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Tiny zero-dependency Jinja-expression highlighter.
|
||||
*
|
||||
* A transparent <textarea> is layered over a synced <pre class="jinja-hl">.
|
||||
* On every input the text is re-tokenised and painted into the <pre> so the
|
||||
* caret and selection stay native while the colours live underneath. The two
|
||||
* layers share identical font metrics (set in CSS via --font-mono) so the
|
||||
* highlight aligns pixel-perfectly with the typed glyphs.
|
||||
*
|
||||
* Tokenised: strings, numbers, the sandbox globals (min|max|abs|round|clamp),
|
||||
* the `raw` keyword, bound input variable names (supplied live via
|
||||
* getInputNames), and operators. Everything else renders as plain text.
|
||||
*
|
||||
* Usage:
|
||||
* const ed = create({ textarea, getInputNames: () => ['audio','temp'], onChange });
|
||||
* ed.refresh(); // re-paint after the input list or value changes externally
|
||||
* ed.destroy();
|
||||
*/
|
||||
|
||||
/** Globals available inside the sandboxed expression (see backend contract). */
|
||||
const JINJA_GLOBALS = new Set(['min', 'max', 'abs', 'round', 'clamp']);
|
||||
const JINJA_RAW = 'raw';
|
||||
|
||||
export interface JinjaEditorOpts {
|
||||
textarea: HTMLTextAreaElement;
|
||||
getInputNames: () => string[];
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export interface JinjaEditorHandle {
|
||||
/** Re-paint the highlight layer (e.g. after the bound-input list changed). */
|
||||
refresh: () => void;
|
||||
/** Detach listeners and remove the highlight overlay. */
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
type TokenKind = 'str' | 'num' | 'fn' | 'raw' | 'var' | 'op' | 'text';
|
||||
|
||||
interface Token {
|
||||
kind: TokenKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenise a Jinja expression. Deliberately small — this is presentational
|
||||
* only; the backend is the source of truth for validity.
|
||||
*/
|
||||
function tokenize(src: string, inputNames: Set<string>): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
const n = src.length;
|
||||
let i = 0;
|
||||
|
||||
const push = (kind: TokenKind, text: string) => {
|
||||
// Coalesce consecutive plain-text runs to keep the DOM tiny.
|
||||
const last = tokens[tokens.length - 1];
|
||||
if (kind === 'text' && last && last.kind === 'text') last.text += text;
|
||||
else tokens.push({ kind, text });
|
||||
};
|
||||
|
||||
while (i < n) {
|
||||
const ch = src[i];
|
||||
|
||||
// Strings — single or double quoted, with simple escape passthrough.
|
||||
if (ch === '"' || ch === "'") {
|
||||
const quote = ch;
|
||||
let j = i + 1;
|
||||
while (j < n && src[j] !== quote) {
|
||||
if (src[j] === '\\' && j + 1 < n) j += 2;
|
||||
else j += 1;
|
||||
}
|
||||
j = Math.min(j + 1, n); // include closing quote if present
|
||||
push('str', src.slice(i, j));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbers — integer / float.
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
let j = i + 1;
|
||||
while (j < n && /[0-9._]/.test(src[j])) j += 1;
|
||||
push('num', src.slice(i, j));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifiers — globals, the `raw` keyword, bound input names, or plain.
|
||||
if (/[A-Za-z_]/.test(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < n && /[A-Za-z0-9_]/.test(src[j])) j += 1;
|
||||
const word = src.slice(i, j);
|
||||
if (JINJA_GLOBALS.has(word)) push('fn', word);
|
||||
else if (word === JINJA_RAW) push('raw', word);
|
||||
else if (inputNames.has(word)) push('var', word);
|
||||
else push('text', word);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Operators / punctuation.
|
||||
if ('+-*/%()[]<>=!,&|?:'.includes(ch)) {
|
||||
push('op', ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Whitespace and everything else.
|
||||
push('text', ch);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function render(src: string, inputNames: Set<string>): string {
|
||||
// A trailing newline is swallowed by <pre>; pad it so the highlight box
|
||||
// keeps the same height as the textarea while typing a fresh line.
|
||||
const padded = src.endsWith('\n') ? src + ' ' : src;
|
||||
const html = tokenize(padded, inputNames)
|
||||
.map(tok =>
|
||||
tok.kind === 'text'
|
||||
? escapeHtml(tok.text)
|
||||
: `<span class="tok-${tok.kind}">${escapeHtml(tok.text)}</span>`,
|
||||
)
|
||||
.join('');
|
||||
return html;
|
||||
}
|
||||
|
||||
export function create({ textarea, getInputNames, onChange }: JinjaEditorOpts): JinjaEditorHandle {
|
||||
// Wrap the textarea so the highlight layer can sit directly behind it.
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'jinja-editor';
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'jinja-hl';
|
||||
pre.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const parent = textarea.parentNode;
|
||||
if (parent) {
|
||||
parent.insertBefore(wrap, textarea);
|
||||
wrap.appendChild(pre);
|
||||
wrap.appendChild(textarea);
|
||||
}
|
||||
textarea.classList.add('jinja-input');
|
||||
textarea.spellcheck = false;
|
||||
textarea.setAttribute('autocomplete', 'off');
|
||||
textarea.setAttribute('autocapitalize', 'off');
|
||||
textarea.setAttribute('autocorrect', 'off');
|
||||
textarea.setAttribute('wrap', 'off');
|
||||
|
||||
const paint = () => {
|
||||
pre.innerHTML = render(textarea.value, new Set(getInputNames()));
|
||||
// Keep the highlight scrolled in lock-step with the textarea.
|
||||
pre.scrollTop = textarea.scrollTop;
|
||||
pre.scrollLeft = textarea.scrollLeft;
|
||||
};
|
||||
|
||||
const onInput = () => {
|
||||
paint();
|
||||
if (onChange) onChange(textarea.value);
|
||||
};
|
||||
const onScroll = () => {
|
||||
pre.scrollTop = textarea.scrollTop;
|
||||
pre.scrollLeft = textarea.scrollLeft;
|
||||
};
|
||||
|
||||
textarea.addEventListener('input', onInput);
|
||||
textarea.addEventListener('scroll', onScroll);
|
||||
|
||||
paint();
|
||||
|
||||
return {
|
||||
refresh: paint,
|
||||
destroy: () => {
|
||||
textarea.removeEventListener('input', onInput);
|
||||
textarea.removeEventListener('scroll', onScroll);
|
||||
textarea.classList.remove('jinja-input');
|
||||
// Restore the textarea to its original place, drop the overlay.
|
||||
if (wrap.parentNode) {
|
||||
wrap.parentNode.insertBefore(textarea, wrap);
|
||||
wrap.remove();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { apiGet } from '../core/api-client.ts';
|
||||
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
|
||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge, validateConnection, getDependents } from '../core/graph-connections.ts';
|
||||
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, updateListSlotConnection, duplicateSubgraph, isEditableEdge, validateConnection, getDependents, checkSchemaDrift } from '../core/graph-connections.ts';
|
||||
import { showTypePicker } from '../core/icon-select.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.ts';
|
||||
@@ -358,6 +358,10 @@ export async function loadGraphEditor(): Promise<void> {
|
||||
if (gc) gc.appendChild(overlay);
|
||||
}
|
||||
|
||||
// Dev safety net: warn once if the frontend connection map drifts from the
|
||||
// backend schema (fire-and-forget; never blocks the graph).
|
||||
void checkSchemaDrift();
|
||||
|
||||
try {
|
||||
const entities = await _fetchAllEntities();
|
||||
// Index raw entities by id for subtype-safe bindable-slot resolution.
|
||||
@@ -805,6 +809,40 @@ export async function graphExportTopology(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate the current node selection server-side. The backend clones the
|
||||
* value / colour-strip sources in the selection (full config preserved, no
|
||||
* secrets crossing the wire), rewires references that point *within* the
|
||||
* selection, and shares everything else. The graph then reloads with the new
|
||||
* cluster selected.
|
||||
*/
|
||||
export async function graphDuplicateSelection(): Promise<void> {
|
||||
const ids = [..._selectedIds];
|
||||
if (ids.length === 0) {
|
||||
showToast(t('graph.duplicate_none'), 'info');
|
||||
return;
|
||||
}
|
||||
const res = await duplicateSubgraph(ids);
|
||||
if (!res) {
|
||||
showToast(t('graph.duplicate_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
const newIds = Object.values(res.id_map || {});
|
||||
if (newIds.length === 0) {
|
||||
showToast(t('graph.duplicate_none_eligible'), 'info');
|
||||
return;
|
||||
}
|
||||
await loadGraphEditor();
|
||||
_selectedIds = new Set(newIds);
|
||||
const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null;
|
||||
if (nodeGroup) updateSelection(nodeGroup, _selectedIds);
|
||||
const hasWarn = !!res.warnings?.length;
|
||||
showToast(
|
||||
t(hasWarn ? 'graph.duplicate_done_warn' : 'graph.duplicate_done', { count: newIds.length }),
|
||||
hasWarn ? 'info' : 'success',
|
||||
);
|
||||
}
|
||||
|
||||
/** Frame and highlight all nodes flagged with configuration issues. */
|
||||
export function graphShowIssues(): void {
|
||||
if (_issueIds.size === 0 || !_nodeMap || !_canvas) {
|
||||
@@ -1353,6 +1391,10 @@ function _graphHTML(): string {
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||
<span>${t('graph.export')}</span>
|
||||
</button>
|
||||
<button onclick="graphDuplicateSelection(); closeToolbarOverflow()">
|
||||
<svg class="icon" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
<span>${t('graph.duplicate')}</span>
|
||||
</button>
|
||||
<div class="graph-overflow-sep"></div>
|
||||
<button id="graph-overflow-help" onclick="toggleGraphHelp(); closeToolbarOverflow()">
|
||||
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
||||
@@ -2628,16 +2670,22 @@ function _onConnectPointerUp(e: PointerEvent): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the bindable slots the target entity actually exposes (subtype-safe)
|
||||
* — e.g. an "effect" strip offers `intensity`/`scale`, a "picture" strip offers
|
||||
* `smoothing`. Non-bindable matches always pass through.
|
||||
* Keep only the slots the target entity actually exposes (subtype-safe) — a
|
||||
* field is offered iff its first path segment is a key on the serialized entity.
|
||||
* E.g. an "effect" strip offers `intensity`/`scale`, a "picture" strip offers
|
||||
* `smoothing`; a processed strip offers `input_source_id`. Applies to ALL
|
||||
* matches (bindable and top-level alike); reference fields are always emitted by
|
||||
* `to_dict` so empty slots stay wireable.
|
||||
*/
|
||||
function _availableMatches<T extends { field: string; bindable?: boolean }>(matches: T[], targetId: string): T[] {
|
||||
function _availableMatches<T extends { field: string }>(matches: T[], targetId: string): T[] {
|
||||
const ent = _entitiesById.get(targetId);
|
||||
return matches.filter(m => {
|
||||
if (!m.bindable || !ent) return true;
|
||||
return m.field.split('.')[0] in ent;
|
||||
});
|
||||
if (!ent) return matches; // no data (e.g. freshly created) → don't over-filter
|
||||
// Offer a field only if the target entity actually exposes its slot (its
|
||||
// first path segment is a key on the serialized entity). Reference fields
|
||||
// are always emitted by `to_dict` even when empty, so empty slots stay
|
||||
// wireable (B2); subtype-specific fields (e.g. processed-strip
|
||||
// `input_source_id`) are correctly hidden on subtypes that lack them.
|
||||
return matches.filter(m => m.field.split('.')[0] in ent);
|
||||
}
|
||||
|
||||
/** Ask the user which field to wire when a source maps to multiple target fields. */
|
||||
@@ -2841,6 +2889,13 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
|
||||
_dismissEdgeContextMenu();
|
||||
|
||||
const field = edgePath.getAttribute('data-field') || '';
|
||||
// List-slot edges (composite layers / mapped zones) aren't editable through
|
||||
// the flat (to, field) path, but each carries its slot index so it can be
|
||||
// re-wired individually.
|
||||
if (edgePath.getAttribute('data-slot-list')) {
|
||||
_showListSlotRewireMenu(edgePath, e, container);
|
||||
return;
|
||||
}
|
||||
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
|
||||
|
||||
const toId = edgePath.getAttribute('data-to') ?? '';
|
||||
@@ -2877,6 +2932,73 @@ function _dismissEdgeContextMenu(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-wire menu for a list-slot edge (a composite layer or mapped zone source).
|
||||
* Each such edge carries `data-slot-*`, so we can replace just that one
|
||||
* element's reference and leave its siblings (and its own other settings)
|
||||
* untouched. The picker lists every node of the source's kind; the backend
|
||||
* rejects self-reference / cycles.
|
||||
*/
|
||||
function _showListSlotRewireMenu(edgePath: Element, e: MouseEvent, container: HTMLElement): void {
|
||||
const toId = edgePath.getAttribute('data-to') ?? ''; // composite/mapped owner
|
||||
const fromId = edgePath.getAttribute('data-from') ?? ''; // current source occupant
|
||||
const listField = edgePath.getAttribute('data-slot-list') ?? '';
|
||||
const index = parseInt(edgePath.getAttribute('data-slot-index') ?? '', 10);
|
||||
const refField = edgePath.getAttribute('data-slot-ref') ?? '';
|
||||
const toNode = _nodeMap?.get(toId);
|
||||
if (!toNode || Number.isNaN(index)) return;
|
||||
|
||||
const sourceKind = _nodeMap?.get(fromId)?.kind ?? 'color_strip_source';
|
||||
const candidates = [...(_nodeMap?.values() ?? [])]
|
||||
.filter((n: any) => n.kind === sourceKind && n.id !== toId && n.id !== fromId)
|
||||
.sort((a: any, b: any) => (a.name || a.id).localeCompare(b.name || b.id));
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'graph-edge-menu';
|
||||
menu.style.left = `${e.clientX - container.getBoundingClientRect().left}px`;
|
||||
menu.style.top = `${e.clientY - container.getBoundingClientRect().top}px`;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'graph-edge-menu-item';
|
||||
btn.textContent = t('graph.rewire');
|
||||
btn.addEventListener('click', () => {
|
||||
_dismissEdgeContextMenu();
|
||||
if (candidates.length === 0) { showToast(t('graph.no_compatible_connection'), 'info'); return; }
|
||||
showTypePicker({
|
||||
title: t('graph.rewire_choose_source'),
|
||||
items: candidates.map((n: any) => ({ value: n.id, icon: _ico(P.link), label: n.name || n.id })),
|
||||
onPick: (newSourceId: string) => { void _doListSlotRewire(toId, toNode.kind, listField, index, refField, newSourceId, fromId); },
|
||||
});
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
|
||||
container.querySelector('.graph-container')!.appendChild(menu);
|
||||
_edgeContextMenu = menu;
|
||||
}
|
||||
|
||||
/** Re-wire one list slot (composite layer / mapped zone) and record an undoable action. */
|
||||
async function _doListSlotRewire(
|
||||
targetId: string, targetKind: string, listField: string, index: number,
|
||||
refField: string, newSourceId: string, prevSourceId: string,
|
||||
): Promise<void> {
|
||||
if (newSourceId === prevSourceId) return;
|
||||
// Pass the expected current occupant so the write refuses if the slot was
|
||||
// reordered/edited out-of-band (positional `index` could otherwise hit the
|
||||
// wrong element). Each step expects the slot to still hold what it replaces.
|
||||
const ok = await updateListSlotConnection(targetId, targetKind, listField, index, refField, newSourceId, prevSourceId);
|
||||
if (ok) {
|
||||
pushUndoAction({
|
||||
label: t('graph.action.rewire'),
|
||||
undo: async () => { if (!(await updateListSlotConnection(targetId, targetKind, listField, index, refField, prevSourceId, newSourceId))) throw new Error(t('graph.connection_failed')); },
|
||||
redo: async () => { if (!(await updateListSlotConnection(targetId, targetKind, listField, index, refField, newSourceId, prevSourceId))) throw new Error(t('graph.connection_failed')); },
|
||||
});
|
||||
showToast(t('graph.connection_updated'), 'success');
|
||||
await loadGraphEditor();
|
||||
} else {
|
||||
showToast(t('graph.connection_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _detachSelectedEdge(): Promise<void> {
|
||||
if (!_selectedEdge) return;
|
||||
const { from, to, field, targetKind } = _selectedEdge;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
|
||||
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH,
|
||||
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD, ICON_X,
|
||||
ICON_CHECK, ICON_FILE_TEXT,
|
||||
} from '../core/icons.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
||||
@@ -56,9 +57,10 @@ registerIconEntityType('value_source', makeSimpleIconAdapter<any>({
|
||||
import type { IconSelectItem } from '../core/icon-select.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import * as JinjaEditor from '../core/jinja-editor.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import { hexToRgbArray, rgbArrayToHex } from './css-gradient-editor.ts';
|
||||
import type { ValueSource } from '../types.ts';
|
||||
import type { ValueSource, TemplateInput } from '../types.ts';
|
||||
|
||||
export { getValueSourceIcon };
|
||||
|
||||
@@ -78,6 +80,14 @@ let _vsAnimColorClockEntitySelect: EntitySelect | null = null;
|
||||
let _vsHTTPEndpointEntitySelect: EntitySelect | null = null;
|
||||
let _vsTagsInput: TagInput | null = null;
|
||||
|
||||
// ── Template value source editor state ──
|
||||
// (the bound inputs live in the DOM rows; read them via _readTemplateInputRows)
|
||||
const _vsTemplateInputSelects = new Map<HTMLElement, EntitySelect>();
|
||||
let _vsTemplateEditor: JinjaEditor.JinjaEditorHandle | null = null;
|
||||
let _templateValidateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _templateValidateGen = 0;
|
||||
let _vsTemplateInputUid = 0;
|
||||
|
||||
class ValueSourceModal extends Modal {
|
||||
constructor() { super('value-source-modal'); }
|
||||
|
||||
@@ -95,6 +105,13 @@ class ValueSourceModal extends Modal {
|
||||
if (_vsAnimColorClockEntitySelect) { _vsAnimColorClockEntitySelect.destroy(); _vsAnimColorClockEntitySelect = null; }
|
||||
if (_vsGameIntegrationEntitySelect) { _vsGameIntegrationEntitySelect.destroy(); _vsGameIntegrationEntitySelect = null; }
|
||||
if (_vsHTTPEndpointEntitySelect) { _vsHTTPEndpointEntitySelect.destroy(); _vsHTTPEndpointEntitySelect = null; }
|
||||
// Template editor: destroy all per-row EntitySelect portals + the highlighter
|
||||
// and cancel any pending validation so stale responses are ignored.
|
||||
_vsTemplateInputSelects.forEach(sel => sel.destroy());
|
||||
_vsTemplateInputSelects.clear();
|
||||
if (_vsTemplateEditor) { _vsTemplateEditor.destroy(); _vsTemplateEditor = null; }
|
||||
if (_templateValidateTimer) { clearTimeout(_templateValidateTimer); _templateValidateTimer = null; }
|
||||
_templateValidateGen++;
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
@@ -146,6 +163,11 @@ class ValueSourceModal extends Modal {
|
||||
httpMin: (document.getElementById('value-source-http-min') as HTMLInputElement | null)?.value || '',
|
||||
httpMax: (document.getElementById('value-source-http-max') as HTMLInputElement | null)?.value || '',
|
||||
httpSmoothing: (document.getElementById('value-source-http-smoothing') as HTMLInputElement | null)?.value || '',
|
||||
// Template value source
|
||||
template: (document.getElementById('value-source-template-expression') as HTMLTextAreaElement | null)?.value || '',
|
||||
templateInputs: JSON.stringify(_readTemplateInputRows()),
|
||||
templateDefault: (document.getElementById('value-source-template-default-value') as HTMLInputElement | null)?.value || '',
|
||||
templateEvalInterval: (document.getElementById('value-source-template-eval-interval') as HTMLInputElement | null)?.value || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -188,7 +210,7 @@ function _autoGenerateVSName() {
|
||||
|
||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||
|
||||
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event', 'http'];
|
||||
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event', 'http', 'template'];
|
||||
const VS_COLOR_TYPE_KEYS = ['static_color', 'animated_color', 'adaptive_time_color', 'gradient_map', 'css_extract'];
|
||||
const VS_TYPE_KEYS = [...VS_FLOAT_TYPE_KEYS, ...VS_COLOR_TYPE_KEYS];
|
||||
|
||||
@@ -396,6 +418,16 @@ function _onMetricChange(metric: string) {
|
||||
if (networkFields) networkFields.style.display = networkMetrics.includes(metric) ? '' : 'none';
|
||||
if (diskFields) diskFields.style.display = metric === 'disk_usage' ? '' : 'none';
|
||||
if (sensorFields) sensorFields.style.display = sensorMetrics.includes(metric) ? '' : 'none';
|
||||
// The Normalize toggle only makes sense where a Min/Max range is shown
|
||||
// (rangeMetrics). Percent/network/disk metrics have a fixed natural→0-1
|
||||
// mapping in their reader, so "clamp the raw value as-is" would just
|
||||
// saturate them; hide the toggle and force normalization on for those.
|
||||
const normGroup = document.getElementById('value-source-sysmetric-normalize-group') as HTMLElement | null;
|
||||
const normCb = document.getElementById('value-source-sysmetric-normalize') as HTMLInputElement | null;
|
||||
const showNorm = rangeMetrics.includes(metric);
|
||||
if (normGroup) normGroup.style.display = showNorm ? '' : 'none';
|
||||
if (!showNorm && normCb) normCb.checked = true;
|
||||
_syncVsNormalizeUI();
|
||||
}
|
||||
|
||||
// ── Game Event Value Source helpers ──
|
||||
@@ -597,6 +629,8 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
||||
(document.getElementById('value-source-min-ha-value') as HTMLInputElement).value = String(editData.min_ha_value ?? 0);
|
||||
(document.getElementById('value-source-max-ha-value') as HTMLInputElement).value = String(editData.max_ha_value ?? 100);
|
||||
_setSlider('value-source-ha-smoothing', editData.smoothing ?? 0);
|
||||
(document.getElementById('value-source-ha-normalize') as HTMLInputElement).checked = editData.normalize !== false;
|
||||
_syncVsNormalizeUI();
|
||||
} else if (editData.source_type === 'gradient_map') {
|
||||
_populateGradientInputDropdown(editData.value_source_id || '');
|
||||
_populateGradientEntityDropdown(editData.gradient_id || '');
|
||||
@@ -616,7 +650,9 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
||||
(document.getElementById('value-source-sensor-label') as HTMLInputElement).value = editData.sensor_label || '';
|
||||
_setSlider('value-source-poll-interval', editData.poll_interval ?? 1.0);
|
||||
_setSlider('value-source-sysmetric-smoothing', editData.smoothing ?? 0);
|
||||
(document.getElementById('value-source-sysmetric-normalize') as HTMLInputElement).checked = editData.normalize !== false;
|
||||
_onMetricChange(editData.metric || 'cpu_load');
|
||||
_syncVsNormalizeUI();
|
||||
} else if (editData.source_type === 'game_event') {
|
||||
_populateVSGameIntegrationDropdown(editData.game_integration_id || '');
|
||||
_populateVSGameEventTypeDropdown(editData.event_type || 'health');
|
||||
@@ -633,6 +669,17 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
||||
(document.getElementById('value-source-http-min') as HTMLInputElement).value = String(editData.min_value ?? 0);
|
||||
(document.getElementById('value-source-http-max') as HTMLInputElement).value = String(editData.max_value ?? 100);
|
||||
_setSlider('value-source-http-smoothing', editData.smoothing ?? 0);
|
||||
(document.getElementById('value-source-http-normalize') as HTMLInputElement).checked = editData.normalize !== false;
|
||||
_syncVsNormalizeUI();
|
||||
} else if (editData.source_type === 'template') {
|
||||
(document.getElementById('value-source-template-expression') as HTMLTextAreaElement).value = editData.template || '';
|
||||
_setSlider('value-source-template-default-value', editData.default_value ?? 0.0);
|
||||
(document.getElementById('value-source-template-default-value-display') as HTMLElement).textContent =
|
||||
parseFloat(String(editData.default_value ?? 0.0)).toFixed(2);
|
||||
(document.getElementById('value-source-template-eval-interval') as HTMLInputElement).value = String(editData.eval_interval ?? 0);
|
||||
_ensureTemplateEditor();
|
||||
_populateTemplateInputsUI(editData.inputs ?? []);
|
||||
_runTemplateValidationDebounced();
|
||||
}
|
||||
} else {
|
||||
(document.getElementById('value-source-name') as HTMLInputElement).value = '';
|
||||
@@ -683,6 +730,7 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
||||
(document.getElementById('value-source-min-ha-value') as HTMLInputElement).value = '0';
|
||||
(document.getElementById('value-source-max-ha-value') as HTMLInputElement).value = '100';
|
||||
_setSlider('value-source-ha-smoothing', 0);
|
||||
(document.getElementById('value-source-ha-normalize') as HTMLInputElement).checked = true;
|
||||
// Gradient map defaults
|
||||
(document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value = 'linear';
|
||||
// CSS extract defaults
|
||||
@@ -697,6 +745,7 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
||||
(document.getElementById('value-source-sensor-label') as HTMLInputElement).value = '';
|
||||
_setSlider('value-source-poll-interval', 1.0);
|
||||
_setSlider('value-source-sysmetric-smoothing', 0);
|
||||
(document.getElementById('value-source-sysmetric-normalize') as HTMLInputElement).checked = true;
|
||||
// HTTP value source defaults
|
||||
const httpJsonPath = document.getElementById('value-source-http-json-path') as HTMLInputElement | null;
|
||||
if (httpJsonPath) httpJsonPath.value = '';
|
||||
@@ -707,6 +756,24 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
||||
const httpMax = document.getElementById('value-source-http-max') as HTMLInputElement | null;
|
||||
if (httpMax) httpMax.value = '100';
|
||||
_setSlider('value-source-http-smoothing', 0);
|
||||
const httpNormalize = document.getElementById('value-source-http-normalize') as HTMLInputElement | null;
|
||||
if (httpNormalize) httpNormalize.checked = true;
|
||||
_syncVsNormalizeUI();
|
||||
// Template value source defaults
|
||||
const tmplExpr = document.getElementById('value-source-template-expression') as HTMLTextAreaElement | null;
|
||||
if (tmplExpr) tmplExpr.value = '';
|
||||
_setSlider('value-source-template-default-value', 0.0);
|
||||
const tmplDefDisp = document.getElementById('value-source-template-default-value-display') as HTMLElement | null;
|
||||
if (tmplDefDisp) tmplDefDisp.textContent = '0.00';
|
||||
const tmplEval = document.getElementById('value-source-template-eval-interval') as HTMLInputElement | null;
|
||||
if (tmplEval) tmplEval.value = '0';
|
||||
if (presetType === 'template') {
|
||||
_ensureTemplateEditor();
|
||||
_populateTemplateInputsUI([]);
|
||||
_runTemplateValidationDebounced();
|
||||
} else {
|
||||
_populateTemplateInputsUI([]);
|
||||
}
|
||||
_autoGenerateVSName();
|
||||
}
|
||||
|
||||
@@ -763,6 +830,19 @@ export function onValueSourceTypeChange() {
|
||||
// before the integrations tab has been visited.
|
||||
httpEndpointsCache.fetch().then(() => _populateVSHTTPEndpointDropdown(''));
|
||||
}
|
||||
const templateSec = document.getElementById('value-source-template-section') as HTMLElement | null;
|
||||
if (templateSec) templateSec.style.display = type === 'template' ? '' : 'none';
|
||||
if (type === 'template') {
|
||||
_ensureTemplateEditor();
|
||||
_runTemplateValidationDebounced();
|
||||
} else {
|
||||
// Leaving template type — cancel any pending/in-flight validation so a
|
||||
// stale response cannot re-disable save on a non-template form, then
|
||||
// restore the button.
|
||||
if (_templateValidateTimer) { clearTimeout(_templateValidateTimer); _templateValidateTimer = null; }
|
||||
_templateValidateGen++;
|
||||
_setVSSaveEnabled(true);
|
||||
}
|
||||
(document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
|
||||
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
|
||||
|
||||
@@ -815,6 +895,35 @@ function _syncDaylightVSSpeedVisibility() {
|
||||
(document.getElementById('value-source-daylight-speed-group') as HTMLElement).style.display = rt ? 'none' : '';
|
||||
}
|
||||
|
||||
// ── Normalize toggle (magnitude sources: ha_entity / system_metrics / http) ──
|
||||
|
||||
export function onValueSourceNormalizeChange() {
|
||||
_syncVsNormalizeUI();
|
||||
}
|
||||
|
||||
// Grey out + disable the Min/Max range inputs when a magnitude source is in
|
||||
// clamp-passthrough mode (normalize off) — they are inert there. Only the
|
||||
// visible section's pair is relevant, but syncing all three is harmless.
|
||||
function _syncVsNormalizeUI() {
|
||||
const groups: Array<[string, string[]]> = [
|
||||
['value-source-ha-normalize', ['value-source-min-ha-value', 'value-source-max-ha-value']],
|
||||
['value-source-sysmetric-normalize', ['value-source-sysmetric-min', 'value-source-sysmetric-max']],
|
||||
['value-source-http-normalize', ['value-source-http-min', 'value-source-http-max']],
|
||||
];
|
||||
for (const [cbId, inputIds] of groups) {
|
||||
const cb = document.getElementById(cbId) as HTMLInputElement | null;
|
||||
if (!cb) continue;
|
||||
const on = cb.checked;
|
||||
for (const id of inputIds) {
|
||||
const inp = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (!inp) continue;
|
||||
inp.disabled = !on;
|
||||
const fg = inp.closest('.form-group') as HTMLElement | null;
|
||||
if (fg) fg.style.opacity = on ? '' : '0.45';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
|
||||
export async function saveValueSource() {
|
||||
@@ -889,6 +998,7 @@ export async function saveValueSource() {
|
||||
payload.min_ha_value = parseFloat((document.getElementById('value-source-min-ha-value') as HTMLInputElement).value);
|
||||
payload.max_ha_value = parseFloat((document.getElementById('value-source-max-ha-value') as HTMLInputElement).value);
|
||||
payload.smoothing = parseFloat((document.getElementById('value-source-ha-smoothing') as HTMLInputElement).value);
|
||||
payload.normalize = (document.getElementById('value-source-ha-normalize') as HTMLInputElement).checked;
|
||||
if (!payload.ha_source_id) {
|
||||
errorEl.textContent = t('value_source.ha_source') + ' required';
|
||||
errorEl.style.display = '';
|
||||
@@ -932,6 +1042,7 @@ export async function saveValueSource() {
|
||||
payload.sensor_label = (document.getElementById('value-source-sensor-label') as HTMLInputElement).value;
|
||||
payload.poll_interval = parseFloat((document.getElementById('value-source-poll-interval') as HTMLInputElement).value) || 1.0;
|
||||
payload.smoothing = parseFloat((document.getElementById('value-source-sysmetric-smoothing') as HTMLInputElement).value) || 0;
|
||||
payload.normalize = (document.getElementById('value-source-sysmetric-normalize') as HTMLInputElement).checked;
|
||||
} else if (sourceType === 'game_event') {
|
||||
payload.game_integration_id = (document.getElementById('value-source-game-integration') as HTMLSelectElement).value;
|
||||
payload.event_type = (document.getElementById('value-source-game-event-type') as HTMLSelectElement).value;
|
||||
@@ -952,6 +1063,7 @@ export async function saveValueSource() {
|
||||
payload.min_value = parseFloat((document.getElementById('value-source-http-min') as HTMLInputElement).value) || 0;
|
||||
payload.max_value = parseFloat((document.getElementById('value-source-http-max') as HTMLInputElement).value) || 100;
|
||||
payload.smoothing = parseFloat((document.getElementById('value-source-http-smoothing') as HTMLInputElement).value) || 0;
|
||||
payload.normalize = (document.getElementById('value-source-http-normalize') as HTMLInputElement).checked;
|
||||
if (!payload.http_endpoint_id) {
|
||||
errorEl.textContent = t('value_source.http.endpoint_required');
|
||||
errorEl.style.display = '';
|
||||
@@ -962,6 +1074,26 @@ export async function saveValueSource() {
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
} else if (sourceType === 'template') {
|
||||
const inputs = _getTemplateInputsFromUI();
|
||||
if (inputs === null) return; // toast already shown
|
||||
payload.template = (document.getElementById('value-source-template-expression') as HTMLTextAreaElement).value;
|
||||
payload.inputs = inputs;
|
||||
payload.default_value = parseFloat((document.getElementById('value-source-template-default-value') as HTMLInputElement).value) || 0.0;
|
||||
const evalRaw = (document.getElementById('value-source-template-eval-interval') as HTMLInputElement).value.trim();
|
||||
payload.eval_interval = evalRaw === '' ? null : (parseFloat(evalRaw) || 0);
|
||||
if (!payload.template.trim()) {
|
||||
errorEl.textContent = t('value_source.template.error.invalid_expr');
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
// Final server-side gate: block save when the expression is invalid.
|
||||
const result = await _validateTemplateRequest(payload.template, inputs, id || undefined);
|
||||
if (result && result.valid === false) {
|
||||
errorEl.textContent = (result.errors && result.errors[0]) || result.error || t('value_source.template.error.invalid_expr');
|
||||
errorEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1373,6 +1505,7 @@ const VALUE_BADGE: Record<string, string> = {
|
||||
gradient_map: 'VALUE · MAP',
|
||||
css_extract: 'VALUE · STRIP',
|
||||
system_metrics: 'VALUE · SYS',
|
||||
template: 'VALUE · EXPR',
|
||||
};
|
||||
|
||||
function _valueSourceChipsAndExtras(src: ValueSource): { chips: ModChipOpts[]; metaText: string; extra: string } {
|
||||
@@ -1503,6 +1636,16 @@ function _valueSourceChipsAndExtras(src: ValueSource): { chips: ModChipOpts[]; m
|
||||
const metricLabel = t(`value_source.metric.${(src as any).metric}`) || (src as any).metric;
|
||||
chips.push({ icon: ICON_ACTIVITY, text: metricLabel });
|
||||
metaText = metricLabel;
|
||||
} else if (src.source_type === 'template') {
|
||||
const inputs = (src as any).inputs || [];
|
||||
const nInputs = inputs.length;
|
||||
const expr = ((src as any).template || '').trim();
|
||||
chips.push({
|
||||
icon: ICON_FILE_TEXT,
|
||||
text: `${nInputs} ${nInputs === 1 ? t('value_source.template.input_count_one') : t('value_source.template.input_count')}`,
|
||||
});
|
||||
if (expr) chips.push({ icon: ICON_ACTIVITY, text: expr.length > 28 ? expr.slice(0, 27) + '…' : expr });
|
||||
metaText = `${t('value_source.type.template')} · ${nInputs} ${t('value_source.template.input_count')}`;
|
||||
}
|
||||
|
||||
return { chips, metaText, extra };
|
||||
@@ -1970,3 +2113,302 @@ function _populateCSSSourceDropdown(selectedId: string) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Template (Jinja expression) helpers ────────────────────────
|
||||
|
||||
/** Reserved identifiers an input variable may NOT shadow. */
|
||||
const TEMPLATE_RESERVED_NAMES = new Set([
|
||||
'min', 'max', 'abs', 'round', 'clamp', 'raw', 'range', 'dict', 'namespace',
|
||||
]);
|
||||
const TEMPLATE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
/** Lazily attach the syntax-highlighting overlay to the expression textarea. */
|
||||
function _ensureTemplateEditor() {
|
||||
const ta = document.getElementById('value-source-template-expression') as HTMLTextAreaElement | null;
|
||||
if (!ta) return;
|
||||
if (_vsTemplateEditor) { _vsTemplateEditor.refresh(); return; }
|
||||
_vsTemplateEditor = JinjaEditor.create({
|
||||
textarea: ta,
|
||||
getInputNames: () => _readTemplateInputRows().map(i => i.name).filter(Boolean),
|
||||
onChange: () => _runTemplateValidationDebounced(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Bound-input names that are currently valid identifiers (for the highlighter + hints). */
|
||||
function _currentTemplateVarNames(): string[] {
|
||||
return _readTemplateInputRows()
|
||||
.map(i => i.name)
|
||||
.filter(n => TEMPLATE_IDENT_RE.test(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one `.template-input-row` (a variable-name field + a value-source
|
||||
* EntitySelect + a remove button). Invoked from the inline add button.
|
||||
*/
|
||||
export function addTemplateInput(name: string = '', vsId: string = '') {
|
||||
const list = document.getElementById('value-source-template-inputs-list');
|
||||
if (!list) return;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'template-input-row';
|
||||
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.className = 'template-input-name';
|
||||
nameInput.placeholder = t('value_source.template.input_name');
|
||||
nameInput.value = name;
|
||||
nameInput.spellcheck = false;
|
||||
nameInput.autocomplete = 'off';
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.id = `value-source-template-input-${++_vsTemplateInputUid}`;
|
||||
const floatSources = _templateFloatSources();
|
||||
select.innerHTML = `<option value=""></option>` + floatSources.map(s =>
|
||||
`<option value="${escapeHtml(s.id)}"${s.id === vsId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
select.value = vsId || '';
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'btn btn-icon btn-danger btn-sm';
|
||||
removeBtn.innerHTML = ICON_TRASH;
|
||||
removeBtn.setAttribute('aria-label', t('common.remove') || 'Remove');
|
||||
|
||||
row.appendChild(nameInput);
|
||||
row.appendChild(select);
|
||||
row.appendChild(removeBtn);
|
||||
// Remove any "no inputs yet" placeholder before adding a real row.
|
||||
const empty = list.querySelector('.template-inputs-empty');
|
||||
if (empty) empty.remove();
|
||||
list.appendChild(row);
|
||||
|
||||
const es = new EntitySelect({
|
||||
target: select,
|
||||
getItems: () => _templateFloatSources().map(s => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: getValueSourceIcon(s.source_type),
|
||||
desc: s.source_type,
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
allowNone: true,
|
||||
noneLabel: '—',
|
||||
onChange: () => _runTemplateValidationDebounced(),
|
||||
});
|
||||
_vsTemplateInputSelects.set(select, es);
|
||||
|
||||
nameInput.addEventListener('input', () => {
|
||||
_vsTemplateEditor?.refresh();
|
||||
_runTemplateValidationDebounced();
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', () => {
|
||||
const sel = _vsTemplateInputSelects.get(select);
|
||||
if (sel) { sel.destroy(); _vsTemplateInputSelects.delete(select); }
|
||||
row.remove();
|
||||
if (!list.querySelector('.template-input-row')) _showTemplateInputsEmpty(list);
|
||||
_vsTemplateEditor?.refresh();
|
||||
_renderTemplateHintVars();
|
||||
_runTemplateValidationDebounced();
|
||||
});
|
||||
|
||||
_renderTemplateHintVars();
|
||||
}
|
||||
|
||||
/** Float value sources eligible to bind, excluding the source being edited. */
|
||||
function _templateFloatSources(): ValueSource[] {
|
||||
const selfId = (document.getElementById('value-source-id') as HTMLInputElement).value || undefined;
|
||||
return _cachedValueSources.filter(v => v.return_type === 'float' && v.id !== selfId);
|
||||
}
|
||||
|
||||
function _showTemplateInputsEmpty(list: HTMLElement) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'template-inputs-empty';
|
||||
empty.textContent = t('value_source.template.inputs.empty');
|
||||
list.appendChild(empty);
|
||||
}
|
||||
|
||||
/** Raw row read — no validation, no toast (used by snapshot + the live validator). */
|
||||
function _readTemplateInputRows(): TemplateInput[] {
|
||||
const rows = document.querySelectorAll('#value-source-template-inputs-list .template-input-row');
|
||||
const out: TemplateInput[] = [];
|
||||
rows.forEach(row => {
|
||||
const name = (row.querySelector('.template-input-name') as HTMLInputElement)?.value.trim() ?? '';
|
||||
const select = row.querySelector('select') as HTMLSelectElement | null;
|
||||
out.push({ name, value_source_id: select?.value || '' });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validated read for save: every name must be a unique, non-reserved, valid
|
||||
* identifier. Shows a toast and returns null on the first failure.
|
||||
*/
|
||||
function _getTemplateInputsFromUI(): TemplateInput[] | null {
|
||||
const rows = _readTemplateInputRows();
|
||||
const seen = new Set<string>();
|
||||
for (const inp of rows) {
|
||||
if (!inp.name) {
|
||||
showToast(t('value_source.template.error.missing_input'), 'error');
|
||||
return null;
|
||||
}
|
||||
if (!TEMPLATE_IDENT_RE.test(inp.name)) {
|
||||
showToast(`${t('value_source.template.error.invalid_name')}: ${inp.name}`, 'error');
|
||||
return null;
|
||||
}
|
||||
if (TEMPLATE_RESERVED_NAMES.has(inp.name)) {
|
||||
showToast(`${t('value_source.template.error.reserved_name')}: ${inp.name}`, 'error');
|
||||
return null;
|
||||
}
|
||||
if (seen.has(inp.name)) {
|
||||
showToast(`${t('value_source.template.error.duplicate_name')}: ${inp.name}`, 'error');
|
||||
return null;
|
||||
}
|
||||
seen.add(inp.name);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Rebuild the inputs list from scratch (destroying any leaked EntitySelects). */
|
||||
function _populateTemplateInputsUI(inputs: TemplateInput[]) {
|
||||
_vsTemplateInputSelects.forEach(sel => sel.destroy());
|
||||
_vsTemplateInputSelects.clear();
|
||||
const list = document.getElementById('value-source-template-inputs-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
if (inputs.length === 0) {
|
||||
_showTemplateInputsEmpty(list);
|
||||
} else {
|
||||
inputs.forEach(i => addTemplateInput(i.name, i.value_source_id));
|
||||
}
|
||||
_vsTemplateEditor?.refresh();
|
||||
_renderTemplateHintVars();
|
||||
}
|
||||
|
||||
/** Paint the live list of bound variable chips into the hints panel. */
|
||||
function _renderTemplateHintVars() {
|
||||
const host = document.getElementById('value-source-template-hint-vars');
|
||||
if (!host) return;
|
||||
const names = _currentTemplateVarNames();
|
||||
if (names.length === 0) {
|
||||
host.innerHTML = `<span class="jinja-hints-empty">${escapeHtml(t('value_source.template.hints.no_inputs'))}</span>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = names
|
||||
.map(n => `<span class="tok-var-chip">${escapeHtml(n)}</span>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// ── Live validation (debounced + generation-tagged) ────────────
|
||||
|
||||
interface TemplateValidateResult {
|
||||
valid: boolean;
|
||||
error: string | null;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
variables: string[];
|
||||
}
|
||||
|
||||
/** Enable/disable the value-source modal's primary (save) button. */
|
||||
function _setVSSaveEnabled(enabled: boolean) {
|
||||
const btn = document.querySelector('#value-source-modal .modal-footer .btn-primary') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = !enabled;
|
||||
}
|
||||
|
||||
function _runTemplateValidationDebounced() {
|
||||
if (_vsTemplateEditor) _vsTemplateEditor.refresh();
|
||||
_renderTemplateHintVars();
|
||||
if (_templateValidateTimer) clearTimeout(_templateValidateTimer);
|
||||
_templateValidateTimer = setTimeout(() => { void _validateTemplate(); }, 300);
|
||||
}
|
||||
|
||||
/** POST the current expression/inputs to the validate endpoint (no UI side effects). */
|
||||
async function _validateTemplateRequest(
|
||||
template: string, inputs: TemplateInput[], id?: string,
|
||||
): Promise<TemplateValidateResult | null> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/value-sources/validate-template', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template, inputs, ...(id ? { id } : {}) }),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json() as TemplateValidateResult;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Live validator: paints inline error/ok/warn state and gates the save button. */
|
||||
async function _validateTemplate() {
|
||||
const editor = document.querySelector('#value-source-template-section .jinja-editor') as HTMLElement | null;
|
||||
const errEl = document.getElementById('value-source-template-error') as HTMLElement | null;
|
||||
const okEl = document.getElementById('value-source-template-ok') as HTMLElement | null;
|
||||
const warnEl = document.getElementById('value-source-template-warn') as HTMLElement | null;
|
||||
const ta = document.getElementById('value-source-template-expression') as HTMLTextAreaElement | null;
|
||||
if (!ta) return;
|
||||
|
||||
// If the user switched away from template while this was pending, do nothing.
|
||||
if ((document.getElementById('value-source-type') as HTMLSelectElement).value !== 'template') {
|
||||
_setVSSaveEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const template = ta.value;
|
||||
// Drop blank-name rows: TemplateInput.name is min_length=1 server-side, so
|
||||
// posting a half-typed row would 422 and silently blank the live validator.
|
||||
const inputs = _readTemplateInputRows().filter(i => i.name.trim());
|
||||
const id = (document.getElementById('value-source-id') as HTMLInputElement).value || undefined;
|
||||
|
||||
const gen = ++_templateValidateGen;
|
||||
const result = await _validateTemplateRequest(template, inputs, id);
|
||||
if (gen !== _templateValidateGen) return; // a newer request superseded this one
|
||||
// The user may have switched away from template during the await — never
|
||||
// touch a non-template form's save state.
|
||||
if ((document.getElementById('value-source-type') as HTMLSelectElement).value !== 'template') {
|
||||
_setVSSaveEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const setSaveEnabled = _setVSSaveEnabled;
|
||||
|
||||
const clearMsgs = () => {
|
||||
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
||||
if (okEl) { okEl.style.display = 'none'; okEl.innerHTML = ''; }
|
||||
if (warnEl) { warnEl.style.display = 'none'; warnEl.textContent = ''; }
|
||||
};
|
||||
|
||||
if (!result) {
|
||||
// Network/endpoint failure — don't block the user; clear inline state.
|
||||
editor?.classList.remove('field-invalid');
|
||||
clearMsgs();
|
||||
setSaveEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
clearMsgs();
|
||||
if (result.valid === false) {
|
||||
editor?.classList.add('field-invalid');
|
||||
if (errEl) {
|
||||
errEl.innerHTML = `${ICON_X}<span></span>`;
|
||||
const span = errEl.querySelector('span');
|
||||
if (span) span.textContent = (result.errors && result.errors[0]) || result.error || t('value_source.template.error.invalid_expr');
|
||||
errEl.style.display = '';
|
||||
}
|
||||
setSaveEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
editor?.classList.remove('field-invalid');
|
||||
setSaveEnabled(true);
|
||||
if (okEl && template.trim()) {
|
||||
okEl.innerHTML = `${ICON_CHECK}<span></span>`;
|
||||
const span = okEl.querySelector('span');
|
||||
if (span) span.textContent = t('value_source.template.valid');
|
||||
okEl.style.display = '';
|
||||
}
|
||||
if (warnEl && result.warnings && result.warnings.length > 0) {
|
||||
warnEl.textContent = result.warnings.join(' · ');
|
||||
warnEl.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -331,11 +331,13 @@ startTargetOverlay: (...args: any[]) => any;
|
||||
deleteValueSource: (...args: any[]) => any;
|
||||
onValueSourceTypeChange: (...args: any[]) => any;
|
||||
onDaylightVSRealTimeChange: (...args: any[]) => any;
|
||||
onValueSourceNormalizeChange: (...args: any[]) => any;
|
||||
addSchedulePoint: (...args: any[]) => any;
|
||||
addAnimatedColor: (...args: any[]) => any;
|
||||
removeAnimatedColor: (...args: any[]) => any;
|
||||
addColorSchedulePoint: (...args: any[]) => any;
|
||||
removeColorSchedulePoint: (...args: any[]) => any;
|
||||
addTemplateInput: (...args: any[]) => any;
|
||||
testValueSource: (...args: any[]) => any;
|
||||
closeTestValueSourceModal: (...args: any[]) => any;
|
||||
|
||||
@@ -379,6 +381,7 @@ startTargetOverlay: (...args: any[]) => any;
|
||||
graphRelayout: (...args: any[]) => any;
|
||||
graphShowIssues: (...args: any[]) => any;
|
||||
graphExportTopology: (...args: any[]) => any;
|
||||
graphDuplicateSelection: (...args: any[]) => any;
|
||||
graphToggleFullscreen: (...args: any[]) => any;
|
||||
graphAddEntity: (...args: any[]) => any;
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ export type {
|
||||
SystemMetricsValueSource,
|
||||
GameEventValueSource,
|
||||
HTTPValueSource,
|
||||
TemplateValueSource,
|
||||
TemplateInput,
|
||||
ValueSource,
|
||||
ValueSourceListResponse,
|
||||
} from './types/value-source.ts';
|
||||
|
||||
@@ -9,7 +9,12 @@ export type ValueSourceType =
|
||||
| 'adaptive_time' | 'adaptive_scene' | 'daylight'
|
||||
| 'static_color' | 'animated_color' | 'adaptive_time_color'
|
||||
| 'ha_entity' | 'gradient_map' | 'css_extract'
|
||||
| 'system_metrics' | 'game_event' | 'http';
|
||||
| 'system_metrics' | 'game_event' | 'http' | 'template';
|
||||
|
||||
export interface TemplateInput {
|
||||
name: string;
|
||||
value_source_id: string;
|
||||
}
|
||||
|
||||
export interface SchedulePoint {
|
||||
time: string;
|
||||
@@ -121,6 +126,7 @@ export interface HAEntityValueSource extends ValueSourceBase {
|
||||
min_ha_value: number;
|
||||
max_ha_value: number;
|
||||
smoothing: number;
|
||||
normalize: boolean;
|
||||
}
|
||||
|
||||
export interface GradientMapValueSource extends ValueSourceBase {
|
||||
@@ -150,6 +156,7 @@ export interface SystemMetricsValueSource extends ValueSourceBase {
|
||||
sensor_label: string;
|
||||
poll_interval: number;
|
||||
smoothing: number;
|
||||
normalize: boolean;
|
||||
}
|
||||
|
||||
export interface GameEventValueSource extends ValueSourceBase {
|
||||
@@ -173,6 +180,16 @@ export interface HTTPValueSource extends ValueSourceBase {
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
smoothing: number;
|
||||
normalize: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateValueSource extends ValueSourceBase {
|
||||
source_type: 'template';
|
||||
return_type: 'float';
|
||||
template: string;
|
||||
inputs: TemplateInput[];
|
||||
default_value: number;
|
||||
eval_interval?: number | null;
|
||||
}
|
||||
|
||||
export type ValueSource =
|
||||
@@ -190,7 +207,8 @@ export type ValueSource =
|
||||
| CSSExtractValueSource
|
||||
| SystemMetricsValueSource
|
||||
| GameEventValueSource
|
||||
| HTTPValueSource;
|
||||
| HTTPValueSource
|
||||
| TemplateValueSource;
|
||||
|
||||
export interface ValueSourceListResponse {
|
||||
sources: ValueSource[];
|
||||
|
||||
@@ -1985,6 +1985,8 @@
|
||||
"value_source.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.",
|
||||
"value_source.daylight.use_real_time": "Use Real Time:",
|
||||
"value_source.daylight.use_real_time.hint": "When enabled, the value follows the actual time of day. Speed is ignored.",
|
||||
"value_source.normalize": "Normalize to 0–1:",
|
||||
"value_source.normalize.hint": "On: rescale the raw value to 0–1 using Min/Max. Off: the value is clamped to 0–1 as-is (for sources that already report a 0–1 fraction). The raw value stays available to templates (raw[name]) and automations.",
|
||||
"value_source.daylight.enable_real_time": "Follow wall clock",
|
||||
"value_source.daylight.latitude": "Latitude:",
|
||||
"value_source.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Steepens or flattens the sunrise/sunset edges of the cycle.",
|
||||
@@ -2590,7 +2592,10 @@
|
||||
"graph.action.connect": "Connect",
|
||||
"graph.action.disconnect": "Disconnect",
|
||||
"graph.action.move": "Move node",
|
||||
"graph.action.rewire": "Re-wire slot",
|
||||
"graph.choose_connection": "Choose connection",
|
||||
"graph.rewire": "Re-wire…",
|
||||
"graph.rewire_choose_source": "Choose a new source",
|
||||
"graph.issues": "Issues",
|
||||
"graph.issues_none": "No issues found",
|
||||
"graph.issue.broken_ref": "Broken reference: {field}",
|
||||
@@ -2601,6 +2606,12 @@
|
||||
"graph.export": "Export graph (JSON)",
|
||||
"graph.export_done": "Graph exported",
|
||||
"graph.export_failed": "Failed to export graph",
|
||||
"graph.duplicate": "Duplicate selection",
|
||||
"graph.duplicate_none": "Select one or more nodes to duplicate",
|
||||
"graph.duplicate_none_eligible": "Nothing duplicable in the selection (only value & colour-strip sources)",
|
||||
"graph.duplicate_done": "Duplicated {count} source(s)",
|
||||
"graph.duplicate_done_warn": "Duplicated {count} source(s) — some references could not be remapped",
|
||||
"graph.duplicate_failed": "Failed to duplicate selection",
|
||||
"graph.delete_with_dependents_confirm": "This entity is used by {count} other(s): {names}. Delete it and break those connections?",
|
||||
"automation.enabled": "Automation enabled",
|
||||
"automation.disabled": "Automation disabled",
|
||||
@@ -3064,6 +3075,39 @@
|
||||
"value_source.http.modulator.hint": "Only used when this source drives brightness or color. Automation rules read the raw extracted value and ignore these settings.",
|
||||
"value_source.http.endpoint_required": "HTTP endpoint is required",
|
||||
"value_source.http.interval_invalid": "Interval must be at least 1 second",
|
||||
"value_source.type.template": "Jinja Template",
|
||||
"value_source.type.template.desc": "Combine bound inputs with a sandboxed Jinja expression to compute a 0-1 value.",
|
||||
"value_source.template.expression": "Expression:",
|
||||
"value_source.template.expression.hint": "A sandboxed Jinja expression returning a number. Bound inputs are available by name; use raw[name] for the un-normalized value.",
|
||||
"value_source.template.expression.placeholder": "clamp((temp - 18) / 10, 0, 1)",
|
||||
"value_source.template.inputs": "Inputs:",
|
||||
"value_source.template.inputs.hint": "Bind float value sources to variable names you reference in the expression.",
|
||||
"value_source.template.inputs.empty": "No inputs yet. Click + to bind a value source.",
|
||||
"value_source.template.add_input": "+ Add Input",
|
||||
"value_source.template.input_name": "variable name",
|
||||
"value_source.template.input_count": "inputs",
|
||||
"value_source.template.input_count_one": "input",
|
||||
"value_source.template.default_value": "Default Value:",
|
||||
"value_source.template.default_value.hint": "Output used when the expression cannot be evaluated (e.g. an input is missing).",
|
||||
"value_source.template.eval_interval": "Eval Interval (s):",
|
||||
"value_source.template.eval_interval.hint": "How often to re-evaluate the expression. 0 = every poll (re-evaluate as fast as the inputs update).",
|
||||
"value_source.template.valid": "Expression is valid",
|
||||
"value_source.template.hints.title": "Expression help",
|
||||
"value_source.template.hints.inputs_title": "Bound inputs",
|
||||
"value_source.template.hints.no_inputs": "No inputs bound yet",
|
||||
"value_source.template.hints.globals_title": "Globals",
|
||||
"value_source.template.hints.globals": "min(a, b), max(a, b), abs(x), round(x), clamp(x, lo, hi)",
|
||||
"value_source.template.hints.raw_title": "Raw values",
|
||||
"value_source.template.hints.raw": "raw[name] gives the un-normalized value of an input that has one.",
|
||||
"value_source.template.hints.examples_title": "Examples",
|
||||
"value_source.template.hints.time": "Tip: for time-of-day logic, bind an Adaptive (Time) or Daylight source as an input.",
|
||||
"value_source.template.error.invalid_expr": "Expression is invalid",
|
||||
"value_source.template.error.cycle": "This expression would create a dependency cycle",
|
||||
"value_source.template.error.missing_input": "Every input needs a variable name",
|
||||
"value_source.template.error.invalid_name": "Invalid variable name",
|
||||
"value_source.template.error.reserved_name": "Reserved name cannot be used as an input",
|
||||
"value_source.template.error.duplicate_name": "Duplicate input name",
|
||||
"value_source.template.error.unbound": "Expression references an unbound variable",
|
||||
"automations.rule.http_poll": "HTTP Poll",
|
||||
"automations.rule.http_poll.desc": "Activate when the latest extracted value from an HTTP value source matches.",
|
||||
"automations.rule.http_poll.hint": "Compares the latest extracted value against your input. The value source decides what gets extracted (raw body or JSON path).",
|
||||
|
||||
@@ -1845,6 +1845,8 @@
|
||||
"value_source.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.",
|
||||
"value_source.daylight.use_real_time": "Реальное время:",
|
||||
"value_source.daylight.use_real_time.hint": "Значение следует за реальным временем суток. Скорость игнорируется.",
|
||||
"value_source.normalize": "Нормализовать в 0–1:",
|
||||
"value_source.normalize.hint": "Вкл.: масштабировать сырое значение в 0–1 по Min/Max. Выкл.: значение ограничивается диапазоном 0–1 как есть (для источников, уже выдающих долю 0–1). Сырое значение остаётся доступным в шаблонах (raw[name]) и автоматизациях.",
|
||||
"value_source.daylight.enable_real_time": "Следовать за часами",
|
||||
"value_source.daylight.latitude": "Широта:",
|
||||
"value_source.daylight.latitude.hint": "Географическая широта (-90 до 90). Делает переходы рассвета и заката круче или плавнее.",
|
||||
@@ -2272,7 +2274,10 @@
|
||||
"graph.action.connect": "Соединить",
|
||||
"graph.action.disconnect": "Отсоединить",
|
||||
"graph.action.move": "Переместить узел",
|
||||
"graph.action.rewire": "Переподключить слот",
|
||||
"graph.choose_connection": "Выберите соединение",
|
||||
"graph.rewire": "Переподключить…",
|
||||
"graph.rewire_choose_source": "Выберите новый источник",
|
||||
"graph.issues": "Проблемы",
|
||||
"graph.issues_none": "Проблем не найдено",
|
||||
"graph.issue.broken_ref": "Битая ссылка: {field}",
|
||||
@@ -2283,6 +2288,12 @@
|
||||
"graph.export": "Экспорт графа (JSON)",
|
||||
"graph.export_done": "Граф экспортирован",
|
||||
"graph.export_failed": "Не удалось экспортировать граф",
|
||||
"graph.duplicate": "Дублировать выбранное",
|
||||
"graph.duplicate_none": "Выберите один или несколько узлов для дублирования",
|
||||
"graph.duplicate_none_eligible": "В выборе нечего дублировать (только источники значений и цветовых лент)",
|
||||
"graph.duplicate_done": "Продублировано источников: {count}",
|
||||
"graph.duplicate_done_warn": "Продублировано источников: {count} — часть ссылок не удалось перепривязать",
|
||||
"graph.duplicate_failed": "Не удалось дублировать выбранное",
|
||||
"graph.delete_with_dependents_confirm": "Этот объект используется {count} другими: {names}. Удалить и разорвать эти связи?",
|
||||
"automation.enabled": "Автоматизация включена",
|
||||
"automation.disabled": "Автоматизация выключена",
|
||||
@@ -2746,6 +2757,39 @@
|
||||
"value_source.http.modulator.hint": "Используется только когда этот источник управляет яркостью или цветом. Правила автоматизации читают извлечённое значение в исходном виде и игнорируют эти настройки.",
|
||||
"value_source.http.endpoint_required": "Требуется HTTP-эндпоинт",
|
||||
"value_source.http.interval_invalid": "Интервал должен быть не меньше 1 секунды",
|
||||
"value_source.type.template": "Шаблон Jinja",
|
||||
"value_source.type.template.desc": "Объедините привязанные входы в изолированном выражении Jinja для вычисления значения 0-1.",
|
||||
"value_source.template.expression": "Выражение:",
|
||||
"value_source.template.expression.hint": "Изолированное выражение Jinja, возвращающее число. Привязанные входы доступны по имени; используйте raw[name] для ненормализованного значения.",
|
||||
"value_source.template.expression.placeholder": "clamp((temp - 18) / 10, 0, 1)",
|
||||
"value_source.template.inputs": "Входы:",
|
||||
"value_source.template.inputs.hint": "Привяжите числовые источники значений к именам переменных, используемым в выражении.",
|
||||
"value_source.template.inputs.empty": "Пока нет входов. Нажмите +, чтобы привязать источник значений.",
|
||||
"value_source.template.add_input": "+ Добавить вход",
|
||||
"value_source.template.input_name": "имя переменной",
|
||||
"value_source.template.input_count": "входов",
|
||||
"value_source.template.input_count_one": "вход",
|
||||
"value_source.template.default_value": "Значение по умолчанию:",
|
||||
"value_source.template.default_value.hint": "Значение на выходе, когда выражение нельзя вычислить (например, отсутствует вход).",
|
||||
"value_source.template.eval_interval": "Интервал вычисления (с):",
|
||||
"value_source.template.eval_interval.hint": "Как часто пересчитывать выражение. 0 = при каждом опросе (так быстро, как обновляются входы).",
|
||||
"value_source.template.valid": "Выражение корректно",
|
||||
"value_source.template.hints.title": "Справка по выражению",
|
||||
"value_source.template.hints.inputs_title": "Привязанные входы",
|
||||
"value_source.template.hints.no_inputs": "Входы ещё не привязаны",
|
||||
"value_source.template.hints.globals_title": "Глобальные функции",
|
||||
"value_source.template.hints.globals": "min(a, b), max(a, b), abs(x), round(x), clamp(x, lo, hi)",
|
||||
"value_source.template.hints.raw_title": "Исходные значения",
|
||||
"value_source.template.hints.raw": "raw[name] даёт ненормализованное значение входа, если оно есть.",
|
||||
"value_source.template.hints.examples_title": "Примеры",
|
||||
"value_source.template.hints.time": "Совет: для логики времени суток привяжите источник «Адаптивный (время)» или «Дневной цикл» как вход.",
|
||||
"value_source.template.error.invalid_expr": "Некорректное выражение",
|
||||
"value_source.template.error.cycle": "Это выражение создало бы циклическую зависимость",
|
||||
"value_source.template.error.missing_input": "Каждому входу нужно имя переменной",
|
||||
"value_source.template.error.invalid_name": "Некорректное имя переменной",
|
||||
"value_source.template.error.reserved_name": "Зарезервированное имя нельзя использовать как вход",
|
||||
"value_source.template.error.duplicate_name": "Повторяющееся имя входа",
|
||||
"value_source.template.error.unbound": "Выражение ссылается на непривязанную переменную",
|
||||
"automations.rule.http_poll": "HTTP-опрос",
|
||||
"automations.rule.http_poll.desc": "Срабатывает, когда последнее значение HTTP-источника соответствует условию.",
|
||||
"automations.rule.http_poll.hint": "Сравнивает последнее извлечённое значение с вашим вводом. Что именно извлекается (тело или JSON-путь), задаётся в источнике-значении.",
|
||||
|
||||
@@ -1841,6 +1841,8 @@
|
||||
"value_source.daylight.speed.hint": "周期速度倍率。1.0 = 完整日夜周期约4分钟。",
|
||||
"value_source.daylight.use_real_time": "使用实时:",
|
||||
"value_source.daylight.use_real_time.hint": "启用后,数值跟随实际时间。速度设置将被忽略。",
|
||||
"value_source.normalize": "归一化到 0–1:",
|
||||
"value_source.normalize.hint": "开启:使用最小/最大值将原始值缩放到 0–1。关闭:直接将数值钳制到 0–1(适用于本身就输出 0–1 比例的来源)。原始值始终可在模板(raw[name])和自动化中使用。",
|
||||
"value_source.daylight.enable_real_time": "跟随系统时钟",
|
||||
"value_source.daylight.latitude": "纬度:",
|
||||
"value_source.daylight.latitude.hint": "地理纬度(-90到90)。使日出/日落过渡更陡峭或更平缓。",
|
||||
@@ -2268,7 +2270,10 @@
|
||||
"graph.action.connect": "连接",
|
||||
"graph.action.disconnect": "断开连接",
|
||||
"graph.action.move": "移动节点",
|
||||
"graph.action.rewire": "重新连接槽位",
|
||||
"graph.choose_connection": "选择连接",
|
||||
"graph.rewire": "重新连接…",
|
||||
"graph.rewire_choose_source": "选择新的来源",
|
||||
"graph.issues": "问题",
|
||||
"graph.issues_none": "未发现问题",
|
||||
"graph.issue.broken_ref": "无效引用:{field}",
|
||||
@@ -2279,6 +2284,12 @@
|
||||
"graph.export": "导出图谱 (JSON)",
|
||||
"graph.export_done": "图谱已导出",
|
||||
"graph.export_failed": "导出图谱失败",
|
||||
"graph.duplicate": "复制所选",
|
||||
"graph.duplicate_none": "请选择一个或多个节点以复制",
|
||||
"graph.duplicate_none_eligible": "所选内容中没有可复制的项(仅值源和色带源)",
|
||||
"graph.duplicate_done": "已复制 {count} 个源",
|
||||
"graph.duplicate_done_warn": "已复制 {count} 个源 — 部分引用无法重新映射",
|
||||
"graph.duplicate_failed": "复制所选失败",
|
||||
"graph.delete_with_dependents_confirm": "此实体被 {count} 个其他实体引用:{names}。删除并断开这些连接?",
|
||||
"automation.enabled": "自动化已启用",
|
||||
"automation.disabled": "自动化已禁用",
|
||||
@@ -2740,6 +2751,39 @@
|
||||
"value_source.http.modulator.hint": "仅当此源用于驱动亮度或颜色时使用。自动化规则会直接读取提取的原始值,并忽略这些设置。",
|
||||
"value_source.http.endpoint_required": "需要 HTTP 端点",
|
||||
"value_source.http.interval_invalid": "间隔至少为 1 秒",
|
||||
"value_source.type.template": "Jinja 模板",
|
||||
"value_source.type.template.desc": "使用沙盒化的 Jinja 表达式组合已绑定的输入,计算出 0-1 的值。",
|
||||
"value_source.template.expression": "表达式:",
|
||||
"value_source.template.expression.hint": "返回数字的沙盒化 Jinja 表达式。已绑定的输入可按名称使用;使用 raw[name] 获取未归一化的值。",
|
||||
"value_source.template.expression.placeholder": "clamp((temp - 18) / 10, 0, 1)",
|
||||
"value_source.template.inputs": "输入:",
|
||||
"value_source.template.inputs.hint": "将浮点值源绑定到你在表达式中引用的变量名。",
|
||||
"value_source.template.inputs.empty": "暂无输入。点击 + 绑定一个值源。",
|
||||
"value_source.template.add_input": "+ 添加输入",
|
||||
"value_source.template.input_name": "变量名",
|
||||
"value_source.template.input_count": "个输入",
|
||||
"value_source.template.input_count_one": "个输入",
|
||||
"value_source.template.default_value": "默认值:",
|
||||
"value_source.template.default_value.hint": "当表达式无法求值(例如缺少输入)时使用的输出值。",
|
||||
"value_source.template.eval_interval": "求值间隔(秒):",
|
||||
"value_source.template.eval_interval.hint": "重新求值表达式的频率。0 = 每次轮询(随输入更新一样快地重新求值)。",
|
||||
"value_source.template.valid": "表达式有效",
|
||||
"value_source.template.hints.title": "表达式帮助",
|
||||
"value_source.template.hints.inputs_title": "已绑定的输入",
|
||||
"value_source.template.hints.no_inputs": "尚未绑定输入",
|
||||
"value_source.template.hints.globals_title": "全局函数",
|
||||
"value_source.template.hints.globals": "min(a, b), max(a, b), abs(x), round(x), clamp(x, lo, hi)",
|
||||
"value_source.template.hints.raw_title": "原始值",
|
||||
"value_source.template.hints.raw": "raw[name] 给出某个输入的未归一化值(如果有)。",
|
||||
"value_source.template.hints.examples_title": "示例",
|
||||
"value_source.template.hints.time": "提示:要实现一天中的时间逻辑,请将“自适应(时间)”或“日光周期”源绑定为输入。",
|
||||
"value_source.template.error.invalid_expr": "表达式无效",
|
||||
"value_source.template.error.cycle": "该表达式会造成依赖循环",
|
||||
"value_source.template.error.missing_input": "每个输入都需要一个变量名",
|
||||
"value_source.template.error.invalid_name": "变量名无效",
|
||||
"value_source.template.error.reserved_name": "保留名称不能用作输入",
|
||||
"value_source.template.error.duplicate_name": "输入名称重复",
|
||||
"value_source.template.error.unbound": "表达式引用了未绑定的变量",
|
||||
"automations.rule.http_poll": "HTTP 轮询",
|
||||
"automations.rule.http_poll.desc": "当 HTTP 值源的最新提取值匹配时激活。",
|
||||
"automations.rule.http_poll.hint": "将最新的提取值与您的输入进行比较。提取的内容(原始响应体或 JSON 路径)由值源决定。",
|
||||
|
||||
@@ -6,7 +6,10 @@ writes go through to SQLite immediately (write-through cache).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Dict, Generic, List, TypeVar
|
||||
|
||||
from ledgrab.storage.database import Database
|
||||
@@ -26,6 +29,10 @@ class BaseSqliteStore(Generic[T]):
|
||||
|
||||
_table_name: str
|
||||
_entity_name: str
|
||||
# Opt-in allowlist for clone(): defaults off so a new (possibly
|
||||
# secret-bearing) store is never cloneable by accident. Subclasses that hold
|
||||
# no inline secrets and are safe to duplicate set this True.
|
||||
_cloneable: bool = False
|
||||
|
||||
def __init__(self, db: Database, deserializer: Callable[[dict], T]):
|
||||
self._db = db
|
||||
@@ -136,13 +143,50 @@ class BaseSqliteStore(Generic[T]):
|
||||
await asyncio.to_thread(self._delete_item, item_id)
|
||||
logger.info(f"Deleted {self._entity_name}: {item_id}")
|
||||
|
||||
def clone(self, item_id: str, new_name: str) -> T:
|
||||
"""Faithfully duplicate an entity under a new id and name.
|
||||
|
||||
Deep-copies every field of the original (no serialize/deserialize
|
||||
round-trip, so no field can be silently lost to a schema/dataclass name
|
||||
mismatch), mints a fresh id that preserves the original's id prefix,
|
||||
applies ``new_name`` and resets timestamps. References *inside* the clone
|
||||
still point at whatever the original referenced — callers that want to
|
||||
rewire intra-set references must do so after cloning.
|
||||
|
||||
SECURITY: this copies *every* field verbatim, including any secret a
|
||||
model might hold. Callers must restrict cloning to non-secret-bearing
|
||||
kinds (see ``_DUPLICABLE_KINDS`` in ``api/routes/graph.py``).
|
||||
"""
|
||||
if not self._cloneable:
|
||||
raise NotImplementedError(f"{self._entity_name} store does not support cloning")
|
||||
with self._lock:
|
||||
if item_id not in self._items:
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
|
||||
raise EntityNotFoundError(f"{self._entity_name} not found: {item_id}")
|
||||
self._check_name_unique(new_name)
|
||||
new = copy.deepcopy(self._items[item_id])
|
||||
prefix = item_id.rsplit("_", 1)[0] if "_" in item_id else item_id
|
||||
new_id = f"{prefix}_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.now(timezone.utc)
|
||||
new.id = new_id
|
||||
new.name = new_name
|
||||
if hasattr(new, "created_at"):
|
||||
new.created_at = now
|
||||
if hasattr(new, "updated_at"):
|
||||
new.updated_at = now
|
||||
self._items[new_id] = new
|
||||
self._save_item(new_id, new)
|
||||
logger.info("Cloned %s %s -> %s (%s)", self._entity_name, item_id, new_id, new_name)
|
||||
return new
|
||||
|
||||
def count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._items)
|
||||
|
||||
# -- Helpers -------------------------------------------------------------
|
||||
|
||||
def _check_name_unique(self, name: str, exclude_id: str = None) -> None:
|
||||
def _check_name_unique(self, name: str, exclude_id: str | None = None) -> None:
|
||||
"""Raise ValueError if *name* is empty or already taken.
|
||||
|
||||
Must be called while holding ``self._lock``.
|
||||
|
||||
@@ -33,6 +33,7 @@ class ColorStripStore(BaseSqliteStore[ColorStripSource]):
|
||||
|
||||
_table_name = "color_strip_sources"
|
||||
_entity_name = "Color strip source"
|
||||
_cloneable = True # no inline secrets — only references shared entities by id
|
||||
|
||||
def __init__(self, db: Database):
|
||||
super().__init__(db, ColorStripSource.from_dict)
|
||||
|
||||
@@ -66,6 +66,11 @@ class ValueSource:
|
||||
"use_real_time": None,
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
# Template (Jinja expression combinator)
|
||||
"template": None,
|
||||
"inputs": None,
|
||||
"default_value": None,
|
||||
"eval_interval": None,
|
||||
}
|
||||
if self.icon:
|
||||
d["icon"] = self.icon
|
||||
@@ -391,6 +396,11 @@ class HAEntityValueSource(ValueSource):
|
||||
min_ha_value: float = 0.0 # raw HA value mapped to output 0.0
|
||||
max_ha_value: float = 100.0 # raw HA value mapped to output 1.0
|
||||
smoothing: float = 0.0 # EMA smoothing factor (0.0–1.0)
|
||||
# When False, skip the min/max rescale: get_value() clamps the raw value
|
||||
# into [0,1] as-is (for entities that already report a 0–1 fraction). The
|
||||
# un-clamped magnitude stays available via get_raw_value(). get_value() is
|
||||
# always in [0,1] regardless, so the normalized scalar bus is preserved.
|
||||
normalize: bool = True
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
@@ -400,6 +410,7 @@ class HAEntityValueSource(ValueSource):
|
||||
d["min_ha_value"] = self.min_ha_value
|
||||
d["max_ha_value"] = self.max_ha_value
|
||||
d["smoothing"] = self.smoothing
|
||||
d["normalize"] = self.normalize
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -416,6 +427,7 @@ class HAEntityValueSource(ValueSource):
|
||||
data.get("max_ha_value") if data.get("max_ha_value") is not None else 100.0
|
||||
),
|
||||
smoothing=float(data.get("smoothing") or 0.0),
|
||||
normalize=bool(data.get("normalize", True)),
|
||||
)
|
||||
|
||||
|
||||
@@ -514,6 +526,13 @@ class GameEventValueSource(ValueSource):
|
||||
smoothing: float = 0.0 # EMA smoothing factor (0.0-1.0)
|
||||
default_value: float = 0.5 # value when timed out or no events
|
||||
timeout: float = 5.0 # seconds before reverting to default
|
||||
# When False, skip the min/max rescale: get_value() clamps the raw game
|
||||
# value into [0,1] as-is. The un-clamped value stays available via
|
||||
# get_raw_value(). get_value() is always in [0,1]. See HAEntityValueSource.
|
||||
# NOTE: game_event has no value-source CRUD schema/API (constructed only via
|
||||
# the game-integration path), so this flag is settable only there, not over
|
||||
# POST/PUT /value-sources.
|
||||
normalize: bool = True
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
@@ -524,6 +543,7 @@ class GameEventValueSource(ValueSource):
|
||||
d["smoothing"] = self.smoothing
|
||||
d["default_value"] = self.default_value
|
||||
d["timeout"] = self.timeout
|
||||
d["normalize"] = self.normalize
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -543,6 +563,7 @@ class GameEventValueSource(ValueSource):
|
||||
data.get("default_value") if data.get("default_value") is not None else 0.5
|
||||
),
|
||||
timeout=float(data.get("timeout") if data.get("timeout") is not None else 5.0),
|
||||
normalize=bool(data.get("normalize", True)),
|
||||
)
|
||||
|
||||
|
||||
@@ -562,6 +583,10 @@ class SystemMetricsValueSource(ValueSource):
|
||||
sensor_label: str = "" # for cpu_temp/fan_speed (empty = first available)
|
||||
poll_interval: float = 1.0 # seconds between reads
|
||||
smoothing: float = 0.0 # EMA smoothing factor
|
||||
# When False, skip the min/max rescale: get_value() clamps the raw metric
|
||||
# into [0,1] as-is. The un-clamped reading stays available via
|
||||
# get_raw_value(). get_value() is always in [0,1]. See HAEntityValueSource.
|
||||
normalize: bool = True
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
@@ -573,6 +598,7 @@ class SystemMetricsValueSource(ValueSource):
|
||||
d["sensor_label"] = self.sensor_label
|
||||
d["poll_interval"] = self.poll_interval
|
||||
d["smoothing"] = self.smoothing
|
||||
d["normalize"] = self.normalize
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -591,6 +617,7 @@ class SystemMetricsValueSource(ValueSource):
|
||||
sensor_label=data.get("sensor_label") or "",
|
||||
poll_interval=float(data.get("poll_interval") or 1.0),
|
||||
smoothing=float(data.get("smoothing") or 0.0),
|
||||
normalize=bool(data.get("normalize", True)),
|
||||
)
|
||||
|
||||
|
||||
@@ -617,6 +644,11 @@ class HTTPValueSource(ValueSource):
|
||||
min_value: float = 0.0 # raw value → 0.0
|
||||
max_value: float = 100.0 # raw value → 1.0
|
||||
smoothing: float = 0.0 # EMA smoothing on the normalized output
|
||||
# When False, skip the min/max rescale: get_value() coerces the extracted
|
||||
# value to float and clamps it into [0,1] as-is. The verbatim extracted
|
||||
# value (which may be str/bool) stays available via get_raw_value().
|
||||
# get_value() is always a float in [0,1]. See HAEntityValueSource.
|
||||
normalize: bool = True
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
@@ -626,6 +658,7 @@ class HTTPValueSource(ValueSource):
|
||||
d["min_value"] = self.min_value
|
||||
d["max_value"] = self.max_value
|
||||
d["smoothing"] = self.smoothing
|
||||
d["normalize"] = self.normalize
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -640,6 +673,70 @@ class HTTPValueSource(ValueSource):
|
||||
min_value=float(data.get("min_value") or 0.0),
|
||||
max_value=float(data.get("max_value") if data.get("max_value") is not None else 100.0),
|
||||
smoothing=float(data.get("smoothing") or 0.0),
|
||||
normalize=bool(data.get("normalize", True)),
|
||||
)
|
||||
|
||||
|
||||
def _coerce_float(value, fallback):
|
||||
"""Best-effort float; returns ``fallback`` on None/non-numeric.
|
||||
|
||||
Keeps a tampered DB / buggy migration from dropping the whole row when
|
||||
BaseSqliteStore's loader swallows a per-row deserialization error.
|
||||
"""
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateValueSource(ValueSource):
|
||||
"""Value source that evaluates a sandboxed Jinja expression over the live
|
||||
values of other value sources (the system's float combinator).
|
||||
|
||||
``template`` is a Jinja *expression* (no statements/blocks) evaluated by the
|
||||
hardened engine in :mod:`ledgrab.utils.template_expr`. Each entry in
|
||||
``inputs`` binds a variable ``name`` to another value source by id; at
|
||||
runtime the variable holds that source's normalized ``get_value()`` (0..1)
|
||||
and ``raw[name]`` holds its un-normalized ``get_raw_value()`` (float) where
|
||||
the stream exposes one. The callables ``min``/``max``/``abs``/``round``/
|
||||
``clamp`` are available. The result is coerced to float, NaN/inf rejected,
|
||||
and clamped to [0, 1]; any error falls back to ``default_value``.
|
||||
"""
|
||||
|
||||
template: str = ""
|
||||
inputs: List[dict] = field(default_factory=list) # [{name, value_source_id}]
|
||||
default_value: float = 0.0 # fallback when the expression errors (0.0-1.0)
|
||||
eval_interval: float | None = None # re-eval throttle (s); None/0 = every poll
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["template"] = self.template
|
||||
d["inputs"] = [dict(i) for i in self.inputs]
|
||||
d["default_value"] = self.default_value
|
||||
d["eval_interval"] = self.eval_interval
|
||||
d["return_type"] = "float"
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "TemplateValueSource":
|
||||
common = _parse_common_fields(data)
|
||||
raw_inputs = data.get("inputs") or []
|
||||
inputs = [
|
||||
{
|
||||
"name": str(i.get("name", "")),
|
||||
"value_source_id": str(i.get("value_source_id", "")),
|
||||
}
|
||||
for i in raw_inputs
|
||||
if isinstance(i, dict)
|
||||
]
|
||||
return cls(
|
||||
**common,
|
||||
source_type="template",
|
||||
template=data.get("template") or "",
|
||||
inputs=inputs,
|
||||
default_value=_coerce_float(data.get("default_value"), 0.0),
|
||||
eval_interval=_coerce_float(data.get("eval_interval"), None),
|
||||
)
|
||||
|
||||
|
||||
@@ -661,4 +758,5 @@ _VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
|
||||
"system_metrics": SystemMetricsValueSource,
|
||||
"game_event": GameEventValueSource,
|
||||
"http": HTTPValueSource,
|
||||
"template": TemplateValueSource,
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ from ledgrab.storage.value_source import (
|
||||
StaticColorValueSource,
|
||||
StaticValueSource,
|
||||
SystemMetricsValueSource,
|
||||
TemplateValueSource,
|
||||
ValueSource,
|
||||
_VALUE_SOURCE_MAP,
|
||||
)
|
||||
@@ -237,6 +238,7 @@ def _build_ha_entity(
|
||||
min_ha_value: float | None = None,
|
||||
max_ha_value: float | None = None,
|
||||
smoothing: float | None = None,
|
||||
normalize: bool | None = None,
|
||||
**_,
|
||||
) -> ValueSource:
|
||||
if not ha_source_id:
|
||||
@@ -251,6 +253,7 @@ def _build_ha_entity(
|
||||
min_ha_value=min_ha_value if min_ha_value is not None else 0.0,
|
||||
max_ha_value=max_ha_value if max_ha_value is not None else 100.0,
|
||||
smoothing=smoothing if smoothing is not None else 0.0,
|
||||
normalize=normalize if normalize is not None else True,
|
||||
)
|
||||
|
||||
|
||||
@@ -301,6 +304,7 @@ def _build_system_metrics(
|
||||
sensor_label: str | None = None,
|
||||
poll_interval: float | None = None,
|
||||
smoothing: float | None = None,
|
||||
normalize: bool | None = None,
|
||||
**_,
|
||||
) -> ValueSource:
|
||||
m = metric or "cpu_load"
|
||||
@@ -316,6 +320,7 @@ def _build_system_metrics(
|
||||
sensor_label=sensor_label or "",
|
||||
poll_interval=poll_interval if poll_interval is not None else 1.0,
|
||||
smoothing=smoothing if smoothing is not None else 0.0,
|
||||
normalize=normalize if normalize is not None else True,
|
||||
)
|
||||
|
||||
|
||||
@@ -347,6 +352,7 @@ def _build_http(
|
||||
min_value: float | None = None,
|
||||
max_value: float | None = None,
|
||||
smoothing: float | None = None,
|
||||
normalize: bool | None = None,
|
||||
**_,
|
||||
) -> ValueSource:
|
||||
if not http_endpoint_id:
|
||||
@@ -362,6 +368,80 @@ def _build_http(
|
||||
min_value=min_value if min_value is not None else 0.0,
|
||||
max_value=max_value if max_value is not None else 100.0,
|
||||
smoothing=smoothing if smoothing is not None else 0.0,
|
||||
normalize=normalize if normalize is not None else True,
|
||||
)
|
||||
|
||||
|
||||
def _validate_template_inputs(inputs: list | None) -> list:
|
||||
"""Validate + normalize template input bindings.
|
||||
|
||||
Each input is ``{name, value_source_id}``; ``name`` must be a valid,
|
||||
non-reserved identifier and unique. ``value_source_id`` is *not* required to
|
||||
resolve (lenient, like gradient_map) — a missing/unknown id just yields a
|
||||
runtime fallback to ``default_value``.
|
||||
"""
|
||||
from ledgrab.utils.template_expr import validate_input_name
|
||||
|
||||
result: list = []
|
||||
seen: set = set()
|
||||
for item in inputs or []:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("each template input must be an object with name and value_source_id")
|
||||
name = str(item.get("name", "")).strip()
|
||||
vs_id = str(item.get("value_source_id", "")).strip()
|
||||
validate_input_name(name) # identifier + reserved-name check (raises ValueError)
|
||||
if name in seen:
|
||||
raise ValueError(f"duplicate template input name: {name!r}")
|
||||
seen.add(name)
|
||||
result.append({"name": name, "value_source_id": vs_id})
|
||||
return result
|
||||
|
||||
|
||||
def _reject_unbound_template_vars(template: str, inputs: list) -> None:
|
||||
"""Reject expression variables that aren't bound to an input.
|
||||
|
||||
An unbound variable raises ``UndefinedError`` at runtime, so the template
|
||||
would silently always return ``default_value`` — almost always a typo. The
|
||||
globals (min/max/abs/round/clamp) and ``raw`` are excluded by
|
||||
``extract_variables``, so anything left over is genuinely unbound.
|
||||
"""
|
||||
from ledgrab.utils.template_expr import extract_variables
|
||||
|
||||
declared = {i["name"] for i in inputs}
|
||||
undeclared = sorted(set(extract_variables(template)) - declared)
|
||||
if undeclared:
|
||||
raise ValueError("expression uses unbound variable(s): " + ", ".join(undeclared))
|
||||
|
||||
|
||||
def _build_template(
|
||||
*,
|
||||
common: dict,
|
||||
template: str | None = None,
|
||||
inputs: list | None = None,
|
||||
default_value: float | None = None,
|
||||
eval_interval: float | None = None,
|
||||
**_,
|
||||
) -> ValueSource:
|
||||
from ledgrab.utils.template_expr import validate_template_expression
|
||||
|
||||
tpl = (template or "").strip()
|
||||
if not tpl:
|
||||
raise ValueError("template expression is required for template type")
|
||||
validate_template_expression(tpl) # raises ValueError on compile / cost-bomb
|
||||
clean_inputs = _validate_template_inputs(inputs)
|
||||
_reject_unbound_template_vars(tpl, clean_inputs)
|
||||
dv = default_value if default_value is not None else 0.0
|
||||
if not (0.0 <= dv <= 1.0):
|
||||
raise ValueError("default_value must be between 0.0 and 1.0")
|
||||
ei = float(eval_interval) if eval_interval is not None else None
|
||||
if ei is not None and ei < 0.0:
|
||||
raise ValueError("eval_interval must be >= 0")
|
||||
return TemplateValueSource(
|
||||
**common,
|
||||
template=tpl,
|
||||
inputs=clean_inputs,
|
||||
default_value=dv,
|
||||
eval_interval=ei,
|
||||
)
|
||||
|
||||
|
||||
@@ -381,6 +461,7 @@ CREATE_BUILDERS: Dict[str, CreateBuilder] = {
|
||||
"system_metrics": _build_system_metrics,
|
||||
"game_event": _build_game_event,
|
||||
"http": _build_http,
|
||||
"template": _build_template,
|
||||
}
|
||||
|
||||
|
||||
@@ -569,6 +650,7 @@ def _apply_ha_entity(
|
||||
min_ha_value=None,
|
||||
max_ha_value=None,
|
||||
smoothing=None,
|
||||
normalize=None,
|
||||
**_,
|
||||
) -> None:
|
||||
if ha_source_id is not None:
|
||||
@@ -583,6 +665,8 @@ def _apply_ha_entity(
|
||||
source.max_ha_value = max_ha_value
|
||||
if smoothing is not None:
|
||||
source.smoothing = smoothing
|
||||
if normalize is not None:
|
||||
source.normalize = normalize
|
||||
|
||||
|
||||
def _apply_gradient_map(
|
||||
@@ -630,6 +714,7 @@ def _apply_system_metrics(
|
||||
sensor_label=None,
|
||||
poll_interval=None,
|
||||
smoothing=None,
|
||||
normalize=None,
|
||||
**_,
|
||||
) -> None:
|
||||
if metric is not None:
|
||||
@@ -650,6 +735,8 @@ def _apply_system_metrics(
|
||||
source.poll_interval = poll_interval
|
||||
if smoothing is not None:
|
||||
source.smoothing = smoothing
|
||||
if normalize is not None:
|
||||
source.normalize = normalize
|
||||
|
||||
|
||||
def _apply_game_event(source: GameEventValueSource, **_) -> None:
|
||||
@@ -667,6 +754,7 @@ def _apply_http(
|
||||
min_value=None,
|
||||
max_value=None,
|
||||
smoothing=None,
|
||||
normalize=None,
|
||||
**_,
|
||||
) -> None:
|
||||
if http_endpoint_id is not None:
|
||||
@@ -683,6 +771,47 @@ def _apply_http(
|
||||
source.max_value = max_value
|
||||
if smoothing is not None:
|
||||
source.smoothing = smoothing
|
||||
if normalize is not None:
|
||||
source.normalize = normalize
|
||||
|
||||
|
||||
def _apply_template(
|
||||
source: TemplateValueSource,
|
||||
*,
|
||||
template=None,
|
||||
inputs=None,
|
||||
default_value=None,
|
||||
eval_interval=None,
|
||||
**_,
|
||||
) -> None:
|
||||
from ledgrab.utils.template_expr import validate_template_expression
|
||||
|
||||
# Compute the prospective final state and validate it BEFORE mutating, so a
|
||||
# rejected update never leaves the cached object half-applied. (inputs/
|
||||
# template may each change independently; unbound vars are checked against
|
||||
# the combined final state.)
|
||||
final_template = template.strip() if template is not None else source.template
|
||||
final_inputs = _validate_template_inputs(inputs) if inputs is not None else source.inputs
|
||||
|
||||
if template is not None:
|
||||
if not final_template:
|
||||
raise ValueError("template expression cannot be empty")
|
||||
validate_template_expression(final_template)
|
||||
if template is not None or inputs is not None:
|
||||
_reject_unbound_template_vars(final_template, final_inputs)
|
||||
|
||||
if template is not None:
|
||||
source.template = final_template
|
||||
if inputs is not None:
|
||||
source.inputs = final_inputs
|
||||
if default_value is not None:
|
||||
if not (0.0 <= default_value <= 1.0):
|
||||
raise ValueError("default_value must be between 0.0 and 1.0")
|
||||
source.default_value = default_value
|
||||
if eval_interval is not None:
|
||||
if eval_interval < 0.0:
|
||||
raise ValueError("eval_interval must be >= 0")
|
||||
source.eval_interval = eval_interval
|
||||
|
||||
|
||||
UPDATE_APPLIERS: Dict[str, UpdateApplier] = {
|
||||
@@ -701,6 +830,7 @@ UPDATE_APPLIERS: Dict[str, UpdateApplier] = {
|
||||
"system_metrics": _apply_system_metrics,
|
||||
"game_event": _apply_game_event,
|
||||
"http": _apply_http,
|
||||
"template": _apply_template,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ from datetime import datetime, timezone
|
||||
|
||||
from ledgrab.storage.base_sqlite_store import BaseSqliteStore
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.storage.value_source import ValueSource
|
||||
from ledgrab.storage.value_source import (
|
||||
GradientMapValueSource,
|
||||
TemplateValueSource,
|
||||
ValueSource,
|
||||
)
|
||||
from ledgrab.storage.value_source_factories import (
|
||||
apply_update as _apply_value_source_update,
|
||||
build_source as _build_value_source,
|
||||
@@ -21,12 +25,18 @@ from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Storage-level cap on value-source reference nesting depth. The runtime acquire
|
||||
# backstop (ValueStreamManager) uses a higher cap so legitimate chains never trip
|
||||
# it. Color-strip sources cap at 4; value-source chains are flatter combinators.
|
||||
MAX_VALUE_SOURCE_DEPTH = 8
|
||||
|
||||
|
||||
class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
||||
"""Persistent storage for value sources."""
|
||||
|
||||
_table_name = "value_sources"
|
||||
_entity_name = "Value source"
|
||||
_cloneable = True # no inline secrets — only references shared entities by id
|
||||
|
||||
def __init__(self, db: Database):
|
||||
super().__init__(db, ValueSource.from_dict)
|
||||
@@ -66,6 +76,12 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Reject over-deep reference chains (cycles are impossible at create:
|
||||
# the new id is not yet referenceable by anything).
|
||||
child_ids = self._child_ids_of(source)
|
||||
if child_ids:
|
||||
self.validate_nesting(None, child_ids)
|
||||
|
||||
# Name-uniqueness happens last so we never burn a uuid on a source
|
||||
# we end up rejecting AND so the user-facing error precedence
|
||||
# (type errors before name errors) matches the old code's order.
|
||||
@@ -87,6 +103,13 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
||||
"""
|
||||
source = self.get(source_id)
|
||||
|
||||
# Cycle/depth guard FIRST — before any field mutation — so a rejection
|
||||
# never leaves the cached object half-mutated. validate_nesting works
|
||||
# off the prospective child ids derived from kwargs without applying them.
|
||||
child_ids = self._prospective_child_ids(source, kwargs)
|
||||
if child_ids:
|
||||
self.validate_nesting(source_id, child_ids)
|
||||
|
||||
name = kwargs.pop("name", None)
|
||||
if name is not None:
|
||||
self._check_name_unique(name, exclude_id=source_id)
|
||||
@@ -118,3 +141,110 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
||||
|
||||
logger.info(f"Updated value source: {source_id}")
|
||||
return source
|
||||
|
||||
# ── Reference graph (cycle / depth / referential integrity) ──────
|
||||
#
|
||||
# Value sources may reference other value sources: gradient_map via
|
||||
# ``value_source_id`` and template via ``inputs[].value_source_id``. (Note:
|
||||
# css_extract.color_strip_source_id points into the *color strip* store, a
|
||||
# different graph, so it is intentionally not followed here.) Without a
|
||||
# guard, a cycle would infinitely recurse in ValueStreamManager.acquire().
|
||||
|
||||
@staticmethod
|
||||
def _child_ids_of(source: ValueSource) -> list[str]:
|
||||
"""Value-source ids that ``source`` references (gradient_map / template)."""
|
||||
if isinstance(source, TemplateValueSource):
|
||||
return [
|
||||
i["value_source_id"]
|
||||
for i in source.inputs
|
||||
if isinstance(i, dict) and i.get("value_source_id")
|
||||
]
|
||||
if isinstance(source, GradientMapValueSource):
|
||||
return [source.value_source_id] if source.value_source_id else []
|
||||
return []
|
||||
|
||||
def _prospective_child_ids(self, source: ValueSource, kwargs: dict) -> list[str]:
|
||||
"""Child ids the source *would* reference after applying ``kwargs``.
|
||||
|
||||
Computed without mutating ``source`` so cycle/depth validation can run
|
||||
before the update is applied (a raise must not leave a half-mutated
|
||||
cached object).
|
||||
"""
|
||||
if isinstance(source, TemplateValueSource):
|
||||
inputs = kwargs.get("inputs")
|
||||
if inputs is None:
|
||||
inputs = source.inputs
|
||||
return [
|
||||
i["value_source_id"]
|
||||
for i in (inputs or [])
|
||||
if isinstance(i, dict) and i.get("value_source_id")
|
||||
]
|
||||
if isinstance(source, GradientMapValueSource):
|
||||
vs_id = kwargs.get("value_source_id")
|
||||
if vs_id is None:
|
||||
vs_id = source.value_source_id
|
||||
return [vs_id] if vs_id else []
|
||||
return []
|
||||
|
||||
def get_transitive_dependencies(self, source_id: str) -> set[str]:
|
||||
"""All value-source ids reachable from ``source_id`` via reference edges."""
|
||||
seen: set[str] = set()
|
||||
stack = self._children_of_id(source_id)
|
||||
while stack:
|
||||
cid = stack.pop()
|
||||
if cid in seen:
|
||||
continue
|
||||
seen.add(cid)
|
||||
stack.extend(self._children_of_id(cid))
|
||||
return seen
|
||||
|
||||
def _children_of_id(self, source_id: str) -> list[str]:
|
||||
try:
|
||||
src = self.get(source_id)
|
||||
except Exception:
|
||||
return []
|
||||
return self._child_ids_of(src)
|
||||
|
||||
def _max_depth(self, ids: list[str], visiting: set[str]) -> int:
|
||||
"""Longest reference chain length starting at any id in ``ids``."""
|
||||
best = 0
|
||||
for cid in ids:
|
||||
if cid in visiting:
|
||||
continue # cycle — handled separately; don't recurse forever
|
||||
try:
|
||||
src = self.get(cid)
|
||||
except Exception:
|
||||
depth = 1 # unresolved leaf still counts as one hop
|
||||
else:
|
||||
depth = 1 + self._max_depth(self._child_ids_of(src), visiting | {cid})
|
||||
best = max(best, depth)
|
||||
return best
|
||||
|
||||
def validate_nesting(self, parent_id: str | None, child_ids: list[str]) -> None:
|
||||
"""Reject self-reference, circular dependencies, and over-deep nesting.
|
||||
|
||||
``parent_id`` is ``None`` at create time — a brand-new node has no id, so
|
||||
no existing source can reference it and a cycle through it is impossible;
|
||||
only the depth check applies. At update time the cycle check runs too.
|
||||
"""
|
||||
if 1 + self._max_depth(child_ids, set()) > MAX_VALUE_SOURCE_DEPTH:
|
||||
raise ValueError(
|
||||
f"value source reference chain too deep (max {MAX_VALUE_SOURCE_DEPTH})"
|
||||
)
|
||||
if parent_id is None:
|
||||
return
|
||||
for cid in child_ids:
|
||||
if cid == parent_id:
|
||||
raise ValueError("a value source cannot reference itself")
|
||||
if parent_id in self.get_transitive_dependencies(cid):
|
||||
raise ValueError(f"input {cid!r} creates a circular value-source dependency")
|
||||
|
||||
def find_referencing_sources(self, source_id: str) -> list[str]:
|
||||
"""Names of value sources that reference ``source_id`` (for delete-protection)."""
|
||||
names: list[str] = []
|
||||
for src in self.get_all():
|
||||
if src.id == source_id:
|
||||
continue
|
||||
if source_id in self._child_ids_of(src):
|
||||
names.append(src.name)
|
||||
return names
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<option value="css_extract" data-i18n="value_source.type.css_extract">Strip Extract</option>
|
||||
<option value="system_metrics" data-i18n="value_source.type.system_metrics">System Metrics</option>
|
||||
<option value="http" data-i18n="value_source.type.http">HTTP Poll</option>
|
||||
<option value="template" data-i18n="value_source.type.template">Jinja Template</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -394,6 +395,18 @@
|
||||
<input type="text" id="value-source-attribute" placeholder="">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-ha-normalize" data-i18n="value_source.normalize">Normalize to 0–1:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.normalize.hint">On: rescale the raw value to 0–1 using Min/Max below. Off: the value is clamped to 0–1 as-is (for entities that already report a 0–1 fraction). The raw value is always available to templates (raw[name]) and automations.</small>
|
||||
<label class="settings-toggle">
|
||||
<input type="checkbox" id="value-source-ha-normalize" checked onchange="onValueSourceNormalizeChange()">
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-min-ha-value"><span data-i18n="value_source.min_ha_value">Min HA Value:</span> <span id="value-source-min-ha-value-display">0</span></label>
|
||||
@@ -456,6 +469,82 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template (Jinja expression) fields -->
|
||||
<div id="value-source-template-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-template-expression" data-i18n="value_source.template.expression">Expression:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.template.expression.hint">A sandboxed Jinja expression returning a number. Bound inputs are available by name; use raw[name] for the un-normalized value.</small>
|
||||
<!-- The highlight overlay is injected around this textarea by jinja-editor.ts -->
|
||||
<textarea id="value-source-template-expression" rows="3" spellcheck="false"
|
||||
data-i18n-placeholder="value_source.template.expression.placeholder"
|
||||
placeholder="clamp((temp - 18) / 10, 0, 1)"></textarea>
|
||||
<div id="value-source-template-error" class="field-error-msg" style="display:none"></div>
|
||||
<div id="value-source-template-ok" class="field-ok-msg" style="display:none"></div>
|
||||
<div id="value-source-template-warn" class="field-warn-msg" style="display:none"></div>
|
||||
|
||||
<details class="jinja-hints">
|
||||
<summary data-i18n="value_source.template.hints.title">Expression help</summary>
|
||||
<div class="jinja-hints-body">
|
||||
<div class="jinja-hints-section">
|
||||
<span class="jinja-hints-section-title" data-i18n="value_source.template.hints.inputs_title">Bound inputs</span>
|
||||
<div id="value-source-template-hint-vars" class="jinja-hints-vars"></div>
|
||||
</div>
|
||||
<div class="jinja-hints-section">
|
||||
<span class="jinja-hints-section-title" data-i18n="value_source.template.hints.globals_title">Globals</span>
|
||||
<div data-i18n="value_source.template.hints.globals">min(a, b), max(a, b), abs(x), round(x), clamp(x, lo, hi)</div>
|
||||
</div>
|
||||
<div class="jinja-hints-section">
|
||||
<span class="jinja-hints-section-title" data-i18n="value_source.template.hints.raw_title">Raw values</span>
|
||||
<div data-i18n="value_source.template.hints.raw">raw[name] gives the un-normalized value of an input that has one.</div>
|
||||
</div>
|
||||
<div class="jinja-hints-section">
|
||||
<span class="jinja-hints-section-title" data-i18n="value_source.template.hints.examples_title">Examples</span>
|
||||
<ul class="jinja-hints-examples">
|
||||
<li><code>min(audio * 2, 1)</code></li>
|
||||
<li><code>clamp((temp - 18) / 10, 0, 1)</code></li>
|
||||
<li><code>(a + b) / 2</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="jinja-hints-section">
|
||||
<small class="jinja-hints-time" data-i18n="value_source.template.hints.time">Tip: for time-of-day logic, bind an Adaptive (Time) or Daylight source as an input.</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="value_source.template.inputs">Inputs:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.template.inputs.hint">Bind float value sources to variable names you reference in the expression.</small>
|
||||
<div id="value-source-template-inputs-list" class="template-inputs-list"></div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="addTemplateInput()" data-i18n="value_source.template.add_input">+ Add Input</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-template-default-value"><span data-i18n="value_source.template.default_value">Default Value:</span> <span id="value-source-template-default-value-display">0.00</span></label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.template.default_value.hint">Output used when the expression cannot be evaluated (e.g. an input is missing).</small>
|
||||
<input type="range" id="value-source-template-default-value" min="0" max="1" step="0.01" value="0"
|
||||
oninput="document.getElementById('value-source-template-default-value-display').textContent = parseFloat(this.value).toFixed(2)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-template-eval-interval" data-i18n="value_source.template.eval_interval">Eval Interval (s):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.template.eval_interval.hint">How often to re-evaluate the expression. 0 = every poll (re-evaluate as fast as the inputs update).</small>
|
||||
<input type="number" id="value-source-template-eval-interval" min="0" step="0.1" value="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSS Extract fields -->
|
||||
<div id="value-source-css-extract-section" style="display:none">
|
||||
<div class="form-group">
|
||||
@@ -509,6 +598,18 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="value-source-sysmetric-normalize-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-sysmetric-normalize" data-i18n="value_source.normalize">Normalize to 0–1:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.normalize.hint">On: rescale the raw value to 0–1 using Min/Max below. Off: the value is clamped to 0–1 as-is (for metrics that already report a 0–1 fraction). The raw value is always available to templates (raw[name]) and automations.</small>
|
||||
<label class="settings-toggle">
|
||||
<input type="checkbox" id="value-source-sysmetric-normalize" checked onchange="onValueSourceNormalizeChange()">
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="value-source-sysmetric-range" style="display:none">
|
||||
<div class="form-group">
|
||||
<label for="value-source-sysmetric-min"><span data-i18n="value_source.sysmetric.min">Min Value:</span></label>
|
||||
@@ -670,6 +771,17 @@
|
||||
<summary data-i18n="value_source.http.modulator.summary">Modulator mapping (optional)</summary>
|
||||
<div class="form-collapse-body">
|
||||
<small class="input-hint" data-i18n="value_source.http.modulator.hint">Only used when this source drives brightness or color. Automation rules read the raw extracted value and ignore these settings.</small>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-http-normalize" data-i18n="value_source.normalize">Normalize to 0–1:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="value_source.normalize.hint">On: rescale the raw value to 0–1 using Min/Max below. Off: the value is clamped to 0–1 as-is (for endpoints that already return a 0–1 fraction). The raw value is always available to templates (raw[name]) and automations.</small>
|
||||
<label class="settings-toggle">
|
||||
<input type="checkbox" id="value-source-http-normalize" checked onchange="onValueSourceNormalizeChange()">
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="value-source-http-min" data-i18n="value_source.http.min_value">Min Value:</label>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from .file_ops import atomic_write_json, read_upload_capped
|
||||
from .logger import setup_logging, get_logger
|
||||
from .monitor_names import get_monitor_names, get_monitor_name, get_monitor_refresh_rates
|
||||
from .numeric import clamp01
|
||||
from .timer import high_resolution_timer
|
||||
from .log_broadcaster import broadcaster as log_broadcaster, install_broadcast_handler
|
||||
from .url_scheme import infer_http_scheme
|
||||
@@ -15,6 +16,7 @@ __all__ = [
|
||||
"get_monitor_names",
|
||||
"get_monitor_name",
|
||||
"get_monitor_refresh_rates",
|
||||
"clamp01",
|
||||
"high_resolution_timer",
|
||||
"log_broadcaster",
|
||||
"install_broadcast_handler",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Small numeric helpers shared across the processing layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def clamp01(x: float, default: float = 0.0) -> float:
|
||||
"""Clamp ``x`` into the unit interval ``[0.0, 1.0]``, finite-safe.
|
||||
|
||||
NaN/inf are rejected to ``default`` *before* clamping — they are valid
|
||||
floats, so ``max/min`` alone would silently pass them through (and an
|
||||
``int(base * inf)`` cast downstream raises OverflowError, ``int(nan)``
|
||||
raises ValueError). Use this at any boundary that feeds a value into a
|
||||
fixed-point / uint brightness multiply where a non-finite or out-of-range
|
||||
value would corrupt or crash the math.
|
||||
"""
|
||||
if not math.isfinite(x):
|
||||
return default
|
||||
if x < 0.0:
|
||||
return 0.0
|
||||
if x > 1.0:
|
||||
return 1.0
|
||||
return x
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Hardened sandboxed-Jinja expression engine for template value sources.
|
||||
|
||||
Single source of truth for compiling, validating, and evaluating user-authored
|
||||
Jinja *expressions* that combine the live values of other value sources into a
|
||||
single float in [0, 1]. Imported by the storage factory (create/update
|
||||
validation), the runtime ``TemplateValueStream``, and the validate-template API
|
||||
route, so the client and server can never disagree about what is valid.
|
||||
|
||||
Security model — a user-authored expression is attacker-influenceable config
|
||||
that runs server-side (LAN device, shareable backups), so we layer defenses:
|
||||
|
||||
* :class:`~jinja2.sandbox.ImmutableSandboxedEnvironment` blocks ``__class__`` /
|
||||
``mro`` traversal, mutation, and unsafe attribute/method access.
|
||||
* ALL default filters and tests are stripped (``|attr``, ``|pprint``, ``|map``,
|
||||
``|format`` …) and the auto-injected globals (``range``, ``dict``,
|
||||
``namespace``, ``cycler``, ``lipsum``, ``joiner``) are removed — none are
|
||||
needed for numeric math and several are escape/DoS amplifiers.
|
||||
* Only five vetted numeric callables are exposed: ``min``, ``max``, ``abs``,
|
||||
``round``, ``clamp``.
|
||||
* The evaluation context contains ONLY primitive floats plus a flat dict of
|
||||
floats (``raw``) — never any application object — so even a hypothetical
|
||||
sandbox escape has nothing privileged to pivot to.
|
||||
* Obvious cost bombs are rejected at validate time: the ``**`` (power) operator
|
||||
and string/list ``*`` repetition can hang or OOM the interpreter, which
|
||||
``try/except`` cannot catch.
|
||||
* The result is coerced to float, NaN/inf are rejected (they are valid floats,
|
||||
not exceptions, so clamping alone would silently keep them), then clamped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import nodes
|
||||
from jinja2.exceptions import TemplateError
|
||||
from jinja2.sandbox import ImmutableSandboxedEnvironment
|
||||
|
||||
|
||||
def clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
||||
"""Clamp ``x`` into ``[lo, hi]`` (defaults to the unit interval)."""
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
# The five callables a template author may use. Kept as the *only* names in the
|
||||
# environment globals so no other builtin/Jinja helper is reachable.
|
||||
GLOBALS: dict[str, Any] = {
|
||||
"min": min,
|
||||
"max": max,
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"clamp": clamp,
|
||||
}
|
||||
|
||||
# Input variable names that would shadow a global or the ``raw`` dict (and thus
|
||||
# silently break the expression) or that Jinja auto-injects. Rejected at save
|
||||
# time so the user gets a clear error instead of a template that always falls
|
||||
# back to ``default_value``.
|
||||
RESERVED_NAMES: frozenset[str] = frozenset(
|
||||
{*GLOBALS, "raw", "range", "dict", "namespace", "cycler", "lipsum", "joiner"}
|
||||
)
|
||||
|
||||
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
class TemplateValidationError(ValueError):
|
||||
"""Raised when a template expression (or an input name) is invalid/unsafe.
|
||||
|
||||
Subclasses :class:`ValueError` so existing route handlers that map
|
||||
``ValueError -> HTTP 400`` surface a clean message automatically.
|
||||
"""
|
||||
|
||||
|
||||
def _build_env() -> ImmutableSandboxedEnvironment:
|
||||
env = ImmutableSandboxedEnvironment(autoescape=False)
|
||||
# Strip the entire default filter/test surface — numeric expressions need
|
||||
# none of them, and |attr/|pprint/|map/|format are escape/DoS amplifiers.
|
||||
env.filters.clear()
|
||||
env.tests.clear()
|
||||
# Replace the auto-injected globals (range/dict/namespace/cycler/...) with
|
||||
# only our five vetted callables.
|
||||
env.globals.clear()
|
||||
env.globals.update(GLOBALS)
|
||||
return env
|
||||
|
||||
|
||||
# Module-level shared environment. Compiled expressions and evaluation are
|
||||
# cheap; the environment itself is immutable config built once.
|
||||
SANDBOX_ENV = _build_env()
|
||||
|
||||
|
||||
def _clean_jinja_error(exc: TemplateError) -> str:
|
||||
"""A user-facing one-line message from a Jinja compile error."""
|
||||
msg = str(exc) or exc.__class__.__name__
|
||||
return msg.strip()
|
||||
|
||||
|
||||
def _guard_ast(template: str) -> None:
|
||||
"""Reject cost-bomb / disallowed constructs via the Jinja AST.
|
||||
|
||||
Called only on already-compilable single expressions, so wrapping in an
|
||||
output block is safe (a ``}}`` inside a string literal stays inside it).
|
||||
"""
|
||||
try:
|
||||
tree = SANDBOX_ENV.parse("{{ (" + template + ") }}")
|
||||
except TemplateError as exc: # pragma: no cover - compile already gated this
|
||||
raise TemplateValidationError(_clean_jinja_error(exc)) from exc
|
||||
|
||||
if next(tree.find_all(nodes.Pow), None) is not None:
|
||||
raise TemplateValidationError("the '**' (power) operator is not allowed")
|
||||
|
||||
if next(tree.find_all((nodes.Filter, nodes.Test)), None) is not None:
|
||||
raise TemplateValidationError("filters and tests are not allowed")
|
||||
|
||||
# Collection literals have no use in a numeric expression and enable a
|
||||
# memory-bomb via repetition ([0] * 10**8 allocates gigabytes). The only
|
||||
# collection access we need is raw[...] subscript, which is a Getitem, not a
|
||||
# literal. Forbid list/tuple/dict literals outright.
|
||||
if next(tree.find_all((nodes.List, nodes.Tuple, nodes.Dict)), None) is not None:
|
||||
raise TemplateValidationError("list/tuple/dict literals are not allowed")
|
||||
|
||||
# Attribute access (``a.b``) has no use in numeric expressions and is the
|
||||
# classic sandbox-escape vector (``__class__``, ``.format`` …). Raw values
|
||||
# are read by subscript (``raw['x']``), which stays allowed.
|
||||
if next(tree.find_all(nodes.Getattr), None) is not None:
|
||||
raise TemplateValidationError("attribute access ('.') is not allowed")
|
||||
|
||||
# Only the five vetted globals may be called — blocks dict()/namespace()/
|
||||
# cycler()/etc. at validate time with a clear message (they would also fail
|
||||
# at runtime since the environment globals are cleared, but failing early is
|
||||
# better UX and defense in depth).
|
||||
for call in tree.find_all(nodes.Call):
|
||||
fn = call.node
|
||||
if not (isinstance(fn, nodes.Name) and fn.name in GLOBALS):
|
||||
name = getattr(fn, "name", None) or fn.__class__.__name__
|
||||
raise TemplateValidationError(
|
||||
f"only min/max/abs/round/clamp may be called (got {name!r})"
|
||||
)
|
||||
|
||||
# float * float is fine; reject only string/list repetition (the OOM path).
|
||||
for mul in tree.find_all(nodes.Mul):
|
||||
for side in (mul.left, mul.right):
|
||||
if isinstance(side, nodes.Const) and isinstance(side.value, (str, list, tuple)):
|
||||
raise TemplateValidationError("string/list repetition is not allowed")
|
||||
|
||||
|
||||
def compile_template(template: str):
|
||||
"""Compile ``template`` to a reusable Jinja ``Expression``.
|
||||
|
||||
Raises :class:`TemplateValidationError` if empty, uncompilable, or it uses a
|
||||
disallowed/cost-bomb construct. Globals (min/max/abs/round/clamp) resolve
|
||||
from ``SANDBOX_ENV.globals`` at call time, so the returned expression is
|
||||
invoked with only the data context: ``expr(**ctx)``.
|
||||
"""
|
||||
if not template or not template.strip():
|
||||
raise TemplateValidationError("expression is empty")
|
||||
try:
|
||||
expr = SANDBOX_ENV.compile_expression(template)
|
||||
except TemplateError as exc:
|
||||
raise TemplateValidationError(_clean_jinja_error(exc)) from exc
|
||||
_guard_ast(template)
|
||||
return expr
|
||||
|
||||
|
||||
def validate_template_expression(template: str) -> None:
|
||||
"""Validate ``template`` (compile + guard); raise on any problem."""
|
||||
compile_template(template)
|
||||
|
||||
|
||||
def validate_input_name(name: str) -> None:
|
||||
"""Validate a single template input variable name; raise on any problem."""
|
||||
if not name or not _IDENT_RE.match(name):
|
||||
raise TemplateValidationError(f"input name {name!r} is not a valid identifier")
|
||||
if name in RESERVED_NAMES:
|
||||
raise TemplateValidationError(f"input name {name!r} is reserved")
|
||||
|
||||
|
||||
def extract_variables(template: str) -> list[str]:
|
||||
"""Return the free input variables referenced by ``template``.
|
||||
|
||||
Excludes the globals and ``raw`` so the validate endpoint reports only the
|
||||
names the author is expected to bind. Returns ``[]`` if unparsable.
|
||||
"""
|
||||
from jinja2 import meta
|
||||
|
||||
try:
|
||||
tree = SANDBOX_ENV.parse("{{ (" + template + ") }}")
|
||||
except TemplateError:
|
||||
return []
|
||||
undeclared = meta.find_undeclared_variables(tree)
|
||||
return sorted(undeclared - set(GLOBALS) - {"raw"})
|
||||
|
||||
|
||||
def finalize_result(value: Any, default: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
||||
"""Coerce an evaluated result to a safe float in ``[lo, hi]``.
|
||||
|
||||
Non-numeric → ``default``; NaN/inf → ``default`` (they are valid floats, so
|
||||
clamping alone would silently keep them); otherwise clamp.
|
||||
"""
|
||||
try:
|
||||
f = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: float() of a multi-hundred-digit int (e.g. a chained
|
||||
# big-int multiply). Treated as "not a usable number" → default.
|
||||
return default
|
||||
if math.isnan(f) or math.isinf(f):
|
||||
return default
|
||||
return clamp(f, lo, hi)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Tests for template value source API: CRUD, validate-template, delete-protection."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ledgrab.api import dependencies as deps
|
||||
from ledgrab.api.routes.value_sources import router
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _route_db(tmp_path):
|
||||
from ledgrab.storage.database import Database
|
||||
|
||||
db = Database(tmp_path / "test.db")
|
||||
yield db
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(_route_db):
|
||||
return ValueSourceStore(_route_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(store):
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
from ledgrab.api.auth import verify_api_key
|
||||
|
||||
app.dependency_overrides[verify_api_key] = lambda: "test-user"
|
||||
app.dependency_overrides[deps.get_value_source_store] = lambda: store
|
||||
app.dependency_overrides[deps.get_processor_manager] = lambda: MagicMock()
|
||||
app.dependency_overrides[deps.get_output_target_store] = lambda: MagicMock(
|
||||
get_all_targets=lambda: []
|
||||
)
|
||||
|
||||
deps._deps["processor_manager"] = MagicMock()
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _create(client, **over):
|
||||
body = {
|
||||
"source_type": "template",
|
||||
"name": "Combo",
|
||||
"template": "min(a * 2, 1)",
|
||||
"inputs": [{"name": "a", "value_source_id": ""}],
|
||||
"default_value": 0.2,
|
||||
}
|
||||
body.update(over)
|
||||
return client.post("/api/v1/value-sources", json=body)
|
||||
|
||||
|
||||
class TestCRUD:
|
||||
def test_create_get_list_roundtrip(self, client):
|
||||
r = _create(client)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["source_type"] == "template"
|
||||
assert body["return_type"] == "float"
|
||||
assert body["template"] == "min(a * 2, 1)"
|
||||
assert body["inputs"] == [{"name": "a", "value_source_id": ""}]
|
||||
assert body["default_value"] == 0.2
|
||||
sid = body["id"]
|
||||
|
||||
got = client.get(f"/api/v1/value-sources/{sid}").json()
|
||||
assert got["template"] == "min(a * 2, 1)"
|
||||
|
||||
lst = client.get("/api/v1/value-sources").json()
|
||||
assert any(s["id"] == sid and s["source_type"] == "template" for s in lst["sources"])
|
||||
|
||||
def test_update(self, client):
|
||||
sid = _create(client).json()["id"]
|
||||
r = client.put(
|
||||
f"/api/v1/value-sources/{sid}",
|
||||
json={"source_type": "template", "template": "clamp(a * 3)"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["template"] == "clamp(a * 3)"
|
||||
|
||||
def test_create_compile_error_returns_400(self, client):
|
||||
r = _create(client, template="a +")
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_create_reserved_name_returns_400(self, client):
|
||||
r = _create(client, inputs=[{"name": "min", "value_source_id": ""}])
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
class TestDeleteProtection:
|
||||
def test_delete_blocked_when_referenced(self, client):
|
||||
base = client.post(
|
||||
"/api/v1/value-sources",
|
||||
json={"source_type": "static", "name": "Base", "value": 0.5},
|
||||
).json()
|
||||
_create(
|
||||
client,
|
||||
name="Uses",
|
||||
template="b",
|
||||
inputs=[{"name": "b", "value_source_id": base["id"]}],
|
||||
)
|
||||
r = client.delete(f"/api/v1/value-sources/{base['id']}")
|
||||
assert r.status_code == 400
|
||||
assert "referenced by" in r.json()["detail"]
|
||||
|
||||
|
||||
class TestValidateEndpoint:
|
||||
def _validate(self, client, **body):
|
||||
return client.post("/api/v1/value-sources/validate-template", json=body)
|
||||
|
||||
def test_valid_expression(self, client):
|
||||
r = self._validate(
|
||||
client,
|
||||
template="min(a, b)",
|
||||
inputs=[{"name": "a", "value_source_id": ""}, {"name": "b", "value_source_id": ""}],
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["valid"] is True
|
||||
assert set(data["variables"]) == {"a", "b"}
|
||||
|
||||
def test_compile_error(self, client):
|
||||
r = self._validate(client, template="a +", inputs=[])
|
||||
data = r.json()
|
||||
assert data["valid"] is False
|
||||
assert data["error"]
|
||||
|
||||
def test_reserved_name(self, client):
|
||||
r = self._validate(
|
||||
client, template="min(0,1)", inputs=[{"name": "raw", "value_source_id": ""}]
|
||||
)
|
||||
assert r.json()["valid"] is False
|
||||
|
||||
def test_missing_input_is_warning_not_error(self, client):
|
||||
r = self._validate(
|
||||
client, template="a", inputs=[{"name": "a", "value_source_id": "vs_nope"}]
|
||||
)
|
||||
data = r.json()
|
||||
assert data["valid"] is True
|
||||
assert data["warnings"]
|
||||
|
||||
def test_unbound_variable_is_error(self, client):
|
||||
# Typo: expression uses 'ha_enti' but the input is named 'ha_entity'.
|
||||
r = self._validate(
|
||||
client, template="ha_enti", inputs=[{"name": "ha_entity", "value_source_id": ""}]
|
||||
)
|
||||
data = r.json()
|
||||
assert data["valid"] is False
|
||||
assert any("unbound" in e for e in data["errors"])
|
||||
|
||||
def test_cycle_detected_with_id(self, client):
|
||||
t1 = _create(client, name="T1", template="clamp(0.5)", inputs=[]).json()
|
||||
t2 = _create(
|
||||
client,
|
||||
name="T2",
|
||||
template="x",
|
||||
inputs=[{"name": "x", "value_source_id": t1["id"]}],
|
||||
).json()
|
||||
# Editing t1 to point at t2 would close a cycle.
|
||||
r = self._validate(
|
||||
client, template="x", inputs=[{"name": "x", "value_source_id": t2["id"]}], id=t1["id"]
|
||||
)
|
||||
assert r.json()["valid"] is False
|
||||
|
||||
|
||||
class TestResponseMapCoverage:
|
||||
def test_template_in_response_map(self):
|
||||
from ledgrab.api.routes.value_sources import _RESPONSE_MAP
|
||||
from ledgrab.storage.value_source import TemplateValueSource
|
||||
|
||||
assert TemplateValueSource in _RESPONSE_MAP
|
||||
|
||||
def test_template_in_all_unions(self):
|
||||
from ledgrab.api.schemas import value_sources as sch
|
||||
|
||||
for union_name in ("ValueSourceResponse", "ValueSourceCreate", "ValueSourceUpdate"):
|
||||
src = repr(getattr(sch, union_name))
|
||||
assert "template" in src.lower() or "Template" in src
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Integration tests for server-side subgraph duplication.
|
||||
|
||||
Exercises ``_duplicate_subgraph`` against the *real* value-source and
|
||||
colour-strip stores (temp DB), asserting that references *within* the selection
|
||||
are remapped to the clones while references to entities *outside* the selection
|
||||
stay shared with the originals — and that the deep-copy clone preserves config.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.api import dependencies as deps
|
||||
from ledgrab.api.graph_schema import serialize_entity
|
||||
from ledgrab.api.routes.graph import _duplicate_subgraph
|
||||
from ledgrab.storage.color_strip_store import ColorStripStore
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stores(tmp_path, monkeypatch):
|
||||
db = Database(tmp_path / "dup.db")
|
||||
css = ColorStripStore(db)
|
||||
vss = ValueSourceStore(db)
|
||||
monkeypatch.setattr(deps, "_deps", {"color_strip_store": css, "value_source_store": vss})
|
||||
yield css, vss
|
||||
db.close()
|
||||
|
||||
|
||||
def _layer(sid: str) -> dict:
|
||||
return {"source_id": sid, "blend_mode": "normal", "opacity": 1.0, "enabled": True}
|
||||
|
||||
|
||||
def test_duplicate_composite_remaps_only_in_selection_refs(stores):
|
||||
css, _vss = stores
|
||||
b = css.create_source(name="B", source_type="single_color", colors=[[255, 0, 0]])
|
||||
d = css.create_source(name="D", source_type="single_color", colors=[[0, 255, 0]])
|
||||
a = css.create_source(name="A", source_type="composite", layers=[_layer(b.id), _layer(d.id)])
|
||||
|
||||
res = _duplicate_subgraph([a.id, b.id], " (copy)")
|
||||
|
||||
assert set(res["id_map"]) == {a.id, b.id}
|
||||
assert res["skipped"] == []
|
||||
assert res["warnings"] == []
|
||||
|
||||
a_new = serialize_entity(css.get(res["id_map"][a.id]))
|
||||
layer_ids = [ly["source_id"] for ly in a_new["layers"]]
|
||||
# B was in the selection -> remapped to the clone; D was not -> stays shared.
|
||||
assert layer_ids == [res["id_map"][b.id], d.id]
|
||||
assert a_new["name"] == "A (copy)"
|
||||
# Original is untouched.
|
||||
assert [ly["source_id"] for ly in serialize_entity(css.get(a.id))["layers"]] == [b.id, d.id]
|
||||
|
||||
|
||||
def test_duplicate_preserves_clone_config(stores):
|
||||
"""The deep-copy clone keeps every field except identity/name/timestamps —
|
||||
guarding against the storage-vs-create-schema name mismatch (e.g. single
|
||||
color's ``colors``) that a create-schema round-trip would silently drop."""
|
||||
css, _vss = stores
|
||||
src = css.create_source(name="Solid", source_type="single_color", colors=[[12, 34, 56]])
|
||||
res = _duplicate_subgraph([src.id], " (copy)")
|
||||
orig = serialize_entity(css.get(src.id))
|
||||
clone = serialize_entity(css.get(res["id_map"][src.id]))
|
||||
ignore = {"id", "name", "created_at", "updated_at"}
|
||||
assert {k: v for k, v in clone.items() if k not in ignore} == {
|
||||
k: v for k, v in orig.items() if k not in ignore
|
||||
}
|
||||
|
||||
|
||||
def test_duplicate_partial_selection_keeps_layer_refs_shared(stores):
|
||||
css, _vss = stores
|
||||
b = css.create_source(name="B", source_type="single_color", colors=[[255, 0, 0]])
|
||||
a = css.create_source(name="A", source_type="composite", layers=[_layer(b.id)])
|
||||
|
||||
res = _duplicate_subgraph([a.id], " (copy)") # only the composite, not its layer
|
||||
|
||||
a_new = serialize_entity(css.get(res["id_map"][a.id]))
|
||||
assert [ly["source_id"] for ly in a_new["layers"]] == [b.id] # shared with original
|
||||
|
||||
|
||||
def test_duplicate_remaps_bindable_slot_to_cloned_value_source(stores):
|
||||
"""Pass-2's dict round-trip path: a colour-strip bindable slot bound to a
|
||||
value source that is *also* in the selection must point at the value clone
|
||||
(not the original) after duplication."""
|
||||
css, vss = stores
|
||||
vs = vss.create_source(name="Pulse", source_type="static", value=0.5)
|
||||
c = css.create_source(name="Candle", source_type="candlelight")
|
||||
css.update_source(c.id, intensity={"source_id": vs.id}) # bind intensity -> vs
|
||||
assert serialize_entity(css.get(c.id))["intensity"]["source_id"] == vs.id # binding took
|
||||
|
||||
res = _duplicate_subgraph([c.id, vs.id], " (copy)")
|
||||
|
||||
assert res["warnings"] == []
|
||||
c_new = serialize_entity(css.get(res["id_map"][c.id]))
|
||||
assert c_new["intensity"]["source_id"] == res["id_map"][vs.id]
|
||||
|
||||
|
||||
def test_duplicate_remaps_layer_brightness_source(stores):
|
||||
"""A composite layer's value-source brightness binding (list + value ref) is
|
||||
remapped when that value source is also in the selection."""
|
||||
css, vss = stores
|
||||
vs = vss.create_source(name="Dim", source_type="static", value=0.3)
|
||||
leaf = css.create_source(name="Leaf", source_type="single_color", colors=[[9, 9, 9]])
|
||||
comp = css.create_source(
|
||||
name="Comp",
|
||||
source_type="composite",
|
||||
layers=[{**_layer(leaf.id), "brightness_source_id": vs.id}],
|
||||
)
|
||||
res = _duplicate_subgraph([comp.id, leaf.id, vs.id], " (copy)")
|
||||
layer = serialize_entity(css.get(res["id_map"][comp.id]))["layers"][0]
|
||||
assert layer["source_id"] == res["id_map"][leaf.id]
|
||||
assert layer["brightness_source_id"] == res["id_map"][vs.id]
|
||||
|
||||
|
||||
def test_duplicate_safety_net_flags_unremapped_ref(stores, monkeypatch):
|
||||
"""If a reference somehow isn't remapped, the post-clone safety net reports
|
||||
it in `warnings` rather than silently leaving two pipelines sharing a node."""
|
||||
css, _vss = stores
|
||||
b = css.create_source(name="B", source_type="single_color", colors=[[1, 2, 3]])
|
||||
a = css.create_source(name="A", source_type="composite", layers=[_layer(b.id)])
|
||||
# Force remap to a no-op so the clone keeps pointing at the original in-set id.
|
||||
monkeypatch.setattr("ledgrab.api.routes.graph.remap_refs", lambda *a, **k: 0)
|
||||
res = _duplicate_subgraph([a.id, b.id], " (copy)")
|
||||
assert any(w["id"] == res["id_map"][a.id] for w in res["warnings"])
|
||||
|
||||
|
||||
def test_duplicate_value_source(stores):
|
||||
_css, vss = stores
|
||||
v = vss.create_source(name="V", source_type="static", value=0.5)
|
||||
res = _duplicate_subgraph([v.id], " (copy)")
|
||||
assert list(res["id_map"]) == [v.id]
|
||||
new = vss.get(res["id_map"][v.id])
|
||||
assert new.id != v.id
|
||||
assert new.name == "V (copy)"
|
||||
assert getattr(new, "value", None) == 0.5
|
||||
|
||||
|
||||
def test_duplicate_skips_non_duplicable_ids(stores):
|
||||
_css, vss = stores
|
||||
v = vss.create_source(name="V", source_type="static", value=0.5)
|
||||
res = _duplicate_subgraph([v.id, "dev_external", "bogus"], " (copy)")
|
||||
assert list(res["id_map"]) == [v.id]
|
||||
assert {s["id"] for s in res["skipped"]} == {"dev_external", "bogus"}
|
||||
|
||||
|
||||
def test_duplicate_name_collision_is_suffixed(stores):
|
||||
_css, vss = stores
|
||||
v = vss.create_source(name="V", source_type="static", value=0.5)
|
||||
vss.create_source(name="V (copy)", source_type="static", value=0.1) # occupy the obvious name
|
||||
res = _duplicate_subgraph([v.id], " (copy)")
|
||||
new = vss.get(res["id_map"][v.id])
|
||||
assert new.name == "V (copy) 2"
|
||||
|
||||
|
||||
def test_clone_allowlist_invariant():
|
||||
"""Only explicitly-flagged (secret-free) stores are cloneable; the base
|
||||
default is off so a new store is never cloneable by accident."""
|
||||
from ledgrab.storage.base_sqlite_store import BaseSqliteStore
|
||||
|
||||
assert BaseSqliteStore._cloneable is False
|
||||
assert ColorStripStore._cloneable is True
|
||||
assert ValueSourceStore._cloneable is True
|
||||
|
||||
|
||||
def test_clone_refuses_non_cloneable_store(stores, monkeypatch):
|
||||
"""clone() refuses stores not on the allowlist (defence-in-depth even though
|
||||
the duplicate endpoint already restricts to the safe kinds)."""
|
||||
css, _vss = stores
|
||||
src = css.create_source(name="X", source_type="single_color", colors=[[1, 1, 1]])
|
||||
monkeypatch.setattr(type(css), "_cloneable", False)
|
||||
with pytest.raises(NotImplementedError):
|
||||
css.clone(src.id, "X (copy)")
|
||||
@@ -4,6 +4,10 @@ These exercise reference extraction, topology building, dependents, cycle and
|
||||
dangling-reference detection without booting the app or any store.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ledgrab.api.graph_schema import (
|
||||
CONNECTION_SCHEMA,
|
||||
ENTITY_KINDS,
|
||||
@@ -11,7 +15,11 @@ from ledgrab.api.graph_schema import (
|
||||
detect_cycles,
|
||||
extract_refs,
|
||||
find_dependents,
|
||||
graph_field_roots,
|
||||
is_editable,
|
||||
remap_refs,
|
||||
schema_for_kind,
|
||||
serialize_entity_for_graph,
|
||||
validate_connection,
|
||||
would_create_cycle,
|
||||
)
|
||||
@@ -60,6 +68,56 @@ def test_extract_refs_nested_object_none_is_safe():
|
||||
) == ["pt_1"]
|
||||
|
||||
|
||||
# ── remap_refs (write-twin of extract_refs) ──────────────────────────────────
|
||||
|
||||
|
||||
def test_remap_refs_top_level():
|
||||
e = {"device_id": "a"}
|
||||
assert remap_refs(e, "device_id", {"a": "b"}) == 1
|
||||
assert e["device_id"] == "b"
|
||||
|
||||
|
||||
def test_remap_refs_leaves_unmapped_ids_untouched():
|
||||
e = {"device_id": "external"}
|
||||
assert remap_refs(e, "device_id", {"a": "b"}) == 0
|
||||
assert e["device_id"] == "external" # shared dependency outside the set
|
||||
|
||||
|
||||
def test_remap_refs_bindable_bound():
|
||||
e = {"brightness": {"value": 1.0, "source_id": "a"}}
|
||||
assert remap_refs(e, "brightness.source_id", {"a": "b"}) == 1
|
||||
assert e["brightness"] == {"value": 1.0, "source_id": "b"}
|
||||
|
||||
|
||||
def test_remap_refs_unbound_bindable_is_safe():
|
||||
e = {"brightness": 0.5} # plain number, no binding
|
||||
assert remap_refs(e, "brightness.source_id", {"a": "b"}) == 0
|
||||
assert e["brightness"] == 0.5
|
||||
|
||||
|
||||
def test_remap_refs_list_field_only_mapped_elements():
|
||||
e = {"layers": [{"source_id": "a"}, {"source_id": "external"}, {"source_id": "c"}]}
|
||||
assert remap_refs(e, "layers[].source_id", {"a": "b", "c": "d"}) == 2
|
||||
assert [layer["source_id"] for layer in e["layers"]] == ["b", "external", "d"]
|
||||
|
||||
|
||||
def test_remap_refs_deep_object_then_list():
|
||||
e = {"calibration": {"lines": [{"picture_source_id": "p1"}, {"picture_source_id": "p2"}]}}
|
||||
assert remap_refs(e, "calibration.lines[].picture_source_id", {"p1": "q1"}) == 1
|
||||
assert [ln["picture_source_id"] for ln in e["calibration"]["lines"]] == ["q1", "p2"]
|
||||
|
||||
|
||||
def test_remap_refs_missing_keys_are_safe():
|
||||
assert remap_refs({}, "layers[].source_id", {"a": "b"}) == 0
|
||||
assert remap_refs({"layers": None}, "layers[].source_id", {"a": "b"}) == 0
|
||||
|
||||
|
||||
def test_remap_refs_round_trips_with_extract_refs():
|
||||
e = {"layers": [{"source_id": "a"}, {"source_id": "a"}]}
|
||||
remap_refs(e, "layers[].source_id", {"a": "b"})
|
||||
assert extract_refs(e, "layers[].source_id") == ["b", "b"]
|
||||
|
||||
|
||||
# ── registry consistency ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -249,7 +307,79 @@ def test_validate_connection_rejects_list_field():
|
||||
entities, "color_strip_source", "css_1", "layers[].source_id", "css_2"
|
||||
)
|
||||
assert ok is False
|
||||
assert "List connection" in err
|
||||
assert "not editable" in err
|
||||
|
||||
|
||||
def test_validate_connection_rejects_color_bindable():
|
||||
# Colour bindings are structurally bindable but not graph-editable (a value
|
||||
# source can't drive a colour).
|
||||
entities = {
|
||||
"color_strip_source": [
|
||||
{"id": "css_1", "name": "X", "source_type": "single_color", "color": [255, 0, 0]}
|
||||
],
|
||||
"value_source": [{"id": "vs_1", "name": "V", "source_type": "static"}],
|
||||
}
|
||||
ok, err = validate_connection(
|
||||
entities, "color_strip_source", "css_1", "color.source_id", "vs_1"
|
||||
)
|
||||
assert ok is False
|
||||
assert "not editable" in err
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeRule:
|
||||
token: str = "SUPER_SECRET_WEBHOOK_TOKEN"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeAutomation:
|
||||
id: str = "auto_1"
|
||||
name: str = "A"
|
||||
enabled: bool = True
|
||||
scene_preset_id: str = "sp_1"
|
||||
rules: list = field(default_factory=lambda: [_FakeRule()])
|
||||
|
||||
|
||||
# Keys that must NEVER appear in the /graph projection allowlist.
|
||||
_SECRET_KEY_RE = re.compile(
|
||||
r"token|password|secret|credential|api[_-]?key|_key$|username|\burl\b|\bhost\b", re.I
|
||||
)
|
||||
|
||||
|
||||
def test_projection_roots_never_expose_secrets():
|
||||
# The /graph projection (graph_field_roots) is the leak boundary. Assert no
|
||||
# kind's allowlist contains a secret-bearing key — locks the boundary against
|
||||
# future schema drift (a new reference field whose root looks like a secret).
|
||||
for kind in ENTITY_KINDS:
|
||||
for root in graph_field_roots(kind):
|
||||
assert not _SECRET_KEY_RE.search(
|
||||
root
|
||||
), f"{kind}: projected root {root!r} looks secret-bearing"
|
||||
|
||||
|
||||
def test_serialize_entity_for_graph_drops_secrets():
|
||||
# The graph projection must strip everything except id/name/type + reference
|
||||
# roots — a deep asdict would otherwise leak the webhook token (a real,
|
||||
# auth-equivalent secret) in the /graph response.
|
||||
projected = serialize_entity_for_graph("automation", _FakeAutomation())
|
||||
assert projected["id"] == "auto_1"
|
||||
assert projected["name"] == "A"
|
||||
assert projected["scene_preset_id"] == "sp_1" # reference root kept
|
||||
assert "rules" not in projected # non-reference field dropped
|
||||
assert "enabled" not in projected
|
||||
assert "SUPER_SECRET_WEBHOOK_TOKEN" not in json.dumps(projected)
|
||||
|
||||
|
||||
def test_is_editable_classifies_fields():
|
||||
by_field = {(c.target_kind, c.field): c for c in CONNECTION_SCHEMA}
|
||||
# Top-level reference and single-level BindableFloat → editable.
|
||||
assert is_editable(by_field[("output_target", "device_id")])
|
||||
assert is_editable(by_field[("color_strip_source", "brightness.source_id")])
|
||||
assert is_editable(by_field[("output_target", "transition.source_id")])
|
||||
# Colour binding, list slot, and double-nested → NOT editable.
|
||||
assert not is_editable(by_field[("color_strip_source", "color.source_id")])
|
||||
assert not is_editable(by_field[("color_strip_source", "layers[].source_id")])
|
||||
assert not is_editable(by_field[("output_target", "settings.brightness.source_id")])
|
||||
|
||||
|
||||
def test_validate_connection_rejects_cycle():
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Contract tests for graph drag-to-wire writes.
|
||||
|
||||
The graph editor's ``updateConnection()`` performs a *partial* PUT for a single
|
||||
dragged edge: it sends only the reference (or bindable) field being wired. Five
|
||||
entity kinds have ``Body(discriminator=...)`` PUT routes, so such a partial body
|
||||
is rejected with a 422 unless it also echoes the entity's subtype
|
||||
(``source_type`` / ``stream_type`` / ``target_type``). ``updateConnection()`` now
|
||||
reads the target's subtype back and includes it.
|
||||
|
||||
These tests lock in that contract from the backend side: each ``(kind, field)``
|
||||
pair the frontend ``CONNECTION_MAP`` drag-edits must validate as a minimal
|
||||
``{discriminator, field}`` body — and must be rejected without the discriminator
|
||||
(the exact failure that silently broke wiring). If a future schema change makes
|
||||
one of these fields required-with-siblings, or drops a subtype, these tests fail
|
||||
and flag that graph wiring will break.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from ledgrab.api.schemas.audio_sources import AudioSourceUpdate
|
||||
from ledgrab.api.schemas.color_strip_sources import ColorStripSourceUpdate
|
||||
from ledgrab.api.schemas.output_targets import OutputTargetUpdate
|
||||
from ledgrab.api.schemas.picture_sources import PictureSourceUpdate
|
||||
from ledgrab.api.schemas.value_sources import ValueSourceUpdate
|
||||
|
||||
# Each row mirrors one drag-editable (target_kind, field) pair from the frontend
|
||||
# CONNECTION_MAP, paired with a real subtype tag that owns that field and the
|
||||
# body shape updateConnection() sends: a flat ref id, or a bindable
|
||||
# ``{parent: {source_id}}`` slot (here keyed by the parent field root).
|
||||
# (kind, update_union, discriminator_field, subtype_tag, body_field, sample_value)
|
||||
_WIRING_WRITES = [
|
||||
("value_source", ValueSourceUpdate, "source_type", "audio", "audio_source_id", "as_1"),
|
||||
(
|
||||
"value_source",
|
||||
ValueSourceUpdate,
|
||||
"source_type",
|
||||
"adaptive_scene",
|
||||
"picture_source_id",
|
||||
"ps_1",
|
||||
),
|
||||
("value_source", ValueSourceUpdate, "source_type", "gradient_map", "value_source_id", "vs_1"),
|
||||
(
|
||||
"value_source",
|
||||
ValueSourceUpdate,
|
||||
"source_type",
|
||||
"css_extract",
|
||||
"color_strip_source_id",
|
||||
"css_1",
|
||||
),
|
||||
(
|
||||
"color_strip_source",
|
||||
ColorStripSourceUpdate,
|
||||
"source_type",
|
||||
"picture",
|
||||
"picture_source_id",
|
||||
"ps_1",
|
||||
),
|
||||
(
|
||||
"color_strip_source",
|
||||
ColorStripSourceUpdate,
|
||||
"source_type",
|
||||
"audio",
|
||||
"audio_source_id",
|
||||
"as_1",
|
||||
),
|
||||
(
|
||||
"color_strip_source",
|
||||
ColorStripSourceUpdate,
|
||||
"source_type",
|
||||
"processed",
|
||||
"input_source_id",
|
||||
"css_2",
|
||||
),
|
||||
(
|
||||
"color_strip_source",
|
||||
ColorStripSourceUpdate,
|
||||
"source_type",
|
||||
"processed",
|
||||
"processing_template_id",
|
||||
"cspt_1",
|
||||
),
|
||||
# bindable BindableFloat slot — body is {<parent>: {source_id}}
|
||||
(
|
||||
"color_strip_source",
|
||||
ColorStripSourceUpdate,
|
||||
"source_type",
|
||||
"audio",
|
||||
"smoothing",
|
||||
{"source_id": "vs_1"},
|
||||
),
|
||||
("audio_source", AudioSourceUpdate, "source_type", "capture", "audio_template_id", "at_1"),
|
||||
("audio_source", AudioSourceUpdate, "source_type", "processed", "audio_source_id", "as_2"),
|
||||
("picture_source", PictureSourceUpdate, "stream_type", "raw", "capture_template_id", "ct_1"),
|
||||
("picture_source", PictureSourceUpdate, "stream_type", "processed", "source_stream_id", "ps_2"),
|
||||
(
|
||||
"picture_source",
|
||||
PictureSourceUpdate,
|
||||
"stream_type",
|
||||
"processed",
|
||||
"postprocessing_template_id",
|
||||
"ppt_1",
|
||||
),
|
||||
("output_target", OutputTargetUpdate, "target_type", "led", "device_id", "dev_1"),
|
||||
("output_target", OutputTargetUpdate, "target_type", "led", "color_strip_source_id", "css_1"),
|
||||
# bindable slots on output targets
|
||||
(
|
||||
"output_target",
|
||||
OutputTargetUpdate,
|
||||
"target_type",
|
||||
"led",
|
||||
"brightness",
|
||||
{"source_id": "vs_1"},
|
||||
),
|
||||
(
|
||||
"output_target",
|
||||
OutputTargetUpdate,
|
||||
"target_type",
|
||||
"ha_light",
|
||||
"transition",
|
||||
{"source_id": "vs_1"},
|
||||
),
|
||||
]
|
||||
|
||||
_IDS = [f"{kind}.{field}[{tag}]" for kind, _u, _d, tag, field, _v in _WIRING_WRITES]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,union,disc,tag,field,value", _WIRING_WRITES, ids=_IDS)
|
||||
def test_partial_wiring_put_validates_with_discriminator(kind, union, disc, tag, field, value):
|
||||
"""A single dragged wiring edit validates when the subtype is echoed.
|
||||
|
||||
This is exactly what updateConnection() now sends; if it fails, drag-to-wire
|
||||
for ``kind.field`` is broken.
|
||||
"""
|
||||
model = TypeAdapter(union).validate_python({disc: tag, field: value})
|
||||
assert getattr(model, disc) == tag
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,union,disc,tag,field,value", _WIRING_WRITES, ids=_IDS)
|
||||
def test_partial_wiring_put_rejected_without_discriminator(kind, union, disc, tag, field, value):
|
||||
"""Without the discriminator the same body is rejected.
|
||||
|
||||
This is the 422 that silently broke drag-to-wire before updateConnection()
|
||||
echoed the subtype — guarding against anyone "simplifying" that away.
|
||||
"""
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(union).validate_python({field: value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,union,disc,tag,field,value", _WIRING_WRITES, ids=_IDS)
|
||||
def test_partial_wiring_detach_validates_with_discriminator(kind, union, disc, tag, field, value):
|
||||
"""Detach (clearing a slot) goes through the same partial-PUT path and must
|
||||
also validate with the subtype echoed: ``{field: ""}`` for a flat reference,
|
||||
``{parent: {source_id: ""}}`` for a bindable slot — exactly what
|
||||
``detachConnection()`` -> ``updateConnection(..., "")`` sends.
|
||||
"""
|
||||
cleared = {"source_id": ""} if isinstance(value, dict) else ""
|
||||
model = TypeAdapter(union).validate_python({disc: tag, field: cleared})
|
||||
assert getattr(model, disc) == tag
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Tests for TemplateValueStream (the Jinja combinator runtime)."""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.core.processing.value_stream import (
|
||||
TemplateValueStream,
|
||||
ValueStreamManager,
|
||||
)
|
||||
from ledgrab.storage.value_source import TemplateValueSource
|
||||
|
||||
|
||||
# --- Fakes for precise control over input values / raw -----------------------
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
_NO_RAW = object()
|
||||
|
||||
def __init__(self, value, raw=_NO_RAW):
|
||||
self._value = value
|
||||
self._raw = raw
|
||||
|
||||
def get_value(self):
|
||||
return self._value
|
||||
|
||||
# get_raw_value only exists when a raw value was provided
|
||||
def __getattr__(self, name):
|
||||
if name == "get_raw_value" and self._raw is not _FakeStream._NO_RAW:
|
||||
return lambda: self._raw
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
class _FakeVSM:
|
||||
def __init__(self, streams):
|
||||
self._streams = streams # id -> _FakeStream
|
||||
self.refcounts = defaultdict(int)
|
||||
|
||||
def acquire(self, vs_id):
|
||||
self.refcounts[vs_id] += 1
|
||||
return self._streams[vs_id]
|
||||
|
||||
def release(self, vs_id):
|
||||
self.refcounts[vs_id] -= 1
|
||||
|
||||
|
||||
def _inputs(*pairs):
|
||||
return [{"name": n, "value_source_id": i} for n, i in pairs]
|
||||
|
||||
|
||||
def _make(template, inputs, streams, default_value=0.0, eval_interval=None):
|
||||
vsm = _FakeVSM(streams)
|
||||
stream = TemplateValueStream(
|
||||
template=template,
|
||||
inputs=inputs,
|
||||
default_value=default_value,
|
||||
eval_interval=eval_interval,
|
||||
value_stream_manager=vsm,
|
||||
)
|
||||
stream.start()
|
||||
return stream, vsm
|
||||
|
||||
|
||||
class TestEvaluation:
|
||||
def test_eval_with_inputs(self):
|
||||
stream, vsm = _make("min(a * 2, 1)", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.3)})
|
||||
assert vsm.refcounts["vs_a"] == 1
|
||||
assert stream.get_value() == pytest.approx(0.6)
|
||||
|
||||
def test_clamps_out_of_range(self):
|
||||
stream, _ = _make("a * 10", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)})
|
||||
assert stream.get_value() == 1.0 # 5.0 clamped
|
||||
|
||||
def test_two_inputs(self):
|
||||
stream, _ = _make(
|
||||
"(a + b) / 2",
|
||||
_inputs(("a", "vs_a"), ("b", "vs_b")),
|
||||
{"vs_a": _FakeStream(0.2), "vs_b": _FakeStream(0.8)},
|
||||
)
|
||||
assert stream.get_value() == pytest.approx(0.5)
|
||||
|
||||
def test_shared_id_single_ref(self):
|
||||
# Two variables bound to the same source share one acquisition.
|
||||
stream, vsm = _make(
|
||||
"min(a + b, 1)",
|
||||
_inputs(("a", "vs_x"), ("b", "vs_x")),
|
||||
{"vs_x": _FakeStream(0.3)},
|
||||
)
|
||||
assert vsm.refcounts["vs_x"] == 1
|
||||
assert stream.get_value() == pytest.approx(0.6)
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
def test_div_by_zero_returns_default(self):
|
||||
stream, _ = _make(
|
||||
"a / 0", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)}, default_value=0.25
|
||||
)
|
||||
assert stream.get_value() == 0.25
|
||||
|
||||
def test_missing_variable_returns_default(self):
|
||||
# template references 'b' but only 'a' is bound
|
||||
stream, _ = _make(
|
||||
"a + b", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)}, default_value=0.1
|
||||
)
|
||||
assert stream.get_value() == 0.1
|
||||
|
||||
def test_nan_returns_default(self):
|
||||
stream, _ = _make(
|
||||
"a - a", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(float("inf"))}, default_value=0.3
|
||||
)
|
||||
# inf - inf = nan -> default
|
||||
assert stream.get_value() == 0.3
|
||||
|
||||
def test_invalid_template_uses_default(self):
|
||||
stream, _ = _make(
|
||||
"a +", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.5)}, default_value=0.42
|
||||
)
|
||||
assert stream.get_value() == 0.42
|
||||
|
||||
|
||||
class TestRawExposure:
|
||||
def test_raw_present_when_stream_exposes_it(self):
|
||||
stream, _ = _make(
|
||||
"raw['t'] / 100",
|
||||
_inputs(("t", "vs_t")),
|
||||
{"vs_t": _FakeStream(0.5, raw=42.0)},
|
||||
)
|
||||
assert stream.get_value() == pytest.approx(0.42)
|
||||
|
||||
def test_raw_absent_without_getter(self):
|
||||
# input stream has no get_raw_value -> raw['t'] -> None -> error -> default
|
||||
stream, _ = _make(
|
||||
"raw['t'] / 100",
|
||||
_inputs(("t", "vs_t")),
|
||||
{"vs_t": _FakeStream(0.5)},
|
||||
default_value=0.2,
|
||||
)
|
||||
assert stream.get_value() == 0.2
|
||||
|
||||
def test_non_numeric_raw_is_dropped(self):
|
||||
# raw value is a string -> never crosses into sandbox -> raw['t'] absent
|
||||
stream, _ = _make(
|
||||
"raw['t'] / 100",
|
||||
_inputs(("t", "vs_t")),
|
||||
{"vs_t": _FakeStream(0.5, raw="playing")},
|
||||
default_value=0.15,
|
||||
)
|
||||
assert stream.get_value() == 0.15
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
def test_stop_releases_all(self):
|
||||
stream, vsm = _make(
|
||||
"min(a + b, 1)",
|
||||
_inputs(("a", "vs_a"), ("b", "vs_b")),
|
||||
{"vs_a": _FakeStream(0.1), "vs_b": _FakeStream(0.2)},
|
||||
)
|
||||
stream.stop()
|
||||
assert vsm.refcounts["vs_a"] == 0
|
||||
assert vsm.refcounts["vs_b"] == 0
|
||||
|
||||
def test_eval_interval_caches(self):
|
||||
backing = _FakeStream(0.2)
|
||||
stream, _ = _make("a", _inputs(("a", "vs_a")), {"vs_a": backing}, eval_interval=3600.0)
|
||||
first = stream.get_value()
|
||||
backing._value = 0.9 # change the live input
|
||||
# Cached within the interval -> still the first value.
|
||||
assert stream.get_value() == pytest.approx(first)
|
||||
|
||||
|
||||
class TestHotUpdate:
|
||||
def test_swap_input_releases_old_acquires_new(self):
|
||||
stream, vsm = _make(
|
||||
"a", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.1), "vs_b": _FakeStream(0.9)}
|
||||
)
|
||||
assert vsm.refcounts["vs_a"] == 1
|
||||
new_src = TemplateValueSource(
|
||||
id="t1",
|
||||
name="t",
|
||||
source_type="template",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
template="a",
|
||||
inputs=_inputs(("a", "vs_b")),
|
||||
default_value=0.0,
|
||||
)
|
||||
stream.update_source(new_src)
|
||||
assert vsm.refcounts["vs_a"] == 0 # old released
|
||||
assert vsm.refcounts["vs_b"] == 1 # new acquired
|
||||
assert stream.get_value() == pytest.approx(0.9)
|
||||
|
||||
def test_rename_keeps_same_source(self):
|
||||
stream, vsm = _make("a", _inputs(("a", "vs_a")), {"vs_a": _FakeStream(0.7)})
|
||||
renamed = TemplateValueSource(
|
||||
id="t1",
|
||||
name="t",
|
||||
source_type="template",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
template="b", # variable renamed a -> b, same source id
|
||||
inputs=_inputs(("b", "vs_a")),
|
||||
default_value=0.0,
|
||||
)
|
||||
stream.update_source(renamed)
|
||||
assert vsm.refcounts["vs_a"] == 1 # not re-acquired (unchanged id)
|
||||
assert stream.get_value() == pytest.approx(0.7)
|
||||
|
||||
|
||||
class TestAcquireDepthBackstop:
|
||||
def test_self_reference_does_not_overflow(self):
|
||||
"""A cycle that bypassed storage validation must not stack-overflow."""
|
||||
now = datetime.now(timezone.utc)
|
||||
src = TemplateValueSource(
|
||||
id="vs_cycle",
|
||||
name="cycle",
|
||||
source_type="template",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
template="x",
|
||||
inputs=_inputs(("x", "vs_cycle")),
|
||||
default_value=0.0,
|
||||
)
|
||||
|
||||
class _CycleStore:
|
||||
def get_source(self, vs_id):
|
||||
return src
|
||||
|
||||
manager = ValueStreamManager(value_source_store=_CycleStore())
|
||||
stream = manager.acquire("vs_cycle") # must terminate, not recurse forever
|
||||
assert isinstance(stream.get_value(), float)
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Tests for the optional-normalization feature on magnitude value sources.
|
||||
|
||||
Covers, for HA / HTTP / system_metrics / game_event:
|
||||
* the ``normalize`` flag round-trips through to_dict/from_dict and defaults to
|
||||
True for old rows that predate the field,
|
||||
* ``normalize=False`` makes get_value() a finite-safe clamp passthrough while
|
||||
get_value() stays in [0, 1] (the normalized scalar-bus contract), and
|
||||
* the raw magnitude remains available via get_raw_value() (added to
|
||||
game_event, which had no raw channel before).
|
||||
Plus a unit test for the shared finite-safe ``clamp01`` helper.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||
from ledgrab.core.game_integration.events import GameEvent
|
||||
from ledgrab.core.processing.value_kinds import ValueStreamDeps, build_stream
|
||||
from ledgrab.core.processing.value_stream import (
|
||||
HAEntityValueStream,
|
||||
HTTPValueStream,
|
||||
SystemMetricsValueStream,
|
||||
)
|
||||
from ledgrab.core.value_sources.game_event_value_source import GameEventValueStream
|
||||
from ledgrab.storage.value_source import ValueSource
|
||||
from ledgrab.utils import clamp01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clamp01 finite-safe helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClamp01:
|
||||
def test_in_range_passthrough(self):
|
||||
assert clamp01(0.0) == 0.0
|
||||
assert clamp01(0.42) == pytest.approx(0.42)
|
||||
assert clamp01(1.0) == 1.0
|
||||
|
||||
def test_out_of_range_clamped(self):
|
||||
assert clamp01(-2.5) == 0.0
|
||||
assert clamp01(5.0) == 1.0
|
||||
|
||||
def test_non_finite_rejected_to_default(self):
|
||||
assert clamp01(float("nan")) == 0.0
|
||||
assert clamp01(float("inf")) == 0.0
|
||||
assert clamp01(float("-inf")) == 0.0
|
||||
# custom default is honoured
|
||||
assert clamp01(float("nan"), default=0.5) == 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-trip persistence of the normalize flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _base(source_type: str, **extra) -> dict:
|
||||
return {
|
||||
"id": f"vs_{source_type}",
|
||||
"name": source_type,
|
||||
"source_type": source_type,
|
||||
"created_at": "2025-01-01T00:00:00+00:00",
|
||||
"updated_at": "2025-01-01T00:00:00+00:00",
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
class TestNormalizeRoundTrip:
|
||||
@pytest.mark.parametrize(
|
||||
"source_type,extra",
|
||||
[
|
||||
("ha_entity", {"ha_source_id": "ha1", "entity_id": "sensor.temp"}),
|
||||
("http", {"http_endpoint_id": "ep1"}),
|
||||
("system_metrics", {"metric": "cpu_load"}),
|
||||
("game_event", {}),
|
||||
],
|
||||
)
|
||||
def test_normalize_false_round_trips(self, source_type, extra):
|
||||
src = ValueSource.from_dict(_base(source_type, normalize=False, **extra))
|
||||
assert src.normalize is False
|
||||
assert src.to_dict()["normalize"] is False
|
||||
restored = ValueSource.from_dict(src.to_dict())
|
||||
assert restored.normalize is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source_type,extra",
|
||||
[
|
||||
("ha_entity", {"ha_source_id": "ha1", "entity_id": "sensor.temp"}),
|
||||
("http", {"http_endpoint_id": "ep1"}),
|
||||
("system_metrics", {"metric": "cpu_load"}),
|
||||
("game_event", {}),
|
||||
],
|
||||
)
|
||||
def test_missing_key_defaults_true(self, source_type, extra):
|
||||
"""An old row written before the field existed reads back normalize=True."""
|
||||
src = ValueSource.from_dict(_base(source_type, **extra))
|
||||
assert src.normalize is True
|
||||
assert src.to_dict()["normalize"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP stream — clamp passthrough + verbatim raw
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _http(normalize: bool, raw) -> HTTPValueStream:
|
||||
s = HTTPValueStream(
|
||||
endpoint_id="ep",
|
||||
json_path="",
|
||||
interval_s=60,
|
||||
min_value=0.0,
|
||||
max_value=100.0,
|
||||
smoothing=0.0,
|
||||
normalize=normalize,
|
||||
)
|
||||
s._raw_value = raw # bypass the poll loop; exercise get_value() directly
|
||||
return s
|
||||
|
||||
|
||||
class TestHttpNormalize:
|
||||
def test_normalize_true_rescales(self):
|
||||
assert _http(True, 50.0).get_value() == pytest.approx(0.5)
|
||||
|
||||
def test_normalize_false_clamps_in_range(self):
|
||||
assert _http(False, 0.7).get_value() == pytest.approx(0.7)
|
||||
|
||||
def test_normalize_false_clamps_out_of_range(self):
|
||||
assert _http(False, 5.0).get_value() == 1.0
|
||||
assert _http(False, -3.0).get_value() == 0.0
|
||||
|
||||
def test_normalize_false_non_finite_safe(self):
|
||||
assert _http(False, float("inf")).get_value() == 0.0
|
||||
assert _http(False, float("nan")).get_value() == 0.0
|
||||
|
||||
def test_non_numeric_raw_keeps_get_value_numeric_and_raw_verbatim(self):
|
||||
s = _http(False, "playing")
|
||||
assert s.get_value() == 0.0 # float("playing") fails -> safe fallback
|
||||
assert s.get_raw_value() == "playing" # verbatim for automations/templates
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HA stream — clamp passthrough via a minimal fake HA manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeState:
|
||||
def __init__(self, state, attributes=None):
|
||||
self.state = state
|
||||
self.attributes = attributes or {}
|
||||
|
||||
|
||||
class _FakeHA:
|
||||
def __init__(self, state):
|
||||
self._state = state
|
||||
|
||||
def get_state(self, _source_id, _entity_id):
|
||||
return self._state
|
||||
|
||||
|
||||
def _ha(normalize: bool, state_value: str) -> HAEntityValueStream:
|
||||
return HAEntityValueStream(
|
||||
ha_source_id="ha1",
|
||||
entity_id="sensor.temp",
|
||||
attribute="",
|
||||
min_ha_value=0.0,
|
||||
max_ha_value=100.0,
|
||||
smoothing=0.0,
|
||||
normalize=normalize,
|
||||
ha_manager=_FakeHA(_FakeState(state_value)),
|
||||
)
|
||||
|
||||
|
||||
class TestHaNormalize:
|
||||
def test_normalize_true_rescales(self):
|
||||
assert _ha(True, "50").get_value() == pytest.approx(0.5)
|
||||
|
||||
def test_normalize_false_clamps_and_keeps_raw(self):
|
||||
s = _ha(False, "21.5") # e.g. °C — out of [0,1]
|
||||
assert s.get_value() == 1.0 # clamped
|
||||
assert s.get_raw_value() == pytest.approx(21.5) # un-clamped magnitude
|
||||
|
||||
def test_normalize_false_in_range_fraction(self):
|
||||
s = _ha(False, "0.3")
|
||||
assert s.get_value() == pytest.approx(0.3)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# system_metrics stream — clamp passthrough (raw reader monkeypatched)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSystemMetricsNormalize:
|
||||
def test_normalize_false_clamps_and_keeps_raw(self):
|
||||
s = SystemMetricsValueStream(
|
||||
metric="cpu_load", min_value=0.0, max_value=100.0, normalize=False
|
||||
)
|
||||
s._read_metric = lambda: 65.0 # type: ignore[method-assign]
|
||||
assert s.get_value() == 1.0 # 65 clamped to 1.0
|
||||
assert s.get_raw_value() == pytest.approx(65.0)
|
||||
|
||||
def test_normalize_false_in_range_fraction(self):
|
||||
s = SystemMetricsValueStream(
|
||||
metric="cpu_load", min_value=0.0, max_value=100.0, normalize=False
|
||||
)
|
||||
s._read_metric = lambda: 0.25 # type: ignore[method-assign]
|
||||
assert s.get_value() == pytest.approx(0.25)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# game_event stream — new raw channel + normalize flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _evt(value: float) -> GameEvent:
|
||||
return GameEvent(adapter_id="t", event_type="health", value=value)
|
||||
|
||||
|
||||
class TestGameEventNormalizeAndRaw:
|
||||
def test_raw_value_none_before_first_event(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(event_type="health", event_bus=bus)
|
||||
stream.start()
|
||||
assert stream.get_raw_value() is None
|
||||
stream.stop()
|
||||
|
||||
def test_raw_value_exposed_after_event(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health", min_game_value=0.0, max_game_value=100.0, event_bus=bus
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_evt(73.0))
|
||||
assert stream.get_value() == pytest.approx(0.73) # normalized output
|
||||
assert stream.get_raw_value() == pytest.approx(73.0) # un-clamped raw
|
||||
stream.stop()
|
||||
|
||||
def test_normalize_false_clamps_output_keeps_raw(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
normalize=False,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_evt(50.0)) # 50 is > 1 -> clamped to 1.0 in passthrough mode
|
||||
assert stream.get_value() == 1.0
|
||||
assert stream.get_raw_value() == pytest.approx(50.0)
|
||||
stream.stop()
|
||||
|
||||
def test_normalize_false_in_range_fraction(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(event_type="health", normalize=False, event_bus=bus)
|
||||
stream.start()
|
||||
bus.publish(_evt(0.4))
|
||||
assert stream.get_value() == pytest.approx(0.4)
|
||||
stream.stop()
|
||||
|
||||
def test_get_value_always_finite_in_passthrough(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(event_type="health", normalize=False, event_bus=bus)
|
||||
stream.start()
|
||||
bus.publish(_evt(math.inf))
|
||||
assert stream.get_value() == 0.0 # finite-safe clamp
|
||||
stream.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live update_source() flip of the normalize flag on a running stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The design's load-bearing invariant: flipping normalize live never blends a
|
||||
# raw magnitude against a fraction in the EMA, because BOTH modes emit [0,1].
|
||||
# These tests lock that in (a future regression dropping a stream's
|
||||
# _normalize_enabled reassignment would otherwise pass every other test).
|
||||
|
||||
|
||||
class TestLiveNormalizeFlip:
|
||||
def test_http_flip_true_to_false_and_back(self):
|
||||
s = _http(True, 50.0)
|
||||
assert s.get_value() == pytest.approx(0.5) # rescaled
|
||||
s.update_source(
|
||||
ValueSource.from_dict(
|
||||
_base(
|
||||
"http", http_endpoint_id="ep", min_value=0.0, max_value=100.0, normalize=False
|
||||
)
|
||||
)
|
||||
)
|
||||
assert s.get_value() == 1.0 # now clamp-passthrough of 50.0
|
||||
s.update_source(
|
||||
ValueSource.from_dict(
|
||||
_base("http", http_endpoint_id="ep", min_value=0.0, max_value=100.0, normalize=True)
|
||||
)
|
||||
)
|
||||
assert s.get_value() == pytest.approx(0.5) # rescaled again, no stale-cache blend
|
||||
|
||||
def test_ha_flip_true_to_false(self):
|
||||
s = _ha(True, "50")
|
||||
assert s.get_value() == pytest.approx(0.5)
|
||||
s.update_source(
|
||||
ValueSource.from_dict(
|
||||
_base(
|
||||
"ha_entity",
|
||||
ha_source_id="ha1",
|
||||
entity_id="sensor.temp",
|
||||
min_ha_value=0.0,
|
||||
max_ha_value=100.0,
|
||||
normalize=False,
|
||||
)
|
||||
)
|
||||
)
|
||||
assert s.get_value() == 1.0
|
||||
|
||||
def test_system_metrics_flip_true_to_false(self):
|
||||
s = SystemMetricsValueStream(
|
||||
metric="cpu_load", min_value=0.0, max_value=100.0, normalize=True
|
||||
)
|
||||
s._read_metric = lambda: 65.0 # type: ignore[method-assign]
|
||||
first = s.get_value() # rescaled via the cpu_load spec
|
||||
assert 0.0 <= first <= 1.0
|
||||
s.update_source(
|
||||
ValueSource.from_dict(
|
||||
_base(
|
||||
"system_metrics",
|
||||
metric="cpu_load",
|
||||
min_value=0.0,
|
||||
max_value=100.0,
|
||||
normalize=False,
|
||||
)
|
||||
)
|
||||
)
|
||||
s._read_metric = lambda: 65.0 # type: ignore[method-assign]
|
||||
s._prev_value = None # force a fresh poll (bypass the poll-interval cache)
|
||||
assert s.get_value() == 1.0 # clamp-passthrough of 65.0
|
||||
|
||||
def test_game_event_flip_true_to_false(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
normalize=True,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_evt(50.0))
|
||||
assert stream.get_value() == pytest.approx(0.5)
|
||||
stream.update_source(
|
||||
ValueSource.from_dict(
|
||||
_base("game_event", min_game_value=0.0, max_game_value=100.0, normalize=False)
|
||||
)
|
||||
)
|
||||
bus.publish(_evt(50.0))
|
||||
assert stream.get_value() == 1.0 # clamp-passthrough
|
||||
assert stream.get_raw_value() == pytest.approx(50.0)
|
||||
stream.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_stream() forwarding — game_event's SOLE wiring path (no CRUD/schema)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildStreamForwarding:
|
||||
def test_game_event_builder_forwards_normalize(self):
|
||||
src = ValueSource.from_dict(
|
||||
_base("game_event", min_game_value=0.0, max_game_value=100.0, normalize=False)
|
||||
)
|
||||
bus = GameEventBus()
|
||||
deps = ValueStreamDeps(value_stream_manager=None, event_bus=bus)
|
||||
stream = build_stream(src, deps)
|
||||
assert isinstance(stream, GameEventValueStream)
|
||||
assert stream._normalize_enabled is False # forwarded from the dataclass
|
||||
stream.start()
|
||||
bus.publish(_evt(50.0))
|
||||
assert stream.get_value() == 1.0 # passthrough clamp, not 0.5 rescale
|
||||
assert stream.get_raw_value() == pytest.approx(50.0)
|
||||
stream.stop()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Tests for the Android playback-capture audio engine.
|
||||
|
||||
These run on desktop CI (no Android device needed): ``is_android`` is
|
||||
monkeypatched and PCM is pushed directly into the module-level queue,
|
||||
exactly as the Kotlin bridge would.
|
||||
"""
|
||||
|
||||
import queue
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# Importing the package triggers auto-registration of AndroidAudioEngine.
|
||||
import ledgrab.core.audio # noqa: F401
|
||||
from ledgrab.core.audio import android_audio_engine as eng
|
||||
from ledgrab.core.audio.analysis import AudioAnalysis, AudioAnalyzer
|
||||
from ledgrab.core.audio.audio_capture import AudioCaptureManager
|
||||
from ledgrab.core.audio.factory import AudioEngineRegistry
|
||||
|
||||
ENGINE_MOD = "ledgrab.core.audio.android_audio_engine"
|
||||
SAMPLE_RATE = 48000
|
||||
CHANNELS = 2
|
||||
CHUNK = 1024
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _drain() -> None:
|
||||
while not eng._pcm_queue.empty():
|
||||
try:
|
||||
eng._pcm_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
|
||||
def _block(marker: float = 0.0, frames: int = CHUNK, channels: int = CHANNELS) -> np.ndarray:
|
||||
"""A float32 interleaved block whose first sample is ``marker``."""
|
||||
data = np.zeros(frames * channels, dtype=np.float32)
|
||||
data[0] = marker
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_engine():
|
||||
"""Reset module-global engine state; snapshot/restore the registry.
|
||||
|
||||
The engine keeps its queue + format in module globals and the registry
|
||||
is a class-level singleton — both must be restored so this test file
|
||||
never disturbs the desktop engines other tests rely on.
|
||||
"""
|
||||
saved_engines = dict(AudioEngineRegistry._engines)
|
||||
eng.shutdown()
|
||||
_drain()
|
||||
eng._sample_rate = SAMPLE_RATE
|
||||
eng._channels = CHANNELS
|
||||
eng._chunk_size = CHUNK
|
||||
eng._frames_received = 0
|
||||
|
||||
yield eng
|
||||
|
||||
eng.shutdown()
|
||||
_drain()
|
||||
AudioEngineRegistry._engines.clear()
|
||||
AudioEngineRegistry._engines.update(saved_engines)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def on_android(monkeypatch, reset_engine):
|
||||
"""Engine fixture with ``is_android`` forced True and demo mode off."""
|
||||
monkeypatch.setattr(f"{ENGINE_MOD}.is_android", lambda: True)
|
||||
monkeypatch.setattr("ledgrab.core.audio.factory.is_demo_mode", lambda: False)
|
||||
return reset_engine
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Queue / push contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_configure_then_push_round_trips_samples(reset_engine):
|
||||
# Arrange
|
||||
eng.configure(SAMPLE_RATE, CHANNELS, CHUNK)
|
||||
samples = np.arange(CHUNK * CHANNELS, dtype=np.float32)
|
||||
|
||||
# Act
|
||||
eng.push_samples(samples.tobytes())
|
||||
stream = eng.AndroidAudioEngine.create_stream(0, True, {})
|
||||
stream.initialize()
|
||||
got = stream.read_chunk()
|
||||
|
||||
# Assert
|
||||
assert got is not None
|
||||
np.testing.assert_array_equal(got, samples)
|
||||
|
||||
|
||||
def test_queue_drops_oldest_when_full(reset_engine):
|
||||
# Arrange
|
||||
eng.configure(SAMPLE_RATE, CHANNELS, CHUNK)
|
||||
maxsize = eng._pcm_queue.maxsize # 8
|
||||
|
||||
# Act — push more blocks than the queue can hold, each tagged 0..N-1
|
||||
total = maxsize + 2
|
||||
for i in range(total):
|
||||
eng.push_samples(_block(marker=float(i)).tobytes())
|
||||
|
||||
drained = []
|
||||
while True:
|
||||
try:
|
||||
drained.append(eng._pcm_queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Assert — only the newest `maxsize` blocks survived, oldest dropped
|
||||
assert len(drained) == maxsize
|
||||
markers = [int(b[0]) for b in drained]
|
||||
assert markers == list(range(total - maxsize, total))
|
||||
|
||||
|
||||
def test_initialize_raises_when_not_configured(reset_engine):
|
||||
# Arrange — fixture left the engine inactive
|
||||
stream = eng.AndroidAudioEngine.create_stream(0, True, {})
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(RuntimeError):
|
||||
stream.initialize()
|
||||
|
||||
|
||||
def test_read_chunk_returns_none_when_empty(reset_engine):
|
||||
eng.configure(SAMPLE_RATE, CHANNELS, CHUNK)
|
||||
stream = eng.AndroidAudioEngine.create_stream(0, True, {})
|
||||
stream.initialize()
|
||||
assert stream.read_chunk() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Availability / enumeration (platform-gated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_available_requires_android_and_active(monkeypatch, reset_engine):
|
||||
# Not configured yet → inactive → unavailable even on Android.
|
||||
monkeypatch.setattr(f"{ENGINE_MOD}.is_android", lambda: True)
|
||||
assert eng.AndroidAudioEngine.is_available() is False
|
||||
|
||||
# Configured → active + Android → available.
|
||||
eng.configure(SAMPLE_RATE, CHANNELS, CHUNK)
|
||||
assert eng.AndroidAudioEngine.is_available() is True
|
||||
|
||||
# Active but not on Android → unavailable.
|
||||
monkeypatch.setattr(f"{ENGINE_MOD}.is_android", lambda: False)
|
||||
assert eng.AndroidAudioEngine.is_available() is False
|
||||
|
||||
|
||||
def test_enumerate_devices(on_android):
|
||||
# Inactive → no devices.
|
||||
assert eng.AndroidAudioEngine.enumerate_devices() == []
|
||||
|
||||
# Active → exactly one loopback device.
|
||||
eng.configure(SAMPLE_RATE, CHANNELS, CHUNK)
|
||||
devices = eng.AndroidAudioEngine.enumerate_devices()
|
||||
assert len(devices) == 1
|
||||
dev = devices[0]
|
||||
assert dev.is_loopback is True
|
||||
assert dev.is_input is True
|
||||
assert "Android playback" in dev.name
|
||||
assert dev.channels == CHANNELS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression guard — the analyzer must never crash on a malformed block
|
||||
# (over-length or non-frame-divisible). This is the on-device failure the
|
||||
# plan review surfaced; the desktop suite must catch it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_floats",
|
||||
[
|
||||
(CHUNK + 100) * CHANNELS, # over-length (more frames than chunk_size)
|
||||
CHUNK * CHANNELS + 1, # not a whole number of stereo frames
|
||||
3, # tiny + odd
|
||||
CHUNK * CHANNELS, # exact (control)
|
||||
],
|
||||
)
|
||||
def test_pushed_block_never_crashes_analyzer(reset_engine, raw_floats):
|
||||
# Arrange
|
||||
eng.configure(SAMPLE_RATE, CHANNELS, CHUNK)
|
||||
pcm = np.random.default_rng(0).standard_normal(raw_floats).astype(np.float32)
|
||||
analyzer = AudioAnalyzer(sample_rate=SAMPLE_RATE, chunk_size=CHUNK)
|
||||
stream = eng.AndroidAudioEngine.create_stream(0, True, {})
|
||||
stream.initialize()
|
||||
|
||||
# Act
|
||||
eng.push_samples(pcm.tobytes())
|
||||
chunk = stream.read_chunk()
|
||||
|
||||
# Assert — chunk is a safe shape and analyze() does not raise.
|
||||
assert chunk is not None
|
||||
assert len(chunk) % CHANNELS == 0
|
||||
assert len(chunk) <= CHUNK * CHANNELS
|
||||
analysis = analyzer.analyze(chunk, CHANNELS)
|
||||
assert isinstance(analysis, AudioAnalysis)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_best_available_engine_is_android_when_active(on_android):
|
||||
# Arrange
|
||||
eng.configure(SAMPLE_RATE, CHANNELS, CHUNK)
|
||||
|
||||
# Act
|
||||
best = AudioEngineRegistry.get_best_available_engine()
|
||||
|
||||
# Assert — priority 100 beats every desktop engine; demo only wins in demo mode.
|
||||
assert best == "android_playback"
|
||||
|
||||
|
||||
def test_stream_via_registry_yields_pushed_chunk(on_android):
|
||||
# Arrange
|
||||
eng.configure(SAMPLE_RATE, CHANNELS, CHUNK)
|
||||
samples = np.linspace(-1.0, 1.0, CHUNK * CHANNELS, dtype=np.float32)
|
||||
|
||||
# Act
|
||||
stream = AudioEngineRegistry.create_stream("android_playback", 0, True, {})
|
||||
stream.initialize()
|
||||
eng.push_samples(samples.tobytes())
|
||||
got = stream.read_chunk()
|
||||
|
||||
# Assert
|
||||
assert stream.channels == CHANNELS
|
||||
assert stream.sample_rate == SAMPLE_RATE
|
||||
assert stream.chunk_size == CHUNK
|
||||
np.testing.assert_array_equal(got, samples)
|
||||
|
||||
|
||||
def test_device_surfaces_through_capture_manager(on_android):
|
||||
# Arrange
|
||||
eng.configure(SAMPLE_RATE, CHANNELS, CHUNK)
|
||||
|
||||
# Act
|
||||
devices = AudioCaptureManager.enumerate_devices()
|
||||
|
||||
# Assert — the Android device is enumerated and tagged with its engine.
|
||||
android = [d for d in devices if d["engine_type"] == "android_playback"]
|
||||
assert len(android) == 1
|
||||
assert android[0]["name"] == "Android playback (system audio)"
|
||||
assert android[0]["is_loopback"] is True
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Demo-seed regression tests (value sources, incl. the template combinator)."""
|
||||
|
||||
from ledgrab.core.demo_seed import seed_demo_data
|
||||
from ledgrab.storage.database import Database
|
||||
from ledgrab.storage.value_source import StaticValueSource, TemplateValueSource
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
|
||||
|
||||
def _seed(tmp_path):
|
||||
db = Database(tmp_path / "demo.db")
|
||||
seed_demo_data(db)
|
||||
return db
|
||||
|
||||
|
||||
def test_demo_seeds_template_value_source(tmp_path):
|
||||
db = _seed(tmp_path)
|
||||
try:
|
||||
store = ValueSourceStore(db)
|
||||
by_id = {s.id: s for s in store.get_all_sources()}
|
||||
|
||||
base = by_id["vs_demo0001"]
|
||||
boost = by_id["vs_demo0002"]
|
||||
assert isinstance(base, StaticValueSource)
|
||||
assert isinstance(boost, TemplateValueSource)
|
||||
assert boost.template == "clamp(level * 1.5)"
|
||||
assert boost.inputs == [{"name": "level", "value_source_id": "vs_demo0001"}]
|
||||
|
||||
# The reference graph is intact and consistent.
|
||||
assert store.get_transitive_dependencies("vs_demo0002") == {"vs_demo0001"}
|
||||
assert store.find_referencing_sources("vs_demo0001") == [boost.name]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_demo_template_evaluates_through_manager(tmp_path):
|
||||
"""The seeded template must actually evaluate over its seeded input."""
|
||||
from ledgrab.core.processing.value_stream import ValueStreamManager
|
||||
|
||||
db = _seed(tmp_path)
|
||||
try:
|
||||
store = ValueSourceStore(db)
|
||||
vsm = ValueStreamManager(value_source_store=store)
|
||||
stream = vsm.acquire("vs_demo0002")
|
||||
try:
|
||||
# base level 0.5 -> clamp(0.5 * 1.5) = 0.75
|
||||
assert abs(stream.get_value() - 0.75) < 1e-6
|
||||
finally:
|
||||
vsm.release("vs_demo0002")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Tests for the template value source: model, factory, cycle/depth, refs."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.storage.value_source import TemplateValueSource
|
||||
|
||||
|
||||
class TestModelRoundTrip:
|
||||
def _make(self, **over):
|
||||
now = datetime.now(timezone.utc)
|
||||
defaults = dict(
|
||||
id="vs_t1",
|
||||
name="Combo",
|
||||
source_type="template",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
template="min(a * 2, 1)",
|
||||
inputs=[{"name": "a", "value_source_id": "vs_a"}],
|
||||
default_value=0.2,
|
||||
eval_interval=1.5,
|
||||
)
|
||||
defaults.update(over)
|
||||
return TemplateValueSource(**defaults)
|
||||
|
||||
def test_to_from_dict_idempotent(self):
|
||||
src = self._make()
|
||||
rebuilt = TemplateValueSource.from_dict(src.to_dict())
|
||||
assert rebuilt.template == src.template
|
||||
assert rebuilt.inputs == src.inputs
|
||||
assert rebuilt.default_value == src.default_value
|
||||
assert rebuilt.eval_interval == src.eval_interval
|
||||
assert rebuilt.to_dict()["return_type"] == "float"
|
||||
|
||||
def test_old_row_deserializes_with_defaults(self):
|
||||
"""A row written before template fields existed must load safely."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
src = TemplateValueSource.from_dict(
|
||||
{
|
||||
"id": "vs_old",
|
||||
"name": "Old",
|
||||
"source_type": "template",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
assert src.template == ""
|
||||
assert src.inputs == []
|
||||
assert src.default_value == 0.0
|
||||
assert src.eval_interval is None
|
||||
|
||||
def test_dirty_scalars_coerce_to_defaults(self):
|
||||
"""Non-numeric stored scalars must not drop the whole row on load."""
|
||||
src = TemplateValueSource.from_dict(
|
||||
{
|
||||
"id": "x",
|
||||
"name": "n",
|
||||
"source_type": "template",
|
||||
"template": "a",
|
||||
"default_value": "not-a-number",
|
||||
"eval_interval": "bad",
|
||||
}
|
||||
)
|
||||
assert src.default_value == 0.0
|
||||
assert src.eval_interval is None
|
||||
|
||||
def test_inputs_normalized_from_dirty_data(self):
|
||||
src = TemplateValueSource.from_dict(
|
||||
{
|
||||
"id": "x",
|
||||
"name": "n",
|
||||
"source_type": "template",
|
||||
"inputs": [{"name": "a", "value_source_id": "vs_a"}, "junk", {"bad": 1}],
|
||||
}
|
||||
)
|
||||
# non-dict entries dropped; dict entries coerced to {name, value_source_id}
|
||||
assert src.inputs == [
|
||||
{"name": "a", "value_source_id": "vs_a"},
|
||||
{"name": "", "value_source_id": ""},
|
||||
]
|
||||
|
||||
|
||||
class TestFactoryCreate:
|
||||
def test_create_valid(self, value_source_store):
|
||||
src = value_source_store.create_source(
|
||||
"Combo",
|
||||
"template",
|
||||
template="min(a * 2, 1)",
|
||||
inputs=[{"name": "a", "value_source_id": ""}],
|
||||
default_value=0.3,
|
||||
)
|
||||
assert isinstance(src, TemplateValueSource)
|
||||
assert src.id.startswith("vs_")
|
||||
assert src.default_value == 0.3
|
||||
|
||||
def test_empty_template_rejected(self, value_source_store):
|
||||
with pytest.raises(ValueError):
|
||||
value_source_store.create_source("X", "template", template=" ", inputs=[])
|
||||
|
||||
def test_compile_error_rejected(self, value_source_store):
|
||||
with pytest.raises(ValueError):
|
||||
value_source_store.create_source("X", "template", template="a +", inputs=[])
|
||||
|
||||
def test_cost_bomb_rejected(self, value_source_store):
|
||||
with pytest.raises(ValueError):
|
||||
value_source_store.create_source("X", "template", template="10 ** 10", inputs=[])
|
||||
|
||||
def test_reserved_input_name_rejected(self, value_source_store):
|
||||
with pytest.raises(ValueError):
|
||||
value_source_store.create_source(
|
||||
"X",
|
||||
"template",
|
||||
template="min(0, 1)",
|
||||
inputs=[{"name": "min", "value_source_id": "vs_a"}],
|
||||
)
|
||||
|
||||
def test_duplicate_input_name_rejected(self, value_source_store):
|
||||
with pytest.raises(ValueError):
|
||||
value_source_store.create_source(
|
||||
"X",
|
||||
"template",
|
||||
template="a",
|
||||
inputs=[
|
||||
{"name": "a", "value_source_id": "vs_a"},
|
||||
{"name": "a", "value_source_id": "vs_b"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_default_value_out_of_range_rejected(self, value_source_store):
|
||||
with pytest.raises(ValueError):
|
||||
value_source_store.create_source(
|
||||
"X",
|
||||
"template",
|
||||
template="a",
|
||||
inputs=[{"name": "a", "value_source_id": ""}],
|
||||
default_value=5.0,
|
||||
)
|
||||
|
||||
def test_unbound_variable_rejected(self, value_source_store):
|
||||
# 'ha_enti' is referenced but only 'ha_entity' is bound (typo) → reject.
|
||||
with pytest.raises(ValueError):
|
||||
value_source_store.create_source(
|
||||
"X",
|
||||
"template",
|
||||
template="ha_enti",
|
||||
inputs=[{"name": "ha_entity", "value_source_id": ""}],
|
||||
)
|
||||
|
||||
|
||||
class TestFactoryUpdate:
|
||||
def test_partial_update_template_only(self, value_source_store):
|
||||
src = value_source_store.create_source(
|
||||
"X",
|
||||
"template",
|
||||
template="a",
|
||||
inputs=[{"name": "a", "value_source_id": ""}],
|
||||
default_value=0.1,
|
||||
)
|
||||
updated = value_source_store.update_source(src.id, template="clamp(a * 3)")
|
||||
assert updated.template == "clamp(a * 3)"
|
||||
assert updated.default_value == 0.1 # unchanged
|
||||
|
||||
def test_update_invalid_template_rejected(self, value_source_store):
|
||||
src = value_source_store.create_source("X", "template", template="clamp(0.5)", inputs=[])
|
||||
with pytest.raises(ValueError):
|
||||
value_source_store.update_source(src.id, template="a |")
|
||||
|
||||
|
||||
class TestCycleAndDepth:
|
||||
def test_self_reference_rejected(self, value_source_store):
|
||||
t = value_source_store.create_source("T", "template", template="clamp(0.5)", inputs=[])
|
||||
with pytest.raises(ValueError):
|
||||
value_source_store.update_source(t.id, inputs=[{"name": "x", "value_source_id": t.id}])
|
||||
|
||||
def test_circular_reference_rejected(self, value_source_store):
|
||||
t1 = value_source_store.create_source("T1", "template", template="clamp(0.5)", inputs=[])
|
||||
t2 = value_source_store.create_source(
|
||||
"T2",
|
||||
"template",
|
||||
template="x",
|
||||
inputs=[{"name": "x", "value_source_id": t1.id}],
|
||||
)
|
||||
# t1 -> t2 -> t1 would be a cycle
|
||||
with pytest.raises(ValueError):
|
||||
value_source_store.update_source(
|
||||
t1.id, inputs=[{"name": "x", "value_source_id": t2.id}]
|
||||
)
|
||||
|
||||
def test_deep_chain_rejected(self, value_source_store):
|
||||
prev = value_source_store.create_source("L0", "template", template="clamp(0.5)", inputs=[])
|
||||
created = 1
|
||||
with pytest.raises(ValueError):
|
||||
for i in range(1, 12):
|
||||
node = value_source_store.create_source(
|
||||
f"L{i}",
|
||||
"template",
|
||||
template="x",
|
||||
inputs=[{"name": "x", "value_source_id": prev.id}],
|
||||
)
|
||||
prev = node
|
||||
created += 1
|
||||
# Should have rejected before building an unbounded chain.
|
||||
assert created <= 8
|
||||
|
||||
def test_get_transitive_dependencies(self, value_source_store):
|
||||
leaf = value_source_store.create_source(
|
||||
"leaf", "template", template="clamp(0.5)", inputs=[]
|
||||
)
|
||||
mid = value_source_store.create_source(
|
||||
"mid", "template", template="x", inputs=[{"name": "x", "value_source_id": leaf.id}]
|
||||
)
|
||||
top = value_source_store.create_source(
|
||||
"top", "template", template="x", inputs=[{"name": "x", "value_source_id": mid.id}]
|
||||
)
|
||||
deps = value_source_store.get_transitive_dependencies(top.id)
|
||||
assert deps == {mid.id, leaf.id}
|
||||
|
||||
|
||||
class TestReferencingSources:
|
||||
def test_find_referencing_sources(self, value_source_store):
|
||||
base = value_source_store.create_source("Base", "static", value=0.5)
|
||||
tmpl = value_source_store.create_source(
|
||||
"Uses",
|
||||
"template",
|
||||
template="b",
|
||||
inputs=[{"name": "b", "value_source_id": base.id}],
|
||||
)
|
||||
refs = value_source_store.find_referencing_sources(base.id)
|
||||
assert tmpl.name in refs
|
||||
assert value_source_store.find_referencing_sources(tmpl.id) == []
|
||||
@@ -7,7 +7,10 @@ from ledgrab.storage.value_source import (
|
||||
AnimatedValueSource,
|
||||
AudioValueSource,
|
||||
DaylightValueSource,
|
||||
HAEntityValueSource,
|
||||
HTTPValueSource,
|
||||
StaticValueSource,
|
||||
SystemMetricsValueSource,
|
||||
ValueSource,
|
||||
)
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
@@ -238,6 +241,50 @@ class TestValueSourceStoreCRUD:
|
||||
assert updated.speed == 30.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize flag — full store create/update path (factory builder + applier)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValueSourceNormalizeStoreRoundTrip:
|
||||
"""The ``normalize`` flag must survive the real write path —
|
||||
create_source -> CREATE_BUILDERS and update_source -> UPDATE_APPLIERS —
|
||||
not just dataclass from_dict/to_dict. A dropped builder/applier line would
|
||||
silently revert/ignore the flag while every dataclass-level test stayed
|
||||
green, so these exercise the factory layer end-to-end (game_event is
|
||||
intentionally excluded — it has no store/CRUD path).
|
||||
"""
|
||||
|
||||
_CASES = [
|
||||
("ha_entity", {"ha_source_id": "ha1", "entity_id": "sensor.temp"}, HAEntityValueSource),
|
||||
("http", {"http_endpoint_id": "ep1"}, HTTPValueSource),
|
||||
("system_metrics", {"metric": "cpu_load"}, SystemMetricsValueSource),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("source_type,kwargs,cls", _CASES)
|
||||
def test_create_normalize_false_persists(self, store, source_type, kwargs, cls):
|
||||
s = store.create_source(
|
||||
name=f"{source_type}_n", source_type=source_type, normalize=False, **kwargs
|
||||
)
|
||||
assert isinstance(s, cls)
|
||||
assert s.normalize is False
|
||||
# Re-read exercises the SQLite blob persistence round-trip as well.
|
||||
assert store.get_source(s.id).normalize is False
|
||||
|
||||
@pytest.mark.parametrize("source_type,kwargs,cls", _CASES)
|
||||
def test_create_normalize_defaults_true(self, store, source_type, kwargs, cls):
|
||||
s = store.create_source(name=f"{source_type}_d", source_type=source_type, **kwargs)
|
||||
assert s.normalize is True
|
||||
|
||||
@pytest.mark.parametrize("source_type,kwargs,cls", _CASES)
|
||||
def test_update_normalize_flip(self, store, source_type, kwargs, cls):
|
||||
s = store.create_source(
|
||||
name=f"{source_type}_u", source_type=source_type, normalize=False, **kwargs
|
||||
)
|
||||
assert store.update_source(s.id, normalize=True).normalize is True
|
||||
assert store.update_source(s.id, normalize=False).normalize is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name uniqueness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for the hardened sandboxed-Jinja expression engine."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.utils.template_expr import (
|
||||
GLOBALS,
|
||||
RESERVED_NAMES,
|
||||
TemplateValidationError,
|
||||
clamp,
|
||||
compile_template,
|
||||
extract_variables,
|
||||
finalize_result,
|
||||
validate_input_name,
|
||||
validate_template_expression,
|
||||
)
|
||||
|
||||
|
||||
class TestCompileAndEval:
|
||||
def test_basic_eval(self):
|
||||
assert compile_template("min(a * 2, 1)")(a=0.3, raw={}) == pytest.approx(0.6)
|
||||
|
||||
def test_clamp_global(self):
|
||||
assert compile_template("clamp((t - 18) / 10)")(t=22.5, raw={}) == pytest.approx(0.45)
|
||||
|
||||
def test_raw_subscript(self):
|
||||
assert compile_template("raw['t'] / 100")(raw={"t": 42.0}) == pytest.approx(0.42)
|
||||
|
||||
def test_ternary_and_comparison(self):
|
||||
expr = compile_template("a if a > 0.5 else b")
|
||||
assert expr(a=0.8, b=0.1, raw={}) == pytest.approx(0.8)
|
||||
assert expr(a=0.2, b=0.1, raw={}) == pytest.approx(0.1)
|
||||
|
||||
def test_all_globals_callable(self):
|
||||
for tpl in ("min(a, b)", "max(a, b)", "abs(a - b)", "round(a, 1)", "clamp(a)"):
|
||||
compile_template(tpl)(a=0.4, b=0.6, raw={})
|
||||
|
||||
|
||||
class TestRejections:
|
||||
@pytest.mark.parametrize(
|
||||
"tpl",
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
"a +", # syntax error
|
||||
"10 ** 3", # power bomb
|
||||
"'a' * 1000", # string repetition
|
||||
"a | pprint", # filter
|
||||
"a is defined", # test
|
||||
"a.__class__", # attribute access
|
||||
"raw['s'].format(1)", # str gadget via attribute
|
||||
"dict(x=1)", # non-global call
|
||||
"namespace(x=1)",
|
||||
"range(3)",
|
||||
"cycler(1, 2)",
|
||||
"[0] * 1000000", # list-literal repetition (memory bomb)
|
||||
"(1,) * 1000000", # tuple-literal repetition (memory bomb)
|
||||
"[1, 2, 3]", # bare list literal
|
||||
"{1: 2}", # dict literal
|
||||
],
|
||||
)
|
||||
def test_rejected(self, tpl):
|
||||
with pytest.raises(TemplateValidationError):
|
||||
validate_template_expression(tpl)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tpl",
|
||||
[
|
||||
"min(a * 2, 1)",
|
||||
"(a + b) / 2",
|
||||
"clamp((t - 18) / 10, 0, 1)",
|
||||
"raw['x'] / 100",
|
||||
"a if a > b else b",
|
||||
"abs(a - b)",
|
||||
],
|
||||
)
|
||||
def test_accepted(self, tpl):
|
||||
validate_template_expression(tpl) # must not raise
|
||||
|
||||
|
||||
class TestFinalizeResult:
|
||||
def test_nan_returns_default(self):
|
||||
assert finalize_result(float("nan"), 0.25) == 0.25
|
||||
|
||||
def test_inf_returns_default(self):
|
||||
assert finalize_result(float("inf"), 0.25) == 0.25
|
||||
assert finalize_result(float("-inf"), 0.25) == 0.25
|
||||
|
||||
def test_non_numeric_returns_default(self):
|
||||
assert finalize_result("nope", 0.25) == 0.25
|
||||
assert finalize_result(None, 0.25) == 0.25
|
||||
|
||||
def test_overflow_returns_default(self):
|
||||
# float() of a multi-hundred-digit int (chained big-int multiply) raises
|
||||
# OverflowError, not ValueError — must still fall back, not propagate.
|
||||
assert finalize_result(10**400, 0.25) == 0.25
|
||||
|
||||
def test_clamps_to_unit(self):
|
||||
assert finalize_result(5.0, 0.0) == 1.0
|
||||
assert finalize_result(-1.0, 0.0) == 0.0
|
||||
assert finalize_result(0.5, 0.0) == pytest.approx(0.5)
|
||||
|
||||
def test_clamp_helper(self):
|
||||
assert clamp(2.0) == 1.0
|
||||
assert clamp(-2.0) == 0.0
|
||||
assert clamp(5.0, 0.0, 10.0) == 5.0
|
||||
|
||||
|
||||
class TestInputNames:
|
||||
@pytest.mark.parametrize("name", ["audio", "cpu_load", "_x", "Temp2"])
|
||||
def test_valid(self, name):
|
||||
validate_input_name(name)
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "1bad", "has space", "a-b", "a.b"])
|
||||
def test_invalid_identifier(self, name):
|
||||
with pytest.raises(TemplateValidationError):
|
||||
validate_input_name(name)
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(RESERVED_NAMES))
|
||||
def test_reserved(self, name):
|
||||
with pytest.raises(TemplateValidationError):
|
||||
validate_input_name(name)
|
||||
|
||||
def test_globals_are_reserved(self):
|
||||
assert set(GLOBALS).issubset(RESERVED_NAMES)
|
||||
assert "raw" in RESERVED_NAMES
|
||||
|
||||
|
||||
class TestExtractVariables:
|
||||
def test_excludes_globals_and_raw(self):
|
||||
assert extract_variables("min(a, raw['x']) + b") == ["a", "b"]
|
||||
|
||||
def test_empty_for_uncompilable(self):
|
||||
assert extract_variables("a +") == []
|
||||
|
||||
def test_constant_expression(self):
|
||||
assert extract_variables("clamp(0.5)") == []
|
||||
Reference in New Issue
Block a user