Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pair<Long, JoyconButton>> = 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,
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int, Int> = 0x123 to 0x456,
rightStick: Pair<Int, Int> = 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<Int, Int>) {
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ private fun ShoulderButtons(pressed: Set<String>) {
) {
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),
verticalArrangement = Arrangement.spacedBy(Dimens.sidewaysShoulderGap),
) {
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())
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Int> = mapOf(
JoyconButton.A.id to 0,
JoyconButton.B.id to 1,
Expand All @@ -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
Expand All @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading