Compare commits

..

10 Commits

Author SHA1 Message Date
alexei.dolgolyov 1ada5ac334 feat(automations): weekday + timezone scheduling for time-of-day rule
Extend the time-of-day condition from a bare server-local HH:MM window to a real
schedule: pick which weekdays it is active (0=Mon..6=Sun, empty = every day) and
an optional IANA timezone (empty = server local). Closes the parity gap where
even a $5 WLED chip has weekday timers.

- Overnight windows (start > end) count toward the day they START on, so the
  after-midnight tail is matched against the previous weekday.
- Timezones are resolved via zoneinfo, cached, and fall back to server-local
  with a one-time warning on an invalid name (the ~1Hz tick never log-spams).
- Backward compatible: new fields default to all-days / server-local, so
  existing automations are unchanged (no migration).
- Frontend: weekday chips + timezone input on the rule editor, day/timezone in
  the rule summary, styles + i18n (en/ru/zh).

10 unit tests (weekday filter, overnight start-day semantics, tz fallback,
round-trip, invalid-day filtering); full suite green (1936 passed).
(Geographic sunrise/sunset triggers are a natural follow-up — the daylight
value source already has the solar math to reuse.)
2026-06-04 23:54:03 +03:00
alexei.dolgolyov e18d56c838 feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool)
Seed five curated, read-only post-processing templates so a non-expert gets
instant good-looking output before discovering the filter pipeline. Each is an
opinionated chain of existing filters (auto-crop/saturation/contrast/colour-
temperature/temporal-blur) tuned for a use case (films, games, evening ambience,
low-flicker, crisp cool-white).

Mirrors the built-in-gradient pattern: adds is_builtin to PostprocessingTemplate,
seeds missing looks on store init (idempotent, additive — no migration), and
makes built-ins read-only (update/delete raise -> 400; clone to customise).
Surfaced via the existing template picker + is_builtin in the response/type.

7 unit tests (seeding, idempotency, read-only protection, round-trip); full
suite green (1926 passed). (A runtime intensity slider is a follow-up — it needs
a filter-chain parameterisation layer.)
2026-06-04 23:43:11 +03:00
alexei.dolgolyov 7728aecb4f feat(wled): native realtime UDP output (DRGB/DRGBW/DNRGB) with auto-revert
Add WLED's native realtime UDP protocol (port 21324) as a third output mode for
LED targets, alongside DDP and HTTP. For the device LedGrab drives most, this
brings three user-visible wins DDP lacks:

- Auto-revert: every packet carries a timeout byte, so if the stream stops
  (host hiccup/sleep/crash) WLED returns to its preset instead of freezing on
  the last frame.
- Correct RGBW whites: the DRGBW variant carries an explicit white channel.
- Lighter on weak Wi-Fi: raw RGB with a 2-byte header.

New WledRealtimeClient auto-selects DRGB (<=490), DRGBW (<=367), or chunked
DNRGB (>490). WLED applies its own per-bus colour order in realtime mode, so we
send plain RGB and the user's colour-order config just works. Protocol 'udp' is
threaded through WLEDConfig/provider/processor and the schema pattern; the
target editor gains a protocol option + badge + i18n (en/ru/zh).

8 unit tests for the packet builder; full suite green (1919 passed).
2026-06-04 23:34:26 +03:00
alexei.dolgolyov e28ab5a956 Merge feat/power-budget-abl: automatic brightness limiting (ABL) / power budget 2026-06-04 23:22:18 +03:00
alexei.dolgolyov 1e395fd09e Merge fix/verified-bugs: weak default key, broken MQTT route, scene brightness sync 2026-06-04 23:22:18 +03:00
alexei.dolgolyov ffee156c17 feat(targets): automatic brightness limiting (ABL) / per-LED power budget
Cap an addressable strip's estimated current draw to a PSU budget so bright/
white scenes can't brown out an under-spec'd supply (voltage sag -> red/orange
shift, flicker, controller resets) — a classic 'it's broken' first impression.

- New core/processing/power_limit.py: pure current estimate (full white over N
  LEDs draws N * mA_per_led) + a (0,1] scale to land a frame on budget.
- Applied in WledTargetProcessor._send_to_device (single choke point, every send
  path; scales into a reusable scratch buffer, never mutates shared frames).
- Two per-target fields on LED targets: max_milliamps (0 = unlimited) and
  milliamps_per_led (default 55), threaded through model/store/manager/processor/
  schema/route with hot-update via update_target_settings. Additive with safe
  defaults (no data migration needed; legacy targets read as unlimited).
