From d5f9ffa6542bc1455fc9bec6bf10cd2741600e4e Mon Sep 17 00:00:00 2001 From: mygamecache <258696518+mygamecache@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:18:41 -0700 Subject: [PATCH] Support Joy-Con 2 clones that only speak the console protocol NYXI Hyperion 3 Joy-Cons copy the Joy-Con 2 GATT table but ignore the common command and input characteristics, answering only on the side-specific channel a Switch 2 console uses. They are recognised by the SMP security request they send on connect, or by silence on the common channel after init, and are then driven by the console start-up sequence and input report. Genuine Joy-Con 2s answer the console channel too, so a controller moved over by the timeout returns to the common path as soon as a report arrives there. Android's pairing with these controllers always fails, so the pairing dialog is suppressed and a pending L2CAP channel keeps the link from being dropped afterwards, retried with a growing delay while it fails instantly. A 7.5 ms connection interval is requested, with CONNECTION_PRIORITY_HIGH as the fallback, and every controller asks for its interval again when another one joins the pool. That pending channel outlives the link: the stack keeps it armed as a pending connect and reconnects to the controller on its next SYNC, swallowing the advertisement and leaving a link no GATT client owns, so the pairing dialog returns and powers the controller off. A dropped controller therefore keeps its pool entry and an armed connectGatt for two minutes, which makes that connection its own and brings it back on SYNC without a rescan. Both protocols now run over one operation mechanism and one command builder behind a ControllerProtocol interface, so a GATT callback releases the operation in flight rather than whatever else is waiting, and the common path's frames are built rather than hardcoded. The motion block holds an accelerometer on the same 4096 = 1 g scale as the common report, with y against the genuine axis, and no rotation at all: these send only the 30-byte block type, where a genuine Joy-Con 2 also sends a 40-byte one. Such a controller reports MotionSupport.AccelerometerOnly: DSU advertises the slot as a pad without a gyroscope, and the readout names it and warns that motion aiming cannot work, so nothing downstream reads zeros as a measurement. The console report carries a battery level rather than a voltage, so JoyconInput now holds the charge as a BatteryCharge percentage the UI shows directly. --- README.md | 4 + .../joycon2android/model/BatteryCharge.kt | 13 + .../joycon2android/model/JoyconInput.kt | 3 +- .../joycon2android/model/MotionSupport.kt | 21 + .../joycon2android/model/BatteryChargeTest.kt | 25 ++ docs/protocol.md | 135 ++++++- feature/connection/data/build.gradle.kts | 1 + .../connection/CommonChannel.kt | 31 ++ .../connection/CommonSession.kt | 164 ++++++++ .../connection/ConnectionPool.kt | 20 +- .../connection/ControllerCommands.kt | 41 ++ .../connection/ControllerProtocol.kt | 13 + .../joycon2android/connection/GattOpQueue.kt | 87 +++- .../connection/JoyconConnection.kt | 379 +++++++++--------- .../joycon2android/connection/PacketParser.kt | 5 +- .../connection/console/ConnectionInterval.kt | 32 ++ .../connection/console/ConsoleChannel.kt | 49 +++ .../connection/console/ConsoleCommands.kt | 40 ++ .../connection/console/ConsolePacketParser.kt | 98 +++++ .../connection/console/ConsoleSession.kt | 152 +++++++ .../connection/console/ConsoleTransport.kt | 112 ++++++ .../connection/console/LinkHolder.kt | 72 ++++ .../console/SecurityRequestReceiver.kt | 61 +++ .../connection/ControllerCommandsTest.kt | 63 +++ .../console/ConsolePacketParserTest.kt | 117 ++++++ .../connection/presentation/BatteryPill.kt | 15 +- .../presentation/CompactPlayerRow.kt | 2 +- .../presentation/ControllerButtons.kt | 8 +- .../connection/presentation/ImuDisplay.kt | 21 +- .../connection/presentation/NoGyroNote.kt | 26 ++ .../presentation/ProControllerLayout.kt | 2 +- .../presentation/SidewaysImuDisplay.kt | 21 +- .../presentation/SidewaysLeftLayout.kt | 4 +- .../presentation/SidewaysRightLayout.kt | 4 +- .../src/main/res/values/strings.xml | 2 + .../joycon2android/dsu/DsuPacketEncoder.kt | 15 +- .../dsu/DsuPacketEncoderTest.kt | 5 +- .../joycon2android/dsu/DsuServerTest.kt | 5 +- .../gamepad/ReportMapperTest.kt | 3 +- gradle/libs.versions.toml | 2 + 40 files changed, 1633 insertions(+), 240 deletions(-) create mode 100644 core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryCharge.kt create mode 100644 core/model/src/main/kotlin/com/joegec/joycon2android/model/MotionSupport.kt create mode 100644 core/model/src/test/kotlin/com/joegec/joycon2android/model/BatteryChargeTest.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonChannel.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerCommands.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerProtocol.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConnectionInterval.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleChannel.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleCommands.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParser.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleSession.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleTransport.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/LinkHolder.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/SecurityRequestReceiver.kt create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ControllerCommandsTest.kt create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParserTest.kt create mode 100644 feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/NoGyroNote.kt diff --git a/README.md b/README.md index fe69e8df..154583fa 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryCharge.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryCharge.kt new file mode 100644 index 00000000..f714e685 --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryCharge.kt @@ -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) + } +} diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt index c163fda3..4d20036e 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt @@ -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, ) diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/MotionSupport.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/MotionSupport.kt new file mode 100644 index 00000000..1ea117d4 --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/MotionSupport.kt @@ -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 + } +} diff --git a/core/model/src/test/kotlin/com/joegec/joycon2android/model/BatteryChargeTest.kt b/core/model/src/test/kotlin/com/joegec/joycon2android/model/BatteryChargeTest.kt new file mode 100644 index 00000000..c631121b --- /dev/null +++ b/core/model/src/test/kotlin/com/joegec/joycon2android/model/BatteryChargeTest.kt @@ -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) + } +} diff --git a/docs/protocol.md b/docs/protocol.md index 65540c2a..f36805c0 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 @@ -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. diff --git a/feature/connection/data/build.gradle.kts b/feature/connection/data/build.gradle.kts index 26a9d34d..d7a76926 100644 --- a/feature/connection/data/build.gradle.kts +++ b/feature/connection/data/build.gradle.kts @@ -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) } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonChannel.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonChannel.kt new file mode 100644 index 00000000..6b4311f8 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonChannel.kt @@ -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)) + } + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt new file mode 100644 index 00000000..5ebf03e1 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt @@ -0,0 +1,164 @@ +package com.joegec.joycon2android.connection + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothStatusCodes +import android.os.Build +import android.util.Log +import com.joegec.joycon2android.model.JoyconInput +import com.joegec.joycon2android.model.Side +import java.util.UUID + +/** Start-up sequence: docs/protocol.md#input-report */ +@SuppressLint("MissingPermission") +internal class CommonSession( + private val gatt: BluetoothGatt, + private val channel: CommonChannel, + private val side: Side, + private val ops: GattOpQueue, + initialLedBitmask: Byte, + private val onInput: (JoyconInput) -> Unit, + private val onAccentColor: (Int) -> Unit, + private val onReady: () -> Unit, +) : ControllerProtocol { + companion object { + private const val TAG = "Joycon2" + + // A controller misses an init command sent too soon after the one before. + private const val INIT_GAP_MS = 500L + + private val ALL_FEATURES: Byte = 0xFF.toByte() + private val FEATURES_INIT = ControllerCommands.selectFeatures(ALL_FEATURES) + private val FEATURES_ENABLE = ControllerCommands.enableFeatures(ALL_FEATURES) + + // 0x40 bytes of the DeviceInfo block: docs/protocol.md#spi-reads. This path has always + // sent transport 0x00 (USB) here, and a Joy-Con 2 answers it. + private val SPI_READ_COLOR = ControllerCommands.spiRead( + 0x40, ControllerCommands.DEVICE_INFO_ADDRESS, ControllerCommands.USB, + ) + } + + @Volatile private var ready = false + @Volatile private var ledBitmask = initialLedBitmask + private var ledSentAfterFirstPacket = false + + override fun start() { + channel.write.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + subscribeCommandResponse() + subscribeInput() + ops.enqueue(INIT_GAP_MS) { write(FEATURES_INIT) } + ops.enqueue(INIT_GAP_MS) { write(FEATURES_ENABLE) } + ops.enqueue(INIT_GAP_MS) { write(SPI_READ_COLOR) } + ops.enqueue { + ready = true + Log.i(TAG, "[$side] Init sequence complete") + onReady() + false // no GATT op — advance immediately + } + } + + override fun stop() { + ready = false + } + + /** The console detour ended, so this channel is driving the controller again. */ + fun resume() { + ready = true + } + + override fun onCharacteristicChanged(uuid: UUID, value: ByteArray) { + when (uuid) { + CommonChannel.INPUT -> onReport(value) + CommonChannel.COMMAND_RESPONSE -> onCommandResponse(value) + } + } + + override fun setPlayerLed(bitmask: Byte) { + ledBitmask = bitmask + if (ready) sendPlayerLed() + } + + override fun setHighPriority(enabled: Boolean) { + val priority = if (enabled) { + BluetoothGatt.CONNECTION_PRIORITY_HIGH + } else { + BluetoothGatt.CONNECTION_PRIORITY_BALANCED + } + Log.i(TAG, "[$side] Connection priority high=$enabled accepted=${gatt.requestConnectionPriority(priority)}") + } + + private fun onReport(report: ByteArray) { + PacketParser.parse(report, side)?.let(onInput) + if (!ledSentAfterFirstPacket && ready) { + ledSentAfterFirstPacket = true + sendPlayerLed() + } + } + + private fun onCommandResponse(value: ByteArray) { + Log.d(TAG, "[$side] Cmd response: ${value.joinToString(" ") { "%02X".format(it) }}") + SpiColorParser.parseAccentColor(value)?.let { color -> + Log.i(TAG, "[$side] Accent color: #${"%06X".format(color)}") + onAccentColor(color) + } + } + + private fun sendPlayerLed() { + ops.enqueue { + val cmd = ControllerCommands.playerLed(ledBitmask) + Log.i(TAG, "[$side] Sending LED cmd: ${cmd.joinToString(" ") { "%02X".format(it) }}") + write(cmd) + } + } + + private fun subscribeCommandResponse() { + val characteristic = channel.commandResponse ?: return + gatt.setCharacteristicNotification(characteristic, true) + val cccd = characteristic.getDescriptor(CommonChannel.CCCD) + if (cccd == null) { + Log.w(TAG, "[$side] CMD_RESPONSE char has no CCCD descriptor") + return + } + ops.enqueue { + Log.d(TAG, "[$side] Writing CMD_RESPONSE CCCD") + writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } + } + + private fun subscribeInput() { + gatt.setCharacteristicNotification(channel.input, true) + val cccd = channel.input.getDescriptor(CommonChannel.CCCD) + if (cccd == null) { + Log.e(TAG, "[$side] NOTIFY char has no CCCD descriptor — notifications won't work!") + return + } + ops.enqueue { + Log.d(TAG, "[$side] Writing NOTIFY CCCD") + writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } + } + + private fun write(value: ByteArray): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic( + channel.write, value, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + ) == BluetoothStatusCodes.SUCCESS + } else { + @Suppress("DEPRECATION") + channel.write.value = value + @Suppress("DEPRECATION") + gatt.writeCharacteristic(channel.write) + } + + private fun writeDescriptor(descriptor: BluetoothGattDescriptor, value: ByteArray): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS + } else { + @Suppress("DEPRECATION") + descriptor.value = value + @Suppress("DEPRECATION") + gatt.writeDescriptor(descriptor) + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt index 53691df2..3c90f7ad 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt @@ -3,6 +3,7 @@ package com.joegec.joycon2android.connection import android.annotation.SuppressLint import android.bluetooth.le.ScanResult import android.content.Context +import com.joegec.joycon2android.connection.console.SecurityRequestReceiver import com.joegec.joycon2android.model.Side import java.util.concurrent.ConcurrentHashMap @@ -11,6 +12,10 @@ import java.util.concurrent.ConcurrentHashMap class ConnectionPool(private val context: Context) { private val connections = ConcurrentHashMap() + private val securityRequests = SecurityRequestReceiver( + onSecurityRequested = { address -> connections[address]?.let { it.onSecurityRequested(); true } ?: false }, + onPairingFailed = { address -> connections[address]?.onPairingFailed() }, + ) var onPoolChanged: (() -> Unit)? = null @@ -21,12 +26,18 @@ class ConnectionPool(private val context: Context) { /** Null for an address already in the pool (a duplicate scan result). */ fun connect(result: ScanResult, side: Side, name: String, highPriority: Boolean): JoyconConnection? { val address = result.device.address - val connection = JoyconConnection(context, side, name) { + val connection = JoyconConnection( + context, + side, + name, + onReady = { reassertOtherPriorities(address) }, + ) { connections.remove(address) onPoolChanged?.invoke() } connection.setHighPriority(highPriority) if (connections.putIfAbsent(address, connection) != null) return null + securityRequests.register(context) connection.connect(result.device) return connection } @@ -37,6 +48,13 @@ class ConnectionPool(private val context: Context) { connections.values.forEach { it.setHighPriority(enabled) } } + // Android can slow an existing connection when another controller connects. + private fun reassertOtherPriorities(joined: String) { + connections.forEach { (address, connection) -> + if (address != joined) connection.reassertPriority() + } + } + fun disconnect(address: String) { connections.remove(address)?.disconnect() } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerCommands.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerCommands.kt new file mode 100644 index 00000000..0a3d6b9c --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerCommands.kt @@ -0,0 +1,41 @@ +package com.joegec.joycon2android.connection + +/** Command framing, shared by both protocols: docs/protocol.md#commands */ +internal object ControllerCommands { + + const val HEADER_LENGTH = 8 + + // Transport, at [2] of the header. + const val BLUETOOTH = 0x01 + const val USB = 0x00 + + // A reply echoes the id at [0], this at [1] and the sub-command at [3]. + const val REPLY_MARKER: Byte = 0x01 + + const val DEVICE_INFO_ADDRESS = 0x013000 + const val LED_ALL_ON: Byte = 0x0F + + private const val REQUEST = 0x91 + + /** Header: id, direction, transport, sub-command, unknown, data length, two reserved bytes. */ + fun command(id: Int, sub: Int, data: ByteArray = ByteArray(0), transport: Int = BLUETOOTH): ByteArray = + bytes(id, REQUEST, transport, sub, 0x00, data.size, 0x00, 0x00) + data + + fun selectFeatures(mask: Byte) = command(0x0C, 0x02, featureMask(mask)) + + fun enableFeatures(mask: Byte) = command(0x0C, 0x04, featureMask(mask)) + + fun playerLed(bitmask: Byte) = command(0x09, 0x07, byteArrayOf(bitmask) + ByteArray(7)) + + fun spiRead(length: Int, address: Int, transport: Int = BLUETOOTH) = command( + 0x02, 0x04, + byteArrayOf(length.toByte(), 0x7E, 0x00, 0x00) + littleEndian(address), + transport, + ) + + private fun featureMask(mask: Byte) = byteArrayOf(mask, 0x00, 0x00, 0x00) + + private fun littleEndian(value: Int) = bytes(value, value shr 8, value shr 16, value shr 24) + + private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerProtocol.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerProtocol.kt new file mode 100644 index 00000000..ecfc04b0 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerProtocol.kt @@ -0,0 +1,13 @@ +package com.joegec.joycon2android.connection + +import java.util.UUID + +/** How one controller is driven: the common channel, or the console one. */ +internal interface ControllerProtocol { + fun start() + fun stop() + fun onCharacteristicChanged(uuid: UUID, value: ByteArray) + fun setPlayerLed(bitmask: Byte) + fun setHighPriority(enabled: Boolean) + fun onPairingFailed() {} +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt index 62da5675..124ead32 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt @@ -1,11 +1,17 @@ package com.joegec.joycon2android.connection +import android.bluetooth.BluetoothGatt import android.os.Handler import android.os.Looper import android.util.Log import java.util.ArrayDeque +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit -/** Android silently drops a second outstanding GATT op. Advances after [TIMEOUT_MS] if a callback never comes. */ +/** + * Android silently drops a second outstanding GATT op, so every op on a connection goes through + * here. A callback releases the op that is in flight, never whatever else is waiting. + */ class GattOpQueue { companion object { @@ -13,43 +19,84 @@ class GattOpQueue { private const val TIMEOUT_MS = 2000L } + private class Op(val run: () -> Boolean, val gapMs: Long, val finished: CountDownLatch?) { + @Volatile var status = BluetoothGatt.GATT_FAILURE + } + private val handler = Handler(Looper.getMainLooper()) - private val queue = ArrayDeque<() -> Boolean>() - private var inFlight = false + private val queue = ArrayDeque() + private var inFlight: Op? = null private val timeoutRunnable = Runnable { Log.w(TAG, "Op timed out — advancing queue") - inFlight = false - runNext() + complete(BluetoothGatt.GATT_FAILURE) } - fun enqueue(op: () -> Boolean) { - queue.add(op) - runNext() + /** Runs [op] when the queue reaches it, then waits [gapMs] before the next one. */ + fun enqueue(gapMs: Long = 0L, op: () -> Boolean) = add(Op(op, gapMs, null)) + + /** Runs [op] and returns its status. Blocks, so callers must stay off the main thread. */ + fun awaitOp(timeoutMs: Long, op: () -> Boolean): Int { + val entry = Op(op, 0L, CountDownLatch(1)) + add(entry) + return if (entry.finished!!.await(timeoutMs, TimeUnit.MILLISECONDS)) { + entry.status + } else { + BluetoothGatt.GATT_FAILURE + } } - fun complete() { + @Synchronized + fun complete(status: Int) { handler.removeCallbacks(timeoutRunnable) - inFlight = false - runNext() + val op = inFlight ?: return + inFlight = null + op.status = status + op.finished?.countDown() + if (op.gapMs > 0L) handler.postDelayed({ runNext() }, op.gapMs) else runNext() } + @Synchronized fun clear() { handler.removeCallbacks(timeoutRunnable) + val abandoned = queue.toList() + listOfNotNull(inFlight) queue.clear() - inFlight = false + inFlight = null + abandoned.forEach { it.finished?.countDown() } + } + + @Synchronized + private fun add(op: Op) { + queue.add(op) + runNext() } + @Synchronized private fun runNext() { - if (inFlight) return + if (inFlight != null) return val op = queue.poll() ?: return - inFlight = true - val success = op() - if (!success) { - Log.w(TAG, "Op returned false — advancing queue immediately") - inFlight = false - runNext() - } else { + inFlight = op + handler.post { start(op) } + } + + // Ops run on the main thread, and outside the lock: one of them reports the controller ready. + private fun start(op: Op) { + if (!isInFlight(op)) return + if (op.run()) { handler.postDelayed(timeoutRunnable, TIMEOUT_MS) + return } + Log.w(TAG, "Op returned false — advancing queue immediately") + abandon(op) + } + + @Synchronized + private fun isInFlight(op: Op) = inFlight === op + + @Synchronized + private fun abandon(op: Op) { + if (inFlight !== op) return + inFlight = null + op.finished?.countDown() + runNext() } } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt index cb75c71f..beafc089 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt @@ -7,12 +7,12 @@ import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothProfile -import android.bluetooth.BluetoothStatusCodes import android.content.Context -import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log +import com.joegec.joycon2android.connection.console.ConsoleChannel +import com.joegec.joycon2android.connection.console.ConsoleSession import com.joegec.joycon2android.model.JoyconConnectionState import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.PlayerNumber @@ -27,48 +27,19 @@ class JoyconConnection( private val context: Context, val side: Side, val deviceName: String, + private val onReady: () -> Unit, private val onDisconnected: (() -> Unit)? = null, ) { companion object { private const val TAG = "Joycon2" + private const val DESIRED_MTU = 247 - private val INPUT_SERVICE = UUID.fromString("ab7de9be-89fe-49ad-828f-118f09df7fd0") - private val NOTIFY_CHAR = UUID.fromString("ab7de9be-89fe-49ad-828f-118f09df7fd2") - private val WRITE_CHAR = UUID.fromString("649d4ac9-8eb7-4e6c-af44-1ea54fe5f005") - private val CMD_RESPONSE_CHAR = UUID.fromString("c765a961-d9d8-4d36-a20a-5315b111836a") - private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") - - private val INIT_CMD_1 = byteArrayOf( - 0x0C, 0x91.toByte(), 0x01, 0x02, 0x00, 0x04, - 0x00, 0x00, 0xFF.toByte(), 0x00, 0x00, 0x00 - ) - private val INIT_CMD_2 = byteArrayOf( - 0x0C, 0x91.toByte(), 0x01, 0x04, 0x00, 0x04, - 0x00, 0x00, 0xFF.toByte(), 0x00, 0x00, 0x00 - ) - - // 0x40 bytes of the DeviceInfo block at 0x013000: docs/protocol.md#spi-reads - private val SPI_READ_COLOR_CMD = byteArrayOf( - 0x02, 0x91.toByte(), 0x00, 0x04, 0x00, 0x08, 0x00, 0x00, - 0x40, 0x7E, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00 - ) - - // Bitmask layout: docs/protocol.md#player-leds - private fun playerLedCmd(bitmask: Byte): ByteArray { - return byteArrayOf( - 0x09, 0x91.toByte(), 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, - bitmask, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 - ) - } + // A controller that speaks the common protocol answers the SPI read or starts streaming + // well within this window; one that answers later is moved back by its first report. + private const val CONSOLE_FALLBACK_MS = 1_500L - // All four solid. - private val LED_ALL_ON_CMD = byteArrayOf( - 0x09, 0x91.toByte(), 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, - 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 - ) - - private const val DESIRED_MTU = 247 - private const val INIT_GAP_MS = 500L + // How long a dropped controller is waited for before the player loses it. + private const val RECONNECT_WINDOW_MS = 120_000L } private val _connectionState = MutableStateFlow( @@ -80,28 +51,73 @@ class JoyconConnection( val input: StateFlow = _input.asStateFlow() private val mainHandler = Handler(Looper.getMainLooper()) - private val opQueue = GattOpQueue() + private val ops = GattOpQueue() private val stickCalibrator = StickCalibrator() private var gatt: BluetoothGatt? = null - private var writeChar: BluetoothGattCharacteristic? = null - private var notifyChar: BluetoothGattCharacteristic? = null - private var cmdResponseChar: BluetoothGattCharacteristic? = null - private var pendingPlayerLed: PlayerNumber? = null + private var device: BluetoothDevice? = null + private var common: CommonSession? = null + @Volatile private var console: ConsoleSession? = null + + // The console protocol takes over for as long as it runs; everything else goes through here. + private val protocol: ControllerProtocol? get() = console ?: common + @Volatile var initComplete = false private set @Volatile private var highPriority = false - private var ledSentAfterFirstPacket = false + @Volatile private var securityRequested = false + @Volatile private var servicesDiscovered = false + @Volatile private var commonReportSeen = false + @Volatile private var commandReplySeen = false + @Volatile private var assignedPlayer: PlayerNumber? = null + @Volatile private var reconnecting = false + @Volatile private var released = false fun connect(device: BluetoothDevice) { + this.device = device gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) } + /** docs/protocol.md#android-workarounds */ + fun onSecurityRequested() { + if (securityRequested) return + securityRequested = true + Log.i(TAG, "[$side] SMP security request received") + val g = gatt ?: return + if (servicesDiscovered && !commonChannelAnswered()) mainHandler.post { startConsoleSession(g) } + } + + fun onPairingFailed() { + protocol?.onPairingFailed() + } + + fun reassertPriority() { + if (initComplete) protocol?.setHighPriority(highPriority) + } + + fun setHighPriority(enabled: Boolean) { + highPriority = enabled + if (initComplete) protocol?.setHighPriority(enabled) + } + + fun setPlayerLed(player: PlayerNumber) { + assignedPlayer = player + protocol?.setPlayerLed(player.ledBitmask) + } + + fun clearPlayerLed() { + assignedPlayer = null + protocol?.setPlayerLed(ControllerCommands.LED_ALL_ON) + } + fun disconnect() { + released = true + reconnecting = false + stopProtocols() mainHandler.removeCallbacksAndMessages(null) gatt?.disconnect() gatt?.close() gatt = null - opQueue.clear() + ops.clear() _connectionState.value = JoyconConnectionState() _input.value = JoyconInput() } @@ -111,6 +127,7 @@ class JoyconConnection( when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.i(TAG, "[$side] Connected. Requesting MTU $DESIRED_MTU") + reconnecting = false _connectionState.value = JoyconConnectionState( connected = true, deviceName = deviceName ) @@ -118,18 +135,15 @@ class JoyconConnection( } BluetoothProfile.STATE_DISCONNECTED -> { Log.w(TAG, "[$side] Disconnected (status=$status)") - opQueue.clear() - g.close() - gatt = null - initComplete = false - ledSentAfterFirstPacket = false + endSession(g) + _input.value = JoyconInput() + if (!released && awaitReturn()) return _connectionState.value = JoyconConnectionState( deviceName = deviceName, error = if (status != BluetoothGatt.GATT_SUCCESS) { "Connection lost (status $status)" } else null ) - _input.value = JoyconInput() onDisconnected?.invoke() } } @@ -143,188 +157,189 @@ class JoyconConnection( override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { Log.i(TAG, "[$side] Services discovered (status=$status)") if (status != BluetoothGatt.GATT_SUCCESS) { - _connectionState.value = JoyconConnectionState( - error = "Service discovery failed", deviceName = deviceName - ) + fail("Service discovery failed") return } - val svc = g.getService(INPUT_SERVICE) - if (svc == null) { - _connectionState.value = JoyconConnectionState( - error = "Not a compatible Joy-Con 2", deviceName = deviceName - ) + servicesDiscovered = true + if (securityRequested) { + startConsoleSession(g) return } - - writeChar = svc.getCharacteristic(WRITE_CHAR) - notifyChar = svc.getCharacteristic(NOTIFY_CHAR) - cmdResponseChar = svc.getCharacteristic(CMD_RESPONSE_CHAR) - if (writeChar == null || notifyChar == null) { - _connectionState.value = JoyconConnectionState( - error = "Missing BLE characteristics", deviceName = deviceName - ) + if (!CommonChannel.hasService(g)) { + fail("Not a compatible Joy-Con 2") return } - writeChar!!.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - - // LED and SPI replies arrive here. - if (cmdResponseChar != null) { - g.setCharacteristicNotification(cmdResponseChar, true) - val cmdCccd = cmdResponseChar!!.getDescriptor(CCCD) - if (cmdCccd != null) { - opQueue.enqueue { - Log.d(TAG, "[$side] Writing CMD_RESPONSE CCCD") - writeDescriptor(g, cmdCccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) - } - } else { - Log.w(TAG, "[$side] CMD_RESPONSE char has no CCCD descriptor") - } - } - - g.setCharacteristicNotification(notifyChar, true) - val notifyCccd = notifyChar!!.getDescriptor(CCCD) - if (notifyCccd != null) { - opQueue.enqueue { - Log.d(TAG, "[$side] Writing NOTIFY CCCD") - writeDescriptor(g, notifyCccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) - } - } else { - Log.e(TAG, "[$side] NOTIFY char has no CCCD descriptor — notifications won't work!") - } - enqueueInitWrite(g, INIT_CMD_1) - enqueueInitWrite(g, INIT_CMD_2) - enqueueInitWrite(g, SPI_READ_COLOR_CMD) - - opQueue.enqueue { - initComplete = true - _connectionState.value = _connectionState.value.copy( - connected = true, ready = true, deviceName = deviceName - ) - Log.i(TAG, "[$side] Init sequence complete") - if (highPriority) requestPriority(g) - false // no GATT op — advance immediately + val channel = CommonChannel.find(g) + if (channel == null) { + fail("Missing BLE characteristics") + return } + common = CommonSession( + gatt = g, + channel = channel, + side = side, + ops = ops, + initialLedBitmask = assignedPlayer?.ledBitmask ?: ControllerCommands.LED_ALL_ON, + onInput = ::publishInput, + onAccentColor = ::publishAccentColor, + onReady = { + onProtocolReady() + mainHandler.postDelayed({ fallBackToConsoleIfSilent(g) }, CONSOLE_FALLBACK_MS) + }, + ).also { it.start() } } override fun onDescriptorWrite( g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int ) { Log.i(TAG, "[$side] CCCD write status=$status") - mainHandler.post { opQueue.complete() } + ops.complete(status) } override fun onCharacteristicWrite( g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int ) { Log.d(TAG, "[$side] Char write status=$status initComplete=$initComplete") - val delay = if (initComplete) 0L else INIT_GAP_MS - mainHandler.postDelayed({ opQueue.complete() }, delay) + ops.complete(status) } @Deprecated("Deprecated in Java - used for API < 33") override fun onCharacteristicChanged( g: BluetoothGatt, ch: BluetoothGattCharacteristic ) { - handleCharacteristicChanged(g, ch.uuid, ch.value ?: return) + handleCharacteristicChanged(ch.uuid, ch.value ?: return) } override fun onCharacteristicChanged( g: BluetoothGatt, ch: BluetoothGattCharacteristic, value: ByteArray ) { - handleCharacteristicChanged(g, ch.uuid, value) + handleCharacteristicChanged(ch.uuid, value) } } - fun setHighPriority(enabled: Boolean) { - highPriority = enabled - if (initComplete) gatt?.let(::requestPriority) + private fun handleCharacteristicChanged(uuid: UUID, data: ByteArray) { + if (uuid == CommonChannel.INPUT) { + commonReportSeen = true + // Only a controller that speaks the common protocol notifies here, so the fallback misfired. + if (console != null && common != null) returnToCommonChannel() + } else if (console == null && uuid == CommonChannel.COMMAND_RESPONSE) { + commandReplySeen = true + } + protocol?.onCharacteristicChanged(uuid, data) } - // The connection interval is the report rate: docs/protocol.md#android-ble-gotchas - private fun requestPriority(g: BluetoothGatt) { - val priority = if (highPriority) { - BluetoothGatt.CONNECTION_PRIORITY_HIGH - } else { - BluetoothGatt.CONNECTION_PRIORITY_BALANCED + private fun commonChannelAnswered() = commonReportSeen || commandReplySeen + + private fun fallBackToConsoleIfSilent(g: BluetoothGatt) { + if (commonChannelAnswered()) return + Log.i(TAG, "[$side] No reply on the common channel; switching to the console protocol") + startConsoleSession(g) + } + + @Synchronized + private fun startConsoleSession(g: BluetoothGatt) { + if (console != null || commonReportSeen || gatt !== g) return + val channel = ConsoleChannel.find(g) + if (channel == null) { + fail("Not a compatible Joy-Con 2") + return } - Log.i(TAG, "[$side] Connection priority high=$highPriority accepted=${g.requestConnectionPriority(priority)}") + ops.clear() + initComplete = false + _connectionState.value = _connectionState.value.copy(ready = false) + console = ConsoleSession( + gatt = g, + channel = channel, + ops = ops, + initialLedBitmask = assignedPlayer?.ledBitmask ?: ControllerCommands.LED_ALL_ON, + onInput = ::publishInput, + onAccentColor = ::publishAccentColor, + onReady = ::onProtocolReady, + ).also { it.start() } } - fun setPlayerLed(player: PlayerNumber) { - pendingPlayerLed = player - if (!initComplete) return - val g = gatt ?: return - opQueue.enqueue { sendLedCommand(g) } + private fun returnToCommonChannel() { + Log.i(TAG, "[$side] Common-channel report arrived; leaving the console protocol") + stopConsoleSession() + common?.resume() + initComplete = true + _connectionState.value = _connectionState.value.copy( + connected = true, ready = true, deviceName = deviceName + ) + protocol?.setHighPriority(highPriority) } - fun clearPlayerLed() { - pendingPlayerLed = null - if (!initComplete) return - val g = gatt ?: return - opQueue.enqueue { sendLedCommand(g) } + private fun onProtocolReady() { + initComplete = true + _connectionState.value = _connectionState.value.copy( + connected = true, ready = true, deviceName = deviceName + ) + if (highPriority) protocol?.setHighPriority(true) + onReady() } - private fun sendLedCommand(g: BluetoothGatt): Boolean { - val pending = pendingPlayerLed - pendingPlayerLed = null - val cmd = if (pending != null) playerLedCmd(pending.ledBitmask) else LED_ALL_ON_CMD - Log.i(TAG, "[$side] Sending LED cmd: ${cmd.joinToString(" ") { "%02X".format(it) }}") - return writeCharacteristic(g, writeChar!!, cmd) + // Everything that belongs to one link, so the next one starts its own detection over. + private fun endSession(g: BluetoothGatt) { + stopProtocols() + ops.clear() + g.close() + gatt = null + initComplete = false + securityRequested = false + servicesDiscovered = false + commonReportSeen = false + commandReplySeen = false } - private fun enqueueInitWrite(g: BluetoothGatt, bytes: ByteArray) { - opQueue.enqueue { writeCharacteristic(g, writeChar!!, bytes) } + /** + * Android keeps this app's pending L2CAP connect armed after a drop, so the controller is + * reconnected to on its next SYNC whatever we do: docs/protocol.md#android-workarounds. Holding + * the pool entry and asking for that connection makes it ours, dialog suppression included. + */ + private fun awaitReturn(): Boolean { + val target = device ?: return false + val reopened = target.connectGatt(context, true, gattCallback, BluetoothDevice.TRANSPORT_LE) + ?: return false + Log.i(TAG, "[$side] Waiting for the controller to come back") + gatt = reopened + reconnecting = true + _connectionState.value = JoyconConnectionState(connecting = true, deviceName = deviceName) + mainHandler.postDelayed(::stopAwaitingReturn, RECONNECT_WINDOW_MS) + return true } - private fun handleCharacteristicChanged(g: BluetoothGatt, uuid: UUID, data: ByteArray) { - when (uuid) { - NOTIFY_CHAR -> { - PacketParser.parse(data, side)?.let { _input.value = stickCalibrator.calibrate(it) } - if (!ledSentAfterFirstPacket && initComplete) { - ledSentAfterFirstPacket = true - mainHandler.post { opQueue.enqueue { sendLedCommand(g) } } - } - } - CMD_RESPONSE_CHAR -> { - Log.d(TAG, "[$side] Cmd response: ${data.joinToString(" ") { "%02X".format(it) }}") - SpiColorParser.parseAccentColor(data)?.let { color -> - Log.i(TAG, "[$side] Accent color: #${"%06X".format(color)}") - _connectionState.value = _connectionState.value.copy(accentColor = color) - } - } - } + private fun stopAwaitingReturn() { + if (!reconnecting) return + reconnecting = false + Log.i(TAG, "[$side] Gave up waiting for the controller") + gatt?.disconnect() + gatt?.close() + gatt = null + _connectionState.value = JoyconConnectionState(deviceName = deviceName) + onDisconnected?.invoke() } - private fun writeCharacteristic( - g: BluetoothGatt, - ch: BluetoothGattCharacteristic, - value: ByteArray, - ): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - g.writeCharacteristic(ch, value, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) == - BluetoothStatusCodes.SUCCESS - } else { - @Suppress("DEPRECATION") - ch.value = value - @Suppress("DEPRECATION") - g.writeCharacteristic(ch) - } + private fun stopConsoleSession() { + console?.stop() + console = null } - private fun writeDescriptor( - g: BluetoothGatt, - descriptor: BluetoothGattDescriptor, - value: ByteArray, - ): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - g.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS - } else { - @Suppress("DEPRECATION") - descriptor.value = value - @Suppress("DEPRECATION") - g.writeDescriptor(descriptor) - } + private fun stopProtocols() { + stopConsoleSession() + common?.stop() + common = null + } + + private fun publishInput(input: JoyconInput) { + _input.value = stickCalibrator.calibrate(input) + } + + private fun publishAccentColor(color: Int) { + _connectionState.value = _connectionState.value.copy(accentColor = color) } + private fun fail(message: String) { + _connectionState.value = JoyconConnectionState(error = message, deviceName = deviceName) + } } 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 ea9fdd32..2d5bf264 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 @@ -1,5 +1,6 @@ package com.joegec.joycon2android.connection +import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side @@ -46,7 +47,7 @@ object PacketParser { gyroX = bb.getShort(0x36).toInt(), gyroY = bb.getShort(0x38).toInt(), gyroZ = bb.getShort(0x3A).toInt(), - batteryVolts = (bb.getShort(0x1F).toInt() and 0xFFFF) / 1000f, + battery = BatteryCharge.fromVolts((bb.getShort(0x1F).toInt() and 0xFFFF) / 1000f), ) } @@ -85,6 +86,6 @@ object PacketParser { ((data[offset + 1].toInt() and 0xFF) shl 8) or ((data[offset + 2].toInt() and 0xFF) shl 16) - private fun decodeButtons(buttons: Long): Set = + internal fun decodeButtons(buttons: Long): Set = buttonMasks.filter { (mask, _) -> buttons and mask != 0L }.map { it.second.id }.toSet() } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConnectionInterval.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConnectionInterval.kt new file mode 100644 index 00000000..5578ea06 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConnectionInterval.kt @@ -0,0 +1,32 @@ +package com.joegec.joycon2android.connection.console + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.os.Build +import android.util.Log +import org.lsposed.hiddenapibypass.HiddenApiBypass + +/** 7.5 ms, the LE minimum: docs/protocol.md#console-protocol-controllers */ +@SuppressLint("MissingPermission") +internal object ConnectionInterval { + + private const val TAG = "Joycon2" + + // Units of 1.25 ms and 10 ms: 6 * 1.25 = 7.5 ms, no peripheral latency, 5 s supervision timeout. + private val FASTEST = arrayOf(6, 6, 0, 500, 0, 0) + + fun requestFastest(gatt: BluetoothGatt): Boolean { + val accepted = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + HiddenApiBypass.invoke(BluetoothGatt::class.java, gatt, "requestLeConnectionUpdate", *FASTEST) + } else { + BluetoothGatt::class.java + .getMethod("requestLeConnectionUpdate", *Array(FASTEST.size) { Int::class.java }) + .invoke(gatt, *FASTEST) + } == true + }.onFailure { Log.w(TAG, "7.5 ms interval request unavailable: $it") }.getOrDefault(false) + + if (!accepted) gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) + return accepted + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleChannel.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleChannel.kt new file mode 100644 index 00000000..f8c90e16 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleChannel.kt @@ -0,0 +1,49 @@ +package com.joegec.joycon2android.connection.console + +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import com.joegec.joycon2android.model.Side +import java.util.UUID + +/** Characteristics and command framing: docs/protocol.md#console-protocol-controllers */ +internal class ConsoleChannel( + val side: Side, + val command: BluetoothGattCharacteristic, + val input: BluetoothGattCharacteristic, + val extendedResponse: BluetoothGattCharacteristic?, +) { + companion object { + const val COMMAND_PREFIX_LENGTH = 17 + + val RESPONSE: UUID = UUID.fromString("c765a961-d9d8-4d36-a20a-5315b111836a") + val SESSION_START: UUID = UUID.fromString("00c5af5d-1964-4e30-8f51-1956f96bd282") + val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + val REPORT_RATE: UUID = UUID.fromString("679d5510-5a24-4dee-9557-95df80486ecb") + + private val LEFT_COMMAND = UUID.fromString("ce49a830-dced-48ae-931e-c8cf88aadbea") + private val LEFT_INPUT = UUID.fromString("cc1bbbb5-7354-4d32-a716-a81cb241a32a") + private val LEFT_EXTENDED_RESPONSE = UUID.fromString("63a3810f-aec7-474b-9010-3d52403cb996") + private val RIGHT_COMMAND = UUID.fromString("65a724b3-f1e7-4a61-8078-a342376b27ff") + private val RIGHT_INPUT = UUID.fromString("d5a9e01e-2ffc-4cca-b20c-8b67142bf442") + private val RIGHT_EXTENDED_RESPONSE = UUID.fromString("640ca58e-0e88-410c-a7f3-426faf2b690b") + + fun find(gatt: BluetoothGatt): ConsoleChannel? = + resolve(gatt, Side.LEFT, LEFT_COMMAND, LEFT_INPUT, LEFT_EXTENDED_RESPONSE) + ?: resolve(gatt, Side.RIGHT, RIGHT_COMMAND, RIGHT_INPUT, RIGHT_EXTENDED_RESPONSE) + + fun characteristic(gatt: BluetoothGatt, uuid: UUID): BluetoothGattCharacteristic? = + gatt.services.firstNotNullOfOrNull { it.getCharacteristic(uuid) } + + private fun resolve( + gatt: BluetoothGatt, + side: Side, + command: UUID, + input: UUID, + extendedResponse: UUID, + ): ConsoleChannel? { + val commandChar = characteristic(gatt, command) ?: return null + val inputChar = characteristic(gatt, input) ?: return null + return ConsoleChannel(side, commandChar, inputChar, characteristic(gatt, extendedResponse)) + } + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleCommands.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleCommands.kt new file mode 100644 index 00000000..26250b41 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleCommands.kt @@ -0,0 +1,40 @@ +package com.joegec.joycon2android.connection.console + +import com.joegec.joycon2android.connection.ControllerCommands.command +import com.joegec.joycon2android.connection.ControllerCommands.enableFeatures +import com.joegec.joycon2android.connection.ControllerCommands.selectFeatures + +/** Command frames, in the order a console sends them: docs/protocol.md#console-protocol-controllers */ +internal object ConsoleCommands { + + val HELLO = command(0x07, 0x01) + val FIRMWARE_INFO = command(0x10, 0x01) + val UNKNOWN_16_01 = command(0x16, 0x01) + val UNKNOWN_11_03 = command(0x11, 0x03) + val UNKNOWN_11_01 = command(0x11, 0x01) + + val VIBRATION_SAMPLE = command(0x0A, 0x02, bytes(0x03, 0x00, 0x00, 0x00)) + val VIBRATION_DATA = command( + 0x0A, 0x08, + bytes( + 0x01, 0x59, 0x09, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0x35, + 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ), + ) + + // Feature mask 0x37, as the console sends it; includes motion (bit 2) and mouse (bit 4). + private const val FEATURES: Byte = 0x37 + val FEATURES_INIT = selectFeatures(FEATURES) + val FEATURES_ENABLE = enableFeatures(FEATURES) + + val SPI_READS_AFTER_FEATURES_INIT = listOf( + 0x40 to 0x013080, + 0x40 to 0x1FC040, + 0x10 to 0x013040, + 0x18 to 0x013100, + ) + const val SPI_READ_BEFORE_VIBRATION = 0x013060 + const val SPI_READ_BEFORE_VIBRATION_LENGTH = 0x20 + + private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParser.kt new file mode 100644 index 00000000..d4c1b59f --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParser.kt @@ -0,0 +1,98 @@ +package com.joegec.joycon2android.connection.console + +import com.joegec.joycon2android.connection.PacketParser +import com.joegec.joycon2android.model.BatteryCharge +import com.joegec.joycon2android.model.JoyconInput +import com.joegec.joycon2android.model.MotionSupport +import com.joegec.joycon2android.model.Side + +/** Console input report, translated into the common bitmask: docs/protocol.md#input-report */ +internal object ConsolePacketParser { + + private const val MIN_REPORT_SIZE = 8 + private const val MAX_BATTERY_LEVEL = 9 + + // Motion block: docs/protocol.md#motion-block + private const val MOTION_LEFT = 0x0F + private const val MOTION_RIGHT = 0x10 + private const val ACCEL_IN_BLOCK = 0x12 + private const val BLOCK_WITH_ACCEL = 0x1C + private const val AXIS_STRIDE = 4 + private const val AXES = 3 + private val NO_AXES = IntArray(AXES) + + private val rightButtons = listOf( + 0 to 0x00000400L, // B + 1 to 0x00000800L, // A + 2 to 0x00000100L, // Y + 3 to 0x00000200L, // X + 4 to 0x00004000L, // R + 5 to 0x00008000L, // ZR + 6 to 0x00020000L, // + + 7 to 0x00040000L, // RS + 8 to 0x00100000L, // Home + 12 to 0x00400000L, // C + 14 to 0x00001000L, // SR + 15 to 0x00002000L, // SL + ) + + private val leftButtons = listOf( + 0 to 0x01000000L, // Down + 1 to 0x04000000L, // Right + 2 to 0x08000000L, // Left + 3 to 0x02000000L, // Up + 4 to 0x40000000L, // L + 5 to 0x80000000L, // ZL + 6 to 0x00010000L, // - + 7 to 0x00080000L, // LS + 8 to 0x00200000L, // Capture + 14 to 0x10000000L, // SR + 15 to 0x20000000L, // SL + ) + + fun counter(report: ByteArray): Int = report[0].toInt() and 0xFF + + fun parse(report: ByteArray, side: Side, packetId: Int): JoyconInput? { + if (report.size < MIN_REPORT_SIZE) return null + val raw = u8(report, 2) or (u8(report, 3) shl 8) + val table = if (side == Side.LEFT) leftButtons else rightButtons + val buttons = table.fold(0L) { acc, (bit, mask) -> if (raw shr bit and 1 == 1) acc or mask else acc } + val stick = u8(report, 5) or (u8(report, 6) shl 8) or (u8(report, 7) shl 16) + val level = (u8(report, 1) shr 2 and 0x0F).coerceAtMost(MAX_BATTERY_LEVEL) + val accel = accel(report, side) + val axes = accel ?: NO_AXES + + return JoyconInput( + packetId = packetId, + buttons = buttons, + pressed = PacketParser.decodeButtons(buttons), + stickX = stick and 0xFFF, + stickY = stick shr 12 and 0xFFF, + battery = BatteryCharge.fromLevel(level, MAX_BATTERY_LEVEL), + accelX = axes[0], + accelY = -axes[1], + accelZ = axes[2], + motionSupport = if (accel == null) MotionSupport.None else MotionSupport.AccelerometerOnly, + ) + } + + // Each axis is an int16 in the high half of its word; y runs opposite a genuine Joy-Con 2. + // Null for a block too short to hold them, or one whose words are not shaped that way at all. + private fun accel(report: ByteArray, side: Side): IntArray? { + val block = if (side == Side.LEFT) MOTION_LEFT else MOTION_RIGHT + val first = block + ACCEL_IN_BLOCK + if (report.size < first + (AXES - 1) * AXIS_STRIDE + 2) return null + if (u8(report, block - 1) < BLOCK_WITH_ACCEL) return null + if ((0 until AXES).any { axis -> lowHalf(report, first + axis * AXIS_STRIDE) != 0 }) return null + return IntArray(AXES) { axis -> i16(report, first + axis * AXIS_STRIDE) } + } + + private fun lowHalf(report: ByteArray, axis: Int) = u8(report, axis - 2) or u8(report, axis - 1) + + private fun i16(data: ByteArray, index: Int): Int { + val value = u8(data, index) or (u8(data, index + 1) shl 8) + return if (value and 0x8000 != 0) value - 0x10000 else value + } + + private fun u8(data: ByteArray, index: Int) = data[index].toInt() and 0xFF +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleSession.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleSession.kt new file mode 100644 index 00000000..54b56bb6 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleSession.kt @@ -0,0 +1,152 @@ +package com.joegec.joycon2android.connection.console + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.util.Log +import com.joegec.joycon2android.connection.ControllerCommands +import com.joegec.joycon2android.connection.ControllerProtocol +import com.joegec.joycon2android.connection.GattOpQueue +import com.joegec.joycon2android.connection.SpiColorParser +import com.joegec.joycon2android.model.JoyconInput +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException + +/** Start-up sequence: docs/protocol.md#console-protocol-controllers */ +@SuppressLint("MissingPermission") +internal class ConsoleSession( + private val gatt: BluetoothGatt, + private val channel: ConsoleChannel, + private val ops: GattOpQueue, + initialLedBitmask: Byte, + private val onInput: (JoyconInput) -> Unit, + private val onAccentColor: (Int) -> Unit, + private val onReady: () -> Unit, +) : ControllerProtocol { + companion object { + private const val TAG = "Joycon2" + private val SESSION_START_VALUE = byteArrayOf(0x01, 0x00) + + // Written to the input characteristic's 0x679d5510 descriptor before subscribing, as the console does. + private val REPORT_RATE_VALUE = byteArrayOf(0x85.toByte(), 0x00) + } + + private val label = channel.side.name + private val worker = Executors.newSingleThreadExecutor { Thread(it, "console-$label") } + private val transport = ConsoleTransport(gatt, channel, label, ops) + private val linkHolder = LinkHolder(gatt.device, label) + + @Volatile private var stopped = false + @Volatile private var ledBitmask = initialLedBitmask + private var lastCounter = -1 + private var packetId = 0 + + override fun start() { + Log.i(TAG, "[$label] Using the console protocol") + linkHolder.start() + submit(::initialise) + } + + override fun stop() { + stopped = true + linkHolder.stop() + transport.close() + ops.clear() + worker.shutdownNow() + } + + override fun onPairingFailed() { + linkHolder.holdNow() + } + + // 7.5 ms is worth far more here than on the common channel: docs/protocol.md#android-ble-gotchas + override fun setHighPriority(enabled: Boolean) { + if (enabled) { + submit { ConnectionInterval.requestFastest(gatt) } + return + } + val accepted = gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_BALANCED) + Log.i(TAG, "[$label] Connection priority high=false accepted=$accepted") + } + + override fun setPlayerLed(bitmask: Byte) { + ledBitmask = bitmask + submit { transport.send(ControllerCommands.playerLed(bitmask)) } + } + + override fun onCharacteristicChanged(uuid: UUID, value: ByteArray) { + if (uuid == channel.input.uuid) onReport(value) else transport.onReply(value) + } + + private fun onReport(report: ByteArray) { + if (report.isEmpty()) return + val counter = ConsolePacketParser.counter(report) + packetId += if (lastCounter < 0) 1 else (counter - lastCounter + 256) % 256 + lastCounter = counter + ConsolePacketParser.parse(report, channel.side, packetId)?.let(onInput) + } + + private fun initialise() { + openCommandChannel() + identify() + configure() + if (!enableInput()) { + Log.e(TAG, "[$label] Could not enable console input reports") + return + } + Log.i(TAG, "[$label] Console init complete") + if (!stopped) onReady() + } + + private fun openCommandChannel() { + ConsoleChannel.characteristic(gatt, ConsoleChannel.SESSION_START)?.let { transport.write(it, SESSION_START_VALUE) } + ConsoleChannel.characteristic(gatt, ConsoleChannel.RESPONSE)?.let(transport::subscribe) + channel.extendedResponse?.let(transport::subscribe) + } + + private fun identify() { + transport.send(ConsoleCommands.HELLO) + transport.send(ControllerCommands.spiRead(0x40, ControllerCommands.DEVICE_INFO_ADDRESS)) + ?.let(SpiColorParser::parseAccentColor) + ?.let(onAccentColor) + transport.send(ConsoleCommands.FIRMWARE_INFO) + transport.send(ConsoleCommands.UNKNOWN_16_01) + } + + private fun configure() { + transport.send(ConsoleCommands.VIBRATION_SAMPLE) + transport.send(ControllerCommands.playerLed(ledBitmask)) + transport.send(ConsoleCommands.FEATURES_INIT) + ConsoleCommands.SPI_READS_AFTER_FEATURES_INIT.forEach { (length, address) -> + transport.send(ControllerCommands.spiRead(length, address)) + } + transport.send(ConsoleCommands.UNKNOWN_11_03) + transport.send( + ControllerCommands.spiRead( + ConsoleCommands.SPI_READ_BEFORE_VIBRATION_LENGTH, + ConsoleCommands.SPI_READ_BEFORE_VIBRATION, + ), + ) + transport.send(ConsoleCommands.VIBRATION_DATA) + transport.send(ConsoleCommands.UNKNOWN_11_01) + transport.send(ConsoleCommands.FEATURES_ENABLE) + } + + private fun enableInput(): Boolean { + channel.input.getDescriptor(ConsoleChannel.REPORT_RATE)?.let { transport.writeDescriptor(it, REPORT_RATE_VALUE) } + return transport.subscribe(channel.input) && !stopped + } + + private fun submit(task: () -> Unit) { + if (stopped) return + try { + worker.execute { + try { + task() + } catch (_: InterruptedException) { + } + } + } catch (_: RejectedExecutionException) { + } + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleTransport.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleTransport.kt new file mode 100644 index 00000000..01530e3c --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleTransport.kt @@ -0,0 +1,112 @@ +package com.joegec.joycon2android.connection.console + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothStatusCodes +import android.os.Build +import android.os.SystemClock +import android.util.Log +import com.joegec.joycon2android.connection.ControllerCommands +import com.joegec.joycon2android.connection.GattOpQueue +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** Blocking request/reply on the console channel, so callers must stay off the main thread. */ +@SuppressLint("MissingPermission") +internal class ConsoleTransport( + private val gatt: BluetoothGatt, + private val channel: ConsoleChannel, + private val label: String, + private val ops: GattOpQueue, +) { + companion object { + private const val TAG = "Joycon2" + private const val REPLY_TIMEOUT_MS = 700L + private const val OPERATION_TIMEOUT_MS = 3_000L + } + + private val replies = LinkedBlockingQueue() + @Volatile private var closed = false + + fun close() { + closed = true + } + + fun onReply(value: ByteArray) { + replies.offer(value) + } + + /** Sends [command] and returns the reply from its header onwards, or null if none arrived. */ + fun send(command: ByteArray): ByteArray? { + replies.clear() + val frame = ByteArray(ConsoleChannel.COMMAND_PREFIX_LENGTH) + command + if (!write(channel.command, frame)) return null + val deadline = SystemClock.elapsedRealtime() + REPLY_TIMEOUT_MS + while (!closed) { + val remaining = deadline - SystemClock.elapsedRealtime() + if (remaining <= 0) break + val value = replies.poll(remaining, TimeUnit.MILLISECONDS) ?: break + val start = replyStart(value, command) + if (start >= 0) return value.copyOfRange(start, value.size) + } + Log.w(TAG, "[$label] No reply to ${hex(command.copyOf(ControllerCommands.HEADER_LENGTH))}") + return null + } + + fun subscribe(characteristic: BluetoothGattCharacteristic): Boolean { + gatt.setCharacteristicNotification(characteristic, true) + val cccd = characteristic.getDescriptor(ConsoleChannel.CCCD) ?: return false + return writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } + + fun write(characteristic: BluetoothGattCharacteristic, value: ByteArray): Boolean { + val type = if (characteristic.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) { + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + } else { + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + } + Log.d(TAG, "[$label] TX ${hex(value)}") + return runOperation { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic(characteristic, value, type) == BluetoothStatusCodes.SUCCESS + } else { + @Suppress("DEPRECATION") + characteristic.writeType = type + @Suppress("DEPRECATION") + characteristic.value = value + @Suppress("DEPRECATION") + gatt.writeCharacteristic(characteristic) + } + } + } + + fun writeDescriptor(descriptor: BluetoothGattDescriptor, value: ByteArray): Boolean = runOperation { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS + } else { + @Suppress("DEPRECATION") + descriptor.value = value + @Suppress("DEPRECATION") + gatt.writeDescriptor(descriptor) + } + } + + // A reply echoes the command id at [0] and sub-command at [3], with 0x01 at [1]. + private fun replyStart(value: ByteArray, command: ByteArray): Int { + for (i in 0..value.size - ControllerCommands.HEADER_LENGTH) { + if (value[i] == command[0] && value[i + 1] == ControllerCommands.REPLY_MARKER && value[i + 3] == command[3]) { + return i + } + } + return -1 + } + + private fun runOperation(start: () -> Boolean): Boolean { + if (closed) return false + return ops.awaitOp(OPERATION_TIMEOUT_MS, start) == BluetoothGatt.GATT_SUCCESS + } + + private fun hex(bytes: ByteArray) = bytes.joinToString(" ") { "%02X".format(it) } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/LinkHolder.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/LinkHolder.kt new file mode 100644 index 00000000..3778e21f --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/LinkHolder.kt @@ -0,0 +1,72 @@ +package com.joegec.joycon2android.connection.console + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothSocket +import android.os.Build +import android.os.SystemClock +import android.util.Log +import java.util.concurrent.ConcurrentHashMap + +/** Holds the link open after Android's pairing fails: docs/protocol.md#android-workarounds */ +@SuppressLint("MissingPermission") +internal class LinkHolder(private val device: BluetoothDevice, private val label: String) { + + companion object { + private const val TAG = "Joycon2" + private const val PSM = 0x0080 + private const val QUICK_FAILURE_MS = 1_000L + private const val FIRST_RETRY_DELAY_MS = 200L + private const val MAX_RETRY_DELAY_MS = 5_000L + } + + @Volatile private var running = false + private val sockets = ConcurrentHashMap.newKeySet() + + fun start() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || running) return + running = true + Thread({ + var retryDelay = FIRST_RETRY_DELAY_MS + while (running) { + if (attempt() >= QUICK_FAILURE_MS) { + retryDelay = FIRST_RETRY_DELAY_MS + continue + } + SystemClock.sleep(retryDelay) + retryDelay = (retryDelay * 2).coerceAtMost(MAX_RETRY_DELAY_MS) + } + }, "link-holder-$label").start() + } + + /** Adds an extra pending channel right away, for the moment Android's pairing gives up. */ + fun holdNow() { + if (!running) return + Thread({ attempt() }, "link-holder-now-$label").start() + } + + fun stop() { + running = false + sockets.forEach { runCatching { it.close() } } + sockets.clear() + } + + private fun attempt(): Long { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 0 + val started = SystemClock.elapsedRealtime() + var socket: BluetoothSocket? = null + try { + socket = device.createInsecureL2capChannel(PSM) + sockets.add(socket) + if (running) socket.connect() + } catch (e: Exception) { + Log.v(TAG, "[$label] link hold attempt ended: ${e.message}") + } finally { + socket?.let { + sockets.remove(it) + runCatching { it.close() } + } + } + return SystemClock.elapsedRealtime() - started + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/SecurityRequestReceiver.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/SecurityRequestReceiver.kt new file mode 100644 index 00000000..2430c296 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/SecurityRequestReceiver.kt @@ -0,0 +1,61 @@ +package com.joegec.joycon2android.connection.console + +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build + +/** Identifies console-protocol clones and hides their pairing dialog: docs/protocol.md#android-workarounds */ +internal class SecurityRequestReceiver( + private val onSecurityRequested: (String) -> Boolean, + private val onPairingFailed: (String) -> Unit, +) : BroadcastReceiver() { + + companion object { + private const val PRIORITY_AHEAD_OF_SETTINGS = 999 + } + + private var registered = false + + @Synchronized + fun register(context: Context) { + if (registered) return + val filter = IntentFilter().apply { + addAction(BluetoothDevice.ACTION_PAIRING_REQUEST) + addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + priority = PRIORITY_AHEAD_OF_SETTINGS + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(this, filter, Context.RECEIVER_EXPORTED) + } else { + context.registerReceiver(this, filter) + } + registered = true + } + + override fun onReceive(context: Context, intent: Intent) { + val address = deviceOf(intent)?.address ?: return + when (intent.action) { + BluetoothDevice.ACTION_PAIRING_REQUEST -> { + if (onSecurityRequested(address) && isOrderedBroadcast) abortBroadcast() + } + BluetoothDevice.ACTION_BOND_STATE_CHANGED -> { + val previous = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.ERROR) + val current = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR) + if (previous == BluetoothDevice.BOND_BONDING && current == BluetoothDevice.BOND_NONE) { + onPairingFailed(address) + } + } + } + } + + private fun deviceOf(intent: Intent): BluetoothDevice? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ControllerCommandsTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ControllerCommandsTest.kt new file mode 100644 index 00000000..f66a88c9 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ControllerCommandsTest.kt @@ -0,0 +1,63 @@ +package com.joegec.joycon2android.connection + +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class ControllerCommandsTest { + + // The frames both protocols sent before one builder produced them. + @Test + fun `builds the feature commands`() { + assertArrayEquals( + bytes(0x0C, 0x91, 0x01, 0x02, 0x00, 0x04, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00), + ControllerCommands.selectFeatures(0xFF.toByte()), + ) + assertArrayEquals( + bytes(0x0C, 0x91, 0x01, 0x04, 0x00, 0x04, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00), + ControllerCommands.enableFeatures(0xFF.toByte()), + ) + assertArrayEquals( + bytes(0x0C, 0x91, 0x01, 0x02, 0x00, 0x04, 0x00, 0x00, 0x37, 0x00, 0x00, 0x00), + ControllerCommands.selectFeatures(0x37), + ) + } + + @Test + fun `builds the player LED command`() { + assertArrayEquals( + bytes(0x09, 0x91, 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, 0x0F, 0, 0, 0, 0, 0, 0, 0), + ControllerCommands.playerLed(ControllerCommands.LED_ALL_ON), + ) + assertArrayEquals( + bytes(0x09, 0x91, 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0), + ControllerCommands.playerLed(0x01), + ) + } + + // The common path has always read the colour over transport 0x00, the console one over 0x01. + @Test + fun `builds the SPI read on either transport`() { + assertArrayEquals( + bytes(0x02, 0x91, 0x00, 0x04, 0x00, 0x08, 0x00, 0x00, 0x40, 0x7E, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00), + ControllerCommands.spiRead(0x40, ControllerCommands.DEVICE_INFO_ADDRESS, ControllerCommands.USB), + ) + assertArrayEquals( + bytes(0x02, 0x91, 0x01, 0x04, 0x00, 0x08, 0x00, 0x00, 0x40, 0x7E, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00), + ControllerCommands.spiRead(0x40, ControllerCommands.DEVICE_INFO_ADDRESS), + ) + assertArrayEquals( + bytes(0x02, 0x91, 0x01, 0x04, 0x00, 0x08, 0x00, 0x00, 0x18, 0x7E, 0x00, 0x00, 0x00, 0x31, 0x01, 0x00), + ControllerCommands.spiRead(0x18, 0x013100), + ) + } + + @Test + fun `a command with no data still carries a full header`() { + assertArrayEquals( + bytes(0x07, 0x91, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00), + ControllerCommands.command(0x07, 0x01), + ) + } + + private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() } +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParserTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParserTest.kt new file mode 100644 index 00000000..ed7f642f --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParserTest.kt @@ -0,0 +1,117 @@ +package com.joegec.joycon2android.connection.console + +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.MotionSupport +import com.joegec.joycon2android.model.Side +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ConsolePacketParserTest { + + // Captured from a NYXI Hyperion 3 (right) at rest: counter 2, power 0x18, stick centred. + private val restingRight = hex("02 18 00 00 07 00 F8 7F 00 00 00 00 00 00 00 1E") + + // Captured flat, button face up, so gravity sits on z at 4096 = 1 g. + private val flatRight = hex( + "46 18 00 00 07 00 B8 7F 00 00 00 00 00 00 00 1E FF 86 01 0C 02 00 33 56 01 2F 0F 2C 00 " + + "3A AB 89 00 00 02 01 00 00 84 FF 00 00 F6 0F 00 00 00 00 00 00 00 00 00 00 00 00 00 " + + "00 00 00 00 00 00", + ) + private val flatLeft = hex( + "46 18 00 00 07 F1 17 82 00 00 00 00 00 00 1E FC 8E 01 0C 00 2E 88 BA 01 8E 52 08 01 A3 " + + "ED 77 00 00 99 FF 00 00 1C 00 00 00 EF 0F 00 00 00 00 00 00 00 00 00 00 00 00 00 00 " + + "00 00 00 00 00 00", + ) + + private fun report(buttons: Int, power: Int = 0x18): ByteArray = + restingRight.copyOf(63).apply { + this[1] = power.toByte() + this[2] = buttons.toByte() + this[3] = (buttons shr 8).toByte() + } + + @Test + fun `rejects reports shorter than the stick field`() { + assertNull(ConsolePacketParser.parse(ByteArray(7), Side.RIGHT, 1)) + } + + @Test + fun `decodes the packed stick`() { + val input = ConsolePacketParser.parse(restingRight, Side.RIGHT, 1)!! + assertEquals(2048, input.stickX) + assertEquals(2047, input.stickY) + } + + @Test + fun `right face and system buttons map onto the common bitmask`() { + val input = ConsolePacketParser.parse(report(0b0101_0001_0000_0011), Side.RIGHT, 1)!! + assertEquals( + setOf(JoyconButton.B, JoyconButton.A, JoyconButton.Home, JoyconButton.Chat, JoyconButton.SrRight).map { it.id }.toSet(), + input.pressed, + ) + } + + @Test + fun `left buttons map onto the common bitmask`() { + val input = ConsolePacketParser.parse(report(0b1000_0001_1111_0001), Side.LEFT, 1)!! + assertEquals( + setOf( + JoyconButton.Down, JoyconButton.L, JoyconButton.ZL, JoyconButton.Minus, + JoyconButton.LS, JoyconButton.Capture, JoyconButton.SlLeft, + ).map { it.id }.toSet(), + input.pressed, + ) + } + + @Test + fun `battery level becomes a percentage of the nine levels`() { + assertEquals(100, ConsolePacketParser.parse(report(0, power = 9 shl 2), Side.RIGHT, 1)!!.battery?.percent) + assertEquals(55, ConsolePacketParser.parse(report(0, power = 5 shl 2), Side.RIGHT, 1)!!.battery?.percent) + } + + @Test + fun `decodes the accelerometer, gravity on z, with y against the raw axis`() { + val input = ConsolePacketParser.parse(flatRight, Side.RIGHT, 1)!! + assertEquals(258, input.accelX) + assertEquals(124, input.accelY) + assertEquals(4086, input.accelZ) + } + + @Test + fun `the left controller's motion block starts a byte earlier`() { + val input = ConsolePacketParser.parse(flatLeft, Side.LEFT, 1)!! + assertEquals(-103, input.accelX) + assertEquals(-28, input.accelY) + assertEquals(4079, input.accelZ) + } + + @Test + fun `reports an accelerometer without a gyroscope`() { + assertEquals(MotionSupport.AccelerometerOnly, ConsolePacketParser.parse(flatRight, Side.RIGHT, 1)!!.motionSupport) + } + + @Test + fun `a block too short to hold motion reports none rather than a reading of zero`() { + val starting = flatRight.copyOf().also { it[0x0F] = 0x04 } + + val input = ConsolePacketParser.parse(starting, Side.RIGHT, 1)!! + + assertEquals(MotionSupport.None, input.motionSupport) + assertEquals(0, input.accelZ) + } + + @Test + fun `a motion block shaped some other way reports none`() { + val foreign = flatRight.copyOf().also { it[0x20] = 0x01 } + + assertEquals(MotionSupport.None, ConsolePacketParser.parse(foreign, Side.RIGHT, 1)!!.motionSupport) + } + + @Test + fun `counter is the first byte`() { + assertEquals(2, ConsolePacketParser.counter(restingRight)) + } + + private fun hex(value: String) = value.split(" ").map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/BatteryPill.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/BatteryPill.kt index 3b993b9f..64b00ffb 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/BatteryPill.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/BatteryPill.kt @@ -18,16 +18,16 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import com.joegec.joycon2android.connection.presentation.R -import com.joegec.joycon2android.model.BatteryGauge +import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.ui.theme.AccentDim import com.joegec.joycon2android.ui.theme.AppType import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.batteryColor @Composable -internal fun BatteryPill(volts: Float, modifier: Modifier = Modifier) { +internal fun BatteryPill(charge: BatteryCharge, modifier: Modifier = Modifier) { BatteryReadout( - volts = volts, + charge = charge, modifier = modifier .background(AccentDim, RoundedCornerShape(Dimens.pillCorner)) .padding(horizontal = Dimens.pillPaddingHorizontal, vertical = Dimens.pillPaddingVertical), @@ -35,8 +35,8 @@ internal fun BatteryPill(volts: Float, modifier: Modifier = Modifier) { } @Composable -internal fun BatteryReadout(volts: Float, modifier: Modifier = Modifier) { - val percent = BatteryGauge.percentFromVolts(volts) +internal fun BatteryReadout(charge: BatteryCharge, modifier: Modifier = Modifier) { + val percent = charge.percent val color = batteryColor(percent) Row(modifier, verticalAlignment = Alignment.CenterVertically) { BatteryIcon(percent, color) @@ -52,9 +52,8 @@ internal fun BatteryReadout(volts: Float, modifier: Modifier = Modifier) { } @Composable -internal fun BatteryGlyph(volts: Float, modifier: Modifier = Modifier) { - val percent = BatteryGauge.percentFromVolts(volts) - BatteryIcon(percent, batteryColor(percent), modifier) +internal fun BatteryGlyph(charge: BatteryCharge, modifier: Modifier = Modifier) { + BatteryIcon(charge.percent, batteryColor(charge.percent), modifier) } @Composable diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt index 0dce09eb..99a5a117 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt @@ -98,7 +98,7 @@ private fun ControllerChip( horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), ) { val battery = @Composable { - if (joycon.input.batteryVolts > 0f) BatteryGlyph(joycon.input.batteryVolts) + joycon.input.battery?.let { BatteryGlyph(it) } } if (batteryFirst) battery() diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerButtons.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerButtons.kt index 6f2a6dcc..5bac11bc 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerButtons.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerButtons.kt @@ -29,11 +29,11 @@ internal fun MinusBatteryRow(input: JoyconInput, pressed: Set, modifier: verticalAlignment = Alignment.CenterVertically, ) { AnimatedVisibility( - visible = input.batteryVolts > 0f, + visible = input.battery != null, enter = fadeIn(), exit = fadeOut(), ) { - BatteryPill(input.batteryVolts) + input.battery?.let { BatteryPill(it) } } SmallButton(JoyconButton.Minus.label, JoyconButton.Minus.id in pressed) } @@ -48,11 +48,11 @@ internal fun PlusBatteryRow(input: JoyconInput, pressed: Set, modifier: ) { SmallButton(JoyconButton.Plus.label, JoyconButton.Plus.id in pressed) AnimatedVisibility( - visible = input.batteryVolts > 0f, + visible = input.battery != null, enter = fadeIn(), exit = fadeOut(), ) { - BatteryPill(input.batteryVolts) + input.battery?.let { BatteryPill(it) } } } } diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt index d986f24b..055e4b76 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt @@ -20,8 +20,17 @@ import com.joegec.joycon2android.ui.theme.TextDim @Composable internal fun ImuDisplay(input: JoyconInput, modifier: Modifier = Modifier) { Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(Dimens.imuSectionSpacing)) { - SensorRow(stringResource(R.string.imu_accel), "X" to input.accelX, "Y" to input.accelY, "Z" to input.accelZ) - SensorRow(stringResource(R.string.imu_gyro), "X" to input.gyroX, "Y" to input.gyroY, "Z" to input.gyroZ) + if (input.motionSupport.measuresAcceleration) { + SensorRow(stringResource(R.string.imu_accel), "X" to input.accelX, "Y" to input.accelY, "Z" to input.accelZ) + } else { + AbsentRow(stringResource(R.string.imu_accel)) + } + if (input.motionSupport.measuresRotation) { + SensorRow(stringResource(R.string.imu_gyro), "X" to input.gyroX, "Y" to input.gyroY, "Z" to input.gyroZ) + } else { + AbsentRow(stringResource(R.string.imu_gyro)) + NoGyroNote() + } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { ImuLabel(stringResource(R.string.imu_packet)) ImuValue(input.packetId.toString()) @@ -29,6 +38,14 @@ internal fun ImuDisplay(input: JoyconInput, modifier: Modifier = Modifier) { } } +@Composable +private fun AbsentRow(title: String) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + ImuLabel(title) + ImuValue(stringResource(R.string.imu_sensor_absent)) + } +} + @Composable private fun SensorRow(title: String, vararg axes: Pair) { Column(verticalArrangement = Arrangement.spacedBy(Dimens.imuTitleGap)) { diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/NoGyroNote.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/NoGyroNote.kt new file mode 100644 index 00000000..ba77cc9b --- /dev/null +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/NoGyroNote.kt @@ -0,0 +1,26 @@ +package com.joegec.joycon2android.connection.presentation + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.connection.presentation.R +import com.joegec.joycon2android.ui.theme.AppType +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.WarningText + +// So that motion doing nothing in an emulator reads as the controller's limit, not a fault. +@Composable +internal fun NoGyroNote(modifier: Modifier = Modifier) { + Text( + stringResource(R.string.imu_no_gyro), + modifier, + color = WarningText, + fontSize = Dimens.fontSizeLabel, + style = noteTextStyle, + ) +} + +private val noteTextStyle = AppType.telemetry.copy( + lineHeight = Dimens.fontSizeLabel * 1.3f, +) 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 48b2e2df..bb44ebdc 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 @@ -67,7 +67,7 @@ private fun ButtonRow(state: PlayerState, pressed: Set) { verticalAlignment = Alignment.CenterVertically, ) { SmallButton(JoyconButton.Minus.label, JoyconButton.Minus.id in pressed) - BatteryPill(state.leftInput.batteryVolts) + state.leftInput.battery?.let { BatteryPill(it) } SmallButton(JoyconButton.Plus.label, JoyconButton.Plus.id in pressed) } } diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt index 7da3366d..54aed113 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt @@ -23,6 +23,7 @@ import com.joegec.joycon2android.ui.theme.TextDim internal fun SidewaysImuDisplay(input: JoyconInput, modifier: Modifier = Modifier) { Column(modifier, verticalArrangement = Arrangement.spacedBy(Dimens.imuRowSpacing)) { SensorRow(input) + if (!input.motionSupport.measuresRotation) NoGyroNote() PacketRow(input) } } @@ -33,8 +34,16 @@ private fun SensorRow(input: JoyconInput) { Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.cardPadding), ) { - SensorColumn(stringResource(R.string.imu_accel), input.accelX, input.accelY, input.accelZ, Modifier.weight(1f)) - SensorColumn(stringResource(R.string.imu_gyro), input.gyroX, input.gyroY, input.gyroZ, Modifier.weight(1f)) + if (input.motionSupport.measuresAcceleration) { + SensorColumn(stringResource(R.string.imu_accel), input.accelX, input.accelY, input.accelZ, Modifier.weight(1f)) + } else { + AbsentColumn(stringResource(R.string.imu_accel), Modifier.weight(1f)) + } + if (input.motionSupport.measuresRotation) { + SensorColumn(stringResource(R.string.imu_gyro), input.gyroX, input.gyroY, input.gyroZ, Modifier.weight(1f)) + } else { + AbsentColumn(stringResource(R.string.imu_gyro), Modifier.weight(1f)) + } } } @@ -52,6 +61,14 @@ private fun PacketRow(input: JoyconInput) { } } +@Composable +private fun AbsentColumn(title: String, modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(Dimens.imuTitleGap)) { + ImuText(title, bold = true) + ImuText(stringResource(R.string.imu_sensor_absent)) + } +} + @Composable private fun SensorColumn(title: String, x: Int, y: Int, z: Int, modifier: Modifier = Modifier) { Column(modifier, verticalArrangement = Arrangement.spacedBy(Dimens.imuTitleGap)) { diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysLeftLayout.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysLeftLayout.kt index b3a89694..22ceb8a5 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysLeftLayout.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysLeftLayout.kt @@ -79,11 +79,11 @@ private fun TopRow(state: PlayerState) { ) { SmallButton(JoyconButton.Minus.label, JoyconButton.Minus.id in pressed) AnimatedVisibility( - visible = state.leftInput.batteryVolts > 0f, + visible = state.leftInput.battery != null, enter = fadeIn(), exit = fadeOut(), ) { - BatteryPill(state.leftInput.batteryVolts) + state.leftInput.battery?.let { BatteryPill(it) } } CaptureButton(pressed) } diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysRightLayout.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysRightLayout.kt index d290b666..d5b6539f 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysRightLayout.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysRightLayout.kt @@ -85,11 +85,11 @@ private fun TopRow(state: PlayerState) { HomeButton(pressed) } AnimatedVisibility( - visible = state.rightInput.batteryVolts > 0f, + visible = state.rightInput.battery != null, enter = fadeIn(), exit = fadeOut(), ) { - BatteryPill(state.rightInput.batteryVolts) + state.rightInput.battery?.let { BatteryPill(it) } } SmallButton(JoyconButton.Plus.label, JoyconButton.Plus.id in pressed) } diff --git a/feature/connection/presentation/src/main/res/values/strings.xml b/feature/connection/presentation/src/main/res/values/strings.xml index 2ae80421..926a9fa9 100644 --- a/feature/connection/presentation/src/main/res/values/strings.xml +++ b/feature/connection/presentation/src/main/res/values/strings.xml @@ -1,6 +1,8 @@ Accel Gyro + none + This controller has no gyroscope, so motion aiming and tilt will not work. Packet %d%% P%1$d diff --git a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt index 0b0b5227..0b17ceda 100644 --- a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt +++ b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt @@ -3,8 +3,8 @@ import com.joegec.joycon2android.dsu.motion.DsuMotion import com.joegec.joycon2android.dsu.motion.MotionConverter import com.joegec.joycon2android.dsu.motion.SidewaysMotion -import com.joegec.joycon2android.model.BatteryGauge import com.joegec.joycon2android.model.GamepadState +import com.joegec.joycon2android.model.MotionSupport import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState import java.nio.ByteBuffer @@ -76,7 +76,7 @@ class DsuPacketEncoder( val connected = player?.hasController == true packet.put(slot.toByte()) packet.put(if (connected) SLOT_STATE_CONNECTED else 0) - packet.put(if (connected) MODEL_FULL_GYRO else 0) + packet.put(if (connected) model(player) else 0) packet.put(if (connected) CONNECTION_BLUETOOTH else 0) packet.put(macBytes(player)) packet.put(batteryByte(player)) @@ -88,10 +88,14 @@ class DsuPacketEncoder( return ByteArray(MAC_SIZE) { (parts[it].toIntOrNull(16) ?: 0).toByte() } } + private fun model(player: PlayerState?): Byte = + when (player?.motionSource?.input?.motionSupport) { + MotionSupport.AccelerometerOnly, MotionSupport.None -> MODEL_NO_GYRO + else -> MODEL_FULL_GYRO + } + private fun batteryByte(player: PlayerState?): Byte { - val volts = player?.motionSource?.input?.batteryVolts ?: 0f - if (volts <= 0f) return BATTERY_NA - val percent = BatteryGauge.percentFromVolts(volts) + val percent = player?.motionSource?.input?.battery?.percent ?: return BATTERY_NA return when { percent >= 90 -> BATTERY_FULL percent >= 70 -> BATTERY_HIGH @@ -160,6 +164,7 @@ class DsuPacketEncoder( private const val TYPE_PAD_DATA = 0x100002 private const val SLOT_STATE_CONNECTED: Byte = 2 + private const val MODEL_NO_GYRO: Byte = 1 private const val MODEL_FULL_GYRO: Byte = 2 private const val CONNECTION_BLUETOOTH: Byte = 2 diff --git a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoderTest.kt b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoderTest.kt index a8462f59..3e1fda8b 100644 --- a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoderTest.kt +++ b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoderTest.kt @@ -1,6 +1,7 @@ package com.joegec.joycon2android.dsu import com.joegec.joycon2android.dsu.motion.MotionConverter +import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput @@ -69,7 +70,7 @@ class DsuPacketEncoderTest { assertEquals(2, packet[23].toInt()) // bluetooth val expectedMac = byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte(), 0xEE.toByte(), 0x02) assertArrayEquals(expectedMac, packet.copyOfRange(24, 30)) - assertEquals(0x05, packet[30].toInt()) // 3.6 V → 100% → full + assertEquals(0x05, packet[30].toInt()) // 100% → full assertEquals(1, packet[31].toInt()) // is-connected flag } @@ -152,7 +153,7 @@ class DsuPacketEncoderTest { pressed = setOf(JoyconButton.A.id, JoyconButton.ZR.id), accelX = 4096, gyroZ = 1000, - batteryVolts = 3.6f, + battery = BatteryCharge(100), ), ), ) diff --git a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt index f5597280..89e26c29 100644 --- a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt +++ b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt @@ -1,5 +1,6 @@ package com.joegec.joycon2android.dsu +import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.PlayerNumber @@ -159,7 +160,7 @@ class DsuServerTest { address = "AA:BB:CC:DD:EE:02", side = Side.RIGHT, deviceName = "Joy-Con (R)", - input = JoyconInput(accelX = 4096, batteryVolts = 3.6f), + input = JoyconInput(accelX = 4096, battery = BatteryCharge(100)), ), ) @@ -169,7 +170,7 @@ class DsuServerTest { address = "AA:BB:CC:DD:EE:01", side = Side.LEFT, deviceName = "Joy-Con (L)", - input = JoyconInput(accelZ = 4096, batteryVolts = 3.5f), + input = JoyconInput(accelZ = 4096, battery = BatteryCharge(92)), ), ) diff --git a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt index 4967c458..8a218ab9 100644 --- a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt +++ b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt @@ -1,5 +1,6 @@ package com.joegec.joycon2android.gamepad +import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput @@ -23,7 +24,7 @@ class ReportMapperTest { rightStickY = 2048, accelX = 0, accelY = 0, accelZ = 0, gyroX = 0, gyroY = 0, gyroZ = 0, - batteryVolts = 4f, + battery = BatteryCharge(100), ) val pro = ConnectedJoycon(address = "pro", side = Side.PRO, deviceName = "Pro", input = input) return ReportMapper.buildReport(PlayerState(PlayerNumber.P1, left = pro)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 37c29200..5b4aab98 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ coroutines = "1.10.2" konsist = "0.17.3" datastore = "1.1.1" json = "20240303" +hiddenapibypass = "6.1" [libraries] shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" } @@ -24,6 +25,7 @@ compose-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compile androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +hiddenapibypass = { group = "org.lsposed.hiddenapibypass", name = "hiddenapibypass", version.ref = "hiddenapibypass" } junit = { group = "junit", name = "junit", version.ref = "junit" } # Real org.json for unit tests; the android.jar stub returns defaults instead of parsing json = { group = "org.json", name = "json", version.ref = "json" }