diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3cee06b..d746c03 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,9 @@ dependencies { // Image loading implementation(libs.coil.compose) + // USB serial (ESP32 Joy-Con bridge) + implementation(libs.usb.serial) + // Testing testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt index c51b0db..7a25328 100644 --- a/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt +++ b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt @@ -14,6 +14,8 @@ import android.hardware.usb.UsbInterface import android.hardware.usb.UsbManager import android.os.Build 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 kotlin.concurrent.thread import android.view.InputDevice @@ -172,8 +174,25 @@ class ControllerManager( 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> = 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 = - (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. */ fun tryClaimAlreadyConnectedUsb() { @@ -251,6 +270,15 @@ class ControllerManager( 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). releaseClaim() @@ -331,6 +359,9 @@ class ControllerManager( } fun releaseClaim() { + // Stop ESP32 serial reader if active. + stopEspSerialReader() + val c = claimedDevice ?: return appendEventLog("USB releasing claim on '${c.device.productName ?: c.device.deviceName}'") @@ -468,6 +499,235 @@ class ControllerManager( 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) { // buf[0] = 0x00 (type), buf[1] = 0x14 (len) val buttons = (buf[2].toInt() and 0xFF) or ((buf[3].toInt() and 0xFF) shl 8) diff --git a/app/src/main/res/xml/usb_device_filter.xml b/app/src/main/res/xml/usb_device_filter.xml index d4231bd..fd8d2ab 100644 --- a/app/src/main/res/xml/usb_device_filter.xml +++ b/app/src/main/res/xml/usb_device_filter.xml @@ -14,4 +14,10 @@ + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e11cf96..27edc37 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ coroutines = "1.10.2" windowSizeClass = "1.3.2" datastorePrefs = "1.1.4" coil = "2.7.0" +usbSerial = "3.9.0" [libraries] 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-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] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index adc56d5..c25a8e7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } }