Compare commits

..

34 Commits

Author SHA1 Message Date
alexei.dolgolyov c2c9af3c60 chore: release v0.4.1
Build Release / create-release (push) Successful in 4s
Build Android APK / build-android (push) Failing after 1m41s
Build Release / build-linux (push) Successful in 3m3s
Build Release / build-docker (push) Successful in 4m13s
Lint & Test / test (push) Successful in 5m24s
Build Release / build-windows (push) Successful in 5m33s
2026-04-22 19:21:27 +03:00
alexei.dolgolyov 4f7794ccd4 fix(installer): bundle cryptography + just-playback, set TCL env, clean stale debug.bat
Lint & Test / test (push) Successful in 2m20s
Windows installer silently failed to launch because build-dist-windows.sh
maintained its own DEPS list that drifted from server/pyproject.toml and
was missing `cryptography` — ledgrab.utils.secret_box imports AESGCM at
module load, so pythonw.exe crashed before the tray icon appeared. Also
missing: just-playback (lazy import, silent until a sound triggers).

- Add cryptography + just-playback to DEPS with a sync-with-pyproject
  warning comment
- Extend the post-cleanup on-disk check to abort the build if
  cryptography / cffi / just_playback go missing again
- Launcher now exports TCL_LIBRARY / TK_LIBRARY so the screen-overlay
  tkinter thread stops logging "Can't find init.tcl" at startup
- Installer wipes stale debug.bat / debug.log on install and uninstall
  (leftovers from the pre-rename wled_controller era produced a
  misleading ModuleNotFoundError when users tried to diagnose launch
  failures)
2026-04-22 19:19:07 +03:00
alexei.dolgolyov a0d63a3663 docs(release): drop stale WLED-rename task, document android signing secrets
Lint & Test / test (push) Successful in 2m1s
- Remove the top-of-file "IMPORTANT: Remove WLED naming throughout the
  app" checklist. The effort was absorbed by the multi-backend refactor
  (BLE / USB-serial / ESP-NOW / MQTT / OpenRGB providers all shipped),
  and the remaining user-facing copy has been swept in separate commits.
- Add an "Android Signing Secrets (Gitea)" section covering the four
  secrets the release APK CI expects, the one-off `keytool` command to
  generate `release.jks`, the consequences of losing the keystore, and
  a checklist of the remaining setup steps before tagging v0.4.1.
2026-04-21 20:01:26 +03:00
alexei.dolgolyov 35b75a2ed8 ci(android): fix keystore env scoping, fail loudly on release without key
Lint & Test / test (push) Successful in 3m17s
Primary bug — step-level env is not visible in that same step's `if:`
expression. `Decode signing keystore` had
  if: env.ANDROID_KEYSTORE_BASE64 != ''
  env:
    ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
so the env context seen by the `if:` evaluator was empty regardless of
whether the secret was configured. The step was skipped, keystore.present
never became 'true', and every release tag silently fell back to
assembleDebug. Result: APKs named `LedGrab-0.4.0-android-debug.apk` that
can't upgrade a previously-release-signed install (signature mismatch).

Fix — move ANDROID_KEYSTORE_BASE64 to the job-level env block. It's now
resolvable in the if-expression of any step in the job, and the shell
inherits it exactly the same way as before.

Secondary — add a "Guard release tag against missing keystore" step that
fires between the decode attempt and the gradle build. If is_release=true
but keystore.present!='true', the job fails with a clear error directing
the operator to configure the four signing secrets. Previously a
misconfigured Gitea silently shipped debug APKs labeled as releases.
2026-04-21 19:55:55 +03:00
alexei.dolgolyov 4ed099d564 docs(release): drop WLED-specific language from auto-generated release notes
The "discover your WLED devices" line predates BLE / USB-serial / ESP-NOW /
MQTT / OpenRGB support and misrepresents what the app does. Replaced with
a generic "add your LED devices" — the device-add UI lists what's actually
supported, and INSTALLATION.md carries the long-form detail.
2026-04-21 19:55:55 +03:00
alexei.dolgolyov d467eb5dae chore: release v0.4.0
Build Release / create-release (push) Successful in 3s
Build Android APK / build-android (push) Successful in 5m57s
Build Release / build-linux (push) Successful in 5m44s
Build Release / build-docker (push) Successful in 7m51s
Lint & Test / test (push) Successful in 8m59s
Build Release / build-windows (push) Successful in 8m51s
2026-04-21 19:41:40 +03:00
alexei.dolgolyov 524e422517 ci: decouple android release attach, add workflow_dispatch to release.yml
Lint & Test / test (push) Successful in 2m16s
build-android.yml
- Attach step upserts the Gitea release: GET /releases/tags/<TAG>, and
  POST to create it on 404 instead of warning-and-skipping. Removes the
  ordering dependency on release.yml's create-release job — the Android
  workflow can now own its own release attachment end-to-end.
- Fail loudly on broken DEPLOY_TOKEN: curl -f on every asset call so
  403/422 surface as job failures instead of "Uploaded" lies, and an
  explicit check that the token is non-empty before starting.
- Preserve the pre-existing replace-on-re-run behavior for idempotent
  asset uploads.

release.yml
- Add workflow_dispatch trigger with optional `version` input so the
  Windows/Linux/Docker builds can be exercised on demand between real
  releases (was tag-push only).
- Gate create-release on github.event_name == 'push' so a manual
  dispatch doesn't create a stray Gitea release.
- Each build job gets `if: !cancelled() && (needs.create-release.result
  in (success, skipped))` so dispatch runs still produce artifacts even
  though create-release was skipped.
- Gate each "Attach * to release" step on github.event_name == 'push'.
- Docker: login + push are push-only; build runs on both triggers so
  dispatch validates the Dockerfile without needing registry creds.
