Add ESP32 Joy-Con bridge serial reader

Adds app-side support for the ESP32-WROOM-32 Joy-Con bridge over USB
serial. The ESP32 pairs with the Joy-Con via Bluetooth Classic (using
Bluepad32), parses input reports, and streams an 11-byte wire protocol
frame at 60 Hz over the CP2102's USB-CDC serial connection.

ControllerManager now auto-detects known USB-serial chips (CP2102,
CH340, Espressif native USB) by VID/PID, opens the port at 115200 8N1
via usb-serial-for-android, and parses incoming frames into the same
dispatchButton / onAnalogEvent pipeline used by all other controller
paths. Frame format: [0xA5 sync] [seq] [btns_lo] [btns_hi] [dpad hat]
[lx] [ly] [rx] [ry] [flags] [XOR crc8].

Verified stable for 10+ minutes with zero disconnects on the Samsung
Fold 6 — the 8BitDo USB adapter wedged within 2 minutes on the same
phone. Full firmware control eliminates the OTG selective-suspend issue
that plagued the adapter path.
This commit is contained in:
2026-04-12 15:57:30 -04:00
parent 2154f7fd3c
commit 89f590480f
5 changed files with 275 additions and 1 deletions

View File

@@ -83,6 +83,9 @@ dependencies {
// Image loading // Image loading
implementation(libs.coil.compose) implementation(libs.coil.compose)
// USB serial (ESP32 Joy-Con bridge)
implementation(libs.usb.serial)
// Testing // Testing
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)

View File

