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.
7.0 KiB
LedGrab TODO
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:
- Rebuild
pydantic-corewheels for all three ABIs with the current SOABI + libpython linking settings (android/build-scripts/build-pydantic-core.sh— now supportsarm64,x86_64,x86args; defaults to all three). - Verify wheels: all three now list
libpython3.11.soinNEEDED(llvm-readelf -d), automated in the build script. - Restored
abiFilters += listOf("arm64-v8a", "x86_64", "x86")inbuild.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.
- Generate Gradle wrapper (
gradlew) and commit it - 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 -son Linux CI ./gradlew assembleDebugon master push,assembleReleaseonv*tags (if signing secrets set)- Uploads APK as CI artifact; attaches to Gitea release on tag push
- Commit pre-built pydantic-core wheels to
android/wheels/(arm64, x86, x86_64) - 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_PASSWORDANDROID_KEY_ALIASANDROID_KEY_PASSWORD
- Add
LedGrab-{tag}-android-release.apkrow to the release description table in.gitea/workflows/release.yml→create-releasejob - 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 would give much better UX.
- Detect root at runtime: check for
subinary,Superuser.apk, etc. - Implement
SurfaceControlCaptureEngine(new capture engine) using hiddenSurfaceControl.screenshot()API via reflection- No permission dialog
- No system capture indicator
- Direct bitmap output (no encoder/decoder roundtrip)
- Engine priority: higher than MediaProjection when root detected
- Fallback chain:
SurfaceControl(root) →MediaProjection(stock) →adb screencap(last resort) - Handle Android version differences in
SurfaceControlAPI surface (renamed/moved across API 29, 30, 33) - Alternative: shell out to
screenrecord --output-format=h264 -as root (same H.264 decode as scrcpy_client_engine, but local instead of remote ADB)
Known projects using this approach for reference: scrcpy-hidden-api, shizuku, commercial scrcpy-derived apps.
Android USB Serial Support
Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV box via USB-to-serial adapters.
- Added
com.github.mik3y:usb-serial-for-android:3.8.1(via JitPack) toandroid/app/build.gradle.kts. - Kotlin
UsbSerialBridgesingleton (android/app/src/main/java/com/ledgrab/android/UsbSerialBridge.kt) — exposeslistDevices(),open(vid, pid, serial, baud),write(handle, ByteArray),close(handle). Permission request fires automatically fromopen()when the user hasn't granted access yet. Handles are opaque integers, port map is synchronized, so Python threads can share one bridge. - Python
AndroidSerialTransportinserver/src/ledgrab/core/devices/android_serial_transport.pydrives the bridge through Chaquopy.SerialTransportProtocol +PySerialTransport+list_serial_ports()factory live inserial_transport.py;AdalightClientandSerialDeviceProvidernow go through the abstraction instead of importingpyserialdirectly. - URL scheme extended:
usb:VID:PID[:serial][@baud]on Android alongside the existingCOM3[:baud]//dev/ttyUSB0[:baud]desktop paths. - App initializes the bridge on startup (
LedGrabApp.onCreate→UsbSerialBridge.init(this)); manifest declaresuses-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". - ESP-NOW client (
espnow_client.py/espnow_provider.py) still importspyserialdirectly and needs bidirectional reads — separate refactor to extend the transport withread()if ESP-NOW-via-USB on Android is needed.
Performance Metrics Abstraction
MetricsProviderprotocol + dataclass DTOs (MemorySnapshot,ProcessSnapshot) live inserver/src/ledgrab/utils/metrics/types.py. Each provider has its own module:psutil_provider.py,null_provider.py,android_provider.py.- Factory
get_metrics_provider()inutils/metrics/__init__.pyselects Android → psutil → Null.psutilimport is now confined to one place. api/routes/system.pyandcore/processing/metrics_history.pyuse the provider; no moreif psutil is not Noneguards in the hot paths.- 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/procfile 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:
- Device battery + thermal-zone readings (
/sys/class/power_supply/battery/{capacity,temp},/sys/class/thermal/thermal_zone*/tempfiltered by zone type). Surfaced throughMetricsProvider.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 thanVmRSSfor split-app-process accounting) - [WONTDO] Optional: GPU usage via
/sys/class/kgsl/kgsl-3d0/gpubusyon Adreno, Mali-specific paths for Mali GPUs