Files
ledgrab/android/app/build.gradle.kts
T
alexei.dolgolyov 7fcb8dd346
Build Android APK / build-android (push) Failing after 1m41s
Lint & Test / test (push) Successful in 4m51s
feat(devices): Android USB-serial support for Adalight/AmbiLED controllers
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

123 lines
4.4 KiB
Kotlin

plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.chaquo.python")
}
android {
namespace = "com.ledgrab.android"
compileSdk = 34
defaultConfig {
applicationId = "com.ledgrab.android"
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
targetSdk = 34
versionCode = 1
versionName = "0.3.0"
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")
}
}
// 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 {
isMinifyEnabled = false
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.)
// Must use absolute file:// URI with forward slashes
options("--find-links", "file:///${rootDir.absolutePath.replace("\\", "/")}/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")
}
}
}
// 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")
// 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")
}