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.
This commit is contained in:
2026-04-14 16:34:09 +03:00
parent ecae05d00b
commit 7fcb8dd346
11 changed files with 753 additions and 84 deletions
+3
View File
@@ -116,4 +116,7 @@ dependencies {
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")
}
+6
View File
@@ -21,6 +21,12 @@
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="true"
@@ -18,5 +18,9 @@ class LedGrabApp : Application() {
if (!Python.isStarted()) {
Python.start(AndroidPlatform(this))
}
// 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)
}
}
@@ -0,0 +1,203 @@
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.atomic.AtomicInteger
/**
* 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>()
/** Called once from [LedGrabApp.onCreate] so we can resolve services. */
@JvmStatic
fun init(context: Context) {
val app = context.applicationContext
appContext = app
val filter = IntentFilter(ACTION_USB_PERMISSION)
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
// We just log; the next open() call checks hasPermission() again.
val granted = intent.getBooleanExtra(
UsbManager.EXTRA_PERMISSION_GRANTED,
false,
)
Log.i(TAG, "USB permission broadcast: granted=$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}") }
}
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)
}
}
+2
View File
@@ -11,6 +11,8 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
// usb-serial-for-android (mik3y) is distributed via JitPack
maven { url = uri("https://jitpack.io") }
}
}