2026-04-21 19:10:14 +03:00
alexei.dolgolyov 5d6310f28c fix(android): make wheels find-links URL work on Linux CI
Lint & Test / test (push) Successful in 3m31s
Chaquopy's pip --find-links argument was built with a hard-coded
"file:///" prefix plus rootDir.absolutePath. That works on Windows
(paths start with "C:/...", so prefix + path produces three slashes
after "file:") but breaks on Linux (paths start with "/workspace/...",
so prefix + path produces four slashes — pip then parses "workspace"
as the URL's hostname and aborts with
  "ValueError: non-local file URIs are not supported on this platform".

Pick the prefix based on whether the absolute path already starts with
"/", so we always end up with exactly three slashes between "file:" and
the drive letter or root.
2026-04-21 18:58:17 +03:00
alexei.dolgolyov 7ef17c1595 ci(android): fix missing python symlink parent, restrict to release tags
Lint & Test / test (push) Successful in 3m53s
- Create android/app/src/main/python before `ln -sfn` — the parent dir
  isn't committed (android/.gitignore:17 ignores /ledgrab inside it) so
  fresh CI checkouts had nothing to link into, failing with
  "No such file or directory".
- Also wrap the step with `set -euo pipefail` and a `test -d` check so a
  broken link aborts the job instead of producing a silently-empty APK.
- Drop the `branches: [master]` push trigger and the `paths:` filter —
  every master commit touching android/** or server/src/ledgrab/** was
  queueing a ~100 MB APK build that nobody used. Only tag pushes (v*)
  and workflow_dispatch remain.
2026-04-21 18:53:43 +03:00
alexei.dolgolyov b3775b2f98 feat(android): boot-time autostart, capture watchdog, versionCode from git
Boot-time startup so LedGrab has display capture and control without user
interaction on rooted TV boxes. Also folds in a batch of review findings
from the Android package audit.

Autostart
- BootReceiver fires on BOOT_COMPLETED / LOCKED_BOOT_COMPLETED /
  MY_PACKAGE_REPLACED, gated by AutostartPrefs and Root.looksRooted().
  Dispatches CaptureService.createRootIntent via
  ContextCompat.startForegroundService. Unrooted devices are a no-op
  because MediaProjection consent cannot be bypassed silently.
- AutostartPrefs: thin SharedPreferences wrapper, defaults to enabled.
  Exposed as a CheckBox on the stopped panel; greyed out when not rooted.
- Manifest: RECEIVE_BOOT_COMPLETED, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
  WAKE_LOCK permissions + the new BootReceiver.
- MainActivity prompts for battery-optimization exemption on first opt-in
  so Doze/App Standby doesn't kill the FG service on phones.

Service stability
- onStartCommand now flips isRunning only after startForeground succeeds
  (was stuck=true forever if the FG transition threw) and resets on
  exception. Returns START_REDELIVER_INTENT for root mode so the OS can
  restart the service with the original intent (no consent token to
  invalidate); MediaProjection mode keeps START_NOT_STICKY.
- Watchdog coroutine monitors RootScreenrecord.framesDelivered. Respawns
  the pipeline on stall (reusing the existing Python bridge — no server
  restart), caps at 3 consecutive restarts before giving up.
- RootScreenrecord.framesDelivered is now an AtomicInteger, exposed as a
  public property for the watchdog.
- ScreenCapture takes an onProjectionStopped lambda; when the user taps
  the system Cast/Screen-capture stop banner, the whole service is torn
  down instead of leaving a stale FG notification.
- MainActivity's two startForegroundService calls switch to
  ContextCompat.startForegroundService, clearing pre-existing NewApi lint
  errors (minSdk=24 < API 26 native method).

Build
- versionCode derived from git rev-list --count HEAD (or the
  ANDROID_VERSION_CODE env var for CI). Was pinned to 1 — sideload
  upgrades were silently refusing to install.
- New i18n strings (autostart_label, autostart_unavailable, version_prefix)
  in en/ru/zh; version_text now uses the resource instead of string
  concat.

TODO.md: new "Android Autostart on Boot" section tracking done/pending
items; real-hardware verification on a Magisk'd TV box is the remaining
checkbox.
2026-04-21 18:53:27 +03:00
alexei.dolgolyov 45f93fd30e fix(devices): SP110E vendor handshake + Windows/bleak robustness
Build Android APK / build-android (push) Failing after 1m38s
Lint & Test / test (push) Successful in 4m32s
SP110E peripherals silently tear down the GATT link ~1s after connect
unless a two-write vendor handshake (01 00 → FFE2, 01 B7 E3 D5 → FFE1)
arrives immediately. Without it the first real write hangs 30s then
reconnect-loops forever. Adds optional BLEProtocol.init_writes executed
on connect, plumbs a per-write char_uuid through both transports, and
fixes the SP110E color/power frames from an incorrect 5 bytes to the
documented 4 bytes.

Windows/WinRT robustness:
- asyncio.wait_for hangs on bleak because WinRT IAsyncOperations refuse
  to cancel. _bounded_await() uses asyncio.wait() instead so timeouts
  actually return control even when the inner task is uncancellable.
- BleakClient connect by raw MAC string times out when WinRT guesses
  address type wrong; switched to pre-scanning with BleakScanner and
  passing the resolved BLEDevice, which carries the address type.
- Target-start fetch timeout bumped to 30s with retry disabled so the
  UI doesn't abort during the BLE pre-scan + connect + handshake path.

UI:
- Settings modal exposes Protocol Family (IconSelect grid, shared with
  add-device via parameterized ensureBleFamilyIconSelect) so users can
  fix a wrong family pick without recreating the device. Govee AES key
  row toggles on/off with family selection.

Also turns LAN auth back on in default_config.yaml, logs start_processing
requests on entry for easier diagnosis, and captures the full debug trail
in docs/BLE_LED_CONTROLLERS.md for future BLE work.

Refs the mbullington SP110E protocol gist for the handshake bytes.
2026-04-21 17:45:21 +03:00
alexei.dolgolyov 2b5dac2c42 feat(devices): BLE LED controller support (SP110E/Triones/Zengge/Govee)
Build Android APK / build-android (push) Failing after 1m44s
Lint & Test / test (push) Successful in 4m22s
End-to-end BLE streaming: provider + client + per-protocol wire encoders
with whole-strip averaging, desktop (bleak) and Android (Kotlin BleBridge
via Chaquopy) transports, discovery with protocol-family detection that
auto-fills the UI, throttled not-connected warning + 10 s reconnect
cooldown so a dropped link no longer stalls the pipeline at ~30 s/frame,
and an explicit asyncio.wait_for wrapper around bleak connect() since
the WinRT backend doesn't always honor the timeout kwarg.

Also rewrites server/restart.ps1 to be parameterized (-Port / -Module /
-PythonVersion / timeouts / -Quiet), pick the right interpreter via the
py launcher, pre-flight the target module, poll port readiness on both
shutdown and startup, redirect child stdout/stderr so Start-Process
doesn't hang on inherited Git-Bash handles, and return proper exit codes.

Rolls in concurrent work: Android BLE permissions + launcher icons + ru/zh
resources, Chaquopy-safe value_stream psutil fallback, setup-required
modal, asset-store test coverage, and misc system/config touch-ups.
2026-04-21 14:58:35 +03:00
alexei.dolgolyov d3a6416a1d refactor(devices): per-provider typed configs (phases 1-4)
Phase 1 — DeviceConfig hierarchy (device_config.py):
- 17 @dataclass(frozen=True) subclasses (WLEDConfig, AdalightConfig, …) sharing
  BaseDeviceConfig; DeviceConfig = Union[all 17]
- Device.to_config() in device_store.py: single flat→typed dispatch point

Phase 2+3 — Typed provider signatures + call-site migration:
- ProviderDeps(device_store) frozen dataclass in led_client.py
- LEDDeviceProvider.create_client(config, *, deps) abstract signature
- create_led_client(config, *, deps) factory dispatches via config.device_type
- All 17 providers narrowed to their specific config type; drop kwargs.get()
- GroupLEDClient.connect() uses device.to_config() + create_led_client()
- wled_target_processor: replaced 21-field DeviceInfo unpacking with to_config()
  + dataclasses.replace(config, use_ddp=…) for DDP override
- device_test_mode: build typed config via to_config() + ProviderDeps
- Deleted DeviceInfo dataclass, _get_device_info(), _DEVICE_FIELD_DEFAULTS
- TargetContext: replaced get_device_info callback with is_test_mode_active

Phase 4 — Test migration:
- 47-case test suite in tests/core/devices/test_device_config.py (100% coverage)
- test_group_device.py TestGroupLEDClient migrated to GroupConfig + ProviderDeps
- Removed legacy keyword-arg init path from GroupLEDClient
2026-04-18 01:24:27 +03:00
alexei.dolgolyov 123da1b5c4 fix: comprehensive security, stability, and code quality audit
Build Android APK / build-android (push) Failing after 1m45s
Lint & Test / test (push) Successful in 4m54s
Security:
- Force API key auth for LAN (non-loopback) requests; remove shipped dev key
- Block path-traversal in backup restore; require auth on backup endpoints
- SSRF protection: DNS resolve + private/loopback/link-local IP rejection
- AES-256-GCM encryption for HA tokens and MQTT passwords with auto-migration
- WebSocket auth migrated from query-string to first-message protocol
- Asset upload: extension allowlist, server-side mime, Content-Disposition
- Update installer: SHA256 verification, tar/zip member validation
- Tightened CORS (explicit methods/headers, no credentials)
- ADB serial regex allowlist, webhook rate-limit key fix, log scrubbing

Android:
- Root-capture: ordered teardown, screenrecord respawn watchdog, child reaping
- USB permission blocking API via CompletableDeferred
- Python init crash guard with fatal-error screen
- Moved root grant + QR generation off Main thread
- Cached PyObject engine for per-frame bridge calls
- Ordered ScreenCapture resource cleanup, allowBackup=false

Python:
- Replaced all asyncio.get_event_loop() with get_running_loop/to_thread
- Split color_strip_sources.py (1683->5 files) and color_strip_stream.py
  (1324->7 files) into packages
- Extracted FrameLimiter utility, migrated 9 stream loops
- Provider base-class reuse, WLED state caching + URL normalization
- Narrowed broad except-pass in WS routes, threading fixes in BaseStore

Frontend:
- XSS fix: escapeHtml on dynamic option labels, reconcile-based list renders
- Typed DOM helpers, safe localStorage access, AbortController listener hygiene
- openAuthedWs helper for first-message WS auth protocol
- Migrated remaining plain <select>s to IconSelect/EntitySelect

Design:
- WCAG AA primary color on light theme (#2e7d32, 5.4:1 contrast)
- Android TV 10-foot breakpoint (tv.css)
- Consolidated z-index tokens, unified easing, card-running GPU hints
2026-04-16 04:56:04 +03:00
alexei.dolgolyov 5fcb9f82bd feat(android): root-based screen capture bypassing MediaProjection
Build Android APK / build-android (push) Failing after 1m40s
Lint & Test / test (push) Successful in 3m40s
On rooted TV boxes, spawn `su -c screenrecord ... -` and feed the
H.264 stdout through MediaCodec into an ImageReader, surfacing RGBA
frames via PythonBridge. RootScreenrecordEngine (priority 110) is
picked automatically when root is available; falls back to
MediaProjection when Root.requestGrant() returns false.
2026-04-14 19:30:26 +03:00
alexei.dolgolyov 928d626620 refactor(devices): route ESP-NOW client through SerialTransport
Build Android APK / build-android (push) Failing after 1m39s
Lint & Test / test (push) Successful in 4m54s
Drops the direct pyserial imports from espnow_client/espnow_provider
in favor of open_transport/list_serial_ports/port_exists. The gateway
protocol is write-only, so no read() extension was needed. ESP-NOW
gateways are now reachable via usb:VID:PID URLs on Android.
2026-04-14 19:15:08 +03:00
alexei.dolgolyov 580bd692e6 fix(scenes): coerce BindableFloat fps to int when snapshotting
Build Android APK / build-android (push) Failing after 1m41s
Lint & Test / test (push) Successful in 4m32s
OutputTarget.fps is a BindableFloat but TargetSnapshot.fps is a plain
int — capture_current_snapshot was stuffing the BindableFloat directly
into the snapshot, causing json.dumps to fail on recapture (500) and
polluting the in-memory cache so subsequent list calls also 500'd with
pydantic validation errors.
2026-04-14 19:03:58 +03:00
alexei.dolgolyov 7fcb8dd346 feat(devices): Android USB-serial support for Adalight/AmbiLED controllers
Build Android APK / build-android (push) Failing after 1m41s
Lint & Test / test (push) Successful in 4m51s
Adds end-to-end support for driving USB-connected Adalight / AmbiLED
LED controllers from Android TV boxes. Android's security model blocks
direct USB access from Python, so writes route through a Kotlin
UsbSerialBridge singleton via Chaquopy.

Python side:
- New SerialTransport Protocol (serial_transport.py) with open / write /
  flush / close. Desktop uses PySerialTransport (wraps pyserial),
  Android uses AndroidSerialTransport (wraps the Kotlin bridge).
- list_serial_ports() factory returns desktop COM ports on desktop,
  USB devices on Android — callers don't branch.
- URL scheme extended: existing COM3[:baud] and /dev/ttyUSB0[:baud]
  unchanged; new usb:VID:PID[:serial][@baud] for Android (@ is the
  baud separator since : is already used between VID and PID).
- AdalightClient and SerialDeviceProvider refactored to go through
  the transport — no more direct pyserial imports in hot paths.
- 17 new unit tests cover URL parsing, PySerial transport, factory
  selection, platform-branching discovery. Full suite 750 passing.

Kotlin side:
- UsbSerialBridge.kt singleton uses com.hoho.android.usbserial (mik3y)
  which ships drivers for CH340, CP2102, FTDI, Prolific, and CDC-ACM
  (Arduino). Exposes listDevices, open, write, close via @JvmStatic
  for Chaquopy. First open() attempt without permission triggers the
  system USB permission dialog; next call succeeds once user grants.
- usb-serial-for-android is distributed via JitPack — added that repo
  in settings.gradle.kts and the dependency in app/build.gradle.kts.
- AndroidManifest declares uses-feature android.hardware.usb.host
  (required=false so non-USB-host phones still install).
- LedGrabApp.onCreate calls UsbSerialBridge.init(this) so the bridge
  resolves the UsbManager without needing an Activity ref.

Verified: ./gradlew compileDebugKotlin succeeds; off-Android import
of android_serial_transport works. Real-hardware smoke test on a
TV box with a CH340/CP2102/FTDI adapter still pending.

ESP-NOW (espnow_client / espnow_provider) still imports pyserial
directly because it needs bidirectional reads — separate refactor
to extend the transport with read() if that path ever needs Android
USB support.
2026-04-14 16:34:09 +03:00
alexei.dolgolyov ecae05d00b feat(metrics): battery + thermal-zone readings with dashboard temp chart
Build Android APK / build-android (push) Failing after 1m40s
Lint & Test / test (push) Successful in 4m18s
Extends MetricsProvider with thermals() returning a ThermalSnapshot
(battery_percent, battery_temp_c, cpu_temp_c — all optional). Each
provider implements it independently:

- AndroidMetricsProvider reads /sys/class/power_supply/battery/{capacity,
  temp} (battery temp is tenths of degC) and walks
  /sys/class/thermal/thermal_zone*, filtering by zone type
  (cpu/soc/tsens/core) so battery and skin sensors don't dominate the
  reading. Rejects nonsense values like INT_MAX from buggy zones.
- PsutilMetricsProvider uses sensors_battery() and
  sensors_temperatures() when present (Linux+laptops); no-ops on
  Windows/macOS where psutil doesn't expose them.
- NullMetricsProvider returns the empty snapshot.

PerformanceResponse gains battery_percent / battery_temp_c / cpu_temp_c.
The metrics-history ring buffer also carries cpu_temp / battery_pct /
battery_temp per sample so the dashboard can graph them over time.

Frontend dashboard (perf-charts.ts) gets a new Temperature chart card,
hidden by default and revealed only after seed/poll confirms the
backend reports cpu_temp_c. Battery temperature shows inline as a
secondary badge. The GPU card now also hides entirely when the backend
reports gpu=null instead of showing an "unavailable" placeholder.
HOST_ONLY_KEYS prevents the System/App/Both toggle from flipping a
non-existent app dataset for temp.

Tests: 6 new for thermals (battery tenths-of-degC parsing, CPU zone
filtering, fallback when sensors absent, INT_MAX rejection); 18 metrics
tests total; full suite 733 passing.
2026-04-14 13:48:01 +03:00
alexei.dolgolyov 546b24d015 refactor(metrics): MetricsProvider abstraction with Android /proc backend
Build Android APK / build-android (push) Failing after 1m39s
Lint & Test / test (push) Successful in 4m20s
Moves direct psutil.* calls behind a MetricsProvider Protocol so the
codebase no longer needs ad-hoc `if psutil is not None` guards at every
call site. Each provider lives in its own module under
utils/metrics/: PsutilMetricsProvider for desktop, NullMetricsProvider
as a zeroed fallback, AndroidMetricsProvider that reads /proc/stat,
/proc/meminfo, /proc/self/stat, and /proc/self/status directly (psutil
isn't available under Chaquopy). The Android provider tracks the
previous CPU sample so cpu_percent() returns delta-based percentages
matching psutil's interval=None semantics, and degrades to zeros when
any /proc file is unreadable instead of crashing the dashboard.

Factory get_metrics_provider() in utils/metrics/__init__.py picks
Android > psutil > Null. api/routes/system.py and
core/processing/metrics_history.py now go through the factory; psutil
import is confined to one place. 12 new unit tests cover paren-in-comm
parsing of /proc/self/stat, delta CPU%, missing-file resilience, and
factory selection order. Full suite: 727 passing.
2026-04-14 13:34:32 +03:00
alexei.dolgolyov 488df98996 fix(frontend): add autocomplete attrs to credential inputs
Build Android APK / build-android (push) Failing after 1m40s
Lint & Test / test (push) Successful in 3m35s
Silences browser accessibility warnings on the HA token, MQTT username,
and MQTT password fields. Uses new-password for the secret inputs to
discourage Chrome's site-password autofill from leaking into broker /
HA-token configuration.
2026-04-14 12:50:50 +03:00
alexei.dolgolyov 2477e00fae ci: add Android APK row to release downloads table
Lint & Test / test (push) Successful in 1m37s
2026-04-14 12:47:27 +03:00
alexei.dolgolyov 151cea3ecb ci: Android multi-ABI APK pipeline + pydantic-core wheel rebuild
Build Android APK / build-android (push) Failing after 2m31s
Lint & Test / test (push) Successful in 6m15s
Adds .gitea/workflows/build-android.yml — Linux runner installs JDK 17,
Python 3.11, Android SDK/NDK, symlinks server/src/ledgrab into the
Chaquopy python source dir, and runs assembleDebug on master pushes /
assembleRelease on v* tags. APK is uploaded as an artifact and attached
to the Gitea release on tag push. Conditional signing config in
build.gradle.kts reads keystore from env vars (CI secrets) and falls
back to debug signing locally. Gradle wrapper (gradlew/gradlew.bat/
gradle-wrapper.jar) committed so CI can drive the build.

Rebuilds pydantic-core wheels for arm64-v8a and x86_64 — both were
missing libpython3.11.so in NEEDED, which would have crashed at import
on real devices. build-pydantic-core.sh rewritten as a multi-ABI builder:
selects targets via args, sets RUSTFLAGS=-C link-arg=-Wl,--no-as-needed
-C link-arg=-lpython3.11 to force the symbol-resolution dependency,
uses the per-ABI sysconfigdata + libpython staged in
android/.build-cache/, prefers `py -3.11` on Windows (Git Bash's
python3.11 is an MSStore stub), uses the .cmd clang wrapper on Windows
(fixes os error 193), and verifies NEEDED via llvm-readelf after each
build. abiFilters restored to the full triple in build.gradle.kts;
multi-ABI debug APK builds cleanly (~99 MB).
2026-04-14 12:36:13 +03:00
alexei.dolgolyov 8574424fb7 feat: Android TV app embedding Python server via Chaquopy
Lint & Test / test (push) Successful in 2m10s
Adds a native Android TV application that runs the full LedGrab Python
server in-process via Chaquopy. Captures the TV box screen using the
MediaProjection API and exposes the existing web UI on the device's
local network — users configure via phone/tablet browser.

Android (new /android/ module):
- Kotlin shell: MainActivity, CaptureService (foreground service),
  ScreenCapture (MediaProjection + ImageReader), PythonBridge (Chaquopy).
- Polished Leanback-themed UI with QR code for easy web UI access.
- AGP 8.9 + Chaquopy 17 + Gradle 8.11 (avoids the AGP 8.7 thread-lock bug).
- Pre-built pydantic-core wheels for arm64-v8a, x86_64, x86 cross-compiled
  with maturin + Android NDK, linked against Chaquopy's libpython3.11.so.

Python server platform guards:
- New utils/platform.py with is_android()/is_windows()/is_linux() helpers.
- Guard every top-level import of desktop-only packages (mss, psutil,
  sounddevice, pyserial, PyAudioWPatch, etc.) with try/except ImportError.
- Android-incompatible calls gated with None-checks so the server runs on
  reduced capabilities on Android (no CPU/RAM metrics, no mss displays).
- utils/image_codec.py gains a Pillow fallback for resize + JPEG encode
  when cv2 is unavailable; all internal cv2.resize callers migrated.
- New android_entry.py start_server/stop_server invoked from Kotlin.
- get_displays API falls back to best available engine when mss fails.

New capture engines:
- MediaProjectionEngine: receives RGBA frames pushed from Kotlin through
  a thread-safe queue; caches last frame for static-screen previews.
- ScrcpyClientEngine: optional H.264 streaming via scrcpy-client library
  (priority 10, overrides the ADB-screencap engine when installed).

Frontend:
- Tab loaders previously required an apiKey; now correctly treat
  "auth disabled" as authenticated (Android has no auth by default).
- Re-trigger the active tab's loader after loadServerInfo resolves
  authRequired, since initTabs runs earlier.
- Add i18n keys for the demo / mediaprojection / scrcpy_client engines.

Docs:
- TODO.md: follow-ups for multi-ABI wheel rebuilds, CI pipeline, USB
  serial LED controllers, root-only capture, perf metrics abstraction.
- CLAUDE.md: Android dependency sync policy (pip --exclude doesn't exist).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 03:11:43 +03:00
alexei.dolgolyov a0b65e3fcb refactor: move build scripts to build/ directory
Lint & Test / test (push) Successful in 2m21s
Declutters the repo root by consolidating build-common.sh,
build-dist.sh, build-dist-windows.sh, build-dist.ps1, and installer.nsi
into build/. Updates all path references in CI workflows, NSIS installer,
and documentation.
2026-04-12 23:16:40 +03:00
alexei.dolgolyov 02cd9d519c refactor: rename project to LedGrab, split HA integration into separate repo
Lint & Test / test (push) Successful in 1m56s
- Rename Python package: wled_controller -> ledgrab
- Rename env var prefix: WLED_ -> LEDGRAB_ (with auto-migration for old vars)
- Rename localStorage key: wled_api_key -> ledgrab_api_key (with migration)
- Rename HA integration domain: wled_screen_controller -> ledgrab
- Update all imports, build scripts, Docker, installer, config, docs
- Remove HA integration (moved to ledgrab-haos-integration repo)
- Remove hacs.json (belongs in HA repo now)
- Add startup warning for users with old WLED_ env vars
- All tests pass (715/715), ruff clean, tsc clean, frontend builds
2026-04-12 22:45:28 +03:00
alexei.dolgolyov 38f73badbf fix: register pattern-templates API route; add responsive toolbar overflow menu
Lint & Test / test (push) Successful in 2m19s
Pattern templates route existed but was never wired into the router,
dependencies, or database table allowlist — causing 404 on graph tab load.

Graph toolbar now collapses secondary actions into a "more" overflow menu
on viewports narrower than 700px. Primary controls (fit, zoom, add) stay
visible; search, filter, panels, undo/redo, relayout, fullscreen, and
help move into the dropdown.
2026-04-12 21:22:50 +03:00
alexei.dolgolyov e678e5590a docs: update TODO and frontend context docs
Lint & Test / test (push) Successful in 1m54s
Replace TODO-css-improvements.md with TODO-release.md. Add EntityPalette
and IconSelect patterns for dynamic entity lists to frontend context.
2026-04-12 20:55:58 +03:00
alexei.dolgolyov 83ceaeda9d fix: HA Light Target cards flickering on every poll cycle
Lint & Test / test (push) Has been cancelled
Use stable placeholder values in card HTML for volatile metrics (fps,
uptime, HA status, entity swatches) so CardSection.reconcile() skips
unchanged cards. Actual values are patched in-place via
patchHALightTargetMetrics() — same pattern LED target cards already use.
2026-04-12 20:55:30 +03:00
alexei.dolgolyov d3cd48e7a7 fix: EntitySelect not showing selected value in weather/processed CSS editors
Lint & Test / test (push) Successful in 1m46s
After _populateWeatherSourceDropdown() and _populateProcessedSelectors()
create new EntitySelect instances, the subsequent .value = assignment on
the native <select> doesn't trigger _syncTrigger(), so the trigger
button still shows "—". Call .refresh() after setting the value.
2026-04-12 20:47:33 +03:00
alexei.dolgolyov cc9900d801 feat: support nesting for composite color strip sources
Lint & Test / test (push) Successful in 2m17s
Allow composite sources to reference other composite/mapped sources as
layers. Adds cycle detection (via transitive dependency graph walk),
depth limiting (MAX_COMPOSITE_DEPTH=4), and a runtime safety net in the
stream manager. Frontend layer dropdown now shows all source types
except the source being edited.

17 new tests covering cycles, depth limits, and valid nesting — all
715 tests passing.
2026-04-12 20:41:15 +03:00
alexei.dolgolyov 4940007e54 feat: add Group device type for combining multiple devices
Lint & Test / test (push) Successful in 2m19s
Introduces a new "group" device type that aggregates multiple physical
(or nested group) devices into one virtual device. Supports two modes:
- Sequence: LEDs concatenated end-to-end (led_count = sum of children)
- Independent: full pixel array resampled to each child independently

Includes cycle detection (DFS) to prevent circular group references,
delete protection for devices referenced by groups, recursive LED count
resolution for nested groups, and reorder controls (move up/down) for
child devices in the UI.

Backend: Device model, API schemas, GroupLEDClient, GroupDeviceProvider,
route validation, processing pipeline integration.
Frontend: type picker, child device picker with reorder, mode selector,
i18n (en/ru/zh), layers icon, CSS for group child rows.
Tests: 20 unit tests for cycle detection, LED count resolution, and
GroupLEDClient (sequence slicing, independent resampling, cleanup).
2026-04-11 02:26:56 +03:00
alexei.dolgolyov 92585e7c19 fix(build): bundle bettercam/dxcam/windows-capture in installer
Lint & Test / test (push) Successful in 2m5s
The Windows installer was only shipping mss as a screen-capture
backend, so EngineRegistry.get_available_engines() reported just
['mss', 'camera', 'demo'] on installed builds. Picture sources
configured to use bettercam/dxcam/wgc were rejected at the
test/ws handshake with HTTP 403 (close-before-accept on
"Engine '<x>' not available").

Add the three Windows screen-capture wheels to WIN_DEPS so the
installer build matches a 'pip install -e .' dev environment.
2026-04-08 23:15:10 +03:00
alexei.dolgolyov 0e09eaf43b fix(launcher): set TCL_LIBRARY/TK_LIBRARY for embedded Python
Embedded Python ships with tcl8.6/ and tk8.6/ next to python.exe, but
Tcl's auto-detection searches <exe>/../lib/tcl8.6 — a path that doesn't
exist in our layout. Without these env vars, tkinter.Tk() raises
"Can't find a usable init.tcl", breaking the screen overlay (and any
other tk-based UI) on installed builds.
2026-04-08 23:14:58 +03:00
667 changed files with 22891 additions and 10144 deletions
+246
View File
@@ -0,0 +1,246 @@
name: Build Android APK
on:
# Release tags only — building the ~100 MB APK on every master push
# burned Gitea runner minutes without producing a useful artifact.
# Use workflow_dispatch for on-demand dev builds.
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Version label (e.g. dev, 0.3.0-test)'
required: false
default: 'dev'
jobs:
build-android:
runs-on: ubuntu-latest
env:
JAVA_VERSION: '17'
PYTHON_VERSION: '3.11'
ANDROID_CMDLINE_TOOLS_VERSION: '11076708'
ANDROID_SDK_PLATFORM: 'android-34'
ANDROID_BUILD_TOOLS: '34.0.0'
ANDROID_NDK_VERSION: '26.1.10909125'
# Surfaced at job level (not step level) so the `if: env.X != ''`
# check on the Decode step actually sees it — step-level env is
# NOT available in that step's own `if:` expression, which
# silently skipped the decode and produced debug-signed release
# APKs until it was noticed.
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve build label
id: label
run: |
REF="${{ gitea.ref_name }}"
if echo "$REF" | grep -qE '^v[0-9]'; then
LABEL="${REF#v}"
IS_RELEASE="true"
elif [ -n "${{ inputs.version }}" ]; then
LABEL="${{ inputs.version }}"
IS_RELEASE="false"
else
LABEL="dev-${{ gitea.sha }}"
IS_RELEASE="false"
fi
LABEL="${LABEL:0:40}"
echo "label=$LABEL" >> "$GITHUB_OUTPUT"
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
echo "Build label: $LABEL (release=$IS_RELEASE)"
- name: Setup JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
- name: Setup Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Android SDK + NDK
run: |
set -euo pipefail
SDK_ROOT="$HOME/android-sdk"
mkdir -p "$SDK_ROOT/cmdline-tools"
cd "$SDK_ROOT/cmdline-tools"
curl -sSL --retry 3 \
-o cmdline-tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip"
unzip -q cmdline-tools.zip
rm cmdline-tools.zip
mv cmdline-tools latest
export ANDROID_SDK_ROOT="$SDK_ROOT"
export ANDROID_HOME="$SDK_ROOT"
SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
yes | "$SDKMANAGER" --licenses > /dev/null 2>&1 || true
"$SDKMANAGER" --install \
"platform-tools" \
"platforms;${ANDROID_SDK_PLATFORM}" \
"build-tools;${ANDROID_BUILD_TOOLS}" \
"ndk;${ANDROID_NDK_VERSION}" > /dev/null
echo "ANDROID_SDK_ROOT=$SDK_ROOT" >> "$GITHUB_ENV"
echo "ANDROID_HOME=$SDK_ROOT" >> "$GITHUB_ENV"
echo "ANDROID_NDK_HOME=$SDK_ROOT/ndk/${ANDROID_NDK_VERSION}" >> "$GITHUB_ENV"
echo "$SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
echo "$SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
- name: Create local.properties
run: |
echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties
- name: Link Python source (junction equivalent)
run: |
set -euo pipefail
# Chaquopy reads Python modules from android/app/src/main/python/
# On Windows dev machines this is a directory junction; on Linux
# CI use a symlink. The parent dir is .gitignore'd (the junction
# target is the real content), so we have to create it first.
mkdir -p android/app/src/main/python
ln -sfn "$(pwd)/server/src/ledgrab" android/app/src/main/python/ledgrab
ls -la android/app/src/main/python/
# Sanity check — readlink resolves the link and the directory exists.
test -d android/app/src/main/python/ledgrab
- name: Decode signing keystore
id: keystore
if: env.ANDROID_KEYSTORE_BASE64 != ''
run: |
set -euo pipefail
mkdir -p android/keystore
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/keystore/release.jks
echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT"
echo "present=true" >> "$GITHUB_OUTPUT"
- name: Guard release tag against missing keystore
# Release tags MUST produce a release-signed APK, otherwise existing
# installs can't upgrade (signature mismatch). Fail loudly instead
# of silently falling back to the debug signing config.
if: ${{ steps.label.outputs.is_release == 'true' && steps.keystore.outputs.present != 'true' }}
run: |
echo "::error::Release tag ${{ gitea.ref_name }} requires ANDROID_KEYSTORE_BASE64 (plus KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) to be configured in Gitea → Settings → Secrets."
exit 1
- name: Build APK
working-directory: android
env:
ANDROID_KEYSTORE_PATH: ${{ steps.keystore.outputs.path }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
chmod +x gradlew
if [ "${{ steps.keystore.outputs.present }}" = "true" ] && [ "${{ steps.label.outputs.is_release }}" = "true" ]; then
echo "Building signed release APK"
./gradlew --no-daemon assembleRelease
else
echo "Building debug APK (no signing keystore available or not a release tag)"
./gradlew --no-daemon assembleDebug
fi
- name: Locate and rename APK
id: apk
run: |
set -euo pipefail
SRC=$(ls android/app/build/outputs/apk/release/*.apk 2>/dev/null | head -1 || true)
if [ -z "$SRC" ]; then
SRC=$(ls android/app/build/outputs/apk/debug/*.apk | head -1)
VARIANT="debug"
else
VARIANT="release"
fi
DEST="build/LedGrab-${{ steps.label.outputs.label }}-android-${VARIANT}.apk"
mkdir -p build
cp "$SRC" "$DEST"
echo "path=$DEST" >> "$GITHUB_OUTPUT"
echo "name=$(basename "$DEST")" >> "$GITHUB_OUTPUT"
ls -lh "$DEST"
- name: Upload APK artifact
uses: actions/upload-artifact@v3
with:
name: LedGrab-${{ steps.label.outputs.label }}-android
path: ${{ steps.apk.outputs.path }}
retention-days: 90
- name: Attach APK to Gitea release (upsert)
if: ${{ steps.label.outputs.is_release == 'true' }}
env:
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ gitea.ref_name }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
APK_PATH="${{ steps.apk.outputs.path }}"
APK_NAME="${{ steps.apk.outputs.name }}"
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::DEPLOY_TOKEN secret not configured — cannot attach APK"
exit 1
fi
# Upsert: look up release by tag. If it exists, reuse it; if 404,
# create one. Makes the Android workflow self-sufficient — no
# ordering dependency on release.yml's create-release job.
HTTP=$(curl -s -o /tmp/release.json -w "%{http_code}" \
"$BASE_URL/releases/tags/$TAG" \
-H "Authorization: token $GITEA_TOKEN")
case "$HTTP" in
200)
RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release.json'))['id'])")
echo "Found existing release id=$RELEASE_ID"
;;
404)
echo "No release for tag $TAG — creating one"
IS_PRE="false"
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
IS_PRE="true"
fi
CREATE_HTTP=$(curl -s -o /tmp/created.json -w "%{http_code}" \
-X POST "$BASE_URL/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"LedGrab $TAG\",\"draft\":false,\"prerelease\":$IS_PRE}")
if [ "$CREATE_HTTP" != "201" ] && [ "$CREATE_HTTP" != "200" ]; then
echo "::error::Failed to create release (HTTP $CREATE_HTTP)"
cat /tmp/created.json
exit 1
fi
RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/created.json'))['id'])")
echo "Created release id=$RELEASE_ID"
;;
*)
echo "::error::Unexpected HTTP $HTTP when looking up release for tag $TAG"
cat /tmp/release.json
exit 1
;;
esac
# Replace existing asset if present (re-run safety).
EXISTING_ID=$(curl -fsS "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$APK_NAME'),''))")
if [ -n "$EXISTING_ID" ]; then
curl -fsS -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
-H "Authorization: token $GITEA_TOKEN"
echo "Replaced existing asset: $APK_NAME"
fi
# -f: exit non-zero on 4xx/5xx so a broken token fails the job
# loudly instead of the previous silent "Uploaded" lie.
curl -fsS -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$APK_NAME" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$APK_PATH"
echo "Uploaded: $APK_NAME"
+4 -4
View File
@@ -34,8 +34,8 @@ jobs:
- name: Cross-build Windows distribution
run: |
chmod +x build-dist-windows.sh
./build-dist-windows.sh "v${{ inputs.version }}"
chmod +x build/build-dist-windows.sh
./build/build-dist-windows.sh "v${{ inputs.version }}"
- uses: actions/upload-artifact@v3
with:
@@ -70,8 +70,8 @@ jobs:
- name: Build Linux distribution
run: |
chmod +x build-dist.sh
./build-dist.sh "v${{ inputs.version }}"
chmod +x build/build-dist.sh
./build/build-dist.sh "v${{ inputs.version }}"
- uses: actions/upload-artifact@v3
with:
+35 -9
View File
@@ -4,10 +4,21 @@ on:
push:
tags:
- 'v*'
# Manual dispatch builds Windows/Linux/Docker artifacts without creating
# a Gitea release — for validating build scripts between real releases.
# Attach/push steps are gated on github.event_name == 'push'.
workflow_dispatch:
inputs:
version:
description: 'Version label for dispatch builds (artifacts only, no release)'
required: false
default: 'dev'
jobs:
# ── Create the release first (shared by all build jobs) ────
create-release:
# Skipped on workflow_dispatch — dispatch is for build validation only.
if: github.event_name == 'push'
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create.outputs.release_id }}
@@ -65,6 +76,7 @@ jobs:
| Windows (installer) | \`LedGrab-{tag}-setup.exe\` | Install with Start Menu shortcut, optional autostart, uninstaller |
| Windows (portable) | \`LedGrab-{tag}-win-x64.zip\` | Unzip anywhere, run LedGrab.bat |
| Linux | \`LedGrab-{tag}-linux-x64.tar.gz\` | Extract, run ./run.sh |
| Android | \`LedGrab-{tag}-android-release.apk\` | Sideload on Android 7.0+ (API 24+) — TV boxes, Fire TV, phones, tablets. arm64-v8a / x86_64 / x86 |
| Docker | See below | docker pull + docker run |
After starting, open **http://localhost:8080** in your browser.
@@ -78,9 +90,9 @@ jobs:
### First-time setup
1. Change the default API key in config/default_config.yaml
2. Open http://localhost:8080 and discover your WLED devices
3. See INSTALLATION.md for detailed configuration
1. Change the default API key in `config/default_config.yaml`.
2. Open http://localhost:8080 and add your LED devices.
3. See `INSTALLATION.md` for detailed configuration.
''').strip())
print(json.dumps('\n\n'.join(sections)))
@@ -111,6 +123,9 @@ jobs:
# ── Windows portable ZIP (cross-built from Linux) ─────────
build-windows:
needs: create-release
# `!cancelled()` lets this job run even when create-release was skipped
# (dispatch) or failed. The attach step itself is still push-gated.
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -135,8 +150,8 @@ jobs:
- name: Cross-build Windows distribution
run: |
chmod +x build-dist-windows.sh
./build-dist-windows.sh "${{ gitea.ref_name }}"
chmod +x build/build-dist-windows.sh
./build/build-dist-windows.sh "${{ gitea.ref_name }}"
- name: Upload build artifacts
uses: actions/upload-artifact@v3
@@ -148,6 +163,8 @@ jobs:
retention-days: 90
- name: Attach assets to release
# Push (tag) only — dispatch runs produce artifacts but no release.
if: github.event_name == 'push'
env:
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
@@ -183,6 +200,7 @@ jobs:
# ── Linux tarball ──────────────────────────────────────────
build-linux:
needs: create-release
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -207,8 +225,8 @@ jobs:
- name: Build Linux distribution
run: |
chmod +x build-dist.sh
./build-dist.sh "${{ gitea.ref_name }}"
chmod +x build/build-dist.sh
./build/build-dist.sh "${{ gitea.ref_name }}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
@@ -218,6 +236,7 @@ jobs:
retention-days: 90
- name: Attach tarball to release
if: github.event_name == 'push'
env:
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
@@ -246,6 +265,7 @@ jobs:
# ── Docker image ───────────────────────────────────────────
build-docker:
needs: create-release
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -269,6 +289,9 @@ jobs:
- name: Login to Gitea Container Registry
id: docker-login
# Dispatch runs don't need registry credentials — the build step
# verifies the Dockerfile locally and push is skipped.
if: github.event_name == 'push'
continue-on-error: true
run: |
echo "${{ secrets.DEPLOY_TOKEN }}" | docker login \
@@ -276,7 +299,10 @@ jobs:
-u "${{ gitea.actor }}" --password-stdin
- name: Build Docker image
if: steps.docker-login.outcome == 'success'
# Always build — dispatch uses this to validate the Dockerfile.
# On push, still gate on successful login so we don't build a
# tagged image that can't be pushed.
if: github.event_name != 'push' || steps.docker-login.outcome == 'success'
run: |
TAG="${{ gitea.ref_name }}"
REGISTRY="${{ steps.meta.outputs.registry }}"
@@ -295,7 +321,7 @@ jobs:
fi
- name: Push Docker image
if: steps.docker-login.outcome == 'success'
if: github.event_name == 'push' && steps.docker-login.outcome == 'success'
run: |
TAG="${{ gitea.ref_name }}"
REGISTRY="${{ steps.meta.outputs.registry }}"
+14 -1
View File
@@ -4,7 +4,16 @@ __pycache__/
*$py.class
*.so
.Python
build/
# Build output artifacts (LedGrab/, *.zip, *.exe, *.tar.gz, cached downloads)
build/LedGrab/
build/*.zip
build/*.exe
build/*.tar.gz
build/*.msi
build/python-embed-*.zip
build/pip-wheels/
build/win-wheels/
build/tk-extract/
develop-eggs/
dist/
downloads/
@@ -16,6 +25,10 @@ parts/
sdist/
var/
wheels/
# …but keep pre-built Android wheels (pydantic-core cross-compiled for
# arm64-v8a / x86_64 / x86, required by the Chaquopy build)
!android/wheels/
!android/wheels/*
*.egg-info/
.installed.cfg
*.egg
+14 -1
View File
@@ -1,4 +1,4 @@
# Claude Instructions for WLED Screen Controller
# Claude Instructions for LedGrab
## Code Search
@@ -28,8 +28,21 @@ ast-index changed --base master # Show symbols changed in current bran
## Project Structure
- `/server` — Python FastAPI backend (see [server/CLAUDE.md](server/CLAUDE.md))
- `/android` — Android TV app (Kotlin shell + embedded Python via Chaquopy)
- `/contexts` — Context files for Claude (frontend conventions, graph editor, Chrome tools, server ops, demo mode)
## Android Dependency Sync (CRITICAL)
The Android app (`android/app/build.gradle.kts`) installs the server package with `--no-deps` and lists Android-compatible dependencies **explicitly** in the Chaquopy `pip {}` block. This is because `server/pyproject.toml` includes desktop-only packages (mss, psutil, sounddevice, etc.) that have no Android wheels.
**When adding a new dependency to `server/pyproject.toml`:**
1. If the package is **pure Python or has Chaquopy wheels** (check [Chaquopy PyPI](https://chaquo.com/pypi-13.1/)), also add it to `android/app/build.gradle.kts` in the `pip { install(...) }` block
2. If the package is **desktop-only** (native C/Rust extension without Android support), do NOT add it to `build.gradle.kts` — and guard its import with `try/except ImportError` in Python code
3. If unsure, check Chaquopy's package index first
**Incident context:** Chaquopy's pip runs on the build machine (Windows), not on Android. Platform markers like `sys_platform != 'linux'` evaluate against the BUILD host, not the target device. `pip install --exclude` does not exist. The only reliable way to exclude packages is to not list them.
## Context Files
| File | When to read |
+4 -4
View File
@@ -9,8 +9,8 @@
## Development Setup
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller-mixed/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
# Python environment
python -m venv venv
@@ -29,7 +29,7 @@ npm run build
cd server
export PYTHONPATH=$(pwd)/src # Linux/Mac
# set PYTHONPATH=%CD%\src # Windows
python -m wled_controller.main
python -m ledgrab.main
```
Open http://localhost:8080 to access the dashboard.
@@ -55,7 +55,7 @@ ruff check src/ tests/
## Frontend Changes
After modifying any file under `server/src/wled_controller/static/js/` or `static/css/`, rebuild the bundle:
After modifying any file under `server/src/ledgrab/static/js/` or `static/css/`, rebuild the bundle:
```bash
cd server
+29 -79
View File
@@ -1,15 +1,17 @@
# Installation Guide
Complete installation guide for LED Grab (WLED Screen Controller) server and Home Assistant integration.
Complete installation guide for the LedGrab server.
## Table of Contents
1. [Docker Installation (recommended)](#docker-installation)
2. [Manual Installation](#manual-installation)
3. [First-Time Setup](#first-time-setup)
4. [Home Assistant Integration](#home-assistant-integration)
5. [Configuration Reference](#configuration-reference)
6. [Troubleshooting](#troubleshooting)
4. [Configuration Reference](#configuration-reference)
5. [Troubleshooting](#troubleshooting)
> **Home Assistant integration** has moved to a separate repository:
> [ledgrab-haos-integration](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration)
---
@@ -20,8 +22,8 @@ The fastest way to get running. Requires [Docker](https://docs.docker.com/get-do
1. **Clone and start:**
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
docker compose up -d
```
@@ -54,7 +56,7 @@ cd server
docker build -t ledgrab .
docker run -d \
--name wled-screen-controller \
--name ledgrab \
-p 8080:8080 \
-v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \
@@ -84,8 +86,8 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
1. **Clone the repository:**
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
```
2. **Build the frontend bundle:**
@@ -95,7 +97,7 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
npm run build
```
This compiles TypeScript and bundles JS/CSS into `src/wled_controller/static/dist/`.
This compiles TypeScript and bundles JS/CSS into `src/ledgrab/static/dist/`.
3. **Create a virtual environment:**
@@ -131,11 +133,11 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
```bash
# Linux / macOS
export PYTHONPATH=$(pwd)/src
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
# Windows (cmd)
set PYTHONPATH=%CD%\src
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
```
6. **Verify:** open <http://localhost:8080> in your browser.
@@ -160,7 +162,7 @@ auth:
Option B -- set an environment variable:
```bash
export WLED_AUTH__API_KEYS__dev="your-secure-key-here"
export LEDGRAB_AUTH__API_KEYS__dev="your-secure-key-here"
```
Generate a random key:
@@ -184,7 +186,7 @@ server:
Or via environment variable:
```bash
WLED_SERVER__CORS_ORIGINS='["http://localhost:8080","http://192.168.1.100:8080"]'
LEDGRAB_SERVER__CORS_ORIGINS='["http://localhost:8080","http://192.168.1.100:8080"]'
```
### Discover devices
@@ -193,57 +195,12 @@ Open the dashboard and go to the **Devices** tab. Click **Discover** to find WLE
---
## Home Assistant Integration
### Option 1: HACS (recommended)
1. Install [HACS](https://hacs.xyz/docs/setup/download) if you have not already.
2. Open HACS in Home Assistant.
3. Click the three-dot menu, then **Custom repositories**.
4. Add URL: `https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed`
5. Set category to **Integration** and click **Add**.
6. Search for "WLED Screen Controller" in HACS and click **Download**.
7. Restart Home Assistant.
8. Go to **Settings > Devices & Services > Add Integration** and search for "WLED Screen Controller".
9. Enter your server URL (e.g., `http://192.168.1.100:8080`) and API key.
### Option 2: Manual
Copy the `custom_components/wled_screen_controller/` folder from this repository into your Home Assistant `config/custom_components/` directory, then restart Home Assistant and add the integration as above.
### Automation example
```yaml
automation:
- alias: "Start ambient lighting when TV turns on"
trigger:
- platform: state
entity_id: media_player.living_room_tv
to: "on"
action:
- service: switch.turn_on
target:
entity_id: switch.living_room_tv_processing
- alias: "Stop ambient lighting when TV turns off"
trigger:
- platform: state
entity_id: media_player.living_room_tv
to: "off"
action:
- service: switch.turn_off
target:
entity_id: switch.living_room_tv_processing
```
---
## Configuration Reference
The server reads configuration from three sources (in order of priority):
1. **Environment variables** -- prefix `WLED_`, double underscore as nesting delimiter (e.g., `WLED_SERVER__PORT=9090`)
2. **YAML config file** -- `server/config/default_config.yaml` (or set `WLED_CONFIG_PATH` to override)
1. **Environment variables** -- prefix `LEDGRAB_`, double underscore as nesting delimiter (e.g., `LEDGRAB_SERVER__PORT=9090`)
2. **YAML config file** -- `server/config/default_config.yaml` (or set `LEDGRAB_CONFIG_PATH` to override)
3. **Built-in defaults**
See [`server/.env.example`](server/.env.example) for every available variable with descriptions.
@@ -252,14 +209,14 @@ See [`server/.env.example`](server/.env.example) for every available variable wi
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `WLED_SERVER__PORT` | `8080` | HTTP listen port |
| `WLED_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `WLED_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) |
| `WLED_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) |
| `WLED_STORAGE__DATABASE_FILE` | `data/ledgrab.db` | SQLite database path |
| `WLED_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery |
| `WLED_MQTT__BROKER_HOST` | `localhost` | MQTT broker address |
| `WLED_DEMO` | `false` | Enable demo mode (sandbox with virtual devices) |
| `LEDGRAB_SERVER__PORT` | `8080` | HTTP listen port |
| `LEDGRAB_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `LEDGRAB_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) |
| `LEDGRAB_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) |
| `LEDGRAB_STORAGE__DATABASE_FILE` | `data/ledgrab.db` | SQLite database path |
| `LEDGRAB_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery |
| `LEDGRAB_MQTT__BROKER_HOST` | `localhost` | MQTT broker address |
| `LEDGRAB_DEMO` | `false` | Enable demo mode (sandbox with virtual devices) |
---
@@ -276,7 +233,7 @@ python --version # must be 3.11+
**Check the frontend bundle exists:**
```bash
ls server/src/wled_controller/static/dist/app.bundle.js
ls server/src/ledgrab/static/dist/app.bundle.js
```
If missing, run `cd server && npm ci && npm run build`.
@@ -288,7 +245,7 @@ If missing, run `cd server && npm ci && npm run build`.
docker compose logs -f
# Manual install
tail -f logs/wled_controller.log
tail -f logs/ledgrab.log
```
### Cannot access the dashboard from another machine
@@ -297,13 +254,6 @@ tail -f logs/wled_controller.log
2. Check your firewall allows inbound traffic on port 8080.
3. Add your server's LAN IP to `cors_origins` (see [Configure CORS](#configure-cors-for-lan-access) above).
### Home Assistant integration not appearing
1. Verify HACS installed the component: check that `config/custom_components/wled_screen_controller/` exists.
2. Clear your browser cache.
3. Restart Home Assistant.
4. Check logs at **Settings > System > Logs** and search for `wled_screen_controller`.
### WLED device not responding
1. Confirm the device is powered on and connected to Wi-Fi.
@@ -324,4 +274,4 @@ tail -f logs/wled_controller.log
- [API Documentation](docs/API.md)
- [Calibration Guide](docs/CALIBRATION.md)
- [Repository Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues)
- [Repository Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/issues)
+15 -19
View File
@@ -87,8 +87,8 @@ A Home Assistant integration exposes devices as entities for smart home automati
### Docker (recommended)
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
docker compose up -d
```
@@ -97,8 +97,8 @@ docker compose up -d
Requires Python 3.11+ and Node.js 18+.
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
# Build the frontend bundle
npm ci && npm run build
@@ -112,7 +112,7 @@ pip install .
# Start the server
export PYTHONPATH=$(pwd)/src # Linux/Mac
# set PYTHONPATH=%CD%\src # Windows
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
```
Open **http://localhost:8080** to access the dashboard.
@@ -125,17 +125,17 @@ See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, includin
Demo mode runs the server with virtual devices, sample data, and isolated storage — useful for exploring the UI without real hardware.
Set the `WLED_DEMO` environment variable to `true`, `1`, or `yes`:
Set the `LEDGRAB_DEMO` environment variable to `true`, `1`, or `yes`:
```bash
# Docker
docker compose run -e WLED_DEMO=true server
docker compose run -e LEDGRAB_DEMO=true server
# Python
WLED_DEMO=true uvicorn wled_controller.main:app --host 0.0.0.0 --port 8081
LEDGRAB_DEMO=true uvicorn ledgrab.main:app --host 0.0.0.0 --port 8081
# Windows (installed app)
set WLED_DEMO=true
set LEDGRAB_DEMO=true
LedGrab.bat
```
@@ -144,9 +144,9 @@ Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores
## Architecture
```text
wled-screen-controller/
ledgrab/
├── server/ # Python FastAPI backend
│ ├── src/wled_controller/
│ ├── src/ledgrab/
│ │ ├── main.py # Application entry point
│ │ ├── config.py # YAML + env var configuration
│ │ ├── api/
@@ -171,8 +171,6 @@ wled-screen-controller/
│ ├── tests/ # pytest suite
│ ├── Dockerfile
│ └── docker-compose.yml
├── custom_components/ # Home Assistant integration (HACS)
│ └── wled_screen_controller/
├── docs/
│ ├── API.md # REST API reference
│ └── CALIBRATION.md # LED calibration guide
@@ -182,7 +180,7 @@ wled-screen-controller/
## Configuration
Edit `server/config/default_config.yaml` or use environment variables with the `WLED_` prefix:
Edit `server/config/default_config.yaml` or use environment variables with the `LEDGRAB_` prefix:
```yaml
server:
@@ -200,11 +198,11 @@ storage:
logging:
format: "json"
file: "logs/wled_controller.log"
file: "logs/ledgrab.log"
max_size_mb: 100
```
Environment variable override example: `WLED_SERVER__PORT=9090`.
Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
## API
@@ -234,9 +232,7 @@ See [docs/CALIBRATION.md](docs/CALIBRATION.md) for a step-by-step guide.
## Home Assistant
Install via HACS (add as a custom repository) or manually copy `custom_components/wled_screen_controller/` into your HA config directory. The integration creates light, switch, sensor, and number entities for each configured device.
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
For Home Assistant integration, see the separate [ledgrab-haos-integration](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration) repository.
## Development
+14 -156
View File
@@ -1,171 +1,29 @@
## v0.3.0 (2026-04-08)
This release brings a major expansion of integrations and source types: Home Assistant (with light output targets), a unified Integrations tab, processed audio sources with 11 DSP filters, multi-instance MQTT, a game integration system, BindableFloat for universal value-source binding, and many new value source types. Plus a much-improved build and launcher on Windows.
### Features
#### Home Assistant Integration
- Home Assistant integration with WebSocket connection, automation conditions, and UI ([2153dde](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/2153dde))
- HA light output targets — cast LED colors to Home Assistant lights ([cb9289f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/cb9289f))
- Entity picker for HA light mapping — searchable EntitySelect for light entities ([324a308](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/324a308))
- HA light target live color preview — per-entity swatches via WebSocket ([40751fe](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/40751fe))
- HA source cards use health-dot indicators ([e7c9a56](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e7c9a56))
#### Integrations & Tabs
- New **Integrations** tab and responsive icon-only tabs ([b7da4ab](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b7da4ab))
- Multi-instance MQTT — refactored from global config to entity model ([c59107c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c59107c))
- Game integration system ([492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/492bdb9))
#### Audio
- Processed audio sources — audio filter framework ([86a9d34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/86a9d34))
- 11 audio filters implemented ([eb94066](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/eb94066))
- Processed audio source model + runtime filter integration ([353c090](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/353c090), [ab43578](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ab43578))
- Frontend audio processing templates + source type cleanup ([5534639](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5534639), [1ce0dc6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/1ce0dc6))
- Music sync viz modes and `auto_gain` audio filter ([b04978a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b04978a))
#### Value Sources
- **BindableFloat** — universal value source binding for all scalar properties ([8a17bb5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/8a17bb5))
- New value source types: HA entity, gradient map, strip extract ([384362c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/384362c))
- `system_metrics` value source type ([b6713be](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b6713be))
- Color value source test visualization ([f6c25cd](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f6c25cd))
- HA value source test — raw value axis + behavior IconSelect ([0a87371](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/0a87371))
- Value source card crosslinks + gradient_map test shows input value ([4b7a8d7](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/4b7a8d7))
#### Sources & Assets
- Asset-based image/video sources, notification sounds, UI improvements ([e2e1107](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e2e1107))
- `math_wave` color strip source type ([ace2471](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ace2471))
- `api_input` LED interpolation; fixes for LED preview, FPS charts, dashboard layout ([3e0bf85](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e0bf85))
- Custom file drop zone for asset upload modal ([f61a020](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f61a020))
#### UI & UX
- Card glare effect on dashboard and perf chart cards ([ce53ca6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce53ca6))
- System theme option + toast timer overlap fix ([db5008a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/db5008a))
- Donation banner, About tab, settings UI improvements ([f3d07fc](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f3d07fc))
- Custom app icon for shortcuts and installer ([5f70302](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5f70302))
#### Runtime
- Port busy check before starting the server ([ea812bb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ea812bb))
## v0.4.1 (2026-04-22)
### Bug Fixes
- Tray: replace tkinter messagebox with Win32 `MessageBoxW` ([d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d037a2e))
- Launcher: `start-hidden.vbs` must be ASCII + CRLF, use `python.exe` ([fc8ee34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fc8ee34))
- Launcher: set `PYTHONPATH` and `WLED_CONFIG_PATH` in `start-hidden.vbs` ([e262a8b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e262a8b))
- Weather CSS card shows empty source name after hard refresh ([6e8b159](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6e8b159))
- Replace HA test icon with refresh; make automation rules collapsible ([edc6d27](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/edc6d27))
- `pystray` is a core dependency on Windows (no longer optional extra) ([99460a8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/99460a8))
- Audio tree structure, filter i18n, IconSelect for filter options ([af2c89c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/af2c89c))
- Reference check before deleting audio processing template ([d04192f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d04192f))
- Device card header layout — URL badge overflow and hide button gap ([11d5d6b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/11d5d6b))
- KC color strip test preview uses `LiveStreamManager` instead of raw engine ([a9e6e8c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a9e6e8c))
- Composite layer opacity/brightness widgets + CSS layout ([78ce6c8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/78ce6c8))
- HA light target — brightness source, `transition=0`, dashboard type label ([381ee75](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/381ee75))
- Rename HA Lights → Home Assistant, HA Light Targets → Light Targets ([89d1b13](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89d1b13))
- Command palette actions and automation condition button ([c0853ce](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c0853ce))
- Show template name instead of ID in filter list and card badges ([be4c98b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/be4c98b))
- Clip graph node title and subtitle to prevent overflow ([dca2d21](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/dca2d21))
- Replace emoji with SVG icons on weather and daylight cards ([53986f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/53986f8))
- Send `gradient_id` instead of palette in effect transient preview ([a4a9f6f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a4a9f6f))
- Installer now bundles `cryptography` and `just-playback`, sets the `TCL` environment for Tk, and removes the stale `debug.bat` shim ([4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c))
---
### Development / Internal
#### Build
- Drop `packaging` dependency, inline version parsing ([d4ffe2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d4ffe2e))
- Fix shell syntax error in `smoke_test_imports` heredoc ([feb91ad](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/feb91ad))
- Keep `.py` sources; smoke test skips uninstalled modules ([17c5c02](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/17c5c02))
- Stop stripping `zeroconf/_services`; add import smoke test ([fd6776a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fd6776a))
- Stop stripping `numpy.lib`/`linalg` from site-packages ([9f34ffb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9f34ffb))
- Normalize non-PEP440 versions, fix `.py`/`compileall` ordering, wipe NSIS payload dirs ([b5842e6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b5842e6))
#### CI/Build
- Scope the Android keystore env correctly and fail loudly when a release build is attempted without a signing key ([35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2))
#### CI
- Add manual build workflow for testing artifacts ([fb98e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fb98e6e))
- Use sparse checkout for release notes in release workflow ([9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9fcfdb8))
#### Refactoring
- Split `color-strips.ts` into focused modules under `color-strips/` folder ([7a9c368](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/7a9c368))
- Key colors targets → CSS source type; HA target improvements ([3e6760f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e6760f))
- Move Weather and Home Assistant sources to Integrations tree group ([3c2efd5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3c2efd5))
#### Tests
- Isolate tests from production database ([992495e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/992495e))
- Processed audio sources: phase 7 testing and polish + phase 8 design review ([ce1f484](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce1f484), [6b0e4e5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6b0e4e5))
#### Chores
- Remove processed-audio-sources plan files ([89990f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89990f8))
- Remove python3.11 version pin from pre-commit config ([f345687](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f345687))
#### Documentation
- Drop the stale WLED-rename task and document the Android signing secrets ([a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3))
- Remove WLED-specific language from the auto-generated release notes template ([4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d))
---
<details>
<summary>All Commits (63)</summary>
<summary>All Commits</summary>
| Hash | Message |
|------|---------|
| [d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d037a2e) | fix(tray): replace tkinter messagebox with Win32 MessageBoxW |
| [fc8ee34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fc8ee34) | fix(launcher): start-hidden.vbs must be ASCII + CRLF, use python.exe |
| [e262a8b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e262a8b) | fix(launcher): set PYTHONPATH and WLED_CONFIG_PATH in start-hidden.vbs |
| [d4ffe2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d4ffe2e) | refactor: drop packaging dependency, inline version parsing |
| [feb91ad](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/feb91ad) | fix(build): fix shell syntax error in smoke_test_imports heredoc |
| [17c5c02](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/17c5c02) | fix(build): keep .py sources + make smoke test skip uninstalled modules |
| [fd6776a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fd6776a) | fix(build): stop stripping zeroconf/_services + add import smoke test |
| [9f34ffb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9f34ffb) | fix(build): stop stripping numpy.lib/linalg from site-packages |
| [b5842e6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b5842e6) | fix(build): normalize non-PEP440 versions, fix .py/compileall ordering, wipe NSIS payload dirs |
| [7a9c368](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/7a9c368) | refactor: split color-strips.ts into focused modules under color-strips/ folder |
| [ce53ca6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce53ca6) | feat: add card glare effect to dashboard and perf chart cards |
| [b04978a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b04978a) | feat: add music sync viz modes and auto_gain audio filter |
| [6e8b159](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6e8b159) | fix: weather CSS card shows empty source name after hard refresh |
| [ace2471](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ace2471) | feat: add math_wave color strip source type |
| [edc6d27](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/edc6d27) | fix: replace HA test icon with refresh, make automation rules collapsible |
| [b7da4ab](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b7da4ab) | feat: add Integrations tab and responsive icon-only tabs |
| [99460a8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/99460a8) | fix: make pystray a core dependency on Windows instead of optional extra |
| [89990f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89990f8) | chore: remove processed-audio-sources plan files |
| [af2c89c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/af2c89c) | fix: audio tree structure, filter i18n, and IconSelect for filter options |
| [d04192f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d04192f) | fix: add reference check before deleting audio processing template |
| [992495e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/992495e) | fix: isolate tests from production database |
| [6b0e4e5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6b0e4e5) | feat(processed-audio-sources): phase 8 - frontend design consistency review |
| [ce1f484](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce1f484) | feat(processed-audio-sources): phase 7 - testing and polish |
| [1ce0dc6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/1ce0dc6) | feat(processed-audio-sources): phase 6 - frontend source type cleanup |
| [5534639](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5534639) | feat(processed-audio-sources): phase 5 - frontend audio processing templates |
| [ab43578](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ab43578) | feat(processed-audio-sources): phase 4 - runtime filter integration |
| [353c090](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/353c090) | feat(processed-audio-sources): phase 3 - processed audio source model |
| [eb94066](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/eb94066) | feat(processed-audio-sources): phase 2 - implement 11 audio filters |
| [86a9d34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/86a9d34) | feat(processed-audio-sources): phase 1 - audio filter framework |
| [c59107c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c59107c) | feat: refactor MQTT from global config to multi-instance entity model |
| [e7c9a56](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e7c9a56) | feat: HA source cards use health-dot indicators |
| [492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/492bdb9) | feat: game integration system |
| [b6713be](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b6713be) | feat: system_metrics value source type |
| [db5008a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/db5008a) | feat: system theme option + fix toast timer overlap |
| [4b7a8d7](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/4b7a8d7) | feat: value source card crosslinks + gradient_map test shows input value |
| [f6c25cd](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f6c25cd) | feat: color value source test visualization |
| [0a87371](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/0a87371) | feat: HA value source test — raw value axis + behavior IconSelect |
| [11d5d6b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/11d5d6b) | fix: device card header layout — URL badge overflow and hide button gap |
| [384362c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/384362c) | feat: new value source types (HA entity, gradient map, strip extract) + UI fixes |
| [ea812bb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ea812bb) | feat: check if port is busy before starting the server |
| [a9e6e8c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a9e6e8c) | fix: KC color strip test preview — use LiveStreamManager instead of raw engine |
| [78ce6c8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/78ce6c8) | fix: composite layer opacity/brightness widgets + CSS layout |
| [8a17bb5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/8a17bb5) | feat: BindableFloat — universal value source binding for all scalar properties |
| [5f70302](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5f70302) | feat: use custom app icon for shortcuts and installer |
| [40751fe](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/40751fe) | feat: HA light target live color preview — per-entity swatches via WebSocket |
| [381ee75](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/381ee75) | fix: HA light target — brightness source, transition=0, dashboard type label |
| [3e6760f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e6760f) | refactor: key colors targets → CSS source type, HA target improvements |
| [89d1b13](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89d1b13) | fix: rename HA Lights → Home Assistant, HA Light Targets → Light Targets |
| [324a308](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/324a308) | feat: entity picker for HA light mapping — searchable EntitySelect for light entities |
| [cb9289f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/cb9289f) | feat: HA light output targets — cast LED colors to Home Assistant lights |
| [fb98e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fb98e6e) | ci: add manual build workflow for testing artifacts |
| [3c2efd5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3c2efd5) | refactor: move Weather and Home Assistant sources to Integrations tree group |
| [2153dde](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/2153dde) | feat: Home Assistant integration — WebSocket connection, automation conditions, UI |
| [f3d07fc](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f3d07fc) | feat: donation banner, About tab, settings UI improvements |
| [f61a020](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f61a020) | feat: custom file drop zone for asset upload modal; fix review issues |
| [f345687](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f345687) | chore: remove python3.11 version pin from pre-commit config |
| [e2e1107](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e2e1107) | feat: asset-based image/video sources, notification sounds, UI improvements |
| [c0853ce](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c0853ce) | fix: improve command palette actions and automation condition button |
| [3e0bf85](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e0bf85) | feat: add api_input LED interpolation; fix LED preview, FPS charts, dashboard layout |
| [be4c98b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/be4c98b) | fix: show template name instead of ID in filter list and card badges |
| [dca2d21](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/dca2d21) | fix: clip graph node title and subtitle to prevent overflow |
| [53986f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/53986f8) | fix: replace emoji with SVG icons on weather and daylight cards |
| [a4a9f6f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a4a9f6f) | fix: send gradient_id instead of palette in effect transient preview |
| [9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9fcfdb8) | ci: use sparse checkout for release notes in release workflow |
| Hash | Message | Author |
|------|---------|--------|
| [4f7794c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4f7794c) | fix(installer): bundle cryptography + just-playback, set TCL env, clean stale debug.bat | alexei.dolgolyov |
| [a0d63a3](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a0d63a3) | docs(release): drop stale WLED-rename task, document android signing secrets | alexei.dolgolyov |
| [35b75a2](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/35b75a2) | ci(android): fix keystore env scoping, fail loudly on release without key | alexei.dolgolyov |
| [4ed099d](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4ed099d) | docs(release): drop WLED-specific language from auto-generated release notes | alexei.dolgolyov |
</details>
-116
View File
@@ -1,116 +0,0 @@
# TODO
## IMPORTANT: Remove WLED naming throughout the app
- [ ] Rename all references to "WLED" in user-facing strings, class names, module names, config keys, file paths, and documentation
- [ ] The app is **LedGrab** — not tied to WLED specifically. WLED is just one of many supported output protocols
- [ ] Audit: i18n keys, page titles, tray labels, installer text, pyproject.toml description, README, CLAUDE.md, context files, API docs
- [ ] Rename `wled_controller` package → decide on new package name (e.g. `ledgrab`)
- [ ] Update import paths, entry points, config references, build scripts, Docker, CI/CD
- [ ] **Migration required** if renaming storage paths or config keys (see data migration policy in CLAUDE.md)
---
## Donation / Open-Source Banner
- [x] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
- [x] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
- [x] Remember dismissal in localStorage so it doesn't reappear every session
- [x] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
- [ ] Configure `DONATE_URL` and `REPO_URL` constants in `donation.ts` once platform is chosen
---
# Color Strip Source Improvements
## New Source Types
- [x] **`weather`** — Weather-reactive ambient: maps weather conditions (rain, snow, clear, storm) to colors/animations via API
- [ ] **`music_sync`** — Beat-synced patterns: BPM detection, energy envelope, drop detection (higher-level than raw `audio`)
- [ ] **`math_wave`** — Mathematical wave generator: user-defined sine/triangle/sawtooth expressions, superposition
- [ ] **`text_scroll`** — Scrolling text marquee: bitmap font rendering, static text or RSS/API data source *(delayed)*
### Discuss: `home_assistant`
Need to research HAOS communication options first (WebSocket API, REST API, MQTT, etc.) before deciding scope.
### Deferred
- `image` — Static image sampler *(not now)*
- `clock` — Time display *(not now)*
## Improvements to Existing Sources
### `effect` (now 12 types)
- [x] Add effects: rain, comet, bouncing ball, fireworks, sparkle rain, lava lamp, wave interference
- [x] Custom palette support: user-defined [[pos,R,G,B],...] stops via JSON textarea
### `gradient`
- [x] Noise-perturbed gradient: value noise displacement on stop positions (`noise_perturb` animation type)
- [x] Gradient hue rotation: `hue_rotate` animation type — preserves S/V, rotates H
- [x] Easing functions between stops: linear, ease_in_out (smoothstep), step, cubic
### `audio`
- [x] New audio source type: band extractor (bass/mid/treble split) — responsibility of audio source layer, not CSS
- [ ] Peak hold indicator: global option on audio source (not per-mode), configurable decay time
### `daylight`
- [x] Longitude support for accurate solar position (NOAA solar equations)
- [x] Season awareness (day-of-year drives sunrise/sunset via solar declination)
### `candlelight`
- [x] Wind simulation: correlated flicker bursts across all candles (wind_strength 0.0-2.0)
- [x] Candle type presets: taper (steady), votive (flickery), bonfire (chaotic) — applied at render time
- [x] Wax drip effect: localized brightness dips with fade-in/fade-out recovery
### `composite`
- [ ] Allow nested composites (with cycle detection)
- [x] More blend modes: overlay, soft light, hard light, difference, exclusion
- [x] Per-layer LED range masks (optional start/end/reverse on each composite layer)
### `notification`
- [x] Chase effect (light bounces across strip with glowing tail)
- [x] Gradient flash (bright center fades to edges, exponential decay)
- [x] Queue priority levels (color_override = high priority, interrupts current)
### `api_input`
- [x] ~~Crossfade transition~~ — won't do: external client owns temporal transitions; crossfading on our side would double-smooth
- [x] Interpolation when incoming LED count differs from strip count (linear/nearest/none modes)
- [x] Last-write-wins from any client — already the default behavior (push overwrites buffer)
## Architectural / Pipeline
### Processing Templates (CSPT)
- [x] HSL shift filter (hue rotation + lightness adjustment)
- [x] ~~Color temperature filter~~ — already exists as `color_correction`
- [x] Contrast filter
- [x] ~~Saturation filter~~ — already exists
- [x] ~~Pixelation filter~~ — already exists as `pixelate`
- [x] Temporal blur filter (blend frames over time)
### Transition Engine
Needs deeper design discussion. Likely a new entity type `ColorStripSourceTransition` that defines how source switches happen (crossfade, wipe, etc.). Interacts with automations when they switch a target's active source.
### Deferred
- Global BPM sync *(not sure)*
- Recording/playback *(not now)*
- Source preview in editor modal *(not needed — overlay preview on devices is sufficient)*
---
## Remaining Open Discussion
1. ~~**`home_assistant` source** — Need to research HAOS communication protocols first~~ **DONE** — WebSocket API chosen, connection layer + automation condition + UI implemented
2. **Transition engine** — Design as `ColorStripSourceTransition` entity: what transition types? (crossfade, wipe, dissolve?) How does a target reference its transition config? How do automations trigger it?
3. **Home Assistant output targets** — Investigate casting LED colors TO Home Assistant lights (reverse direction). Use HA `light.turn_on` service call with `rgb_color` via WebSocket API. Could enable: ambient lighting on HA-controlled bulbs (Hue, WLED via HA, Zigbee lights), room-by-room color sync, whole-home ambient scenes. Need to research: rate limiting (don't spam HA with 30fps updates), grouping multiple lights, brightness/color_temp mapping, transition parameter support.
+54
View File
@@ -0,0 +1,54 @@
# TODO
## Donation / Open-Source Banner
- [x] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
- [x] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
- [x] Remember dismissal in localStorage so it doesn't reappear every session
- [x] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
- [ ] Configure `DONATE_URL` and `REPO_URL` constants in `donation.ts` once platform is chosen
## Android Signing Secrets (Gitea)
The CI workflow `build-android.yml` produces a signed release APK **only** when all four secrets below are configured in Gitea → Settings → Secrets. When any one is missing, the "Guard release tag against missing keystore" step hard-fails a `v*` tag build — previously we silently shipped a debug-signed APK labeled as release.
| Secret | Contents |
| --- | --- |
| `ANDROID_KEYSTORE_BASE64` | Output of `base64 -w0 release.jks` — the whole keystore as one line |
| `ANDROID_KEYSTORE_PASSWORD` | Keystore password (the `-storepass` passed to `keytool`) |
| `ANDROID_KEY_ALIAS` | Key alias (e.g. `ledgrab-release`) |
| `ANDROID_KEY_PASSWORD` | Key password (can be the same as keystore password) |
### Generate the keystore (one-time, ~2 min)
```bash
keytool -genkeypair -v \
-storetype JKS \
-keystore release.jks \
-alias ledgrab-release \
-keyalg RSA -keysize 4096 \
-validity 9125 \
-dname "CN=LedGrab, O=Dolgolyov, C=BY"
base64 -w0 release.jks > release.jks.b64 # Linux / Git Bash
# Windows alternative:
# certutil -encode release.jks release.jks.b64
# (strip the -----BEGIN/END CERTIFICATE----- header/footer lines)
```
### Critical — back up `release.jks` outside the repo
- 1Password attachment, encrypted USB stick, or printed hex + password written down somewhere physical.
- Losing the keystore = every existing sideloaded install is permanently unable to upgrade. The only workaround is uninstall-then-reinstall, which wipes user data.
- The `release.jks` file itself must **never** be committed to git. Only the base64 string lives in Gitea secrets.
### Why it matters even without Play Store
Android's package manager refuses to install an upgrade whose signature differs from the currently-installed APK's signature — enforced by the OS, not Play. So once users install a build signed by key X, every future build they can upgrade to must also be signed by key X.
### Current state
- [ ] Generate `release.jks` with `keytool` (above) and back it up
- [ ] Upload the four secrets to Gitea
- [ ] Tag a throwaway `v0.4.1-test` to verify signed release APK is produced (then delete the tag + release)
- [ ] Note: any existing `v0.4.0` debug-signed install cannot upgrade to a release-signed v0.4.1 — users must uninstall first
+128
View File
@@ -0,0 +1,128 @@
# LedGrab TODO
## BLE LED Controller Support (SP110E / Triones / Zengge / Govee)
Add support for Bluetooth Low Energy LED controllers driven by mobile apps like "LED Hue", HappyLighting, iLightsIn. Whole-strip ambient-color output only — these protocols don't support per-pixel streaming.
- [x] Add `bleak>=0.22` as optional extra `[ble]` in `server/pyproject.toml` (desktop-only, NOT in android `build.gradle.kts`)
- [x] `core/devices/ble_transport.py` — bleak wrapper: scan, connect, write-with/without-response
- [x] `core/devices/ble_protocols/` package
- [x] `__init__.py``BLEProtocol` dataclass + registry (family → encoder)
- [x] `sp110e.py` — SP110E / SP108E (service FFE0, char FFE1, `RR GG BB 00 1E` static-color frame)
- [x] `triones.py` — Triones / HappyLighting / LEDnet (service FFE5, char FFE9, `7E 07 05 03 RR GG BB 10 EF`)
- [x] `zengge.py` — Zengge / iLightsIn (service FFE0, framing `56 RR GG BB 00 F0 AA`)
- [x] `govee.py` — Govee unencrypted framed protocol (AES keyed variants — marked experimental)
- [x] `core/devices/ble_client.py` — unified `BLEClient(LEDClient)` — picks protocol by `ble_family`, averages strip → one color, drops duplicate frames, rate-limits to BLE connection interval
- [x] `core/devices/ble_provider.py``BLEDeviceProvider` + discovery via `BleakScanner`
- [x] Register in `core/devices/led_client.py::_register_builtin_providers` (guarded `try/except ImportError`)
- [x] Storage: `ble_family`, `ble_govee_key` fields threaded through `Device.__init__`/`to_dict`/`from_dict`/`_UPDATABLE_FIELDS`/`create_device`
- [x] Schemas: BLE fields on `DeviceCreate`, `DeviceUpdate`, `DeviceResponse`
- [x] Routes: BLE fields propagated through create/update in `api/routes/devices.py` + `_device_to_response`
- [x] ProcessorManager: `ble_family`/`ble_govee_key` added to `_DEVICE_FIELD_DEFAULTS` and `DeviceInfo`; passed through `wled_target_processor.py` and `group_client.py` to `create_led_client`
- [x] Tests: 21 protocol encoder unit tests + 16 BLEClient fake-transport tests — all passing, 814 total tests still green
- [x] Frontend: BLE option in the device type picker with a bluetooth Lucide icon; add-device modal shows a 4-option `IconSelect` for protocol family (SP110E / Triones / Zengge / Govee) with a Govee-only AES key field that auto-hides for the other three families; URL label/placeholder/hint adapt to `ble://<address>` pattern; submit payload carries `ble_family` (+ optional `ble_govee_key`); clone flow pre-fills family and key; modal dirty-check snapshots the new fields; network scan button now also discovers BLE peripherals via the existing `/api/v1/devices/discover?device_type=ble` endpoint
- [x] Frontend: `isBleDevice` helper in `core/api.ts`; `ICON_BLUETOOTH` + `ICON_LIGHTBULB` constants in `core/icons.ts`; `bluetooth` path in `core/icon-paths.ts`; i18n keys in `en.json` / `ru.json` / `zh.json`; TypeScript compiles; esbuild bundle rebuilt
- [x] Android BLE via Kotlin bridge — `BleBridge.kt` singleton (scan/connect/write/disconnect); `android_ble_transport.py` Python wrapper; `make_transport()` factory in `ble_transport.py` auto-selects backend; `BleBridge.init()` called from `LedGrabApp.onCreate`; BLE permissions in `AndroidManifest.xml`
- [x] Govee per-model AES key — `_encrypt_govee_frame()` in `ble_client.py` uses AES-128-ECB from `cryptography`; key validated on `BLEClient` construction; applied to both `send_pixels` and `set_power`; 8 new AES unit tests
## Android — Restore Multi-ABI Wheels
During emulator testing, we switched the build to **x86 only** (see `android/app/build.gradle.kts` `abiFilters`) to avoid having to keep the arm64-v8a / x86_64 pydantic-core wheels current. Before shipping, restore all three ABIs:
- [x] Rebuild `pydantic-core` wheels for all three ABIs with the current SOABI + libpython linking settings (`android/build-scripts/build-pydantic-core.sh` — now supports `arm64`, `x86_64`, `x86` args; defaults to all three).
- [x] Verify wheels: all three now list `libpython3.11.so` in `NEEDED` (`llvm-readelf -d`), automated in the build script.
- [x] Restored `abiFilters += listOf("arm64-v8a", "x86_64", "x86")` in `build.gradle.kts`. Multi-ABI debug APK builds cleanly (~99 MB).
- [ ] Re-test on real ARM64 Android TV hardware (still pending — only emulator-verified build).
Build cache + scripts live in `android/build-scripts/` and `android/.build-cache/` (junction host + sysconfigdata for each ABI).
## Android CI Pipeline
Build the Android APK automatically on push/tag.
- [x] Generate Gradle wrapper (`gradlew`) and commit it
- [x] Create CI workflow (`.gitea/workflows/build-android.yml`)
- JDK 17 + Android SDK + NDK setup
- Python 3.11 for Chaquopy build
- Recreate the directory junction via `ln -s` on Linux CI
- `./gradlew assembleDebug` on master push, `assembleRelease` on `v*` tags (if signing secrets set)
- Uploads APK as CI artifact; attaches to Gitea release on tag push
- [x] Commit pre-built pydantic-core wheels to `android/wheels/` (arm64, x86, x86_64)
- [x] APK signing for release builds — conditional signing config reads keystore from env vars (`ANDROID_KEYSTORE_PATH/_PASSWORD/_ALIAS/_KEY_PASSWORD`), falls back to debug signing locally
- [ ] Provision a real keystore and add the four CI secrets:
- `ANDROID_KEYSTORE_BASE64` (base64-encoded .jks)
- `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS`
- `ANDROID_KEY_PASSWORD`
- [ ] Add `LedGrab-{tag}-android-release.apk` row to the release description table in `.gitea/workflows/release.yml``create-release` job
- [ ] Verify the CI workflow passes end-to-end with the now-restored multi-ABI build (larger APK, longer Android build step)
## Android Root Capture (No Permission Dialog, No System Indicator)
MediaProjection shows a mandatory system overlay/indicator while capturing — unavoidable on stock Android. Many cheap Android TV boxes ship pre-rooted, so an alternative root-only path gives much better UX.
- [x] Root detection — `Root.kt` checks common `su` binary paths and, on demand, runs `su -c id` to actually prove UID 0. First call triggers Magisk's grant dialog; grant is cached per session. Exposed to Python via Chaquopy.
- [x] `RootScreenrecord.kt` — spawns `su -c screenrecord --output-format=h264 --size=WxH -`, feeds the H.264 stdout through a MediaCodec decoder whose output Surface is wired into an ImageReader (RGBA_8888, row-stride-aware). Decoded frames reach the Python pipeline via `PythonBridge.pushRootFrame`.
- [x] Python-side `RootScreenrecordEngine` (`core/capture_engines/root_screenrecord_engine.py`) mirrors `MediaProjectionEngine` with `ENGINE_PRIORITY=110` (> MediaProjection's 100) so the factory picks it automatically when available.
- [x] `MainActivity` tries `Root.requestGrant()` before launching the MediaProjection consent flow — on rooted devices the consent dialog is skipped entirely. `CaptureService` has a `createRootIntent()` entry point that bypasses the MediaProjection path.
- [x] Fallback: if `Root.requestGrant()` returns false (no root, user denied, or `su` timeout) the existing MediaProjection flow runs unchanged.
- [ ] Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) grant dialog appears once, (2) frames actually flow through MediaCodec without the Android 14 capture indicator showing, (3) stop/start cycle terminates the `su` process cleanly.
- [WONTDO] `SurfaceControl.screenshot()` via reflection — renamed/moved across API 28/29/30/33, hidden-API blocklist varies by release, even rooted apps hit it; days of maintenance for a marginal latency win over the screenrecord path. Not worth it.
- [WONTDO] `adb screencap` fallback — full-PNG-per-frame pipeline is slower than MediaProjection, no value as a last resort.
Known projects using the screenrecord approach for reference: scrcpy (over ADB), scrcpy-hidden-api, shizuku.
## Android Autostart on Boot
Boot-time, zero-interaction startup so LedGrab always has display capture and control on rooted TV boxes.
- [x] Manifest: declare `RECEIVE_BOOT_COMPLETED`, `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`, `WAKE_LOCK`; register `.BootReceiver` for `BOOT_COMPLETED` / `LOCKED_BOOT_COMPLETED` / `MY_PACKAGE_REPLACED`.
- [x] `BootReceiver.kt` — gated by `AutostartPrefs` + `Root.looksRooted()`; dispatches `CaptureService.createRootIntent()` via `ContextCompat.startForegroundService`. Unrooted devices are a no-op because MediaProjection consent cannot be bypassed silently.
- [x] `AutostartPrefs.kt` — thin SharedPreferences wrapper, defaults to enabled. Shown as a CheckBox on the stopped panel; greyed out on unrooted devices.
- [x] `CaptureService` returns `START_REDELIVER_INTENT` for root-mode intents so the OS can cleanly restart the service after being killed (token-free path). MediaProjection-mode keeps `START_NOT_STICKY` — restart is pointless with a dead consent token.
- [x] `isRunning` race: moved assignment to after `startForeground` succeeds, resets on exception; `onStartCommand` wraps `startForeground` in try/catch and stops the service cleanly if the FG transition fails.
- [x] Root-capture watchdog: coroutine on `serviceScope` checks `RootScreenrecord.framesDelivered` every 5s after a 5s grace. Respawns the pipeline (reusing the existing Python bridge) on stall, caps at 3 consecutive restarts before giving up.
- [x] `RootScreenrecord.framesDelivered` exposed as a property backed by `AtomicInteger` (was `@Volatile var framesDelivered = 0` with non-atomic `+= 1`).
- [x] `ScreenCapture` accepts `onProjectionStopped` lambda — `MediaProjection.Callback.onStop` now tears the whole service down instead of leaving a stale FG notification.
- [x] `MainActivity` wires the autostart toggle to `AutostartPrefs`; enabling it prompts `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` so Doze doesn't kill the FG service on phones.
- [x] `versionCode` derived from `git rev-list --count HEAD` (or `ANDROID_VERSION_CODE` env var in CI). Was stuck at 1 — sideload updates were silently refusing to install.
- [ ] Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) boot-time autostart dispatches the service without UI, (2) capture indicator still absent under root mode post-reboot, (3) watchdog respawns the pipeline when `screenrecord` is externally killed, (4) sideload upgrade installs cleanly after the versionCode bump.
- [ ] Optional follow-up: "kiosk" mode — add `<category android:name="android.intent.category.HOME" />` to `MainActivity` so power users can set LedGrab as the default TV launcher for truly always-running behavior.
## Android USB Serial Support
Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV box via USB-to-serial adapters.
- [x] Added `com.github.mik3y:usb-serial-for-android:3.8.1` (via JitPack) to `android/app/build.gradle.kts`.
- [x] Kotlin `UsbSerialBridge` singleton (`android/app/src/main/java/com/ledgrab/android/UsbSerialBridge.kt`) — exposes `listDevices()`, `open(vid, pid, serial, baud)`, `write(handle, ByteArray)`, `close(handle)`. Permission request fires automatically from `open()` when the user hasn't granted access yet. Handles are opaque integers, port map is synchronized, so Python threads can share one bridge.
- [x] Python `AndroidSerialTransport` in `server/src/ledgrab/core/devices/android_serial_transport.py` drives the bridge through Chaquopy. `SerialTransport` Protocol + `PySerialTransport` + `list_serial_ports()` factory live in `serial_transport.py`; `AdalightClient` and `SerialDeviceProvider` now go through the abstraction instead of importing `pyserial` directly.
- [x] URL scheme extended: `usb:VID:PID[:serial][@baud]` on Android alongside the existing `COM3[:baud]` / `/dev/ttyUSB0[:baud]` desktop paths.
- [x] App initializes the bridge on startup (`LedGrabApp.onCreate``UsbSerialBridge.init(this)`); manifest declares `uses-feature android.hardware.usb.host`.
- [ ] Real-device test pending — no USB-serial hardware on dev machine. Need to verify on a TV box with CH340, CP2102, or FTDI adapter.
- [ ] Document supported USB LED controllers in README (once real-device test passes).
- [ ] Optional: auto-launch the app when a known USB-serial adapter is plugged in (intent-filter on `USB_DEVICE_ATTACHED` + `res/xml/device_filter.xml`). Skipped in v1 — users can just open LedGrab and hit "Discover".
- [x] ESP-NOW client (`espnow_client.py` / `espnow_provider.py`) now routes through `SerialTransport``open_transport()` for the gateway serial link, `list_serial_ports()` + `port_exists()` for discovery/validation. Works transparently with `usb:VID:PID` URLs on Android. (Gateway protocol is write-only, so no `read()` extension was needed after all.)
## Performance Metrics Abstraction
- [x] `MetricsProvider` protocol + dataclass DTOs (`MemorySnapshot`, `ProcessSnapshot`) live in `server/src/ledgrab/utils/metrics/types.py`. Each provider has its own module: `psutil_provider.py`, `null_provider.py`, `android_provider.py`.
- [x] Factory `get_metrics_provider()` in `utils/metrics/__init__.py` selects Android → psutil → Null. `psutil` import is now confined to one place.
- [x] `api/routes/system.py` and `core/processing/metrics_history.py` use the provider; no more `if psutil is not None` guards in the hot paths.
- [x] Android `/proc`-backed provider implemented (`/proc/stat`, `/proc/meminfo`, `/proc/self/stat`, `/proc/self/status`). Carries previous-sample state for delta-based CPU%; degrades to zeros if any `/proc` file is locked down. 12 unit tests cover both desktop and Android paths.
## Android Performance Metrics — Future Enhancements
Beyond the `/proc`-based AndroidMetricsProvider that's now in place:
- [x] Device battery + thermal-zone readings (`/sys/class/power_supply/battery/{capacity,temp}`, `/sys/class/thermal/thermal_zone*/temp` filtered by zone type). Surfaced through `MetricsProvider.thermals()`, `PerformanceResponse.{cpu_temp_c,battery_percent,battery_temp_c}`, the metrics-history snapshot, and a new dashboard temperature chart that hides itself when the backend reports null. GPU card now hides (no "unavailable" placeholder) when no GPU is present.
- [WONTDO] Optional: app-specific memory via `Debug.getMemoryInfo()` through a Kotlin → Python Chaquopy bridge (more accurate than `VmRSS` for split-app-process accounting)
- [WONTDO] Optional: GPU usage via `/sys/class/kgsl/kgsl-3d0/gpubusy` on Adreno, Mali-specific paths for Mali GPUs
## Refactor: Per-Provider Device Configs
Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated union of typed per-provider config dataclasses. Full plan: [docs/plans/device-typed-configs.md](docs/plans/device-typed-configs.md).
- [x] Phase 1 — `DeviceConfig` hierarchy + `Device.to_config()` (non-breaking, additive only)
- [x] Phases 2+3 — narrow `LEDDeviceProvider.create_client` to typed configs; migrate 3 call sites; delete `DeviceInfo` + `_get_device_info` + `_DEVICE_FIELD_DEFAULTS` (single PR)
- [x] Phase 4 — migrate `tests/test_group_device.py` to `GroupConfig`/`ProviderDeps`; remove legacy `GroupLEDClient` init path; 47-test config suite with 100% coverage on `device_config.py`
- [ ] Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in `api/schemas/devices.py`; scope frontend POST/PATCH payloads by `device_type`
+20
View File
@@ -0,0 +1,20 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/app/build
/captures
.externalNativeBuild
.cxx
local.properties
# Chaquopy build cache
.build-cache/
# Python source junction (points at ../server/src/ledgrab — do not commit)
/app/src/main/python/ledgrab
# Signing keystore decoded from CI secrets
/keystore/
+183
View File
@@ -0,0 +1,183 @@
import java.util.concurrent.TimeUnit
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.chaquo.python")
}
// Derive versionCode from git commit count so sideload/Play upgrades
// always see a strictly-greater value without anyone remembering to
// bump by hand. ANDROID_VERSION_CODE env var wins for CI pipelines
// that prefer explicit values; fallback is `1` when neither is
// available (e.g. building from a tarball with no .git).
val ledgrabVersionCode: Int = run {
System.getenv("ANDROID_VERSION_CODE")?.toIntOrNull()?.let { return@run it }
try {
val process = ProcessBuilder("git", "rev-list", "--count", "HEAD")
.directory(rootDir)
.redirectErrorStream(true)
.start()
if (process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0) {
process.inputStream.bufferedReader().readText().trim().toIntOrNull() ?: 1
} else {
1
}
} catch (_: Exception) {
1
}
}
android {
namespace = "com.ledgrab.android"
compileSdk = 34
defaultConfig {
applicationId = "com.ledgrab.android"
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
targetSdk = 34
// Derived from git commit count (or ANDROID_VERSION_CODE env var
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.4.1"
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
// emulators), x86 (legacy emulators). Wheels in android/wheels/
// must be kept in sync — see build-scripts/build-pydantic-core.sh.
abiFilters += listOf("arm64-v8a", "x86_64", "x86")
}
}
// Per-ABI APK splits — reduces download size by ~60% vs universal APK.
// Each split contains only one native ABI's shared libraries + wheels.
splits {
abi {
isEnable = true
reset()
include("arm64-v8a", "x86_64", "x86")
isUniversalApk = true // also produce a fat APK for sideloading
}
}
// Signing config from env vars (CI) — only registered when all four are set.
// Local release builds fall back to the debug signing config.
val ciKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
val ciKeystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
val ciKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
val ciKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
val hasCiSigning = listOf(ciKeystorePath, ciKeystorePassword, ciKeyAlias, ciKeyPassword)
.all { !it.isNullOrBlank() } && file(ciKeystorePath!!).exists()
signingConfigs {
if (hasCiSigning) {
create("release") {
storeFile = file(ciKeystorePath!!)
storePassword = ciKeystorePassword
keyAlias = ciKeyAlias
keyPassword = ciKeyPassword
}
}
}
buildTypes {
release {
// TODO(minify): keep R8 disabled until Chaquopy reflection is
// verified end-to-end. Chaquopy resolves Kotlin classes & static
// methods (PythonBridge, UsbSerialBridge, Root) by name from
// Python via PyObject — silent stripping breaks the app at
// runtime, after release. proguard-rules.pro contains keep
// rules covering the known entry points, but until we have
// a release smoke test that exercises every PyObject path we
// do NOT ship a minified release.
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = if (hasCiSigning) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
chaquopy {
defaultConfig {
version = "3.11"
pip {
// Pre-built wheels directory (for pydantic-core etc.) as an
// absolute file:// URI with forward slashes. Pip requires
// exactly three slashes after `file:` for an empty-host local
// path; the path-normalization differs by platform:
// Windows: absolute path is "C:/..." → prefix "file:///"
// Linux: absolute path is "/..." → prefix "file://"
// Using a fixed "file:///" prefix on both made CI produce
// "file:////workspace/…", which pip rejects as non-local.
val wheelsPath = rootDir.absolutePath.replace("\\", "/")
val wheelsUrlPrefix = if (wheelsPath.startsWith("/")) "file://" else "file:///"
options("--find-links", "$wheelsUrlPrefix$wheelsPath/wheels/")
// ── Android-compatible dependencies ─────────────────
// Listed explicitly because pyproject.toml includes
// desktop-only packages with no Android wheels.
// See CLAUDE.md "Android Dependency Sync" for policy.
install("fastapi")
install("uvicorn") // without [standard] — no uvloop/httptools
install("httpx")
install("numpy")
install("pydantic") // needs pydantic-core wheel in wheels/
install("pydantic-settings")
install("PyYAML")
install("structlog")
install("python-json-logger")
install("python-dateutil")
install("python-multipart")
install("jinja2")
install("zeroconf")
install("aiomqtt")
install("openrgb-python")
// opencv-python-headless: no cp311 Android wheel on Chaquopy.
// LedGrab's cv2 usage is guarded with try/except ImportError
// and falls back to numpy/Pillow alternatives on Android.
install("Pillow")
install("websockets")
install("cryptography") // AES-GCM secret-box for HA/MQTT credentials
}
}
}
// LedGrab Python source is included via a directory junction:
// android/app/src/main/python/ledgrab -> server/src/ledgrab
// This is the standard Chaquopy way to include local Python packages.
// Create the junction (run from repo root, no admin needed):
// cmd /c "mklink /J android\app\src\main\python\ledgrab server\src\ledgrab"
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.leanback:leanback:1.0.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// QR code generation for displaying server URL on TV
implementation("com.google.zxing:core:3.5.3")
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
}
+27
View File
@@ -0,0 +1,27 @@
# LedGrab ProGuard / R8 rules.
#
# IMPORTANT: Chaquopy resolves Java/Kotlin classes and static methods by
# name from Python (e.g. UsbSerialBridge.INSTANCE.listDevices()) via
# reflection. Anything reachable through PyObject must be kept by name
# or the release build will throw NoSuchMethod / ClassNotFound at
# runtime silently, only on the user's device.
#
# Keep ALL of com.ledgrab.android.* members for safety. The app is
# small enough that the size win from stripping these isn't worth the
# fragility.
-keep class com.ledgrab.android.** { *; }
# Chaquopy runtime itself.
-keep class com.chaquo.python.** { *; }
-dontwarn com.chaquo.python.**
# usb-serial-for-android driver classes are loaded via the prober's
# default device-id list, which uses reflection in some chip drivers.
-keep class com.hoho.android.usbserial.driver.** { *; }
-dontwarn com.hoho.android.usbserial.**
# Kotlin coroutines keep the debug agent off and the metadata intact.
-dontwarn kotlinx.coroutines.**
# Standard Android best-practice keeps.
-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation*
+101
View File
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- BLE scanning and connecting — API ≥31 uses granular permissions;
older releases need BLUETOOTH + ACCESS_FINE_LOCATION for scanning.
neverForLocation avoids the location permission dialog on API 31+. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- BLE hardware — required=false so non-BT boxes still install. -->
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />
<!-- Network access for WLED HTTP/UDP, web UI, MQTT -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- MediaProjection requires a foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Autostart on boot — BootReceiver spawns CaptureService in root
mode so capture resumes without the user touching the remote. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Exempt from Doze/App Standby so the FG service isn't killed
overnight on phones; essentially a no-op on TV boxes. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Optional wake lock for sustained-performance boxes that aggressively
sleep the CPU with the display off. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Android TV declarations -->
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<!-- USB host — for USB-to-TTL adapters driving Adalight/AmbiLED
controllers. required=false so phones without USB host still install. -->
<uses-feature
android:name="android.hardware.usb.host"
android:required="false" />
<application
android:name=".LedGrabApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:banner="@drawable/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.LedGrab">
<!-- TV launcher activity -->
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Foreground service for screen capture + Python server -->
<service
android:name=".CaptureService"
android:foregroundServiceType="mediaProjection"
android:exported="false" />
<!-- Autostart — fires on device boot (and package replace).
On rooted devices, launches CaptureService directly so capture
resumes without the user tapping Start. Unrooted devices are
no-op because MediaProjection consent cannot be bypassed. -->
<receiver
android:name=".BootReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,26 @@
package com.ledgrab.android
import android.content.Context
/**
* Thin SharedPreferences wrapper for the boot-autostart toggle.
*
* Default is `true`: [BootReceiver] still bails out when the device
* isn't rooted, so a true default is safe — it only takes effect on
* boxes where we can actually honor it silently.
*/
class AutostartPrefs(context: Context) {
private val prefs = context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
var isEnabled: Boolean
get() = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
set(value) { prefs.edit().putBoolean(KEY_ENABLED, value).apply() }
companion object {
private const val PREFS_NAME = "ledgrab_autostart"
private const val KEY_ENABLED = "autostart_enabled"
private const val DEFAULT_ENABLED = true
}
}
@@ -0,0 +1,288 @@
package com.ledgrab.android
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import java.util.Collections
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
/**
* Android BLE bridge exposed to the Python server via Chaquopy.
*
* Wraps the Android BluetoothGatt / BluetoothLeScanner APIs into
* synchronous, blocking calls that can be safely invoked from
* a Python thread (Chaquopy proxy threads are real OS threads).
*
* All GATT callbacks run on a private [HandlerThread] so they don't
* block the main looper. [runBlocking] is used to bridge callback
* completions back to the calling Python thread.
*
* Python callers access the singleton via
* `BleBridge.INSTANCE.scan()` etc. — see
* `server/src/ledgrab/core/devices/android_ble_transport.py`.
*/
object BleBridge {
private const val TAG = "BleBridge"
private const val CONNECT_TIMEOUT_MS = 18_000L // connect + service discovery
private const val WRITE_TIMEOUT_MS = 5_000L
@Volatile private var appContext: Context? = null
private val handleSeq = AtomicInteger(1)
// Dedicated looper thread so BLE callbacks don't land on the main thread.
private val bleHandlerThread = HandlerThread("LedGrab-BLE").also { it.start() }
private val bleHandler = Handler(bleHandlerThread.looper)
private data class GattHandle(
val gatt: BluetoothGatt,
val writeChar: BluetoothGattCharacteristic,
)
private val handles = ConcurrentHashMap<Int, GattHandle>()
// Write completion futures, keyed by handle. Only populated for
// WRITE_TYPE_DEFAULT (with-response) writes.
private val pendingWrites = ConcurrentHashMap<Int, CompletableDeferred<Boolean>>()
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
@JvmStatic
fun init(context: Context) {
appContext = context.applicationContext
}
private fun ctx(): Context =
appContext ?: error("BleBridge.init() not called — app context unavailable")
private fun adapter() =
ctx().getSystemService(BluetoothManager::class.java)?.adapter
// ─── Public API ──────────────────────────────────────────────────────────
/**
* Scan for BLE peripherals for [timeoutMs] milliseconds.
*
* Returns a list of `"address|name|rssi"` strings. Addresses are
* deduplicated — only the last-seen RSSI for each address is kept.
* Returns an empty list if Bluetooth is off or the permission is denied.
*/
@JvmStatic
@JvmOverloads
fun scan(timeoutMs: Long = 4_000L): List<String> {
val adapter = adapter() ?: return emptyList()
if (!adapter.isEnabled) return emptyList()
val scanner = try { adapter.bluetoothLeScanner } catch (_: SecurityException) { null }
?: return emptyList()
val seen = Collections.synchronizedMap(LinkedHashMap<String, String>())
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val address = result.device.address ?: return
val name = result.scanRecord?.deviceName ?: result.device.name ?: ""
seen[address] = "$address|$name|${result.rssi}"
}
override fun onScanFailed(errorCode: Int) {
Log.w(TAG, "BLE scan failed with error $errorCode")
}
}
try {
bleHandler.post { scanner.startScan(callback) }
Thread.sleep(timeoutMs)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
} finally {
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
}
return seen.values.toList()
}
/**
* Connect to the BLE peripheral at [address] and locate the GATT
* characteristic identified by [writeCharUuid] across all services.
*
* Blocks until connected + services discovered, or returns -1 on failure.
* The returned integer is an opaque handle passed to [write]/[disconnect].
*/
@JvmStatic
fun connect(address: String, writeCharUuid: String): Int {
val adapter = adapter() ?: return -1
val device = try { adapter.getRemoteDevice(address) } catch (e: Exception) {
Log.e(TAG, "Invalid BLE address '$address': ${e.message}")
return -1
}
val readyDeferred = CompletableDeferred<Boolean>()
val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when {
newState == BluetoothProfile.STATE_CONNECTED
&& status == BluetoothGatt.GATT_SUCCESS -> {
Log.d(TAG, "GATT connected to $address, discovering services")
gatt.discoverServices()
}
newState == BluetoothProfile.STATE_DISCONNECTED -> {
Log.w(TAG, "GATT disconnected from $address (status=$status)")
readyDeferred.complete(false)
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
readyDeferred.complete(status == BluetoothGatt.GATT_SUCCESS)
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int,
) {
val h = handles.entries.firstOrNull { it.value.gatt === gatt }?.key ?: return
pendingWrites.remove(h)?.complete(status == BluetoothGatt.GATT_SUCCESS)
}
}
val gatt: BluetoothGatt = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
device.connectGatt(
ctx(), false, callback,
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
android.bluetooth.BluetoothDevice.PHY_LE_1M_MASK,
bleHandler,
)
} else {
@Suppress("DEPRECATION")
device.connectGatt(
ctx(), false, callback,
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
)
}
} catch (e: SecurityException) {
Log.e(TAG, "BLUETOOTH_CONNECT permission denied for $address", e)
return -1
} catch (e: Exception) {
Log.e(TAG, "connectGatt failed for $address", e)
return -1
}
val ready = try {
runBlocking { withTimeout(CONNECT_TIMEOUT_MS) { readyDeferred.await() } }
} catch (_: TimeoutCancellationException) {
Log.e(TAG, "BLE connect+discovery timed out for $address")
runCatching { gatt.close() }
return -1
}
if (!ready) {
runCatching { gatt.close() }
return -1
}
val charUuid = try { UUID.fromString(writeCharUuid) } catch (e: Exception) {
Log.e(TAG, "Invalid characteristic UUID '$writeCharUuid'")
gatt.disconnect(); gatt.close()
return -1
}
val writeChar = gatt.services.flatMap { it.characteristics }
.firstOrNull { it.uuid == charUuid }
if (writeChar == null) {
Log.e(TAG, "Characteristic $writeCharUuid not found on $address")
gatt.disconnect(); gatt.close()
return -1
}
val handle = handleSeq.getAndIncrement()
handles[handle] = GattHandle(gatt, writeChar)
Log.i(TAG, "BLE connected: address=$address char=$writeCharUuid handle=$handle")
return handle
}
/**
* Write [data] to the characteristic associated with [handle].
*
* [withResponse] controls the GATT write type:
* - `true` → Write Request (waits for device ACK, slower but reliable)
* - `false` → Write Command (fire-and-forget, faster, used by SP110E/Triones/Zengge)
*
* Returns `true` on success, `false` on any error.
*/
@JvmStatic
fun write(handle: Int, data: ByteArray, withResponse: Boolean): Boolean {
val entry = handles[handle] ?: return false
val gatt = entry.gatt
val char = entry.writeChar
val writeType = if (withResponse)
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
else
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
return if (withResponse) {
val deferred = CompletableDeferred<Boolean>()
pendingWrites[handle] = deferred
val initiated = gattWrite(gatt, char, data, writeType)
if (!initiated) {
pendingWrites.remove(handle)
return false
}
try {
runBlocking { withTimeout(WRITE_TIMEOUT_MS) { deferred.await() } }
} catch (_: TimeoutCancellationException) {
pendingWrites.remove(handle)
Log.w(TAG, "BLE write-with-response timed out on handle $handle")
false
}
} else {
gattWrite(gatt, char, data, writeType)
}
}
/** Disconnect and close the GATT connection for [handle]. */
@JvmStatic
fun disconnect(handle: Int) {
val entry = handles.remove(handle) ?: return
pendingWrites.remove(handle)?.complete(false)
runCatching {
entry.gatt.disconnect()
entry.gatt.close()
}.onFailure { Log.w(TAG, "BLE disconnect error for handle $handle: ${it.message}") }
Log.i(TAG, "BLE disconnected handle=$handle")
}
// ─── Internal helpers ─────────────────────────────────────────────────────
private fun gattWrite(
gatt: BluetoothGatt,
char: BluetoothGattCharacteristic,
data: ByteArray,
writeType: Int,
): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(char, data, writeType) ==
android.bluetooth.BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
char.writeType = writeType
@Suppress("DEPRECATION")
char.value = data
@Suppress("DEPRECATION")
gatt.writeCharacteristic(char)
}
}
@@ -0,0 +1,62 @@
package com.ledgrab.android
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.content.ContextCompat
/**
* Starts the LedGrab capture service automatically on device boot and
* after the app is updated.
*
* Autostart is best-effort and deliberately limited:
* - Requires root. MediaProjection consent cannot be granted silently,
* so we can't resume capture on unrooted boxes without the user
* walking up to the TV and tapping Start.
* - Gated behind [AutostartPrefs] so the user can opt out.
*
* Receivers declared with BOOT_COMPLETED must be fast — we hand off to
* a foreground service within milliseconds and let the service do the
* heavy lifting (Chaquopy bootstrap, pipeline setup).
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
Log.i(TAG, "Boot event: $action")
if (action != Intent.ACTION_BOOT_COMPLETED
&& action != Intent.ACTION_LOCKED_BOOT_COMPLETED
&& action != Intent.ACTION_MY_PACKAGE_REPLACED
) {
return
}
if (!AutostartPrefs(context).isEnabled) {
Log.i(TAG, "Autostart disabled — skipping")
return
}
// Cheap check first (no process spawn). The real `su -c id` probe
// would block and may not complete before the OS reclaims us.
if (!Root.looksRooted()) {
Log.i(TAG, "Device not rooted — cannot autostart without MediaProjection consent")
return
}
try {
ContextCompat.startForegroundService(
context,
CaptureService.createRootIntent(context),
)
Log.i(TAG, "LedGrab capture service dispatched on boot")
} catch (e: Exception) {
Log.e(TAG, "Failed to start service on boot", e)
}
}
companion object {
private const val TAG = "BootReceiver"
}
}
@@ -0,0 +1,353 @@
package com.ledgrab.android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.IBinder
import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Foreground service that runs the Python LedGrab server and captures
* the screen via MediaProjection.
*/
class CaptureService : Service() {
companion object {
private const val TAG = "CaptureService"
private const val CHANNEL_ID = "ledgrab_capture"
private const val NOTIFICATION_ID = 1
private const val EXTRA_RESULT_CODE = "result_code"
private const val EXTRA_RESULT_DATA = "result_data"
private const val EXTRA_USE_ROOT = "use_root"
private const val SERVER_PORT = 8080
private const val CAPTURE_WIDTH = 480
private const val CAPTURE_HEIGHT = 270
private const val CAPTURE_FPS = 30
// Watchdog: if no new root-capture frames arrive inside this
// window the pipeline is considered stalled and respawned.
// Grace gives screenrecord time to produce its first I-frame.
private const val WATCHDOG_GRACE_MS = 5_000L
private const val WATCHDOG_CHECK_MS = 5_000L
private const val WATCHDOG_MAX_RESTARTS = 3
/** True while the service is alive. Survives activity recreation. */
@Volatile
@JvmStatic
var isRunning: Boolean = false
private set
fun createIntent(
context: Context,
resultCode: Int,
resultData: Intent,
): Intent {
return Intent(context, CaptureService::class.java).apply {
putExtra(EXTRA_RESULT_CODE, resultCode)
putExtra(EXTRA_RESULT_DATA, resultData)
}
}
/** Root-mode intent — no MediaProjection consent data required. */
fun createRootIntent(context: Context): Intent {
return Intent(context, CaptureService::class.java).apply {
putExtra(EXTRA_USE_ROOT, true)
}
}
}
private var bridge: PythonBridge? = null
private var screenCapture: ScreenCapture? = null
private var rootCapture: RootScreenrecord? = null
private var mediaProjection: MediaProjection? = null
// Service-scoped coroutine scope for the root-capture watchdog.
// SupervisorJob so a watchdog crash doesn't tear down other children.
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var watchdogJob: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// CRITICAL: startForeground must be called IMMEDIATELY —
// before any other work, especially before getMediaProjection().
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val url = "http://$localIp:$SERVER_PORT"
try {
startForeground(NOTIFICATION_ID, buildNotification(url))
} catch (e: Exception) {
// Most common cause: missing foregroundServiceType permission
// or denied POST_NOTIFICATIONS on API 34+.
Log.e(TAG, "startForeground failed — service cannot run", e)
stopSelf()
return START_NOT_STICKY
}
// Only flip the public flag once the FG transition has succeeded,
// otherwise `isRunning=true` sticks forever when startForeground throws.
isRunning = true
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
if (intent == null && !useRoot) {
// MediaProjection mode can't recover from a redelivery —
// the consent token in the original intent is single-use.
Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
isRunning = false
stopSelf()
return START_NOT_STICKY
}
try {
if (useRoot) {
startRootCapture(url)
} else {
startMediaProjectionCapture(intent!!, url)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start capture", e)
isRunning = false
stopSelf()
}
// Root mode can be cleanly restarted from the original intent
// because it carries no perishable consent token. MediaProjection
// mode cannot — a restart there would silently start the service
// with a dead projection. START_NOT_STICKY there, the user has
// to tap Start again.
return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY
}
private fun startRootCapture(url: String) {
val newBridge = PythonBridge(this).also { b ->
b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
}
bridge = newBridge
val pipeline = RootScreenrecord(
bridge = newBridge,
width = CAPTURE_WIDTH,
height = CAPTURE_HEIGHT,
fps = CAPTURE_FPS,
)
if (!pipeline.start()) {
Log.w(TAG, "Root capture failed to launch — stopping service")
stopSelf()
return
}
rootCapture = pipeline
startWatchdog()
Log.i(TAG, "LedGrab service started (root mode) — web UI at $url")
}
/**
* Replace the active root pipeline with a fresh instance, reusing
* the existing Python bridge (no server restart). Returns true if
* the new pipeline launched, false otherwise.
*/
private fun restartRootPipeline(): Boolean {
val currentBridge = bridge ?: return false
val old = rootCapture
rootCapture = null
runCatching { old?.stop() }
val next = RootScreenrecord(
bridge = currentBridge,
width = CAPTURE_WIDTH,
height = CAPTURE_HEIGHT,
fps = CAPTURE_FPS,
)
if (!next.start()) {
Log.e(TAG, "Root capture failed to restart")
return false
}
rootCapture = next
return true
}
/**
* Monitor the root pipeline's frame counter. If no new frames
* arrive within one check window, respawn the pipeline. Caps at
* [WATCHDOG_MAX_RESTARTS] consecutive failures before giving up so
* we don't burn battery forever on a device that genuinely can't
* produce frames.
*/
private fun startWatchdog() {
watchdogJob?.cancel()
watchdogJob = serviceScope.launch {
delay(WATCHDOG_GRACE_MS)
var last = rootCapture?.framesDelivered ?: 0
var restartAttempts = 0
while (isActive) {
delay(WATCHDOG_CHECK_MS)
val pipe = rootCapture ?: break
val current = pipe.framesDelivered
if (current == last) {
restartAttempts += 1
Log.w(
TAG,
"Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
"restart attempt $restartAttempts/$WATCHDOG_MAX_RESTARTS",
)
if (restartAttempts > WATCHDOG_MAX_RESTARTS) {
Log.e(TAG, "Watchdog gave up after $WATCHDOG_MAX_RESTARTS restarts")
stopSelf()
return@launch
}
if (!restartRootPipeline()) {
stopSelf()
return@launch
}
last = rootCapture?.framesDelivered ?: 0
} else {
// Forward progress — forgive earlier glitches.
restartAttempts = 0
last = current
}
}
}
}
private fun startMediaProjectionCapture(intent: Intent, url: String) {
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
@Suppress("DEPRECATION")
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
} else {
intent.getParcelableExtra(EXTRA_RESULT_DATA)
}
if (resultData == null) {
Log.e(TAG, "No MediaProjection result data")
stopSelf()
return
}
val projectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val projection = projectionManager.getMediaProjection(resultCode, resultData)
if (projection == null) {
Log.e(TAG, "Failed to create MediaProjection")
stopSelf()
return
}
mediaProjection = projection
val metrics = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val windowMetrics = (getSystemService(Context.WINDOW_SERVICE) as WindowManager)
.currentWindowMetrics
DisplayMetrics().apply {
val bounds = windowMetrics.bounds
widthPixels = bounds.width()
heightPixels = bounds.height()
// densityDpi is still needed for VirtualDisplay; read from resources.
densityDpi = resources.displayMetrics.densityDpi
}
} else {
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
DisplayMetrics().also { m ->
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(m)
}
}
val newBridge = PythonBridge(this).also { b ->
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
b.startServer(SERVER_PORT)
}
bridge = newBridge
screenCapture = ScreenCapture(
projection = projection,
metrics = metrics,
bridge = newBridge,
targetWidth = CAPTURE_WIDTH,
targetHeight = CAPTURE_HEIGHT,
targetFps = CAPTURE_FPS,
// If the user taps the system Cast/Screen-capture stop banner,
// MediaProjection.Callback.onStop fires — tear the whole
// service down so the notification/Python server don't linger.
onProjectionStopped = { stopSelf() },
).also { it.start() }
Log.i(TAG, "LedGrab service started (MediaProjection) — web UI at $url")
}
override fun onDestroy() {
isRunning = false
watchdogJob?.cancel()
watchdogJob = null
serviceScope.cancel()
screenCapture?.stop()
screenCapture = null
rootCapture?.stop()
rootCapture = null
bridge?.stopServer()
bridge = null
mediaProjection?.stop()
mediaProjection = null
Log.i(TAG, "LedGrab service destroyed")
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"LedGrab Screen Capture",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Shows while LedGrab is capturing the screen"
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun buildNotification(url: String): Notification {
val tapIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("LedGrab Running")
.setContentText("Web UI: $url")
.setSmallIcon(R.drawable.ic_launcher)
.setContentIntent(tapIntent)
.setOngoing(true)
.build()
}
}
@@ -0,0 +1,83 @@
package com.ledgrab.android
import android.app.Application
import android.util.Log
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
import java.io.File
import java.io.PrintWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Application class — initializes the Chaquopy Python runtime and
* installs a global uncaught exception handler that persists crash
* logs to app-private storage.
*
* `Python.start()` must be called once before any Python code runs.
* It loads libpython, extracts stdlib + pip packages from APK assets
* (first launch only), and sets up `sys.path`.
*/
class LedGrabApp : Application() {
/** Set if [Python.start] threw — surfaced by MainActivity. */
@Volatile
var initError: Throwable? = null
private set
override fun onCreate() {
super.onCreate()
installCrashLogger()
try {
if (!Python.isStarted()) {
Python.start(AndroidPlatform(this))
}
} catch (t: Throwable) {
// Don't crash here — MainActivity will render a failure
// screen with a Copy log button so the user can report it.
Log.e(TAG, "Python.start() failed", t)
initError = t
return
}
// Bind application context for the USB-serial bridge so Python
// can enumerate and open USB-to-TTL adapters without needing
// an Activity reference.
UsbSerialBridge.init(this)
// Bind application context for the BLE bridge so Python can
// scan and connect to BLE LED controllers.
BleBridge.init(this)
}
/**
* Install a global uncaught exception handler that writes the
* stack trace to `files/crash-<timestamp>.log` before letting
* the default handler terminate the process. Logs survive app
* restarts and can be pulled via `adb pull` for diagnostics.
*/
private fun installCrashLogger() {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
val ts = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val logFile = File(filesDir, "crash-$ts.log")
PrintWriter(logFile).use { pw ->
pw.println("LedGrab crash at $ts")
pw.println("Thread: ${thread.name}")
pw.println()
throwable.printStackTrace(pw)
}
Log.e(TAG, "Crash log written to ${logFile.absolutePath}")
} catch (_: Exception) {
// Best effort — don't crash inside the crash handler.
}
// Chain to the default handler so Android shows the crash dialog
// and terminates the process.
defaultHandler?.uncaughtException(thread, throwable)
}
}
companion object {
private const val TAG = "LedGrabApp"
}
}
@@ -0,0 +1,314 @@
package com.ledgrab.android
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.TextView
import android.app.Activity
import androidx.core.content.ContextCompat
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Main (and only) Activity for the Android TV app.
*
* Two-state UI: stopped (Start button) and running (URL + QR + Stop).
* Navigable with D-pad / USB controller.
*/
class MainActivity : Activity() {
// Activity-scoped coroutine scope. We don't depend on AppCompat /
// androidx.lifecycle's lifecycleScope here because the TV launcher
// theme inherits from Leanback (non-AppCompat).
private val uiScope: CoroutineScope = MainScope()
companion object {
private const val TAG = "MainActivity"
private const val SERVER_PORT = 8080
private const val REQUEST_MEDIA_PROJECTION = 1001
private const val REQUEST_POST_NOTIFICATIONS = 1002
}
private lateinit var stoppedPanel: View
private lateinit var runningPanel: View
private lateinit var statusText: TextView
private lateinit var urlText: TextView
private lateinit var qrImage: ImageView
private lateinit var toggleButton: Button
private lateinit var stopButtonRunning: Button
private lateinit var versionText: TextView
private lateinit var autostartCheck: CheckBox
private lateinit var autostartPrefs: AutostartPrefs
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Surface fatal Python init errors instead of crashing.
val initError = (application as? LedGrabApp)?.initError
if (initError != null) {
showFatalErrorScreen(initError)
return
}
setContentView(R.layout.activity_main)
stoppedPanel = findViewById(R.id.stopped_panel)
runningPanel = findViewById(R.id.running_panel)
statusText = findViewById(R.id.status_text)
urlText = findViewById(R.id.url_text)
qrImage = findViewById(R.id.qr_image)
toggleButton = findViewById(R.id.toggle_button)
stopButtonRunning = findViewById(R.id.stop_button_running)
versionText = findViewById(R.id.version_text)
autostartCheck = findViewById(R.id.autostart_check)
val versionName = packageManager
.getPackageInfo(packageName, 0).versionName
versionText.text = getString(R.string.version_prefix, versionName ?: "?")
autostartPrefs = AutostartPrefs(this)
autostartCheck.isChecked = autostartPrefs.isEnabled
// Autostart only takes effect on rooted devices — grey it out
// on unrooted hardware so users don't expect magic. Cheap probe
// (file-existence only, no process spawn).
if (!Root.looksRooted()) {
autostartCheck.isEnabled = false
autostartCheck.text = getString(R.string.autostart_unavailable)
}
autostartCheck.setOnCheckedChangeListener { _, isChecked ->
autostartPrefs.isEnabled = isChecked
if (isChecked) ensureIgnoringBatteryOptimizations()
}
toggleButton.setOnClickListener { startCapture() }
stopButtonRunning.setOnClickListener { stopCaptureService() }
updateUI()
}
/**
* Decide whether to go through the MediaProjection consent flow or
* jump straight into root capture. Root check is fast but may block
* briefly the first time Magisk shows its grant dialog — running it
* on the UI thread is acceptable because we're responding to a
* button press and we want to block until the user answers.
*/
override fun onDestroy() {
uiScope.cancel()
super.onDestroy()
}
private fun startCapture() {
// `su -c id` can block for seconds while Magisk shows its grant
// dialog; running it on the Main thread caused ANRs.
toggleButton.isEnabled = false
statusText.text = "Checking root access…"
uiScope.launch(Dispatchers.IO) {
val rooted = Root.requestGrant()
withContext(Dispatchers.Main) {
toggleButton.isEnabled = true
statusText.text = ""
if (rooted) {
Log.i(TAG, "Root available — skipping MediaProjection consent")
startRootCaptureService()
} else {
requestMediaProjection()
}
}
}
}
private fun requestMediaProjection() {
val manager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
@Suppress("DEPRECATION")
startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
}
private fun startRootCaptureService() {
ensureNotificationPermission()
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
updateUI()
}
@Deprecated("Using deprecated API for plain Activity compatibility")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_MEDIA_PROJECTION) {
if (resultCode == RESULT_OK && data != null) {
startCaptureService(resultCode, data)
} else {
statusText.text = "Permission denied — screen capture requires authorization"
Log.w(TAG, "MediaProjection permission denied")
}
}
}
private fun startCaptureService(resultCode: Int, resultData: Intent) {
ensureNotificationPermission()
val intent = CaptureService.createIntent(this, resultCode, resultData)
ContextCompat.startForegroundService(this, intent)
updateUI()
}
private fun stopCaptureService() {
stopService(Intent(this, CaptureService::class.java))
updateUI()
}
private fun updateUI() {
if (CaptureService.isRunning) {
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val url = "http://$localIp:$SERVER_PORT"
urlText.text = url
qrImage.setImageBitmap(null)
// Build the bitmap pixels off the Main thread — encode + 313k
// setPixel calls were noticeably janky on slow TV boxes.
uiScope.launch(Dispatchers.Default) {
val bitmap = generateQrCode(url)
withContext(Dispatchers.Main) {
if (CaptureService.isRunning && urlText.text == url) {
qrImage.setImageBitmap(bitmap)
}
}
}
stoppedPanel.visibility = View.GONE
versionText.visibility = View.GONE
runningPanel.visibility = View.VISIBLE
stopButtonRunning.requestFocus()
} else {
urlText.text = ""
qrImage.setImageBitmap(null)
runningPanel.visibility = View.GONE
stoppedPanel.visibility = View.VISIBLE
versionText.visibility = View.VISIBLE
toggleButton.requestFocus()
}
}
private fun generateQrCode(text: String): Bitmap {
val size = 560
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
val pixels = IntArray(size * size)
for (y in 0 until size) {
val rowOffset = y * size
for (x in 0 until size) {
pixels[rowOffset + x] =
if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
}
}
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
bitmap.setPixels(pixels, 0, size, 0, 0, size, size)
return bitmap
}
/**
* Minimal failure UI shown when Python.start() (Chaquopy) blew up.
* Rendered programmatically so we don't depend on the regular layout
* (which itself may reference resources affected by the failure).
*/
private fun showFatalErrorScreen(error: Throwable) {
Log.e(TAG, "Fatal init error — showing error screen", error)
val stackText = android.util.Log.getStackTraceString(error)
val container = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(48, 48, 48, 48)
}
val title = TextView(this).apply {
text = "LedGrab failed to start"
textSize = 22f
}
val body = TextView(this).apply {
text = "Python runtime initialization failed:\n\n$stackText"
textSize = 12f
setTextIsSelectable(true)
}
val copyBtn = Button(this).apply {
text = "Copy log"
setOnClickListener {
val cm = getSystemService(CLIPBOARD_SERVICE)
as android.content.ClipboardManager
cm.setPrimaryClip(
android.content.ClipData.newPlainText("LedGrab error", stackText)
)
}
}
val scroll = android.widget.ScrollView(this).apply { addView(body) }
container.addView(title)
container.addView(copyBtn)
container.addView(scroll)
setContentView(container)
}
/**
* Prompt the user to exempt LedGrab from battery optimization. On
* TV boxes this is usually a no-op, but on phones Doze/App Standby
* will kill the foreground service after a few hours of sleep. We
* only ask when autostart is turned on. No-op on pre-M or when
* already exempt.
*
* Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
* — LedGrab's ambient-capture use case falls under the documented
* acceptable-use exceptions (a foreground service that must not be
* killed to fulfill its primary function).
*/
@SuppressLint("BatteryLife")
private fun ensureIgnoringBatteryOptimizations() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val pm = getSystemService(POWER_SERVICE) as PowerManager
if (pm.isIgnoringBatteryOptimizations(packageName)) return
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
startActivity(intent)
} catch (e: Exception) {
// Some TV-box OEM builds strip this intent. Fall back to the
// generic settings screen so the user can find it manually.
Log.w(TAG, "Direct exemption intent unavailable: ${e.message}")
runCatching {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
}
}
/**
* Request POST_NOTIFICATIONS permission on Android 13+ so the
* foreground service notification is visible. On older API levels
* this is a no-op.
*/
private fun ensureNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
@Suppress("DEPRECATION")
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
REQUEST_POST_NOTIFICATIONS,
)
}
}
}
}
@@ -0,0 +1,28 @@
package com.ledgrab.android
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import java.net.Inet4Address
/**
* Network utilities for discovering the device's local IP address.
*/
object NetworkUtils {
/**
* Return the device's local IPv4 address on the active network,
* or `null` if unavailable.
*/
fun getLocalIpAddress(context: Context): String? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return null
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
return props.linkAddresses
.map { it.address }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
?.hostAddress
}
}
@@ -0,0 +1,141 @@
package com.ledgrab.android
import android.content.Context
import android.util.Log
import com.chaquo.python.PyObject
import com.chaquo.python.Python
/**
* Bridge between Kotlin and the LedGrab Python server.
*
* All Python calls go through Chaquopy's `Python.getInstance()`.
* Frame data crosses the JNI boundary as a `ByteArray`.
*/
class PythonBridge(private val context: Context) {
companion object {
private const val TAG = "PythonBridge"
}
private var serverThread: Thread? = null
@Volatile private var running = false
// Cached PyObject handles for the per-frame fast path. Looking these
// up via Python.getInstance().getModule(...) every frame was a real
// measurable cost (~1ms/frame on TV boxes). Cached once at configure
// time and read on the capture thread — @Volatile is enough for the
// single-writer/single-reader pattern we have here.
@Volatile private var mediaProjectionEngine: PyObject? = null
@Volatile private var rootEngine: PyObject? = null
/**
* Configure the MediaProjection engine with screen dimensions.
* Must be called before [startServer].
*/
fun configureCapture(width: Int, height: Int) {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
engine.callAttr("configure", width, height)
mediaProjectionEngine = engine
Log.i(TAG, "MediaProjection engine configured: ${width}x${height}")
}
/**
* Configure the root screenrecord engine with screen dimensions.
* Used instead of [configureCapture] when root is available.
*/
fun configureRootCapture(width: Int, height: Int) {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.capture_engines.root_screenrecord_engine")
engine.callAttr("configure", width, height)
rootEngine = engine
Log.i(TAG, "Root screenrecord engine configured: ${width}x${height}")
}
/**
* Start the LedGrab FastAPI server on a background thread.
*
* This blocks until [stopServer] is called, so it runs in its own thread.
*/
fun startServer(port: Int = 8080) {
if (running) {
Log.w(TAG, "Server already running")
return
}
running = true
val dataDir = context.filesDir.absolutePath
serverThread = Thread({
try {
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
val py = Python.getInstance()
val entry = py.getModule("ledgrab.android_entry")
entry.callAttr("start_server", dataDir, port)
} catch (e: Exception) {
Log.e(TAG, "Python server error", e)
} finally {
running = false
}
}, "ledgrab-python-server")
serverThread?.start()
}
/**
* Signal the Python server to shut down gracefully.
*/
fun stopServer() {
if (!running) return
try {
val py = Python.getInstance()
val entry = py.getModule("ledgrab.android_entry")
entry.callAttr("stop_server")
} catch (e: Exception) {
Log.e(TAG, "Error stopping server", e)
}
serverThread?.join(10_000)
serverThread = null
running = false
Log.i(TAG, "Server stopped")
}
/**
* Push a captured RGBA frame to the Python MediaProjection engine.
*
* Called from [ScreenCapture] on the capture thread. The byte array
* crosses the JNI boundary — keep frames small (downscale to 480p
* before calling).
*/
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
if (!running) return
val engine = mediaProjectionEngine ?: return
try {
engine.callAttr("push_frame", rgbaBytes, width, height)
} catch (e: Exception) {
Log.w(TAG, "Failed to push frame: ${e.message}")
}
}
/**
* Push a frame produced by the root `screenrecord` pipeline.
*
* Routed to a separate Python module so the two capture paths stay
* independently introspectable (engine priority, availability flags,
* dashboard diagnostics).
*/
fun pushRootFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
if (!running) return
val engine = rootEngine ?: return
try {
engine.callAttr("push_frame", rgbaBytes, width, height)
} catch (e: Exception) {
Log.w(TAG, "Failed to push root frame: ${e.message}")
}
}
val isRunning: Boolean get() = running
}
@@ -0,0 +1,136 @@
package com.ledgrab.android
import android.util.Log
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
/**
* Lightweight root-access utilities.
*
* Detection is two-phase:
* 1. Cheap check for a `su` binary in common paths (no process spawn).
* 2. On demand, a real `su -c id` execution that returns true only when
* the shell actually runs as UID 0. This triggers Magisk's grant
* dialog the first time, exactly like any other root request.
*
* The heavier check is cached after it succeeds so we don't ask Magisk
* repeatedly mid-session.
*/
object Root {
private const val TAG = "Root"
private val SU_PATHS = listOf(
"/system/bin/su",
"/system/xbin/su",
"/sbin/su",
"/su/bin/su",
"/debug_ramdisk/su",
"/vendor/bin/su",
)
@Volatile private var cachedGranted: Boolean? = null
/** Cheap probe: true if a known `su` binary exists. Doesn't run anything. */
@JvmStatic
fun looksRooted(): Boolean = SU_PATHS.any { java.io.File(it).exists() }
/**
* Actually exercise `su`. Triggers Magisk's grant dialog on first call.
* Returns true if the shell reports UID 0 within the timeout; false
* otherwise (no root, user denied, or timeout).
*/
@JvmStatic
fun requestGrant(timeoutSeconds: Long = 10): Boolean {
cachedGranted?.let { return it }
if (!looksRooted()) {
cachedGranted = false
return false
}
val granted = try {
// redirectErrorStream merges stderr into stdout so a single
// drain thread is enough — avoids the classic pipe-buffer
// deadlock where waitFor() blocks because stderr filled up.
val process = ProcessBuilder("su", "-c", "id")
.redirectErrorStream(true)
.start()
val outputBuilder = StringBuilder()
val drain = Thread({
try {
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
val buf = CharArray(512)
while (true) {
val n = r.read(buf)
if (n < 0) break
synchronized(outputBuilder) { outputBuilder.append(buf, 0, n) }
}
}
} catch (_: Exception) {
// Process gone — drain ends.
}
}, "Root-su-drain").apply { isDaemon = true; start() }
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
drain.join(500)
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
false
} else {
drain.join(500)
val output = synchronized(outputBuilder) { outputBuilder.toString() }
if (process.exitValue() != 0) {
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
false
} else {
val rooted = output.contains("uid=0")
Log.i(TAG, "su -c id → '${output.trim()}' → rooted=$rooted")
rooted
}
}
} catch (e: Exception) {
Log.w(TAG, "su invocation failed: ${e.message}")
false
}
cachedGranted = granted
return granted
}
/**
* Run an `su -c <cmd>` command. Returns true on exit-zero. Failure
* invalidates the cached grant so the next [requestGrant] re-checks
* (covers cases like Magisk grant being revoked mid-session).
*/
@JvmStatic
fun runAsRoot(cmd: String, timeoutSeconds: Long = 5): Boolean {
return try {
val process = ProcessBuilder("su", "-c", cmd)
.redirectErrorStream(true)
.start()
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
cachedGranted = null
false
} else if (process.exitValue() != 0) {
cachedGranted = null
false
} else {
true
}
} catch (e: Exception) {
Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}")
cachedGranted = null
false
}
}
/** Forget the cached grant result — useful if Magisk permission was revoked. */
@JvmStatic
fun invalidateCache() {
cachedGranted = null
}
}
@@ -0,0 +1,273 @@
package com.ledgrab.android
import android.graphics.PixelFormat
import android.media.ImageReader
import android.media.MediaCodec
import android.media.MediaFormat
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import java.io.InputStream
import java.util.concurrent.atomic.AtomicInteger
/**
* Root-only screen capture backed by `/system/bin/screenrecord`.
*
* When the device is rooted (Magisk), we spawn ``su -c screenrecord
* --output-format=h264 …`` and read the encoder's H.264 bitstream from
* stdout. A MediaCodec decoder is configured to render into an
* ImageReader's Surface, and the ImageReader's `onImageAvailable`
* callback feeds RGBA bytes back to the Python pipeline — same sink as
* the MediaProjection path, just a different source.
*
* Why bother: MediaProjection forces Android to draw a persistent
* capture indicator overlay (unavoidable on stock Android 14+). The
* root path sidesteps that entirely because `screenrecord` runs as
* system UID through `su`. It also skips the one-time MediaProjection
* consent dialog, which matters on TV-only setups where keyboard input
* is awkward.
*/
class RootScreenrecord(
private val bridge: PythonBridge,
private val width: Int = 480,
private val height: Int = 270,
private val bitRate: Int = 4_000_000,
private val fps: Int = 30,
) {
companion object {
private const val TAG = "RootScreenrecord"
private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC
private const val INPUT_CHUNK = 64 * 1024
}
@Volatile private var process: Process? = null
private var decoder: MediaCodec? = null
private var imageReader: ImageReader? = null
private var readerThread: HandlerThread? = null
private var inputThread: Thread? = null
private var outputThread: Thread? = null
@Volatile private var running = false
private val framesDeliveredCounter = AtomicInteger(0)
@Volatile private var stopped = false
/** Monotonic count of frames pushed to the Python bridge. */
val framesDelivered: Int get() = framesDeliveredCounter.get()
/** True once at least one frame has reached the Python bridge. */
val hasProducedFrame: Boolean get() = framesDelivered > 0
/**
* Start the capture pipeline. Returns false if `su`/`screenrecord`
* couldn't be launched at all; true doesn't guarantee frames will
* actually flow (the caller should monitor [hasProducedFrame] and
* be ready to fall back to MediaProjection on timeout).
*/
fun start(): Boolean {
if (running) return true
running = true
try {
imageReader = buildImageReader()
decoder = buildDecoder(imageReader!!)
process = spawnScreenrecord() ?: run {
stop()
return false
}
startInputPump(process!!.inputStream, decoder!!)
startOutputDrain(decoder!!)
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
return true
} catch (e: Exception) {
Log.e(TAG, "Failed to start root capture", e)
stop()
return false
}
}
/** Stop everything and release resources. Idempotent. */
@Synchronized
fun stop() {
if (stopped) return
stopped = true
// Order matters: signal first so worker loops drop out, then
// stop the codec on the thread that created it (this one), then
// join workers BEFORE releasing the codec/ImageReader they may
// still be touching, then kill the external screenrecord process.
running = false
runCatching { decoder?.stop() }
inputThread?.interrupt()
outputThread?.interrupt()
runCatching { inputThread?.join(500) }
runCatching { outputThread?.join(500) }
inputThread = null
outputThread = null
// Best-effort: kill the screenrecord child before reaping `su`,
// otherwise screenrecord can outlive su as an orphan and keep
// the GPU encoder busy. Fire-and-forget; ignore failures.
runCatching { Root.runAsRoot("pkill -TERM screenrecord", timeoutSeconds = 2) }
runCatching { decoder?.release() }
decoder = null
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
runCatching { imageReader?.close() }
imageReader = null
readerThread?.quitSafely()
runCatching { readerThread?.join(500) }
readerThread = null
runCatching { process?.destroy() }
process = null
Log.i(TAG, "Root capture pipeline stopped (frames delivered: ${framesDelivered})")
}
private fun buildImageReader(): ImageReader {
val thread = HandlerThread("RootReader").apply { start() }
readerThread = thread
val handler = Handler(thread.looper)
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
reader.setOnImageAvailableListener({ r ->
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
try {
val plane = image.planes[0]
val buffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
val bytes = if (rowStride == width * pixelStride) {
ByteArray(buffer.remaining()).also { buffer.get(it) }
} else {
// Strip row padding — common when width isn't a multiple of 16.
val rowBytes = width * pixelStride
ByteArray(width * height * 4).also { out ->
for (row in 0 until height) {
buffer.position(row * rowStride)
buffer.get(out, row * rowBytes, rowBytes)
}
}
}
bridge.pushRootFrame(bytes, width, height)
framesDeliveredCounter.incrementAndGet()
} catch (e: Exception) {
Log.w(TAG, "Root frame delivery failed: ${e.message}")
} finally {
image.close()
}
}, handler)
return reader
}
private fun buildDecoder(reader: ImageReader): MediaCodec {
val format = MediaFormat.createVideoFormat(MIME_TYPE, width, height).apply {
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
}
val codec = MediaCodec.createDecoderByType(MIME_TYPE)
codec.configure(format, reader.surface, null, 0)
codec.start()
return codec
}
private fun spawnScreenrecord(): Process? {
val cmd = buildString {
append("screenrecord")
append(" --output-format=h264")
append(" --size=${width}x$height")
append(" --bit-rate=$bitRate")
// Time limit 0 isn't supported; the largest accepted is 180s.
// We restart the process ourselves if it exits early.
append(" --time-limit=180")
append(" -")
}
return try {
Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
} catch (e: Exception) {
Log.e(TAG, "Failed to spawn `su -c screenrecord`: ${e.message}")
null
}
}
private fun startInputPump(initialStream: InputStream, codec: MediaCodec) {
inputThread = Thread({
val buf = ByteArray(INPUT_CHUNK)
var stream: InputStream = initialStream
try {
outer@ while (running) {
val n = try {
stream.read(buf)
} catch (e: Exception) {
if (!running) break
Log.w(TAG, "screenrecord read error: ${e.message}")
-1
}
if (n <= 0) {
if (!running) break
// screenrecord caps at --time-limit=180s. When it
// exits cleanly we respawn so capture survives
// long sessions instead of freezing after ~3min.
Log.i(TAG, "screenrecord EOF — respawning")
runCatching { process?.destroy() }
val next = spawnScreenrecord()
if (next == null) {
// Avoid a tight loop if `su` is suddenly unhappy.
try { Thread.sleep(500) } catch (_: InterruptedException) { break }
continue@outer
}
process = next
stream = next.inputStream
continue@outer
}
var offset = 0
while (offset < n && running) {
val index = codec.dequeueInputBuffer(50_000)
if (index < 0) continue
val inputBuffer = codec.getInputBuffer(index) ?: continue
inputBuffer.clear()
val chunk = minOf(n - offset, inputBuffer.capacity())
inputBuffer.put(buf, offset, chunk)
codec.queueInputBuffer(
index,
0,
chunk,
System.nanoTime() / 1_000,
0,
)
offset += chunk
}
}
} catch (_: InterruptedException) {
// Expected on stop()
} catch (e: Exception) {
Log.w(TAG, "Input pump error: ${e.message}")
}
}, "RootDecoderInput").also { it.start() }
}
private fun startOutputDrain(codec: MediaCodec) {
outputThread = Thread({
val info = MediaCodec.BufferInfo()
try {
while (running) {
val index = codec.dequeueOutputBuffer(info, 50_000)
if (index >= 0) {
// `true` = render to the configured surface (ImageReader),
// which triggers onImageAvailable on the reader's handler.
codec.releaseOutputBuffer(index, true)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
Log.i(TAG, "Decoder reported EOS")
break
}
}
}
} catch (_: InterruptedException) {
// Expected on stop()
} catch (e: Exception) {
Log.w(TAG, "Output drain error: ${e.message}")
}
}, "RootDecoderOutput").also { it.start() }
}
}
@@ -0,0 +1,155 @@
package com.ledgrab.android
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.os.Handler
import android.os.HandlerThread
import android.util.DisplayMetrics
import android.util.Log
import java.nio.ByteBuffer
/**
* Captures the Android screen via MediaProjection and feeds frames
* to [PythonBridge].
*
* Frames are downscaled to [targetWidth] x [targetHeight] before
* crossing the JNI boundary to minimize overhead. For LED ambient
* lighting, even 480x270 contains far more data than needed.
*/
class ScreenCapture(
private val projection: MediaProjection,
private val metrics: DisplayMetrics,
private val bridge: PythonBridge,
private val targetWidth: Int = 480,
private val targetHeight: Int = 270,
private val targetFps: Int = 30,
private val onProjectionStopped: () -> Unit = {},
) {
companion object {
private const val TAG = "ScreenCapture"
private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
}
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
private var captureThread: HandlerThread? = null
private var captureHandler: Handler? = null
@Volatile private var running = false
private var lastFrameTimeMs = 0L
private val frameIntervalMs = 1000L / targetFps
/**
* Start capturing the screen.
*/
fun start() {
if (running) return
running = true
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
captureHandler = Handler(captureThread!!.looper)
// Android 14+ requires registering a callback before createVirtualDisplay
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped (external)")
stop()
// Notify the service so the foreground notification /
// Python server get torn down too — otherwise a stale
// "Running" notification lingers after the user taps
// Android's system Cast/Screen-capture stop banner.
onProjectionStopped()
}
}, captureHandler)
imageReader = ImageReader.newInstance(
targetWidth,
targetHeight,
PixelFormat.RGBA_8888,
2, // maxImages — double buffer
)
imageReader?.setOnImageAvailableListener({ reader ->
if (!running) return@setOnImageAvailableListener
val now = System.currentTimeMillis()
if (now - lastFrameTimeMs < frameIntervalMs) {
// Skip frame to maintain target FPS
reader.acquireLatestImage()?.close()
return@setOnImageAvailableListener
}
val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
try {
val plane = image.planes[0]
val buffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
// Handle row padding: rowStride may be > width * pixelStride
val rgbaBytes = if (rowStride == targetWidth * pixelStride) {
// No padding — direct copy
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
bytes
} else {
// Strip row padding
val rowBytes = targetWidth * pixelStride
val bytes = ByteArray(targetWidth * targetHeight * 4)
for (row in 0 until targetHeight) {
buffer.position(row * rowStride)
buffer.get(bytes, row * rowBytes, rowBytes)
}
bytes
}
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
lastFrameTimeMs = now
} catch (e: Exception) {
Log.w(TAG, "Frame processing error: ${e.message}")
} finally {
image.close()
}
}, captureHandler)
virtualDisplay = projection.createVirtualDisplay(
VIRTUAL_DISPLAY_NAME,
targetWidth,
targetHeight,
metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader?.surface,
null,
captureHandler,
)
Log.i(TAG, "Screen capture started (${targetWidth}x${targetHeight} @ ${targetFps}fps)")
}
/**
* Stop capturing and release all resources.
*/
fun stop() {
running = false
// Order matters: detach the listener BEFORE releasing the
// VirtualDisplay so the handler can't be re-entered with stale
// resources, then quit & join the handler thread, only then
// close the ImageReader.
runCatching { imageReader?.setOnImageAvailableListener(null, null) }
runCatching { virtualDisplay?.release() }
virtualDisplay = null
captureThread?.quitSafely()
runCatching { captureThread?.join(500) }
captureThread = null
captureHandler = null
runCatching { imageReader?.close() }
imageReader = null
Log.i(TAG, "Screen capture stopped")
}
}
@@ -0,0 +1,288 @@
package com.ledgrab.android
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbManager
import android.os.Build
import android.util.Log
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
/**
* USB-serial bridge exposed to the Python server via Chaquopy.
*
* Uses the `usb-serial-for-android` library (mik3y) which ships drivers
* for the common USB-to-TTL chips (CH340, CP2102, FTDI, Prolific, and
* CDC-ACM) found on Arduino boards and Adalight/AmbiLED controllers.
*
* Python callers access the singleton instance via
* `UsbSerialBridge.INSTANCE.listDevices()` etc. — see
* `server/src/ledgrab/core/devices/android_serial_transport.py`.
*
* The bridge holds no Context of its own; [init] must be called once
* from [LedGrabApp.onCreate] to bind the application context.
*/
object UsbSerialBridge {
private const val TAG = "UsbSerialBridge"
private const val ACTION_USB_PERMISSION = "com.ledgrab.android.USB_PERMISSION"
@Volatile private var appContext: Context? = null
private val handleSeq = AtomicInteger(1)
private val openPorts = HashMap<Int, UsbSerialPort>()
private val initialized = AtomicBoolean(false)
private val pendingPermissions = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()
/** Called once from [LedGrabApp.onCreate] so we can resolve services. */
@JvmStatic
fun init(context: Context) {
val app = context.applicationContext
appContext = app
// Idempotent: re-entrant init() must not double-register the
// receiver (which would leak listeners and double-fire callbacks).
if (!initialized.compareAndSet(false, true)) return
val filter = IntentFilter(ACTION_USB_PERMISSION)
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val granted = intent.getBooleanExtra(
UsbManager.EXTRA_PERMISSION_GRANTED,
false,
)
val device = intent.getParcelableExtra<android.hardware.usb.UsbDevice>(
UsbManager.EXTRA_DEVICE,
)
Log.i(TAG, "USB permission broadcast: granted=$granted device=${device?.deviceName}")
device?.deviceName?.let { name ->
pendingPermissions.remove(name)?.complete(granted)
}
}
}
// Android 14 requires RECEIVER_NOT_EXPORTED for non-system broadcasts.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
app.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
app.registerReceiver(receiver, filter)
}
}
private fun ctx(): Context =
appContext ?: error("UsbSerialBridge.init() not called — app context unavailable")
private fun safeSerial(driver: UsbSerialDriver): String =
try {
driver.device.serialNumber ?: ""
} catch (_: SecurityException) {
// Reading the serial requires USB permission on API 29+.
""
}
/**
* Enumerate attached USB-serial devices.
*
* Each entry is `"VID|PID|serial|description"` with VID/PID as
* 4-char lowercase hex. Pipe is used as the separator so device
* descriptions containing colons (common on FTDI strings) don't
* confuse the Python parser.
*/
@JvmStatic
fun listDevices(): List<String> {
val manager = ctx().getSystemService(Context.USB_SERVICE) as UsbManager
val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
return drivers.map { driver ->
val dev = driver.device
val vid = "%04x".format(dev.vendorId)
val pid = "%04x".format(dev.productId)
val serial = safeSerial(driver)
val description = buildString {
append(dev.manufacturerName ?: "USB")
val product = dev.productName
if (!product.isNullOrBlank()) {
append(' ')
append(product)
}
}.trim().ifEmpty { "USB $vid:$pid" }
"$vid|$pid|$serial|$description"
}
}
/**
* Open the first matching USB-serial device. Returns a non-negative
* opaque handle on success, -1 on failure (device not found, user
* denied permission, or driver error). Failures also trigger an
* async permission-request dialog when applicable — subsequent
* open() calls will succeed once the user grants.
*/
@JvmStatic
fun open(vendorId: Int, productId: Int, serial: String, baud: Int): Int {
val context = ctx()
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
val driver = drivers.firstOrNull { d ->
val dev = d.device
dev.vendorId == vendorId &&
dev.productId == productId &&
(serial.isEmpty() || safeSerial(d) == serial)
}
if (driver == null) {
Log.w(TAG, "No matching device for $vendorId:$productId:$serial")
return -1
}
if (!manager.hasPermission(driver.device)) {
Log.w(TAG, "USB permission not yet granted for ${driver.device.deviceName}")
requestPermission(context, manager, driver)
return -1
}
val connection = manager.openDevice(driver.device)
if (connection == null) {
Log.w(TAG, "openDevice returned null for ${driver.device.deviceName}")
return -1
}
val port = driver.ports.firstOrNull()
if (port == null) {
connection.close()
Log.w(TAG, "Driver reports no ports for ${driver.device.deviceName}")
return -1
}
try {
port.open(connection)
port.setParameters(
baud,
8,
UsbSerialPort.STOPBITS_1,
UsbSerialPort.PARITY_NONE,
)
} catch (e: Exception) {
Log.e(TAG, "Failed to configure serial port", e)
runCatching { port.close() }
return -1
}
val handle = handleSeq.getAndIncrement()
synchronized(openPorts) { openPorts[handle] = port }
Log.i(
TAG,
"Opened USB serial ${driver.device.deviceName} baud=$baud handle=$handle",
)
return handle
}
/** Write bytes to the previously-opened handle. Throws if invalid. */
@JvmStatic
fun write(handle: Int, data: ByteArray) {
val port = synchronized(openPorts) { openPorts[handle] }
?: throw IllegalStateException("Invalid handle $handle")
// 1s write timeout matches the old pyserial `timeout=1` behavior.
port.write(data, 1_000)
}
/** Close a previously-opened handle. Silently ignores unknown handles. */
@JvmStatic
fun close(handle: Int) {
val port = synchronized(openPorts) { openPorts.remove(handle) } ?: return
runCatching { port.close() }
.onFailure { Log.w(TAG, "close($handle): ${it.message}") }
}
/**
* Block until the user grants (or denies) USB permission for the
* device with [deviceName] (e.g. "/dev/bus/usb/001/004"). Returns
* true if granted within [timeoutMs], false otherwise. Safe to call
* from a Python thread via Chaquopy.
*/
@JvmStatic
@JvmOverloads
fun requestPermissionBlocking(deviceName: String, timeoutMs: Long = 15_000L): Boolean {
val context = ctx()
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
.firstOrNull { it.device.deviceName == deviceName }
?: return false
if (manager.hasPermission(driver.device)) return true
// Coalesce concurrent requests for the same device — only the
// first caller actually fires the system dialog.
val deferred = pendingPermissions.computeIfAbsent(deviceName) {
CompletableDeferred<Boolean>().also {
requestPermission(context, manager, driver)
}
}
return try {
runBlocking {
withTimeout(timeoutMs) { deferred.await() }
}
} catch (_: TimeoutCancellationException) {
pendingPermissions.remove(deviceName)
Log.w(TAG, "Permission request timed out for $deviceName")
false
} catch (e: Exception) {
pendingPermissions.remove(deviceName)
Log.w(TAG, "Permission request failed for $deviceName: ${e.message}")
false
}
}
/**
* Like [open] but blocks for permission first. Use this from Python
* instead of relying on the open()/retry pattern.
*/
@JvmStatic
@JvmOverloads
fun openWithPermission(
vendorId: Int,
productId: Int,
serial: String,
baud: Int,
timeoutMs: Long = 15_000L,
): Int {
val context = ctx()
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager)
.firstOrNull { d ->
val dev = d.device
dev.vendorId == vendorId &&
dev.productId == productId &&
(serial.isEmpty() || safeSerial(d) == serial)
} ?: return -1
if (!manager.hasPermission(driver.device)) {
val granted = requestPermissionBlocking(driver.device.deviceName, timeoutMs)
if (!granted) return -1
}
return open(vendorId, productId, serial, baud)
}
private fun requestPermission(
context: Context,
manager: UsbManager,
driver: UsbSerialDriver,
) {
val flags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE
} else {
0
}
val intent = Intent(ACTION_USB_PERMISSION).apply {
setPackage(context.packageName)
}
val pending = PendingIntent.getBroadcast(context, 0, intent, flags)
manager.requestPermission(driver.device, pending)
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true" android:color="#ffffff" />
<item android:color="@color/purple_accent" />
</selector>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<layer-list>
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
<shape android:shape="rectangle">
<solid android:color="#4064ffda" />
<corners android:radius="44dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#64ffda" />
<corners android:radius="36dp" />
</shape>
</item>
</layer-list>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#3dccb0" />
<corners android:radius="36dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/teal_accent" />
<corners android:radius="36dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<layer-list>
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
<shape android:shape="rectangle">
<solid android:color="#40bb86fc" />
<corners android:radius="44dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#7c4dff" />
<corners android:radius="36dp" />
</shape>
</item>
</layer-list>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#9966d4" />
<corners android:radius="36dp" />
<stroke android:width="2dp" android:color="@color/purple_accent" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#1Abb86fc" />
<corners android:radius="36dp" />
<stroke android:width="2dp" android:color="@color/purple_accent" />
</shape>
</item>
</selector>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<color android:color="@color/bg_navy" />
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:gradientRadius="900dp"
android:centerX="0.5"
android:centerY="-0.2"
android:startColor="#1A64ffda"
android:endColor="#000d1117" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:gradientRadius="600dp"
android:centerX="1.1"
android:centerY="1.2"
android:startColor="#12bb86fc"
android:endColor="#000d1117" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:left="-8dp" android:top="-8dp" android:right="-8dp" android:bottom="-8dp">
<shape android:shape="rectangle">
<solid android:color="#2064ffda" />
<corners android:radius="24dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#ffffff" />
<corners android:radius="16dp" />
<stroke android:width="3dp" android:color="@color/teal_accent" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/green_status" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_surface_elevated" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="#2264ffda" />
</shape>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background circle -->
<path
android:fillColor="#0d1117"
android:pathData="M54,54m-50,0a50,50 0,1 1,100 0a50,50 0,1 1,-100 0" />
<!-- Border ring -->
<path
android:strokeColor="#2264ffda"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:pathData="M54,54m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0" />
<!-- TV body -->
<path
android:fillColor="#1c2333"
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
<!-- TV screen -->
<path
android:fillColor="#161b22"
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
<!-- LED glow - top (teal) -->
<path
android:fillColor="#64ffda"
android:fillAlpha="0.7"
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
<!-- LED glow - left (purple) -->
<path
android:fillColor="#bb86fc"
android:fillAlpha="0.6"
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
<!-- LED glow - right (red) -->
<path
android:fillColor="#ff6b6b"
android:fillAlpha="0.6"
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
<!-- LED glow - bottom (yellow) -->
<path
android:fillColor="#ffd93d"
android:fillAlpha="0.6"
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
<!-- TV stand -->
<path
android:fillColor="#1c2333"
android:pathData="M44,72 L44,78 L64,78 L64,72" />
<path
android:fillColor="#1c2333"
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
</vector>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Adaptive icon foreground: TV with LED glow strips.
Centered in the 108dp safe zone (inner 72dp is guaranteed visible). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- TV body -->
<path
android:fillColor="#1c2333"
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
<!-- TV screen -->
<path
android:fillColor="#161b22"
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
<!-- LED glow - top (teal) -->
<path
android:fillColor="#64ffda"
android:fillAlpha="0.7"
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
<!-- LED glow - left (purple) -->
<path
android:fillColor="#bb86fc"
android:fillAlpha="0.6"
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
<!-- LED glow - right (red) -->
<path
android:fillColor="#ff6b6b"
android:fillAlpha="0.6"
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
<!-- LED glow - bottom (yellow) -->
<path
android:fillColor="#ffd93d"
android:fillAlpha="0.6"
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
<!-- TV stand -->
<path
android:fillColor="#1c2333"
android:pathData="M44,72 L44,78 L64,78 L64,72" />
<path
android:fillColor="#1c2333"
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
</vector>
@@ -0,0 +1,191 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_main">
<!-- STOPPED STATE -->
<LinearLayout
android:id="@+id/stopped_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:paddingStart="160dp"
android:paddingEnd="160dp">
<ImageView
android:layout_width="72dp"
android:layout_height="72dp"
android:src="@drawable/ic_launcher"
android:contentDescription="@null"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="@color/teal_accent"
android:textSize="64sp"
android:textStyle="bold"
android:letterSpacing="0.08"
android:layout_marginBottom="12dp"
android:fontFamily="sans-serif-light" />
<TextView
android:id="@+id/status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tagline"
android:textColor="@color/text_secondary"
android:textSize="28sp"
android:layout_marginBottom="64dp" />
<Button
android:id="@+id/toggle_button"
style="@style/Widget.LedGrab.Button.Primary"
android:layout_width="320dp"
android:layout_height="72dp"
android:text="@string/btn_start"
android:textSize="22sp"
android:focusable="true"
android:focusableInTouchMode="true" />
<CheckBox
android:id="@+id/autostart_check"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/autostart_label"
android:textColor="@color/text_secondary"
android:textSize="20sp"
android:buttonTint="@color/teal_accent"
android:focusable="true"
android:focusableInTouchMode="true" />
</LinearLayout>
<!-- Version at bottom -->
<TextView
android:id="@+id/version_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="32dp"
android:textColor="@color/text_hint"
android:textSize="18sp"
tools:text="v0.1.0" />
<!-- RUNNING STATE -->
<LinearLayout
android:id="@+id/running_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="120dp"
android:paddingEnd="120dp"
android:paddingTop="80dp"
android:paddingBottom="80dp"
android:visibility="gone">
<!-- Left: status + URL + stop -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="start|center_vertical"
android:paddingEnd="64dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
<View
android:layout_width="18dp"
android:layout_height="18dp"
android:background="@drawable/bg_status_dot"
android:layout_marginEnd="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_running"
android:textColor="@color/green_status"
android:textSize="28sp"
android:textStyle="bold"
android:letterSpacing="0.05" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_web_ui"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/url_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/teal_accent"
android:textSize="30sp"
android:maxLines="1"
android:textStyle="bold"
android:background="@drawable/bg_url_chip"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:layout_marginBottom="56dp"
tools:text="http://192.168.1.5:8080" />
<Button
android:id="@+id/stop_button_running"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="240dp"
android:layout_height="64dp"
android:text="@string/btn_stop"
android:textSize="20sp"
android:focusable="true"
android:focusableInTouchMode="true" />
</LinearLayout>
<!-- Right: QR code -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_qr_container"
android:padding="20dp"
android:layout_marginBottom="20dp">
<ImageView
android:id="@+id/qr_image"
android:layout_width="280dp"
android:layout_height="280dp"
android:contentDescription="@string/qr_description"
android:scaleType="fitXY" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan_to_configure"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:gravity="center" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_navy" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LedGrab</string>
<string name="tagline">Фоновая подсветка для телевизора</string>
<string name="btn_start">Начать захват</string>
<string name="btn_stop">Стоп</string>
<string name="status_running">Работает</string>
<string name="label_web_ui">Адрес веб-интерфейса</string>
<string name="scan_to_configure">Сканируйте для настройки</string>
<string name="qr_description">QR-код для веб-интерфейса</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">Запускать при загрузке (только с root)</string>
<string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен root)</string>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LedGrab</string>
<string name="tagline">电视氛围灯光</string>
<string name="btn_start">开始捕获</string>
<string name="btn_stop">停止</string>
<string name="status_running">运行中</string>
<string name="label_web_ui">Web界面地址</string>
<string name="scan_to_configure">扫码配置</string>
<string name="qr_description">Web界面二维码</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">开机自启(仅限 root)</string>
<string name="autostart_unavailable">开机自启 — 不可用(需要 root)</string>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg_navy">#0d1117</color>
<color name="bg_navy_mid">#111827</color>
<color name="bg_surface">#161b22</color>
<color name="bg_surface_elevated">#1c2333</color>
<color name="teal_accent">#64ffda</color>
<color name="teal_accent_dim">#3399aa</color>
<color name="teal_accent_alpha30">#4D64ffda</color>
<color name="teal_accent_alpha15">#2664ffda</color>
<color name="purple_accent">#bb86fc</color>
<color name="purple_accent_dim">#7c4dff</color>
<color name="purple_accent_alpha30">#4Dbb86fc</color>
<color name="purple_accent_alpha15">#26bb86fc</color>
<color name="green_status">#4caf50</color>
<color name="green_status_dim">#1b5e20</color>
<color name="text_primary">#e6edf3</color>
<color name="text_secondary">#8b949e</color>
<color name="text_hint">#484f58</color>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LedGrab</string>
<string name="tagline">Ambient lighting for your TV</string>
<string name="btn_start">Start Capture</string>
<string name="btn_stop">Stop</string>
<string name="status_running">Running</string>
<string name="label_web_ui">Web UI address</string>
<string name="scan_to_configure">Scan to configure</string>
<string name="qr_description">QR code for web UI</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">Start on boot (root only)</string>
<string name="autostart_unavailable">Start on boot — unavailable (root required)</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LedGrab" parent="@style/Theme.Leanback">
<item name="android:windowBackground">@color/bg_navy</item>
<item name="android:colorBackground">@color/bg_navy</item>
<item name="android:windowNoTitle">true</item>
<item name="android:textColorPrimary">@color/text_primary</item>
<item name="android:textColorSecondary">@color/text_secondary</item>
<item name="android:textColorHint">@color/text_hint</item>
<item name="android:colorAccent">@color/teal_accent</item>
<item name="android:colorControlHighlight">@color/teal_accent_dim</item>
<item name="android:colorControlActivated">@color/teal_accent</item>
</style>
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
<item name="android:background">@drawable/bg_button_primary</item>
<item name="android:textColor">@color/bg_navy</item>
<item name="android:textStyle">bold</item>
<item name="android:focusable">true</item>
<item name="android:stateListAnimator">@null</item>
</style>
<style name="Widget.LedGrab.Button.Secondary" parent="@android:style/Widget.Button">
<item name="android:background">@drawable/bg_button_secondary</item>
<item name="android:textColor">@color/btn_secondary_text</item>
<item name="android:textStyle">bold</item>
<item name="android:focusable">true</item>
<item name="android:stateListAnimator">@null</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
LedGrab communicates with WLED controllers, Home Assistant, and MQTT
brokers on the local network via plain HTTP/UDP. Cleartext traffic
must be allowed for these connections to work on Android 9+.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
#
# Cross-compile pydantic-core for Android across all three ABIs:
# arm64-v8a (primary — real TV hardware)
# x86_64 (modern emulators)
# x86 (legacy emulators)
#
# Outputs wheels into android/wheels/. Wheels are linked against the real
# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see
# memory/project_android_app.md for the incident notes).
#
# Prerequisites (on host):
# - Rust + cargo (rustup) with targets: aarch64/x86_64/i686-linux-android
# - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*)
# - Python 3.11 (matches Chaquopy's embedded version)
# - maturin (pip install maturin)
#
# Rebuild cadence: whenever PYDANTIC_CORE_VERSION changes, or pydantic's
# core dependency version changes.
#
# Usage:
# ./build-pydantic-core.sh # build all three ABIs
# ./build-pydantic-core.sh arm64 # build a single ABI
# ./build-pydantic-core.sh arm64 x86_64 # build a subset
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ANDROID_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
WHEELS_DIR="$ANDROID_DIR/wheels"
BUILD_DIR="$ANDROID_DIR/.build-cache"
PYDANTIC_CORE_VERSION="2.46.0"
API_LEVEL=24
PY_VERSION="3.11"
# Resolve a concrete python3.11 interpreter path (maturin needs it callable).
# Windows Git Bash doesn't ship `python3.11` on PATH; fall back to `py -3.11`.
if [ -z "${PYTHON_BIN:-}" ]; then
# Prefer `py -3.11` on Windows (Git Bash's `python3.11` resolves to a
# non-functional MSStore stub). Fall back to `python3.11` on Linux/macOS.
if command -v py >/dev/null 2>&1 && py -3.11 -c '' 2>/dev/null; then
PYTHON_BIN="$(py -3.11 -c 'import sys; print(sys.executable)' 2>/dev/null | tr -d '\r')"
elif command -v python3.11 >/dev/null 2>&1 && python3.11 -c '' 2>/dev/null; then
PYTHON_BIN="$(command -v python3.11)"
fi
fi
[ -n "${PYTHON_BIN:-}" ] || { echo "ERROR: python3.11 not found; set PYTHON_BIN"; exit 1; }
echo "Python: $PYTHON_BIN"
mkdir -p "$WHEELS_DIR"
# ── Find Android NDK ────────────────────────────────────────────────
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
for candidate in \
"$HOME/Library/Android/sdk/ndk"/* \
"$HOME/Android/Sdk/ndk"/* \
"${LOCALAPPDATA:-}/Android/Sdk/ndk"/* \
"/c/Users/$(whoami)/AppData/Local/Android/Sdk/ndk"/* \
"/usr/local/lib/android/sdk/ndk"/* \
"${ANDROID_SDK_ROOT:-}/ndk"/*; do
if [ -d "$candidate" ]; then
ANDROID_NDK_HOME="$candidate"
break
fi
done
fi
[ -n "${ANDROID_NDK_HOME:-}" ] || { echo "ERROR: NDK not found; set ANDROID_NDK_HOME"; exit 1; }
echo "NDK: $ANDROID_NDK_HOME"
case "$(uname -s)" in
Linux*) HOST_TAG="linux-x86_64" ;;
Darwin*) HOST_TAG="darwin-x86_64" ;;
MINGW*|MSYS*|CYGWIN*) HOST_TAG="windows-x86_64" ;;
*) echo "Unsupported host OS"; exit 1 ;;
esac
TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$HOST_TAG"
READELF="$TOOLCHAIN/bin/llvm-readelf"
# ── Prepare source tree ─────────────────────────────────────────────
SRC_DIR="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION"
if [ ! -d "$SRC_DIR" ]; then
echo "Downloading pydantic_core $PYDANTIC_CORE_VERSION sdist..."
mkdir -p "$BUILD_DIR"
SDIST="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz"
[ -f "$SDIST" ] || curl -sSL --retry 3 -o "$SDIST" \
"https://files.pythonhosted.org/packages/source/p/pydantic_core/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz"
tar -xzf "$SDIST" -C "$BUILD_DIR"
fi
# ── ABI table ───────────────────────────────────────────────────────
# Columns: short_name rust_target clang_prefix sysconfig_dir
ABI_TABLE=(
"arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig"
"x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64"
"x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86"
)
declare -A ABI_TAG_MAP=(
[arm64]="arm64_v8a"
[x86_64]="x86_64"
[x86]="x86"
)
# ── Select which ABIs to build ──────────────────────────────────────
SELECTED=("$@")
if [ ${#SELECTED[@]} -eq 0 ]; then
SELECTED=(arm64 x86_64 x86)
fi
# ── Ensure rust targets are installed ───────────────────────────────
for name in "${SELECTED[@]}"; do
for row in "${ABI_TABLE[@]}"; do
read -r sname rtarget _ _ <<<"$row"
if [ "$sname" = "$name" ]; then
rustup target add "$rtarget" >/dev/null 2>&1 || true
fi
done
done
# ── Build loop ──────────────────────────────────────────────────────
cd "$SRC_DIR"
for name in "${SELECTED[@]}"; do
MATCHED=""
for row in "${ABI_TABLE[@]}"; do
read -r sname rtarget cprefix sysdir <<<"$row"
if [ "$sname" = "$name" ]; then
MATCHED="1"
break
fi
done
[ -n "$MATCHED" ] || { echo "Unknown ABI: $name"; exit 1; }
CROSS_LIB_DIR="$BUILD_DIR/$sysdir"
[ -f "$CROSS_LIB_DIR/libpython3.11.so" ] || {
echo "ERROR: missing $CROSS_LIB_DIR/libpython3.11.so (extract from a Chaquopy APK)"
exit 1
}
# On Windows hosts the clang wrapper is a .cmd batch file; cargo/rustc
# can't exec it as a linker directly (os error 193). Use the .cmd path
# explicitly so CreateProcess picks up cmd.exe as interpreter.
CC_BIN="$TOOLCHAIN/bin/${cprefix}-clang"
if [ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${CC_BIN}.cmd" ]; then
CC_BIN="${CC_BIN}.cmd"
fi
AR_BIN="$TOOLCHAIN/bin/llvm-ar"
[ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${AR_BIN}.exe" ] && AR_BIN="${AR_BIN}.exe"
[ -f "$CC_BIN" ] || { echo "ERROR: $CC_BIN not found"; exit 1; }
# Normalize rust target name for env var (cargo wants UPPER_WITH_UNDERSCORES)
TARGET_ENV=$(echo "$rtarget" | tr 'a-z-' 'A-Z_')
echo ""
echo "════════════════════════════════════════════════════════════"
echo " Building pydantic_core $PYDANTIC_CORE_VERSION for $name ($rtarget)"
echo "════════════════════════════════════════════════════════════"
env \
CARGO_TARGET_DIR="$SRC_DIR/target" \
"CC_${rtarget//-/_}=$CC_BIN" \
"AR_${rtarget//-/_}=$AR_BIN" \
"CARGO_TARGET_${TARGET_ENV}_LINKER=$CC_BIN" \
PYO3_CROSS=1 \
PYO3_CROSS_LIB_DIR="$CROSS_LIB_DIR" \
PYO3_CROSS_PYTHON_VERSION="$PY_VERSION" \
RUSTFLAGS="-C link-arg=-Wl,--no-as-needed -C link-arg=-lpython3.11 -C link-arg=-L$CROSS_LIB_DIR" \
maturin build \
--release \
--target "$rtarget" \
--interpreter "$PYTHON_BIN" \
--out "$WHEELS_DIR" \
--compatibility linux
# maturin writes the wheel with the correct android_<api>_<abi> platform
# tag directly (the sysconfigdata provides MULTIARCH). Find + verify.
ABI_TAG="${ABI_TAG_MAP[$name]}"
OUT_WHL="$WHEELS_DIR/pydantic_core-$PYDANTIC_CORE_VERSION-cp311-cp311-android_${API_LEVEL}_${ABI_TAG}.whl"
[ -f "$OUT_WHL" ] || { echo "ERROR: expected wheel not found: $OUT_WHL"; exit 1; }
TMP=$(mktemp -d)
unzip -qo "$OUT_WHL" -d "$TMP" "pydantic_core/*.so"
SO=$(find "$TMP" -name "*.so" | head -1)
if "$READELF" -d "$SO" | grep -q "libpython3.11.so"; then
echo " NEEDED OK: libpython3.11.so present in $(basename "$OUT_WHL")"
else
echo " ERROR: libpython3.11.so NOT in NEEDED — wheel will crash at import"
"$READELF" -d "$SO" | grep NEEDED
rm -rf "$TMP"
exit 1
fi
rm -rf "$TMP"
done
echo ""
echo "=== Build complete ==="
ls -lh "$WHEELS_DIR"/pydantic_core-*.whl
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
#
# Set up the cross-compilation environment for building Python native
# extensions targeting Android ARM64.
#
# This script:
# 1. Verifies Android NDK is installed
# 2. Installs the Rust aarch64-linux-android target
# 3. Installs maturin (Python wheel builder for Rust extensions)
# 4. Runs a quick test compile to verify the toolchain works
#
set -euo pipefail
echo "=== LedGrab Android NDK Setup ==="
# ── Check prerequisites ─────────────────────────────────────────────
if ! command -v rustc &>/dev/null; then
echo "ERROR: Rust is not installed."
echo "Install from: https://rustup.rs/"
exit 1
fi
echo "Rust: $(rustc --version)"
if ! command -v cargo &>/dev/null; then
echo "ERROR: Cargo is not installed."
exit 1
fi
echo "Cargo: $(cargo --version)"
if ! command -v python3.11 &>/dev/null && ! command -v python3 &>/dev/null; then
echo "WARNING: Python 3.11 not found. Needed for maturin builds."
fi
# ── Install Rust target ─────────────────────────────────────────────
echo ""
echo "Installing Rust Android target..."
rustup target add aarch64-linux-android
echo "Installed targets:"
rustup target list --installed | grep android
# ── Install maturin ─────────────────────────────────────────────────
echo ""
echo "Installing maturin..."
pip install maturin 2>/dev/null || pip3 install maturin 2>/dev/null || {
echo "WARNING: Could not install maturin. Install manually: pip install maturin"
}
if command -v maturin &>/dev/null; then
echo "maturin: $(maturin --version)"
fi
# ── Verify NDK ──────────────────────────────────────────────────────
echo ""
if [ -n "${ANDROID_NDK_HOME:-}" ]; then
echo "ANDROID_NDK_HOME: $ANDROID_NDK_HOME"
else
echo "ANDROID_NDK_HOME is not set."
echo "Set it to your NDK installation path, e.g.:"
echo " export ANDROID_NDK_HOME=\$HOME/Android/Sdk/ndk/26.1.10909125"
echo ""
echo "Or install NDK via Android Studio:"
echo " SDK Manager → SDK Tools → NDK (Side by side)"
fi
echo ""
echo "=== Setup complete ==="
echo ""
echo "Next steps:"
echo " 1. Ensure ANDROID_NDK_HOME is set"
echo " 2. Run: ./build-pydantic-core.sh"
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.9.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.chaquo.python") version "17.0.0" apply false
}
+7
View File
@@ -0,0 +1,7 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
org.gradle.parallel=false
org.gradle.configuration-cache=false
org.gradle.unsafe.isolated-projects=false
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+20
View File
@@ -0,0 +1,20 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
// usb-serial-for-android (mik3y) is distributed via JitPack
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "LedGrab"
include(":app")
+4 -4
View File
@@ -143,8 +143,8 @@ cleanup_site_packages() {
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
fi
# ── Remove wled_controller if pip-installed ───────────────
rm -rf "$sp_dir"/wled_controller* "$sp_dir"/wled*.dist-info 2>/dev/null || true
# ── Remove ledgrab if pip-installed ───────────────
rm -rf "$sp_dir"/ledgrab* "$sp_dir"/ledgrab*.dist-info 2>/dev/null || true
local cleaned_size
cleaned_size=$(du -sh "$sp_dir" | cut -f1)
@@ -191,7 +191,7 @@ compile_and_strip_sources() {
# ── Import smoke test ────────────────────────────────────────
#
# Verifies that every top-level dependency that wled_controller actually
# Verifies that every top-level dependency that ledgrab actually
# uses can be imported from the stripped site-packages. Catches regressions
# where cleanup_site_packages removes a submodule that turns out to be
# imported internally by the package (e.g. numpy.linalg, zeroconf._services).
@@ -200,7 +200,7 @@ compile_and_strip_sources() {
# Args:
# $1 — path to site-packages to test against
# $2 — python executable
# $3 — (optional) extra PYTHONPATH entry (e.g. app/src for wled_controller)
# $3 — (optional) extra PYTHONPATH entry (e.g. app/src for ledgrab)
smoke_test_imports() {
local sp_dir="$1"
@@ -14,10 +14,11 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$SCRIPT_DIR"
DIST_NAME="LedGrab"
DIST_DIR="$BUILD_DIR/$DIST_NAME"
SERVER_DIR="$SCRIPT_DIR/server"
SERVER_DIR="$REPO_ROOT/server"
PYTHON_DIR="$DIST_DIR/python"
APP_DIR="$DIST_DIR/app"
PYTHON_VERSION="${PYTHON_VERSION:-3.11.9}"
@@ -66,7 +67,7 @@ if ! grep -q 'Lib\\site-packages' "$PTH_FILE"; then
echo 'Lib\site-packages' >> "$PTH_FILE"
fi
# Embedded Python ._pth overrides PYTHONPATH, so we must add the app
# source directory here for wled_controller to be importable
# source directory here for ledgrab to be importable
if ! grep -q '\.\./app/src' "$PTH_FILE"; then
echo '../app/src' >> "$PTH_FILE"
fi
@@ -177,10 +178,16 @@ echo "[6/9] Downloading Windows dependencies..."
WHEEL_DIR="$BUILD_DIR/win-wheels"
mkdir -p "$WHEEL_DIR"
# Core dependencies (cross-platform, should have win_amd64 wheels)
# Core dependencies (cross-platform, should have win_amd64 wheels).
# KEEP IN SYNC with server/pyproject.toml [project.dependencies] — this
# list duplicates it because cross-build on Linux can't invoke `pip install
# <path>` against pyproject.toml with a Windows target. Missing entries
# ship a broken installer that silently fails under pythonw.exe (no
# traceback visible to the user). Audit after every pyproject.toml edit.
DEPS=(
"fastapi>=0.115.0"
"uvicorn[standard]>=0.32.0"
"cryptography>=42.0.0"
"httpx>=0.27.2"
"mss>=9.0.2"
"numpy>=2.1.3"
@@ -200,6 +207,7 @@ DEPS=(
"aiomqtt>=2.0.0"
"openrgb-python>=0.2.15"
"opencv-python-headless>=4.8.0"
"just-playback>=0.1.7"
)
# Windows-only deps
@@ -213,6 +221,10 @@ WIN_DEPS=(
# System tray (Pillow needed by pystray for tray icon)
"pystray>=0.19.0"
"Pillow>=10.4.0"
# Windows screen capture engines (mss is the only fallback without these)
"dxcam>=0.0.5"
"bettercam>=1.0.0"
"windows-capture>=1.5.0"
)
# Download cross-platform deps (prefer binary, allow source for pure Python)
@@ -286,9 +298,10 @@ compile_and_strip_sources "$SITE_PACKAGES" "python"
echo " Verifying required submodules exist after cleanup..."
for required in \
"numpy/linalg" "numpy/lib" "numpy/matrixlib" "numpy/ma" \
"zeroconf/_services"; do
"zeroconf/_services" \
"cryptography" "cffi" "just_playback"; do
if [ ! -d "$SITE_PACKAGES/$required" ] && [ ! -f "$SITE_PACKAGES/$required.py" ] && [ ! -f "$SITE_PACKAGES/$required.pyc" ]; then
echo " ERROR: $required missing from site-packages — cleanup_site_packages removed something required. Aborting."
echo " ERROR: $required missing from site-packages — either cleanup_site_packages removed something required, or DEPS is out of sync with pyproject.toml. Aborting."
exit 1
fi
done
@@ -321,14 +334,20 @@ cd /d "%~dp0"
:: Set paths
set PYTHONPATH=%~dp0app\src
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
set LEDGRAB_CONFIG_PATH=%~dp0app\config\default_config.yaml
:: Tcl/Tk ship under python\ but Tk's default search path is
:: <python.exe>\..\lib\tcl8.6. Point it at the right location so
:: the screen-overlay feature (tkinter) can start without errors.
set TCL_LIBRARY=%~dp0python\tcl8.6
set TK_LIBRARY=%~dp0python\tk8.6
:: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs"
:: Start the server (tray icon handles UI and exit)
"%~dp0python\pythonw.exe" -m wled_controller
"%~dp0python\pythonw.exe" -m ledgrab
LAUNCHER
# Convert launcher to Windows line endings
@@ -336,7 +355,7 @@ sed -i 's/$/\r/' "$DIST_DIR/LedGrab.bat"
# Copy hidden launcher VBS
mkdir -p "$DIST_DIR/scripts"
cp server/scripts/start-hidden.vbs "$DIST_DIR/scripts/"
cp "$SERVER_DIR/scripts/start-hidden.vbs" "$DIST_DIR/scripts/"
# ── Create autostart scripts ─────────────────────────────────
+16 -10
View File
@@ -34,11 +34,11 @@ param(
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' # faster downloads
$ScriptRoot = $PSScriptRoot
$BuildDir = Join-Path $ScriptRoot "build"
$RepoRoot = Split-Path $PSScriptRoot -Parent
$BuildDir = $PSScriptRoot
$DistName = "LedGrab"
$DistDir = Join-Path $BuildDir $DistName
$ServerDir = Join-Path $ScriptRoot "server"
$ServerDir = Join-Path $RepoRoot "server"
$PythonDir = Join-Path $DistDir "python"
$AppDir = Join-Path $DistDir "app"
@@ -58,7 +58,7 @@ if (-not $Version) {
}
if (-not $Version) {
# Parse from __init__.py
$initFile = Join-Path $ServerDir "src\wled_controller\__init__.py"
$initFile = Join-Path $ServerDir "src\ledgrab\__init__.py"
$match = Select-String -Path $initFile -Pattern '__version__\s*=\s*"([^"]+)"'
if ($match) { $Version = $match.Matches[0].Groups[1].Value }
}
@@ -107,7 +107,7 @@ if ($pthContent -notmatch 'Lib\\site-packages') {
$pthContent = $pthContent.TrimEnd() + "`nLib\site-packages`n"
}
# Embedded Python ._pth overrides PYTHONPATH, so add the app source path
# directly for wled_controller to be importable
# directly for ledgrab to be importable
if ($pthContent -notmatch '\.\.[/\\]app[/\\]src') {
$pthContent = $pthContent.TrimEnd() + "`n..\app\src`n"
}
@@ -144,10 +144,10 @@ if ($LASTEXITCODE -ne 0) {
Write-Host " Some optional deps may have failed (continuing)..." -ForegroundColor Yellow
}
# Remove the installed wled_controller package to avoid duplication
# Remove the installed ledgrab package to avoid duplication
$sitePackages = Join-Path $PythonDir "Lib\site-packages"
Get-ChildItem -Path $sitePackages -Filter "wled*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Filter "wled*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Filter "ledgrab*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Filter "ledgrab*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Clean up caches and test files to reduce size
Write-Host " Cleaning up caches..."
@@ -206,14 +206,20 @@ cd /d "%~dp0"
:: Set paths
set PYTHONPATH=%~dp0app\src
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
set LEDGRAB_CONFIG_PATH=%~dp0app\config\default_config.yaml
:: Tcl/Tk ship under python\ but Tk's default search path is
:: <python.exe>\..\lib\tcl8.6. Point it at the right location so
:: the screen-overlay feature (tkinter) can start without errors.
set TCL_LIBRARY=%~dp0python\tcl8.6
set TK_LIBRARY=%~dp0python\tk8.6
:: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs"
:: Start the server (tray icon handles UI and exit)
"%~dp0python\pythonw.exe" -m wled_controller
"%~dp0python\pythonw.exe" -m ledgrab
'@
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
+5 -4
View File
@@ -10,10 +10,11 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$SCRIPT_DIR"
DIST_NAME="LedGrab"
DIST_DIR="$BUILD_DIR/$DIST_NAME"
SERVER_DIR="$SCRIPT_DIR/server"
SERVER_DIR="$REPO_ROOT/server"
VENV_DIR="$DIST_DIR/venv"
APP_DIR="$DIST_DIR/app"
@@ -83,12 +84,12 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export PYTHONPATH="$SCRIPT_DIR/app/src"
export WLED_CONFIG_PATH="$SCRIPT_DIR/app/config/default_config.yaml"
export LEDGRAB_CONFIG_PATH="$SCRIPT_DIR/app/config/default_config.yaml"
mkdir -p "$SCRIPT_DIR/data" "$SCRIPT_DIR/logs"
source "$SCRIPT_DIR/venv/bin/activate"
exec python -m wled_controller.main
exec python -m ledgrab.main
LAUNCHER
sed -i "s/VERSION_PLACEHOLDER/${VERSION_CLEAN}/" "$DIST_DIR/run.sh"
+20 -12
View File
@@ -22,7 +22,7 @@
!endif
Name "${APPNAME} v${VERSION}"
OutFile "build\${APPNAME}-v${VERSION}-win-x64-setup.exe"
OutFile "${APPNAME}-v${VERSION}-win-x64-setup.exe"
InstallDir "$LOCALAPPDATA\${APPNAME}"
InstallDirRegKey HKCU "Software\${APPNAME}" "InstallDir"
RequestExecutionLevel user
@@ -30,8 +30,8 @@ SetCompressor /SOLID lzma
; Modern UI Configuration
!define MUI_ICON "server\src\wled_controller\static\icons\icon.ico"
!define MUI_UNICON "server\src\wled_controller\static\icons\icon.ico"
!define MUI_ICON "..\server\src\ledgrab\static\icons\icon.ico"
!define MUI_UNICON "..\server\src\ledgrab\static\icons\icon.ico"
!define MUI_ABORTWARNING
; Pages
@@ -98,12 +98,18 @@ Section "!${APPNAME} (required)" SecCore
RMDir /r "$INSTDIR\app"
RMDir /r "$INSTDIR\scripts"
Delete "$INSTDIR\LedGrab.bat"
; Legacy leftovers from the wled_controller-era install. The current
; build does not ship debug.bat, but upgrades from older versions left
; one behind with a stale `-m wled_controller` command that gives a
; misleading ModuleNotFoundError when run. Remove it on upgrade.
Delete "$INSTDIR\debug.bat"
Delete "$INSTDIR\debug.log"
; Copy the entire portable build
File /r "build\LedGrab\python"
File /r "build\LedGrab\app"
File /r "build\LedGrab\scripts"
File "build\LedGrab\LedGrab.bat"
File /r "LedGrab\python"
File /r "LedGrab\app"
File /r "LedGrab\scripts"
File "LedGrab\LedGrab.bat"
; Create data and logs directories
CreateDirectory "$INSTDIR\data"
@@ -116,7 +122,7 @@ Section "!${APPNAME} (required)" SecCore
CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\app\src\wled_controller\static\icons\icon.ico" 0
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe"
; Registry: install location + Add/Remove Programs entry
@@ -132,11 +138,11 @@ Section "!${APPNAME} (required)" SecCore
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"InstallLocation" "$INSTDIR"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"DisplayIcon" "$INSTDIR\app\src\wled_controller\static\icons\icon.ico"
"DisplayIcon" "$INSTDIR\app\src\ledgrab\static\icons\icon.ico"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"Publisher" "Alexei Dolgolyov"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"URLInfoAbout" "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
"URLInfoAbout" "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"NoModify" 1
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
@@ -152,13 +158,13 @@ SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\app\src\wled_controller\static\icons\icon.ico" 0
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
SectionEnd
Section "Start with Windows" SecAutostart
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\app\src\wled_controller\static\icons\icon.ico" 0
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
SectionEnd
; Section Descriptions
@@ -187,6 +193,8 @@ Section "Uninstall"
RMDir /r "$INSTDIR\app"
RMDir /r "$INSTDIR\scripts"
Delete "$INSTDIR\LedGrab.bat"
Delete "$INSTDIR\debug.bat"
Delete "$INSTDIR\debug.log"
Delete "$INSTDIR\uninstall.exe"
; Remove logs (but keep data/)
+10 -9
View File
@@ -7,7 +7,8 @@
| File | Trigger | Purpose |
|------|---------|---------|
| `.gitea/workflows/test.yml` | Push/PR to master | Lint (ruff) + pytest |
| `.gitea/workflows/release.yml` | Tag `v*` | Build artifacts + create Gitea release |
| `.gitea/workflows/release.yml` | Tag `v*` | Build Windows/Linux/Docker artifacts + create Gitea release |
| `.gitea/workflows/build-android.yml` | Push master (android/server paths), tag `v*`, manual | Build Android APK; attach to release on tag |
## Release Pipeline (`release.yml`)
@@ -17,7 +18,7 @@ Four parallel jobs triggered by pushing a `v*` tag:
Creates the Gitea release with a description table listing all artifacts. **The description must stay in sync with actual build outputs** — if you add/remove/rename an artifact, update the body template here.
### 2. `build-windows` (cross-built from Linux)
- Runs `build-dist-windows.sh` on Ubuntu with NSIS + msitools
- Runs `build/build-dist-windows.sh` on Ubuntu with NSIS + msitools
- Downloads Windows embedded Python 3.11 + pip wheels cross-platform
- Bundles tkinter from Python MSI via msiextract
- Builds frontend (`npm run build`)
@@ -25,7 +26,7 @@ Creates the Gitea release with a description table listing all artifacts. **The
- Produces: **`LedGrab-{tag}-win-x64.zip`** (portable) and **`LedGrab-{tag}-setup.exe`** (NSIS installer)
### 3. `build-linux`
- Runs `build-dist.sh` on Ubuntu
- Runs `build/build-dist.sh` on Ubuntu
- Creates a venv, installs deps, builds frontend
- Produces: **`LedGrab-{tag}-linux-x64.tar.gz`**
@@ -38,8 +39,8 @@ Creates the Gitea release with a description table listing all artifacts. **The
| Script | Platform | Output |
|--------|----------|--------|
| `build-dist-windows.sh` | Linux → Windows cross-build | ZIP + NSIS installer |
| `build-dist.sh` | Linux native | tarball |
| `build/build-dist-windows.sh` | Linux → Windows cross-build | ZIP + NSIS installer |
| `build/build-dist.sh` | Linux native | tarball |
| `server/Dockerfile` | Docker | Container image |
## Release Versioning
@@ -71,7 +72,7 @@ Build scripts use a fallback chain: CLI argument → exact git tag → CI env va
- **Launch after install**: Use `MUI_FINISHPAGE_RUN_FUNCTION` (not `MUI_FINISHPAGE_RUN_PARAMETERS` — NSIS `Exec` chokes on quoting). Still requires `MUI_FINISHPAGE_RUN ""` defined for checkbox visibility
- **Detect running instance**: `.onInit` checks file lock on `python.exe`, offers to kill process before install
- **Uninstall preserves user data**: Remove `python/`, `app/`, `logs/` but NOT `data/`
- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" installer.nsi`
- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" build/installer.nsi`
## Hidden Launcher (VBS)
@@ -135,12 +136,12 @@ The `create-release` job has fallback logic — if the release already exists fo
```bash
npm ci && npm run build # frontend
bash build-dist-windows.sh v1.0.0 # Windows dist
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" installer.nsi # installer
bash build/build-dist-windows.sh v1.0.0 # Windows dist
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" build/installer.nsi # installer
```
### Iterating on installer only
If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build-dist-windows.sh` first since `dist/` is a snapshot.
If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build/build-dist-windows.sh` first since `dist/` is a snapshot.
### Common issues
+15 -1
View File
@@ -4,7 +4,7 @@
## CSS Custom Properties (Variables)
Defined in `server/src/wled_controller/static/css/base.css`.
Defined in `server/src/ledgrab/static/css/base.css`.
**IMPORTANT:** There is NO `--accent` variable. Always use `--primary-color` for accent/brand color.
@@ -96,6 +96,20 @@ Both widgets hide the native `<select>` but keep it in the DOM with its value in
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
### Dynamic entity lists (e.g. group child devices)
When a modal needs an **ordered list of entity references** (like child devices in a group), use the `EntityPalette.pick()` pattern instead of plain `<select>` dropdowns per row. Each row should be a styled card with:
- An icon (from `getDeviceTypeIcon()` / `getXxxIcon()`) showing the entity type
- The entity name and metadata (LED count, type, etc.)
- SVG icon buttons for reorder (chevron-up/down) and remove (trash)
- A click handler that opens `EntityPalette.pick()` to change the selection
- `data-*` attributes to store the selected value (not hidden `<select>` elements)
See `_addGroupChildRow()` in `device-discovery.ts` for the reference implementation. This pattern keeps the list visually consistent with the rest of the UI and avoids plain HTML selects.
For **mode/type toggles** with 2-4 fixed options (e.g. group mode: Sequence vs Independent), use `IconSelect` with descriptive icons and labels rather than radio buttons or plain selects. See `ensureGroupModeIconSelect()` for the pattern.
### Modal dirty check (discard unsaved changes)
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.ts`:
+20 -16
View File
@@ -8,36 +8,38 @@ Two independent server modes with separate configs, ports, and data directories:
| Mode | Command | Config | Port | API Key | Data |
| ---- | ------- | ------ | ---- | ------- | ---- |
| **Real** | `python -m wled_controller` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
| **Real** | `python -m ledgrab` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
| **Demo** | `python -m ledgrab.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
Demo mode can also be triggered via the `WLED_DEMO` environment variable (`true`, `1`, or `yes`). This works with any launch method — Python, Docker (`-e WLED_DEMO=true`), or the installed app (`set WLED_DEMO=true` before `LedGrab.bat`).
Demo mode can also be triggered via the `LEDGRAB_DEMO` environment variable (`true`, `1`, or `yes`). This works with any launch method — Python, Docker (`-e LEDGRAB_DEMO=true`), or the installed app (`set LEDGRAB_DEMO=true` before `LedGrab.bat`).
Both modes can run simultaneously on different ports.
## Restart Procedure
Use the PowerShell restart script — it gracefully shuts the running server down (so stores persist to disk), kills stragglers, launches a detached replacement, and polls the port until it's actually accepting connections. Exit code is 0 on success, 1 if the new server failed to bind the port, 2 on environment errors.
### Real server
Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
```bash
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-controller\server\restart.ps1"
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\led-grab-mixed\led-grab\server\restart.ps1"
```
### Demo server
Find and kill the process on port 8081, then restart:
```bash
# Find PID
powershell -Command "netstat -ano | Select-String ':8081.*LISTEN'"
# Kill it
powershell -Command "Stop-Process -Id <PID> -Force"
# Restart
cd server && python -m wled_controller.demo
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\led-grab-mixed\led-grab\server\restart.ps1" `
-Port 8081 -Module ledgrab.demo -ConfigPath "config\demo_config.yaml"
```
### Useful parameters
- `-Port <int>` / `-Module <name>` — override the target (default: 8080 / `ledgrab`).
- `-StartupTimeoutSec <int>` — how long to wait for the new server to bind the port (default: 30).
- `-ShutdownTimeoutSec <int>` — how long to wait for graceful shutdown before force-killing (default: 15).
- `-Quiet` — suppress progress output.
- `-SkipBrowser:$false` — allow the app to open a browser tab on startup (default: skipped).
**Do NOT use** `Stop-Process -Name python` — it kills unrelated Python processes (VS Code extensions, etc.).
**Do NOT use** bash background `&` jobs — they get killed when the shell session ends.
@@ -45,6 +47,7 @@ cd server && python -m wled_controller.demo
## When to Restart
**Restart required** for changes to:
- API routes (`api/routes/`, `api/schemas/`)
- Core logic (`core/*.py`)
- Configuration (`config.py`)
@@ -52,6 +55,7 @@ cd server && python -m wled_controller.demo
- Data models (`storage/`)
**No restart needed** for:
- Static files (`static/js/`, `static/css/`) — but **must rebuild bundle**: `cd server && npm run build`
- Locale files (`static/locales/*.json`) — loaded by frontend
- Documentation files (`*.md`)
@@ -68,13 +72,13 @@ Auto-reload is disabled (`reload=False` in `main.py`) due to watchfiles causing
2. **New capture engines**: Verify demo mode filtering works — demo engines use `is_demo_mode()` gate in `is_available()`.
3. **New audio engines**: Same as capture engines — `is_available()` must respect `is_demo_mode()`.
4. **New device providers**: Gate discovery with `is_demo_mode()` like `DemoDeviceProvider.discover()`.
5. **New seed data**: Update `server/src/wled_controller/core/demo_seed.py` to include sample entities.
5. **New seed data**: Update `server/src/ledgrab/core/demo_seed.py` to include sample entities.
6. **Frontend indicators**: Demo state exposed via `GET /api/v1/version` -> `demo_mode: bool`. Frontend stores it as `demoMode` in app state and sets `document.body.dataset.demo = 'true'`.
7. **Backup/Restore**: New stores added to `STORE_MAP` in `system.py` automatically work in demo mode since the data directory is already isolated.
### Key files
- Config flag: `server/src/wled_controller/config.py` -> `Config.demo`, `is_demo_mode()`
- Config flag: `server/src/ledgrab/config.py` -> `Config.demo`, `is_demo_mode()`
- Demo engines: `core/capture_engines/demo_engine.py`, `core/audio/demo_engine.py`
- Demo devices: `core/devices/demo_provider.py`
- Seed data: `core/demo_seed.py`
@@ -1,182 +0,0 @@
"""The LED Screen Controller integration."""
from __future__ import annotations
import logging
from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
DOMAIN,
CONF_SERVER_NAME,
CONF_SERVER_URL,
CONF_API_KEY,
DEFAULT_SCAN_INTERVAL,
TARGET_TYPE_HA_LIGHT,
DATA_COORDINATOR,
DATA_EVENT_LISTENER,
)
from .coordinator import WLEDScreenControllerCoordinator
from .event_listener import EventStreamListener
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.LIGHT,
Platform.SWITCH,
Platform.SENSOR,
Platform.NUMBER,
Platform.SELECT,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up LED Screen Controller from a config entry."""
server_name = entry.data.get(CONF_SERVER_NAME, "LED Screen Controller")
server_url = entry.data[CONF_SERVER_URL]
api_key = entry.data[CONF_API_KEY]
session = async_get_clientsession(hass)
coordinator = WLEDScreenControllerCoordinator(
hass,
session,
server_url,
api_key,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
await coordinator.async_config_entry_first_refresh()
event_listener = EventStreamListener(hass, server_url, api_key, coordinator)
await event_listener.start()
# Create device entries for each target and remove stale ones
device_registry = dr.async_get(hass)
current_identifiers: set[tuple[str, str]] = set()
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
if target_type == TARGET_TYPE_HA_LIGHT:
model = "HA Light Target"
else:
model = "LED Target"
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, target_id)},
name=info.get("name", target_id),
manufacturer=server_name,
model=model,
configuration_url=server_url,
)
current_identifiers.add((DOMAIN, target_id))
# Create a single "Scenes" device for scene preset buttons
scenes_identifier = (DOMAIN, f"{entry.entry_id}_scenes")
scene_presets = coordinator.data.get("scene_presets", []) if coordinator.data else []
if scene_presets:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={scenes_identifier},
name="Scenes",
manufacturer=server_name,
model="Scene Presets",
configuration_url=server_url,
)
current_identifiers.add(scenes_identifier)
# Remove devices for targets that no longer exist
for device_entry in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
if not device_entry.identifiers & current_identifiers:
_LOGGER.info("Removing stale device: %s", device_entry.name)
device_registry.async_remove_device(device_entry.id)
# Store data
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
DATA_COORDINATOR: coordinator,
DATA_EVENT_LISTENER: event_listener,
}
# Track target and scene IDs to detect changes
known_target_ids = set(coordinator.data.get("targets", {}).keys() if coordinator.data else [])
known_scene_ids = set(
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
)
def _on_coordinator_update() -> None:
"""Detect target/scene list changes and trigger reload."""
nonlocal known_target_ids, known_scene_ids
if not coordinator.data:
return
targets = coordinator.data.get("targets", {})
# Reload if target or scene list changed
current_ids = set(targets.keys())
current_scene_ids = set(p["id"] for p in coordinator.data.get("scene_presets", []))
if current_ids != known_target_ids or current_scene_ids != known_scene_ids:
known_target_ids = current_ids
known_scene_ids = current_scene_ids
_LOGGER.info("Target or scene list changed, reloading integration")
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
coordinator.async_add_listener(_on_coordinator_update)
# Register set_leds service (once across all entries)
async def handle_set_leds(call) -> None:
"""Handle the set_leds service call."""
source_id = call.data["source_id"]
segments = call.data["segments"]
# Route to the coordinator that owns this source
for entry_data in hass.data[DOMAIN].values():
coord = entry_data.get(DATA_COORDINATOR)
if not coord or not coord.data:
continue
source_ids = {s["id"] for s in coord.data.get("css_sources", [])}
if source_id in source_ids:
await coord.push_segments(source_id, segments)
return
_LOGGER.error("No server found with source_id %s", source_id)
if not hass.services.has_service(DOMAIN, "set_leds"):
hass.services.async_register(
DOMAIN,
"set_leds",
handle_set_leds,
schema=vol.Schema(
{
vol.Required("source_id"): str,
vol.Required("segments"): list,
}
),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id]
await entry_data[DATA_EVENT_LISTENER].shutdown()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
# Unregister service if no entries remain
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, "set_leds")
return unload_ok
@@ -1,74 +0,0 @@
"""Button platform for LED Screen Controller — scene preset activation."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up scene preset buttons."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data:
for preset in coordinator.data.get("scene_presets", []):
entities.append(
SceneActivateButton(coordinator, preset, entry.entry_id)
)
async_add_entities(entities)
class SceneActivateButton(CoordinatorEntity, ButtonEntity):
"""Button that activates a scene preset."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
preset: dict[str, Any],
entry_id: str,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self._preset_id = preset["id"]
self._entry_id = entry_id
self._attr_unique_id = f"{entry_id}_scene_{preset['id']}"
self._attr_translation_key = "activate_scene"
self._attr_translation_placeholders = {"scene_name": preset["name"]}
self._attr_icon = "mdi:palette"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information — all scene buttons belong to the Scenes device."""
return {"identifiers": {(DOMAIN, f"{self._entry_id}_scenes")}}
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.data:
return False
return self._preset_id in {
p["id"] for p in self.coordinator.data.get("scene_presets", [])
}
async def async_press(self) -> None:
"""Activate the scene preset."""
await self.coordinator.activate_scene(self._preset_id)
@@ -1,127 +0,0 @@
"""Config flow for LED Screen Controller integration."""
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import urlparse, urlunparse
import aiohttp
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, CONF_SERVER_NAME, CONF_SERVER_URL, CONF_API_KEY, DEFAULT_TIMEOUT
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_SERVER_NAME, default="LED Screen Controller"): str,
vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str,
vol.Optional(CONF_API_KEY, default=""): str,
}
)
def normalize_url(url: str) -> str:
"""Normalize URL to ensure port is an integer."""
parsed = urlparse(url)
if parsed.port is not None:
netloc = parsed.hostname or "localhost"
port = int(parsed.port)
if port != (443 if parsed.scheme == "https" else 80):
netloc = f"{netloc}:{port}"
parsed = parsed._replace(netloc=netloc)
return urlunparse(parsed)
async def validate_server(
hass: HomeAssistant, server_url: str, api_key: str
) -> dict[str, Any]:
"""Validate server connectivity and API key."""
session = async_get_clientsession(hass)
timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
# Step 1: Check connectivity via health endpoint (no auth needed)
try:
async with session.get(f"{server_url}/health", timeout=timeout) as resp:
if resp.status != 200:
raise ConnectionError(f"Server returned status {resp.status}")
data = await resp.json()
version = data.get("version", "unknown")
except aiohttp.ClientError as err:
raise ConnectionError(f"Cannot connect to server: {err}") from err
# Step 2: Validate API key via authenticated endpoint (skip if no key and auth not required)
auth_required = data.get("auth_required", True)
if api_key:
headers = {"Authorization": f"Bearer {api_key}"}
try:
async with session.get(
f"{server_url}/api/v1/output-targets",
headers=headers,
timeout=timeout,
) as resp:
if resp.status == 401:
raise PermissionError("Invalid API key")
resp.raise_for_status()
except PermissionError:
raise
except aiohttp.ClientError as err:
raise ConnectionError(f"API request failed: {err}") from err
elif auth_required:
raise PermissionError("Server requires an API key")
return {"version": version}
class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LED Screen Controller."""
VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
server_name = user_input.get(CONF_SERVER_NAME, "LED Screen Controller")
server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/"))
api_key = user_input[CONF_API_KEY]
try:
await validate_server(self.hass, server_url, api_key)
await self.async_set_unique_id(server_url)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=server_name,
data={
CONF_SERVER_NAME: server_name,
CONF_SERVER_URL: server_url,
CONF_API_KEY: api_key,
},
)
except ConnectionError as err:
_LOGGER.error("Connection error: %s", err)
errors["base"] = "cannot_connect"
except PermissionError:
errors["base"] = "invalid_api_key"
except Exception as err:
_LOGGER.exception("Unexpected exception: %s", err)
errors["base"] = "unknown"
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
@@ -1,22 +0,0 @@
"""Constants for the LED Screen Controller integration."""
DOMAIN = "wled_screen_controller"
# Configuration
CONF_SERVER_NAME = "server_name"
CONF_SERVER_URL = "server_url"
CONF_API_KEY = "api_key"
# Default values
DEFAULT_SCAN_INTERVAL = 3 # seconds
DEFAULT_TIMEOUT = 10 # seconds
WS_RECONNECT_DELAY = 5 # seconds
WS_MAX_RECONNECT_DELAY = 60 # seconds
# Target types
TARGET_TYPE_LED = "led"
TARGET_TYPE_HA_LIGHT = "ha_light"
# Data keys stored in hass.data[DOMAIN][entry_id]
DATA_COORDINATOR = "coordinator"
DATA_EVENT_LISTENER = "event_listener"
@@ -1,426 +0,0 @@
"""Data update coordinator for LED Screen Controller."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import Any
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
DOMAIN,
DEFAULT_TIMEOUT,
)
_LOGGER = logging.getLogger(__name__)
class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
"""Class to manage fetching LED Screen Controller data."""
def __init__(
self,
hass: HomeAssistant,
session: aiohttp.ClientSession,
server_url: str,
api_key: str,
update_interval: timedelta,
) -> None:
"""Initialize the coordinator."""
self.server_url = server_url
self.session = session
self.api_key = api_key
self.server_version = "unknown"
self._auth_headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=update_interval,
)
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from API."""
try:
async with asyncio.timeout(DEFAULT_TIMEOUT * 3):
if self.server_version == "unknown":
await self._fetch_server_version()
targets_list = await self._fetch_targets()
# Fetch state and metrics for all targets in parallel
targets_data: dict[str, dict[str, Any]] = {}
async def fetch_target_data(target: dict) -> tuple[str, dict]:
target_id = target["id"]
try:
state, metrics = await asyncio.gather(
self._fetch_target_state(target_id),
self._fetch_target_metrics(target_id),
)
except Exception as err:
_LOGGER.warning(
"Failed to fetch data for target %s: %s",
target_id,
err,
)
state = None
metrics = None
return target_id, {
"info": target,
"state": state,
"metrics": metrics,
}
results = await asyncio.gather(
*(fetch_target_data(t) for t in targets_list),
return_exceptions=True,
)
for r in results:
if isinstance(r, Exception):
_LOGGER.warning("Target fetch failed: %s", r)
continue
target_id, data = r
targets_data[target_id] = data
# Fetch devices, CSS sources, value sources, and scene presets in parallel
devices_data, css_sources, value_sources, scene_presets = await asyncio.gather(
self._fetch_devices(),
self._fetch_css_sources(),
self._fetch_value_sources(),
self._fetch_scene_presets(),
)
return {
"targets": targets_data,
"devices": devices_data,
"css_sources": css_sources,
"value_sources": value_sources,
"scene_presets": scene_presets,
"server_version": self.server_version,
}
except asyncio.TimeoutError as err:
raise UpdateFailed(f"Timeout fetching data: {err}") from err
except aiohttp.ClientError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
async def _fetch_server_version(self) -> None:
"""Fetch server version from health endpoint."""
try:
async with self.session.get(
f"{self.server_url}/health",
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
self.server_version = data.get("version", "unknown")
except Exception as err:
_LOGGER.warning("Failed to fetch server version: %s", err)
self.server_version = "unknown"
async def _fetch_targets(self) -> list[dict[str, Any]]:
"""Fetch all output targets."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("targets", [])
async def _fetch_target_state(self, target_id: str) -> dict[str, Any]:
"""Fetch target processing state."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/state",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
async def _fetch_target_metrics(self, target_id: str) -> dict[str, Any]:
"""Fetch target metrics."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
async def _fetch_devices(self) -> dict[str, dict[str, Any]]:
"""Fetch all devices with capabilities and brightness."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/devices",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
devices = data.get("devices", [])
except Exception as err:
_LOGGER.warning("Failed to fetch devices: %s", err)
return {}
# Fetch brightness for all capable devices in parallel
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
device_id = device["id"]
entry: dict[str, Any] = {"info": device, "brightness": None}
if "brightness_control" in (device.get("capabilities") or []):
try:
async with self.session.get(
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 200:
bri_data = await resp.json()
entry["brightness"] = bri_data.get("brightness")
except Exception as err:
_LOGGER.warning(
"Failed to fetch brightness for device %s: %s",
device_id,
err,
)
return device_id, entry
results = await asyncio.gather(
*(fetch_device_entry(d) for d in devices),
return_exceptions=True,
)
devices_data: dict[str, dict[str, Any]] = {}
for r in results:
if isinstance(r, Exception):
_LOGGER.warning("Device fetch failed: %s", r)
continue
device_id, entry = r
devices_data[device_id] = entry
return devices_data
async def set_brightness(self, device_id: str, brightness: int) -> None:
"""Set brightness for a device."""
async with self.session.put(
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"brightness": brightness},
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to set brightness for device %s: %s %s",
device_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def set_color(self, device_id: str, color: list[int] | None) -> None:
"""Set or clear the static color for a device."""
async with self.session.put(
f"{self.server_url}/api/v1/devices/{device_id}/color",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"color": color},
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to set color for device %s: %s %s",
device_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def _fetch_css_sources(self) -> list[dict[str, Any]]:
"""Fetch all color strip sources."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/color-strip-sources",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("sources", [])
except Exception as err:
_LOGGER.warning("Failed to fetch CSS sources: %s", err)
return []
async def _fetch_value_sources(self) -> list[dict[str, Any]]:
"""Fetch all value sources."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/value-sources",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("sources", [])
except Exception as err:
_LOGGER.warning("Failed to fetch value sources: %s", err)
return []
async def _fetch_scene_presets(self) -> list[dict[str, Any]]:
"""Fetch all scene presets."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/scene-presets",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("presets", [])
except Exception as err:
_LOGGER.warning("Failed to fetch scene presets: %s", err)
return []
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
"""Push flat color array to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"colors": colors},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push colors to source %s: %s %s",
source_id,
resp.status,
body,
)
resp.raise_for_status()
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
"""Push segment data to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"segments": segments},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push segments to source %s: %s %s",
source_id,
resp.status,
body,
)
resp.raise_for_status()
async def activate_scene(self, preset_id: str) -> None:
"""Activate a scene preset."""
async with self.session.post(
f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to activate scene %s: %s %s",
preset_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def update_source(self, source_id: str, **kwargs: Any) -> None:
"""Update a color strip source's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update source %s: %s %s",
source_id,
resp.status,
body,
)
resp.raise_for_status()
async def update_target(self, target_id: str, **kwargs: Any) -> None:
"""Update an output target's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update target %s: %s %s",
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def start_processing(self, target_id: str) -> None:
"""Start processing for a target."""
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/start",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already processing", target_id)
elif resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to start target %s: %s %s",
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def stop_processing(self, target_id: str) -> None:
"""Stop processing for a target."""
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already stopped", target_id)
elif resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to stop target %s: %s %s",
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
@@ -1,95 +0,0 @@
"""WebSocket event listener for server state change notifications."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import WS_RECONNECT_DELAY, WS_MAX_RECONNECT_DELAY
_LOGGER = logging.getLogger(__name__)
class EventStreamListener:
"""Listens to server WS endpoint for state change events.
Triggers a coordinator refresh whenever a target starts or stops processing,
so HAOS entities react near-instantly to external state changes.
"""
def __init__(
self,
hass: HomeAssistant,
server_url: str,
api_key: str,
coordinator: DataUpdateCoordinator,
) -> None:
self._hass = hass
self._server_url = server_url
self._api_key = api_key
self._coordinator = coordinator
self._task: asyncio.Task | None = None
self._shutting_down = False
async def start(self) -> None:
"""Start listening to the event stream."""
self._task = self._hass.async_create_background_task(
self._ws_loop(),
"wled_screen_controller_events",
)
async def _ws_loop(self) -> None:
"""WebSocket connection loop with reconnection."""
delay = WS_RECONNECT_DELAY
session = async_get_clientsession(self._hass)
ws_base = self._server_url.replace("http://", "ws://").replace(
"https://", "wss://"
)
url = f"{ws_base}/api/v1/events/ws?token={self._api_key}"
while not self._shutting_down:
try:
async with session.ws_connect(url) as ws:
delay = WS_RECONNECT_DELAY # reset on successful connect
_LOGGER.debug("Event stream connected")
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
except json.JSONDecodeError:
continue
if data.get("type") == "state_change":
await self._coordinator.async_request_refresh()
elif msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.ERROR,
):
break
except asyncio.CancelledError:
raise
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
_LOGGER.debug("Event stream connection error: %s", err)
except Exception as err:
_LOGGER.error("Unexpected event stream error: %s", err)
if self._shutting_down:
break
await asyncio.sleep(delay)
delay = min(delay * 2, WS_MAX_RECONNECT_DELAY)
async def shutdown(self) -> None:
"""Stop listening."""
self._shutting_down = True
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
@@ -1,151 +0,0 @@
"""Light platform for LED Screen Controller (api_input CSS sources)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller api_input lights."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data:
for source in coordinator.data.get("css_sources", []):
if source.get("source_type") == "api_input":
entities.append(
ApiInputLight(coordinator, source, entry.entry_id)
)
async_add_entities(entities)
class ApiInputLight(CoordinatorEntity, LightEntity):
"""Representation of an api_input CSS source as a light entity."""
_attr_has_entity_name = True
_attr_color_mode = ColorMode.RGB
_attr_supported_color_modes = {ColorMode.RGB}
_attr_translation_key = "api_input_light"
_attr_icon = "mdi:led-strip-variant"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
source: dict[str, Any],
entry_id: str,
) -> None:
"""Initialize the light."""
super().__init__(coordinator)
self._source_id: str = source["id"]
self._source_name: str = source.get("name", self._source_id)
self._entry_id = entry_id
self._attr_unique_id = f"{self._source_id}_light"
# Restore state from fallback_color
fallback = self._get_fallback_color()
is_off = fallback == [0, 0, 0]
self._is_on: bool = not is_off
self._rgb_color: tuple[int, int, int] = (
(255, 255, 255) if is_off else tuple(fallback) # type: ignore[arg-type]
)
self._brightness: int = 255
@property
def device_info(self) -> dict[str, Any]:
"""Return device information — one virtual device per api_input source."""
return {
"identifiers": {(DOMAIN, self._source_id)},
"name": self._source_name,
"manufacturer": "WLED Screen Controller",
"model": "API Input CSS Source",
}
@property
def name(self) -> str:
"""Return the entity name."""
return self._source_name
@property
def is_on(self) -> bool:
"""Return true if the light is on."""
return self._is_on
@property
def rgb_color(self) -> tuple[int, int, int]:
"""Return the current RGB color."""
return self._rgb_color
@property
def brightness(self) -> int:
"""Return the current brightness (0-255)."""
return self._brightness
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light, optionally setting color and brightness."""
if ATTR_RGB_COLOR in kwargs:
self._rgb_color = kwargs[ATTR_RGB_COLOR]
if ATTR_BRIGHTNESS in kwargs:
self._brightness = kwargs[ATTR_BRIGHTNESS]
# Scale RGB by brightness
scale = self._brightness / 255
r, g, b = self._rgb_color
scaled = [round(r * scale), round(g * scale), round(b * scale)]
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": scaled}],
)
# Update fallback_color so the color persists beyond the timeout
await self.coordinator.update_source(
self._source_id, fallback_color=scaled,
)
self._is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light by pushing black and setting fallback to black."""
off_color = [0, 0, 0]
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": off_color}],
)
await self.coordinator.update_source(
self._source_id, fallback_color=off_color,
)
self._is_on = False
self.async_write_ha_state()
def _get_fallback_color(self) -> list[int]:
"""Read fallback_color from the source config in coordinator data."""
if not self.coordinator.data:
return [0, 0, 0]
for source in self.coordinator.data.get("css_sources", []):
if source.get("id") == self._source_id:
fallback = source.get("fallback_color")
if fallback and len(fallback) >= 3:
return list(fallback[:3])
break
return [0, 0, 0]
@@ -1,12 +0,0 @@
{
"domain": "wled_screen_controller",
"name": "LED Screen Controller",
"codeowners": ["@alexeidolgolyov"],
"config_flow": true,
"dependencies": [],
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed",
"iot_class": "local_push",
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues",
"requirements": ["aiohttp>=3.9.0"],
"version": "0.2.0"
}
@@ -1,233 +0,0 @@
"""Number platform for LED Screen Controller (device brightness & HA light settings)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_HA_LIGHT
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller number entities."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities: list[NumberEntity] = []
if coordinator.data and "targets" in coordinator.data:
devices = coordinator.data.get("devices") or {}
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
if target_type == TARGET_TYPE_HA_LIGHT:
# HA Light target — expose tunable settings
entities.append(HALightUpdateRate(coordinator, target_id, entry.entry_id))
entities.append(HALightTransition(coordinator, target_id, entry.entry_id))
entities.append(HALightMinBrightness(coordinator, target_id, entry.entry_id))
entities.append(HALightColorTolerance(coordinator, target_id, entry.entry_id))
continue
# LED target — brightness lives on the device
device_id = info.get("device_id", "")
if not device_id:
continue
device_data = devices.get(device_id)
if not device_data:
continue
capabilities = device_data.get("info", {}).get("capabilities") or []
if "brightness_control" not in capabilities or "static_color" in capabilities:
continue
entities.append(
WLEDScreenControllerBrightness(
coordinator,
target_id,
device_id,
entry.entry_id,
)
)
async_add_entities(entities)
class WLEDScreenControllerBrightness(CoordinatorEntity, NumberEntity):
"""Brightness control for an LED device associated with a target."""
_attr_has_entity_name = True
_attr_native_min_value = 0
_attr_native_max_value = 255
_attr_native_step = 1
_attr_mode = NumberMode.SLIDER
_attr_icon = "mdi:brightness-6"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
device_id: str,
entry_id: str,
) -> None:
"""Initialize the brightness number."""
super().__init__(coordinator)
self._target_id = target_id
self._device_id = device_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_brightness"
self._attr_translation_key = "brightness"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
"""Return the current brightness value."""
if not self.coordinator.data:
return None
device_data = self.coordinator.data.get("devices", {}).get(self._device_id)
if not device_data:
return None
return device_data.get("brightness")
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.data:
return False
targets = self.coordinator.data.get("targets", {})
devices = self.coordinator.data.get("devices", {})
return self._target_id in targets and self._device_id in devices
async def async_set_native_value(self, value: float) -> None:
"""Set brightness value."""
await self.coordinator.set_brightness(self._device_id, int(value))
# --- HA Light target number entities ---
class _HALightNumberBase(CoordinatorEntity, NumberEntity):
"""Base class for HA Light target number entities."""
_attr_has_entity_name = True
_attr_mode = NumberMode.SLIDER
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
*,
field_name: str,
) -> None:
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._field_name = field_name
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
target_data = self._get_target_data()
if not target_data:
return None
return target_data.get("info", {}).get(self._field_name)
@property
def available(self) -> bool:
return self._get_target_data() is not None
async def async_set_native_value(self, value: float) -> None:
await self.coordinator.update_target(self._target_id, **{self._field_name: round(value, 2)})
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
class HALightUpdateRate(_HALightNumberBase):
"""Update rate (Hz) for an HA Light target."""
_attr_native_min_value = 0.5
_attr_native_max_value = 5.0
_attr_native_step = 0.5
_attr_native_unit_of_measurement = "Hz"
_attr_icon = "mdi:update"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="update_rate")
self._attr_unique_id = f"{target_id}_update_rate"
self._attr_translation_key = "ha_light_update_rate"
class HALightTransition(_HALightNumberBase):
"""Transition time (seconds) for an HA Light target."""
_attr_native_min_value = 0.0
_attr_native_max_value = 10.0
_attr_native_step = 0.1
_attr_native_unit_of_measurement = "s"
_attr_icon = "mdi:transition-masked"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="transition")
self._attr_unique_id = f"{target_id}_transition"
self._attr_translation_key = "ha_light_transition"
class HALightMinBrightness(_HALightNumberBase):
"""Minimum brightness threshold for an HA Light target."""
_attr_native_min_value = 0
_attr_native_max_value = 255
_attr_native_step = 1
_attr_icon = "mdi:brightness-4"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="min_brightness_threshold")
self._attr_unique_id = f"{target_id}_min_brightness"
self._attr_translation_key = "ha_light_min_brightness"
class HALightColorTolerance(_HALightNumberBase):
"""Color tolerance (RGB delta skip threshold) for an HA Light target."""
_attr_native_min_value = 0
_attr_native_max_value = 50
_attr_native_step = 1
_attr_icon = "mdi:palette-outline"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="color_tolerance")
self._attr_unique_id = f"{target_id}_color_tolerance"
self._attr_translation_key = "ha_light_color_tolerance"
@@ -1,178 +0,0 @@
"""Select platform for LED Screen Controller (CSS source & brightness source)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR, TARGET_TYPE_HA_LIGHT
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
NONE_OPTION = "\u2014 None \u2014"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller select entities."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities: list[SelectEntity] = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
# Both LED and HA Light targets have a CSS source
entities.append(CSSSourceSelect(coordinator, target_id, entry.entry_id))
# Only LED targets have a brightness value source
if target_type != TARGET_TYPE_HA_LIGHT:
entities.append(BrightnessSourceSelect(coordinator, target_id, entry.entry_id))
async_add_entities(entities)
class CSSSourceSelect(CoordinatorEntity, SelectEntity):
"""Select entity for choosing a color strip source for a target."""
_attr_has_entity_name = True
_attr_icon = "mdi:palette"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_css_source"
self._attr_translation_key = "color_strip_source"
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def options(self) -> list[str]:
if not self.coordinator.data:
return []
sources = self.coordinator.data.get("css_sources") or []
return [s["name"] for s in sources]
@property
def current_option(self) -> str | None:
if not self.coordinator.data:
return None
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
if not target_data:
return None
current_id = target_data["info"].get("color_strip_source_id", "")
sources = self.coordinator.data.get("css_sources") or []
for s in sources:
if s["id"] == current_id:
return s["name"]
return None
@property
def available(self) -> bool:
if not self.coordinator.data:
return False
return self._target_id in self.coordinator.data.get("targets", {})
async def async_select_option(self, option: str) -> None:
source_id = self._name_to_id_map().get(option)
if source_id is None:
_LOGGER.error("CSS source not found: %s", option)
return
await self.coordinator.update_target(self._target_id, color_strip_source_id=source_id)
def _name_to_id_map(self) -> dict[str, str]:
sources = (self.coordinator.data or {}).get("css_sources") or []
return {s["name"]: s["id"] for s in sources}
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
"""Select entity for choosing a brightness value source for an LED target."""
_attr_has_entity_name = True
_attr_icon = "mdi:brightness-auto"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_brightness_source"
self._attr_translation_key = "brightness_source"
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def options(self) -> list[str]:
if not self.coordinator.data:
return [NONE_OPTION]
sources = self.coordinator.data.get("value_sources") or []
return [NONE_OPTION] + [s["name"] for s in sources]
@property
def current_option(self) -> str | None:
if not self.coordinator.data:
return None
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
if not target_data:
return None
# BindableFloat: brightness is either a plain float or {"value": float, "source_id": str}
brightness = target_data["info"].get("brightness", "")
if isinstance(brightness, dict):
current_id = brightness.get("source_id", "")
else:
current_id = target_data["info"].get("brightness_value_source_id", "")
if not current_id:
return NONE_OPTION
sources = self.coordinator.data.get("value_sources") or []
for s in sources:
if s["id"] == current_id:
return s["name"]
return NONE_OPTION
@property
def available(self) -> bool:
if not self.coordinator.data:
return False
return self._target_id in self.coordinator.data.get("targets", {})
async def async_select_option(self, option: str) -> None:
if option == NONE_OPTION:
source_id = ""
else:
name_map = {
s["name"]: s["id"] for s in (self.coordinator.data or {}).get("value_sources") or []
}
source_id = name_map.get(option)
if source_id is None:
_LOGGER.error("Value source not found: %s", option)
return
await self.coordinator.update_target(
self._target_id,
brightness={"value": 1.0, "source_id": source_id} if source_id else 1.0,
)
@@ -1,225 +0,0 @@
"""Sensor platform for LED Screen Controller."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DOMAIN,
TARGET_TYPE_HA_LIGHT,
DATA_COORDINATOR,
)
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller sensors."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities: list[SensorEntity] = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
entities.append(WLEDScreenControllerFPSSensor(coordinator, target_id, entry.entry_id))
entities.append(
WLEDScreenControllerStatusSensor(coordinator, target_id, entry.entry_id)
)
# Add mapped lights sensor for HA Light targets
info = target_data["info"]
if info.get("target_type") == TARGET_TYPE_HA_LIGHT:
entities.append(HALightMappedLightsSensor(coordinator, target_id, entry.entry_id))
async_add_entities(entities)
class WLEDScreenControllerFPSSensor(CoordinatorEntity, SensorEntity):
"""FPS sensor for a LED Screen Controller target."""
_attr_has_entity_name = True
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = "FPS"
_attr_icon = "mdi:speedometer"
_attr_suggested_display_precision = 1
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_fps"
self._attr_translation_key = "fps"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
"""Return the FPS value."""
target_data = self._get_target_data()
if not target_data or not target_data.get("state"):
return None
state = target_data["state"]
if not state.get("processing"):
return None
return state.get("fps_actual")
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional attributes."""
target_data = self._get_target_data()
if not target_data or not target_data.get("state"):
return {}
return {"fps_target": target_data["state"].get("fps_target")}
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
class WLEDScreenControllerStatusSensor(CoordinatorEntity, SensorEntity):
"""Status sensor for a LED Screen Controller target."""
_attr_has_entity_name = True
_attr_icon = "mdi:information-outline"
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = ["processing", "idle", "error", "unavailable"]
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_status"
self._attr_translation_key = "status"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> str:
"""Return the status."""
target_data = self._get_target_data()
if not target_data:
return "unavailable"
state = target_data.get("state")
if not state:
return "unavailable"
if state.get("processing"):
errors = state.get("errors", [])
if errors:
return "error"
return "processing"
return "idle"
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
class HALightMappedLightsSensor(CoordinatorEntity, SensorEntity):
"""Sensor showing the number of mapped HA lights for an HA Light target."""
_attr_has_entity_name = True
_attr_icon = "mdi:lightbulb-group"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_mapped_lights"
self._attr_translation_key = "mapped_lights"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> int | None:
"""Return the number of mapped lights."""
target_data = self._get_target_data()
if not target_data:
return None
mappings = target_data.get("info", {}).get("light_mappings", [])
return len(mappings)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return light mapping details as attributes."""
target_data = self._get_target_data()
if not target_data:
return {}
mappings = target_data.get("info", {}).get("light_mappings", [])
entity_ids = [m.get("entity_id", "") for m in mappings]
return {
"entity_ids": entity_ids,
"mappings": [
{
"entity_id": m.get("entity_id", ""),
"led_start": m.get("led_start", 0),
"led_end": m.get("led_end", -1),
"brightness_scale": m.get("brightness_scale", 1.0),
}
for m in mappings
],
}
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
@@ -1,19 +0,0 @@
set_leds:
name: Set LEDs
description: Push segment data to an api_input color strip source
fields:
source_id:
name: Source ID
description: The api_input CSS source ID (e.g., css_abc12345)
required: true
selector:
text:
segments:
name: Segments
description: >
List of segment objects. Each segment has: start (int), length (int),
mode ("solid"/"per_pixel"/"gradient"), color ([R,G,B] for solid),
colors ([[R,G,B],...] for per_pixel/gradient)
required: true
selector:
object:
@@ -1,103 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Set up LED Screen Controller",
"description": "Enter the URL and API key for your LED Screen Controller server.",
"data": {
"server_name": "Server Name",
"server_url": "Server URL",
"api_key": "API Key"
},
"data_description": {
"server_name": "Display name for this server in Home Assistant",
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
"api_key": "API key from your server's configuration file"
}
}
},
"error": {
"cannot_connect": "Failed to connect to server.",
"invalid_api_key": "Invalid API key.",
"unknown": "Unexpected error occurred."
},
"abort": {
"already_configured": "This server is already configured."
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
}
},
"sensor": {
"fps": {
"name": "FPS"
},
"status": {
"name": "Status",
"state": {
"processing": "Processing",
"idle": "Idle",
"error": "Error",
"unavailable": "Unavailable"
}
},
"mapped_lights": {
"name": "Mapped Lights"
}
},
"number": {
"brightness": {
"name": "Brightness"
},
"ha_light_update_rate": {
"name": "Update Rate"
},
"ha_light_transition": {
"name": "Transition"
},
"ha_light_min_brightness": {
"name": "Min Brightness"
},
"ha_light_color_tolerance": {
"name": "Color Tolerance"
}
},
"select": {
"color_strip_source": {
"name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
}
}
},
"services": {
"set_leds": {
"name": "Set LEDs",
"description": "Push segment data to an api_input color strip source.",
"fields": {
"source_id": {
"name": "Source ID",
"description": "The api_input CSS source ID (e.g., css_abc12345)."
},
"segments": {
"name": "Segments",
"description": "List of segment objects with start, length, mode, and color/colors fields."
}
}
}
}
}
@@ -1,109 +0,0 @@
"""Switch platform for LED Screen Controller."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller switches."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
entities.append(
WLEDScreenControllerSwitch(coordinator, target_id, entry.entry_id)
)
async_add_entities(entities)
class WLEDScreenControllerSwitch(CoordinatorEntity, SwitchEntity):
"""Representation of a LED Screen Controller target processing switch."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_processing"
self._attr_translation_key = "processing"
self._attr_icon = "mdi:television-ambient-light"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def is_on(self) -> bool:
"""Return true if processing is active."""
target_data = self._get_target_data()
if not target_data or not target_data.get("state"):
return False
return target_data["state"].get("processing", False)
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional state attributes."""
target_data = self._get_target_data()
if not target_data:
return {}
attrs: dict[str, Any] = {"target_id": self._target_id}
state = target_data.get("state") or {}
metrics = target_data.get("metrics") or {}
if state:
attrs["fps_target"] = state.get("fps_target")
attrs["fps_actual"] = state.get("fps_actual")
if metrics:
attrs["frames_processed"] = metrics.get("frames_processed")
attrs["errors_count"] = metrics.get("errors_count")
attrs["uptime_seconds"] = metrics.get("uptime_seconds")
return attrs
async def async_turn_on(self, **kwargs: Any) -> None:
"""Start processing."""
await self.coordinator.start_processing(self._target_id)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Stop processing."""
await self.coordinator.stop_processing(self._target_id)
def _get_target_data(self) -> dict[str, Any] | None:
"""Get target data from coordinator."""
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
@@ -1,87 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Set up LED Screen Controller",
"description": "Enter the URL and API key for your LED Screen Controller server.",
"data": {
"server_name": "Server Name",
"server_url": "Server URL",
"api_key": "API Key"
},
"data_description": {
"server_name": "Display name for this server in Home Assistant",
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
"api_key": "API key from your server's configuration file"
}
}
},
"error": {
"cannot_connect": "Failed to connect to server.",
"invalid_api_key": "Invalid API key.",
"unknown": "Unexpected error occurred."
},
"abort": {
"already_configured": "This server is already configured."
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
}
},
"sensor": {
"fps": {
"name": "FPS"
},
"status": {
"name": "Status",
"state": {
"processing": "Processing",
"idle": "Idle",
"error": "Error",
"unavailable": "Unavailable"
}
},
"mapped_lights": {
"name": "Mapped Lights"
}
},
"number": {
"brightness": {
"name": "Brightness"
},
"ha_light_update_rate": {
"name": "Update Rate"
},
"ha_light_transition": {
"name": "Transition"
},
"ha_light_min_brightness": {
"name": "Min Brightness"
},
"ha_light_color_tolerance": {
"name": "Color Tolerance"
}
},
"select": {
"color_strip_source": {
"name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
}
}
}
}
@@ -1,87 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Настройка LED Screen Controller",
"description": "Введите URL и API-ключ вашего сервера LED Screen Controller.",
"data": {
"server_name": "Имя сервера",
"server_url": "URL сервера",
"api_key": "API-ключ"
},
"data_description": {
"server_name": "Отображаемое имя сервера в Home Assistant",
"server_url": "URL сервера LED Screen Controller (например, http://192.168.1.100:8080)",
"api_key": "API-ключ из конфигурационного файла сервера"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться к серверу.",
"invalid_api_key": "Неверный API-ключ.",
"unknown": "Произошла непредвиденная ошибка."
},
"abort": {
"already_configured": "Этот сервер уже настроен."
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Подсветка"
}
},
"switch": {
"processing": {
"name": "Обработка"
}
},
"sensor": {
"fps": {
"name": "FPS"
},
"status": {
"name": "Статус",
"state": {
"processing": "Обработка",
"idle": "Ожидание",
"error": "Ошибка",
"unavailable": "Недоступен"
}
},
"mapped_lights": {
"name": "Привязанные светильники"
}
},
"number": {
"brightness": {
"name": "Яркость"
},
"ha_light_update_rate": {
"name": "Частота обновления"
},
"ha_light_transition": {
"name": "Переход"
},
"ha_light_min_brightness": {
"name": "Мин. яркость"
},
"ha_light_color_tolerance": {
"name": "Допуск цвета"
}
},
"select": {
"color_strip_source": {
"name": "Источник цветовой полосы"
},
"brightness_source": {
"name": "Источник яркости"
}
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
# WLED Screen Controller API Documentation
# LedGrab API Documentation
Complete REST API reference for the WLED Screen Controller server.
Complete REST API reference for the LedGrab server.
**Base URL:** `http://localhost:8080`
**API Version:** v1
+109
View File
@@ -0,0 +1,109 @@
# BLE LED Controllers — Investigation & Implementation Notes
Reference for anyone touching the BLE device provider (`server/src/ledgrab/core/devices/ble_*`). Captures the protocol quirks, Windows/bleak traps, and hardware lockdown we hit while bringing up SP110E / Triones / Zengge / Govee support.
## Architecture
```
BLEDeviceProvider → BLEClient → BLETransport (desktop: bleak, Android: Kotlin BleBridge via Chaquopy)
└─ BLEProtocol (family-specific wire bytes: sp110e.py, triones.py, zengge.py, govee.py)
```
- One `BLEProtocol` dataclass per controller family. Each supplies GATT UUIDs, write type (with/without response), `encode_color` / `encode_power` functions, name prefixes for discovery, and an optional `init_writes` handshake sequence.
- `BLEClient` is whole-strip only. `send_pixels()` averages incoming pixel arrays and emits one solid color per frame — none of these protocols support per-pixel streaming.
- Discovery auto-detects the family via advertised name prefix first, falls back to service UUID matching. The detected family is returned on `DiscoveredDevice.ble_family` and preselected in the UI.
- The settings modal lets users change the family after creation — wrong family → writes go to a characteristic the device ignores → strip stays dark.
## Protocol Quirks
### SP110E / SP108E (critical handshake)
The controller **silently tears the GATT link down within ~1 second of connect** unless a two-write handshake arrives immediately:
```
Write 01 00 → characteristic FFE2
Write 01 B7 E3 D5 → characteristic FFE1
```
Without this, the first real write later hangs for 30 s because bleak thinks the link is up but the peripheral has already dropped it. We carry these writes in `PROTOCOL.init_writes` and execute them from `BLEClient.connect()` right after GATT open.
Color frame is **4 bytes** (`RR GG BB 1E`), not 5 — the earlier implementation had a stray `0x00` padding byte that the device tolerated but isn't documented.
Source: [mbullington's reverse-engineering gist](https://gist.github.com/mbullington/37957501a07ad065b67d4e8d39bfe012).
### Triones / Zengge / Govee
No init handshake required. Color frames and command bytes documented inline in each protocol module. Notable: Zengge and SP110E share service UUID `FFE0/FFE1`, so name-based identification is the only reliable way to tell them apart. In `_register_builtins()`, SP110E is registered first so it wins the `identify_family_by_service_uuids` tie by default — change this if the user base flips.
## bleak + Windows WinRT Traps
These bit us hard. All are now worked around, but future BLE work should keep them in mind.
### 1. `asyncio.wait_for` hangs forever on WinRT
`BleakClient.connect()` / `write_gatt_char()` wrap WinRT `IAsyncOperation`s. When asyncio tries to cancel them (as `wait_for` does on timeout), the WinRT task **never finishes cancelling**, so `wait_for` itself blocks forever while awaiting the cancellation. Symptom: log stops with no timeout error, process is alive but wedged.
**Fix**: `_bounded_await()` in [ble_transport.py](../server/src/ledgrab/core/devices/ble_transport.py) uses `asyncio.wait()` instead, which returns on timeout without awaiting pending tasks. Orphans the hanging WinRT task but frees the caller.
### 2. Connect by raw MAC string fails on Windows
Passing `BleakClient("AA:BB:CC:DD:EE:FF")` makes WinRT guess the address type (public vs random static vs random resolvable). Guesses wrong → connect silently times out. Symptom: `TimeoutError: BLE connect to ... exceeded 10.0s` with no other signal.
**Fix**: Always pre-scan with `BleakScanner.find_device_by_address()` and pass the returned `BLEDevice` object to `BleakClient`. Costs ~400 ms but makes connect reliable.
### 3. Client-side fetch timeout too short for BLE target start
The target-start endpoint does a ~5 s pre-scan + up to 10 s GATT connect + init handshake. Default `fetchWithAuth` has a 10 s timeout and 3× retry, so the UI was aborting and retrying concurrent `/start` requests into the server.
**Fix**: `startTargetProcessing` overrides `timeout: 30000, retry: false`.
### 4. `Start-Process -WindowStyle Hidden` from bash/WSL strips handles
When `restart.ps1` is invoked from Git-Bash / WSL, `Start-Process` inherited handles cause the child uvicorn to exit immediately. Stream redirection fixes it.
**Fix**: `restart.ps1` always uses `-RedirectStandardOutput`/`-RedirectStandardError` to a temp log. Failed startups dump the stderr tail to the caller so root cause is visible.
## Vendor Lockdown (the dead end)
Some controllers — notably the one we tested, advertising as `AlexTable` at `16:61:05:70:68:44`**only accept connections from the vendor phone app**. Diagnostic sequence:
| Test | Result | Meaning |
| --- | --- | --- |
| LedGrab `BleakClient.connect()` | 10 s timeout | Windows can't connect |
| Windows "Bluetooth LE Explorer" | Hangs on connect | Same Windows stack as bleak — not our bug |
| Phone **OS** Bluetooth Settings | Can't connect | Phone OS uses generic BLE stack — also fails |
| Phone **LED Hue** app | Connects fine | Vendor app is the *only* working client |
At this point, further Windows/bleak tweaks have no effect. The peripheral firmware rejects generic GATT connects and only stays connected when the LED Hue app emits its vendor-specific handshake. To unlock such a controller from LedGrab you'd need to:
1. Enable **Developer Options → Bluetooth HCI snoop log** on Android.
2. Reproduce the LED Hue flow (connect → color change → disconnect).
3. `adb bugreport bugreport.zip`; extract `btsnoop_hci.log`.
4. Open in Wireshark; identify the vendor handshake bytes written during connect.
5. Add them to the protocol's `init_writes`.
Alternatively, replace the BLE controller hardware with **WLED on ESP32** — $3, fully supported, vastly more capable.
## Frontend
- BLE family picker uses the project's shared `IconSelect` grid (project rule — see [CLAUDE.md](../CLAUDE.md): "NEVER use plain HTML `<select>`").
- Registry in `device-discovery.ts` is keyed by element ID so both the add-device and settings modals get their own IconSelect instance. Helpers: `ensureBleFamilyIconSelect(selectId, onChange?)` / `destroyBleFamilyIconSelect(selectId)`.
- Govee AES key row is conditionally visible: only shows when the selected family is `govee`.
## HAOS Integration Pair
The sister repo `ledgrab-haos-integration` had its own WebSocket auth bug that surfaced during this session — the integration still used the deprecated `?token=<key>` query param instead of the new first-message handshake. Fixed in [v0.2.1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration). Unrelated to BLE but shared debugging time.
## Tests
`server/tests/test_ble_protocols.py` and `server/tests/test_ble_client.py` use a `FakeTransport` that logs every write with its `char_uuid`, so protocol wire formats and the init handshake are unit-testable without hardware or bleak installed. New protocol additions should extend these.
## Files
- [ble_client.py](../server/src/ledgrab/core/devices/ble_client.py) — provider-facing class; runs init handshake on connect; reconnect backoff.
- [ble_transport.py](../server/src/ledgrab/core/devices/ble_transport.py) — bleak desktop transport; `_bounded_await` helper; per-write char override.
- [android_ble_transport.py](../server/src/ledgrab/core/devices/android_ble_transport.py) — Chaquopy/Kotlin transport; currently ignores `char_uuid` override (bridge binds a single write characteristic).
- [ble_provider.py](../server/src/ledgrab/core/devices/ble_provider.py) — discovery, family detection, `set_color` / `set_power` short-lived sessions.
- [ble_protocols/](../server/src/ledgrab/core/devices/ble_protocols/) — one file per family (pure byte-encoding functions, no BLE deps).
- [BleBridge.kt](../android/app/src/main/java/com/ledgrab/android/BleBridge.kt) — Android-side BLE GATT wrapper exposed to Python via Chaquopy.
+274
View File
@@ -0,0 +1,274 @@
# Refactor Plan: Per-Provider Typed Device Configs
**Status:** Planned, not started.
**Target branch:** `refactor/device-typed-configs`
**Intended executor:** Sonnet agent (one phase per invocation; human review between phases).
## Goal
Replace the flat [`DeviceInfo`](../../server/src/ledgrab/core/processing/target_processor.py) dataclass (and the `**kwargs`-based `LEDDeviceProvider.create_client(url, **kwargs)` contract) with a **discriminated union of per-provider config dataclasses**. Each provider owns its config type and reads typed fields instead of guessing kwargs.
## Motivation
Current pain points:
- [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) unpacks ~21 fields by hand into `create_led_client(**kwargs)`.
- Every provider's `create_client` starts with `kwargs.get("x", default)` — no type safety, no IDE hints, no way to know at a glance which fields a provider actually uses.
- Adding a new per-device-type field requires threading it through `Device``DeviceInfo``_DEVICE_FIELD_DEFAULTS` → call-site unpacking → kwargs bag → provider.
- Fields leak across device types (a WLED device carries `ble_govee_key=""` at runtime for no reason).
## Scope guardrails
- **Storage schema (SQLite) unchanged.** Columns stay, dead-for-this-type fields stay, no destructive migration.
- **Frontend HTML/TS unchanged in phases 1-4.** It already branches on `device_type` with show/hide logic. Frontend changes are deferred to Phase 5.
- **API schemas are last.** Phase 5 converts `DeviceCreate`/`DeviceUpdate`/`DeviceResponse` to a Pydantic v2 discriminated union. This is the only breaking external change and can be deferred indefinitely if needed.
---
## Phase 1 — Config hierarchy (foundation, non-breaking)
### Create
**File:** `server/src/ledgrab/core/devices/device_config.py`
Pattern:
```python
from dataclasses import dataclass
from typing import List, Literal, Optional, Union
@dataclass(frozen=True)
class BaseDeviceConfig:
device_id: str
device_url: str
led_count: int
software_brightness: int = 255
test_mode_active: bool = False
auto_shutdown: bool = False
rgbw: bool = False
@dataclass(frozen=True)
class WLEDConfig(BaseDeviceConfig):
device_type: Literal["wled"] = "wled"
use_ddp: bool = False
# ... one @dataclass(frozen=True) per provider
```
### Config field inventory
Base: `device_id`, `device_url`, `led_count`, `software_brightness`, `test_mode_active`, `auto_shutdown`, `rgbw`.
| Config | Extra fields beyond Base |
| -------------- | ------------------------ |
| WLEDConfig | `use_ddp: bool = False` |
| AdalightConfig | `baud_rate: Optional[int] = None` |
| AmbiLEDConfig | `baud_rate: Optional[int] = None` |
| DMXConfig | `dmx_protocol`, `dmx_start_universe`, `dmx_start_channel` |
| ESPNowConfig | `baud_rate`, `espnow_peer_mac`, `espnow_channel` |
| HueConfig | `hue_username`, `hue_client_key`, `hue_entertainment_group_id` |
| SPIConfig | `spi_speed_hz`, `spi_led_type` |
| ChromaConfig | `chroma_device_type` |
| GameSenseConfig| `gamesense_device_type` |
| BLEConfig | `ble_family`, `ble_govee_key` |
| GroupConfig | `group_mode`, `group_device_ids` (**no `device_store` here** — see Phase 2) |
| OpenRGBConfig | `zone_mode` |
| MockConfig | `send_latency_ms: int = 0` |
| DemoConfig | `send_latency_ms: int = 0` |
| MQTTConfig | (none) |
| WSConfig | (none) |
| USBHIDConfig | (none — `hid_usage_page` is parsed from the URL, not config) |
```python
DeviceConfig = Union[
WLEDConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, ESPNowConfig,
HueConfig, SPIConfig, ChromaConfig, GameSenseConfig, BLEConfig,
GroupConfig, MQTTConfig, WSConfig, USBHIDConfig, OpenRGBConfig,
MockConfig, DemoConfig,
]
```
### Add
**`Device.to_config() -> DeviceConfig`** in [server/src/ledgrab/storage/device_store.py](../../server/src/ledgrab/storage/device_store.py) (around lines 14-97 where `Device` lives).
- Dispatches on `self.device_type`.
- Constructs the right subclass, pulling only relevant columns.
- Ignores columns that don't apply to the type.
- This is the **only** place that knows the flat→typed mapping.
### Do NOT touch in Phase 1
- Provider signatures (still `create_client(self, url, **kwargs)`).
- `create_led_client` factory.
- Any call site.
- `DeviceInfo` itself.
### Acceptance
- New unit test `server/tests/core/devices/test_device_config.py`:
- For each provider, build a `Device` with that `device_type`, call `to_config()`, assert right subclass and right fields.
- Edge case: extra/irrelevant Device fields must not leak into the wrong config type.
- `cd server && ruff check src/ tests/ --fix` — green.
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green (existing tests untouched, new test passes).
- `cd server && npx tsc --noEmit` — green (no TS impact this phase, just a sanity check).
---
## Phase 2 + Phase 3 — Provider API migration + call-site migration (single PR)
**These must land in one commit** because the provider signature change would otherwise break the 3 call sites immediately.
### Change the abstract base
[server/src/ledgrab/core/devices/led_client.py](../../server/src/ledgrab/core/devices/led_client.py):
```python
class LEDDeviceProvider(ABC):
@abstractmethod
def create_client(self, config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient: ...
```
`ProviderDeps` is a tiny new dataclass:
```python
@dataclass(frozen=True)
class ProviderDeps:
device_store: "DeviceStore"
# Add future cross-cutting runtime deps here (http_client, etc.)
```
`create_led_client`:
```python
def create_led_client(config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient:
return get_provider(config.device_type).create_client(config, deps=deps)
```
### Update every provider (17 files)
- Narrow signature per provider: e.g. `WLEDDeviceProvider.create_client(self, config: WLEDConfig, *, deps: ProviderDeps)`.
- Drop all `kwargs.get("x")` lookups — read typed fields directly.
- Providers that don't need `deps` just ignore it.
- **GroupDeviceProvider** is the only current consumer of `deps`: reads `deps.device_store`.
### Call sites (3)
1. [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) lines ~120-148 — the 21-field unpacking. Replace with:
```python
config = device.to_config()
self._led_client = create_led_client(config, deps=self._provider_deps)
```
`self._provider_deps` is plumbed in from `ProcessorManager` when the target processor is constructed.
2. [server/src/ledgrab/core/processing/device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78 — minimal test-mode client. Build a synthetic config via a helper `_minimal_config_for_test_mode(device)` (keeps just `device_id`, `device_url`, `led_count`, `baud_rate`) and pass it.
3. [server/src/ledgrab/core/devices/group_client.py](../../server/src/ledgrab/core/devices/group_client.py) lines 47-70 — child client construction inside the group. Same pattern: `child_config = child_device.to_config()`; pass `deps` through.
### Delete
- `DeviceInfo` dataclass in [server/src/ledgrab/core/processing/target_processor.py](../../server/src/ledgrab/core/processing/target_processor.py) lines 71-109.
- `ProcessorManager._get_device_info()` and `_DEVICE_FIELD_DEFAULTS` in [server/src/ledgrab/core/processing/processor_manager.py](../../server/src/ledgrab/core/processing/processor_manager.py) lines 230-275 — `Device.to_config()` subsumes this. Verify no other callers via `ast-index usages "_get_device_info"`.
### Acceptance
- `ast-index search "device_info\."` — no hits in non-test code.
- `ast-index search "DeviceInfo"` — no hits outside archival comments.
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all tests pass.
- Manual smoke: start server, create a WLED device, start processing, verify LEDs update (or mock output shows frames).
- `cd server && ruff check src/ tests/ --fix` — green.
---
## Phase 4 — Test migration
Update these files:
- `server/tests/storage/test_device_store.py` — add `to_config()` cases per device type.
- `server/tests/api/routes/test_devices_routes.py` — should be mostly untouched (API schemas still flat until Phase 5).
- `server/tests/e2e/test_device_flow.py` — update internal assertions only if they touch `DeviceInfo` directly.
- `server/tests/test_group_device.py` — construct child clients with `GroupConfig`.
- Any fixture helper that builds a fake `DeviceInfo` — migrate to the right `*Config` subclass.
### Acceptance
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all green.
- Coverage of `device_config.py` and `Device.to_config()` ≥ 90%.
---
## Phase 5 — API discriminated union (OPTIONAL, separate PR)
**Do not start until Phases 1-4 are merged and stable.** Flag this to the human before beginning. This is the only phase with an externally breaking change.
### Backend
[server/src/ledgrab/api/schemas/devices.py](../../server/src/ledgrab/api/schemas/devices.py) — replace flat `DeviceCreate`/`DeviceUpdate` with Pydantic v2 tagged unions:
```python
class WLEDDeviceCreate(BaseModel):
device_type: Literal["wled"]
name: str
url: str
led_count: int
use_ddp: bool = False
# ... base fields only
DeviceCreate = Annotated[
Union[WLEDDeviceCreate, AdalightDeviceCreate, ...],
Field(discriminator="device_type"),
]
```
Add `model_config = ConfigDict(extra="ignore")` on each union member for **one release cycle** so existing clients (frontend, HAOS integration, curl scripts) that send extra fields don't 422 immediately. Add a deprecation note and tighten to `extra="forbid"` in a follow-up.
### Frontend
- [server/src/ledgrab/static/js/features/devices.ts](../../server/src/ledgrab/static/js/features/devices.ts) and related — when building the POST/PATCH body, scope the payload to the selected `device_type` using the show/hide knowledge already in `device-discovery.ts`.
- **No plain `<select>` elements** — any new pickers use IconSelect or EntitySelect (see root CLAUDE.md UI rules).
### Tests
- Update `test_devices_routes.py` to assert discriminated union rejection of mismatched shapes.
- Add round-trip tests: create device of each type via API → fetch → compare fields.
### Acceptance
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green.
- `cd server && npx tsc --noEmit && npm run build` — green.
- Manual smoke for at least 3 device types (WLED, DMX, Hue) — create, edit, delete via UI.
- HAOS integration still works against the server (spot-check; not automated).
---
## Conventions the implementing agent must follow
- **Project task tracker is `TODO.md`** — check the "Refactor: Per-Provider Device Configs" section, tick boxes as phases land. Do **not** use the `TodoWrite` tool.
- **Auto-restart after Python changes.** See [contexts/server-operations.md](../../contexts/server-operations.md).
- **No commits without explicit user approval.** Present each phase's diff for review first.
- **Pre-commit gate every phase:**
- `cd server && ruff check src/ tests/ --fix`
- `cd server && py -3.13 -m pytest tests/ --no-cov -q`
- Phase 5 additionally: `cd server && npx tsc --noEmit && npm run build`
- **No plain `<select>`** — Phase 5 uses IconSelect / EntitySelect.
- **Android parity:** if you add any new runtime dep to `server/pyproject.toml`, update `android/app/build.gradle.kts` per the root [CLAUDE.md](../../CLAUDE.md) "Android Dependency Sync" section. This refactor should not need any new deps.
- **Data migration policy:** storage schema is unchanged, so no JSON-file migration is needed. But if you rename any serialized field during `to_dict`/`from_dict`, add migration logic per the root [CLAUDE.md](../../CLAUDE.md) "Data Migration Policy" section.
- **Use `ast-index`** for code search (`ast-index search`, `ast-index usages`, `ast-index callers`, `ast-index class`). Fall back to Grep only for regex/string-literal/comment searches.
- **Never run `cd` in Bash.** Use absolute paths or the project-relative `cd server && <cmd>` idiom (one-shot, same invocation).
## Known risks
1. **Frozen dataclass + inheritance + defaults** — Python's `@dataclass(frozen=True)` with inheritance requires every subclass field to have a default if any parent field does. Base has defaulted fields. Verify in Phase 1. If it breaks, use `kw_only=True` (Python 3.10+).
2. **`use_ddp` origin** — currently inferred from `self._protocol == "ddp"` at the call site, not from Device storage. Options: add a column (schema change, more work), **or** keep inference logic inside `Device.to_config()` (recommended — no schema change). Prefer the latter.
3. **Test-mode minimal client** ([device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78) may not have all `BaseDeviceConfig` fields available. Build a synthetic config via a named helper; do not leak the hack into `Device.to_config()`.
4. **Group `device_store` import cycle** — `GroupConfig` must **not** hold `device_store` (would pull storage into the config module). `ProviderDeps` is the deliberate cut.
5. **BLE optional import** — `BLEDeviceProvider` is conditionally registered (see [led_client.py](../../server/src/ledgrab/core/devices/led_client.py) lines 321-330). Ensure `BLEConfig` still imports cleanly even when `bleak` is absent — put `BLEConfig` in `device_config.py` (not in `ble_provider.py`) so it's always importable.
## Deliverables per phase
1. Branch: `refactor/device-typed-configs`.
2. One commit per phase, conventional-commit messages:
- `refactor(devices): phase 1 — add DeviceConfig hierarchy`
- `refactor(devices): phases 2+3 — typed provider signatures + call-site migration`
- `refactor(devices): phase 4 — test migration to typed configs`
- `refactor(devices): phase 5 — API discriminated union` (separate PR)
3. Phase-by-phase diffs presented for user review **before** each commit.
4. Final PR body linking all phases, with manual test plan per device type touched.
-6
View File
@@ -1,6 +0,0 @@
{
"name": "WLED Screen Controller",
"render_readme": true,
"country": ["US"],
"homeassistant": "2023.1.0"
}
-109
View File
@@ -1,109 +0,0 @@
# math_wave Color Strip Source — Implementation Plan
## Overview
A new CSS type that generates LED colors from configurable mathematical wave functions. Each LED position gets a wave value based on spatial position and time, mapped to a color via a gradient palette. Supports multiple superimposed wave layers, sync clocks, and bindable parameters.
## Requirements
- Waveform types: sine, triangle, sawtooth, square
- Parameters per wave layer: waveform, frequency, amplitude, phase, offset
- Global parameters: speed (bindable), gradient_id (color mapping)
- Wave superposition: list of wave layers combined additively
- Spatial dimension: wave value depends on LED position (0.0-1.0) + time
- Sync clock integration for time parameter
- Color mapping: combined wave output (0.0-1.0) mapped through a gradient
## Phase 1: Storage Model
**File: `server/src/wled_controller/storage/color_strip_source.py`**
- Add `MathWaveColorStripSource` dataclass after `GameEventColorStripSource`
- Fields:
- `waves: list` — default `[{"waveform": "sine", "frequency": 1.0, "amplitude": 1.0, "phase": 0.0, "offset": 0.0}]`
- `speed: BindableFloat` — default 1.0
- `gradient_id: Optional[str]` — references Gradient entity
- Implement `to_dict`, `from_dict`, `create_from_kwargs`, `apply_update` (follow `CandlelightColorStripSource` pattern)
- Valid waveforms: `{"sine", "triangle", "sawtooth", "square"}`
- Add `"math_wave": MathWaveColorStripSource` to `_SOURCE_TYPE_MAP`
## Phase 2: Stream Implementation
**File: `server/src/wled_controller/core/processing/math_wave_stream.py`** (new)
- Class `MathWaveColorStripStream(ColorStripStream)` following `CandlelightColorStripStream` pattern
- Key methods: `__init__`, `_update_from_source`, `configure`, `start`, `stop`, `get_latest_colors`, `update_source`, `set_clock`, `set_gradient_store`
- Animation loop (`_animate_loop`):
- Get `t` from clock (if set) or wall clock
- For each LED at normalized position `p = i / (N-1)`:
- Sum all wave layers: `sum += amplitude * waveform(2*pi*frequency*(p + speed*t) + phase) + offset`
- Clamp result to [0.0, 1.0]
- Map per-LED values to RGB via gradient LUT
- Waveform functions (vectorized with numpy):
- `sine`: `0.5 + 0.5 * np.sin(x)`
- `triangle`: `2.0 * np.abs(np.mod(x / (2*pi), 1.0) - 0.5)`
- `sawtooth`: `np.mod(x / (2*pi), 1.0)`
- `square`: `(np.sin(x) >= 0).astype(float)`
- Double-buffering pattern (same as candlelight)
- Use `self.resolve("speed", self._speed)` for bindable speed
- Gradient resolution via `set_gradient_store` pattern
## Phase 3: API Integration
**File: `server/src/wled_controller/api/schemas/color_strip_sources.py`**
- Add `MathWaveCSSResponse`, `MathWaveCSSCreate`, `MathWaveCSSUpdate`
- Add to `ColorStripSourceResponse`, `ColorStripSourceCreate`, `ColorStripSourceUpdate` unions
**File: `server/src/wled_controller/core/processing/color_strip_stream_manager.py`**
- Import `MathWaveColorStripStream`
- Add `"math_wave": MathWaveColorStripStream` to `_SIMPLE_STREAM_MAP`
- Existing `set_gradient_store` and `_inject_clock` injection handles it automatically
## Phase 4: Frontend
**File: `server/src/wled_controller/static/js/types.ts`**
- Add `'math_wave'` to `CSSSourceType` union
**File: `server/src/wled_controller/static/js/core/icons.ts`**
- Add `math_wave: _svg(P.activity)` to `_colorStripTypeIcons`
**File: `server/src/wled_controller/templates/modals/css-editor.html`**
- Add `<option value="math_wave">` to type select
- Add `<div id="css-editor-math-wave-section">` with:
- Gradient picker (EntitySelect)
- Speed (BindableScalarWidget)
- Wave layers list (dynamic rows: waveform IconSelect, frequency/amplitude/phase/offset inputs, add/remove buttons)
**File: `server/src/wled_controller/static/js/features/color-strips.ts`**
- Add to `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, `NON_PICTURE_TYPES`
- Add to `clockTypes` array in `saveCSSEditor`
- Add type handler: `load(css)`, `reset()`, `getPayload(name)`
- Add card renderer showing wave count, gradient swatch, speed, clock badge
- Add wave layer management: `_renderMathWaveRow`, `addMathWaveLayer`, `removeMathWaveLayer`
**Files: `en.json`, `ru.json`, `zh.json`**
- Add i18n keys for type name, description, all field labels, waveform names
## Phase 5: Testing
**File: `server/tests/core/test_math_wave_stream.py`** (new)
- Test wave functions produce expected values at known inputs
- Test single wave spatial pattern
- Test wave superposition
- Test gradient color mapping
- Test clock integration
- Test `update_source` hot-update
- Test `configure` auto-sizing
**File: `server/tests/e2e/test_color_strip_flow.py`**
- Add `test_math_wave_crud` to lifecycle tests
**Storage model tests:**
- Test `from_dict` roundtrip
- Test `create_from_kwargs` with valid/invalid waveforms
- Test `apply_update`
- Test `_SOURCE_TYPE_MAP` dispatch
## Risks & Mitigations
- **Wave layer UI complexity** — Follow existing composite layers / game event mappings patterns
- **Performance with many layers** — Vectorize with numpy; cap max wave layers to 8
- **Gradient resolution** — Stream manager already injects `set_gradient_store` automatically
-151
View File
@@ -1,151 +0,0 @@
# music_sync Color Strip Source — Implementation Plan
## Overview
A higher-level music-reactive CSS that provides semantic audio analysis (BPM detection, beat tracking, energy envelope, drop detection, frequency band energy) and multiple visualization modes. Builds on existing `AudioCaptureManager` and `AudioAnalysis` infrastructure. No new external dependencies — uses numpy-only analysis.
## Requirements
- BPM estimation from real-time audio
- Beat onset detection with configurable threshold
- Smoothed RMS energy envelope with attack/release
- Drop detection: energy drops/buildups
- Frequency band energy: bass (20-250 Hz), mid (250-4k Hz), treble (4k-20k Hz)
- Four visualization modes: `pulse_on_beat`, `energy_gradient`, `spectrum_bands`, `strobe_on_drop`
- Uses existing audio engine infrastructure (no new audio capture code)
- No external dependencies beyond numpy
## Phase 1: Music Analysis Engine
**File: `server/src/wled_controller/core/audio/music_analyzer.py`** (new)
### 1.1 `MusicFeatures` dataclass (frozen=True)
- `bpm: float` — estimated BPM (0 if unknown)
- `beat: bool` — beat detected this frame
- `beat_intensity: float` — 0.0-1.0
- `beat_phase: float` — 0.0-1.0 position in beat cycle
- `energy: float` — smoothed RMS 0.0-1.0
- `energy_delta: float` — rate of change
- `bass_energy, mid_energy, treble_energy: float` — 0.0-1.0
- `drop_state: str` — "idle"|"buildup"|"drop"|"recovery"
- `drop_intensity: float` — 0.0-1.0
### 1.2 `MusicAnalyzer` class
- **State**: rolling energy buffer (~4s at ~43 Hz = ~172 samples), beat history timestamps (last 30), smoothed band energies, BPM estimate, drop state machine
- **`update(analysis: AudioAnalysis) -> MusicFeatures`**: main entry point
- **BPM estimation**: Track beat timestamps, compute median inter-beat interval, exponential smoothing, clamp 40-220 BPM
- **Beat tracking**: Pass through `AudioAnalysis.beat` + compute `beat_phase` (position in current beat cycle)
- **Energy envelope**: Smoothed RMS with configurable attack/release
- **Drop detection**: State machine: `idle -> buildup` (energy rising steadily 1-2s), `buildup -> drop` (energy drops >50% within 100ms), `drop -> recovery` (after 500ms), `recovery -> idle`
- **Frequency bands**: Sum spectrum bins into 3 bands from 64-band spectrum
## Phase 2: Storage Model
**File: `server/src/wled_controller/storage/color_strip_source.py`**
- Add `MusicSyncColorStripSource` dataclass after `AudioColorStripSource`
- Fields:
- `visualization_mode: str` — default `"pulse_on_beat"`
- `audio_source_id: str` — references AudioSource
- `sensitivity: BindableFloat` — default 1.0
- `smoothing: BindableFloat` — default 0.3
- `palette: str` — default `"rainbow"`
- `gradient_id: Optional[str]`
- `color: BindableColor` — primary color
- `color_secondary: BindableColor` — for two-color modes
- `beat_decay: BindableFloat` — default 0.15
- `led_count: int` — 0 = auto-size
- `mirror: bool`
- Add `"music_sync": MusicSyncColorStripSource` to `_SOURCE_TYPE_MAP`
## Phase 3: Stream Implementation
**File: `server/src/wled_controller/core/processing/music_sync_stream.py`** (new)
- Class `MusicSyncColorStripStream(ColorStripStream)` following `AudioColorStripStream` pattern
- Constructor: accept source + audio_capture_manager + stores. Create `MusicAnalyzer` instance
- `start()`: Acquire audio stream, start background thread
- `stop()`: Release audio stream, stop thread
- `_animate_loop()`:
1. Get `AudioAnalysis` from audio stream
2. Apply audio filter pipeline (if any)
3. Feed to `MusicAnalyzer.update()``MusicFeatures`
4. Dispatch to visualization renderer
5. Double-buffer output
### Visualization Renderers
- **`pulse_on_beat`**: Full-strip flash on beat with exponential decay. Between beats: sine-wave pulsing synced to BPM. Color from palette indexed by beat_intensity.
- **`energy_gradient`**: Maps bass→warm, treble→cool. Overall brightness from energy. Gradient scrolls with beat_phase.
- **`spectrum_bands`**: 3 zones (bass/mid/treble), each fills proportionally to band energy. Mirror mode: bass center, treble edges.
- **`strobe_on_drop`**: Idle=gentle breathing. Buildup=increasing pulse. Drop=rapid strobe at 10 Hz. Recovery=fade back.
## Phase 4: API Integration
**File: `server/src/wled_controller/api/schemas/color_strip_sources.py`**
- Add `MusicSyncCSSResponse`, `MusicSyncCSSCreate`, `MusicSyncCSSUpdate`
- Add to all three union types
**File: `server/src/wled_controller/api/routes/color_strip_sources.py`**
- Import + add to `_RESPONSE_MAP`
**File: `server/src/wled_controller/core/processing/color_strip_stream_manager.py`**
- Add `music_sync` branch in `acquire()` (same pattern as `audio` branch)
- Update `refresh_audio_filter_pipelines()` to include `MusicSyncColorStripStream`
## Phase 5: Frontend
**File: `server/src/wled_controller/static/js/core/icons.ts`**
- Add `music_sync: _svg(P.radio)` icon
**File: `server/src/wled_controller/templates/modals/css-editor.html`**
- Add `<div id="css-editor-music-sync-section">` with:
- Visualization mode selector (IconSelect)
- Audio source dropdown
- Sensitivity, Smoothing, Beat Decay (BindableScalarWidget containers)
- Palette/gradient selector (EntitySelect)
- Primary + Secondary color (BindableColorWidget)
- Mirror checkbox
**File: `server/src/wled_controller/static/js/features/color-strips-music-sync.ts`** (new, extracted)
- Editor logic, widget factories, card renderer
**File: `server/src/wled_controller/static/js/features/color-strips.ts`**
- Register type in `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, `NON_PICTURE_TYPES`
- Import and register from `color-strips-music-sync.ts`
**Files: `en.json`, `ru.json`, `zh.json`**
- Add i18n keys for type, visualization modes, all field labels
## Phase 6: Testing
**File: `server/tests/core/audio/test_music_analyzer.py`** (new)
- BPM estimation from regular beats (within 5 BPM accuracy)
- BPM handles no beats gracefully
- Beat phase progression
- Energy envelope attack/release
- Frequency band splitting
- Drop detection state machine transitions
- No false drops on steady signal
**File: `server/tests/core/processing/test_music_sync_stream.py`** (new)
- Stream lifecycle (start/stop)
- Produces valid colors
- Hot-update parameters
- Auto-size from device
- All 4 visualization modes produce valid (n,3) uint8 arrays
- Mirror mode symmetry
**Storage + API tests:**
- `from_dict` roundtrip
- CRUD via test client
## Risks & Mitigations
- **BPM accuracy** — Median-of-recent-IBIs is robust for dance/electronic. For ambient, BPM noisy but `energy_gradient` and `spectrum_bands` don't depend on BPM
- **Drop detection false positives** — Require minimum energy threshold + sustained increase before buildup state
- **Frontend file size** — Extract to `color-strips-music-sync.ts` (following composite/notification pattern)
- **Strobe photosensitivity** — Cap at 10 Hz (below 15-25 Hz danger zone), add UI warning
- **Thread safety** — `MusicAnalyzer` owned exclusively by one stream thread, no shared access
## Dependency Order
`math_wave` should be implemented first (simpler, no audio dependency), then `music_sync`.
+22 -22
View File
@@ -1,41 +1,41 @@
# WLED Screen Controller — Environment Variables
# LedGrab — Environment Variables
# Copy this file to .env and adjust values as needed.
# All variables use the WLED_ prefix with __ (double underscore) as the nesting delimiter.
# All variables use the LEDGRAB_ prefix with __ (double underscore) as the nesting delimiter.
# ── Server ──────────────────────────────────────────────
# WLED_SERVER__HOST=0.0.0.0 # Listen address (default: 0.0.0.0)
# WLED_SERVER__PORT=8080 # Listen port (default: 8080)
# WLED_SERVER__LOG_LEVEL=INFO # Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
# WLED_SERVER__CORS_ORIGINS=["*"] # JSON array of allowed CORS origins
# LEDGRAB_SERVER__HOST=0.0.0.0 # Listen address (default: 0.0.0.0)
# LEDGRAB_SERVER__PORT=8080 # Listen port (default: 8080)
# LEDGRAB_SERVER__LOG_LEVEL=INFO # Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
# LEDGRAB_SERVER__CORS_ORIGINS=["*"] # JSON array of allowed CORS origins
# ── Authentication ──────────────────────────────────────
# API keys are required. Format: JSON object {"label": "key"}.
# WLED_AUTH__API_KEYS={"dev": "development-key-change-in-production"}
# LEDGRAB_AUTH__API_KEYS={"dev": "development-key-change-in-production"}
# ── Storage ────────────────────────────────────────────
# All data is stored in a single SQLite database.
# WLED_STORAGE__DATABASE_FILE=data/ledgrab.db
# LEDGRAB_STORAGE__DATABASE_FILE=data/ledgrab.db
# ── MQTT (optional) ────────────────────────────────────
# WLED_MQTT__ENABLED=false
# WLED_MQTT__BROKER_HOST=localhost
# WLED_MQTT__BROKER_PORT=1883
# WLED_MQTT__USERNAME=
# WLED_MQTT__PASSWORD=
# WLED_MQTT__CLIENT_ID=ledgrab
# WLED_MQTT__BASE_TOPIC=ledgrab
# LEDGRAB_MQTT__ENABLED=false
# LEDGRAB_MQTT__BROKER_HOST=localhost
# LEDGRAB_MQTT__BROKER_PORT=1883
# LEDGRAB_MQTT__USERNAME=
# LEDGRAB_MQTT__PASSWORD=
# LEDGRAB_MQTT__CLIENT_ID=ledgrab
# LEDGRAB_MQTT__BASE_TOPIC=ledgrab
# ── Logging ─────────────────────────────────────────────
# WLED_LOGGING__FORMAT=json # json or text (default: json)
# WLED_LOGGING__FILE=logs/wled_controller.log
# WLED_LOGGING__MAX_SIZE_MB=100
# WLED_LOGGING__BACKUP_COUNT=5
# LEDGRAB_LOGGING__FORMAT=json # json or text (default: json)
# LEDGRAB_LOGGING__FILE=logs/wled_controller.log
# LEDGRAB_LOGGING__MAX_SIZE_MB=100
# LEDGRAB_LOGGING__BACKUP_COUNT=5
# ── Demo mode ───────────────────────────────────────────
# WLED_DEMO=false # Enable demo mode (uses data/demo/ directory)
# LEDGRAB_DEMO=false # Enable demo mode (uses data/demo/ directory)
# ── Config file override ───────────────────────────────
# WLED_CONFIG_PATH= # Absolute path to a YAML config file (overrides all above)
# LEDGRAB_CONFIG_PATH= # Absolute path to a YAML config file (overrides all above)
# ── Docker Compose extras (not part of WLED_ prefix) ───
# ── Docker Compose extras (not part of LEDGRAB_ prefix) ───
# DISPLAY=:0 # X11 display for Linux screen capture
+10 -10
View File
@@ -1,15 +1,15 @@
# Claude Instructions for WLED Screen Controller Server
# Claude Instructions for LedGrab Server
## Project Structure
- `src/wled_controller/main.py` — FastAPI application entry point
- `src/wled_controller/api/routes/` — REST API endpoints (one file per entity)
- `src/wled_controller/api/schemas/` — Pydantic request/response models (one file per entity)
- `src/wled_controller/core/` — Core business logic (capture, devices, audio, processing, automations)
- `src/wled_controller/storage/` — Data models (dataclasses) and JSON persistence stores
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
- `src/wled_controller/static/` — Frontend files (TypeScript, CSS, locales)
- `src/wled_controller/templates/` — Jinja2 HTML templates
- `src/ledgrab/main.py` — FastAPI application entry point
- `src/ledgrab/api/routes/` — REST API endpoints (one file per entity)
- `src/ledgrab/api/schemas/` — Pydantic request/response models (one file per entity)
- `src/ledgrab/core/` — Core business logic (capture, devices, audio, processing, automations)
- `src/ledgrab/storage/` — Data models (dataclasses) and JSON persistence stores
- `src/ledgrab/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
- `src/ledgrab/static/` — Frontend files (TypeScript, CSS, locales)
- `src/ledgrab/templates/` — Jinja2 HTML templates
- `config/` — Configuration files (YAML)
- `data/` — Runtime data (JSON stores, persisted state)
@@ -22,7 +22,7 @@ Each entity follows: dataclass model (`storage/`) + JSON store (`storage/*_store
Server uses API key authentication via Bearer token in `Authorization` header.
- Config: `config/default_config.yaml` under `auth.api_keys`
- Env var: `WLED_AUTH__API_KEYS`
- Env var: `LEDGRAB_AUTH__API_KEYS`
- When `api_keys` is empty (default), auth is disabled — all endpoints are open
- To enable auth, add key entries (e.g. `dev: "your-secret-key"`)
+7 -7
View File
@@ -4,7 +4,7 @@ WORKDIR /build
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
COPY esbuild.mjs tsconfig.json ./
COPY src/wled_controller/static/ ./src/wled_controller/static/
COPY src/ledgrab/static/ ./src/ledgrab/static/
RUN npm run build
## Stage 2: Python application
@@ -16,8 +16,8 @@ LABEL maintainer="Alexei Dolgolyov <dolgolyov.alexei@gmail.com>"
LABEL org.opencontainers.image.title="LED Grab"
LABEL org.opencontainers.image.description="Ambient lighting system that captures screen content and drives LED strips in real time"
LABEL org.opencontainers.image.version="${APP_VERSION}"
LABEL org.opencontainers.image.url="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
LABEL org.opencontainers.image.url="https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
LABEL org.opencontainers.image.licenses="MIT"
WORKDIR /app
@@ -37,16 +37,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# The real source is copied afterward, keeping the dep layer cached.
COPY pyproject.toml .
RUN sed -i "s/^version = .*/version = \"${APP_VERSION}\"/" pyproject.toml \
&& mkdir -p src/wled_controller && touch src/wled_controller/__init__.py \
&& mkdir -p src/ledgrab && touch src/ledgrab/__init__.py \
&& pip install --no-cache-dir ".[notifications]" \
&& rm -rf src/wled_controller
&& rm -rf src/ledgrab
# Copy source code and config (invalidates cache only when source changes)
COPY src/ ./src/
COPY config/ ./config/
# Copy built frontend bundle from stage 1
COPY --from=frontend /build/src/wled_controller/static/dist/ ./src/wled_controller/static/dist/
COPY --from=frontend /build/src/ledgrab/static/dist/ ./src/ledgrab/static/dist/
# Create non-root user for security
RUN groupadd --gid 1000 ledgrab \
@@ -67,4 +67,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
ENV PYTHONPATH=/app/src
# Run the application
CMD ["uvicorn", "wled_controller.main:app", "--host", "0.0.0.0", "--port", "8080"]
CMD ["uvicorn", "ledgrab.main:app", "--host", "0.0.0.0", "--port", "8080"]
+11 -11
View File
@@ -1,4 +1,4 @@
# WLED Screen Controller - Server
# LedGrab - Server
High-performance FastAPI server that captures screen content and controls WLED devices for ambient lighting.
@@ -47,7 +47,7 @@ export PYTHONPATH=$(pwd)/src # Linux/Mac
set PYTHONPATH=%CD%\src # Windows
# Run server
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
```
## Installation
@@ -85,20 +85,20 @@ storage:
logging:
format: "json"
file: "logs/wled_controller.log"
file: "logs/ledgrab.log"
```
### Environment Variables
```bash
# Server configuration
export WLED_SERVER__HOST="0.0.0.0"
export WLED_SERVER__PORT=8080
export WLED_SERVER__LOG_LEVEL="INFO"
export LEDGRAB_SERVER__HOST="0.0.0.0"
export LEDGRAB_SERVER__PORT=8080
export LEDGRAB_SERVER__LOG_LEVEL="INFO"
# Processing configuration
export WLED_PROCESSING__DEFAULT_FPS=30
export WLED_PROCESSING__BORDER_WIDTH=10
export LEDGRAB_PROCESSING__DEFAULT_FPS=30
export LEDGRAB_PROCESSING__BORDER_WIDTH=10
# WLED configuration
export WLED_WLED__TIMEOUT=5
@@ -147,7 +147,7 @@ curl http://localhost:8080/api/v1/devices/{device_id}/state
pytest
# Run with coverage
pytest --cov=wled_controller --cov-report=html
pytest --cov=ledgrab --cov-report=html
# Run specific test
pytest tests/test_screen_capture.py -v
@@ -158,7 +158,7 @@ pytest tests/test_screen_capture.py -v
### Project Structure
```
src/wled_controller/
src/ledgrab/
├── main.py # FastAPI application
├── config.py # Configuration
├── api/ # API routes
@@ -188,4 +188,4 @@ MIT - see [../LICENSE](../LICENSE)
## Support
- 📖 [Full Documentation](../docs/)
- 🐛 [Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues)
- 🐛 [Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/issues)
+20 -5
View File
@@ -8,14 +8,22 @@ server:
- "http://localhost:8080"
auth:
# API keys — when empty, authentication is disabled (open access).
# To enable auth, add one or more label: "api-key" entries.
# API keys — required for any non-loopback (LAN) request.
# When empty:
# - loopback (127.0.0.1, ::1, localhost) requests are allowed anonymously
# - LAN requests are REJECTED with 401 (security default)
# To enable LAN access, add one or more label: "api-key" entries below
# and send `Authorization: Bearer <api-key>` with each request.
# Generate secure keys: openssl rand -hex 32
api_keys:
dev: "development-key-change-in-production"
storage:
database_file: "data/ledgrab.db"
# 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
# (the whole dir — both the database and assets), or uncomment the block
# below to pin an absolute database file.
# storage:
# database_file: "/absolute/path/to/ledgrab.db"
mqtt:
enabled: false
@@ -28,6 +36,13 @@ mqtt:
logging:
format: "json" # json or text
file: "logs/wled_controller.log"
file: "logs/ledgrab.log"
max_size_mb: 100
backup_count: 5
updates:
# When false (default), updates without a published sha256 checksum
# (sibling .sha256 asset OR 64-hex string in release body) are aborted
# before any installer/extractor runs. NEVER set true unless you
# control the release server end-to-end.
allow_unchecked: false
+2 -2
View File
@@ -1,5 +1,5 @@
# Demo mode configuration
# Loaded automatically when WLED_DEMO=true is set.
# Loaded automatically when LEDGRAB_DEMO=true is set.
# Uses isolated data directory (data/demo/) and a pre-configured API key
# so the demo works out of the box with zero setup.
@@ -26,6 +26,6 @@ mqtt:
logging:
format: "text"
file: "logs/wled_controller.log"
file: "logs/ledgrab.log"
max_size_mb: 100
backup_count: 5
+1 -1
View File
@@ -15,6 +15,6 @@ storage:
logging:
format: "text"
file: "logs/wled_test.log"
file: "logs/ledgrab_test.log"
max_size_mb: 10
backup_count: 2
+17 -17
View File
@@ -1,14 +1,14 @@
services:
wled-controller:
ledgrab:
build:
context: .
dockerfile: Dockerfile
image: ledgrab:latest
container_name: wled-screen-controller
container_name: ledgrab
restart: unless-stopped
ports:
- "${WLED_PORT:-8080}:8080"
- "${LEDGRAB_PORT:-8080}:8080"
volumes:
# Persist device data and configuration across restarts
@@ -22,37 +22,37 @@ services:
environment:
## Server
# Bind address and port (usually no need to change)
- WLED_SERVER__HOST=0.0.0.0
- WLED_SERVER__PORT=8080
- WLED_SERVER__LOG_LEVEL=INFO
- LEDGRAB_SERVER__HOST=0.0.0.0
- LEDGRAB_SERVER__PORT=8080
- LEDGRAB_SERVER__LOG_LEVEL=INFO
# CORS origins — add your LAN IP for remote access, e.g.:
# WLED_SERVER__CORS_ORIGINS=["http://localhost:8080","http://192.168.1.100:8080"]
# LEDGRAB_SERVER__CORS_ORIGINS=["http://localhost:8080","http://192.168.1.100:8080"]
## Auth
# Override the default API key (STRONGLY recommended for production):
# WLED_AUTH__API_KEYS__main=your-secure-key-here
# LEDGRAB_AUTH__API_KEYS__main=your-secure-key-here
# Generate a key: openssl rand -hex 32
## Display (Linux X11 only)
- DISPLAY=${DISPLAY:-:0}
## Processing defaults
#- WLED_PROCESSING__DEFAULT_FPS=30
#- WLED_PROCESSING__BORDER_WIDTH=10
#- LEDGRAB_PROCESSING__DEFAULT_FPS=30
#- LEDGRAB_PROCESSING__BORDER_WIDTH=10
## MQTT (optional — for Home Assistant auto-discovery)
#- WLED_MQTT__ENABLED=true
#- WLED_MQTT__BROKER_HOST=192.168.1.2
#- WLED_MQTT__BROKER_PORT=1883
#- WLED_MQTT__USERNAME=
#- WLED_MQTT__PASSWORD=
#- LEDGRAB_MQTT__ENABLED=true
#- LEDGRAB_MQTT__BROKER_HOST=192.168.1.2
#- LEDGRAB_MQTT__BROKER_PORT=1883
#- LEDGRAB_MQTT__USERNAME=
#- LEDGRAB_MQTT__PASSWORD=
# Uncomment for Linux screen capture (requires host network for X11 access)
# network_mode: host
networks:
- wled-network
- ledgrab-network
networks:
wled-network:
ledgrab-network:
driver: bridge
+4 -4
View File
@@ -1,6 +1,6 @@
# API Authentication Guide
WLED Screen Controller **requires** API key authentication for all API endpoints. This ensures your server is secure and all access is properly authenticated and audited.
LedGrab **requires** API key authentication for all API endpoints. This ensures your server is secure and all access is properly authenticated and audited.
## Configuration
@@ -66,7 +66,7 @@ curl -H "Authorization: Bearer your-api-key-here" \
The integration will prompt for an API key during setup if authentication is enabled. You can also configure it in `configuration.yaml`:
```yaml
wled_screen_controller:
ledgrab:
server_url: "http://192.168.1.100:8080"
api_key: "your-api-key-here" # Optional, only if auth is enabled
```
@@ -168,8 +168,8 @@ export WLED_API_KEY_2="$(openssl rand -hex 32)"
services:
wled-controller:
environment:
- WLED_AUTH__ENABLED=true
- WLED_AUTH__API_KEYS__0=your-key-here
- LEDGRAB_AUTH__ENABLED=true
- LEDGRAB_AUTH__API_KEYS__0=your-key-here
```
Or use Docker secrets for better security.
+1 -1
View File
@@ -1,6 +1,6 @@
import * as esbuild from 'esbuild';
const srcDir = 'src/wled_controller/static';
const srcDir = 'src/ledgrab/static';
const outDir = `${srcDir}/dist`;
const watch = process.argv.includes('--watch');
+18 -7
View File
@@ -3,8 +3,8 @@ requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "wled-screen-controller"
version = "0.3.0"
name = "ledgrab"
version = "0.4.1"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
@@ -25,6 +25,7 @@ classifiers = [
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"cryptography>=42.0.0",
"httpx>=0.27.2",
"mss>=9.0.2",
"numpy>=2.1.3",
@@ -66,6 +67,10 @@ camera = [
# opencv-python-headless is now a core dependency (used for image encoding)
# camera extra kept for backwards compatibility
]
# High-performance Android capture via scrcpy H.264 streaming
scrcpy = [
"scrcpy-client>=0.5.0",
]
# OS notification capture (winrt packages are ~2.5MB total vs winsdk's ~35MB)
notifications = [
"winrt-Windows.UI.Notifications>=3.0.0; sys_platform == 'win32'",
@@ -81,12 +86,18 @@ perf = [
"bettercam>=1.0.0; sys_platform == 'win32'",
"windows-capture>=1.5.0; sys_platform == 'win32'",
]
# BLE LED controllers (SP110E, Triones/HappyLighting, Zengge/iLightsIn, Govee).
# Desktop-only — bleak does not support Android; Chaquopy build must NOT list
# bleak. Imports are guarded with try/except ImportError on all BLE modules.
ble = [
"bleak>=0.22",
]
[project.urls]
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
Documentation = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/src/branch/master/INSTALLATION.md"
Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues"
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
Documentation = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/src/branch/master/INSTALLATION.md"
Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/issues"
[tool.setuptools]
package-dir = {"" = "src"}
@@ -97,7 +108,7 @@ where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "-v --cov=wled_controller --cov-report=html --cov-report=term"
addopts = "-v --cov=ledgrab --cov-report=html --cov-report=term"
[tool.black]
line-length = 100
+307 -71
View File
@@ -1,78 +1,207 @@
# Restart the WLED Screen Controller server
# Uses graceful shutdown first (lets the server persist data to disk),
# then force-kills as a fallback.
<#
.SYNOPSIS
Restart a LedGrab Python server (real or demo) reliably.
$serverRoot = 'c:\Users\Alexei\Documents\wled-screen-controller\server'
.DESCRIPTION
Gracefully asks the running instance to shut down via its HTTP API, waits
for the port to free, then launches a detached replacement and polls the
port until it is actually accepting connections.
# Read API key from config for authenticated shutdown request
$configPath = Join-Path $serverRoot 'config\default_config.yaml'
$apiKey = $null
if (Test-Path $configPath) {
$inKeys = $false
foreach ($line in Get-Content $configPath) {
if ($line -match '^\s*api_keys:') { $inKeys = $true; continue }
if ($inKeys -and $line -match '^\s+\w+:\s*"(.+)"') {
$apiKey = $Matches[1]; break
The script is parameterised so it works for the real server (default:
port 8080, module `ledgrab`), the demo server, and any future variant —
no code edits required to point it somewhere else.
.PARAMETER Port
TCP port the server binds. Used both to locate the running process and
to poll startup readiness.
.PARAMETER Module
Python `-m` module to launch. Also used as a substring match when
identifying which python.exe processes belong to this server so we don't
kill unrelated Python instances.
.PARAMETER ServerRoot
Working directory for the server process. Defaults to the directory that
contains this script.
.PARAMETER ConfigPath
Path (relative to -ServerRoot or absolute) to the YAML config the running
server is using. Used only to read the API key for the graceful-shutdown
request. If empty or missing we skip graceful shutdown and force-kill.
.PARAMETER StartupTimeoutSec
How long to poll for the new server to start accepting connections.
.PARAMETER ShutdownTimeoutSec
How long to wait for the graceful-shutdown API call to cause the running
process to exit before force-killing it.
.PARAMETER SkipBrowser
Set LEDGRAB_RESTART=1 in the child env so the app doesn't open a browser
tab on startup. On by default — pass -SkipBrowser:$false to allow it.
.PARAMETER Quiet
Suppress progress messages; only emit warnings/errors.
.EXAMPLE
# Restart the real server (default invocation)
powershell -ExecutionPolicy Bypass -File restart.ps1
.EXAMPLE
# Restart the demo server on port 8081
powershell -ExecutionPolicy Bypass -File restart.ps1 `
-Port 8081 -Module ledgrab.demo -ConfigPath 'config\demo_config.yaml'
.NOTES
Exit codes:
0 — server is up and accepting connections on the target port
1 — startup timed out; process may or may not be running
2 — could not locate a Python interpreter
#>
[CmdletBinding()]
param(
[int]$Port = 8080,
[string]$Module = 'ledgrab',
[string]$ServerRoot = '',
[string]$ConfigPath = 'config\default_config.yaml',
[int]$StartupTimeoutSec = 30,
[int]$ShutdownTimeoutSec = 15,
[string]$PythonExe = '',
[string]$PythonVersion = '3.13',
[switch]$SkipBrowser = $true,
[switch]$Quiet
)
$ErrorActionPreference = 'Stop'
function Write-Info {
param([string]$Message)
if (-not $Quiet) { Write-Host $Message }
}
# ---- Resolve paths ---------------------------------------------------------
# PS 5.1 doesn't expand $PSScriptRoot at param-binding time, so apply it here.
if (-not $ServerRoot) { $ServerRoot = $PSScriptRoot }
if (-not $ServerRoot) {
Write-Error 'ServerRoot not provided and $PSScriptRoot is unavailable'
exit 2
}
if (-not (Test-Path $ServerRoot)) {
Write-Error "ServerRoot '$ServerRoot' does not exist"
exit 2
}
$ServerRoot = (Resolve-Path $ServerRoot).Path
$resolvedConfig = $null
if ($ConfigPath) {
$candidate = if ([IO.Path]::IsPathRooted($ConfigPath)) {
$ConfigPath
} else {
Join-Path $ServerRoot $ConfigPath
}
if (Test-Path $candidate) { $resolvedConfig = $candidate }
}
# ---- Locate the running server ---------------------------------------------
function Get-ServerProcesses {
param([string]$ModuleName, [string]$Root)
# Match python.exe processes whose command line references this module AND
# whose cwd (via command line fragment) looks like it's running from this
# server root. Excludes unrelated python.exe (VS Code extensions, isort,
# pip tooling, etc.) by requiring a module reference.
$rootPattern = [regex]::Escape($Root)
Get-CimInstance Win32_Process -Filter "Name='python.exe'" -ErrorAction SilentlyContinue |
Where-Object {
$cl = $_.CommandLine
if (-not $cl) { return $false }
# Must launch the target module via `-m <Module>` or an exact token
$launchesModule = $cl -match ('-m\s+' + [regex]::Escape($ModuleName) + '(\s|$|\.)')
if (-not $launchesModule) { return $false }
# Exclude obvious tooling false-positives
if ($cl -match '(vscode|isort|pip[-\s]|flake8|ruff|mypy|pylint|black)') {
return $false
}
return $true
}
if ($inKeys -and $line -match '^\S') { break } # left the api_keys block
}
function Test-PortOpen {
param([int]$Port)
try {
$listener = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop
return [bool]$listener
} catch {
return $false
}
}
# Find running server processes
$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
$existing = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot
if ($procs) {
# Step 1: Request graceful shutdown via API (triggers lifespan shutdown + store save)
$shutdownOk = $false
if ($apiKey) {
Write-Host "Requesting graceful shutdown..."
try {
$headers = @{ Authorization = "Bearer $apiKey" }
Invoke-RestMethod -Uri 'http://localhost:8080/api/v1/system/shutdown' `
-Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop | Out-Null
$shutdownOk = $true
} catch {
Write-Host " API shutdown failed ($($_.Exception.Message)), falling back to process kill"
# ---- Graceful shutdown (if the target is currently up) ---------------------
if ($existing) {
$apiKey = $null
if ($resolvedConfig) {
# Pull the first api_keys entry — good enough for the local shutdown
# endpoint; production deploys don't use this script.
$inKeys = $false
foreach ($line in Get-Content $resolvedConfig) {
if ($line -match '^\s*api_keys:') { $inKeys = $true; continue }
if ($inKeys -and $line -match '^\s+\w+:\s*"(.+)"') {
$apiKey = $Matches[1]; break
}
if ($inKeys -and $line -match '^\S') { break }
}
}
if ($shutdownOk) {
# Step 2: Wait for the server to exit gracefully (up to 15 seconds)
# The server needs time to stop processors, disconnect devices, and persist stores.
Write-Host "Waiting for graceful shutdown..."
$shutdownRequested = $false
if ($apiKey) {
Write-Info 'Requesting graceful shutdown...'
try {
$headers = @{ Authorization = "Bearer $apiKey" }
Invoke-RestMethod -Uri "http://localhost:$Port/api/v1/system/shutdown" `
-Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop | Out-Null
$shutdownRequested = $true
} catch {
Write-Info " API shutdown failed ($($_.Exception.Message)); will force-kill"
}
}
if ($shutdownRequested) {
Write-Info 'Waiting for graceful shutdown...'
$waited = 0
while ($waited -lt 15) {
while ($waited -lt $ShutdownTimeoutSec) {
Start-Sleep -Seconds 1
$waited++
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
if (-not $still) {
Write-Host " Server exited cleanly after ${waited}s"
if (-not (Get-ServerProcesses -ModuleName $Module -Root $ServerRoot)) {
Write-Info " Exited cleanly after ${waited}s"
break
}
}
# Step 3: Force-kill stragglers
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
if ($still) {
Write-Host " Force-killing remaining processes..."
foreach ($p in $still) {
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 1
}
} else {
# No API key or API call failed — force-kill directly
foreach ($p in $procs) {
Write-Host "Stopping server (PID $($p.ProcessId))..."
}
$still = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot
if ($still) {
Write-Info ' Force-killing remaining processes...'
foreach ($p in $still) {
Write-Info " Stop PID $($p.ProcessId)"
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 2
}
# Wait for Windows to release the TCP socket before we rebind. A fixed
# 12 s sleep isn't enough on machines where the kernel lingers in
# CLOSE_WAIT; poll the port state instead.
$portDeadline = (Get-Date).AddSeconds(10)
while ((Get-Date) -lt $portDeadline -and (Test-PortOpen -Port $Port)) {
Start-Sleep -Milliseconds 250
}
}
# Merge registry PATH with current PATH so newly-installed tools (e.g. scrcpy) are visible
# ---- Merge per-user PATH (captures tools installed after the shell started) ----
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
if ($regUser) {
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
@@ -83,25 +212,132 @@ if ($regUser) {
}
}
# Start server detached (set WLED_RESTART=1 to skip browser open)
Write-Host "Starting server..."
$env:WLED_RESTART = "1"
$pythonExe = (Get-Command python -ErrorAction SilentlyContinue).Source
if (-not $pythonExe) {
# Fallback to known install location
$pythonExe = "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe"
# ---- Locate a Python interpreter -------------------------------------------
# We need the Python that actually has the target module installed. Naively
# resolving `python` on PATH can pick up 3.11 or another version that doesn't
# have `ledgrab` in its site-packages, so prefer an explicit interpreter in
# this priority order:
# 1. -PythonExe (caller override)
# 2. `py -<Version>` via the Windows Python launcher
# 3. A Python<Version> install under %LOCALAPPDATA%\Programs\Python
# 4. `python` on PATH (last-resort fallback)
function Test-HasModule {
param([string]$Exe, [string]$ModuleName)
if (-not $Exe -or -not (Test-Path $Exe)) { return $false }
& $Exe -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('$ModuleName') else 1)" 2>$null
return ($LASTEXITCODE -eq 0)
}
Start-Process -FilePath $pythonExe -ArgumentList '-m', 'wled_controller' `
-WorkingDirectory $serverRoot `
-WindowStyle Hidden
Start-Sleep -Seconds 3
$resolvedPython = $null
$launchArgs = @()
# Verify it's running
$check = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
if ($check) {
Write-Host "Server started (PID $($check[0].ProcessId))"
if ($PythonExe) {
if (-not (Test-Path $PythonExe)) {
Write-Error "PythonExe '$PythonExe' does not exist"
exit 2
}
$resolvedPython = (Resolve-Path $PythonExe).Path
} else {
Write-Host "WARNING: Server does not appear to be running!"
# Try `py -<version>`
$pyLauncher = (Get-Command py -ErrorAction SilentlyContinue).Source
if ($pyLauncher) {
$probe = & $pyLauncher "-$PythonVersion" -c "import sys; print(sys.executable)" 2>$null
if ($LASTEXITCODE -eq 0 -and $probe) {
$resolvedPython = $pyLauncher
$launchArgs = @("-$PythonVersion")
}
}
# Fall back to a known install path for that version
if (-not $resolvedPython) {
$verTag = $PythonVersion -replace '\.', ''
$candidate = Join-Path $env:LOCALAPPDATA "Programs\Python\Python$verTag\python.exe"
if (Test-Path $candidate) { $resolvedPython = $candidate }
}
# Last resort: plain `python` on PATH
if (-not $resolvedPython) {
$onPath = (Get-Command python -ErrorAction SilentlyContinue).Source
if ($onPath) { $resolvedPython = $onPath }
}
}
if (-not $resolvedPython) {
Write-Error "No Python $PythonVersion interpreter found (tried: -PythonExe, py -$PythonVersion, %LOCALAPPDATA%\Programs\Python\Python*, PATH)"
exit 2
}
# Verify the module is actually importable with the chosen interpreter so we
# don't launch a process that would immediately die with "No module named X".
# When using the `py` launcher, delegate to the versioned interpreter.
$effectiveExe = if ($launchArgs.Count -gt 0) {
& $resolvedPython @launchArgs -c "import sys; print(sys.executable)" 2>$null
} else {
$resolvedPython
}
if (-not (Test-HasModule -Exe $effectiveExe -ModuleName $Module)) {
Write-Error "Module '$Module' is not importable with $effectiveExe. Install it (e.g. pip install -e .) or pass -PythonExe pointing to the right interpreter."
exit 2
}
$pythonExe = $resolvedPython
# ---- Launch detached replacement -------------------------------------------
Write-Info "Starting $Module on port $Port..."
if ($SkipBrowser) { $env:LEDGRAB_RESTART = '1' }
# Redirect the child's stdout/stderr to a log file. Without this, inheriting
# the parent shell's handles via Start-Process -WindowStyle Hidden can cause
# the child to exit immediately when those handles aren't real console fds
# (e.g. when restart.ps1 is driven from WSL/Git-Bash).
$logPath = Join-Path $env:TEMP ("ledgrab-{0}-{1}.log" -f $Module, $Port)
$errPath = "$logPath.err"
$argList = @()
$argList += $launchArgs
$argList += @('-m', $Module)
$startedProc = Start-Process -FilePath $pythonExe `
-ArgumentList $argList `
-WorkingDirectory $ServerRoot `
-WindowStyle Hidden `
-RedirectStandardOutput $logPath `
-RedirectStandardError $errPath `
-PassThru
$startedPid = $startedProc.Id
# ---- Poll readiness --------------------------------------------------------
# Port readiness is the authoritative signal — the process can be alive for
# many seconds before uvicorn finishes binding on cold starts (store init,
# etc.). Polling avoids spurious "not running" warnings that the old fixed
# 3-second sleep produced.
$deadline = (Get-Date).AddSeconds($StartupTimeoutSec)
$ready = $false
while ((Get-Date) -lt $deadline) {
# Bail early if the process has already exited — something went wrong.
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
if (-not $proc) { break }
if (Test-PortOpen -Port $Port) { $ready = $true; break }
Start-Sleep -Milliseconds 500
}
if ($ready) {
Write-Info "Server ready on port $Port (PID $startedPid)"
exit 0
}
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
if (-not $proc) {
Write-Warning "Server process $startedPid exited before binding port $Port"
} else {
Write-Warning "Server PID $startedPid is running but did not bind port $Port within ${StartupTimeoutSec}s"
}
if (Test-Path $errPath) {
$tail = Get-Content $errPath -Tail 20 -ErrorAction SilentlyContinue
if ($tail) {
Write-Warning "Last stderr lines from $errPath :"
$tail | ForEach-Object { Write-Warning " $_" }
}
}
exit 1
+5 -5
View File
@@ -1,25 +1,25 @@
#!/usr/bin/env bash
# Restart the WLED Screen Controller server (Linux/macOS)
# Restart the LedGrab server (Linux/macOS)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Stop any running instance
PIDS=$(pgrep -f 'wled_controller\.main' 2>/dev/null || true)
PIDS=$(pgrep -f 'ledgrab\.main' 2>/dev/null || true)
if [ -n "$PIDS" ]; then
echo "Stopping server (PID $PIDS)..."
pkill -f 'wled_controller\.main' 2>/dev/null || true
pkill -f 'ledgrab\.main' 2>/dev/null || true
sleep 2
fi
# Start server detached
echo "Starting server..."
cd "$SCRIPT_DIR"
nohup python -m wled_controller.main > /dev/null 2>&1 &
nohup python -m ledgrab.main > /dev/null 2>&1 &
sleep 3
# Verify it's running
NEW_PID=$(pgrep -f 'wled_controller\.main' 2>/dev/null || true)
NEW_PID=$(pgrep -f 'ledgrab\.main' 2>/dev/null || true)
if [ -n "$NEW_PID" ]; then
echo "Server started (PID $NEW_PID)"
else

Some files were not shown because too many files have changed in this diff Show More