- Frontend: editor fields + i18n (en/ru/zh) + LedOutputTarget type.
- Tests: 10 unit tests for the estimator/scale; full suite green (1911 passed).
2026-06-04 22:56:50 +03:00
alexei.dolgolyov 02e2ea37f3 fix(scenes): sync brightness value-source change to live processor
apply_scene_state computed brightness_changed = "brightness" in changed, but
the change dict only ever uses the key "brightness_value_source_id", so the
branch was dead and a running target's brightness source was never live-synced
on scene activation (it only took effect after a restart). Check the correct
key.
2026-06-04 20:46:26 +03:00
alexei.dolgolyov fdc9201660 fix(api): remove broken legacy /system/mqtt/settings route
The GET/PUT /api/v1/system/mqtt/settings handlers read cfg.mqtt.*, but the
single-broker MQTTConfig block was removed in the multi-broker refactor, so any
call raised AttributeError. Brokers are now first-class MQTTSource entities
managed via the mqtt.py router, and the frontend no longer calls this endpoint.
Remove the dead handlers, the _load_mqtt_settings helper, the now-unused
get_config import, and the orphaned MQTTSettings{Request,Response} schemas.
2026-06-04 20:46:24 +03:00
alexei.dolgolyov 5686ae5468 fix(security): remove active weak default API key from shipped config
default_config.yaml shipped api_keys.dev: "development-key-change-in-production"
uncommitted/active, while the surrounding comment claimed it had been removed.
On a non-loopback bind this is a publicly-known credential granting full LAN
access. Restore the documented secure default (empty api_keys -> loopback-only
anonymous, LAN rejected) and leave a commented example instead.
2026-06-04 20:46:13 +03:00
alexei.dolgolyov 9960f15a1b docs(android): remove ANDROID-REVIEW planning/review docs
The Android feature-gap assessment and per-feature design docs have served
their purpose — notification, audio, webcam, and the foreground-app automation
condition are all implemented and merged, so no gaps remain to track. The
implementation is documented in the code, commit messages, and git history; the
review docs are now obsolete. No committed files referenced them (only the
local-only plans/ archives, left as point-in-time records).
2026-06-02 15:05:11 +03:00
40 changed files with 1039 additions and 914 deletions
@@ -1,308 +0,0 @@
# 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 2428 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.
@@ -1,94 +0,0 @@
# Android foreground-app automation condition — implementation notes
> Status: implemented on `feature/android-foreground-app-automation`. Last updated 2026-06-02.
## What & why
The desktop build has an **Application** automation rule (`ApplicationRule`): activate a scene
when given apps are running / foreground / fullscreen. It was already wired end-to-end on
Android (engine, storage, API, editor) but **silently never fired**, because the two
Windows-only ctypes paths return empty off-Windows:
1. **Detection**`PlatformDetector._get_topmost_process_sync()` (and the running/fullscreen
variants) returned `(None, False)` / `set()` on Android.
2. **The app picker** — populated from `GET /api/v1/system/processes`
`get_running_processes()`, also empty on Android, so users couldn't even choose an app.
This feature fills both holes using in-platform Android APIs and the established Kotlin↔Python
bridge pattern. **Zero new Python or Gradle dependencies.**
## Design decision: one implicit "foreground" mode on Android
Android exposes exactly one obtainable signal — the **current foreground app package**. The
desktop rule's four match types (`running` / `topmost` / `fullscreen` / `topmost_fullscreen`)
are either unobtainable (`running``getRunningTasks` is restricted) or identical (a foreground
TV app effectively *is* fullscreen). So on Android:
- The editor **hides the match-type selector** and the collector forces `match_type="topmost"`.
- `_get_topmost_process_sync()` returns `(package, True)`; the running/fullscreen detectors
return the foreground app as a best-effort single-element set so legacy rules still behave.
This avoided touching the existing plain `<select>` (forbidden for new UI) and removed a
misleading 4-way choice — a simplification surfaced by the pre-implementation plan review.
## Detection — `ForegroundAppBridge` (Kotlin) ↔ `platform_detector.py`
`android/app/src/main/java/com/ledgrab/android/ForegroundAppBridge.kt` (an `object` singleton,
mirroring `CameraBridge`, context bound in `LedGrabApp.onCreate`):
- `getForegroundPackage()``UsageStatsManager.queryEvents(now - 10s, now)`, returns the package
of the most recent `MOVE_TO_FOREGROUND` / `ACTIVITY_RESUMED` event (the two constants share a
value; the ~10s window absorbs event lag against the ~1s automation tick). `queryEvents` is the
right call — `queryUsageStats` gives aggregate durations, not "current app".
- `hasUsageAccess()``AppOpsManager` `OPSTR_GET_USAGE_STATS` check (`unsafeCheckOpNoThrow` on
API 29+, `checkOpNoThrow` below).
- `listLaunchableApps()``LauncherApps.getActivityList` → JSON `[{package,label}]` for the
picker. The sanctioned launchable-app API; **no `QUERY_ALL_PACKAGES`**.
`server/src/ledgrab/core/automations/platform_detector.py`:
- Module-level guarded wrappers `get_foreground_package()` / `has_usage_access()` /
`list_installed_apps()` resolve `jclass("com.ledgrab.android.ForegroundAppBridge").INSTANCE`
lazily (never at import — the module loads on desktop CI). These are the **test monkeypatch
surface**, mirroring `android_camera_engine`.
- The `is_android()` branch is placed **ahead of** the import-time `if not _IS_WINDOWS:`
early-return in each detector — the critical fix from plan review (a naive wiring would no-op
behind the Windows guard yet still pass tests). The Windows ctypes path is unchanged
(regression-tested).
- A one-time `logger.warning` fires when Usage Access is missing.
## App picker — `/system/installed-apps` + platform signal
- `GET /api/v1/system/installed-apps``{apps:[{package,label}], count}` (empty off-Android).
- `GET /api/v1/system/info``{is_android, app_match_kind, usage_access_granted}` — the editor
reads it to pick the app source + matching semantics and to show the Usage-Access banner.
- Frontend: the command-palette picker (`core/process-picker.ts`) gained label→value support; a
new `AppPalette` shows the human label and inserts the package name. On Android the app-rule
editor uses it (`attachAppPicker`) instead of the process picker, plus a package-name hint and
the Usage-Access banner.
## Value semantics (no migration)
`ApplicationRule.apps` are **package names** on Android (`com.netflix.mediaclient`) vs **process
names** on Windows (`chrome.exe`). Same field, same matching code — **no storage migration**
but rules are **not portable across platforms**. Documented in the model/schema docstrings and a
user-facing editor hint.
## Permission UX
`PACKAGE_USAGE_STATS` is a special access (can't be granted at runtime):
- Manifest declares it with `tools:ignore="ProtectedPermissions"`.
- MainActivity shows a passive **"Grant usage access"** button (opens
`ACTION_USAGE_ACCESS_SETTINGS`, with a generic-Settings fallback) only while access is missing.
**No blanket prompt at capture start** — most users have no foreground-app rule.
- The web-UI rule editor shows a banner when an Android Application rule lacks access.
## Limitations
- Foreground-app only; no full window-title or arbitrary process enumeration on Android.
- Detection rides the existing ~1s automation poll; `queryEvents` can lag a few seconds.
- Rules authored on desktop don't match on Android and vice-versa (package vs process names).
- The on-device "Grant usage access" button currently shows whenever access is missing (not
gated on whether an Android Application rule exists), to avoid Activity↔server coupling; the
web-UI banner provides the contextual guidance.
@@ -1,196 +0,0 @@
# 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 | ✅ NotificationListenerService → `push_notification()` | No (implemented) |
| Webcam capture | OpenCV | ✅ Camera2 + on-demand bridge (`AndroidCameraEngine`) | No (implemented) |
| GPU monitoring | NVML | ❌ no NVIDIA GPU | Marginal |
| Capture from *another* Android phone | scrcpy/ADB | ❌ | Skip (redundant) |
| Automation: foreground-app condition | Windows ctypes (running/topmost/fullscreen) | ✅ foreground app via UsageStatsManager (`ForegroundAppBridge`) | No (implemented) |
| 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 — **IMPLEMENTED** ✅ (shipped)
- **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.
-**Implemented** on branch `feature/android-notification-capture`: a push-based
`_AndroidBackend` + module-level `push_notification()` in `os_notification_listener.py`,
a Kotlin `LedGrabNotificationListener` (NLS), and prompt-once permission UX. App-name
parity — only the resolved app label crosses the JNI boundary, never the notification
title/body. ⚠️ App labels can differ across OSes (Windows `display_name` / Linux D-Bus
`app_name` / Android `getApplicationLabel`), so desktop-configured per-app colors/filters
may need re-matching on Android.
### 📷 Webcam capture — **IMPLEMENTED** ✅ (shipped)
- **Blocker** was `opencv-python-headless` (no Chaquopy cp311 wheel) — but capture doesn't
*need* OpenCV. Implemented with **Camera2** + `ImageReader` in Kotlin pushing RGB frames
through the same bridge as MediaProjection into a new `AndroidCameraEngine`.
- **Path:** a Kotlin `CameraBridge` singleton (Camera2) enumerates cameras and **opens the
camera on demand** (only while a capture source is active — driven Python→Kotlin via the
`BleBridge`/`UsbSerialBridge` pattern), converts each frame YUV_420_888→RGB, and pushes it
into a push-based `AndroidCameraEngine` (`core/capture_engines/android_camera_engine.py`)
that mirrors `mediaprojection_engine.py`. Cameras surface as selectable "displays" exactly
like the desktop OpenCV `CameraEngine`; the data-driven capture-template UI (engine list +
`resolution` config + display picker) needs **no changes**. **No new Python deps; no new
Gradle deps** (Camera2 is in-platform).
- **Permission:** `CAMERA` requested at capture-start, gated on `FEATURE_CAMERA_ANY` so
camera-less TV boxes never see the prompt; graceful degradation when denied. The service is
promoted with the `camera` FGS type (+ `FOREGROUND_SERVICE_CAMERA`) **only when CAMERA is
already granted**, so backgrounded capture keeps working without risking a failed service
start on camera-less boxes. (Unlike audio playback capture, the camera can't ride the
MediaProjection token, so it needs its own FGS type to survive backgrounding.)
- **Effort:** moderate. **Value:** low (TVs rarely have cameras), but the implementation reuses
existing infrastructure end-to-end. **Priority `0`** so it's never auto-selected over
MediaProjection — chosen explicitly via `engine_type="android_camera"`.
- ⚠️ **MVP scope / limitations:** webcam capture works **while LedGrab capture is running**
(no camera-only server path on Android); one camera active at a time; `"auto"` picks a
balanced output size (not the sensor max) to keep per-frame YUV→RGB cheap; USB-UVC webcams
appear only if the device routes them through Camera2 (varies by box); no frame-rotation
correction.
- 📄 **See `android-webcam-capture-plan.md`** for the full implementation notes.
### 🎮 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: foreground-app condition — **IMPLEMENTED** ✅ (shipped)
- Android forbids full window/process enumeration (`getRunningTasks` restricted since API 21+),
but the *current foreground app package* is obtainable via `UsageStatsManager` (needs the
`PACKAGE_USAGE_STATS` special access).
- **Path:** a Kotlin `ForegroundAppBridge` (UsageStatsManager `queryEvents` over a ~10s trailing
window + `LauncherApps` for the picker + an `AppOpsManager` access check) bridged into
`automations/platform_detector.py` via the guarded-`jclass` pattern, ahead of the Windows-only
ctypes path. The existing `ApplicationRule` / `AutomationEngine` / storage / deactivation modes
are unchanged — only the detection + the picker's data source were filled in. **No new Python
or Gradle deps** (UsageStatsManager + LauncherApps are in-platform; matching only string-compares
the package name, so no `QUERY_ALL_PACKAGES` / package visibility is needed).
- **UI:** the automation editor's app picker lists launchable apps by human label (storing the
package name) via a new `GET /api/v1/system/installed-apps`; on Android the match-type selector
is hidden and `match_type` is forced to `topmost` (the only obtainable signal), with a
cross-platform value caveat — `apps` are **package names** on Android (`com.netflix.mediaclient`)
vs **process names** on Windows (`chrome.exe`), so rules are not portable across platforms.
- **Permission:** `PACKAGE_USAGE_STATS` is a special access (Settings deep-link via
`ACTION_USAGE_ACCESS_SETTINGS`); the device shows a "Grant usage access" button when missing,
and the web-UI rule editor shows a banner (driven by `/system/info`'s `usage_access_granted`).
No blanket prompt at capture start. Detection degrades gracefully (rule never matches, warned
once) until access is granted. **Effort:** moderate. **Value:** moderate (per-app scenes on a
TV box). Full window-title matching remains out of scope (Android does not expose it).
- 📄 **See `android-foreground-app-automation-plan.md`** for the full implementation notes.
### 📱 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 | **✅ Implemented** |
| 2 | Audio capture | Moderate | High | None | **✅ Implemented** |
| 4 | Webcam capture (Camera2) | Moderate | Low | None | **✅ Implemented** |
| 3 | Automation: foreground-app condition | Moderate | Moderate | None | **✅ Implemented** |
| — | GPU load (vendor sysfs) | LowMed | Low | None | Not recommended |
| — | Capture from another phone | — | — | — | Won't do |
| — | Multi-display / monitor names | Low | Low | None | Not recommended |
**Status:** notifications, audio, webcam, **and the foreground-app automation condition** are all
shipped — each reuses existing infrastructure (the Kotlin↔Python bridge pattern, the
MediaProjection consent token / process-global `Python.getInstance()`, the
capture/audio/notification/automation pipelines) and adds **zero** Python dependencies, so none
risks the Chaquopy `--no-deps` build constraint documented in `CLAUDE.md`. No prioritized ideas
remain; GPU load, another-phone capture, and multi-display remain not-recommended / won't-do.
## 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,168 +0,0 @@
# Plan: Android on-device webcam capture
> Status: **implemented** on branch `feature/android-webcam-capture`. Last updated 2026-06-02.
## Context
LedGrab captures webcams on desktop through OpenCV (`cv2.VideoCapture`) in
`server/src/ledgrab/core/capture_engines/camera_engine.py`. On the **experimental Android-TV
build**, `opencv-python-headless` has no Chaquopy cp311 wheel, so the camera engine never
loads and cameras are unusable on-device.
Android doesn't need OpenCV to capture a camera: the platform exposes **Camera2**
(`android.hardware.camera2`), and the codebase already has the bridge shape to plug a Kotlin
capture source into a push-based Python engine. This feature adds an on-device camera engine
so a USB/integrated camera can drive ambient lighting, at parity with how the desktop OpenCV
camera engine feeds the pipeline.
The design mirrors the working screen-capture bridge
(`mediaprojection_engine.py``ScreenCapture.kt`) and the just-shipped audio engine
(`android_audio_engine.py``AudioCapture.kt`). **No new Python dependencies** (numpy already
bundled) and **no new Gradle dependencies** (Camera2 is in-platform) → no Chaquopy /
`build.gradle.kts` changes.
## Approach
A new **push-based** capture engine registered in the existing `EngineRegistry`, plus a Kotlin
`CameraBridge` that opens the camera **on demand**:
```
[capture source acquired] → AndroidCameraCaptureStream.initialize()
→ android_camera_engine.start_camera(index, w, h) [guarded jclass]
→ CameraBridge.startCamera(index, w, h) [Camera2 open + session]
→ onImageAvailable → YUV_420_888→RGB (stride-aware) → push_frame(rgbBytes, w, h)
→ android_camera_engine [module-level queue] → AndroidCameraCaptureStream.capture_frame()
→ ScreenCaptureLiveStream → processing pipeline [unchanged]
[capture source released] → AndroidCameraCaptureStream.cleanup()
→ android_camera_engine.stop_camera() → CameraBridge.stopCamera() [releases the camera]
```
The camera is **only open while a camera source is active** — the camera-in-use indicator and
battery cost are bounded to actual use, unlike always-on screen/audio capture. This on-demand
control reuses the synchronous Python→Kotlin singleton pattern of `BleBridge`/`UsbSerialBridge`.
## Selection path (why nothing downstream changes)
Webcams on desktop are a `ScreenCapturePictureSource` (`stream_type="raw"`) bound to a capture
template whose `engine_type="camera"` + a `display_index`. `live_stream_manager`
`_create_screen_capture_live_stream` reads `engine_type` from the template and calls
`EngineRegistry.create_stream(engine_type, display_index, config)`. Android adds
`engine_type="android_camera"` — the **same path**. The frontend
(`static/js/features/streams-capture-templates.ts`) is fully data-driven: the engine list,
the `resolution` config dropdown (keyed by field name), and the camera picker
(`/config/displays?engine_type=android_camera`, since `HAS_OWN_DISPLAYS=True`) all work with
no frontend changes.
## Part A — Python (`core/capture_engines/android_camera_engine.py`)
Mirrors `mediaprojection_engine.py` (module-level `queue.Queue` + `push_frame` + `_last_frame`
fallback + drop-oldest) and the desktop `CameraEngine` shape (cameras as displays,
`resolution` config).
- `_camera_bridge()` — lazy, `is_android()`-guarded `from java import jclass;
jclass("com.ledgrab.android.CameraBridge").INSTANCE`. **Never imported at module load** (this
module imports on desktop CI). Mirrors `core/devices/android_ble_transport.py`.
- `list_cameras()` → parses `CameraBridge.listCameras()` JSON into
`[{"index","name","facing"}]`; `_enumerate_cameras()` caches it (30 s TTL).
- `push_frame(rgb_bytes, w, h)` → `np.frombuffer(...uint8)` reshape **`(h, w, 3)`** (RGB, 3
B/px — NOT the RGBA `(h,w,4)` of the screen engine) → `.copy()` → drop-oldest enqueue. A
short/malformed buffer is dropped, never reshape-crashes.
- `start_camera(index, w, h) -> bool` / `stop_camera(index)` → guarded bridge calls.
- `AndroidCameraEngine`: `ENGINE_TYPE="android_camera"`, `ENGINE_PRIORITY=0` (never
auto-selected over MediaProjection=100 — explicit `engine_type` only), `HAS_OWN_DISPLAYS=True`,
`is_available()=is_android() and ≥1 enumerated camera`, `get_config_choices()` exposes
`resolution` (same presets as desktop).
- `AndroidCameraCaptureStream`: `initialize()` parses `resolution` → `start_camera(...)` (raises
if it returns False), drains stale frames; `capture_frame()` pops queue / returns `_last_frame`;
`cleanup()` → `stop_camera(...)`.
Registered in `capture_engines/__init__.py` behind a guarded import (mirrors the
mediaprojection block).
## Part B — Android (`CameraBridge.kt`)
`object CameraBridge` (mirrors `BleBridge`):
- `init(context)` — from `LedGrabApp.onCreate` (context only, no camera opened).
- `listCameras(): String` — JSON array from `CameraManager.cameraIdList` + `LENS_FACING`
(front/back/external). No CAMERA permission needed.
- `startCamera(index, width, height): Boolean` — checks CAMERA permission; resolves cameraId;
picks the supported YUV size closest to the request (balanced default ≤1280×720 for "auto");
opens device + capture session on a private `HandlerThread`, blocking until configured
(`runBlocking { withTimeout { ... } }` over `suspendCancellableCoroutine`-wrapped Camera2
callbacks); sets a repeating preview request. Returns false (no throw across JNI) on
permission/range/configure failure. Closes any prior camera first.
- `onImageAvailable` → paced (≈20 fps) → stride-aware **YUV_420_888→RGB** (BT.601 fixed-point,
reused plane + RGB buffers) → push to the cached `android_camera_engine` module handle.
- `stopCamera()` — stops repeating, closes session/device/reader, idempotent.
## Part C — Wiring + permission + manifest
- `LedGrabApp.kt` — `CameraBridge.init(this)` next to `BleBridge.init`.
- `MainActivity.kt` — `ensureCameraPermission()` (mirror `ensureAudioPermission`): request
`CAMERA` iff `hasSystemFeature(FEATURE_CAMERA_ANY)`; called from both `startCaptureService`
(MediaProjection path) and `startRootCaptureService` (root path). Fire-and-forget.
- `AndroidManifest.xml` — `<uses-permission CAMERA>` + `<uses-feature camera.any required=false>`
+ `<uses-permission FOREGROUND_SERVICE_CAMERA>`, and `camera` added to the `CaptureService`
`foregroundServiceType` union (`mediaProjection|specialUse|camera`).
- `CaptureService.onStartCommand` — on API 34+, OR `FOREGROUND_SERVICE_TYPE_CAMERA` into the
promotion type **only when CAMERA is already granted**. Unlike audio playback capture (which
rides the MediaProjection token under the mediaProjection type), the camera has no such
coupling, so without its own FGS type Android 14+ revokes camera access once the app is
backgrounded. The conditional guard avoids a failed `startForeground` (which would kill the
whole service) on a camera-less / not-yet-granted box. If CAMERA is granted later, the camera
type takes effect on the next Start.
- No `proguard-rules.pro` change — the blanket `-keep class com.ledgrab.android.** { *; }`
already covers `CameraBridge`, and R8/minify is disabled.
## What does NOT change
- **Frontend / API** — data-driven engine list, config, and display picker.
- **`build.gradle.kts` / Chaquopy pip block** — no new Python or Gradle packages.
- **Processing pipeline** — `ScreenCaptureLiveStream`, filters, color-strip sources unchanged.
## Files
**Create**
- `server/src/ledgrab/core/capture_engines/android_camera_engine.py`
- `android/app/src/main/java/com/ledgrab/android/CameraBridge.kt`
- `server/tests/core/test_android_camera_engine.py`
**Modify**
- `server/src/ledgrab/core/capture_engines/__init__.py` — guarded import + registration.
- `android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt` — `CameraBridge.init`.
- `android/app/src/main/java/com/ledgrab/android/MainActivity.kt` — `ensureCameraPermission`.
- `android/app/src/main/AndroidManifest.xml` — `CAMERA` + `camera.any`.
## Tests (Python — desktop CI, no device)
`server/tests/core/test_android_camera_engine.py`: push→capture round-trips RGB `(h,w,3)`;
drop-oldest when full; `_last_frame` fallback on empty; short-buffer never crashes;
`initialize()` opens with parsed/auto resolution and raises on open-failure / off-Android;
`cleanup()` closes once (idempotent); `is_available()` gating (android + cameras); display
enumeration; priority 0 never beats MediaProjection; create-via-registry yields a pushed frame.
## Verification
1. **Python:** `py -3.13 -m pytest tests/core/test_android_camera_engine.py --no-cov -q`, then
the full suite (1880 passed, 2 skipped; 15 new).
2. **Lint:** `ruff check src/ tests/ --fix` — clean.
3. **Android build:** `./gradlew :app:assembleDebug` — BUILD SUCCESSFUL.
4. **On device (manual):** install APK → Start capture → grant CAMERA → create a capture
template with engine `android_camera` + a camera display + a ScreenCapture source bound to
a strip → confirm LEDs react to the camera feed and the camera indicator only lights while
the source is active.
## Risks / notes
- **MVP scope:** webcam works **while LedGrab capture is running** (the Python server only runs
inside `CaptureService`; there is no camera-only start path on Android).
- **One camera at a time:** `startCamera` closes any previously-open camera first.
- **`"auto"` resolution** picks a balanced output size (~720p), not the sensor max, to keep the
per-frame YUV→RGB conversion cheap on low-end TV boxes.
- **USB-UVC webcams** appear only if the device exposes them through Camera2 (`LENS_FACING_EXTERNAL`),
which varies by box; an explicit UVC library would be a separate, larger effort.
- **No frame-rotation correction** — sensor orientation is not applied (ambient color sampling
is largely orientation-tolerant); could be added later.
- **CAMERA denied** → the engine reports no usable camera and capture proceeds without it.
+5 -5
View File
@@ -15,11 +15,11 @@ auth:
# - LAN requests are REJECTED with 401 (security default) # - LAN requests are REJECTED with 401 (security default)
# To enable LAN access, uncomment the example below and replace the value # To enable LAN access, uncomment the example below and replace the value
# with a secret you generated yourself (e.g. `openssl rand -hex 32`). # with a secret you generated yourself (e.g. `openssl rand -hex 32`).
# The previous default `dev: "development-key-change-in-production"` has # Do NOT ship a hard-coded key here — a publicly-known token grants full
# been removed — it shipped as a publicly-known token and any deployment # LAN access to anyone on the network.
# that still uses it grants full LAN access to anyone on the network. api_keys: {}
api_keys: # api_keys:
dev: "development-key-change-in-production" # my-client: "replace-with-output-of-openssl-rand-hex-32"
# Storage paths default to ./data relative to the server's working directory. # Storage paths default to ./data relative to the server's working directory.
# Set LEDGRAB_DATA_DIR in the environment to point at a different data root # Set LEDGRAB_DATA_DIR in the environment to point at a different data root
@@ -52,6 +52,8 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
"time_of_day": lambda: TimeOfDayRule( "time_of_day": lambda: TimeOfDayRule(
start_time=s.start_time or "00:00", start_time=s.start_time or "00:00",
end_time=s.end_time or "23:59", end_time=s.end_time or "23:59",
days_of_week=s.days_of_week or [],
timezone=s.timezone or "",
), ),
"system_idle": lambda: SystemIdleRule( "system_idle": lambda: SystemIdleRule(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5, idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
@@ -70,6 +70,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
min_brightness_threshold=target.min_brightness_threshold.to_dict(), min_brightness_threshold=target.min_brightness_threshold.to_dict(),
adaptive_fps=target.adaptive_fps, adaptive_fps=target.adaptive_fps,
protocol=target.protocol, protocol=target.protocol,
max_milliamps=target.max_milliamps,
milliamps_per_led=target.milliamps_per_led,
description=target.description, description=target.description,
tags=target.tags, tags=target.tags,
icon=getattr(target, "icon", "") or "", icon=getattr(target, "icon", "") or "",
@@ -302,6 +304,8 @@ async def create_target(
min_brightness_threshold=data.min_brightness_threshold, min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps, adaptive_fps=data.adaptive_fps,
protocol=data.protocol, protocol=data.protocol,
max_milliamps=data.max_milliamps,
milliamps_per_led=data.milliamps_per_led,
) )
case HALightOutputTargetCreate(): case HALightOutputTargetCreate():
if data.source_kind == "color_vs": if data.source_kind == "color_vs":
@@ -464,6 +468,8 @@ async def update_target(
min_brightness_threshold=data.min_brightness_threshold, min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps, adaptive_fps=data.adaptive_fps,
protocol=data.protocol, protocol=data.protocol,
max_milliamps=data.max_milliamps,
milliamps_per_led=data.milliamps_per_led,
) )
css_changed = data.color_strip_source_id is not None css_changed = data.color_strip_source_id is not None
brightness_changed = data.brightness is not None brightness_changed = data.brightness is not None
@@ -476,6 +482,8 @@ async def update_target(
data.min_brightness_threshold, data.min_brightness_threshold,
data.adaptive_fps, data.adaptive_fps,
data.brightness, data.brightness,
data.max_milliamps,
data.milliamps_per_led,
) )
) )
device_changed = data.device_id is not None device_changed = data.device_id is not None
@@ -51,6 +51,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
tags=t.tags, tags=t.tags,
icon=getattr(t, "icon", "") or "", icon=getattr(t, "icon", "") or "",
icon_color=getattr(t, "icon_color", "") or "", icon_color=getattr(t, "icon_color", "") or "",
is_builtin=getattr(t, "is_builtin", False),
) )
@@ -1,4 +1,4 @@
"""System routes: MQTT, external URL, ADB, logs WebSocket, log level. """System routes: external URL, shutdown action, ADB, logs WebSocket, log level.
Extracted from system.py to keep files under 800 lines. Extracted from system.py to keep files under 800 lines.
""" """
@@ -17,13 +17,10 @@ from ledgrab.api.schemas.system import (
ExternalUrlResponse, ExternalUrlResponse,
LogLevelRequest, LogLevelRequest,
LogLevelResponse, LogLevelResponse,
MQTTSettingsRequest,
MQTTSettingsResponse,
ShutdownAction, ShutdownAction,
ShutdownActionRequest, ShutdownActionRequest,
ShutdownActionResponse, ShutdownActionResponse,
) )
from ledgrab.config import get_config
from ledgrab.storage.database import Database from ledgrab.storage.database import Database
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
@@ -32,85 +29,6 @@ logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
# ---------------------------------------------------------------------------
# MQTT settings
# ---------------------------------------------------------------------------
def _load_mqtt_settings(db: Database) -> dict:
"""Load MQTT settings: YAML config defaults overridden by DB settings."""
cfg = get_config()
defaults = {
"enabled": cfg.mqtt.enabled,
"broker_host": cfg.mqtt.broker_host,
"broker_port": cfg.mqtt.broker_port,
"username": cfg.mqtt.username,
"password": cfg.mqtt.password,
"client_id": cfg.mqtt.client_id,
"base_topic": cfg.mqtt.base_topic,
}
overrides = db.get_setting("mqtt")
if overrides:
defaults.update(overrides)
return defaults
@router.get(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def get_mqtt_settings(_: AuthRequired, db: Database = Depends(get_database)):
"""Get current MQTT broker settings. Password is masked."""
s = _load_mqtt_settings(db)
return MQTTSettingsResponse(
enabled=s["enabled"],
broker_host=s["broker_host"],
broker_port=s["broker_port"],
username=s["username"],
password_set=bool(s.get("password")),
client_id=s["client_id"],
base_topic=s["base_topic"],
)
@router.put(
"/api/v1/system/mqtt/settings",
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def update_mqtt_settings(
_: AuthRequired, body: MQTTSettingsRequest, db: Database = Depends(get_database)
):
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings(db)
# If caller sends an empty password, keep the existing one
password = body.password if body.password else current.get("password", "")
new_settings = {
"enabled": body.enabled,
"broker_host": body.broker_host,
"broker_port": body.broker_port,
"username": body.username,
"password": password,
"client_id": body.client_id,
"base_topic": body.base_topic,
}
db.set_setting("mqtt", new_settings)
logger.info("MQTT settings updated")
return MQTTSettingsResponse(
enabled=new_settings["enabled"],
broker_host=new_settings["broker_host"],
broker_port=new_settings["broker_port"],
username=new_settings["username"],
password_set=bool(new_settings["password"]),
client_id=new_settings["client_id"],
base_topic=new_settings["base_topic"],
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# External URL setting # External URL setting
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -30,6 +30,14 @@ class RuleSchema(BaseModel):
# Time-of-day rule fields # Time-of-day rule fields
start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)") start_time: str | None = Field(None, description="Start time HH:MM (for time_of_day rule)")
end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)") end_time: str | None = Field(None, description="End time HH:MM (for time_of_day rule)")
days_of_week: list[int] | None = Field(
None,
description="Active weekdays for time_of_day rule (0=Mon..6=Sun). Empty/null = every day.",
)
timezone: str | None = Field(
None,
description="IANA timezone for time_of_day rule (e.g. 'Europe/Berlin'). Empty = server local.",
)
# System idle rule fields # System idle rule fields
idle_minutes: int | None = Field( idle_minutes: int | None = Field(
None, description="Idle timeout in minutes (for system_idle rule)" None, description="Idle timeout in minutes (for system_idle rule)"
@@ -91,7 +91,11 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
adaptive_fps: bool = Field( adaptive_fps: bool = Field(
default=False, description="Auto-reduce FPS when device is unresponsive" default=False, description="Auto-reduce FPS when device is unresponsive"
) )
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)") protocol: str = Field(default="ddp", description="Send protocol (ddp, udp, or http)")
max_milliamps: int = Field(
default=0, description="ABL: PSU current budget in mA (0 = unlimited)"
)
milliamps_per_led: int = Field(default=55, description="ABL: full-white draw of one LED in mA")
class HALightOutputTargetResponse(_OutputTargetResponseBase): class HALightOutputTargetResponse(_OutputTargetResponseBase):
@@ -233,8 +237,20 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
) )
protocol: str = Field( protocol: str = Field(
default="ddp", default="ddp",
pattern="^(ddp|http)$", pattern="^(ddp|http|udp)$",
description="Send protocol: ddp (UDP) or http (JSON API)", description="Send protocol: ddp (DDP/UDP), udp (WLED native realtime UDP), or http (JSON API)",
)
max_milliamps: int = Field(
default=0,
ge=0,
le=200000,
description="Automatic brightness limiting: PSU current budget in mA (0 = unlimited)",
)
milliamps_per_led: int = Field(
default=55,
ge=1,
le=200,
description="ABL: estimated full-white draw of a single LED, in mA",
) )
@@ -370,7 +386,15 @@ class LedOutputTargetUpdate(_OutputTargetUpdateBase):
None, description="Auto-reduce FPS when device is unresponsive" None, description="Auto-reduce FPS when device is unresponsive"
) )
protocol: str | None = Field( protocol: str | None = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)" None,
pattern="^(ddp|http|udp)$",
description="Send protocol: ddp (DDP/UDP), udp (WLED native realtime UDP), or http (JSON API)",
)
max_milliamps: int | None = Field(
None, ge=0, le=200000, description="ABL: PSU current budget in mA (0 = unlimited)"
)
milliamps_per_led: int | None = Field(
None, ge=1, le=200, description="ABL: full-white draw of one LED in mA"
) )
@@ -70,6 +70,7 @@ class PostprocessingTemplateResponse(BaseModel):
max_length=32, max_length=32,
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
) )
is_builtin: bool = Field(default=False, description="True for read-only curated 'look' presets")
class PostprocessingTemplateListResponse(BaseModel): class PostprocessingTemplateListResponse(BaseModel):
-29
View File
@@ -194,35 +194,6 @@ class BackupListResponse(BaseModel):
count: int count: int
# ─── MQTT schemas ──────────────────────────────────────────────
class MQTTSettingsResponse(BaseModel):
"""MQTT broker settings response (password is masked)."""
enabled: bool = Field(description="Whether MQTT is enabled")
broker_host: str = Field(description="MQTT broker hostname or IP")
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
username: str = Field(description="MQTT username (empty = anonymous)")
password_set: bool = Field(description="Whether a password is configured")
client_id: str = Field(description="MQTT client ID")
base_topic: str = Field(description="Base topic prefix")
class MQTTSettingsRequest(BaseModel):
"""MQTT broker settings update request."""
enabled: bool = Field(description="Whether MQTT is enabled")
broker_host: str = Field(description="MQTT broker hostname or IP")
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
username: str = Field(default="", description="MQTT username (empty = anonymous)")
password: str = Field(
default="", description="MQTT password (empty = keep existing if omitted)"
)
client_id: str = Field(default="ledgrab", description="MQTT client ID")
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
# ─── External URL schema ─────────────────────────────────────── # ─── External URL schema ───────────────────────────────────────
@@ -26,6 +26,33 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
# Cache resolved IANA timezones (and remember invalid names) so the ~1 Hz
# automation tick neither re-parses tzdata nor log-spams on a bad name.
_TZ_CACHE: Dict[str, object] = {}
_TZ_WARNED: set = set()
def _now_in_tz(tz_name: str) -> datetime:
"""Current local time, in ``tz_name`` (IANA) if given, else the server's."""
if not tz_name:
return datetime.now()
tz = _TZ_CACHE.get(tz_name)
if tz is None:
try:
from zoneinfo import ZoneInfo
tz = ZoneInfo(tz_name)
_TZ_CACHE[tz_name] = tz
except Exception:
if tz_name not in _TZ_WARNED:
_TZ_WARNED.add(tz_name)
logger.warning(
"Invalid timezone %r for time-of-day rule; using server local time",
tz_name,
)
return datetime.now()
return datetime.now(tz)
@dataclass(frozen=True) @dataclass(frozen=True)
class _RuleEvalContext: class _RuleEvalContext:
@@ -519,16 +546,26 @@ class AutomationEngine:
@staticmethod @staticmethod
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool: def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
now = datetime.now() now = _now_in_tz(rule.timezone)
current = now.hour * 60 + now.minute current = now.hour * 60 + now.minute
parts_s = rule.start_time.split(":") parts_s = rule.start_time.split(":")
parts_e = rule.end_time.split(":") parts_e = rule.end_time.split(":")
start = int(parts_s[0]) * 60 + int(parts_s[1]) start = int(parts_s[0]) * 60 + int(parts_s[1])
end = int(parts_e[0]) * 60 + int(parts_e[1]) end = int(parts_e[0]) * 60 + int(parts_e[1])
days = rule.days_of_week
if start <= end: if start <= end:
return start <= current <= end if not (start <= current <= end):
# Overnight range (e.g. 22:00 → 06:00) return False
return current >= start or current <= end return not days or now.weekday() in days
# Overnight range (e.g. 22:00 → 06:00): the window belongs to its
# START day, so the after-midnight tail is matched against yesterday.
if current >= start: # evening portion — today's window
return not days or now.weekday() in days
if current <= end: # early-morning portion — yesterday's window
return not days or ((now.weekday() - 1) % 7) in days
return False
@staticmethod @staticmethod
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool: def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
@@ -23,6 +23,11 @@ class BaseDeviceConfig:
class WLEDConfig(BaseDeviceConfig): class WLEDConfig(BaseDeviceConfig):
device_type: Literal["wled"] = "wled" device_type: Literal["wled"] = "wled"
use_ddp: bool = False use_ddp: bool = False
# WLED native realtime UDP (port 21324) — mutually exclusive with use_ddp.
# realtime_timeout = seconds WLED stays in realtime after the last packet
# before reverting to its normal effect/preset (graceful auto-revert).
use_realtime: bool = False
realtime_timeout: int = 2
@dataclass(frozen=True) @dataclass(frozen=True)
+53 -9
View File
@@ -86,6 +86,8 @@ class WLEDClient(LEDClient):
retry_attempts: int = 3, retry_attempts: int = 3,
retry_delay: int = 1, retry_delay: int = 1,
use_ddp: bool = False, use_ddp: bool = False,
use_realtime: bool = False,
realtime_timeout: int = 2,
): ):
"""Initialize WLED client. """Initialize WLED client.
@@ -95,12 +97,17 @@ class WLEDClient(LEDClient):
retry_attempts: Number of retry attempts on failure retry_attempts: Number of retry attempts on failure
retry_delay: Delay between retries in seconds retry_delay: Delay between retries in seconds
use_ddp: Force DDP protocol (auto-enabled for >500 LEDs) use_ddp: Force DDP protocol (auto-enabled for >500 LEDs)
use_realtime: Use WLED native realtime UDP (port 21324) instead of DDP
realtime_timeout: Seconds WLED stays in realtime after the last packet
before reverting to its normal effect/preset (1-255)
""" """
self.url = url.rstrip("/") self.url = url.rstrip("/")
self.timeout = timeout self.timeout = timeout
self.retry_attempts = retry_attempts self.retry_attempts = retry_attempts
self.retry_delay = retry_delay self.retry_delay = retry_delay
self.use_ddp = use_ddp self.use_ddp = use_ddp
self.use_realtime = use_realtime
self.realtime_timeout = realtime_timeout
# Extract hostname/IP from URL for DDP # Extract hostname/IP from URL for DDP
parsed = urlparse(self.url) parsed = urlparse(self.url)
@@ -108,6 +115,7 @@ class WLEDClient(LEDClient):
self._client: httpx.AsyncClient | None = None self._client: httpx.AsyncClient | None = None
self._ddp_client: DDPClient | None = None self._ddp_client: DDPClient | None = None
self._realtime_client = None # WledRealtimeClient when use_realtime
self._connected = False self._connected = False
self._pre_connect_state: dict | None = None self._pre_connect_state: dict | None = None
@@ -127,8 +135,9 @@ class WLEDClient(LEDClient):
# Test connection by getting device info # Test connection by getting device info
info = await self.get_info() info = await self.get_info()
# Auto-enable DDP for large LED counts # Auto-enable DDP for large LED counts (unless the user explicitly
if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp: # chose native realtime UDP, which handles any size via DNRGB).
if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp and not self.use_realtime:
logger.info( logger.info(
f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), " f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), "
"auto-enabling DDP protocol" "auto-enabling DDP protocol"
@@ -138,8 +147,30 @@ class WLEDClient(LEDClient):
# Snapshot device state BEFORE any mutations (for auto-restore) # Snapshot device state BEFORE any mutations (for auto-restore)
self._pre_connect_state = await self.snapshot_device_state() self._pre_connect_state = await self.snapshot_device_state()
# Create WLED native realtime UDP client if selected
if self.use_realtime:
from ledgrab.core.devices.wled_realtime_client import WledRealtimeClient
self._realtime_client = WledRealtimeClient(
self.host, rgbw=info.rgbw, timeout_secs=self.realtime_timeout
)
await self._realtime_client.connect()
try:
await self._request(
"POST",
"/json/state",
json_data={"on": True, "lor": 0, "AudioReactive": {"on": False}},
)
except Exception as e:
logger.warning(f"Could not configure device for realtime UDP: {e}")
logger.info(
"WLED native realtime UDP enabled (port 21324, %ds timeout, %s)",
self.realtime_timeout,
"RGBW" if info.rgbw else "RGB",
)
# Create DDP client if needed # Create DDP client if needed
if self.use_ddp: elif self.use_ddp:
self._ddp_client = DDPClient(self.host, rgbw=False) self._ddp_client = DDPClient(self.host, rgbw=False)
# Pass per-bus config so DDP client can apply per-bus color reordering # Pass per-bus config so DDP client can apply per-bus color reordering
if info.buses: if info.buses:
@@ -191,6 +222,9 @@ class WLEDClient(LEDClient):
if self._ddp_client: if self._ddp_client:
await self._ddp_client.close() await self._ddp_client.close()
self._ddp_client = None self._ddp_client = None
if self._realtime_client:
await self._realtime_client.close()
self._realtime_client = None
self._connected = False self._connected = False
logger.debug(f"Closed connection to {self.url}") logger.debug(f"Closed connection to {self.url}")
@@ -201,8 +235,10 @@ class WLEDClient(LEDClient):
@property @property
def supports_fast_send(self) -> bool: def supports_fast_send(self) -> bool:
"""True when DDP is active and ready for fire-and-forget sends.""" """True when DDP or native realtime UDP is active (fire-and-forget)."""
return self.use_ddp and self._ddp_client is not None return (self.use_ddp and self._ddp_client is not None) or (
self.use_realtime and self._realtime_client is not None
)
async def _request( async def _request(
self, self,
@@ -384,7 +420,10 @@ class WLEDClient(LEDClient):
raise ValueError(f"Invalid RGB values at index {idx}: {tuple(pixel_arr[idx])}") raise ValueError(f"Invalid RGB values at index {idx}: {tuple(pixel_arr[idx])}")
validated_pixels = pixel_arr.astype(np.uint8) if pixel_arr.dtype != np.uint8 else pixel_arr validated_pixels = pixel_arr.astype(np.uint8) if pixel_arr.dtype != np.uint8 else pixel_arr
# Use DDP protocol if enabled # Native realtime UDP takes precedence, then DDP, then HTTP
if self.use_realtime and self._realtime_client:
self._realtime_client.send_pixels_numpy(validated_pixels)
return True
if self.use_ddp and self._ddp_client: if self.use_ddp and self._ddp_client:
return await self._send_pixels_ddp(validated_pixels, brightness) return await self._send_pixels_ddp(validated_pixels, brightness)
else: else:
@@ -485,8 +524,10 @@ class WLEDClient(LEDClient):
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
brightness: Global brightness (0-255) brightness: Global brightness (0-255)
""" """
if not self.use_ddp or not self._ddp_client: if not (self.use_ddp and self._ddp_client) and not (
raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP") self.use_realtime and self._realtime_client
):
raise RuntimeError("send_pixels_fast requires DDP or realtime UDP; use send_pixels")
if isinstance(pixels, np.ndarray): if isinstance(pixels, np.ndarray):
pixel_array = pixels pixel_array = pixels
@@ -494,7 +535,10 @@ class WLEDClient(LEDClient):
pixel_array = np.array(pixels, dtype=np.uint8) pixel_array = np.array(pixels, dtype=np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness) # Note: brightness already applied by processor loop (_cached_brightness)
self._ddp_client.send_pixels_numpy(pixel_array) if self.use_realtime and self._realtime_client:
self._realtime_client.send_pixels_numpy(pixel_array)
else:
self._ddp_client.send_pixels_numpy(pixel_array)
# ===== LEDClient abstraction methods ===== # ===== LEDClient abstraction methods =====
@@ -86,6 +86,8 @@ class WLEDDeviceProvider(LEDDeviceProvider):
return WLEDClient( return WLEDClient(
config.device_url, config.device_url,
use_ddp=config.use_ddp, use_ddp=config.use_ddp,
use_realtime=config.use_realtime,
realtime_timeout=config.realtime_timeout,
) )
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
@@ -0,0 +1,153 @@
"""WLED native realtime UDP client (port 21324).
WLED exposes a family of "realtime" UDP protocols separate from DDP. Compared to
the DDP path this gives three user-visible wins for the device LedGrab drives
most:
* **Auto-revert** — every packet carries a *timeout* byte. If LedGrab stops
streaming (host hiccup, sleep, crash), WLED returns to its normal effect /
preset after that many seconds instead of freezing on the last frame.
* **Correct RGBW whites** — the DRGBW variant carries an explicit white channel,
so RGBW strips are driven correctly instead of leaving W uncontrolled.
* **Lighter on weak Wi-Fi** — raw RGB with a 2-byte header, no DDP framing.
Unlike the DDP path, WLED applies the configured per-bus color order itself in
realtime mode, so this sender transmits plain RGB (no manual reordering) — the
user's WLED colour-order setting just works.
Packet layout (first byte selects the protocol)::
DRGB (2): [2][timeout] + R G B per LED (<= 490 LEDs)
DRGBW (3): [3][timeout] + R G B W per LED (<= 367 LEDs)
DNRGB (4): [4][timeout][start_hi][start_lo] + R G B per LED (chunked, 489/pkt)
The ``timeout`` byte is in **seconds** (1-255). DNRGB carries a 16-bit start
index so strips larger than one packet are sent as several chunks.
Ref: https://kno.wled.ge/interfaces/udp-realtime/
"""
from __future__ import annotations
import asyncio
import numpy as np
from ledgrab.utils import get_logger
logger = get_logger(__name__)
REALTIME_PORT = 21324
# Protocol selector (first byte).
_DRGB = 2
_DRGBW = 3
_DNRGB = 4
# Per-protocol LED capacity (bounded by the ~1500-byte UDP payload).
_MAX_DRGB = 490 # 2 + 490*3 = 1472
_MAX_DRGBW = 367 # 2 + 367*4 = 1470
_MAX_DNRGB_CHUNK = 489 # 4 + 489*3 = 1471
# Default seconds WLED stays in realtime after the last packet before reverting.
DEFAULT_REALTIME_TIMEOUT = 2
def _clamp_timeout(seconds: int) -> int:
"""Clamp the realtime timeout to the on-wire 1-255 range."""
return max(1, min(255, int(seconds)))
class WledRealtimeClient:
"""Fire-and-forget UDP sender for WLED native realtime protocols."""
def __init__(
self,
host: str,
port: int = REALTIME_PORT,
rgbw: bool = False,
timeout_secs: int = DEFAULT_REALTIME_TIMEOUT,
) -> None:
self.host = host
self.port = port
self.rgbw = rgbw
self.timeout_secs = _clamp_timeout(timeout_secs)
self._transport: asyncio.DatagramTransport | None = None
self._protocol: asyncio.DatagramProtocol | None = None
# Reusable RGBW scratch (resized on demand) so the hot path doesn't
# allocate a fresh (N, 4) array per frame.
self._rgbw_buf: np.ndarray | None = None
self._rgbw_buf_n: int = 0
async def connect(self) -> bool:
"""Open the UDP datagram endpoint to the device."""
loop = asyncio.get_running_loop()
self._transport, self._protocol = await loop.create_datagram_endpoint(
asyncio.DatagramProtocol, remote_addr=(self.host, self.port)
)
logger.info(
"WLED realtime client connected to %s:%d (timeout %ds, %s)",
self.host,
self.port,
self.timeout_secs,
"RGBW" if self.rgbw else "RGB",
)
return True
async def close(self) -> None:
"""Close the datagram endpoint."""
if self._transport is not None:
self._transport.close()
self._transport = None
self._protocol = None
logger.debug("Closed WLED realtime connection to %s:%d", self.host, self.port)
@property
def is_connected(self) -> bool:
return self._transport is not None
def _ensure_rgbw_buf(self, n: int) -> np.ndarray:
"""Return an ``(n, 4)`` uint8 RGBW buffer with the white channel zeroed."""
if self._rgbw_buf is None or self._rgbw_buf_n != n:
self._rgbw_buf = np.zeros((n, 4), dtype=np.uint8)
self._rgbw_buf_n = n
return self._rgbw_buf
def build_packets(self, pixels: np.ndarray) -> list[bytes]:
"""Build the realtime UDP packet(s) for one ``(N, 3)`` uint8 RGB frame.
Exposed (and pure) for unit testing the wire format. Picks DRGBW for
RGBW strips within range, DRGB for small RGB strips, otherwise DNRGB
chunks. The white channel is sent as 0 (colour comes from the RGB LEDs).
"""
pixels = np.ascontiguousarray(pixels, dtype=np.uint8)
n = len(pixels)
t = self.timeout_secs
if n == 0:
return []
if self.rgbw and n <= _MAX_DRGBW:
buf = self._ensure_rgbw_buf(n)
buf[:, 0:3] = pixels
# white channel already zeroed and left at 0
return [bytes([_DRGBW, t]) + buf.tobytes()]
if n <= _MAX_DRGB and not self.rgbw:
return [bytes([_DRGB, t]) + pixels.tobytes()]
# DNRGB: 16-bit start index, chunked. Covers >490 RGB and >367 RGBW
# (the white channel is dropped for oversized RGBW strips).
packets: list[bytes] = []
for start in range(0, n, _MAX_DNRGB_CHUNK):
end = min(start + _MAX_DNRGB_CHUNK, n)
header = bytes([_DNRGB, t, (start >> 8) & 0xFF, start & 0xFF])
packets.append(header + pixels[start:end].tobytes())
return packets
def send_pixels_numpy(self, pixels: np.ndarray) -> bool:
"""Send one frame of ``(N, 3)`` uint8 RGB pixels (fire-and-forget)."""
if self._transport is None:
return False
for packet in self.build_packets(pixels):
self._transport.sendto(packet)
return True
@@ -0,0 +1,58 @@
"""Automatic brightness limiting (ABL) — keep a strip within a PSU current budget.
Estimates the current an addressable LED strip would draw for a frame of
already-brightness-scaled RGB bytes and, if it exceeds the configured budget,
returns a uniform scale factor to bring it back under budget. This prevents the
classic under-spec'd-PSU failure mode: a full-white scene browning out the rail
(voltage sag -> red/orange shift, flicker, controller resets) — which reads to a
new user as "this software is broken".
Model: one addressable LED at full white ``(255, 255, 255)`` draws
``milliamps_per_led`` mA, and current scales linearly with the sum of channel
values, so a frame's draw is::
estimated_ma = sum(channel_bytes) * milliamps_per_led / (255 * 3)
(``255 * 3 = 765`` channel-units == one LED at full white.) Standby/idle current
is intentionally ignored: the limiter only needs to catch the high-draw frames
that cause brownouts, and the default 55 mA/LED already carries real-world
headroom. The same convention as WLED's "maximum current" setting.
"""
from __future__ import annotations
import numpy as np
# Channel units in one LED at full white (R + G + B = 255 * 3).
_FULL_WHITE_UNITS = 765.0
# Typical full-white draw of a single WS2812/SK6812-class LED, in mA.
DEFAULT_MILLIAMPS_PER_LED = 55
def estimate_current_ma(colors: np.ndarray, milliamps_per_led: int) -> float:
"""Estimate strip draw (mA) for already-brightness-scaled RGB bytes.
``colors`` is an ``(N, 3)`` uint8 array of the values actually sent to the
strip. Full white over ``N`` LEDs returns ``N * milliamps_per_led``.
"""
if milliamps_per_led <= 0 or colors.size == 0:
return 0.0
channel_sum = float(int(colors.sum()))
return channel_sum * milliamps_per_led / _FULL_WHITE_UNITS
def power_limit_scale(colors: np.ndarray, max_milliamps: int, milliamps_per_led: int) -> float:
"""Return a scale in ``(0, 1]`` that keeps estimated draw within budget.
Returns ``1.0`` when limiting is disabled (``max_milliamps <= 0``) or the
frame is already within budget. Because current is linear in the channel
values, scaling every pixel by ``max_milliamps / estimated`` lands the frame
exactly on the budget.
"""
if max_milliamps <= 0 or milliamps_per_led <= 0:
return 1.0
estimated = estimate_current_ma(colors, milliamps_per_led)
if estimated <= max_milliamps:
return 1.0
return max_milliamps / estimated
@@ -407,6 +407,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
min_brightness_threshold: int = 0, min_brightness_threshold: int = 0,
adaptive_fps: bool = False, adaptive_fps: bool = False,
protocol: str = "ddp", protocol: str = "ddp",
max_milliamps: int = 0,
milliamps_per_led: int = 55,
): ):
"""Register a WLED target processor.""" """Register a WLED target processor."""
if target_id in self._processors: if target_id in self._processors:
@@ -425,6 +427,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
min_brightness_threshold=min_brightness_threshold, min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps, adaptive_fps=adaptive_fps,
protocol=protocol, protocol=protocol,
max_milliamps=max_milliamps,
milliamps_per_led=milliamps_per_led,
ctx=self._build_context(), ctx=self._build_context(),
) )
self._processors[target_id] = proc self._processors[target_id] = proc
@@ -17,6 +17,7 @@ from ledgrab.core.devices.led_client import (
get_device_capabilities, get_device_capabilities,
) )
from ledgrab.core.capture.screen_capture import get_available_displays from ledgrab.core.capture.screen_capture import get_available_displays
from ledgrab.core.processing.power_limit import DEFAULT_MILLIAMPS_PER_LED, power_limit_scale
from ledgrab.core.processing.target_processor import ( from ledgrab.core.processing.target_processor import (
ProcessingMetrics, ProcessingMetrics,
TargetContext, TargetContext,
@@ -62,6 +63,8 @@ class WledTargetProcessor(TargetProcessor):
min_brightness_threshold: int = 0, min_brightness_threshold: int = 0,
adaptive_fps: bool = False, adaptive_fps: bool = False,
protocol: str = "ddp", protocol: str = "ddp",
max_milliamps: int = 0,
milliamps_per_led: int = 55,
ctx: TargetContext = None, ctx: TargetContext = None,
): ):
from ledgrab.storage.bindable import BindableFloat, bfloat from ledgrab.storage.bindable import BindableFloat, bfloat
@@ -81,6 +84,13 @@ class WledTargetProcessor(TargetProcessor):
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0)) self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
self._adaptive_fps = adaptive_fps self._adaptive_fps = adaptive_fps
self._protocol = protocol self._protocol = protocol
# Automatic brightness limiting (ABL). 0 mA budget = disabled.
self._max_milliamps = max(0, int(max_milliamps or 0))
self._milliamps_per_led = max(1, int(milliamps_per_led or DEFAULT_MILLIAMPS_PER_LED))
# Reusable scratch for in-place power scaling (allocated on first use).
self._power_u16: np.ndarray | None = None
self._power_out: np.ndarray | None = None
self._power_n = 0
# Adaptive FPS / liveness probe runtime state # Adaptive FPS / liveness probe runtime state
self._effective_fps: int = self._target_fps self._effective_fps: int = self._target_fps
@@ -146,9 +156,15 @@ class WledTargetProcessor(TargetProcessor):
from ledgrab.core.devices.device_config import WLEDConfig as _WLEDConfig from ledgrab.core.devices.device_config import WLEDConfig as _WLEDConfig
config = _dev.to_config() config = _dev.to_config()
# use_ddp is a target-derived protocol setting — override on WLEDConfig # The target's protocol selects how we drive a WLED device:
# "ddp" -> DDP UDP (4048) "udp" -> WLED native realtime UDP (21324)
# "http" -> JSON API (use_ddp and use_realtime are exclusive)
if isinstance(config, _WLEDConfig): if isinstance(config, _WLEDConfig):
config = _replace(config, use_ddp=(self._protocol == "ddp")) config = _replace(
config,
use_ddp=(self._protocol == "ddp"),
use_realtime=(self._protocol == "udp"),
)
self._device_config = config self._device_config = config
# Connect to LED device # Connect to LED device
@@ -313,6 +329,12 @@ class WledTargetProcessor(TargetProcessor):
self._adaptive_fps = settings["adaptive_fps"] self._adaptive_fps = settings["adaptive_fps"]
if not self._adaptive_fps: if not self._adaptive_fps:
self._effective_fps = self._target_fps self._effective_fps = self._target_fps
if "max_milliamps" in settings:
self._max_milliamps = max(0, int(settings["max_milliamps"] or 0))
if "milliamps_per_led" in settings:
self._milliamps_per_led = max(
1, int(settings["milliamps_per_led"] or DEFAULT_MILLIAMPS_PER_LED)
)
logger.info(f"Updated settings for target {self._target_id}") logger.info(f"Updated settings for target {self._target_id}")
def update_device(self, device_id: str) -> None: def update_device(self, device_id: str) -> None:
@@ -787,8 +809,33 @@ class WledTargetProcessor(TargetProcessor):
np.copyto(out, blend, casting="unsafe") # float32 → uint8 np.copyto(out, blend, casting="unsafe") # float32 → uint8
return out return out
def _apply_power_limit(self, colors: np.ndarray) -> np.ndarray:
"""Scale ``colors`` down to stay within the PSU current budget (ABL).
Returns ``colors`` unchanged when limiting is disabled or the frame is
already within budget; otherwise returns a scaled copy in a reusable
scratch buffer (the input is never mutated — it may be a shared frame).
"""
if self._max_milliamps <= 0:
return colors
scale = power_limit_scale(colors, self._max_milliamps, self._milliamps_per_led)
if scale >= 1.0:
return colors
factor = int(scale * 256) # 0..255 fixed-point multiplier
n = len(colors)
if self._power_u16 is None or self._power_n != n:
self._power_n = n
self._power_u16 = np.empty((n, 3), dtype=np.uint16)
self._power_out = np.empty((n, 3), dtype=np.uint8)
np.copyto(self._power_u16, colors, casting="unsafe")
self._power_u16 *= factor
self._power_u16 >>= 8
np.copyto(self._power_out, self._power_u16, casting="unsafe")
return self._power_out
async def _send_to_device(self, send_colors: np.ndarray) -> float: async def _send_to_device(self, send_colors: np.ndarray) -> float:
"""Send colors to LED device and return send time in ms.""" """Send colors to LED device and return send time in ms."""
send_colors = self._apply_power_limit(send_colors)
t_start = time.perf_counter() t_start = time.perf_counter()
if self._led_client.supports_fast_send: if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors) self._led_client.send_pixels_fast(send_colors)
@@ -93,7 +93,7 @@ async def apply_scene_state(
proc = processor_manager.get_processor(ts.target_id) proc = processor_manager.get_processor(ts.target_id)
if proc and proc.is_running: if proc and proc.is_running:
css_changed = "color_strip_source_id" in changed css_changed = "color_strip_source_id" in changed
brightness_changed = "brightness" in changed brightness_changed = "brightness_value_source_id" in changed
settings_changed = "fps" in changed settings_changed = "fps" in changed
if css_changed: if css_changed:
target.sync_with_manager( target.sync_with_manager(
@@ -152,6 +152,50 @@
border-left: 1px solid var(--border-color); border-left: 1px solid var(--border-color);
} }
/* Weekday + timezone scheduling (time_of_day rule) */
.rule-weekday-block,
.rule-tz-block {
margin-top: 12px;
}
.rule-field-label {
display: block;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 6px;
}
.weekday-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.weekday-chip {
flex: 1 1 auto;
min-width: 40px;
padding: 6px 8px;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--card-bg);
color: var(--text-muted);
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.weekday-chip:hover {
border-color: var(--primary-color);
}
.weekday-chip.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
.rule-tz-block input.rule-timezone {
width: 100%;
}
.time-range-label { .time-range-label {
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 700; font-weight: 700;
@@ -340,11 +340,15 @@ const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running')); const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') }; return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') };
}, },
time_of_day: (c) => ({ time_of_day: (c) => {
icon: ICON_CLOCK, const days: number[] = Array.isArray(c.days_of_week) ? c.days_of_week : [];
text: `${c.start_time || '00:00'} ${c.end_time || '23:59'}`, let text = `${c.start_time || '00:00'} ${c.end_time || '23:59'}`;
title: t('automations.rule.time_of_day'), if (days.length && days.length < 7) {
}), text += ` · ${[...days].sort((a, b) => a - b).map((d) => t('weekday.short.' + d)).join(' ')}`;
}
if (c.timezone) text += ` · ${c.timezone}`;
return { icon: ICON_CLOCK, text, title: t('automations.rule.time_of_day') };
},
system_idle: (c) => { system_idle: (c) => {
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active'); const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') }; return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') };
@@ -878,6 +882,11 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
const [sh, sm] = startTime.split(':').map(Number); const [sh, sm] = startTime.split(':').map(Number);
const [eh, em] = endTime.split(':').map(Number); const [eh, em] = endTime.split(':').map(Number);
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
const days: number[] = Array.isArray(data.days_of_week) ? data.days_of_week : [];
const tz: string = data.timezone || '';
const dayChips = [0, 1, 2, 3, 4, 5, 6]
.map((d) => `<button type="button" class="weekday-chip${days.includes(d) ? ' active' : ''}" data-day="${d}">${t('weekday.short.' + d)}</button>`)
.join('');
container.innerHTML = ` container.innerHTML = `
<div class="rule-fields"> <div class="rule-fields">
<input type="hidden" class="rule-start-time" value="${startTime}"> <input type="hidden" class="rule-start-time" value="${startTime}">
@@ -901,9 +910,21 @@ function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
</div> </div>
</div> </div>
</div> </div>
<div class="rule-weekday-block">
<span class="rule-field-label">${t('automations.rule.time_of_day.days')}</span>
<div class="weekday-chips">${dayChips}</div>
<small class="rule-hint-desc">${t('automations.rule.time_of_day.days_hint')}</small>
</div>
<div class="rule-tz-block">
<label class="rule-field-label">${t('automations.rule.time_of_day.timezone')}</label>
<input type="text" class="rule-timezone" placeholder="${t('automations.rule.time_of_day.timezone.placeholder')}" value="${tz}">
</div>
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small> <small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
</div>`; </div>`;
_wireTimeRangePicker(container); _wireTimeRangePicker(container);
container.querySelectorAll('.weekday-chip').forEach((chip) => {
chip.addEventListener('click', () => chip.classList.toggle('active'));
});
} }
function _renderSystemIdleFields(container: HTMLElement, data: any): void { function _renderSystemIdleFields(container: HTMLElement, data: any): void {
@@ -1314,6 +1335,9 @@ const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
rule_type: 'time_of_day', rule_type: 'time_of_day',
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00', start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59', end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59',
days_of_week: Array.from(row.querySelectorAll('.weekday-chip.active'))
.map((el) => parseInt((el as HTMLElement).dataset.day || '0', 10)),
timezone: ((row.querySelector('.rule-timezone') as HTMLInputElement)?.value || '').trim(),
}), }),
system_idle: (row) => ({ system_idle: (row) => ({
rule_type: 'system_idle', rule_type: 'system_idle',
@@ -171,6 +171,8 @@ class TargetEditorModal extends Modal {
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30', fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value, keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked, adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
max_milliamps: (document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value,
milliamps_per_led: (document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value,
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []), tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
}; };
} }
@@ -181,8 +183,13 @@ const targetEditorModal = new TargetEditorModal();
function _protocolBadge(device: any, target: any) { function _protocolBadge(device: any, target: any) {
const dt = device?.device_type; const dt = device?.device_type;
if (!dt || dt === 'wled') { if (!dt || dt === 'wled') {
const proto = target.protocol === 'http' ? 'HTTP' : 'DDP'; const wledMap: Record<string, [string, string]> = {
return `${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${proto}`; http: [ICON_GLOBE, 'HTTP'],
udp: [ICON_RADIO, 'WLED UDP'],
ddp: [ICON_RADIO, 'DDP'],
};
const [icon, label] = wledMap[target.protocol] || wledMap.ddp;
return `${icon} ${label}`;
} }
const map = { const map = {
openrgb: [ICON_PALETTE, 'OpenRGB SDK'], openrgb: [ICON_PALETTE, 'OpenRGB SDK'],
@@ -311,10 +318,11 @@ function _ensureProtocolIconSelect() {
if (!sel) return; if (!sel) return;
const items = [ const items = [
{ value: 'ddp', icon: _pIcon(P.radio), label: t('targets.protocol.ddp'), desc: t('targets.protocol.ddp.desc') }, { value: 'ddp', icon: _pIcon(P.radio), label: t('targets.protocol.ddp'), desc: t('targets.protocol.ddp.desc') },
{ value: 'udp', icon: _pIcon(P.radio), label: t('targets.protocol.udp'), desc: t('targets.protocol.udp.desc') },
{ value: 'http', icon: _pIcon(P.globe), label: t('targets.protocol.http'), desc: t('targets.protocol.http.desc') }, { value: 'http', icon: _pIcon(P.globe), label: t('targets.protocol.http'), desc: t('targets.protocol.http.desc') },
]; ];
if (_protocolIconSelect) { _protocolIconSelect.updateItems(items); return; } if (_protocolIconSelect) { _protocolIconSelect.updateItems(items); return; }
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 }); _protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 3 });
} }
function _ensureBrightnessWidget(): BindableScalarWidget { function _ensureBrightnessWidget(): BindableScalarWidget {
@@ -401,6 +409,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false; (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp'; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = String(target.max_milliamps ?? 0);
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = String(target.milliamps_per_led ?? 55);
_populateCssDropdown(target.color_strip_source_id || ''); _populateCssDropdown(target.color_strip_source_id || '');
_ensureBrightnessWidget().setValue(target.brightness ?? 1.0); _ensureBrightnessWidget().setValue(target.brightness ?? 1.0);
@@ -419,6 +429,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false; (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp'; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = String(cloneData.max_milliamps ?? 0);
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = String(cloneData.milliamps_per_led ?? 55);
_populateCssDropdown(cloneData.color_strip_source_id || ''); _populateCssDropdown(cloneData.color_strip_source_id || '');
_ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0); _ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0);
@@ -435,6 +447,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false; (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp'; (document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = '0';
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = '55';
_populateCssDropdown(''); _populateCssDropdown('');
_ensureBrightnessWidget().setValue(1.0); _ensureBrightnessWidget().setValue(1.0);
@@ -515,6 +529,8 @@ export async function saveTargetEditor() {
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked; const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value; const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
const maxMilliamps = Math.max(0, Math.round(Number((document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value) || 0));
const milliampsPerLed = Math.max(1, Math.round(Number((document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value) || 55));
const payload: any = { const payload: any = {
name, name,
@@ -526,6 +542,8 @@ export async function saveTargetEditor() {
keepalive_interval: standbyInterval, keepalive_interval: standbyInterval,
adaptive_fps: adaptiveFps, adaptive_fps: adaptiveFps,
protocol, protocol,
max_milliamps: maxMilliamps,
milliamps_per_led: milliampsPerLed,
tags: _targetTagsInput ? _targetTagsInput.getValue() : [], tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
}; };
@@ -50,6 +50,8 @@ export interface LedOutputTarget extends OutputTargetBase {
min_brightness_threshold?: BindableFloat; min_brightness_threshold?: BindableFloat;
adaptive_fps: boolean; adaptive_fps: boolean;
protocol: string; protocol: string;
max_milliamps?: number;
milliamps_per_led?: number;
} }
export type HALightSourceKind = 'css' | 'color_vs'; export type HALightSourceKind = 'css' | 'color_vs';
@@ -30,6 +30,7 @@ export interface PostprocessingTemplate {
description?: string; description?: string;
icon?: string; icon?: string;
icon_color?: string; icon_color?: string;
is_builtin?: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
+17
View File
@@ -1235,6 +1235,17 @@
"automations.rule.time_of_day.start_time": "Start Time:", "automations.rule.time_of_day.start_time": "Start Time:",
"automations.rule.time_of_day.end_time": "End Time:", "automations.rule.time_of_day.end_time": "End Time:",
"automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:0006:00), set start time after end time.", "automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:0006:00), set start time after end time.",
"automations.rule.time_of_day.days": "Active days",
"automations.rule.time_of_day.days_hint": "Leave all unselected for every day. Overnight windows count toward the day they start on.",
"automations.rule.time_of_day.timezone": "Timezone",
"automations.rule.time_of_day.timezone.placeholder": "Server local (e.g. Europe/Berlin)",
"weekday.short.0": "Mon",
"weekday.short.1": "Tue",
"weekday.short.2": "Wed",
"weekday.short.3": "Thu",
"weekday.short.4": "Fri",
"weekday.short.5": "Sat",
"weekday.short.6": "Sun",
"automations.rule.system_idle": "System Idle", "automations.rule.system_idle": "System Idle",
"automations.rule.system_idle.desc": "User idle/active", "automations.rule.system_idle.desc": "User idle/active",
"automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):", "automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):",
@@ -2079,8 +2090,14 @@
"targets.adaptive_fps.hint": "Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.", "targets.adaptive_fps.hint": "Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.",
"targets.protocol": "Protocol:", "targets.protocol": "Protocol:",
"targets.protocol.hint": "DDP sends pixels via fast UDP (recommended for most setups). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.", "targets.protocol.hint": "DDP sends pixels via fast UDP (recommended for most setups). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.",
"targets.power_limit": "Max current (ABL):",
"targets.power_limit.hint": "Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.",
"targets.power_limit.ma_suffix": "mA (0 = unlimited)",
"targets.power_limit.per_led": "mA per LED (full white):",
"targets.protocol.ddp": "DDP (UDP)", "targets.protocol.ddp": "DDP (UDP)",
"targets.protocol.ddp.desc": "Fast raw UDP packets — recommended", "targets.protocol.ddp.desc": "Fast raw UDP packets — recommended",
"targets.protocol.udp": "WLED UDP (realtime)",
"targets.protocol.udp.desc": "WLED native realtime — RGBW whites + auto-revert if the stream drops",
"targets.protocol.http": "HTTP", "targets.protocol.http": "HTTP",
"targets.protocol.http.desc": "JSON API — slower, ≤500 LEDs", "targets.protocol.http.desc": "JSON API — slower, ≤500 LEDs",
"targets.protocol.serial": "Serial", "targets.protocol.serial": "Serial",
+17
View File
@@ -1269,6 +1269,17 @@
"automations.rule.time_of_day.start_time": "Время начала:", "automations.rule.time_of_day.start_time": "Время начала:",
"automations.rule.time_of_day.end_time": "Время окончания:", "automations.rule.time_of_day.end_time": "Время окончания:",
"automations.rule.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.", "automations.rule.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.",
"automations.rule.time_of_day.days": "Активные дни",
"automations.rule.time_of_day.days_hint": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.",
"automations.rule.time_of_day.timezone": "Часовой пояс",
"automations.rule.time_of_day.timezone.placeholder": "Локальное время сервера (напр. Europe/Berlin)",
"weekday.short.0": "Пн",
"weekday.short.1": "Вт",
"weekday.short.2": "Ср",
"weekday.short.3": "Чт",
"weekday.short.4": "Пт",
"weekday.short.5": "Сб",
"weekday.short.6": "Вс",
"automations.rule.system_idle": "Бездействие системы", "automations.rule.system_idle": "Бездействие системы",
"automations.rule.system_idle.desc": "Бездействие/активность", "automations.rule.system_idle.desc": "Бездействие/активность",
"automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):", "automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
@@ -1939,8 +1950,14 @@
"targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.", "targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.",
"targets.protocol": "Протокол:", "targets.protocol": "Протокол:",
"targets.protocol.hint": "DDP отправляет пиксели по быстрому UDP (рекомендуется). HTTP использует JSON API — медленнее, но надёжнее, ограничение ~500 LED.", "targets.protocol.hint": "DDP отправляет пиксели по быстрому UDP (рекомендуется). HTTP использует JSON API — медленнее, но надёжнее, ограничение ~500 LED.",
"targets.power_limit": "Макс. ток (ABL):",
"targets.power_limit.hint": "Ограничивает расчётный ток ленты бюджетом блока питания, чтобы избежать просадок напряжения (сдвиг цвета, мерцание, перезагрузки) на ярких/белых сценах. Укажите номинальный ток вашего БП с запасом. 0 = без ограничения.",
"targets.power_limit.ma_suffix": "мА (0 = без ограничения)",
"targets.power_limit.per_led": "мА на светодиод (полный белый):",
"targets.protocol.ddp": "DDP (UDP)", "targets.protocol.ddp": "DDP (UDP)",
"targets.protocol.ddp.desc": "Быстрые UDP-пакеты — рекомендуется", "targets.protocol.ddp.desc": "Быстрые UDP-пакеты — рекомендуется",
"targets.protocol.udp": "WLED UDP (realtime)",
"targets.protocol.udp.desc": "Нативный realtime WLED — корректный RGBW и авто-возврат при обрыве потока",
"targets.protocol.http": "HTTP", "targets.protocol.http": "HTTP",
"targets.protocol.http.desc": "JSON API — медленнее, ≤500 LED", "targets.protocol.http.desc": "JSON API — медленнее, ≤500 LED",
"targets.protocol.serial": "Serial", "targets.protocol.serial": "Serial",
+17
View File
@@ -1265,6 +1265,17 @@
"automations.rule.time_of_day.start_time": "开始时间:", "automations.rule.time_of_day.start_time": "开始时间:",
"automations.rule.time_of_day.end_time": "结束时间:", "automations.rule.time_of_day.end_time": "结束时间:",
"automations.rule.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。", "automations.rule.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。",
"automations.rule.time_of_day.days": "生效日期",
"automations.rule.time_of_day.days_hint": "全部不选表示每天生效。跨夜时段归属于其开始的那一天。",
"automations.rule.time_of_day.timezone": "时区",
"automations.rule.time_of_day.timezone.placeholder": "服务器本地时间(如 Europe/Berlin",
"weekday.short.0": "周一",
"weekday.short.1": "周二",
"weekday.short.2": "周三",
"weekday.short.3": "周四",
"weekday.short.4": "周五",
"weekday.short.5": "周六",
"weekday.short.6": "周日",
"automations.rule.system_idle": "系统空闲", "automations.rule.system_idle": "系统空闲",
"automations.rule.system_idle.desc": "空闲/活跃", "automations.rule.system_idle.desc": "空闲/活跃",
"automations.rule.system_idle.idle_minutes": "空闲超时(分钟):", "automations.rule.system_idle.idle_minutes": "空闲超时(分钟):",
@@ -1935,8 +1946,14 @@
"targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。", "targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。",
"targets.protocol": "协议:", "targets.protocol": "协议:",
"targets.protocol.hint": "DDP通过快速UDP发送像素(推荐)。HTTP使用JSON API——较慢但可靠,限制约500个LED。", "targets.protocol.hint": "DDP通过快速UDP发送像素(推荐)。HTTP使用JSON API——较慢但可靠,限制约500个LED。",
"targets.power_limit": "最大电流 (ABL)",
"targets.power_limit.hint": "将灯带的估算电流限制在电源预算内,以防止明亮/白色场景下的电压骤降(颜色偏移、闪烁、重启)。请设为电源的额定电流并留有余量。0 = 不限制。",
"targets.power_limit.ma_suffix": "mA0 = 不限制)",
"targets.power_limit.per_led": "每颗 LED 电流(全白):",
"targets.protocol.ddp": "DDP (UDP)", "targets.protocol.ddp": "DDP (UDP)",
"targets.protocol.ddp.desc": "快速UDP数据包 - 推荐", "targets.protocol.ddp.desc": "快速UDP数据包 - 推荐",
"targets.protocol.udp": "WLED UDP(实时)",
"targets.protocol.udp.desc": "WLED 原生实时 — 正确的 RGBW 白色,断流时自动恢复",
"targets.protocol.http": "HTTP", "targets.protocol.http": "HTTP",
"targets.protocol.http.desc": "JSON API - 较慢,≤500 LED", "targets.protocol.http.desc": "JSON API - 较慢,≤500 LED",
"targets.protocol.serial": "串口", "targets.protocol.serial": "串口",
+15 -2
View File
@@ -65,27 +65,40 @@ class ApplicationRule(Rule):
@dataclass @dataclass
class TimeOfDayRule(Rule): class TimeOfDayRule(Rule):
"""Activate during a specific time range (server local time). """Activate during a specific time range.
Supports overnight ranges: if start_time > end_time, the range wraps Supports overnight ranges: if start_time > end_time, the range wraps
around midnight (e.g. 22:00 06:00). around midnight (e.g. 22:00 06:00) an overnight window belongs to the
day it *starts* on. ``days_of_week`` (0=Mon .. 6=Sun, empty = every day)
restricts which days the window is active. ``timezone`` is an IANA name
(e.g. "Europe/Berlin"); empty = the server's local time.
""" """
rule_type: str = "time_of_day" rule_type: str = "time_of_day"
start_time: str = "00:00" # HH:MM start_time: str = "00:00" # HH:MM
end_time: str = "23:59" # HH:MM end_time: str = "23:59" # HH:MM
days_of_week: List[int] = field(default_factory=list) # 0=Mon..6=Sun; empty=all days
timezone: str = "" # IANA tz name; empty = server local time
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["start_time"] = self.start_time d["start_time"] = self.start_time
d["end_time"] = self.end_time d["end_time"] = self.end_time
d["days_of_week"] = self.days_of_week
d["timezone"] = self.timezone
return d return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "TimeOfDayRule": def from_dict(cls, data: dict) -> "TimeOfDayRule":
raw_days = data.get("days_of_week") or []
days = sorted(
{int(d) for d in raw_days if isinstance(d, (int, float)) and 0 <= int(d) <= 6}
)
return cls( return cls(
start_time=data.get("start_time", "00:00"), start_time=data.get("start_time", "00:00"),
end_time=data.get("end_time", "23:59"), end_time=data.get("end_time", "23:59"),
days_of_week=days,
timezone=data.get("timezone", "") or "",
) )
@@ -95,6 +95,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold: Any = 0, min_brightness_threshold: Any = 0,
adaptive_fps: bool = False, adaptive_fps: bool = False,
protocol: str = "ddp", protocol: str = "ddp",
max_milliamps: int = 0,
milliamps_per_led: int = 55,
description: str | None = None, description: str | None = None,
tags: List[str] | None = None, tags: List[str] | None = None,
# legacy compat # legacy compat
@@ -116,6 +118,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0), min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0),
adaptive_fps=adaptive_fps, adaptive_fps=adaptive_fps,
protocol=protocol, protocol=protocol,
max_milliamps=max(0, int(max_milliamps or 0)),
milliamps_per_led=max(1, int(milliamps_per_led or 55)),
description=description, description=description,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -335,6 +339,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold: Any = None, min_brightness_threshold: Any = None,
adaptive_fps: bool | None = None, adaptive_fps: bool | None = None,
protocol: str | None = None, protocol: str | None = None,
max_milliamps: int | None = None,
milliamps_per_led: int | None = None,
description: str | None = None, description: str | None = None,
tags: List[str] | None = None, tags: List[str] | None = None,
icon: str | None = None, icon: str | None = None,
@@ -356,6 +362,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold=min_brightness_threshold, min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps, adaptive_fps=adaptive_fps,
protocol=protocol, protocol=protocol,
max_milliamps=max_milliamps,
milliamps_per_led=milliamps_per_led,
description=description, description=description,
tags=tags, tags=tags,
icon=icon, icon=icon,
@@ -20,6 +20,7 @@ class PostprocessingTemplate:
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
icon: str = "" icon: str = ""
icon_color: str = "" icon_color: str = ""
is_builtin: bool = False
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert template to dictionary.""" """Convert template to dictionary."""
@@ -31,6 +32,7 @@ class PostprocessingTemplate:
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
"description": self.description, "description": self.description,
"tags": self.tags, "tags": self.tags,
"is_builtin": self.is_builtin,
} }
if self.icon: if self.icon:
d["icon"] = self.icon d["icon"] = self.icon
@@ -61,4 +63,5 @@ class PostprocessingTemplate:
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", "") or "", icon=data.get("icon", "") or "",
icon_color=data.get("icon_color", "") or "", icon_color=data.get("icon_color", "") or "",
is_builtin=data.get("is_builtin", False),
) )
@@ -15,6 +15,57 @@ from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
# Curated, read-only "look" presets — opinionated filter chains that give
# instant good-looking output before a user discovers the filter pipeline.
# Each entry: id-suffix -> (display name, description, [(filter_id, options), ...]).
# Only verified filters/option keys are used.
_BUILTIN_LOOKS: dict[str, tuple[str, str, list[tuple[str, dict]]]] = {
"cinematic": (
"Cinematic",
"Letterbox-aware, gently smoothed, mild colour boost — tuned for films.",
[
("auto_crop", {"threshold": 16, "min_bar_size": 20, "min_aspect_ratio": 1.4}),
("saturation", {"value": 1.12}),
("temporal_blur", {"strength": 0.35}),
],
),
"vivid": (
"Vivid",
"Punchy and responsive with high saturation — tuned for games.",
[
("saturation", {"value": 1.4}),
("contrast", {"value": 1.18}),
],
),
"cozy": (
"Cozy",
"Warm, dim and smooth — relaxed evening ambience.",
[
("color_correction", {"temperature": 3800}),
("brightness", {"value": 0.85}),
("saturation", {"value": 0.95}),
("temporal_blur", {"strength": 0.45}),
],
),
"soft": (
"Soft",
"Heavily smoothed and calm — minimises flicker on busy content.",
[
("temporal_blur", {"strength": 0.55}),
("saturation", {"value": 0.98}),
],
),
"cool": (
"Cool",
"Crisp, cool-white and clean — a modern, neutral look.",
[
("color_correction", {"temperature": 8000}),
("saturation", {"value": 1.1}),
],
),
}
class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]): class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
"""Storage for postprocessing templates. """Storage for postprocessing templates.
@@ -29,11 +80,42 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
def __init__(self, db: Database): def __init__(self, db: Database):
super().__init__(db, PostprocessingTemplate.from_dict) super().__init__(db, PostprocessingTemplate.from_dict)
self._ensure_initial_template() self._ensure_initial_template()
self._seed_missing_builtins()
# Backward-compatible aliases # Backward-compatible aliases
get_all_templates = BaseSqliteStore.get_all get_all_templates = BaseSqliteStore.get_all
get_template = BaseSqliteStore.get get_template = BaseSqliteStore.get
delete_template = BaseSqliteStore.delete
def _seed_missing_builtins(self) -> None:
"""Seed any curated built-in "look" templates not yet in the store."""
now = datetime.now(timezone.utc)
added = 0
for key, (name, description, chain) in _BUILTIN_LOOKS.items():
tid = f"pp_builtin_{key}"
if tid in self._items:
continue
template = PostprocessingTemplate(
id=tid,
name=name,
filters=[FilterInstance(fid, dict(opts)) for fid, opts in chain],
created_at=now,
updated_at=now,
description=description,
tags=["look"],
is_builtin=True,
)
self._items[tid] = template
self._save_item(tid, template)
added += 1
if added:
logger.info(f"Seeded {added} new built-in look templates")
def delete_template(self, template_id: str) -> None:
"""Delete a template. Built-in looks are read-only."""
template = self.get(template_id)
if getattr(template, "is_builtin", False):
raise ValueError("Built-in look templates cannot be deleted. Clone to customise.")
self.delete(template_id)
def _ensure_initial_template(self) -> None: def _ensure_initial_template(self) -> None:
"""Auto-create a default postprocessing template if none exist.""" """Auto-create a default postprocessing template if none exist."""
@@ -114,6 +196,9 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
) -> PostprocessingTemplate: ) -> PostprocessingTemplate:
template = self.get(template_id) template = self.get(template_id)
if getattr(template, "is_builtin", False):
raise ValueError("Built-in look templates are read-only. Clone to customise.")
if name is not None: if name is not None:
self._check_name_unique(name, exclude_id=template_id) self._check_name_unique(name, exclude_id=template_id)
template.name = name template.name = name
@@ -24,6 +24,11 @@ class WledOutputTarget(OutputTarget, type_key="led"):
min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0)) min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive
protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API) protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API)
# Automatic brightness limiting (ABL): cap estimated strip draw to a PSU
# budget. max_milliamps <= 0 disables it. milliamps_per_led is the full-white
# draw of one LED (WS2812-class default 55 mA).
max_milliamps: int = 0
milliamps_per_led: int = 55
def register_with_manager(self, manager) -> None: def register_with_manager(self, manager) -> None:
"""Register this WLED target with the processor manager.""" """Register this WLED target with the processor manager."""
@@ -39,6 +44,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
min_brightness_threshold=self.min_brightness_threshold, min_brightness_threshold=self.min_brightness_threshold,
adaptive_fps=self.adaptive_fps, adaptive_fps=self.adaptive_fps,
protocol=self.protocol, protocol=self.protocol,
max_milliamps=self.max_milliamps,
milliamps_per_led=self.milliamps_per_led,
) )
def sync_with_manager( def sync_with_manager(
@@ -59,6 +66,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
"state_check_interval": self.state_check_interval, "state_check_interval": self.state_check_interval,
"min_brightness_threshold": self.min_brightness_threshold, "min_brightness_threshold": self.min_brightness_threshold,
"adaptive_fps": self.adaptive_fps, "adaptive_fps": self.adaptive_fps,
"max_milliamps": self.max_milliamps,
"milliamps_per_led": self.milliamps_per_led,
}, },
) )
if css_changed: if css_changed:
@@ -81,6 +90,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
min_brightness_threshold=None, min_brightness_threshold=None,
adaptive_fps=None, adaptive_fps=None,
protocol=None, protocol=None,
max_milliamps=None,
milliamps_per_led=None,
description=None, description=None,
tags: List[str] | None = None, tags: List[str] | None = None,
icon: str | None = None, icon: str | None = None,
@@ -122,6 +133,10 @@ class WledOutputTarget(OutputTarget, type_key="led"):
self.adaptive_fps = adaptive_fps self.adaptive_fps = adaptive_fps
if protocol is not None: if protocol is not None:
self.protocol = protocol self.protocol = protocol
if max_milliamps is not None:
self.max_milliamps = max(0, int(max_milliamps))
if milliamps_per_led is not None:
self.milliamps_per_led = max(1, int(milliamps_per_led))
@property @property
def has_picture_source(self) -> bool: def has_picture_source(self) -> bool:
@@ -139,6 +154,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict() d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
d["adaptive_fps"] = self.adaptive_fps d["adaptive_fps"] = self.adaptive_fps
d["protocol"] = self.protocol d["protocol"] = self.protocol
d["max_milliamps"] = self.max_milliamps
d["milliamps_per_led"] = self.milliamps_per_led
return d return d
@classmethod @classmethod
@@ -165,6 +182,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
), ),
adaptive_fps=data.get("adaptive_fps", False), adaptive_fps=data.get("adaptive_fps", False),
protocol=data.get("protocol", "ddp"), protocol=data.get("protocol", "ddp"),
max_milliamps=int(data.get("max_milliamps", 0) or 0),
milliamps_per_led=int(data.get("milliamps_per_led", 55) or 55),
description=data.get("description"), description=data.get("description"),
tags=data.get("tags", []), tags=data.get("tags", []),
icon=data.get("icon", ""), icon=data.get("icon", ""),
@@ -123,6 +123,7 @@
<small class="input-hint" style="display:none" data-i18n="targets.protocol.hint">DDP sends pixels via fast UDP (recommended). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.</small> <small class="input-hint" style="display:none" data-i18n="targets.protocol.hint">DDP sends pixels via fast UDP (recommended). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.</small>
<select id="target-editor-protocol"> <select id="target-editor-protocol">
<option value="ddp">DDP (UDP)</option> <option value="ddp">DDP (UDP)</option>
<option value="udp">WLED UDP (realtime)</option>
<option value="http">HTTP</option> <option value="http">HTTP</option>
</select> </select>
</div> </div>
@@ -138,6 +139,22 @@
<small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small> <small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
<input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value"> <input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
</div> </div>
<div class="form-group" id="target-editor-power-limit-group">
<div class="label-row">
<label for="target-editor-max-milliamps" data-i18n="targets.power_limit">Max current (ABL):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.power_limit.hint">Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.</small>
<div class="label-row">
<input type="number" id="target-editor-max-milliamps" min="0" max="200000" step="100" value="0">
<span data-i18n="targets.power_limit.ma_suffix">mA (0 = unlimited)</span>
</div>
<div class="label-row">
<label for="target-editor-ma-per-led" data-i18n="targets.power_limit.per_led">mA per LED (full white):</label>
<input type="number" id="target-editor-ma-per-led" min="1" max="200" step="1" value="55">
</div>
</div>
</div> </div>
</details> </details>
</div> </div>
+81
View File
@@ -0,0 +1,81 @@
"""Tests for built-in curated 'look' postprocessing templates."""
import pytest
from ledgrab.core.filters.registry import FilterRegistry
from ledgrab.storage.postprocessing_template import PostprocessingTemplate
from ledgrab.storage.postprocessing_template_store import (
_BUILTIN_LOOKS,
PostprocessingTemplateStore,
)
def test_builtins_are_seeded(tmp_db):
store = PostprocessingTemplateStore(tmp_db)
for key in _BUILTIN_LOOKS:
tpl = store.get_template(f"pp_builtin_{key}")
assert tpl.is_builtin is True
assert tpl.filters # non-empty chain
def test_builtin_filters_use_registered_ids(tmp_db):
store = PostprocessingTemplateStore(tmp_db)
for key in _BUILTIN_LOOKS:
tpl = store.get_template(f"pp_builtin_{key}")
for fi in tpl.filters:
assert FilterRegistry.is_registered(fi.filter_id), fi.filter_id
def test_seeding_is_idempotent(tmp_db):
PostprocessingTemplateStore(tmp_db)
store2 = PostprocessingTemplateStore(tmp_db)
ids = [t.id for t in store2.get_all_templates() if t.id.startswith("pp_builtin_")]
assert sorted(ids) == sorted(f"pp_builtin_{k}" for k in _BUILTIN_LOOKS)
def test_builtin_update_is_blocked(tmp_db):
store = PostprocessingTemplateStore(tmp_db)
with pytest.raises(ValueError, match="read-only"):
store.update_template("pp_builtin_vivid", name="Hacked")
def test_builtin_delete_is_blocked(tmp_db):
store = PostprocessingTemplateStore(tmp_db)
with pytest.raises(ValueError, match="cannot be deleted"):
store.delete_template("pp_builtin_vivid")
def test_user_template_still_editable_and_deletable(tmp_db):
store = PostprocessingTemplateStore(tmp_db)
tpl = store.create_template("My Look", filters=[])
assert tpl.is_builtin is False
store.update_template(tpl.id, description="changed")
store.delete_template(tpl.id)
with pytest.raises(ValueError):
store.get_template(tpl.id)
def test_is_builtin_round_trips_through_dict():
tpl = PostprocessingTemplate.from_dict(
{
"id": "pp_x",
"name": "x",
"filters": [],
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
"is_builtin": True,
}
)
assert tpl.is_builtin is True
assert tpl.to_dict()["is_builtin"] is True
# legacy dict without the field defaults to False
legacy = PostprocessingTemplate.from_dict(
{
"id": "pp_y",
"name": "y",
"filters": [],
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
)
assert legacy.is_builtin is False
+70
View File
@@ -0,0 +1,70 @@
"""Unit tests for automatic brightness limiting (ABL) current estimation."""
import numpy as np
import pytest
from ledgrab.core.processing.power_limit import (
DEFAULT_MILLIAMPS_PER_LED,
estimate_current_ma,
power_limit_scale,
)
def test_default_ma_per_led_constant():
assert DEFAULT_MILLIAMPS_PER_LED == 55
def test_full_white_draws_ma_per_led_times_count():
colors = np.full((100, 3), 255, dtype=np.uint8)
assert estimate_current_ma(colors, 55) == pytest.approx(100 * 55)
def test_black_draws_zero():
colors = np.zeros((100, 3), dtype=np.uint8)
assert estimate_current_ma(colors, 55) == 0.0
def test_half_white_is_half_current():
full = estimate_current_ma(np.full((100, 3), 255, dtype=np.uint8), 55)
half = estimate_current_ma(np.full((100, 3), 128, dtype=np.uint8), 55)
assert half == pytest.approx(full * 128 / 255, rel=1e-6)
def test_zero_ma_per_led_draws_zero():
colors = np.full((100, 3), 255, dtype=np.uint8)
assert estimate_current_ma(colors, 0) == 0.0
def test_empty_frame_is_safe():
colors = np.zeros((0, 3), dtype=np.uint8)
assert estimate_current_ma(colors, 55) == 0.0
assert power_limit_scale(colors, 1000, 55) == 1.0
def test_scale_is_one_when_disabled():
colors = np.full((100, 3), 255, dtype=np.uint8)
assert power_limit_scale(colors, 0, 55) == 1.0
assert power_limit_scale(colors, -1, 55) == 1.0
def test_scale_is_one_within_budget():
colors = np.full((100, 3), 255, dtype=np.uint8) # 5500 mA at 55 mA/LED
assert power_limit_scale(colors, 6000, 55) == 1.0
assert power_limit_scale(colors, 5500, 55) == 1.0 # exactly on budget
def test_scale_brings_full_white_to_budget():
colors = np.full((100, 3), 255, dtype=np.uint8) # 5500 mA
scale = power_limit_scale(colors, 2750, 55) # half budget
assert scale == pytest.approx(0.5, rel=1e-6)
def test_applying_scale_lands_within_budget():
colors = np.full((100, 3), 255, dtype=np.uint8) # 5500 mA
budget = 2750
scale = power_limit_scale(colors, budget, 55)
# Mirror the processor's fixed-point application (factor/256).
factor = int(scale * 256)
scaled = ((colors.astype(np.uint16) * factor) >> 8).astype(np.uint8)
# Fixed-point rounding can only ever round DOWN, so we never exceed budget.
assert estimate_current_ma(scaled, 55) <= budget
+78
View File
@@ -0,0 +1,78 @@
"""Tests for time-of-day automation scheduling (weekday + timezone + overnight)."""
import datetime as dt
from ledgrab.core.automations import automation_engine as ae
from ledgrab.core.automations.automation_engine import AutomationEngine, _now_in_tz
from ledgrab.storage.automation import TimeOfDayRule
_eval = AutomationEngine._evaluate_time_of_day
def _patch_now(monkeypatch, fixed: dt.datetime) -> None:
monkeypatch.setattr(ae, "_now_in_tz", lambda tz: fixed)
def test_within_window_every_day(monkeypatch):
_patch_now(monkeypatch, dt.datetime(2026, 6, 3, 20, 0))
assert _eval(TimeOfDayRule(start_time="18:00", end_time="23:00")) is True
def test_outside_window(monkeypatch):
_patch_now(monkeypatch, dt.datetime(2026, 6, 3, 12, 0))
assert _eval(TimeOfDayRule(start_time="18:00", end_time="23:00")) is False
def test_weekday_filter(monkeypatch):
fixed = dt.datetime(2026, 6, 3, 20, 0)
wd = fixed.weekday()
_patch_now(monkeypatch, fixed)
assert _eval(TimeOfDayRule("time_of_day", "18:00", "23:00", days_of_week=[wd])) is True
assert (
_eval(TimeOfDayRule("time_of_day", "18:00", "23:00", days_of_week=[(wd + 1) % 7])) is False
)
def test_overnight_evening_uses_today(monkeypatch):
fixed = dt.datetime(2026, 6, 3, 23, 0) # evening tail of a 22:00->06:00 window
wd = fixed.weekday()
_patch_now(monkeypatch, fixed)
assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[wd])) is True
assert (
_eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[(wd + 1) % 7])) is False
)
def test_overnight_morning_uses_yesterday(monkeypatch):
fixed = dt.datetime(2026, 6, 3, 3, 0) # morning tail belongs to yesterday's window
today = fixed.weekday()
yesterday = (today - 1) % 7
_patch_now(monkeypatch, fixed)
assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[yesterday])) is True
assert _eval(TimeOfDayRule("time_of_day", "22:00", "06:00", days_of_week=[today])) is False
def test_from_dict_filters_invalid_days():
rule = TimeOfDayRule.from_dict({"days_of_week": [0, 7, -1, 3, 3, "x", 2.0]})
assert rule.days_of_week == [0, 2, 3]
def test_to_dict_round_trips_new_fields():
rule = TimeOfDayRule("time_of_day", "08:00", "20:00", days_of_week=[1, 2], timezone="UTC")
d = rule.to_dict()
assert d["days_of_week"] == [1, 2]
assert d["timezone"] == "UTC"
again = TimeOfDayRule.from_dict(d)
assert again.days_of_week == [1, 2] and again.timezone == "UTC"
def test_now_in_tz_invalid_falls_back_to_local():
assert _now_in_tz("Not/AZone").tzinfo is None
def test_now_in_tz_valid_is_aware():
assert _now_in_tz("UTC").tzinfo is not None
def test_now_in_tz_empty_is_local():
assert _now_in_tz("").tzinfo is None
+94
View File
@@ -0,0 +1,94 @@
"""Unit tests for the WLED native realtime UDP packet builder."""
import numpy as np
from ledgrab.core.devices.wled_realtime_client import (
DEFAULT_REALTIME_TIMEOUT,
WledRealtimeClient,
_clamp_timeout,
)
def _rgb(n: int) -> np.ndarray:
return np.arange(n * 3, dtype=np.uint8).reshape(n, 3)
def test_drgb_small_rgb_strip():
c = WledRealtimeClient("1.2.3.4", timeout_secs=2)
pixels = _rgb(10)
packets = c.build_packets(pixels)
assert len(packets) == 1
p = packets[0]
assert p[0] == 2 # DRGB
assert p[1] == 2 # timeout seconds
assert len(p) == 2 + 10 * 3
assert p[2:] == pixels.tobytes()
def test_drgbw_sets_explicit_white_zero():
c = WledRealtimeClient("1.2.3.4", rgbw=True, timeout_secs=5)
pixels = np.full((4, 3), 200, dtype=np.uint8)
packets = c.build_packets(pixels)
assert len(packets) == 1
p = packets[0]
assert p[0] == 3 # DRGBW
assert p[1] == 5
assert len(p) == 2 + 4 * 4
body = np.frombuffer(p[2:], dtype=np.uint8).reshape(4, 4)
assert (body[:, 0:3] == 200).all()
assert (body[:, 3] == 0).all() # white channel zeroed
def test_dnrgb_chunks_large_rgb_strip():
c = WledRealtimeClient("1.2.3.4", timeout_secs=3)
n = 1000 # > 490 -> DNRGB, > 489 per chunk -> 3 packets (489+489+22)
pixels = _rgb(n)
packets = c.build_packets(pixels)
assert len(packets) == 3
# Each packet starts with [4][timeout][start_hi][start_lo]
starts = []
total_leds = 0
for p in packets:
assert p[0] == 4 # DNRGB
assert p[1] == 3 # timeout
start = (p[2] << 8) | p[3]
starts.append(start)
leds = (len(p) - 4) // 3
total_leds += leds
assert starts == [0, 489, 978]
assert total_leds == n
def test_dnrgb_reassembles_to_original():
c = WledRealtimeClient("1.2.3.4", timeout_secs=1)
n = 700
pixels = _rgb(n)
out = bytearray()
for p in c.build_packets(pixels):
out += p[4:]
assert bytes(out) == pixels.tobytes()
def test_empty_frame_no_packets():
c = WledRealtimeClient("1.2.3.4")
assert c.build_packets(np.zeros((0, 3), dtype=np.uint8)) == []
def test_timeout_clamped_to_wire_range():
assert _clamp_timeout(0) == 1
assert _clamp_timeout(-5) == 1
assert _clamp_timeout(255) == 255
assert _clamp_timeout(1000) == 255
assert WledRealtimeClient("h", timeout_secs=0).timeout_secs == 1
def test_rgbw_over_capacity_falls_back_to_dnrgb():
# 400 RGBW LEDs (> 367) can't use DRGBW; falls back to DNRGB (RGB).
c = WledRealtimeClient("1.2.3.4", rgbw=True, timeout_secs=2)
packets = c.build_packets(_rgb(400))
assert all(p[0] == 4 for p in packets) # DNRGB
def test_default_timeout_constant():
assert DEFAULT_REALTIME_TIMEOUT == 2
assert WledRealtimeClient("h").timeout_secs == 2