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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ 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. They need
Android 10 or later to stay connected; on older versions they drop about 30 seconds in.

## Setup guide

Expand Down Expand Up @@ -228,6 +232,8 @@ 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 |
| Controller shows "Connection lost" | Press SYNC on it and it reconnects to the same player by itself, or tap Scan. After two minutes it's removed |
| 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 @@ -10,4 +10,5 @@ data class ConnectedJoycon(
val ready: Boolean = false,
) {
val accentColor: Int? get() = connectionState.accentColor
val connectionLost: Boolean get() = connectionState.connectionLost
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ data class JoyconConnectionState(
val connected: Boolean = false,
val connecting: Boolean = false,
val ready: Boolean = false,
/** Dropped, and held for a while in case it comes back. */
val connectionLost: Boolean = false,
val deviceName: String? = null,
val error: String? = null,
/** 0xRRGGBB from SPI flash; null until read, or unset on the controller. */
Expand Down
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)
}
}
3 changes: 3 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ top-level `Color` vals used directly. One teal accent, plus each controller's ow

**Status**
- `ErrorText` — `#FF6B6B` / `ErrorBg` — `#2D1B1B`
- `WarningText` — `#FFB454` / `WarningBg` — `#2D2113`: a limit or a recoverable state, not a fault —
the no-gyroscope note, and a dropped controller's **Connection lost** (a `WarningBox` at the top
of its player card, a link-off glyph in the compact row, a label in the unassigned card)
- Battery ramp (`batteryColor()`): high `Accent` · medium `#FBBF24` · low `BatteryLow #FF8A8A`
(a lighter red than `ErrorText` so the low % clears AA on the `AccentDim` pill). Shown with a
battery icon whose fill tracks the level, so it isn't colour-only.
Expand Down
145 changes: 143 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,152 @@ 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

**Detection.** These controllers send an SMP Security Request on every connection; a genuine
Joy-Con 2 never does. Android pairs one device at a time, so a second clone connecting while the
first one's pairing is pending sends none, and silence on the common channel 1.5 s after init
switches it over instead. Genuine Joy-Con 2s answer the console channel too, so a controller moved
over by that timeout returns to the common path on its first report on `...fd2`, which only a
controller speaking that protocol sends.

**Holding the link.** The pairing never succeeds (Confirm Value Failed, or a 30 s timeout):

| Problem | Workaround |
|---|---|
| A system pairing dialog appears | `SecurityRequestReceiver` aborts the ordered `ACTION_PAIRING_REQUEST` broadcast |
| `l2cu_start_post_bond_timer` drops the link 3 s after the pairing fails, unless it carries a dynamic L2CAP channel | `LinkHolder` keeps a pending `createInsecureL2capChannel(0x80).connect()` on the link. The controller never answers LE credit-based connection requests, so each attempt pends ~20 s and is replaced when it ends |

