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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ dependencyResolutionManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user