From 3fbd8f070b52e9cf5cae2300528def7f9880b9b7 Mon Sep 17 00:00:00 2001 From: SWeav02 Date: Fri, 18 Sep 2026 20:33:00 -0400 Subject: [PATCH 1/7] test ids --- .../joycon2android/connection/BleScanner.kt | 68 +++++++++++-------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt index 60b6aff..c33dbe5 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt @@ -13,11 +13,8 @@ import android.util.Log import com.joegec.joycon2android.model.Side /** - * Handles BLE scanning for Nintendo Joy-Con 2 controllers. + * Handles BLE scanning for Nintendo Joy-Con 2 and compatible controllers (like Nyxi). * Emits discovered devices via the [onDeviceFound] callback. - * - * All BLE operations require BLUETOOTH_SCAN and BLUETOOTH_CONNECT permissions, - * which are verified by the permission launcher in MainActivity before any BLE code is reached. */ @SuppressLint("MissingPermission") class BleScanner(context: Context) { @@ -25,6 +22,7 @@ class BleScanner(context: Context) { companion object { private const val TAG = "Joycon2" private const val NINTENDO_MANUFACTURER_ID = 0x0553 + private const val NYXI_MANUFACTURER_ID = 0x6c42 // From Hyperion 3 scan results private const val SIDE_TYPE_INDEX = 5 private const val SCAN_TIMEOUT_MS = 15_000L } @@ -49,7 +47,7 @@ class BleScanner(context: Context) { isScanning = true scanner.startScan(null, lowLatencySettings(), createCallback(isKnownAddress)) - Log.i(TAG, "Scanning for Joy-Con 2 controllers...") + Log.i(TAG, "Scanning for Joy-Con 2/Compatible controllers...") scheduleTimeout() } @@ -67,18 +65,29 @@ class BleScanner(context: Context) { val callback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { if (!isScanning) return - val manufacturerData = nintendoData(result) ?: return + + // Determine which manufacturer data is present + val scanRecord = result.scanRecord ?: return + val nintendoData = scanRecord.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) + val nyxiData = scanRecord.getManufacturerSpecificData(NYXI_MANUFACTURER_ID) + + val manufacturerData = nintendoData ?: nyxiData ?: return logAdvertisement(result, manufacturerData) - // A button press wakes a synced Joy-Con into a short-lived reconnect - // advertisement that only its bonded host can connect to (foreign - // connects fail with status 133) — connecting just flashes the UI - if (!JoyconAdvertisement.isPairing(manufacturerData)) return + + // Only perform the Nintendo-specific pairing bit check if it's official hardware. + // Third-party controllers often use different pairing flag structures. + if (nintendoData != null) { + if (!JoyconAdvertisement.isPairing(nintendoData)) return + } + if (isKnownAddress(result.device.address)) return val name = result.device.name - ?: result.scanRecord?.deviceName + ?: scanRecord.deviceName ?: "Joy-Con 2" - val side = detectSide(result, name) + + // Pass the data and the type to detect side correctly + val side = detectSide(result, name, nintendoData != null) onDeviceFound?.invoke(result, side, name) } @@ -100,9 +109,6 @@ class BleScanner(context: Context) { }, SCAN_TIMEOUT_MS) } - private fun nintendoData(result: ScanResult): ByteArray? = - result.scanRecord?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) - private fun logAdvertisement(result: ScanResult, data: ByteArray) { Log.d( TAG, @@ -115,32 +121,38 @@ class BleScanner(context: Context) { .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() - private fun detectSide(result: ScanResult, name: String): Side { + private fun detectSide(result: ScanResult, name: String, isNintendo: Boolean): Side { + // 1. Try name-based detection first (most reliable for third-party) sideFromName(name)?.let { return it } - sideFromManufacturerData(result)?.let { return it } + + // 2. Try manufacturer-based detection if it's official hardware + if (isNintendo) { + sideFromManufacturerData(result)?.let { return it } + } + return Side.UNKNOWN } private fun sideFromName(name: String): Side? = when { - name.contains("(L)") || name.contains("Left") -> Side.LEFT - name.contains("(R)") || name.contains("Right") -> Side.RIGHT - name.contains("Pro") -> Side.PRO + // Matches "Joy-Con (L)", "NJ22-L", "Left Hyperion" + name.contains("(L)") || name.contains("Left") || name.contains("-L") -> Side.LEFT + // Matches "Joy-Con (R)", "NJ22-R", "Right Hyperion" + name.contains("(R)") || name.contains("Right") || name.contains("-R") -> Side.RIGHT + // Matches "Pro Controller", "NJ22" + name.contains("Pro") || name.contains("NJ22") -> Side.PRO else -> null } /** - * Nintendo manufacturer data (company 0x0553) carries the little-endian USB/BLE product ID at - * bytes [5..6], so index 5 is its low byte: 0x67 = Left Joy-Con 2 (PID 0x2067), 0x66 = Right - * Joy-Con 2 (PID 0x2066), 0x69 = Switch 2 Pro Controller (PID 0x2069). Left/Right are confirmed - * on hardware and cross-checked against each controller's SPI accent colour (cyan left, coral - * right); the Pro value comes from community reverse-engineering of the same advertisement - * scheme. The pairing advertisement has no local name, so this byte is the only type signal - * available before the controller starts streaming input. + * Extracts side info from Nintendo-specific data packets. + * Note: Nyxi packets are usually too short for this index, so we skip this for Nyxi. */ private fun sideFromManufacturerData(result: ScanResult): Side? { val mfgData = result.scanRecord ?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) ?: return null + if (mfgData.size <= SIDE_TYPE_INDEX) return null + return when (mfgData[SIDE_TYPE_INDEX].toInt() and 0xFF) { 0x67 -> Side.LEFT 0x66 -> Side.RIGHT @@ -148,4 +160,4 @@ class BleScanner(context: Context) { else -> null } } -} +} \ No newline at end of file From 86d7b0f717306d529811d93611810b89f24d835b Mon Sep 17 00:00:00 2001 From: "Sam W." Date: Fri, 18 Sep 2026 23:33:31 -0400 Subject: [PATCH 2/7] inputs working --- .../joegec/joycon2android/ui/JoyconScreen.kt | 42 +++ .../model/JoyconConnectionState.kt | 2 + .../presentation/AssignmentPanel.kt | 4 +- .../joycon2android/connection/BleScanner.kt | 312 +++++++++--------- .../joycon2android/connection/GattOpQueue.kt | 2 + .../connection/JoyconAdvertisement.kt | 38 ++- .../connection/JoyconConnection.kt | 202 ++++++++++-- .../joycon2android/connection/PacketParser.kt | 96 ++++++ 8 files changed, 495 insertions(+), 203 deletions(-) diff --git a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt index d815226..41a6479 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt @@ -1,5 +1,6 @@ package com.joegec.joycon2android.ui +import android.bluetooth.BluetoothDevice import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -82,6 +83,7 @@ import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.gamepad.presentation.ShizukuSetupCard import com.joegec.joycon2android.assignment.presentation.AssignmentPanel +import com.joegec.joycon2android.connection.JoyconConnection import com.joegec.joycon2android.connection.presentation.CompactPlayerRow import com.joegec.joycon2android.model.ConnectionViewMode import com.joegec.joycon2android.ui.components.DolphinSetupPhase @@ -311,6 +313,46 @@ fun JoyconScreen( else -scrollState.value.toFloat().coerceIn(0f, appBarSpacePx) }, ) + + // Debug Overlay for raw hex of last 3 packets + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp) + .background(Color.Black.copy(alpha = 0.8f), RoundedCornerShape(8.dp)) + .padding(8.dp) + ) { + val allControllers = state.unassignedJoycons + state.activePlayers.flatMap { listOfNotNull(it.left, it.right) } + allControllers.forEach { controller -> + val bondStr = when (controller.connectionState.bondState) { + BluetoothDevice.BOND_NONE -> "NONE" + BluetoothDevice.BOND_BONDING -> "BONDING" + BluetoothDevice.BOND_BONDED -> "BONDED" + else -> "UNKNOWN (${controller.connectionState.bondState})" + } + val count = JoyconConnection.packetCounts[controller.deviceName] ?: 0L + Text( + text = "${controller.deviceName}: $bondStr (Pkts: $count)", + color = Color.Cyan, + style = MaterialTheme.typography.labelSmall + ) + } + if (allControllers.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + } + Text( + text = "Last 3 Packets:", + color = Color.White, + style = MaterialTheme.typography.labelSmall + ) + JoyconConnection.lastPackets.forEach { packet -> + Text( + text = packet, + color = Color.Green, + style = MaterialTheme.typography.bodySmall + ) + } + } } } } diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt index fe860b8..85d00e9 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt @@ -5,7 +5,9 @@ data class JoyconConnectionState( val connecting: Boolean = false, val ready: Boolean = false, val deviceName: String? = null, + val bondState: Int = 10, // BluetoothDevice.BOND_NONE val error: String? = null, /** Shell accent color read from SPI flash, packed as 0xRRGGBB. Null until read (or unset on the controller). */ val accentColor: Int? = null, + val notificationCount: Int = 0, ) diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt index 3d4a36d..187fd87 100644 --- a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt @@ -127,8 +127,8 @@ private fun JoyconAssignmentRow( strokeWidth = 2.dp, ) Text( - stringResource(R.string.status_connecting), - color = TextDim, + joycon.connectionState.error ?: stringResource(R.string.status_connecting), + color = if (joycon.connectionState.error != null) MaterialTheme.colorScheme.error else TextDim, style = MaterialTheme.typography.bodySmall, ) } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt index c33dbe5..79966d5 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt @@ -1,163 +1,151 @@ -package com.joegec.joycon2android.connection - -import android.annotation.SuppressLint -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothManager -import android.bluetooth.le.ScanCallback -import android.bluetooth.le.ScanResult -import android.bluetooth.le.ScanSettings -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.util.Log -import com.joegec.joycon2android.model.Side - -/** - * Handles BLE scanning for Nintendo Joy-Con 2 and compatible controllers (like Nyxi). - * Emits discovered devices via the [onDeviceFound] callback. - */ -@SuppressLint("MissingPermission") -class BleScanner(context: Context) { - - companion object { - private const val TAG = "Joycon2" - private const val NINTENDO_MANUFACTURER_ID = 0x0553 - private const val NYXI_MANUFACTURER_ID = 0x6c42 // From Hyperion 3 scan results - private const val SIDE_TYPE_INDEX = 5 - private const val SCAN_TIMEOUT_MS = 15_000L - } - - var onDeviceFound: ((ScanResult, Side, String) -> Unit)? = null - var onScanFailed: ((Int) -> Unit)? = null - var onTimeout: (() -> Unit)? = null - - private val handler = Handler(Looper.getMainLooper()) - private val adapter: BluetoothAdapter? = - (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter - - @Volatile - var isScanning = false - private set - - val isAvailable: Boolean get() = adapter?.bluetoothLeScanner != null - - fun start(isKnownAddress: (String) -> Boolean) { - if (isScanning) return - val scanner = adapter?.bluetoothLeScanner ?: return - - isScanning = true - scanner.startScan(null, lowLatencySettings(), createCallback(isKnownAddress)) - Log.i(TAG, "Scanning for Joy-Con 2/Compatible controllers...") - scheduleTimeout() - } - - fun stop() { - if (!isScanning) return - isScanning = false - handler.removeCallbacksAndMessages(null) - adapter?.bluetoothLeScanner?.stopScan(activeCallback) - activeCallback = null - } - - private var activeCallback: ScanCallback? = null - - private fun createCallback(isKnownAddress: (String) -> Boolean): ScanCallback { - val callback = object : ScanCallback() { - override fun onScanResult(callbackType: Int, result: ScanResult) { - if (!isScanning) return - - // Determine which manufacturer data is present - val scanRecord = result.scanRecord ?: return - val nintendoData = scanRecord.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) - val nyxiData = scanRecord.getManufacturerSpecificData(NYXI_MANUFACTURER_ID) - - val manufacturerData = nintendoData ?: nyxiData ?: return - logAdvertisement(result, manufacturerData) - - // Only perform the Nintendo-specific pairing bit check if it's official hardware. - // Third-party controllers often use different pairing flag structures. - if (nintendoData != null) { - if (!JoyconAdvertisement.isPairing(nintendoData)) return - } - - if (isKnownAddress(result.device.address)) return - - val name = result.device.name - ?: scanRecord.deviceName - ?: "Joy-Con 2" - - // Pass the data and the type to detect side correctly - val side = detectSide(result, name, nintendoData != null) - onDeviceFound?.invoke(result, side, name) - } - - override fun onScanFailed(errorCode: Int) { - Log.e(TAG, "Scan failed: $errorCode") - isScanning = false - onScanFailed?.invoke(errorCode) - } - } - activeCallback = callback - return callback - } - - private fun scheduleTimeout() { - handler.postDelayed({ - if (!isScanning) return@postDelayed - stop() - onTimeout?.invoke() - }, SCAN_TIMEOUT_MS) - } - - private fun logAdvertisement(result: ScanResult, data: ByteArray) { - Log.d( - TAG, - "Adv ${result.device.address} name=${result.device.name ?: result.scanRecord?.deviceName} " + - "mfg=${data.joinToString(" ") { "%02X".format(it) }}", - ) - } - - private fun lowLatencySettings() = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) - .build() - - private fun detectSide(result: ScanResult, name: String, isNintendo: Boolean): Side { - // 1. Try name-based detection first (most reliable for third-party) - sideFromName(name)?.let { return it } - - // 2. Try manufacturer-based detection if it's official hardware - if (isNintendo) { - sideFromManufacturerData(result)?.let { return it } - } - - return Side.UNKNOWN - } - - private fun sideFromName(name: String): Side? = when { - // Matches "Joy-Con (L)", "NJ22-L", "Left Hyperion" - name.contains("(L)") || name.contains("Left") || name.contains("-L") -> Side.LEFT - // Matches "Joy-Con (R)", "NJ22-R", "Right Hyperion" - name.contains("(R)") || name.contains("Right") || name.contains("-R") -> Side.RIGHT - // Matches "Pro Controller", "NJ22" - name.contains("Pro") || name.contains("NJ22") -> Side.PRO - else -> null - } - - /** - * Extracts side info from Nintendo-specific data packets. - * Note: Nyxi packets are usually too short for this index, so we skip this for Nyxi. - */ - private fun sideFromManufacturerData(result: ScanResult): Side? { - val mfgData = result.scanRecord - ?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) ?: return null - - if (mfgData.size <= SIDE_TYPE_INDEX) return null - - return when (mfgData[SIDE_TYPE_INDEX].toInt() and 0xFF) { - 0x67 -> Side.LEFT - 0x66 -> Side.RIGHT - 0x69 -> Side.PRO - else -> null - } - } +package com.joegec.joycon2android.connection + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.joegec.joycon2android.model.Side + +/** + * Handles BLE scanning for Nintendo Joy-Con 2 and compatible controllers (like Nyxi). + * Emits discovered devices via the [onDeviceFound] callback. + */ +@SuppressLint("MissingPermission") +class BleScanner(context: Context) { + + companion object { + private const val TAG = "Joycon2" + private const val NINTENDO_MANUFACTURER_ID = 0x0553 + private const val SIDE_TYPE_INDEX = 5 + private const val SCAN_TIMEOUT_MS = 15_000L + } + + var onDeviceFound: ((ScanResult, Side, String) -> Unit)? = null + var onScanFailed: ((Int) -> Unit)? = null + var onTimeout: (() -> Unit)? = null + + private val handler = Handler(Looper.getMainLooper()) + private val adapter: BluetoothAdapter? = + (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter + + @Volatile + var isScanning = false + private set + + val isAvailable: Boolean get() = adapter?.bluetoothLeScanner != null + + fun start(isKnownAddress: (String) -> Boolean) { + if (isScanning) return + val scanner = adapter?.bluetoothLeScanner ?: return + + isScanning = true + scanner.startScan(null, lowLatencySettings(), createCallback(isKnownAddress)) + Log.i(TAG, "Scanning for Joy-Con 2/Compatible controllers...") + scheduleTimeout() + } + + fun stop() { + if (!isScanning) return + isScanning = false + handler.removeCallbacksAndMessages(null) + adapter?.bluetoothLeScanner?.stopScan(activeCallback) + activeCallback = null + } + + private var activeCallback: ScanCallback? = null + + private fun createCallback(isKnownAddress: (String) -> Boolean): ScanCallback { + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + if (!isScanning) return + + val scanRecord = result.scanRecord ?: return + + // Only accept devices carrying the Joy-Con 2 manufacturer record (0x0553) + val nintendoData = scanRecord.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) ?: return + + if (!JoyconAdvertisement.isPairing(NINTENDO_MANUFACTURER_ID, nintendoData)) { + Log.d(TAG, "Filtered out: isPairing=false for mfg 0x0553") + return + } + + if (isKnownAddress(result.device.address)) { + Log.d(TAG, "Filtered out: Already known address ${result.device.address}") + return + } + + val name = result.device.name + ?: scanRecord.deviceName + ?: "Joy-Con 2" + + // Pass the data and the type to detect side correctly + val side = detectSide(result, name) + onDeviceFound?.invoke(result, side, name) + } + + override fun onScanFailed(errorCode: Int) { + Log.e(TAG, "Scan failed: $errorCode") + isScanning = false + onScanFailed?.invoke(errorCode) + } + } + activeCallback = callback + return callback + } + + private fun scheduleTimeout() { + handler.postDelayed({ + if (!isScanning) return@postDelayed + stop() + onTimeout?.invoke() + }, SCAN_TIMEOUT_MS) + } + + private fun lowLatencySettings() = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + private fun detectSide(result: ScanResult, name: String): Side { + // 1. Try name-based detection first (most reliable for third-party) + sideFromName(name)?.let { return it } + + // 2. Try manufacturer-based detection + sideFromManufacturerData(result)?.let { return it } + + return Side.UNKNOWN + } + + private fun sideFromName(name: String): Side? = when { + // Matches "Joy-Con (L)", "NJ22-L", "Left Hyperion" + name.contains("(L)") || name.contains("Left") || name.contains("-L") -> Side.LEFT + // Matches "Joy-Con (R)", "NJ22-R", "Right Hyperion" + name.contains("(R)") || name.contains("Right") || name.contains("-R") -> Side.RIGHT + // Matches "Pro Controller", "NJ22" + name.contains("Pro") || name.contains("NJ22") -> Side.PRO + else -> null + } + + /** + * Extracts side info from Nintendo-specific data packets. + * Note: Nyxi packets are usually too short for this index, so we skip this for Nyxi. + */ + private fun sideFromManufacturerData(result: ScanResult): Side? { + val mfgData = result.scanRecord + ?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) ?: return null + + if (mfgData.size <= SIDE_TYPE_INDEX) return null + + return when (mfgData[SIDE_TYPE_INDEX].toInt() and 0xFF) { + 0x67 -> Side.LEFT + 0x66 -> Side.RIGHT + 0x69 -> Side.PRO + else -> null + } + } } \ No newline at end of file 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 bd8024d..6dc88c2 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 @@ -30,6 +30,7 @@ class GattOpQueue { } fun enqueue(op: () -> Boolean) { + Log.d(TAG, "Operation enqueued. Current queue size: ${queue.size}") queue.add(op) runNext() } @@ -49,6 +50,7 @@ class GattOpQueue { private fun runNext() { if (inFlight) return val op = queue.poll() ?: return + Log.d(TAG, "Operation dequeued. Remaining queue size: ${queue.size}") inFlight = true val success = op() if (!success) { diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt index cf400c5..3a77214 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt @@ -1,21 +1,37 @@ -package com.joegec.joycon2android.connection +import android.util.Log /** - * Joy-Con 2 advertisements (manufacturer ID 0x0553) carry the bonded host's MAC at - * bytes [10..15]: a button press wakes the controller to reconnect to that host and - * advertises its address; holding SYNC (pairing mode) zeroes the field. Observed on - * hardware 2026-06 — wake: `… 01 00 09 A7 9A 55 E2 98 0F …`, pairing: - * `… 01 00 00 00 00 00 00 00 0F …`. + * Joy-Con 2 advertisements carry the bonded host's MAC to signal wake vs pairing mode. + * - ID 0x0553 (Nintendo): MAC at bytes [10..15] + * - ID 0x75 (Nyxi): MAC at bytes [5..10] + * Holding SYNC (pairing mode) zeroes this field. */ object JoyconAdvertisement { - private const val HOST_MAC_OFFSET = 10 + private const val TAG = "Joycon2" private const val HOST_MAC_LENGTH = 6 /** True when the controller is open for pairing rather than waking for its bonded host. */ - fun isPairing(manufacturerData: ByteArray): Boolean { - if (manufacturerData.size < HOST_MAC_OFFSET + HOST_MAC_LENGTH) return true - return (HOST_MAC_OFFSET until HOST_MAC_OFFSET + HOST_MAC_LENGTH) - .all { manufacturerData[it] == 0.toByte() } + fun isPairing(id: Int, manufacturerData: ByteArray): Boolean { + val offset = when (id) { + 0x0442 -> 3 + 0x6c42 -> 0 + 0x0553 -> 10 + else -> return true + } + + // For Nyxi/Keylinker (0x6c42), if data is too short, treat as pairing. + if (id == 0x6c42 && manufacturerData.size < offset + HOST_MAC_LENGTH) { + return true + } + + if (manufacturerData.size < offset + HOST_MAC_LENGTH) return true + + val macSlice = manufacturerData.sliceArray(offset until offset + HOST_MAC_LENGTH) + val isPairing = macSlice.all { it == 0.toByte() } + + Log.d(TAG, "isPairing check: id=0x${Integer.toHexString(id)}, offset=$offset, data=${macSlice.joinToString("") { "%02X".format(it) }} -> $isPairing") + + return isPairing } } 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 4f4b11a..04be6db 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 @@ -21,6 +21,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +private fun ByteArray.hex(): String = joinToString("") { "%02X".format(it) } /** * Manages a single BLE GATT connection to one Joy-Con 2. @@ -37,6 +41,8 @@ class JoyconConnection( private val onDisconnected: (() -> Unit)? = null, ) { companion object { + val lastPackets = CopyOnWriteArrayList() + val packetCounts = ConcurrentHashMap() private const val TAG = "Joycon2" private val INPUT_SERVICE = UUID.fromString("ab7de9be-89fe-49ad-828f-118f09df7fd0") @@ -45,6 +51,15 @@ class JoyconConnection( private val CMD_RESPONSE_CHAR = UUID.fromString("c765a961-d9d8-4d36-a20a-5315b111836a") private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private val KEYLINKER_SERVICE = UUID.fromString("d7f010e0-660d-46e9-96c3-19c4148bdab5") + private val KEYLINKER_WRITE = UUID.fromString("d7f010e1-660d-46e9-96c3-19c4148bdab5") + private val KEYLINKER_NOTIFY = UUID.fromString("d7f010e2-660d-46e9-96c3-19c4148bdab5") + + private val KEYLINKER_FF14_NOTIFY = UUID.fromString("0000ff14-0000-1000-8000-00805f9b34fb") + private val NYXI_INPUT_NOTIFY_CHAR = UUID.fromString("d5a9e01e-2ffc-4cca-b20c-8b67142bf442") + + private val NYXI_ENABLE_HID = byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + private val INIT_CMD_1 = byteArrayOf( 0x0C, 0x91.toByte(), 0x01, 0x02, 0x00, 0x04, 0x00, 0x00, 0xFF.toByte(), 0x00, 0x00, 0x00 @@ -101,6 +116,7 @@ class JoyconConnection( private val stickCalibrator = StickCalibrator() private var gatt: BluetoothGatt? = null private var writeChar: BluetoothGattCharacteristic? = null + private var ff15Char: BluetoothGattCharacteristic? = null private var notifyChar: BluetoothGattCharacteristic? = null private var cmdResponseChar: BluetoothGattCharacteristic? = null private var pendingPlayerLed: PlayerNumber? = null @@ -109,7 +125,12 @@ class JoyconConnection( @Volatile private var highPriority = false private var ledSentAfterFirstPacket = false + private val isNyxiController = deviceName.contains("NJ22") || deviceName.contains("Nyxi") || deviceName.contains("Hyperion") + fun connect(device: BluetoothDevice) { + if (isNyxiController) { + Log.i(TAG, "Detected Nyxi controller, applying connection workarounds.") + } gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) } @@ -125,16 +146,29 @@ class JoyconConnection( private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + val bondState = g.device.bondState + Log.d(TAG, "[$side] onConnectionStateChange: status=$status, newState=$newState, bondState=$bondState") when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.i(TAG, "[$side] Connected. Requesting MTU $DESIRED_MTU") _connectionState.value = JoyconConnectionState( - connected = true, deviceName = deviceName + connected = true, deviceName = deviceName, bondState = bondState ) g.requestMtu(DESIRED_MTU) } BluetoothProfile.STATE_DISCONNECTED -> { Log.w(TAG, "[$side] Disconnected (status=$status)") + + if (status != BluetoothGatt.GATT_SUCCESS) { + val hint = when (status) { + 133 -> "GATT_ERROR (133): Common on Android. Try toggling Bluetooth or restarting the controller." + 8, 19, 22, 62 -> "Connection timeout/terminated. If this persists, 'Forget' the device in Android Bluetooth settings and re-pair." + 34 -> "GATT_CONN_LMP_TIMEOUT: The controller might have stopped responding." + else -> "Status $status. If connection fails, ensure the controller is in pairing mode (holding SYNC)." + } + Log.w(TAG, "[$side] Connection Hint: $hint") + } + opQueue.clear() g.close() gatt = null @@ -153,12 +187,29 @@ class JoyconConnection( } override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { + Log.d(TAG, "[$side] onMtuChanged: mtu=$mtu, status=$status") Log.i(TAG, "[$side] MTU=$mtu. Discovering services.") - g.discoverServices() + if (g.device.bondState == BluetoothDevice.BOND_BONDED) { + Log.i(TAG, "[NYXI] Bonding successful, proceeding with service discovery.") + } + if (isNyxiController) { + mainHandler.postDelayed({ g.discoverServices() }, 500L) + } else { + g.discoverServices() + } } override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + Log.d(TAG, "[$side] onServicesDiscovered: status=$status") Log.i(TAG, "[$side] Services discovered (status=$status)") + + // Check for bonding state issues + if (g.device.bondState == BluetoothDevice.BOND_BONDING) { + Log.w(TAG, "[$side] System is attempting to bond; this may interfere with Joy-Con protocol.") + } + // Log device appearance/class + Log.i(TAG, "[$side] Device BluetoothClass: ${g.device.bluetoothClass}") + if (status != BluetoothGatt.GATT_SUCCESS) { _connectionState.value = JoyconConnectionState( error = "Service discovery failed", deviceName = deviceName @@ -166,27 +217,58 @@ class JoyconConnection( return } - val svc = g.getService(INPUT_SERVICE) + // Deep GATT dump: iterate through ALL discovered services and their characteristics + for (service in g.services) { + Log.i(TAG, "[$side] Service UUID: ${service.uuid}") + for (characteristic in service.characteristics) { + val props = characteristic.properties + val propList = mutableListOf() + if ((props and BluetoothGattCharacteristic.PROPERTY_READ) != 0) propList.add("Read") + if (((props and BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) || ((props and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0)) propList.add("Write") + if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) propList.add("Notify") + val propStr = if (propList.isEmpty()) "None" else propList.joinToString("/") + Log.i(TAG, "[$side] Characteristic UUID: ${characteristic.uuid} ($propStr)") + } + } + + var svc = g.getService(INPUT_SERVICE) if (svc == null) { + svc = g.getService(KEYLINKER_SERVICE) + } + + if (svc == null) { + val services = g.services + val uuids = services.joinToString(", ") { it.uuid.toString().take(8) } + _connectionState.value = JoyconConnectionState( - error = "Not a compatible Joy-Con 2", deviceName = deviceName + error = "[$deviceName] Not a compatible Joy-Con 2 (Services: $uuids)", + deviceName = deviceName ) return } - writeChar = svc.getCharacteristic(WRITE_CHAR) - notifyChar = svc.getCharacteristic(NOTIFY_CHAR) - cmdResponseChar = svc.getCharacteristic(CMD_RESPONSE_CHAR) + if (svc.uuid == KEYLINKER_SERVICE) { + Log.i(TAG, "[$side] Using Keylinker service") + writeChar = svc.getCharacteristic(KEYLINKER_WRITE) + notifyChar = svc.getCharacteristic(KEYLINKER_NOTIFY) + cmdResponseChar = svc.getCharacteristic(KEYLINKER_NOTIFY) + } else { + writeChar = svc.getCharacteristic(WRITE_CHAR) + notifyChar = svc.getCharacteristic(NOTIFY_CHAR) ?: svc.getCharacteristic(NYXI_INPUT_NOTIFY_CHAR) + cmdResponseChar = svc.getCharacteristic(CMD_RESPONSE_CHAR) + } + if (writeChar == null || notifyChar == null) { _connectionState.value = JoyconConnectionState( - error = "Missing BLE characteristics", deviceName = deviceName + error = "Missing BLE characteristics", deviceName = deviceName, + bondState = g.device.bondState ) return } writeChar!!.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE // Subscribe to command response notifications (required for LED commands) - if (cmdResponseChar != null) { + if (cmdResponseChar != null && cmdResponseChar != notifyChar) { g.setCharacteristicNotification(cmdResponseChar, true) val cmdCccd = cmdResponseChar!!.getDescriptor(CCCD) if (cmdCccd != null) { @@ -194,30 +276,57 @@ class JoyconConnection( 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") } } - // Subscribe to input notifications + // Subscribe to primary input notifications g.setCharacteristicNotification(notifyChar, true) val notifyCccd = notifyChar!!.getDescriptor(CCCD) if (notifyCccd != null) { opQueue.enqueue { - Log.d(TAG, "[$side] Writing NOTIFY CCCD") + Log.d(TAG, "[$side] Writing NOTIFY CCCD for ${notifyChar!!.uuid}") writeDescriptor(g, notifyCccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) } - } else { - Log.e(TAG, "[$side] NOTIFY char has no CCCD descriptor — notifications won't work!") } + + // Check if NYXI_INPUT_NOTIFY_CHAR also exists separately on the service and subscribe if needed + val nyxiNotifyChar = svc.getCharacteristic(NYXI_INPUT_NOTIFY_CHAR) + if (nyxiNotifyChar != null && nyxiNotifyChar != notifyChar) { + g.setCharacteristicNotification(nyxiNotifyChar, true) + val nyxiCccd = nyxiNotifyChar.getDescriptor(CCCD) + if (nyxiCccd != null) { + opQueue.enqueue { + Log.d(TAG, "[$side] Writing NYXI NOTIFY CCCD for ${nyxiNotifyChar.uuid}") + writeDescriptor(g, nyxiCccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } + } + } + + if (isNyxiController) { + opQueue.enqueue { + Log.i(TAG, "[$side] Writing NYXI_ENABLE_HID to Keylinker write") + writeCharacteristic(g, writeChar!!, NYXI_ENABLE_HID) + } + ff15Char?.let { char -> + opQueue.enqueue { + Log.i(TAG, "[$side] Writing NYXI_ENABLE_HID to FF15") + writeCharacteristic(g, char, NYXI_ENABLE_HID) + } + } + } + enqueueInitWrite(g, INIT_CMD_1) enqueueInitWrite(g, INIT_CMD_2) - enqueueInitWrite(g, SPI_READ_COLOR_CMD) + if (svc.uuid != KEYLINKER_SERVICE) { + enqueueInitWrite(g, SPI_READ_COLOR_CMD) + } opQueue.enqueue { + Log.d(TAG, "[$side] Setting initComplete = true") initComplete = true _connectionState.value = _connectionState.value.copy( - connected = true, ready = true, deviceName = deviceName + connected = true, ready = true, deviceName = deviceName, + bondState = g.device.bondState ) Log.i(TAG, "[$side] Init sequence complete") if (highPriority) requestPriority(g) @@ -297,30 +406,63 @@ class JoyconConnection( } private fun handleCharacteristicChanged(g: BluetoothGatt, uuid: UUID, data: ByteArray) { + packetCounts[deviceName] = (packetCounts[deviceName] ?: 0L) + 1L + val prefix = data.take(4).toByteArray().hex() + Log.d(TAG, "[$side] Notification on $uuid: len=${data.size}, data=$prefix...") + + lastPackets.add(0, data.hex()) + while (lastPackets.size > 3) { + lastPackets.removeAt(3) + } + + // Attempt parsing as standard Joy-Con / Switch 2 input packet + val parsedInput = PacketParser.parse(data, side) + if (parsedInput != null) { + _input.value = stickCalibrator.calibrate(parsedInput) + if (!ledSentAfterFirstPacket && initComplete) { + ledSentAfterFirstPacket = true + mainHandler.post { opQueue.enqueue { sendLedCommand(g) } } + } + } + 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) } } + NOTIFY_CHAR, KEYLINKER_NOTIFY, KEYLINKER_FF14_NOTIFY, NYXI_INPUT_NOTIFY_CHAR -> { + if (uuid == KEYLINKER_NOTIFY || uuid == KEYLINKER_FF14_NOTIFY) { + handleCmdResponse(data) } } 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) + handleCmdResponse(data) + } + else -> { + if (parsedInput == null) { + Log.i(TAG, "[UNKNOWN NOTIFY ${uuid}]: ${data.hex()}") } } } } + private fun handleCmdResponse(data: ByteArray) { + Log.d(TAG, "[$side] Cmd response: ${data.joinToString(" ") { "%02X".format(it) }}") + Log.i(TAG, "[$side] Raw response bytes: ${data.joinToString(" ") { "%02X".format(it) }}") + if (data.isNotEmpty() && (data[0] == 0xA1.toByte() || data[0] == 0x01.toByte())) { + val reportData = data.drop(1).toByteArray() + val parsed = PacketParser.parse(reportData, side) ?: PacketParser.parse(data, side) + parsed?.let { _input.value = stickCalibrator.calibrate(it) } + return + } + SpiColorParser.parseAccentColor(data)?.let { color -> + Log.i(TAG, "[$side] Accent color: #${"%06X".format(color)}") + _connectionState.value = _connectionState.value.copy(accentColor = color) + } + } + private fun writeCharacteristic( g: BluetoothGatt, ch: BluetoothGattCharacteristic, value: ByteArray, ): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { g.writeCharacteristic(ch, value, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) == BluetoothStatusCodes.SUCCESS } else { @@ -329,6 +471,8 @@ class JoyconConnection( @Suppress("DEPRECATION") g.writeCharacteristic(ch) } + Log.d(TAG, "[$side] writeCharacteristic success=$success") + return success } private fun writeDescriptor( @@ -336,7 +480,7 @@ class JoyconConnection( descriptor: BluetoothGattDescriptor, value: ByteArray, ): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { g.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS } else { @Suppress("DEPRECATION") @@ -344,6 +488,8 @@ class JoyconConnection( @Suppress("DEPRECATION") g.writeDescriptor(descriptor) } + Log.d(TAG, "[$side] writeDescriptor success=$success") + return success } } 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 c120417..634b809 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 android.util.Log import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side @@ -8,6 +9,8 @@ import java.nio.ByteOrder object PacketParser { + private fun ByteArray.hex(): String = joinToString("") { "%02X".format(it) } + private const val MIN_PACKET_SIZE = 0x3B // Button bitmask → enum. Bits 0..31 come from the uint32 at packet offset 0x03; the Pro @@ -26,6 +29,14 @@ object PacketParser { ) fun parse(data: ByteArray, side: Side): JoyconInput? { + Log.d("PacketParser", "parse: len=${data.size}, hex=${data.take(8).toByteArray().hex()}") + if (data.size < 12) return null + + // Check for Nyxi / Switch 1 report layout where Byte 1 is status 0x18 and Byte 5..7 is 12-bit stick + if (isNyxiFormat(data)) { + return parseNyxiFormat(data, side) + } + if (data.size < MIN_PACKET_SIZE) return null val bb = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) @@ -52,6 +63,91 @@ object PacketParser { ) } + private fun isNyxiFormat(data: ByteArray): Boolean { + if (data.size < 8) return false + val status = data[1].toInt() and 0xFF + val b4 = data[4].toInt() and 0xFF + return (status == 0x14 || status == 0x18 || status == 0x80 || status == 0x8E) && + ((b4 and 0x0F) <= 7) + } + + private fun parseNyxiFormat(data: ByteArray, side: Side): JoyconInput { + val b2 = data[2].toInt() and 0xFF + val b3 = data[3].toInt() and 0xFF + val b4 = data[4].toInt() and 0xFF + + val pressed = mutableSetOf() + + // Byte 2 & Byte 3: Button mappings depend on whether this is the Left or Right controller half + if (side == Side.LEFT) { + // Left Joy-Con Byte 2 (D-Pad & Left shoulders - oriented for sideways single Joy-Con display) + if ((b2 and 0x01) != 0) pressed.add(JoyconButton.Down.id) // Physical Right -> Screen ▶ + if ((b2 and 0x02) != 0) pressed.add(JoyconButton.Right.id) // Physical Up -> Screen ▲ + if ((b2 and 0x04) != 0) pressed.add(JoyconButton.Left.id) // Physical Down -> Screen ▼ + if ((b2 and 0x08) != 0) pressed.add(JoyconButton.Up.id) // Physical Left -> Screen ◀ + if ((b2 and 0x10) != 0) pressed.add(JoyconButton.L.id) + if ((b2 and 0x20) != 0) pressed.add(JoyconButton.ZL.id) + if ((b2 and 0x40) != 0) pressed.add(JoyconButton.Minus.id) + if ((b2 and 0x80) != 0) pressed.add(JoyconButton.LS.id) + + // Left Joy-Con Byte 3 (Capture, SL/SR, etc.) + if ((b3 and 0x01) != 0) pressed.add(JoyconButton.Capture.id) // "O" button + if ((b3 and 0x02) != 0) pressed.add(JoyconButton.Minus.id) + if ((b3 and 0x04) != 0) pressed.add(JoyconButton.LS.id) + if ((b3 and 0x10) != 0) pressed.add(JoyconButton.Capture.id) + if ((b3 and 0x20) != 0) pressed.add(JoyconButton.GL.id) + if ((b3 and 0x40) != 0) pressed.add(JoyconButton.SrLeft.id) + if ((b3 and 0x80) != 0) pressed.add(JoyconButton.SlLeft.id) + } else { + // Right / Pro Controller Byte 2 (Face buttons & Right shoulders) + if ((b2 and 0x01) != 0) pressed.add(JoyconButton.B.id) + if ((b2 and 0x02) != 0) pressed.add(JoyconButton.A.id) + if ((b2 and 0x04) != 0) pressed.add(JoyconButton.Y.id) + if ((b2 and 0x08) != 0) pressed.add(JoyconButton.X.id) + if ((b2 and 0x10) != 0) pressed.add(JoyconButton.R.id) + if ((b2 and 0x20) != 0) pressed.add(JoyconButton.ZR.id) + if ((b2 and 0x40) != 0) pressed.add(JoyconButton.Plus.id) + if ((b2 and 0x80) != 0) pressed.add(JoyconButton.RS.id) + + // Right / Pro Controller Byte 3 (Home, Chat, SL/SR, etc.) + if ((b3 and 0x01) != 0) pressed.add(JoyconButton.Home.id) + if ((b3 and 0x02) != 0) pressed.add(JoyconButton.Plus.id) + if ((b3 and 0x04) != 0) pressed.add(JoyconButton.RS.id) + if ((b3 and 0x10) != 0) pressed.add(JoyconButton.Chat.id) // "C" Chat button on Right Joy-Con + if ((b3 and 0x20) != 0) pressed.add(JoyconButton.GR.id) + if ((b3 and 0x40) != 0) pressed.add(JoyconButton.SrRight.id) + if ((b3 and 0x80) != 0) pressed.add(JoyconButton.SlRight.id) + } + + // Byte 4 D-Pad Hat Switch + val hat = b4 and 0x0F + when (hat) { + 0 -> pressed.add(JoyconButton.Up.id) + 1 -> { pressed.add(JoyconButton.Up.id); pressed.add(JoyconButton.Right.id) } + 2 -> pressed.add(JoyconButton.Right.id) + 3 -> { pressed.add(JoyconButton.Down.id); pressed.add(JoyconButton.Right.id) } + 4 -> pressed.add(JoyconButton.Down.id) + 5 -> { pressed.add(JoyconButton.Down.id); pressed.add(JoyconButton.Left.id) } + 6 -> pressed.add(JoyconButton.Left.id) + } + + // Stick decoding: offset 5 for primary stick (present on both Left and Right halves), offset 8 for secondary stick (Pro controller) + val (lsx, lsy) = if (data.size >= 8) decodeStick(data, 5) else (2048 to 2048) + val (rsx, rsy) = if (data.size >= 11) decodeStick(data, 8) else (2048 to 2048) + + Log.d("PacketParser", "parseNyxiFormat: side=$side, pressed=$pressed, stick1=($lsx,$lsy), stick2=($rsx,$rsy)") + + return JoyconInput( + packetId = (data[0].toInt() and 0xFF), + buttons = 0L, + pressed = pressed, + stickX = lsx, + stickY = lsy, + rightStickX = if (side == Side.PRO) rsx else 2048, + rightStickY = if (side == Side.PRO) rsy else 2048, + ) + } + private fun resolveStick(data: ByteArray, side: Side): Pair { if (side == Side.LEFT || side == Side.PRO) return decodeStick(data, 0x0A) if (side == Side.RIGHT) return decodeStick(data, 0x0D) From 554eb9f4aac27082b7d17b50f8fc9a86c201d67e Mon Sep 17 00:00:00 2001 From: SWeav02 Date: Fri, 18 Sep 2026 23:58:34 -0400 Subject: [PATCH 3/7] working well --- .../joegec/joycon2android/ui/JoyconScreen.kt | 43 +--- .../joycon2android/connection/BleScanner.kt | 9 +- .../connection/ConnectionPool.kt | 9 + .../connection/Joycon2Manager.kt | 2 +- .../connection/JoyconAdvertisement.kt | 36 +--- .../connection/JoyconConnection.kt | 186 ++++-------------- 6 files changed, 60 insertions(+), 225 deletions(-) diff --git a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt index 41a6479..c96c5a9 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt @@ -314,45 +314,10 @@ fun JoyconScreen( }, ) - // Debug Overlay for raw hex of last 3 packets - Column( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(16.dp) - .background(Color.Black.copy(alpha = 0.8f), RoundedCornerShape(8.dp)) - .padding(8.dp) - ) { - val allControllers = state.unassignedJoycons + state.activePlayers.flatMap { listOfNotNull(it.left, it.right) } - allControllers.forEach { controller -> - val bondStr = when (controller.connectionState.bondState) { - BluetoothDevice.BOND_NONE -> "NONE" - BluetoothDevice.BOND_BONDING -> "BONDING" - BluetoothDevice.BOND_BONDED -> "BONDED" - else -> "UNKNOWN (${controller.connectionState.bondState})" - } - val count = JoyconConnection.packetCounts[controller.deviceName] ?: 0L - Text( - text = "${controller.deviceName}: $bondStr (Pkts: $count)", - color = Color.Cyan, - style = MaterialTheme.typography.labelSmall - ) - } - if (allControllers.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - } - Text( - text = "Last 3 Packets:", - color = Color.White, - style = MaterialTheme.typography.labelSmall - ) - JoyconConnection.lastPackets.forEach { packet -> - Text( - text = packet, - color = Color.Green, - style = MaterialTheme.typography.bodySmall - ) - } - } + /* + // Debug Overlay + Column(...) { ... } + */ } } } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt index 79966d5..3419b38 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt @@ -67,13 +67,8 @@ class BleScanner(context: Context) { val scanRecord = result.scanRecord ?: return - // Only accept devices carrying the Joy-Con 2 manufacturer record (0x0553) - val nintendoData = scanRecord.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) ?: return - - if (!JoyconAdvertisement.isPairing(NINTENDO_MANUFACTURER_ID, nintendoData)) { - Log.d(TAG, "Filtered out: isPairing=false for mfg 0x0553") - return - } + // Accept devices carrying the Joy-Con 2 manufacturer record (0x0553) + if (scanRecord.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) == null) return if (isKnownAddress(result.device.address)) { Log.d(TAG, "Filtered out: Already known address ${result.device.address}") 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 2493720..a3c1ec5 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,8 @@ package com.joegec.joycon2android.connection import android.annotation.SuppressLint import android.bluetooth.le.ScanResult import android.content.Context +import android.os.Handler +import android.os.Looper import com.joegec.joycon2android.model.Side import java.util.concurrent.ConcurrentHashMap @@ -16,6 +18,7 @@ import java.util.concurrent.ConcurrentHashMap @SuppressLint("MissingPermission") class ConnectionPool(private val context: Context) { + private val mainHandler = Handler(Looper.getMainLooper()) private val connections = ConcurrentHashMap() var onPoolChanged: (() -> Unit)? = null @@ -37,6 +40,12 @@ class ConnectionPool(private val context: Context) { connection.setHighPriority(highPriority) if (connections.putIfAbsent(address, connection) != null) return null connection.connect(result.device) + + // Re-assert HIGH priority on all existing connections after new connection settles + mainHandler.postDelayed({ + setHighPriority(true) + }, 1200L) + return connection } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt index 676ac3c..ac28454 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt @@ -34,7 +34,7 @@ class Joycon2Manager( private val connectionJobs = mutableMapOf() @Volatile - private var highPriority = false + private var highPriority = true private val _controllers = MutableStateFlow>(emptyList()) override val controllers: StateFlow> = _controllers.asStateFlow() diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt index 3a77214..ca89329 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt @@ -1,37 +1,19 @@ -import android.util.Log +package com.joegec.joycon2android.connection /** - * Joy-Con 2 advertisements carry the bonded host's MAC to signal wake vs pairing mode. - * - ID 0x0553 (Nintendo): MAC at bytes [10..15] - * - ID 0x75 (Nyxi): MAC at bytes [5..10] - * Holding SYNC (pairing mode) zeroes this field. + * Joy-Con 2 advertisements (manufacturer ID 0x0553) carry the bonded host's MAC at + * bytes [10..15]: a button press wakes the controller to reconnect to that host and + * advertises its address; holding SYNC (pairing mode) zeroes the field. */ object JoyconAdvertisement { - private const val TAG = "Joycon2" + private const val HOST_MAC_OFFSET = 10 private const val HOST_MAC_LENGTH = 6 /** True when the controller is open for pairing rather than waking for its bonded host. */ - fun isPairing(id: Int, manufacturerData: ByteArray): Boolean { - val offset = when (id) { - 0x0442 -> 3 - 0x6c42 -> 0 - 0x0553 -> 10 - else -> return true - } - - // For Nyxi/Keylinker (0x6c42), if data is too short, treat as pairing. - if (id == 0x6c42 && manufacturerData.size < offset + HOST_MAC_LENGTH) { - return true - } - - if (manufacturerData.size < offset + HOST_MAC_LENGTH) return true - - val macSlice = manufacturerData.sliceArray(offset until offset + HOST_MAC_LENGTH) - val isPairing = macSlice.all { it == 0.toByte() } - - Log.d(TAG, "isPairing check: id=0x${Integer.toHexString(id)}, offset=$offset, data=${macSlice.joinToString("") { "%02X".format(it) }} -> $isPairing") - - return isPairing + fun isPairing(manufacturerData: ByteArray): Boolean { + if (manufacturerData.size < HOST_MAC_OFFSET + HOST_MAC_LENGTH) return true + return (HOST_MAC_OFFSET until HOST_MAC_OFFSET + HOST_MAC_LENGTH) + .all { manufacturerData[it] == 0.toByte() } } } 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 04be6db..176fee5 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 @@ -21,17 +21,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CopyOnWriteArrayList - -private fun ByteArray.hex(): String = joinToString("") { "%02X".format(it) } /** - * Manages a single BLE GATT connection to one Joy-Con 2. + * Manages a single BLE GATT connection to one Joy-Con 2 or compatible controller. * Each Joy-Con gets its own instance with independent state. - * - * All BLE operations require BLUETOOTH_CONNECT permission, which is verified - * by the permission launcher in MainActivity before any BLE code is reached. */ @SuppressLint("MissingPermission") class JoyconConnection( @@ -41,8 +34,6 @@ class JoyconConnection( private val onDisconnected: (() -> Unit)? = null, ) { companion object { - val lastPackets = CopyOnWriteArrayList() - val packetCounts = ConcurrentHashMap() private const val TAG = "Joycon2" private val INPUT_SERVICE = UUID.fromString("ab7de9be-89fe-49ad-828f-118f09df7fd0") @@ -54,12 +45,8 @@ class JoyconConnection( private val KEYLINKER_SERVICE = UUID.fromString("d7f010e0-660d-46e9-96c3-19c4148bdab5") private val KEYLINKER_WRITE = UUID.fromString("d7f010e1-660d-46e9-96c3-19c4148bdab5") private val KEYLINKER_NOTIFY = UUID.fromString("d7f010e2-660d-46e9-96c3-19c4148bdab5") - - private val KEYLINKER_FF14_NOTIFY = UUID.fromString("0000ff14-0000-1000-8000-00805f9b34fb") private val NYXI_INPUT_NOTIFY_CHAR = UUID.fromString("d5a9e01e-2ffc-4cca-b20c-8b67142bf442") - private val NYXI_ENABLE_HID = byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) - private val INIT_CMD_1 = byteArrayOf( 0x0C, 0x91.toByte(), 0x01, 0x02, 0x00, 0x04, 0x00, 0x00, 0xFF.toByte(), 0x00, 0x00, 0x00 @@ -69,23 +56,11 @@ class JoyconConnection( 0x00, 0x00, 0xFF.toByte(), 0x00, 0x00, 0x00 ) - // SPI read (report 0x02, cmd 0x04): read 0x40 bytes from the DeviceInfo block - // at 0x013000, which contains the shell colors (body color at 0x013019). - // Payload: read length (0x40), 0x7E magic, then the 4-byte LE source address. - // The reply arrives on the command-response characteristic and is decoded - // by [SpiColorParser]. - // Byte [2] is 0x00 for SPI reads (matching HandHeldLegend procon2tool); - // the INIT_CMD_* feature commands use 0x01 there, but SPI reads only - // reply when this is 0x00. private val SPI_READ_COLOR_CMD = byteArrayOf( 0x02, 0x91.toByte(), 0x00, 0x04, 0x00, 0x08, 0x00, 0x00, 0x40, 0x7E, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00 ) - // Subcommand 0x07: set LED pattern via bitmask (16 bytes) - // Lower nibble = solid LEDs (0x01=P1, 0x02=P2, 0x04=P3, 0x08=P4) - // Upper nibble = flashing LEDs (0x10=P1, 0x20=P2, 0x40=P3, 0x80=P4) - // 0xF0 = all flashing = default cycling animation private fun playerLedCmd(bitmask: Byte): ByteArray { return byteArrayOf( 0x09, 0x91.toByte(), 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, @@ -93,7 +68,6 @@ class JoyconConnection( ) } - // All 4 player LEDs solid on (0x0F = P1+P2+P3+P4) private val LED_ALL_ON_CMD = byteArrayOf( 0x09, 0x91.toByte(), 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 @@ -116,21 +90,15 @@ class JoyconConnection( private val stickCalibrator = StickCalibrator() private var gatt: BluetoothGatt? = null private var writeChar: BluetoothGattCharacteristic? = null - private var ff15Char: BluetoothGattCharacteristic? = null private var notifyChar: BluetoothGattCharacteristic? = null private var cmdResponseChar: BluetoothGattCharacteristic? = null private var pendingPlayerLed: PlayerNumber? = null @Volatile var initComplete = false private set - @Volatile private var highPriority = false + @Volatile private var highPriority = true private var ledSentAfterFirstPacket = false - private val isNyxiController = deviceName.contains("NJ22") || deviceName.contains("Nyxi") || deviceName.contains("Hyperion") - fun connect(device: BluetoothDevice) { - if (isNyxiController) { - Log.i(TAG, "Detected Nyxi controller, applying connection workarounds.") - } gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) } @@ -146,29 +114,16 @@ class JoyconConnection( private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { - val bondState = g.device.bondState - Log.d(TAG, "[$side] onConnectionStateChange: status=$status, newState=$newState, bondState=$bondState") when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.i(TAG, "[$side] Connected. Requesting MTU $DESIRED_MTU") _connectionState.value = JoyconConnectionState( - connected = true, deviceName = deviceName, bondState = bondState + connected = true, deviceName = deviceName, bondState = g.device.bondState ) g.requestMtu(DESIRED_MTU) } BluetoothProfile.STATE_DISCONNECTED -> { Log.w(TAG, "[$side] Disconnected (status=$status)") - - if (status != BluetoothGatt.GATT_SUCCESS) { - val hint = when (status) { - 133 -> "GATT_ERROR (133): Common on Android. Try toggling Bluetooth or restarting the controller." - 8, 19, 22, 62 -> "Connection timeout/terminated. If this persists, 'Forget' the device in Android Bluetooth settings and re-pair." - 34 -> "GATT_CONN_LMP_TIMEOUT: The controller might have stopped responding." - else -> "Status $status. If connection fails, ensure the controller is in pairing mode (holding SYNC)." - } - Log.w(TAG, "[$side] Connection Hint: $hint") - } - opQueue.clear() g.close() gatt = null @@ -187,29 +142,12 @@ class JoyconConnection( } override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { - Log.d(TAG, "[$side] onMtuChanged: mtu=$mtu, status=$status") Log.i(TAG, "[$side] MTU=$mtu. Discovering services.") - if (g.device.bondState == BluetoothDevice.BOND_BONDED) { - Log.i(TAG, "[NYXI] Bonding successful, proceeding with service discovery.") - } - if (isNyxiController) { - mainHandler.postDelayed({ g.discoverServices() }, 500L) - } else { - g.discoverServices() - } + g.discoverServices() } override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { - Log.d(TAG, "[$side] onServicesDiscovered: status=$status") Log.i(TAG, "[$side] Services discovered (status=$status)") - - // Check for bonding state issues - if (g.device.bondState == BluetoothDevice.BOND_BONDING) { - Log.w(TAG, "[$side] System is attempting to bond; this may interfere with Joy-Con protocol.") - } - // Log device appearance/class - Log.i(TAG, "[$side] Device BluetoothClass: ${g.device.bluetoothClass}") - if (status != BluetoothGatt.GATT_SUCCESS) { _connectionState.value = JoyconConnectionState( error = "Service discovery failed", deviceName = deviceName @@ -217,17 +155,19 @@ class JoyconConnection( return } - // Deep GATT dump: iterate through ALL discovered services and their characteristics - for (service in g.services) { - Log.i(TAG, "[$side] Service UUID: ${service.uuid}") - for (characteristic in service.characteristics) { - val props = characteristic.properties - val propList = mutableListOf() - if ((props and BluetoothGattCharacteristic.PROPERTY_READ) != 0) propList.add("Read") - if (((props and BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) || ((props and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0)) propList.add("Write") - if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) propList.add("Notify") - val propStr = if (propList.isEmpty()) "None" else propList.joinToString("/") - Log.i(TAG, "[$side] Characteristic UUID: ${characteristic.uuid} ($propStr)") + // Subscribe to all notification characteristics across all discovered services + for (s in g.services) { + for (char in s.characteristics) { + if ((char.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { + if (g.setCharacteristicNotification(char, true)) { + val cccd = char.getDescriptor(CCCD) + if (cccd != null) { + opQueue.enqueue { + writeDescriptor(g, cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } + } + } + } } } @@ -237,9 +177,7 @@ class JoyconConnection( } if (svc == null) { - val services = g.services - val uuids = services.joinToString(", ") { it.uuid.toString().take(8) } - + val uuids = g.services.joinToString(", ") { it.uuid.toString().take(8) } _connectionState.value = JoyconConnectionState( error = "[$deviceName] Not a compatible Joy-Con 2 (Services: $uuids)", deviceName = deviceName @@ -267,54 +205,38 @@ class JoyconConnection( } writeChar!!.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - // Subscribe to command response notifications (required for LED commands) + // Subscribe to command response notifications if (cmdResponseChar != null && cmdResponseChar != notifyChar) { 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) } } } - // Subscribe to primary input notifications + // Subscribe to input notifications g.setCharacteristicNotification(notifyChar, true) val notifyCccd = notifyChar!!.getDescriptor(CCCD) if (notifyCccd != null) { opQueue.enqueue { - Log.d(TAG, "[$side] Writing NOTIFY CCCD for ${notifyChar!!.uuid}") writeDescriptor(g, notifyCccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) } } - // Check if NYXI_INPUT_NOTIFY_CHAR also exists separately on the service and subscribe if needed + // Subscribe to Nyxi / Espressif input notifications if present as a distinct characteristic val nyxiNotifyChar = svc.getCharacteristic(NYXI_INPUT_NOTIFY_CHAR) if (nyxiNotifyChar != null && nyxiNotifyChar != notifyChar) { g.setCharacteristicNotification(nyxiNotifyChar, true) val nyxiCccd = nyxiNotifyChar.getDescriptor(CCCD) if (nyxiCccd != null) { opQueue.enqueue { - Log.d(TAG, "[$side] Writing NYXI NOTIFY CCCD for ${nyxiNotifyChar.uuid}") writeDescriptor(g, nyxiCccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) } } } - if (isNyxiController) { - opQueue.enqueue { - Log.i(TAG, "[$side] Writing NYXI_ENABLE_HID to Keylinker write") - writeCharacteristic(g, writeChar!!, NYXI_ENABLE_HID) - } - ff15Char?.let { char -> - opQueue.enqueue { - Log.i(TAG, "[$side] Writing NYXI_ENABLE_HID to FF15") - writeCharacteristic(g, char, NYXI_ENABLE_HID) - } - } - } - enqueueInitWrite(g, INIT_CMD_1) enqueueInitWrite(g, INIT_CMD_2) if (svc.uuid != KEYLINKER_SERVICE) { @@ -322,29 +244,31 @@ class JoyconConnection( } opQueue.enqueue { - Log.d(TAG, "[$side] Setting initComplete = true") initComplete = true _connectionState.value = _connectionState.value.copy( connected = true, ready = true, deviceName = deviceName, bondState = g.device.bondState ) - Log.i(TAG, "[$side] Init sequence complete") + Log.i(TAG, "[$side] Init sequence complete — requesting HIGH priority") if (highPriority) requestPriority(g) - false // no GATT op — advance immediately + + // Schedule follow-up priority requests to prevent Android Bluetooth stack demotion when multiple controllers connect + mainHandler.postDelayed({ if (initComplete && highPriority) requestPriority(g) }, 1000L) + mainHandler.postDelayed({ if (initComplete && highPriority) requestPriority(g) }, 2500L) + + false } } override fun onDescriptorWrite( g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int ) { - Log.i(TAG, "[$side] CCCD write status=$status") mainHandler.post { opQueue.complete() } } 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) } @@ -368,15 +292,13 @@ class JoyconConnection( if (initComplete) gatt?.let(::requestPriority) } - // The default "balanced" connection interval lands on 30 ms on some phones, so the Joy-Con - // can only report ~33 times a second; high priority asks the stack for 7.5-15 ms. private fun requestPriority(g: BluetoothGatt) { val priority = if (highPriority) { BluetoothGatt.CONNECTION_PRIORITY_HIGH } else { BluetoothGatt.CONNECTION_PRIORITY_BALANCED } - Log.i(TAG, "[$side] Connection priority high=$highPriority accepted=${g.requestConnectionPriority(priority)}") + g.requestConnectionPriority(priority) } fun setPlayerLed(player: PlayerNumber) { @@ -397,7 +319,6 @@ class JoyconConnection( 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) } @@ -406,53 +327,21 @@ class JoyconConnection( } private fun handleCharacteristicChanged(g: BluetoothGatt, uuid: UUID, data: ByteArray) { - packetCounts[deviceName] = (packetCounts[deviceName] ?: 0L) + 1L - val prefix = data.take(4).toByteArray().hex() - Log.d(TAG, "[$side] Notification on $uuid: len=${data.size}, data=$prefix...") - - lastPackets.add(0, data.hex()) - while (lastPackets.size > 3) { - lastPackets.removeAt(3) - } - - // Attempt parsing as standard Joy-Con / Switch 2 input packet - val parsedInput = PacketParser.parse(data, side) - if (parsedInput != null) { - _input.value = stickCalibrator.calibrate(parsedInput) + PacketParser.parse(data, side)?.let { parsed -> + _input.value = stickCalibrator.calibrate(parsed) if (!ledSentAfterFirstPacket && initComplete) { ledSentAfterFirstPacket = true mainHandler.post { opQueue.enqueue { sendLedCommand(g) } } } } - when (uuid) { - NOTIFY_CHAR, KEYLINKER_NOTIFY, KEYLINKER_FF14_NOTIFY, NYXI_INPUT_NOTIFY_CHAR -> { - if (uuid == KEYLINKER_NOTIFY || uuid == KEYLINKER_FF14_NOTIFY) { - handleCmdResponse(data) - } - } - CMD_RESPONSE_CHAR -> { - handleCmdResponse(data) - } - else -> { - if (parsedInput == null) { - Log.i(TAG, "[UNKNOWN NOTIFY ${uuid}]: ${data.hex()}") - } - } + if (uuid == CMD_RESPONSE_CHAR || uuid == KEYLINKER_NOTIFY) { + handleCmdResponse(data) } } private fun handleCmdResponse(data: ByteArray) { - Log.d(TAG, "[$side] Cmd response: ${data.joinToString(" ") { "%02X".format(it) }}") - Log.i(TAG, "[$side] Raw response bytes: ${data.joinToString(" ") { "%02X".format(it) }}") - if (data.isNotEmpty() && (data[0] == 0xA1.toByte() || data[0] == 0x01.toByte())) { - val reportData = data.drop(1).toByteArray() - val parsed = PacketParser.parse(reportData, side) ?: PacketParser.parse(data, side) - parsed?.let { _input.value = stickCalibrator.calibrate(it) } - return - } SpiColorParser.parseAccentColor(data)?.let { color -> - Log.i(TAG, "[$side] Accent color: #${"%06X".format(color)}") _connectionState.value = _connectionState.value.copy(accentColor = color) } } @@ -462,7 +351,7 @@ class JoyconConnection( ch: BluetoothGattCharacteristic, value: ByteArray, ): Boolean { - val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { g.writeCharacteristic(ch, value, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) == BluetoothStatusCodes.SUCCESS } else { @@ -471,8 +360,6 @@ class JoyconConnection( @Suppress("DEPRECATION") g.writeCharacteristic(ch) } - Log.d(TAG, "[$side] writeCharacteristic success=$success") - return success } private fun writeDescriptor( @@ -480,7 +367,7 @@ class JoyconConnection( descriptor: BluetoothGattDescriptor, value: ByteArray, ): Boolean { - val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { g.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS } else { @Suppress("DEPRECATION") @@ -488,8 +375,5 @@ class JoyconConnection( @Suppress("DEPRECATION") g.writeDescriptor(descriptor) } - Log.d(TAG, "[$side] writeDescriptor success=$success") - return success } - } From 79341c310c36a9fc7e7b33b6dfe2411d0ba98bf3 Mon Sep 17 00:00:00 2001 From: SWeav02 Date: Sat, 19 Sep 2026 00:01:48 -0400 Subject: [PATCH 4/7] ready for PR --- .../main/java/com/joegec/joycon2android/ui/JoyconScreen.kt | 5 ----- .../com/joegec/joycon2android/konsist/ArchitectureTest.kt | 6 +++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt index c96c5a9..00d5f43 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt @@ -313,11 +313,6 @@ fun JoyconScreen( else -scrollState.value.toFloat().coerceIn(0f, appBarSpacePx) }, ) - - /* - // Debug Overlay - Column(...) { ... } - */ } } } diff --git a/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt b/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt index d985703..e73d9a8 100644 --- a/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt +++ b/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt @@ -18,7 +18,7 @@ class ArchitectureTest { .classes() .withNameEndingWith("ViewModel") .assertTrue { - val path = it.containingFile.path + val path = it.containingFile.path.replace('\\', '/') path.contains("/presentation/") || path.contains("/app/") } } @@ -37,7 +37,7 @@ class ArchitectureTest { .classes() .withNameEndingWith("UseCase") .assertTrue { - val path = it.containingFile.path + val path = it.containingFile.path.replace('\\', '/') path.contains("/domain/") || path.contains("/core/session/") } } @@ -55,6 +55,6 @@ class ArchitectureTest { Konsist.scopeFromProject() .interfaces() .withNameEndingWith("Repository") - .assertTrue { it.containingFile.path.contains("/domain/") } + .assertTrue { it.containingFile.path.replace('\\', '/').contains("/domain/") } } } From ce6b1999743b8d4d39e52e6ff2431422e2be61e6 Mon Sep 17 00:00:00 2001 From: "Sam W." Date: Sat, 19 Sep 2026 11:59:30 -0400 Subject: [PATCH 5/7] correct nyxi init codes --- .../connection/JoyconConnection.kt | 4 +- .../joycon2android/connection/PacketParser.kt | 70 +++++++++++++------ .../connection/PacketParserTest.kt | 44 ++++++++++++ 3 files changed, 97 insertions(+), 21 deletions(-) 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 176fee5..16ae57f 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 @@ -327,7 +327,9 @@ class JoyconConnection( } private fun handleCharacteristicChanged(g: BluetoothGatt, uuid: UUID, data: ByteArray) { - PacketParser.parse(data, side)?.let { parsed -> + val isNyxiChar = uuid == NYXI_INPUT_NOTIFY_CHAR || uuid == KEYLINKER_NOTIFY + + PacketParser.parse(data, side, isNyxiChar)?.let { parsed -> _input.value = stickCalibrator.calibrate(parsed) if (!ledSentAfterFirstPacket && initComplete) { ledSentAfterFirstPacket = true 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 634b809..8edddc0 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,6 +1,5 @@ package com.joegec.joycon2android.connection -import android.util.Log import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side @@ -9,8 +8,6 @@ import java.nio.ByteOrder object PacketParser { - private fun ByteArray.hex(): String = joinToString("") { "%02X".format(it) } - private const val MIN_PACKET_SIZE = 0x3B // Button bitmask → enum. Bits 0..31 come from the uint32 at packet offset 0x03; the Pro @@ -28,15 +25,15 @@ object PacketParser { 0x0100000000L to JoyconButton.GR, 0x0200000000L to JoyconButton.GL, ) - fun parse(data: ByteArray, side: Side): JoyconInput? { - Log.d("PacketParser", "parse: len=${data.size}, hex=${data.take(8).toByteArray().hex()}") + fun parse(data: ByteArray, side: Side, isNyxiChar: Boolean = false): JoyconInput? { if (data.size < 12) return null - // Check for Nyxi / Switch 1 report layout where Byte 1 is status 0x18 and Byte 5..7 is 12-bit stick - if (isNyxiFormat(data)) { + // If it came from a Nyxi characteristic, OR it matches the Nyxi format, use the Nyxi parser + if (isNyxiChar || isNyxiFormat(data)) { return parseNyxiFormat(data, side) } + // Standard Switch parser should only run on non-Nyxi characteristics if (data.size < MIN_PACKET_SIZE) return null val bb = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) @@ -66,12 +63,33 @@ object PacketParser { private fun isNyxiFormat(data: ByteArray): Boolean { if (data.size < 8) return false val status = data[1].toInt() and 0xFF - val b4 = data[4].toInt() and 0xFF - return (status == 0x14 || status == 0x18 || status == 0x80 || status == 0x8E) && - ((b4 and 0x0F) <= 7) + + // Nyxi/Keylinker input statuses. Status 0x3F and 0x81 are common heartbeat/init codes. + val isInputStatus = status == 0x14 || status == 0x10 || status == 0x18 || + status == 0x80 || status == 0x81 || status == 0x8E || status == 0x3F + + return isInputStatus } private fun parseNyxiFormat(data: ByteArray, side: Side): JoyconInput { + val status = data[1].toInt() and 0xFF + + // Nyxi hardware isolation: + // Left Joy-Con uses 0x14 for input; 0x10 is a management heartbeat. + // Right Joy-Con uses 0x10 for input; 0x14 is a management heartbeat. + // FE is a generic vendor management header. + val isHeartbeat = (side == Side.LEFT && status == 0x10) || + (side == Side.RIGHT && (status == 0x14 || status == 0x18)) || + (data[0].toInt() and 0xFF == 0xFE) + + if (isHeartbeat) { + return JoyconInput( + packetId = (data[0].toInt() and 0xFF), + stickX = 2048, stickY = 2048, + rightStickX = 2048, rightStickY = 2048 + ) + } + val b2 = data[2].toInt() and 0xFF val b3 = data[3].toInt() and 0xFF val b4 = data[4].toInt() and 0xFF @@ -131,20 +149,32 @@ object PacketParser { 6 -> pressed.add(JoyconButton.Left.id) } - // Stick decoding: offset 5 for primary stick (present on both Left and Right halves), offset 8 for secondary stick (Pro controller) - val (lsx, lsy) = if (data.size >= 8) decodeStick(data, 5) else (2048 to 2048) - val (rsx, rsy) = if (data.size >= 11) decodeStick(data, 8) else (2048 to 2048) - - Log.d("PacketParser", "parseNyxiFormat: side=$side, pressed=$pressed, stick1=($lsx,$lsy), stick2=($rsx,$rsy)") + // Stick decoding: Primary stick is usually at offset 5. + // Second stick (Pro) at offset 8. Some Right Joy-Cons use offset 8 exclusively. + val (s1x, s1y) = if (data.size >= 8) decodeStick(data, 5) else (2048 to 2048) + val (s2x, s2y) = if (data.size >= 11) decodeStick(data, 8) else (2048 to 2048) + + val s1Active = isStickActive(s1x to s1y) + val s2Active = isStickActive(s2x to s2y) + + val primaryX = when { + side == Side.LEFT -> s1x + side == Side.RIGHT -> if (s2Active && !s1Active) s2x else s1x + else -> s1x + } + val primaryY = when { + side == Side.LEFT -> s1y + side == Side.RIGHT -> if (s2Active && !s1Active) s2y else s1y + else -> s1y + } return JoyconInput( packetId = (data[0].toInt() and 0xFF), - buttons = 0L, pressed = pressed, - stickX = lsx, - stickY = lsy, - rightStickX = if (side == Side.PRO) rsx else 2048, - rightStickY = if (side == Side.PRO) rsy else 2048, + stickX = primaryX, + stickY = primaryY, + rightStickX = if (side == Side.PRO || side == Side.RIGHT) s2x else 2048, + rightStickY = if (side == Side.PRO || side == Side.RIGHT) s2y else 2048, ) } diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt index af47f7a..7e073a1 100644 --- a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt @@ -64,4 +64,48 @@ class PacketParserTest { assertTrue(JoyconButton.GR.id !in input.pressed) assertTrue(JoyconButton.GL.id !in input.pressed) } + + @Test + fun `nyxi short packets decode primary stick`() { + val data = ByteArray(12).apply { + this[1] = 0x14 // Status + putStick(5, 0x111 to 0x222) + } + val input = PacketParser.parse(data, Side.LEFT, isNyxiChar = true)!! + assertEquals(0x111, input.stickX) + assertEquals(0x222, input.stickY) + } + + @Test + fun `nyxi heartbeats are stripped to neutral`() { + val data = ByteArray(12).apply { + this[1] = 0x10 // Heartbeat for Left + putStick(5, 0x999 to 0x999) // Should be ignored + } + val input = PacketParser.parse(data, Side.LEFT, isNyxiChar = true)!! + assertEquals(2048, input.stickX) + assertEquals(emptySet(), input.pressed) + } + + @Test + fun `nyxi right joycon decodes stick from offset 8`() { + val data = ByteArray(12).apply { + this[1] = 0x10 // Valid input for Right + putStick(8, 0x333 to 0x444) + } + val input = PacketParser.parse(data, Side.RIGHT, isNyxiChar = true)!! + assertEquals(0x333, input.stickX) // Primary + assertEquals(0x333, input.rightStickX) // Also mapped to right + } + + @Test + fun `nyxi magic byte packets are neutral`() { + val data = ByteArray(64).apply { + this[0] = 0xFE.toByte() + this[1] = 0x10.toByte() + putStick(5, 0x555 to 0x666) + } + val input = PacketParser.parse(data, Side.PRO, isNyxiChar = true)!! + assertEquals(2048, input.stickX) + } } From b335f72000c0e1838e6979af4621af9ff70a8cac Mon Sep 17 00:00:00 2001 From: "Sam W." Date: Sat, 19 Sep 2026 22:57:36 -0400 Subject: [PATCH 6/7] fix_missing_changes --- app/build.gradle.kts | 2 +- .../joycon2android/connection/PacketParser.kt | 33 +++++++++---------- .../connection/PacketParserTest.kt | 18 +++++----- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 119d5f7..73ae7d8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,7 +56,7 @@ android { buildTypes { release { - signingConfig = signingConfigs.findByName("release") + signingConfig = signingConfigs.getByName("debug") isMinifyEnabled = true isShrinkResources = true proguardFiles( 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 8edddc0..dd72baa 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 android.util.Log import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side @@ -28,7 +29,6 @@ object PacketParser { fun parse(data: ByteArray, side: Side, isNyxiChar: Boolean = false): JoyconInput? { if (data.size < 12) return null - // If it came from a Nyxi characteristic, OR it matches the Nyxi format, use the Nyxi parser if (isNyxiChar || isNyxiFormat(data)) { return parseNyxiFormat(data, side) } @@ -64,32 +64,29 @@ object PacketParser { if (data.size < 8) return false val status = data[1].toInt() and 0xFF - // Nyxi/Keylinker input statuses. Status 0x3F and 0x81 are common heartbeat/init codes. - val isInputStatus = status == 0x14 || status == 0x10 || status == 0x18 || + // Accept any 0x1X status as potential Nyxi input + val isInputStatus = (status in 0x10..0x1F) || status == 0x80 || status == 0x81 || status == 0x8E || status == 0x3F return isInputStatus } - private fun parseNyxiFormat(data: ByteArray, side: Side): JoyconInput { + private fun parseNyxiFormat(data: ByteArray, side: Side): JoyconInput? { val status = data[1].toInt() and 0xFF - // Nyxi hardware isolation: - // Left Joy-Con uses 0x14 for input; 0x10 is a management heartbeat. - // Right Joy-Con uses 0x10 for input; 0x14 is a management heartbeat. - // FE is a generic vendor management header. - val isHeartbeat = (side == Side.LEFT && status == 0x10) || - (side == Side.RIGHT && (status == 0x14 || status == 0x18)) || - (data[0].toInt() and 0xFF == 0xFE) + // Check for any input status starting with 0x1X (0x10 to 0x1F) + val is1XStatus = status in 0x10..0x1F - if (isHeartbeat) { - return JoyconInput( - packetId = (data[0].toInt() and 0xFF), - stickX = 2048, stickY = 2048, - rightStickX = 2048, rightStickY = 2048 - ) + // Ensure the packet belongs to the correct controller based on side-specific expectations: + // Left controller typically uses 0x14, 0x1C etc. (even/bit-specific or just general 0x1X). + // Let's accept any 0x1X status that isn't explicitly known to be a heartbeat or from the opposite side, + // but let's be more accommodating to any 0x1X packet as long as it's not a generic vendor header (0xFE). + if (!is1XStatus || (data[0].toInt() and 0xFF == 0xFE)) { + return null } + + val b2 = data[2].toInt() and 0xFF val b3 = data[3].toInt() and 0xFF val b4 = data[4].toInt() and 0xFF @@ -197,7 +194,7 @@ object PacketParser { private fun isStickActive(stick: Pair): Boolean { val (x, y) = stick - return x != 0 || y != 0 + return (x != 0 || y != 0) && (x != 2048 || y != 2048) } /** 12-bit packed stick: 3 bytes → (x, y) each 0..4095 */ diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt index 7e073a1..e2d069c 100644 --- a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.connection import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.Side +import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -77,14 +78,13 @@ class PacketParserTest { } @Test - fun `nyxi heartbeats are stripped to neutral`() { + fun `nyxi heartbeats or mismatched status are accepted if 1X`() { val data = ByteArray(12).apply { - this[1] = 0x10 // Heartbeat for Left - putStick(5, 0x999 to 0x999) // Should be ignored + this[1] = 0x10 // Any 0x1X + putStick(5, 0x999 to 0x999) } - val input = PacketParser.parse(data, Side.LEFT, isNyxiChar = true)!! - assertEquals(2048, input.stickX) - assertEquals(emptySet(), input.pressed) + val input = PacketParser.parse(data, Side.LEFT, isNyxiChar = true) + Assert.assertNotNull(input) } @Test @@ -99,13 +99,13 @@ class PacketParserTest { } @Test - fun `nyxi magic byte packets are neutral`() { + fun `nyxi magic byte packets are ignored`() { val data = ByteArray(64).apply { this[0] = 0xFE.toByte() this[1] = 0x10.toByte() putStick(5, 0x555 to 0x666) } - val input = PacketParser.parse(data, Side.PRO, isNyxiChar = true)!! - assertEquals(2048, input.stickX) + val input = PacketParser.parse(data, Side.PRO, isNyxiChar = true) + assertNull(input) } } From 42a277aecd7b8e0b21892831d5e1c333ce3664fa Mon Sep 17 00:00:00 2001 From: SWeav02 Date: Tue, 22 Sep 2026 18:12:18 -0400 Subject: [PATCH 7/7] broaden parsing --- .../joycon2android/connection/PacketParser.kt | 19 +++++++++---------- .../connection/PacketParserTest.kt | 4 ++-- 2 files changed, 11 insertions(+), 12 deletions(-) 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 dd72baa..fd0d96f 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,6 +1,6 @@ package com.joegec.joycon2android.connection -import android.util.Log + import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side @@ -64,8 +64,8 @@ object PacketParser { if (data.size < 8) return false val status = data[1].toInt() and 0xFF - // Accept any 0x1X status as potential Nyxi input - val isInputStatus = (status in 0x10..0x1F) || + // Accept any 0x0X or 0x1X status as potential Nyxi input + val isInputStatus = (status in 0x00..0x1F) || status == 0x80 || status == 0x81 || status == 0x8E || status == 0x3F return isInputStatus @@ -74,14 +74,12 @@ object PacketParser { private fun parseNyxiFormat(data: ByteArray, side: Side): JoyconInput? { val status = data[1].toInt() and 0xFF - // Check for any input status starting with 0x1X (0x10 to 0x1F) - val is1XStatus = status in 0x10..0x1F + // Accept any low-range status (0x0X, 0x1X) as input, provided it's not a generic vendor header (0xFE). + // Some Nyxi controllers swap status codes (e.g. 0x10 for Left, 0x0C for Right) or use new ones like 0x1C. + val isValidStatus = (status in 0x00..0x1F) || + status == 0x80 || status == 0x81 || status == 0x8E || status == 0x3F - // Ensure the packet belongs to the correct controller based on side-specific expectations: - // Left controller typically uses 0x14, 0x1C etc. (even/bit-specific or just general 0x1X). - // Let's accept any 0x1X status that isn't explicitly known to be a heartbeat or from the opposite side, - // but let's be more accommodating to any 0x1X packet as long as it's not a generic vendor header (0xFE). - if (!is1XStatus || (data[0].toInt() and 0xFF == 0xFE)) { + if (!isValidStatus || (data[0].toInt() and 0xFF == 0xFE)) { return null } @@ -93,6 +91,7 @@ object PacketParser { val pressed = mutableSetOf() + // Byte 2 & Byte 3: Button mappings depend on whether this is the Left or Right controller half if (side == Side.LEFT) { // Left Joy-Con Byte 2 (D-Pad & Left shoulders - oriented for sideways single Joy-Con display) diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt index e2d069c..121e9af 100644 --- a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt @@ -78,9 +78,9 @@ class PacketParserTest { } @Test - fun `nyxi heartbeats or mismatched status are accepted if 1X`() { + fun `nyxi heartbeats or mismatched status are accepted if valid status range`() { val data = ByteArray(12).apply { - this[1] = 0x10 // Any 0x1X + this[1] = 0x0C // Any valid status like 0x0C or 0x10 putStick(5, 0x999 to 0x999) } val input = PacketParser.parse(data, Side.LEFT, isNyxiChar = true)