- `createInsecureL2capChannel` is API 29+. Below Android 10 nothing holds the link, and a clone
drops ~33 s after connecting.
- An attempt that fails at once (a stack that rejects the request) backs off from 200 ms to 5 s.
- Both workarounds depend on AOSP Bluetooth internals and may break on a future release.
- The pending channel outlives a drop: see [Coming back after a drop](#coming-back-after-a-drop).

**Connection interval.** High priority settles at 15 ms for these controllers (~67 reports/s).
`ConnectionInterval` asks the hidden `BluetoothGatt.requestLeConnectionUpdate` for 7.5 ms, the LE
minimum, 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 controller asks again when another one joins,
since Android can slow an existing connection when one does.

## Coming back after a drop

Every controller, genuine or clone, is held for two minutes after it drops (`ReturnWindow`):

- `JoyconConnection` keeps its pool entry, player slot and LEDs, and arms
`connectGatt(autoConnect = true)`.
- The UI marks it **Connection lost** and asks for SYNC, on the player card and the unassigned card.
- A scan that finds it hands it to the waiting connection (`ConnectionPool.connect` →
`reconnect`), which connects directly instead of skipping a known address.
- After two minutes, or a tap on Disconnect, it leaves the pool as any dropped controller did.
- Only a controller that reached ready is held. One that never finished connecting leaves at once.

The hold exists for clones. When a clone drops, the stack turns `LinkHolder`'s in-flight `connect()`
into a pending direct connection, and closing the socket does not remove it; only process death
does. The phone then connects to the controller as soon as it advertises (`initiator:local` in the
HCI log), which consumes the advertisement, so a scan never sees it. Without a GATT client of ours on
that link, nothing aborts the pairing broadcast, and the failed pairing powers the controller off.
Holding the pool entry and arming `autoConnect` makes that link ours. A clone returns on its next
SYNC in ~6 s (5.9 s and 6.8 s, RedMagic Astra, 2026-10-05).

## 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. A callback
doesn't say which operation it answers, so a sent operation holds the queue until its callback or
its own timeout, even after the queue is cleared for a protocol switch.
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
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import com.joegec.joycon2android.ui.theme.Dimens
import com.joegec.joycon2android.ui.theme.JoyconDefaultColor
import com.joegec.joycon2android.ui.theme.TextDim
import com.joegec.joycon2android.ui.theme.TextOnAccent
import com.joegec.joycon2android.ui.theme.WarningText
import com.joegec.joycon2android.ui.theme.joyconBorderColor

@Composable
Expand Down Expand Up @@ -113,7 +114,18 @@ private fun JoyconAssignmentRow(
style = MaterialTheme.typography.titleSmall,
)
AnimatedVisibility(
visible = !joycon.ready,
visible = joycon.connectionLost,
enter = fadeIn() + expandHorizontally(),
exit = fadeOut() + shrinkHorizontally(),
) {
Text(
stringResource(R.string.status_connection_lost),
color = WarningText,
style = MaterialTheme.typography.bodySmall,
)
}
AnimatedVisibility(
visible = !joycon.ready && !joycon.connectionLost,
enter = fadeIn() + expandHorizontally(),
exit = fadeOut() + shrinkHorizontally(),
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<string name="side_right">Right</string>
<string name="side_pro">Pro</string>
<string name="status_connecting">CONNECTING…</string>
<string name="status_connection_lost">CONNECTION LOST · PRESS SYNC</string>
<string name="unassigned_title">Unassigned Controllers</string>
<string name="button_disconnect">Disconnect</string>
<string name="unassigned_combo_tip">Tip: press L + R or SL + SR to assign players!</string>
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
Expand Up @@ -36,12 +36,12 @@ class BleScanner(context: Context) {

val isAvailable: Boolean get() = adapter?.bluetoothLeScanner != null

fun start(isKnownAddress: (String) -> Boolean) {
fun start(isConnected: (String) -> Boolean) {
if (isScanning) return
val scanner = adapter?.bluetoothLeScanner ?: return

isScanning = true
scanner.startScan(null, lowLatencySettings(), createCallback(isKnownAddress))
scanner.startScan(null, lowLatencySettings(), createCallback(isConnected))
Log.i(TAG, "Scanning for Joy-Con 2 controllers...")
scheduleTimeout()
}
Expand All @@ -56,15 +56,15 @@ class BleScanner(context: Context) {

private var activeCallback: ScanCallback? = null

private fun createCallback(isKnownAddress: (String) -> Boolean): ScanCallback {
private fun createCallback(isConnected: (String) -> Boolean): ScanCallback {
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (!isScanning) return
val manufacturerData = nintendoData(result) ?: return
logAdvertisement(result, manufacturerData)
// Only the bonded host can connect to a wake advert: docs/protocol.md#advertising
if (!JoyconAdvertisement.isPairing(manufacturerData)) return
if (isKnownAddress(result.device.address)) return
if (isConnected(result.device.address)) return

val name = result.device.name
?: result.scanRecord?.deviceName
Expand Down
Loading
Loading