From 446e3cd6b1440581bdb68203ee029a415d4c9aca Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 1 Sep 2026 18:37:25 +0100 Subject: [PATCH] Detect Switch 2 Pro Controller and support GL/GR paddles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pro Controller was never identified as Side.PRO: BleScanner only recognised the Joy-Con product bytes (0x67/0x66), so a Pro fell through to Side.UNKNOWN. That routed it into the single-Left-Joy-Con slot, where GamepadState.from applies the sideways 90 degree rotation and pins the right stick to centre — matching the reported symptoms (rotated D-Pad and left stick, dead right stick, working face/shoulder/system buttons). Detection: - Map product byte 0x69 (PID 0x2069) to Side.PRO. Once tagged PRO the existing pipeline fills both player slots (hasFullController), so no rotation is applied and the right stick decodes from offset 0x0D. GL/GR back paddles: - Parse report byte 0x07 (GR = bit 0, GL = bit 1) in PacketParser. - Add GL/GR to JoyconButton, which also makes them selectable remap sources for a Pro (ControllerMappingScreen enumerates the entries). - Expose them as HID buttons 15/16 (ReportMapper + UhidRelay descriptor, reusing the two former padding bits). - Show them in the Pro controller UI. Eden in-game binding for GL/GR is deferred: the Android keycode that HID buttons 15/16 land on is kernel/device-dependent and not yet captured. CI now uploads the debug APK as an artifact (it was built but never published). Adds PacketParserTest covering the PRO stick offsets and the paddle decode. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 7 ++ .../joycon2android/model/JoyconButton.kt | 2 + .../joycon2android/connection/BleScanner.kt | 13 ++-- .../joycon2android/connection/PacketParser.kt | 8 ++- .../connection/PacketParserTest.kt | 67 +++++++++++++++++++ .../presentation/ProControllerLayout.kt | 2 + .../joycon2android/gamepad/ReportMapper.kt | 6 +- .../joycon2android/gamepad/UhidRelay.kt | 9 +-- 8 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9008eca..0c1ce6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,10 @@ jobs: - name: Build, test, lint, and enforce architecture rules run: ./gradlew build :konsist:test --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconButton.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconButton.kt index bef6583..e14b3a0 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconButton.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconButton.kt @@ -24,4 +24,6 @@ enum class JoyconButton(val id: String, val label: String) { X("X", "X"), B("B", "B"), A("A", "A"), + GL("GL", "GL"), + GR("GR", "GR"), } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt index d81046b..60b6aff 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt @@ -129,11 +129,13 @@ class BleScanner(context: Context) { } /** - * Nintendo manufacturer data (company 0x0553) carries a per-side product byte at index 5: - * 0x67 = Left Joy-Con 2, 0x66 = Right Joy-Con 2. Confirmed on hardware and cross-checked - * against each controller's SPI accent colour (cyan left, coral right). The pairing - * advertisement has no local name, so this byte is the only side signal available before - * the controller starts streaming input. + * Nintendo manufacturer data (company 0x0553) carries the little-endian USB/BLE product ID at + * bytes [5..6], so index 5 is its low byte: 0x67 = Left Joy-Con 2 (PID 0x2067), 0x66 = Right + * Joy-Con 2 (PID 0x2066), 0x69 = Switch 2 Pro Controller (PID 0x2069). Left/Right are confirmed + * on hardware and cross-checked against each controller's SPI accent colour (cyan left, coral + * right); the Pro value comes from community reverse-engineering of the same advertisement + * scheme. The pairing advertisement has no local name, so this byte is the only type signal + * available before the controller starts streaming input. */ private fun sideFromManufacturerData(result: ScanResult): Side? { val mfgData = result.scanRecord @@ -142,6 +144,7 @@ class BleScanner(context: Context) { return when (mfgData[SIDE_TYPE_INDEX].toInt() and 0xFF) { 0x67 -> Side.LEFT 0x66 -> Side.RIGHT + 0x69 -> Side.PRO else -> null } } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt index 429d8af..62fd4e2 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt @@ -10,7 +10,9 @@ object PacketParser { private const val MIN_PACKET_SIZE = 0x3B - // Button bitmask → enum (uint32 at packet offset 0x03, little-endian) + // Button bitmask → enum. Bits 0..31 come from the uint32 at packet offset 0x03; the Pro + // Controller's two back paddles live in the next byte (0x07), folded into bits 32..39 so the + // whole set decodes through one mask table. GR is bit 0 of byte 0x07, GL is bit 1. private val buttonMasks: List> = listOf( 0x80000000L to JoyconButton.ZL, 0x40000000L to JoyconButton.L, 0x00010000L to JoyconButton.Minus, 0x00080000L to JoyconButton.LS, 0x01000000L to JoyconButton.Down, 0x02000000L to JoyconButton.Up, @@ -20,13 +22,15 @@ object PacketParser { 0x00002000L to JoyconButton.SlRight, 0x00004000L to JoyconButton.R, 0x00008000L to JoyconButton.ZR, 0x00040000L to JoyconButton.RS, 0x00000100L to JoyconButton.Y, 0x00000200L to JoyconButton.X, 0x00000400L to JoyconButton.B, 0x00000800L to JoyconButton.A, + 0x0100000000L to JoyconButton.GR, 0x0200000000L to JoyconButton.GL, ) fun parse(data: ByteArray, side: Side): JoyconInput? { if (data.size < MIN_PACKET_SIZE) return null val bb = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) - val buttons = bb.getInt(0x03).toLong() and 0xFFFFFFFFL + val buttons = (bb.getInt(0x03).toLong() and 0xFFFFFFFFL) or + ((data[0x07].toInt() and 0xFF).toLong() shl 32) val (sx, sy) = resolveStick(data, side) val (rsx, rsy) = if (side == Side.PRO) decodeStick(data, 0x0D) else (2048 to 2048) diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt new file mode 100644 index 0000000..af47f7a --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt @@ -0,0 +1,67 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.Side +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PacketParserTest { + + // A minimal but valid input report: left stick at 0x0A, right stick at 0x0D, and the Pro + // Controller's back-paddle byte at 0x07 (GR = bit 0, GL = bit 1). + private fun packet( + leftStick: Pair = 0x123 to 0x456, + rightStick: Pair = 0x789 to 0xABC, + paddleByte: Int = 0, + ): ByteArray = ByteArray(64).apply { + putStick(0x0A, leftStick) + putStick(0x0D, rightStick) + this[0x07] = paddleByte.toByte() + } + + private fun ByteArray.putStick(offset: Int, stick: Pair) { + val (x, y) = stick + val v = (x and 0xFFF) or ((y and 0xFFF) shl 12) + this[offset] = (v and 0xFF).toByte() + this[offset + 1] = ((v shr 8) and 0xFF).toByte() + this[offset + 2] = ((v shr 16) and 0xFF).toByte() + } + + @Test + fun `rejects packets below the minimum size`() { + assertNull(PacketParser.parse(ByteArray(16), Side.PRO)) + } + + @Test + fun `pro decodes both sticks from their own offsets`() { + val input = PacketParser.parse(packet(), Side.PRO)!! + assertEquals(0x123, input.stickX) + assertEquals(0x456, input.stickY) + assertEquals(0x789, input.rightStickX) + assertEquals(0xABC, input.rightStickY) + } + + @Test + fun `a single left joy-con reports no right stick`() { + val input = PacketParser.parse(packet(), Side.LEFT)!! + assertEquals(0x123, input.stickX) + assertEquals(2048, input.rightStickX) + assertEquals(2048, input.rightStickY) + } + + @Test + fun `back paddles decode from byte 0x07`() { + val input = PacketParser.parse(packet(paddleByte = 0x03), Side.PRO)!! + assertTrue(JoyconButton.GR.id in input.pressed) + assertTrue(JoyconButton.GL.id in input.pressed) + } + + @Test + fun `paddles are absent when their bits are clear`() { + val input = PacketParser.parse(packet(paddleByte = 0x00), Side.PRO)!! + assertTrue(JoyconButton.GR.id !in input.pressed) + assertTrue(JoyconButton.GL.id !in input.pressed) + } +} diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt index 2fed3df..48b2e2d 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt @@ -46,6 +46,7 @@ private fun ShoulderButtons(pressed: Set) { ) { ShoulderButton(JoyconButton.ZL.label, JoyconButton.ZL.id in pressed, Modifier.fillMaxWidth()) ShoulderButton(JoyconButton.L.label, JoyconButton.L.id in pressed, Modifier.fillMaxWidth()) + ShoulderButton(JoyconButton.GL.label, JoyconButton.GL.id in pressed, Modifier.fillMaxWidth()) } Column( Modifier.weight(1f), @@ -53,6 +54,7 @@ private fun ShoulderButtons(pressed: Set) { ) { ShoulderButton(JoyconButton.ZR.label, JoyconButton.ZR.id in pressed, Modifier.fillMaxWidth()) ShoulderButton(JoyconButton.R.label, JoyconButton.R.id in pressed, Modifier.fillMaxWidth()) + ShoulderButton(JoyconButton.GR.label, JoyconButton.GR.id in pressed, Modifier.fillMaxWidth()) } } } diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt index 3daae22..086eb72 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt @@ -7,7 +7,7 @@ object ReportMapper { private const val REPORT_SIZE = 13 - // Button bit positions matching the HID report descriptor order (Button 1-14) + // Button bit positions matching the HID report descriptor order (Button 1-16) private val BUTTON_MAP: Map = mapOf( JoyconButton.A.id to 0, JoyconButton.B.id to 1, @@ -23,6 +23,8 @@ object ReportMapper { JoyconButton.RS.id to 11, JoyconButton.Home.id to 12, JoyconButton.Camera.id to 13, + JoyconButton.GL.id to 14, + JoyconButton.GR.id to 15, ) private const val HAT_CENTER = 0x0F @@ -32,7 +34,7 @@ object ReportMapper { val report = ByteArray(REPORT_SIZE) val pressed = gamepad.pressed - // Bytes 0-1: 14 button bits + 2 padding (little-endian) + // Bytes 0-1: 16 button bits (little-endian) var buttons = 0 for (name in pressed) { BUTTON_MAP[name]?.let { bit -> buttons = buttons or (1 shl bit) } diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt index 699b574..68f9fc4 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt @@ -167,18 +167,15 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { 0x09, 0x05, // Usage (Game Pad) 0xA1.toByte(), 0x01, // Collection (Application) - // Buttons (14 buttons, 2 bits padding) + // Buttons (16 buttons; the last two are the Pro Controller's GL/GR back paddles) 0x05, 0x09, // Usage Page (Button) 0x19, 0x01, // Usage Minimum (Button 1) - 0x29, 0x0E, // Usage Maximum (Button 14) + 0x29, 0x10, // Usage Maximum (Button 16) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1) - 0x95.toByte(), 0x0E, // Report Count (14) + 0x95.toByte(), 0x10, // Report Count (16) 0x81.toByte(), 0x02, // Input (Data, Var, Abs) - 0x75, 0x01, // Report Size (1) - 0x95.toByte(), 0x02, // Report Count (2) - padding - 0x81.toByte(), 0x03, // Input (Const, Var, Abs) // Hat Switch (D-pad) 0x05, 0x01, // Usage Page (Generic Desktop)