Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ as an ordinary gamepad.
- **Custom button mapping** — choose which button drives which emulator button, per console.
- **Live readout** of buttons, sticks, motion and battery, with each card in the controller's real
shell colour.
- **Third-party clones** — controllers that speak the console's protocol instead, tested with the
NYXI Hyperion 3 (see [protocol.md](docs/protocol.md#console-protocol-controllers)). Motion on
these is accelerometer-only: they send no gyroscope data, so gyro aiming can't work.

## Setup guide

Expand Down Expand Up @@ -228,6 +231,7 @@ shoulder buttons.
| "Shizuku permission denied" | Shizuku → Apps → allow Joycon2Android |
| Controller not found | Hold SYNC again and move closer |
| Controller stops responding | Press SYNC and reconnect; wait a moment if it stays silent |
| Controllers drop when a game starts | Some phones clear background apps when a game launches. Set this app's battery use to unrestricted, and exclude it from the game launcher's cleanup |
| Gamepad doesn't show up in games | Check `adb shell getevent -p` lists "Joy-Con Virtual Gamepad" |
| No DSUClient device in the emulator | Check the server address, restart the emulator and open a mapping screen; `adb logcat -s DsuServer` shows whether it's connecting |
| Emulator won't detect DSU presses | Pick inputs from the list — detection never sees DSU — with the virtual gamepad off |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.joegec.joycon2android.model

/** Charge as a percentage: the common report gives volts, the console report a level. */
@JvmInline
value class BatteryCharge(val percent: Int) {

companion object {
fun fromVolts(volts: Float): BatteryCharge? =
if (volts <= 0f) null else BatteryCharge(BatteryGauge.percentFromVolts(volts))

fun fromLevel(level: Int, maxLevel: Int) = BatteryCharge(level * 100 / maxLevel)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ data class JoyconInput(
val gyroX: Int = 0,
val gyroY: Int = 0,
val gyroZ: Int = 0,
val batteryVolts: Float = 0f,
val battery: BatteryCharge? = null,
val motionSupport: MotionSupport = MotionSupport.Full,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.joegec.joycon2android.model

/** What a controller actually measures; console-protocol ones carry no gyroscope. */
enum class MotionSupport {
Full,
AccelerometerOnly,
None,
;

val measuresAcceleration: Boolean
get() = when (this) {
Full, AccelerometerOnly -> true
None -> false
}

val measuresRotation: Boolean
get() = when (this) {
Full -> true
AccelerometerOnly, None -> false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.joegec.joycon2android.model

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class BatteryChargeTest {

@Test
fun `a voltage becomes the gauge percentage`() {
assertEquals(75, BatteryCharge.fromVolts(3.30f)?.percent)
}

@Test
fun `a packet without a voltage reports no charge`() {
assertNull(BatteryCharge.fromVolts(0f))
}

@Test
fun `a level becomes a percentage of the levels available`() {
assertEquals(100, BatteryCharge.fromLevel(9, 9).percent)
assertEquals(55, BatteryCharge.fromLevel(5, 9).percent)
assertEquals(0, BatteryCharge.fromLevel(0, 9).percent)
}
}
135 changes: 133 additions & 2 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ requested at any alignment.
The packet's voltage reads ~0.6 V below the cell's: ~3.30 V shows 75% on a Switch 2, ~3.60 V shows
100%. `BatteryGauge` interpolates Nintendo's Joy-Con thresholds (3.3 / 3.6 / 3.76 / 3.9 / 4.2 V,
from dekuNukem's docs) shifted down 0.6 V. Below ~3.0 V is extrapolated; no low readings have been
captured yet.
captured yet. `JoyconInput` carries the result as a `BatteryCharge` percentage, since the console
report gives a level rather than a voltage.

## Stick range and centre

Expand All @@ -207,12 +208,142 @@ corrected values:
stores. Spans are seeded just under the smallest travel measured (~1180 LSB), so full tilt works
from the first packet, and only ever widen.

## Console-protocol controllers

Some third-party Joy-Con 2 clones (measured on a NYXI Hyperion 3, left and right, 2026-09) copy the
GATT table above but ignore the write characteristic and never notify on `...fd2`. They implement
only the side-specific channel a Switch 2 console uses, driven by `connection/console/`.

| Thing | Left | Right |
|---|---|---|
| Command write (no response) | `ce49a830-dced-48ae-931e-c8cf88aadbea` | `65a724b3-f1e7-4a61-8078-a342376b27ff` |
| Input notify | `cc1bbbb5-7354-4d32-a716-a81cb241a32a` | `d5a9e01e-2ffc-4cca-b20c-8b67142bf442` |
| Extended responses | `63a3810f-aec7-474b-9010-3d52403cb996` | `640ca58e-0e88-410c-a7f3-426faf2b690b` |
| Responses | `c765a961-d9d8-4d36-a20a-5315b111836a` | same |
| Session start | `00c5af5d-1964-4e30-8f51-1956f96bd282`, write `01 00` | same |
| Report rate descriptor | `679d5510-5a24-4dee-9557-95df80486ecb`, write `85 00` | same |

Commands take the same 8-byte header as above, behind 17 zero bytes. `ConsoleSession` replays the
console's order: hello (`07/01`), the DeviceInfo SPI read, firmware info (`10/01`), `16/01`, a
rumble sample, the player LED, feature mask `0x37`, four more SPI reads, `11/03`, `11/01`, then the
report-rate descriptor and the input CCCD. It does not pair (report `0x15`): pairing would store
this host on the controller and unpair it from its owner's console, and it buys nothing, because
Android connects from a resolvable private address the controller ignores.

### Input report

63 bytes on the input characteristic, report `0x07` left / `0x08` right:

| Offset | Size | Field |
|---|---|---|
| `0` | 1 | counter, +1 per report |
| `1` | 1 | power — bit 0 external, bit 1 charging, bits 2..5 battery level 0–9 |
| `2..3` | 2 | buttons, little-endian |
| `4` | 1 | always `0x07` |
| `5..7` | 3 | stick, packed 12-bit as above |
| `0x0E` (left) / `0x0F` (right) | 1 | motion block length — 4 during init, then 30 |
| `0x0F` (left) / `0x10` (right) | 30 | motion block, below |

#### Motion block

Seven 4-byte words and two spare bytes; the preceding byte gives the length. Offsets are from the
start of the block. ndeadly's reference calls this format unknown and allots it 0x28 bytes, listing
lengths {0, 30, 40}. A NYXI Hyperion 3 only ever sends 30: 8,830 consecutive reports (both sides, at
the 30 ms interval with no output running) are all length 30 and type `0x0C`, bar one 4-byte block
per side at init.

| Word | Contents |
|---|---|
| `0x00` | timestamp, climbing steadily even at rest, then a block type at `0x03` — `0x0C` here |
| `0x04`, `0x08`, `0x0C` | a dead-reckoned estimate in world coordinates, not raw sensor data |
| `0x10`, `0x14`, `0x18` | accel x, y, z: int16 in each word's **high half**, low half always zero, 4096 = 1 g |
| `0x1C` | two spare bytes, always zero — the block ends here |

Accelerometer, measured on a NYXI Hyperion 3 (both sides, 2026-10) against gravity in six
orientations, magnitude 1.00 g throughout: x is the controller's right and z leaves the button face,
as on a genuine Joy-Con 2, but **y runs the opposite way**, so `ConsolePacketParser` negates it.
Confirmed by Mario Kart Wii's tilt steering in Dolphin.

No rotation data reaches the app at all, so these controllers report
`MotionSupport.AccelerometerOnly`: DSU advertises the slot as a pad without a gyroscope, the readout
names it, and nothing downstream reads zeros as a real measurement. The 12 bytes a 40-byte block
would add are exactly where a gyro triple would sit, but nothing moved them: no reply to feature
select (`0x0C`) changes the length, including configure (`0x06`) with the IMU flag and the
reference's own data bytes, dropping the mouse feature (mask `0x07`), or either one of enable and
set-mask. Get-feature-info (`0x0C/0x01`) answers with a bare header on this hardware, where the
reference documents 8 bytes of capability data, so the firmware stubs it. Nor does anything else
reach it: the two characteristics the reference lists as "Input Report (Unknown)"
(`ab7de9be…7fde` and `d3bd69d2…`) accept a subscription and then never notify, and the report-rate
descriptor takes ten different values, content byte included, without the length budging.

The sensor itself is present. NYXI specifies 9-axis motion, and the three words at `0x04`–`0x0F`
hold a world-frame estimate the firmware could only resolve from a gyroscope: they ramp at rest as
an accelerometer bias integrates, and after a 90° yaw the two largest swap roles and rotate with it
while the third stays small. It is the raw rate that never reaches this report.

A genuine Joy-Con 2 forced down this path does send more (measured on an AYN Thor, Android 13): its
length cycles 30 → 4 → 30 → 40, and the 40-byte block — type `0x0F` — arrives at a steady ~34/s per
side, bit-packed and probably several samples per report, which is most likely where the gyro sits.
Its 30-byte blocks carry type `0x0C` as these do but not this layout: their accel words have no zero
low half, so the guards report `MotionSupport.None` for a genuine controller that ends up here.
Asking for 7.5 ms suppresses the 40-byte stream, which drops from 34/s to between 2 and 30/s while
the smaller blocks flood the link, so that measurement needs faster updates off.

Mario Kart Wii tricks and wheelies ride `Gyro Pitch`, so they cannot fire; bind **Shake** to a
button instead ([why](dsu-motion.md#tricks-and-wheelies)). Tilt steering is accelerometer-only and
works.

Buttons, by bit: right `[2]` B A Y X R ZR + RS, `[3]` Home `0x01`, C `0x10`, SR `0x40`, SL `0x80`;
left `[2]` Down Right Left Up L ZL − LS, `[3]` Capture `0x01`, SR `0x40`, SL `0x80`.
`ConsolePacketParser` translates them into the bitmask above, so everything downstream is unchanged.

### Android workarounds

These controllers send an SMP Security Request on every connection, which a genuine Joy-Con 2 never
does — that request is what identifies them. Android pairs one device at a time, so a second clone
connecting while the first one's pairing is pending sends none; silence on the common channel 1.5 s
after init switches it over instead. Genuine Joy-Con 2s answer the console channel too, so that
silence is the only thing separating them: a controller moved over by the timeout goes back to the
common path as soon as a report arrives on `...fd2`, which only a controller speaking that protocol
sends.

The pairing itself can never succeed (Confirm Value Failed, or a 30 s timeout), so:

- `SecurityRequestReceiver` aborts the ordered `ACTION_PAIRING_REQUEST` broadcast, and no system
dialog appears.
- `l2cu_start_post_bond_timer` then drops the link 3 s later unless it carries a dynamic L2CAP
channel. The controller never answers LE credit-based connection requests, so `LinkHolder` keeps a
pending `createInsecureL2capChannel(0x80).connect()` on the link — each attempt pends ~20 s.

Both depend on AOSP Bluetooth internals and may break on a future release.

That pending channel has a side effect. When the controller drops, the stack turns the in-flight
`connect()` into a pending direct connection and keeps it armed; closing the socket does not remove
it. The tablet then connects to the controller the moment it is switched back on — `initiator:local`,
this app's own process — which consumes the advertisement, so no scan ever sees it, and brings the
link up with no GATT client and no pool entry behind it. Nothing aborts the pairing broadcast, the
dialog appears, and the failed pairing powers the controller off; restarting the app was the only
cure, because process death cancels the pending connect. So `JoyconConnection` keeps its pool entry
after a drop and arms `connectGatt(autoConnect = true)` for two minutes, which makes that connection
its own: the dialog is aborted and the start-up sequence runs. A dropped controller returns on its
next SYNC with no rescan — 5.9 s and 6.8 s on a RedMagic Astra, 2026-10-05 — and keeps its player
slot. The wait is not console-specific: any controller is held for those two minutes, then released
to the pool as before.

High priority settles at 15 ms for these controllers (~67 reports/s). `ConnectionInterval` instead
asks the hidden `BluetoothGatt.requestLeConnectionUpdate` for 7.5 ms, the LE minimum, reached
through HiddenApiBypass because the method is on the blocked list; at 7.5 ms both controllers
deliver ~200 reports/s with no lost reports (RedMagic Astra, Android 16, 2026-09). It falls back to
`CONNECTION_PRIORITY_HIGH`, and every console session asks again when another controller joins,
since Android can slow an existing connection down when one does.

## Android BLE gotchas

1. **MTU first.** The default ATT MTU of 23 truncates 63-byte notifications: `requestMtu(247)`
after connecting, wait for `onMtuChanged`, then discover services.
2. **One GATT operation at a time.** A second issued before the callback is silently dropped.
`GattOpQueue` advances on the matching callback, or after a timeout if none comes.
`GattOpQueue` carries every operation on a connection, whichever protocol issues it, and advances
on the callback of the operation in flight, or after a timeout if none comes.
3. **Write the CCCD.** `setCharacteristicNotification(true)` alone delivers nothing; descriptor
`0x2902` must be written too.
4. **Pass `TRANSPORT_LE`** to `connectGatt`, or it may try classic Bluetooth.
Expand Down
1 change: 1 addition & 0 deletions feature/connection/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ dependencies {
implementation(project(":feature:connection:domain"))
implementation(project(":core:model"))
implementation(libs.androidx.datastore.preferences)
implementation(libs.hiddenapibypass)
testImplementation(libs.junit)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.joegec.joycon2android.connection

import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import java.util.UUID

/** The channel a Joy-Con 2 speaks to any host: docs/protocol.md#ble-services */
internal class CommonChannel(
val write: BluetoothGattCharacteristic,
val input: BluetoothGattCharacteristic,
val commandResponse: BluetoothGattCharacteristic?,
) {
companion object {
val INPUT: UUID = UUID.fromString("ab7de9be-89fe-49ad-828f-118f09df7fd2")
val COMMAND_RESPONSE: UUID = UUID.fromString("c765a961-d9d8-4d36-a20a-5315b111836a")
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")

private val SERVICE = UUID.fromString("ab7de9be-89fe-49ad-828f-118f09df7fd0")
private val WRITE = UUID.fromString("649d4ac9-8eb7-4e6c-af44-1ea54fe5f005")

fun hasService(gatt: BluetoothGatt) = gatt.getService(SERVICE) != null

/** Null when a controller advertises the service but not the characteristics to drive it. */
fun find(gatt: BluetoothGatt): CommonChannel? {
val service = gatt.getService(SERVICE) ?: return null
val write = service.getCharacteristic(WRITE) ?: return null
val input = service.getCharacteristic(INPUT) ?: return null
return CommonChannel(write, input, service.getCharacteristic(COMMAND_RESPONSE))
}
}
}
Loading