@@ -14,6 +14,8 @@ import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager import android.hardware.usb.UsbManager
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread import kotlin.concurrent.thread
import android.view.InputDevice import android.view.InputDevice
@@ -172,8 +174,25 @@ class ControllerManager(
0x054C to 0x05C4 // Sony DualShock 4 — what the adapter latched to on macOS 0x054C to 0x05C4 // Sony DualShock 4 — what the adapter latched to on macOS
) )
// ESP32 Joy-Con bridge serial VID/PIDs. When we see one of these, we open it as a
// serial port and read the 11-byte wire protocol frames (see docs/joycon-esp32-bridge.md).
private val espSerialVidPids: Set<Pair<Int, Int>> = setOf(
0x10C4 to 0xEA60, // Silicon Labs CP2102 (most ESP32-WROOM-32 boards)
0x1A86 to 0x7523, // WCH CH340 (some ESP32 boards)
0x303A to 0x1001, // Espressif native USB (ESP32-S3 etc)
)
// ESP32 serial reader state
private var espSerialPort: UsbSerialPort? = null
private var espSerialThread: Thread? = null
private val espSerialStop = AtomicBoolean(false)
private var espPrevBtnsLo = 0
private var espPrevBtnsHi = 0
private var espPrevDpad = 8 // 8 = neutral
private fun isTargetDevice(device: UsbDevice): Boolean = private fun isTargetDevice(device: UsbDevice): Boolean =
(device.vendorId to device.productId) in targetVidPids (device.vendorId to device.productId) in targetVidPids ||
(device.vendorId to device.productId) in espSerialVidPids
/** Sweep already-connected USB devices — called on resume. */ /** Sweep already-connected USB devices — called on resume. */
fun tryClaimAlreadyConnectedUsb() { fun tryClaimAlreadyConnectedUsb() {
@@ -251,6 +270,15 @@ class ControllerManager(
return return
} }
// ESP32 Joy-Con bridge: detect known USB-serial chips and open as a serial port.
val vidPid = device.vendorId to device.productId
if (vidPid in espSerialVidPids) {
appendEventLog("ESP32 serial device detected — opening as Joy-Con bridge")
releaseClaim()
startEspSerialReader(device)
return
}
// Release any prior claim (e.g. the adapter just re-enumerated in a new mode). // Release any prior claim (e.g. the adapter just re-enumerated in a new mode).
releaseClaim() releaseClaim()
@@ -331,6 +359,9 @@ class ControllerManager(
} }
fun releaseClaim() { fun releaseClaim() {
// Stop ESP32 serial reader if active.
stopEspSerialReader()
val c = claimedDevice ?: return val c = claimedDevice ?: return
appendEventLog("USB releasing claim on '${c.device.productName ?: c.device.deviceName}'") appendEventLog("USB releasing claim on '${c.device.productName ?: c.device.deviceName}'")
@@ -468,6 +499,235 @@ class ControllerManager(
xinputReaderThread = null xinputReaderThread = null
} }
// ---- ESP32 Joy-Con Bridge (USB serial) ----
/**
* Open an ESP32 USB-serial device, configure it at 115200 8N1, and spin up a reader
* thread that consumes the 11-byte wire protocol frames from the Joy-Con bridge firmware.
*/
private fun startEspSerialReader(device: UsbDevice) {
if (!usbManager.hasPermission(device)) {
val key = "${device.vendorId}:${device.productId}"
if (key in permissionRequestedFor) {
appendEventLog("ESP32 serial: permission still missing, already requested — skipping")
return
}
permissionRequestedFor.add(key)
appendEventLog("ESP32 serial: requesting USB permission")
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pi = PendingIntent.getBroadcast(
appContext, 0,
Intent(usbPermissionAction).apply { setPackage(appContext.packageName) },
flags
)
try { usbManager.requestPermission(device, pi) } catch (t: Throwable) {
appendEventLog("ESP32 serial: requestPermission threw: ${t.message}")
}
return
}
// Use usb-serial-for-android's prober to auto-detect the serial chip (CP2102/CH340/CDC).
val driver = UsbSerialProber.getDefaultProber().probeDevice(device)
if (driver == null) {
appendEventLog("ESP32 serial: no serial driver matched for ${device.productName}")
return
}
val port = driver.ports.firstOrNull()
if (port == null) {
appendEventLog("ESP32 serial: driver has no ports")
return
}
val connection = usbManager.openDevice(device)
if (connection == null) {
appendEventLog("ESP32 serial: openDevice returned null")
return
}
try {
port.open(connection)
port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
} catch (t: Throwable) {
appendEventLog("ESP32 serial: port.open/setParameters threw: ${t.message}")
try { port.close() } catch (_: Throwable) {}
return
}
espSerialPort = port
hasActiveClaim = true
espPrevBtnsLo = 0
espPrevBtnsHi = 0
espPrevDpad = 8
espSerialStop.set(false)
appendEventLog("ESP32 serial: opened ${driver.javaClass.simpleName} at 115200 baud")
espSerialThread = thread(name = "EspSerialReader", isDaemon = true) {
val buf = ByteArray(128)
var totalFrames = 0
var loggedSamples = 0
try {
while (!espSerialStop.get()) {
val n = try {
port.read(buf, 200) // 200 ms timeout
} catch (t: Throwable) {
if (!espSerialStop.get()) {
Log.w(TAG, "ESP serial read threw", t)
}
-1
}
if (n <= 0) continue
// Scan the read buffer for complete 11-byte frames starting with 0xA5.
// At 60 Hz / 11 bytes per frame / 115200 baud, each read() typically
// returns 1-3 complete frames. Partial frames at boundaries are rare
// and we just drop them (the next read picks up the next sync byte).
var offset = 0
while (offset + ESP_FRAME_SIZE <= n) {
if (buf[offset].toInt() and 0xFF == ESP_SYNC_BYTE) {
// Validate CRC (XOR of bytes 0..9)
var xor = 0
for (j in 0 until ESP_FRAME_SIZE - 1) {
xor = xor xor (buf[offset + j].toInt() and 0xFF)
}
val expectedCrc = buf[offset + ESP_FRAME_SIZE - 1].toInt() and 0xFF
if (xor == expectedCrc) {
totalFrames++
if (loggedSamples < 5) {
val hex = (0 until ESP_FRAME_SIZE).joinToString(" ") {
"%02x".format(buf[offset + it])
}
appendEventLog("ESP frame[$totalFrames]: $hex")
loggedSamples++
}
parseEspFrame(buf, offset)
offset += ESP_FRAME_SIZE
} else {
// Bad CRC — skip this byte and resync
offset++
}
} else {
// Not a sync byte — advance until we find one
offset++
}
}
}
} finally {
Log.d(TAG, "EspSerialReader thread exiting (frames=$totalFrames)")
}
}
refreshUsbDevices()
}
private fun stopEspSerialReader() {
espSerialStop.set(true)
try {
espSerialPort?.close()
} catch (_: Throwable) {}
try {
espSerialThread?.join(500)
} catch (_: InterruptedException) {}
espSerialPort = null
espSerialThread = null
espPrevBtnsLo = 0
espPrevBtnsHi = 0
espPrevDpad = 8
}
/**
* Parse one 11-byte ESP32 Joy-Con bridge frame and dispatch button/analog events.
*
* Wire format (see docs/joycon-esp32-bridge.md):
* [0] 0xA5 sync
* [1] seq rolling counter
* [2] btns_lo: bit0=A(FACE_BOTTOM) bit1=B(FACE_RIGHT) bit2=X(FACE_LEFT)
* bit3=Y(FACE_TOP) bit4=L1 bit5=R1 bit6=L2 bit7=R2
* [3] btns_hi: bit0=START bit1=SELECT bit2=L3 bit3=R3 bit4=HOME(MODE) bit5=CAPTURE
* [4] dpad: low nibble = hat direction 0-7 clockwise from N, 8=none
* [5] lx signed int8
* [6] ly signed int8
* [7] rx signed int8
* [8] ry signed int8
* [9] flags
* [10] crc8 (XOR of bytes 0-9)
*/
private fun parseEspFrame(buf: ByteArray, off: Int) {
val btnsLo = buf[off + 2].toInt() and 0xFF
val btnsHi = buf[off + 3].toInt() and 0xFF
val dpad = buf[off + 4].toInt() and 0x0F
val lx = buf[off + 5].toInt() // signed byte → int preserving sign
val ly = buf[off + 6].toInt()
val rx = buf[off + 7].toInt()
val ry = buf[off + 8].toInt()
// Button diff + dispatch — btns_lo
if (btnsLo != espPrevBtnsLo) {
val changed = btnsLo xor espPrevBtnsLo
for ((bit, btn) in ESP_BTNS_LO_MAP) {
if (changed and bit != 0) dispatchButton(btn, (btnsLo and bit) != 0)
}
espPrevBtnsLo = btnsLo
}
// btns_hi
if (btnsHi != espPrevBtnsHi) {
val changed = btnsHi xor espPrevBtnsHi
for ((bit, btn) in ESP_BTNS_HI_MAP) {
if (changed and bit != 0) dispatchButton(btn, (btnsHi and bit) != 0)
}
espPrevBtnsHi = btnsHi
}
// Dpad (hat switch)
if (dpad != espPrevDpad) {
val active = dpad <= 7
val up = active && (dpad == 0 || dpad == 1 || dpad == 7)
val right = active && (dpad in 1..3)
val down = active && (dpad in 3..5)
val left = active && (dpad in 5..7)
dispatchButton(GamepadButton.DPAD_UP, up)
dispatchButton(GamepadButton.DPAD_RIGHT, right)
dispatchButton(GamepadButton.DPAD_DOWN, down)
dispatchButton(GamepadButton.DPAD_LEFT, left)
espPrevDpad = dpad
}
// Analog sticks — signed int8 → float [-1, 1], Y negated for Android convention
val nLx = lx / 127f
val nLy = -ly / 127f
val nRx = rx / 127f
val nRy = -ry / 127f
onAnalogEvent?.invoke(nLx, nLy, 0)
onAnalogEvent?.invoke(nRx, nRy, 1)
_analogSnapshot.value = AnalogSnapshot(nLx, nLy, nRx, nRy, 0f, 0f)
}
companion object {
private const val ESP_SYNC_BYTE = 0xA5
private const val ESP_FRAME_SIZE = 11
private val ESP_BTNS_LO_MAP = listOf(
0x01 to GamepadButton.FACE_BOTTOM, // A / Cross
0x02 to GamepadButton.FACE_RIGHT, // B / Circle
0x04 to GamepadButton.FACE_LEFT, // X / Square
0x08 to GamepadButton.FACE_TOP, // Y / Triangle
0x10 to GamepadButton.L1,
0x20 to GamepadButton.R1,
0x40 to GamepadButton.L2,
0x80 to GamepadButton.R2,
)
private val ESP_BTNS_HI_MAP = listOf(
0x01 to GamepadButton.START,
0x02 to GamepadButton.SELECT,
0x04 to GamepadButton.L3,
0x08 to GamepadButton.R3,
0x10 to GamepadButton.MODE, // Home / PS
)
}
private fun parseXinputReport(buf: ByteArray) { private fun parseXinputReport(buf: ByteArray) {
// buf[0] = 0x00 (type), buf[1] = 0x14 (len) // buf[0] = 0x00 (type), buf[1] = 0x14 (len)
val buttons = (buf[2].toInt() and 0xFF) or ((buf[3].toInt() and 0xFF) shl 8) val buttons = (buf[2].toInt() and 0xFF) or ((buf[3].toInt() and 0xFF) shl 8)

View File

@@ -14,4 +14,10 @@
<usb-device vendor-id="1118" product-id="654" /> <usb-device vendor-id="1118" product-id="654" />
<!-- Sony DualShock 4 — the mode the adapter latches into on macOS (0x054C/0x05C4) --> <!-- Sony DualShock 4 — the mode the adapter latches into on macOS (0x054C/0x05C4) -->
<usb-device vendor-id="1356" product-id="1476" /> <usb-device vendor-id="1356" product-id="1476" />
<!-- CP2102 USB-serial — ESP32-WROOM-32 dev boards (0x10C4/0xEA60) -->
<usb-device vendor-id="4292" product-id="60000" />
<!-- CH340 USB-serial — some ESP32 dev boards (0x1A86/0x7523) -->
<usb-device vendor-id="6790" product-id="29987" />
<!-- Espressif native USB — ESP32-S3 etc (0x303A/0x1001) -->
<usb-device vendor-id="12346" product-id="4097" />
</resources> </resources>

View File

@@ -15,6 +15,7 @@ coroutines = "1.10.2"
windowSizeClass = "1.3.2" windowSizeClass = "1.3.2"
datastorePrefs = "1.1.4" datastorePrefs = "1.1.4"
coil = "2.7.0" coil = "2.7.0"
usbSerial = "3.9.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -51,6 +52,9 @@ datastore-prefs = { group = "androidx.datastore", name = "datastore-preferences"
# Coil (image loading) # Coil (image loading)
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
# USB serial (ESP32 Joy-Con bridge)
usb-serial = { group = "com.github.mik3y", name = "usb-serial-for-android", version.ref = "usbSerial" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

View File

@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://jitpack.io") }
} }
} }