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
12 KiB
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.
- Add
bleak>=0.22as optional extra[ble]inserver/pyproject.toml(desktop-only, NOT in androidbuild.gradle.kts) core/devices/ble_transport.py— bleak wrapper: scan, connect, write-with/without-responsecore/devices/ble_protocols/package__init__.py—BLEProtocoldataclass + registry (family → encoder)sp110e.py— SP110E / SP108E (service FFE0, char FFE1,RR GG BB 00 1Estatic-color frame)triones.py— Triones / HappyLighting / LEDnet (service FFE5, char FFE9,7E 07 05 03 RR GG BB 10 EF)zengge.py— Zengge / iLightsIn (service FFE0, framing56 RR GG BB 00 F0 AA)govee.py— Govee unencrypted framed protocol (AES keyed variants — marked experimental)
core/devices/ble_client.py— unifiedBLEClient(LEDClient)— picks protocol byble_family, averages strip → one color, drops duplicate frames, rate-limits to BLE connection intervalcore/devices/ble_provider.py—BLEDeviceProvider+ discovery viaBleakScanner- Register in
core/devices/led_client.py::_register_builtin_providers(guardedtry/except ImportError) - Storage:
ble_family,ble_govee_keyfields threaded throughDevice.__init__/to_dict/from_dict/_UPDATABLE_FIELDS/create_device - Schemas: BLE fields on
DeviceCreate,DeviceUpdate,DeviceResponse - Routes: BLE fields propagated through create/update in
api/routes/devices.py+_device_to_response - ProcessorManager:
ble_family/ble_govee_keyadded to_DEVICE_FIELD_DEFAULTSandDeviceInfo; passed throughwled_target_processor.pyandgroup_client.pytocreate_led_client - Tests: 21 protocol encoder unit tests + 16 BLEClient fake-transport tests — all passing, 814 total tests still green
- Frontend: BLE option in the device type picker with a bluetooth Lucide icon; add-device modal shows a 4-option
IconSelectfor 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 toble://<address>pattern; submit payload carriesble_family(+ optionalble_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=bleendpoint - Frontend:
isBleDevicehelper incore/api.ts;ICON_BLUETOOTH+ICON_LIGHTBULBconstants incore/icons.ts;bluetoothpath incore/icon-paths.ts; i18n keys inen.json/ru.json/zh.json; TypeScript compiles; esbuild bundle rebuilt - Android BLE via Kotlin bridge —
BleBridge.ktsingleton (scan/connect/write/disconnect);android_ble_transport.pyPython wrapper;make_transport()factory inble_transport.pyauto-selects backend;BleBridge.init()called fromLedGrabApp.onCreate; BLE permissions inAndroidManifest.xml - Govee per-model AES key —
_encrypt_govee_frame()inble_client.pyuses AES-128-ECB fromcryptography; key validated onBLEClientconstruction; applied to bothsend_pixelsandset_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:
- 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 gives much better UX.
- Root detection —
Root.ktchecks commonsubinary paths and, on demand, runssu -c idto actually prove UID 0. First call triggers Magisk's grant dialog; grant is cached per session. Exposed to Python via Chaquopy. RootScreenrecord.kt— spawnssu -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 viaPythonBridge.pushRootFrame.- Python-side
RootScreenrecordEngine(core/capture_engines/root_screenrecord_engine.py) mirrorsMediaProjectionEnginewithENGINE_PRIORITY=110(> MediaProjection's 100) so the factory picks it automatically when available. MainActivitytriesRoot.requestGrant()before launching the MediaProjection consent flow — on rooted devices the consent dialog is skipped entirely.CaptureServicehas acreateRootIntent()entry point that bypasses the MediaProjection path.- Fallback: if
Root.requestGrant()returns false (no root, user denied, orsutimeout) 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
suprocess 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 screencapfallback — 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 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) now routes throughSerialTransport—open_transport()for the gateway serial link,list_serial_ports()+port_exists()for discovery/validation. Works transparently withusb:VID:PIDURLs on Android. (Gateway protocol is write-only, so noread()extension was needed after all.)
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
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.
- Phase 1 —
DeviceConfighierarchy +Device.to_config()(non-breaking, additive only) - Phases 2+3 — narrow
LEDDeviceProvider.create_clientto typed configs; migrate 3 call sites; deleteDeviceInfo+_get_device_info+_DEVICE_FIELD_DEFAULTS(single PR) - Phase 4 — migrate
tests/test_group_device.pytoGroupConfig/ProviderDeps; remove legacyGroupLEDClientinit path; 47-test config suite with 100% coverage ondevice_config.py - Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in
api/schemas/devices.py; scope frontend POST/PATCH payloads bydevice_type